关于 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 } }` ;
问题仍然存在 上面统一管理查询字符串的体验还凑合,但是真正开发起来就会发现有以下几个问题绕不开:
我如何知道一个字符串对应的变量应该是什么?只能查看字符串本身的定义;
我写字符串的时候如何获得代码提示呢?还是说我只能对着后端的 schema 逐字段慢慢写呢?
既然都统一管理了查询字符串,是不是还得再封装一层查询方法呢?这样业务逻辑处的代码还能更省。
如果都用字符串如何使用 Fragment 呢?(或许可以看一下 graphql-tag )
更好的方法:代码生成 如果完成一个请求需要先写查询字符串,再封装一个关于这个查询字符串的请求方法,开发效率不会很高。可以看到上面的代码很多都是琐碎且平凡的,既然如此,可以尝试生成代码。为此我们需要了解以下的包或者插件:
@graphql-codegen
: graphql 代码生成器 ,一个 npm 包。通过定义的 schema 和 operation 生成包含请求方法的 typescript 文件;
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" 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 里面操作分为 query
和 mutation
,编写具名操作会被 @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 }); }