写点什么

GraphQL 初探

  • 2022 年 5 月 27 日
  • 本文字数:8007 字

    阅读完需:约 26 分钟

GraphQL初探

GraphQL 的出现是 FB 为了解决日益膨胀的大前端对 API 的需求的问题而提出的一个标准。注意是抽象的标准,而不是具体的实现。所以,流行的语言都有各自的 GraphQL 的实现可以使用。本例中使用的是 JavaScript 的实现。

有了 GraphQL 之后,客户端请求什么数据,这些数据的格式都由客户端自己决定。不再需要后端开发额外处理。而且 GraphQL 是自文档的。在开发阶段可以清晰的完整的数据类型体系,边查边组织查询语言。大前端开发效率有效提升的同时,也释放了一定的后端资源。



阅读本文你会学到:

●如何使用 Express 搭建一个 GraphQL 服务

●如何使用 schema first 或 code first 实现服务的细节

●如何处理认证问题

●如何处理 N+1 问题(效率问题)

用 Express 建一个 GraphQL 服务

具体的说,是用 express 和 express-graphql 和 graphql 来搭建一个 GraphQL server。没有用时下很流行的 Apollo 来做。

yarn add express express-graphql graphql
复制代码

本文的例子用的是 SQLite,所以用了一个号称下一代最牛ORM的,叫做prisma。用来模拟一些包含数据库存储的场景。

yarn add @prisma/clientyarn add prisma -D
复制代码

不得不说 prisma 集成了各方面工具的很多优点。尤其在处理数据库定义的时候,包含了添加方式的类似版本控制的东西。如果数据量比较大还有一个图形化的数据处理工具 prisma studio。不过对 mongoDB 的支持目前不是很完整。

让 Express Server 跑起来

import express from 'express';import { graphqlHTTP } from 'express-graphql';import cors from 'cors';
const PORT = 9090;
const schema = buildSchema(` type Query { hello: String }`);
const resolvers = { hello: () => "Hello world!"};
const app = express();app.use(cors());app.use( '/graphql', graphqlHTTP(req => ({ schema, rootValue: { ...resolvers, }, graphiql: true, // process.env.ENV === development })),);app.listen(process.env.PORT ?? PORT);
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
复制代码

在这里,我们启用了GraphiQL

graphqlHTTP(req => ({    schema,    rootValue: {      ...resolvers,    },    graphiql: true, // *  })),
复制代码

graphiql是 GraphQL 服务的一个辅助图形工具。用这个工具可以编辑查 query,可以查看结果,同时可以查看生成的文档。



图里的示例和上文的例子不同。

处理 GraphQL 的部分,一共分几步

GraphQL 是跑在 express server 之上的。搭好 express server 之后需要分几步来处理 GraphQL 的部分呢?两步:

1.编写 GraphQL 的 Schema

2.给 Schema 添加 resolver

定义 GraphQL 的 Schema

GraphQL 的 Schema,或者 GraphQL Schema Language(GSL)是独立于具体实现的一套抽象。

有两种方式可以定义 Schema。

●一种是 schema first。即用 GSL 来实现,就是我们在 GraphQL 官网经常可以看到的那些。

●一种是 code first,即用特定语言的实现来间接描述,然后再转化成 GraphQL 的实现。

使用 GraphQL Query Language 的是这样的:

type Starship {  id: ID!  name: String!  length(unit: LengthUnit = METER): Float}

type Query { starship(id: Int): Starship}
复制代码

另外的一种,code first 的方式,这里用 JavaScript 举例:

const StarshipType = new GraphQLObjectType({  name: 'StarshipType',  fields: () => ({    id: { type: new GraphQLNonNull(GraphQLID) },    name: { type: new GraphQLNonNull(GraphQLString) },    length: {      type: GraphQLFloat,      args: {        unit: {          type: GraphQLString,        },      },      resolve(parent, args) {        const { unit } = args;        const { length } = parent;

if (unit === 'METER') { return length / 100; } return length; }, }, }),});
复制代码



在这里可以使用GraphQLObjectType来定义一个类型。和第一种方式相比,基本上每个 field 都可以一一对应的描述出来。比如id: ID!和id: { type: new GraphQLNonNull(GraphQLID) }。这里可能有的同学已经知道感叹号就是非空,对应的描述类型是GraphQLNonNull。注意需要用 new 来修饰。

但是,在处理 length 这个字段的时候略有一些特殊了。这个 field 带有参数length(unit: LengthUnit = METER): Float。如果这个参数不是必填的可以用这样的方式在声明的时候给定一个默认值。这里就是METER,以米为单位。所以,在间接描述的时候就需要一个独立的 resolver 特别处理一下。

上面 Starship 是一个简单的例子,主要是是为了直观的体现 schema first 和 code first 的差别。

直接用 GraphQL Schema Language 来定义 Schema


上面简单的例子是为了体现 GSL 和间接实现的差别。

在本例中会使用 User、Post 这个一对多的关系。不得不提一下EnvInput,它是一个输入类型,专门用于处理复杂的输入参数,它所对应的输入类型是Environment。当然还有为了上例而添加的Starshiplength字段参数。在实际代码中还有一些其他的实体,可以暂时忽略。


import { buildSchema } from 'graphql';

const schema = buildSchema(`type Query { user: User post: Post users: [User] posts: [Post] envs: [Environment]}

type Mutation { login(email: String, password: String!): Auth post(env: EnvInput! , content: String!, title: String!): Post!}

type Environment { ...}

input EnvInput { ...}

type User { ...}

type Post { ...}

type Auth { ...}`);

export { schema };
复制代码


上面的 buildSchema 方法生成的 GSL 是这样的:

schema {  query: Query  mutation: Mutation}
复制代码


前面的starship已经介绍了如何定义一个 GSL 的类型,这里出现了两个特殊的类型:

●Query

●Mutation

每个 GraphQL 服务肯定会有一个Query类型,但是Mutation就不一定有了。

Query是 GraphQL 服务的根节点,是每个查询的入口。如果你定义的 GraphQL 服务由修改数据的服务,那么Mutation就是这些修改的根(入口)节点。

用 code first 定义 Schema

我们就用 JavaScript 为例,看看的 GraphQL 的类型系统如何在 Javascript 里使用。

首先是User类型:

const UserType = new GraphQLObjectType({  name: 'UserType',  fields: () => ({    userId: { type: GraphQLID },    name: { type: GraphQLString },    email: { type: GraphQLString },    posts: { type: new GraphQLList(PostType) },  }),});

const RootQuery = new GraphQLObjectType({ name: 'RootQueryType', fields: () => ({ starship: { type: StarshipType, args: { id: { type: GraphQLID }, }, resolve(parent, args) { return { id: 1, name: 'Jedi', length: 10 }; }, }, user: { type: UserType, args: { userId: { type: new GraphQLNonNull(GraphQLInt) }, }, async resolve(parent, args) { return findUserById(args.userId); }, }, })});
复制代码

每一个

type User {  id: ID!  name: String}
复制代码

都是一个 Object 类型,也可以这样定义:

const User = new GraphQLObjectType({  name: 'UserType',  fields: () => ({    id: { type: new GraphqlNonNull(GraphQLID) },    name: { type: GraphQLString },  }),});
复制代码

1.类型: type User {} => const User = new GraphQLObjectType({})

2.fields: 在 GSL 里可以直接写名称:类型。在 js 可以放在一个返回对象的方法里。

3.非空,GSL 只需要感叹号。Js 里需要初始化一个对象。id: ID => id: { type: new GraphqlNonNull(GraphQLID) },

到这里为止 GraphQL 还不能用。还需要重要的一步添加 resolver。

添加 Resolver


没有 resolver,GraphQL 服务跑不起来。

本文开始就提到 GraphQL 是一个标准,在这个标准里连接查询语言和数据的服务的就是 Resolver。Resolver 是一个普通的返回数据的方法,也可以是一个 async-await 方法。没有特别的要求,不过最好是返回对应类型的数据。比如上文的 Starship,或者 UserType。

异步方法可以做的就很多了,最主要的两个获取数据的方式就出现在我们的眼前:REST Api、DB。本例中都是用了从数据库读取数据的方法。REST Api 的方法直接调用 fetch Api 或者使用第三方库都可以实现。只是返回的是对应的数据。

添加 resolver 的方法也分两种。在本例中,一种是基于 schema first 的,一种是基于 code first 的。

在 schema first 模式下定义的 Schema 添加 Resolver


上文中定义的 Schema,resolver 如何处理:

app.use(  '/graphql',  graphqlHTTP(req => ({    schema,    rootValue: {      async user(id) {         return // Http request, or read data from DB      },      async users(    },    graphiql: true, // process.env.ENV === development  })),);
复制代码

从上例可以看出,在 schema first 模式下添加 resolver 其实是在定义 schema 的根节点添加的。

查询语句能不能用,和 resolver 直接相关。能查询的类型,或者说定义在 Query 类型里的,都需要由对应的 resolver。

在 code first 的定义中添加 Resolver


在 schema first 中没有办法直接给 schema 的 Object 类型添加 resolver,但是 code first 模式的对应实现却可以。在定义 Query 类型的时候直接给出 resolver。在其他类型里,比如Starshiplength字段也可以写 resolver。

比如 Starship 这个例子:

const StarshipType = new GraphQLObjectType({  name: 'StarshipType',  fields: () => ({    id: { type: new GraphQLNonNull(GraphQLID) },    name: { type: new GraphQLNonNull(GraphQLString) },    length: {      type: GraphQLFloat,      args: {        unit: {          type: GraphQLString,        },      },      resolve(parent, args) {        const { unit } = args;        const { length } = parent;

if (unit === 'METER') { return length / 100; } return length; }, }, }),});
复制代码

其他的 resolver 和上例一样都是在定义某一个 schema 的类型中添加 resolver 的。下面还有一个更加复杂的例子:

const RootQuery = new GraphQLObjectType({  name: 'RootQueryType',  fields: () => ({    starship: {      type: StarshipType,      args: {        id: { type: GraphQLID },      },      resolve(parent, args) {        return { id: 1, name: 'Jedi', length: 10 };      },    },    users: {      type: new GraphQLList(UserType),      args: {        name: { type: GraphQLString },      },      async resolve(parent, args) {        return findUserByName(args.name ?? '');      },    },  }),});
复制代码

在 Query 类型里有Starship的 resolver,在Starship类型的length字段也有一个 resolver。两者关系如何。length字段的 resolver 方法的parent参数就是从 Query 类型的 starship 的 resolver 拿到的。在length字段的 resolver 里可以通过传入的unit单位做一次处理再返回给前端。

GraphQL 开发的方法论


上面的例子是从 0 到 1 构建一个 server。所以,最开始用的是 schema-first 的方式。直接用 GSL 定义类型。这样非常灵活简单而且也很直观,看到的和查到的很接近。

但是,schema-first 的方式有一个很大的问题,比如我们看 github 的 schema 有多少行


快要 5W 行了。

很多时候,开发中需要把不同的类型放在不同的文件里以方便后续的维护。所以在实践中,开发者逐步转向 code-first 的开发模式。也就是用特定的语言的实现来实现最后的 schema。

认证的问题


那么你最关心的问题来了:GraphQL 服务的如何支持用户登录,所有资源都可以被访问么?其实核心的问题是认证,之后授权的部分你自己就可以做到了。

GraphQL 服务如和实现登录呢?只要在 Mutation 里添加一个signin的方法即可。

const Mutation = new GraphQLObjectType({  name: 'MutationType',  fields: () => ({    // ...略...    signIn: {      type: AuthType,      args: {        name: { type: new GraphQLNonNull(GraphQLString) },        password: { type: new GraphQLNonNull(GraphQLString) },      },      async resolve(parent, args, context, info) {        // If username / password are OK, return auth formation (the token).

const { name, password } = args; const newUser = await signIn(name, password); return newUser; }, }, }),});
复制代码

引入的 signIn 方法是一个真正的假登录方法:

/** * Fake authentication * @returns A fake token */function signIn(name, password) {  return Promise.resolve({ token: 'an-auth-token' });}
复制代码

模拟异步方法返回登录后的 token 串。具体的方法可以参考 jwt 的详细文档。

只要这样就可以登录:

mutation Signin($n: String!, $pwd: String!) {  signIn(name: $n, password: $pwd) {    token  }}
复制代码

返回 token:

{  "data": {    "signIn": {      "token": "an-auth-token"    }  }}
复制代码

当用户完成了登录步骤,拿到了 token。怎么验证呢?

我们可以利用graphqlHTTP参数里的context来传递验证 token 的方法。这个context有一个特点,你传递进去的是什么,它都会作为第三个参数原样的传递给 resolver。所以,我们把验证 token 的方法auth放在context里传递出去:

app.use(  '/graphql',  // 1  graphqlHTTP(req => ({    schema,    graphiql: true,     context: {      // 2      auth: async () => {        const authorization = req.headers?.authorization;        if (!authorization) {          return;        }

const token = authorization.split(' ')[1]; return token; }, dataloaders: buildDataloaders(), }, })),);

复制代码

需要注意的是这里graphqlHTTP的参数不再是对象,而是一个方法。它的参数是一个请求对象。用户在 signIn 之后获得的 token 可以这样放在 header 里使用的:

{  header: "Bearer xxxx-xxxx-xxxxx"}
复制代码

作为示例,PostType的 resolver 使用了从context传递过来的auth方法。

const PostType = new GraphQLObjectType({  name: 'PostType',  fields: () => ({    // 略...    author: {      type: UserType,      async resolve(parent, args, context) {        let token = '';        const {auth} = context;        if (auth) {          token = await auth();        }

if (!token) { throw new Error('Not authenticated!'); }

// 略... }, }, // 略... }),});
复制代码

总结一下步骤:

1.在 mutation 里添加一个 signin 方法还用来验证用户的登录信息,成功后返回对应的 token。

2.在 graphqlHTTP 调用的时候传入一个方法,这个方法的参数是 express 的请求对象。

3.在 graphqlHTTP 的参数方法的返回的 context 里加一个验证 token 的方法

4.在 resolver 里的第三个参数获得 context 对象,拿出传入的验证方法(本例的 auth 方法)验证 token。

效率提升

如果有一个这样的查询:

{  posts{    author{      name    }  }}
复制代码

它会查找所有的 post,然后获取每个 post 的作者数据。那么造成的结果是什么:

>findAllPosts

findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 1findUserById: 2findUserById: 2findUserById: 2findUserById: 3
复制代码

这些 post 里面有十条是 1 号用户发的,三条是 2 号用户发的,一条是 3 号用户发的。等于有多少的 post,就会有多少次查询作者的次数。这对于数据库来说简直就是灾难。

FB 提出 GraphQL 这个标准之后也开源了dataloader。它就是专门用来解决这类的问题的,不仅仅限于 GraphQL。

我们来看看 dataloader 可以做到什么:

>findAllPosts

batchFindUsers: [ 1, 2, 3 ]
复制代码

dataloader 可以实现数据请求的 batching 和 caching,就是批处理和缓存。为了达到批处理的效果,需要我们给它提供一个批处理函数:

async function batchFindUsers(Ids) {  console.log('batchFindUsers: ', Ids);  if (!Array.isArray(Ids) || Ids.length === 0) {    return [];  }

try { const condition = { where: { id: { in: Ids }, }, }; const users = await prisma.user.findMany(condition); prisma.$disconnect(); return users; } catch (e) { prisma.$disconnect(); }}
复制代码

示例中用到的condition翻译成 SQL 就是:

select * from User where id in (ids); 
复制代码

这里不讨论 sql 的效率问题。


有了批处理方法就可以新建我们需要的 loader 了:

import DataLoader from 'dataloader';import { batchFindUsers } from './db.js';

const buildDataloaders = () => ({ authorLoader: new DataLoader(ids => batchFindUsers(ids), { cacheKeyFn: key => key.toString(), }),});

export { buildDataloaders };
复制代码

到这里 post 的 author 的 dataloader 就已经建好了。使用的时候和前面说的认证的方法一样,把 dataloader 放在graphqlHTTPcontext里给 resolver 用。所以PostTypeauthor的 resolver 就可以在第三个参数context里拿到 dataloader 来使用了。

const PostType = new GraphQLObjectType({  name: 'PostType',  fields: () => ({    // 略...    author: {      type: UserType,      async resolve(parent, args, context) {        const { authorId } = parent;        const {          dataloaders: { authorLoader },        } = context;         return authorLoader.load(authorId);        },    },  }),});
复制代码

authorLoader.load(authorId),在调用的时候只给了 loader 一个 authorId,并没有直接调用批处理方法。看来dataloader是在这次请求中把很多次的 loader 的authorId都放在一起,然后调用了批处理函数。

上面解释了批处理(batching),那么说好的 caching 呢?让我们来看看,如果这样调用 loader 会怎么样呢?

authorLoader.load(authorId);  authorLoader.load(authorId);  return authorLoader.load(authorId);  
复制代码

我们已经在批处理方法里加了 log。那么执行 posts 查询,结果会是怎么样:

>findAllPostsbatchFindUsers:  [ 1, 2, 3 ]
复制代码

只会出现一次 batchFindUsers: [ 1, 2, 3 ]。可以看到缓存已经起作用了。但是,如文档所述

DataLoader caching does not replace Redis, Memcache, or any other shared application-level cache. DataLoader is first and foremost a data loading mechanism, and its cache only serves the purpose of not repeatedly loading the same data in the context of a single request to your Application.

翻译一下就是:dataloader 和 Redis、Memcache 这样的程序级别的缓存不同。dataloader 首先是一种数据加载机制,是用来缓存一次请求里面可能出现的重复的数据。注意这里 in the context of a single request。所以,一次调用 resolver 里 load 包含多次同样authorId的时候是有缓存的效果的。但是,如果是两次请求之间是没有缓存的。

没有银弹

没有银弹,没有什么东西是万能的,GraphQL 也一样。但是,除了肉眼可见的学习、实现成本之外它的益处是很吸引人的。

GraphQL 的好处是极大的减少 API 的请求数量。因为一次请求就足够拿到所有需要的数据。这些数据的结构还是非常接近与请求的格式的。

另外,支持 GraphQL 的语言很多,必有一款是你喜欢的:



或者移步官网:https://graphql.org/code/,这里介绍很全面。server、client 和工具的实现都一个一个罗列出来了。


发布于: 刚刚阅读数: 5
用户头像

RingCentral铃盛中国研发中心 2021.11.24 加入

全球云商务通信与协作解决方案领导者,连续七年荣膺Gartner UCaaS(统一通信即服务)魔力象限全球领导者。与你分享各种技术专家的文章、公开课,各种好玩有趣的活动与福利,以及最新的招聘机会。

评论

发布
暂无评论
GraphQL初探_JavaScript_RingCentral铃盛_InfoQ写作社区