GraphQL 入门篇:基础查询语法
最近准备面试的东西,所以就开始查漏补缺,就发现缺的东西还是蛮多的吧……(挠头
果然还是得定时找找工作之类的,和市场上的行情校对一下,这样才能够知道最近市场上需求的人才/知识是什么。之前的话有点太沉溺于 React 的垂直发展,最近找纯前端不太顺利,全栈纵向发展又有点不太够……
能补一点是一点吧 😮💨
代码在这里:
https://github.com/GoldenaArcher/graphql-by-example
用的就是课程名
初始项目
这个项目走一下最基础的 graphql 的实现和结构,顺便介绍一点 playground 之类的,给下半篇,也就是正式做 graphql 的项目热身了
服务端代码
- package.json
{ "name": "graphql", "version": "1.0.0", "main": "index.js", "type": "module", "license": "MIT", "dependencies": { "@apollo/server": "^4.12.1", "graphql": "^16.11.0" } }
- server
import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; import { log } from "console"; const typeDefs = `#graphql type Query { greeting: String } `; const resolvers = { Query: { greeting: () => "Hello world!", }, }; const server = new ApolloServer({ typeDefs, resolvers }); const info = await startStandaloneServer(server, { listen: { port: 9000 } }); console.log(`🚀 Server ready at: ${info.url}`);
实现效果如下:
server 里面没有使用其他的服务——如 express 或是内置的 http 开启服务器,而是直接使用了 ApolloServer
—— Apollo 自带的服务器,因此会在 9000 这个端口开启一个 Apollo 的 sandbox
默认情况下,所有的 graphql 请求都是 POST
请求,返回类型是 JSON 格式
服务端
服务端也遵从极简模式,只要能够从 server 拉数据并成功渲染即可
这里选择的是原生 js+ fetch
进行调用,并且通过 DOM 操作渲染到 HTML 文档中
- js
async function fetchGreeting() { const res = await fetch("http://localhost:9000/", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ query: "query {greeting }", }), }); const { data } = await res.json(); return data.greeting; } fetchGreeting().then((greeting) => { document.getElementById("greeting").innerHTML = greeting; });
- html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <script src="app.js"></script> <title>GraphQL Client</title> </head> <body> <h1>GraphQL Client</h1> <p> The server says: <strong id="greeting"> Loading... </strong> </p> </body> </html>
最后渲染结果:
Code-First vs Schema-First
之前看 swagger 的时候就碰到过这种问题……
这种问题本质上来说,没有哪一个比较好,只是根据业务场景/使用情况不同而裁决。比如说当前的 graphql 只在当前内部项目使用,没有暴露的需求,那么 code-first 会更有效率;与之相反的是如果当前的 graphql 一定会被暴露,并当成服务共享,那么先写 schema,生成对应的 contract,再根据具体的情况去完成迭代——deprecate 或者 expire,就是更可取的方式
graphql 默认是 schema-first 的实现,想要避免写 type,用 code-first 的实现方法,可以考虑使用以下几个 dependencies:
- **TypeGraphQL -** 周下载量 20w+,看起来有在维护,但不是特别的 active,应该还算挺稳定的
这个使用方法是最不一样的,是对 TypeScript 的支持,使用的是注解的方法 - Nexus - 已经进入不算太 active 的维护状态,上次更新是两年前的事情,不过下载量还是比较大的(周下载 10w+),应该相对而言比较稳定
- **Pothos GraphQL -** 下载量不算太大,7-8w 左右,相对比较稳定,还在 actively 更新
用 nexus 做下例子,code-first 的实现方法大体如下:
import { queryType, stringArg, makeSchema } from "nexus";
import { GraphQLServer } from "graphql-yoga";
const Query = queryType({
definition(t) {
t.string("hello", {
args: { name: stringArg() },
resolve: (parent, { name }) => `Hello ${name || "World"}!`,
});
},
});
const schema = makeSchema({
types: [Query],
outputs: {
schema: __dirname + "/generated/schema.graphql",
typegen: __dirname + "/generated/typings.ts",
},
});
const server = new GraphQLServer({
schema,
});
server.start(() => `Server is running on http://localhost:4000`);
可以看到,没有 schema 的强调,对于第三方——非开发团队来说,想要复用当前 graphql 是一个比较困难的事情
graphql & 框架
这部分的实现就会使用 express+middleware+官方的 apollo server 管理 graphql,前端则是使用 React+graphql-request——一个轻量的 graphql client 去进行实现
server 端实现
这里也先开始一个比较基础的案例,后面再一点点拓展
-
server
import { ApolloServer } from "@apollo/server"; import { expressMiddleware as apolloMiddleware } from "@apollo/server/express4"; import cors from "cors"; import express from "express"; import { readFile } from "node:fs/promises"; import { authMiddleware, handleLogin } from "./auth.js"; import { resolvers } from "./resolvers.js"; const PORT = 9000; const app = express(); app.use(cors(), express.json(), authMiddleware); app.post("/login", handleLogin); const typeDefs = await readFile("./schema.graphql", "utf-8"); const aplloServer = new ApolloServer({ typeDefs, resolvers }); await aplloServer.start(); app.use("/graphql", apolloMiddleware(aplloServer)); app.listen({ port: PORT }, () => { console.log(`Server running on port ${PORT}`); console.log(`GraphQL endpoint: http://localhost:${PORT}/graphql`); });
-
type defs
type Query { job: Job } type Job { title: String description: String }
-
resolvers
export const resolvers = { Query: { job: () => { return { id: "test-id", title: "The Title", description: "The description", }; }, }, };
这里每个部分都拆成了独立的文件,方便长期管理
client 端
就是 react 的项目,暂时没有放新的东西,等到后面真的牵扯到 UI 再写实际变动的部分再更新
简单配置完后,graphql 的 sandbox 会一样启动:
Scalar
就是 grapnql 的原始类型(primitive type),这个说法大概比较 fancy
默认情况下,graphql 支持下面 5 种格式:
Int
Float
String
Boolean
ID
graphql 也支持个性化实现不同的类型,不过这个实现需要保证可以序列化及反序列化……换句话说如果是 TS 的 class 实现相对而言会有些麻烦,plain object 会容易一些……
非空判断
graphql 默认情况下是支持空值的,如果想要执行非空查询,就需要在定义的时候添加 !
,如:
type Query {
job: Job
}
type Job {
id: ID!
title: String
description: String
}
这个时候,如果传来的值——🆔 出现 null
的情况,graphql 服务端就会跑出异常
返回数组
这是另一个比较常见的需求,这里修改如下:
- type def
注意这里用的是type Query { jobs: [Job!] } type Job { id: ID! title: String description: String }
[Job!]
,非空判断放在Job
上 - resolver
暂时最为 placeholderexport const resolvers = { Query: { jobs: () => { return [ { id: "test-id", title: "The Title", description: "The description", }, ]; }, }, };
最后返回结果如下:
Resolver Chain
在 graphql 里,每一个字段都有独自的 resolver,当查询数据时,graphql 会按照层级调用对应的 resolver,形成一个 resolver chain
这里修改代码如下:
- typedef
type Query { job: Job jobs: [Job] } type Job { id: ID! date: String! title: String! description: String }
- resolver
这里的import { getJobs } from "./db/jobs.js"; export const resolvers = { Query: { job: () => { return { id: "test-id", title: "The Title", description: "The description", date: "2023-01-01", }; }, jobs: async () => getJobs(), }, Job: { date: (parent) => { return toIsoDate(parent.createdAt); }, }, }; function toIsoDate(value) { return new Date(value).toISOString().slice(0, 10); }
date: () => {}
就是一个 resolver chain,其中parent
对应的是传进来的值本身——对于date
来说,parent
就是job
,因此可以通过这个parent
获取合适的数据进行返回
这里暂时只会了解parent
的用法,其他包括args
,context
,用到再谈
最终的实现效果如下:
result:
这里补充一下 job 的数据库格式:
date
是不存在的,需要通过 createdAt
手动转换
文档注释
实现如下:
type Query {
job: Job
jobs: [Job]
}
type Job {
id: ID!
"""
The __date__ when the job was published, in ISO-8601 format. e.g. `2022`12`31`
"""
date: String!
title: String!
description: String
}
就是提供了文档的方法,需要和普通的 #
注释分开,这个注释不会显示在文档里,只是给开发看的
关联对象
graphql 中实现关联对象相对而言比较简单,不过同样需要在 resolver 中查找关联对象,实现如下:
- update
type Query { job: Job jobs: [Job] } type Company { id: ID! name: String! description: String } """ Represents a job ad posted to the board. """ type Job { id: ID! """ The __date__ when the job was published, in ISO-8601 format. e.g. `2022`12`31` """ date: String! title: String! description: String company: Company! }
- resolver
import { getJobs } from "./db/jobs.js"; import { getCompany } from "./db/companies.js"; export const resolvers = { Query: { job: () => { return { id: "test-id", title: "The Title", description: "The description", date: "2023-01-01", }; }, jobs: async () => getJobs(), }, Job: { date: (parent) => { return toIsoDate(parent.createdAt); }, company: (job) => { return getCompany(job.companyId); }, }, }; function toIsoDate(value) { return new Date(value).toISOString().slice(0, 10); }
最后实现效果如下:
React 中获取 graphql 数据
前面提到了,会用到 graphql-request 这个 package,实现的方式为:
import { GraphQLClient, gql } from "graphql-request";
const client = new GraphQLClient("http://localhost:9000/graphql");
export async function getJobs() {
const query = gql`
query {
jobs {
id
date
title
company {
id
name
}
}
}
`;
const { jobs } = await client.request(query);
return jobs;
}
这种调用的方式类似于使用 axios 进行一个 fetch,在 component 中需要调用这个方法,获取对应的数据,如:
import { useEffect, useState } from "react";
import JobList from "../components/JobList";
import { getJobs } from "../lib/graphql/queries";
function HomePage() {
const [jobs, setJobs] = useState([]);
useEffect(() => {
getJobs().then((data) => {
setJobs(data);
});
}, []);
return (
<div>
<h1 className="title">Job Board</h1>
<JobList jobs={jobs} />
</div>
);
}
export default HomePage;
效果如下:
通过 id 获取数据
这个部分主要牵扯到通过 graphql 传值,也是一个新的知识点
- TypeDef
type Query { job(id: ID!): Job jobs: [Job] } type Company { id: ID! name: String! description: String } """ Represents a job ad posted to the board. """ type Job { id: ID! """ The __date__ when the job was published, in ISO-8601 format. e.g. `2022`12`31` """ date: String! title: String! description: String company: Company! }
- resolvers
上文提到过,第二个参数为args
,可以通过这个参数获取 argument:import { getJobs, getJob } from "./db/jobs.js"; import { getCompany } from "./db/companies.js"; export const resolvers = { Query: { job: (_root, { id }) => { return getJob(id); }, jobs: async () => getJobs(id), }, Job: { date: (parent) => { return toIsoDate(parent.createdAt); }, company: (job) => { return getCompany(job.companyId); }, }, }; function toIsoDate(value) { return new Date(value).toISOString().slice(0, 10); }
最终实现效果如下:
sandbox 中的调用方法和前端基本上是一样的——具体调用的方法还是需要参考一下 client 端是怎么包装的,当前的使用场景如下:
- query 部分更新
import { GraphQLClient, gql } from "graphql-request"; const client = new GraphQLClient("http://localhost:9000/graphql"); export async function getJobs() { const query = gql` query { jobs { id date title company { id name } } } `; const { jobs } = await client.request(query); return jobs; } export async function getJob(id) { const query = gql` query ($id: ID!) { job(id: $id) { id date title description company { id name } } } `; const { job } = await client.request(query, { id }); return job; }
- component 部分更新
import { useParams } from "react-router"; import { Link } from "react-router-dom"; import { formatDate } from "../lib/formatters"; import { useEffect, useState } from "react"; import { getJob } from "../lib/graphql/queries"; function JobPage() { const { jobId } = useParams(); const [job, setJob] = useState(null); useEffect(() => { getJob(jobId).then((job) => { setJob(job); }); }, [jobId]); if (!job) { return <div>Loading...</div>; } return ( <div> <h1 className="title is-2">{job.title}</h1> <h2 className="subtitle is-4"> <Link to={`/companies/${job.company.id}`}>{job.company.name}</Link> </h2> <div className="box"> <div className="block has-text-grey"> Posted: {formatDate(job.date, "long")} </div> <p className="block">{job.description}</p> </div> </div> ); } export default JobPage;
最终实现效果如下:
Bidirectional Associations
双向关联
也就是 A ↔ B,在 graqhql 里面的实现就非常简单了
- type def 更新
type Query { job(id: ID!): Job jobs: [Job] company(id: ID!): Company } type Company { id: ID! name: String! description: String jobs: [Job!] } """ Represents a job ad posted to the board. """ type Job { id: ID! """ The __date__ when the job was published, in ISO-8601 format. e.g. `2022`12`31` """ date: String! title: String! description: String company: Company! }
- resolvers 更新
import { getJobs, getJob, getJobsByCompany } from "./db/jobs.js"; import { getCompany } from "./db/companies.js"; export const resolvers = { Query: { job: (_root, { id }) => { return getJob(id); }, jobs: async () => getJobs(), company: (_root, { id }) => { return getCompany(id); }, }, Job: { date: (parent) => { return toIsoDate(parent.createdAt); }, company: (job) => { return getCompany(job.companyId); }, }, Company: { jobs: (parent) => { return getJobsByCompany(parent.id); }, }, }; function toIsoDate(value) { return new Date(value).toISOString().slice(0, 10); }
其实大部分的实现还是依赖于 resolver 部分的实现,react 代码没啥好更新的——毕竟这是 graphql 的课,实现效果是这样的:
递归调用
这是一个非常有趣的情况,使用如下:
这种业务场景其实比较适合流媒/社媒的场景——考虑到 graphql 是 meta 开源的,自然也能理解这样业务场景:
User A
├── Friend B
│ ├── Friend D
│ └── Friend E
└── Friend C
└── Friend F