GraphQL 入门篇:基础查询语法
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 = `#graphqltype 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: Stringdescription: 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: Stringdescription: String
}
这个时候,如果传来的值——🆔 出现 null
的情况,graphql 服务端就会跑出异常
返回数组
这是另一个比较常见的需求,这里修改如下:
- type def
注意这里用的是type Query {jobs: [Job!] }type Job {id: ID!title: Stringdescription: 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: Jobjobs: [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: Jobjobs: [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: Jobjobs: [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: Stringcompany: 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 {iddatetitlecompany {idname}}}`;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!): Jobjobs: [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: Stringcompany: 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 {iddatetitlecompany {idname}}}`;const { jobs } = await client.request(query);return jobs; }export async function getJob(id) {const query = gql`query ($id: ID!) {job(id: $id) {iddatetitledescriptioncompany {idname}}}`;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!): Jobjobs: [Job]company(id: ID!): Company }type Company {id: ID!name: String!description: Stringjobs: [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: Stringcompany: 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