如何相对优雅地使用 GraphQL

关于 GraphQL,它的官网(需要科学上网)是这样介绍的:

GraphQL 既是一种用于 API 的查询语言也是一个满足你数据查询的运行时。 GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述,使得客户端能够准确地获得它需要的数据,而且没有任何冗余,也让 API 更容易地随着时间推移而演进,还能用于构建强大的开发者工具。

作为 RESTful API 的竞品,GraphQL 从开源之初就备受关注,一是因为它是由 Facebook 开源的项目,二是它挑战了 RESTful API 的地位,这是很关键的一点。RESTful API 利用 URI 的具体内容和请求方法来区分请求的资源或者方法,其中的资源 URI 容易与路由路径产生混淆和重复;资源数量达到一定的数目之后,如何给资源 URI 起名或许也是一件困难的事情。而 GraphQL 则鼓励开发者将所有需要请求的信息显式的写在请求体当中,精确到具体的字段,不多也不少。

我参与的几个项目都是用 GraphQL 作为 API 的基础,我总结出了一个在前端相对优雅地使用 GraphQL 的方法。这篇博客不讨论 GraphQL 的基本概念,主要介绍这个方法。(为省篇幅,这篇博客里面的代码均不做异常处理)

GraphQL 的原始用法

GraphQL 在前端的表现其实并不新奇:根据定义好的 schema,前端用 post 请求将 query string 和可选的 variables 包装在 body 当中传给后端的某个节点,后端正确响应之后以前端查询的结构将数据返回。

利用基础方法

那么根据这个基础我们就能够想到在代码中的用法了,首先在 api.js 中定义一个请求的基础方法:

api/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export async function graphql(q, vars) {
const resp = await $axios({
method: "post",
url: BASE_URL + "/api/graphql",
data: {
query: q,
variables: vars,
},
headers: {
token: getYourToken(),
},
});
return resp.data.data;
}

然后在所有需要请求的地方使用这个基础方法就可以完成 graphql 的请求,可以直接把请求参数嵌在具体的请求方法里面,也可以使用 variables 的方法(但这样会需要在字符串中多写些变量定义)。

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { graphql } from "./api";
async function getPerson1() {
const resp = await graphql(
`query {
person(id: ${id}) {
id name address
age gender mobile
}
}`
);
}

async function getPerson2() {
const resp = await graphql(
`
query ($id: ID!) {
person(id: $id) {
id
name
address
age
gender
mobile
}
}
`,
{ id }
);
}

体验糟糕的查询字符串

或许你觉得上面的方法还可以接受,但是如果变量中存在数组、枚举值和布尔值时,直接在 query string 中插入变量的体验就会变的很糟糕。像下面这样,我这辈子都不再想见到这样的写法。

index.js
1
2
3
4
5
6
7
8
async function heyGuys() {
const guys = ["alice", "bob", "carol", "dave"];
const resp = await graphql(`
query {
heyGuys(guys: [${guys.map((n) => '"' + n + '"').join(",")}])
}
`);
}

统一管理查询字符串

在逻辑代码中写大量的 query string 不太利于维护,为此可以将所有字符串分类整理好统一管理。例如放在某个 documents.js 文件内,其他地方需要请求的时候直接从该文件导入即可。这样可以在真正的业务逻辑中避免大量的字符串。

api/documents.js
1
2
3
4
5
6
7
export const getPersonDoc = `
query ($id: ID!) {
person(id: $id) {
id name address
age gender mobile
}
}`;

问题仍然存在

上面统一管理查询字符串的体验还凑合,但是真正开发起来就会发现有以下几个问题绕不开:

  1. 我如何知道一个字符串对应的变量应该是什么?只能查看字符串本身的定义;
  2. 我写字符串的时候如何获得代码提示呢?还是说我只能对着后端的 schema 逐字段慢慢写呢?
  3. 既然都统一管理了查询字符串,是不是还得再封装一层查询方法呢?这样业务逻辑处的代码还能更省。
  4. 如果都用字符串如何使用 Fragment 呢?(或许可以看一下 graphql-tag

更好的方法:代码生成

如果完成一个请求需要先写查询字符串,再封装一个关于这个查询字符串的请求方法,开发效率不会很高。可以看到上面的代码很多都是琐碎且平凡的,既然如此,可以尝试生成代码。为此我们需要了解以下的包或者插件:

  1. @graphql-codegen: graphql 代码生成器,一个 npm 包。通过定义的 schema 和 operation 生成包含请求方法的 typescript 文件;
  2. GraphQL: vscode 插件,用作写 .graphql 文件时的自动补全;

配置 @graphql-codegen

按照该包官方文档的指示进行安装配置即可,不需要太多的配置。其官网上还有下图所示的 live example,非常容易弄懂。

我在开发中一般会配置两个代码生成配置文件,一个用于同步后端、生成代码补全所依赖的 schema 文件,一个用于生成 operation 对应的请求方法。如下是两个配置文件的大致内容。

schema.codegen.yml
1
2
3
4
5
generates:
./graphql/schema.graphql:
schema: "BASE_URL/api/graphql"
plugins:
- schema-ast
operation.codegen.yml
1
2
3
4
5
6
7
8
generates:
./api/demo.ts:
documents: "./graphql/operations.graphql"
schema: "BASE_URL/api/graphql" # 或者直接使用 './graphql/schema.graphql'
plugins:
- typescript
- typescript-operations
- typescript-graphql-request

配置文件中的 plugins 配置项是关键。schema.codegen.yml 中的 schema-ast 是生成 schema 的 graphql 文件;operation.codegen.yml 中的 typescript-* 则是生成请求方法所依赖的插件。我这里给出的是使用 graphql-request 的例子,graphql-request 是一个轻量、简洁,支持 ts 和 promise-based API 的 GraphQL 客户端,在前后端都能使用。

生成请求方法所依赖的插件根据项目特点选定,例如 graphql-request 这个插件我用在 Vue2.x 的项目当中,而在 React 的项目中我是用的插件是 React-Query Hooks。每个插件对应的基础库的特点不一样,生成的代码风格也不尽相同,根据需要灵活选择即可。

编写 operations

operations 顾名思义就是操作,在 GraphQL 里面操作分为 querymutation,编写具名操作会被 @graphql-codegen 转换成为请求方法。下面给一个例子:

schema.graphql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum Gender {
Female
Male
}

type Person {
id: ID!
name: String!
address: String!
age: Int!
gender: Gender!
mobile: String!
}

type Query {
persons: [Person!]!
person(id: ID!): Person
}

type Mutation {
setPersonGender(id: ID!, gender: Gender!): Boolean
greet(id: ID!): String!
}
operations.graphql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
query getPersons {
persons {
id
name
}
}

query getPerson($id: ID!) {
person(id: $id) {
id
name
address
age
gender
mobile
}
}

mutation sayHello($id: ID!) {
greet(id: $id)
}

因为用了 vscode 的插件 GraphQL,所以在写 operations 的时候其实是有代码补全的,开发体验比较好,下图是在 vscode 上的代码补全,在 jetBrains 的 IDE 上面的代码自动补全应该会更完善。

生成 typescript 代码

写完上面的 schema 和 operations 之后,运行 graphql-codegen --config operations.codegen.yml 即在 ./api 目录下可生成一个 demo.ts 文件。其中包含了下面这样的代码:

api/demo.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ... 省略了很多

const defaultWrapper: SdkFunctionWrapper = (action, _operationName) => action();

export function getSdk(
client: GraphQLClient,
withWrapper: SdkFunctionWrapper = defaultWrapper
) {
return {
getPersons(
variables?: GetPersonsQueryVariables,
requestHeaders?: Dom.RequestInit["headers"]
): Promise<GetPersonsQuery> {
return withWrapper(
(wrappedRequestHeaders) =>
client.request<GetPersonsQuery>(GetPersonsDocument, variables, {
...requestHeaders,
...wrappedRequestHeaders,
}),
"getPersons"
);
},
getPerson(
variables: GetPersonQueryVariables,
requestHeaders?: Dom.RequestInit["headers"]
): Promise<GetPersonQuery> {
return withWrapper(
(wrappedRequestHeaders) =>
client.request<GetPersonQuery>(GetPersonDocument, variables, {
...requestHeaders,
...wrappedRequestHeaders,
}),
"getPerson"
);
},
sayHello(
variables: SayHelloMutationVariables,
requestHeaders?: Dom.RequestInit["headers"]
): Promise<SayHelloMutation> {
return withWrapper(
(wrappedRequestHeaders) =>
client.request<SayHelloMutation>(SayHelloDocument, variables, {
...requestHeaders,
...wrappedRequestHeaders,
}),
"sayHello"
);
},
};
}
export type Sdk = ReturnType<typeof getSdk>;

可以看到 getSdk 这个函数会返回一个对象,其中包含了刚刚在在 operations.graphql 中定义的几个操作。这样就从类型上锁定了这个方法的名字、参数以及返回值。这对于项目维护和开发来说无疑都是利好的。

使用生成的代码

因为真正使用的请求方法肯定是要鉴权的,我们需要再调整一下生成的代码,看到上面 demo.ts 中的 defaultWrapper 函数了吗?我们只需要在调用 getSdk 时传入自定义的 Wrapper 即可。下面给个例子:

api/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { getSdk as getDemoSdk } from "./demo";
const getClientOptions = () => {
return {};
};
const apiWrapper = async <T>(
action: (headers?: Record<string, string>) => Promise<T>
) => {
const headers = { token: getYourToken() };
return await action(headers);
};
export const demoClient = getDemoSdk(
new GraphQLClient("/api/graphql", getClientOptions()),
apiWrapper
);

在需要用到的地方只需要导入 demoClient 即可,我们再用几行代码重写一遍上面的 getPerson 函数:

index.js
1
2
3
4
import { demoClient } from "./api";
async function getPerson3() {
const resp = await demoClient.getPerson({ id });
}
作者

PowerfooI

发布于

2021-07-23

更新于

2024-08-04

许可协议

评论