Apollo GraphQL 服务端实践

如果喜欢我们的文章别忘了点击关注阿里南京技术专刊呦~ 本文转载自 阿里南京技术专刊-知乎,欢迎大牛小牛投递阿里南京前端/后端开发等职位,详见 阿里南京诚邀前端小伙伴加入~。

在最近的项目中,我们选择了 GraphQL 作为 API 查询语言替代了传统的 Restful 传参的方法进行前后端数据传递。服务端选用了 egg.js + Apollo graphql-tools,前端使用了 React.js + Apollo graphql-client。这样的架构选择让我们的迭代速度有了很大的提升。

基于 GraphQL 的 API 服务在架构上来看算是 MVC 中的 controller。只是它只有一个固定的路由来处理所有请求。那么在和 MVC 框架结合使用时,在数据转换 ( Convertor )、参数校验 ( Validator ) 等功能上,使用 Apollo GraphQL 带来了一些新的处理方式。下面会介绍一下在这些地方使用 Graphql 带来的一些优势。

什么是 GraphQL:

GraphQL 是由 Facebook 创造的用于描述复杂数据模型的一种查询语言。这里查询语言所指的并不是常规意义上的类似 sql 语句的查询语言,而是一种用于前后端数据查询方式的规范。

什么是 Apollo GraphQL:

Apollo GraphQL 是基于 GraphQL 的全栈解决方案集合。从后端到前端提供了对应的 lib 使得开发使用 GraphQL 更加的方便

Type System

在描述一个数据类型时,GraphQL 通过 type 关键字来定义一个类型,GraphQL 内置两个类型 Query 和 Mutation,用于描述读操作和写操作。

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

正常系统中我们会用到查询当前登录用户,我们在 Query 中定义一个读操作 currentUser ,它将返回一个 User 数据类型。

type Query {
  currentUser: User
}

type User {
  id: String!
  name: String
  avatar: String
  # user's messages
  messages(query: MessageQuery): [Message]
}
复制代码

Interface & Union types

当我们的一个操作需要返回多种数据格式时,GraphQL 提供了 interfaceunion types 来处理。

  • interface: 类似与其他语言中的接口,但是属性并不会被继承下来
  • union types: 类似与接口,它不需要有任何继承关系,更像是组合

以上面的 Message 类型为例,我们可能有多种消息类型,比如通知、提醒

interface Message {
  content: String
}

type Notice implements Message {
  content: String
  noticeTime: Date
}

type Remind implements Message {
  content: String
  endTime: Date
}
复制代码

可能在某个查询中,需要一起返回未读消息和未读邮件。那么我们可以用 union

union Notification = Message | Email
复制代码

数据校验

在大多数 node.js 的 mvc 框架 (express、koa) 中是没有对请求的参数和返回值定义数据结构和类型的,往往我们需要自己做类型转换。比如通过 GET 请求 url 后面问号转入的请求参数默认都是字符串,我们可能要转成数字或者其他类型。

比如上面的获取当前用户的消息,以 egg.js 为例的话,Controller 会写成下面这样

// app/controller/message.js
const Controller = require('egg').Controller;
class MessageController extends Controller {
  async create() {
    const { ctx, service } = this;
    const { page, pageSize } = ctx.query;
    const pageNum = parseInt(page, 0) || 1;
    const pageSizeNum = parseInt(pageSize, 0) || 10;

    const res = await service.message.getByPage(pageNum, pageSizeNum);
    
    ctx.body = res;
  }
}
module.exports = MessageController;
复制代码

更好一点的处理方式是通过定义 JSON Schema + Validator 框架来做验证和转换。

GraphQL 类型校验与转换

GraphQL 的参数是强类型校验的

使用 GraphQL 的话,可以定义一个 Input 类型来描述请求的入参。比如上面的 MessageQuery

# 加上 ! 表示必填参数
input MessageQuery {
  page: Int! 
  pageSize: Int!
}
复制代码

我们可以声明 page 和 pageSize 是 Int 类型的,如果请求传入的值是非 Int 的话,会直接报错。

对于上面消息查询,我们需要提供两个 resolver function。以使用 graphql-tools 为例,egg-graphql 已经集成。

module.exports = {
  Query: {
    currentUser(parent, args, ctx) {
      return {
        id: 123,
        name: 'jack'
      };
    }
  },
  User: {
    messages(parent, {query: {page, pageSize}}, ctx) {
      return service.message.getByPage(page, pageSize);
    }
  }
};
复制代码

我们上面定义的 User 的 id 为 String,这里返回的 id 是数字,这时候 Graphql 会帮我们会转换,Graphql 的 type 默认都会有序列化与反序列化,可以参考下面的自定义类型。

自定义类型

GraphQL 默认定义了几种基本 scalar type (标量类型):

  • Int: A signed 32‐bit integer.
  • Float: A signed double-precision floating-point value.
  • String: A UTF‐8 character sequence.
  • Boolean: true or false.
  • ID: The ID scalar type represents a unique identifier, often used to refetch an object or as the key for a cache. The ID type is serialized in the same way as a String; however, defining it as an ID signifies that it is not intended to be human‐readable.

GraphQL 提供了通过自定义类型的方法,通过 scalar 申明一个新类型,然后在 resovler 中提供该类型的 GraphQLScalarType 的实例。

已最常见的日期处理为例,在我们代码中的时间字段都是用的 Date 类型,然后在返回和入参时用时间戳。

# schema.graphql 中申明类型
scalar Date
复制代码
// resovler.js
const { GraphQLScalarType } = require('graphql');
const { Kind } = require('graphql/language');
const _ = require('lodash');

module.exports = {
  Date: new GraphQLScalarType({
      name: 'Date',
      description: 'Date custom scalar type',
      parseValue(value) {
        return new Date(value);
      },
      serialize(value) {
        if (_.isString(value) && /^\d*$/.test(value)) {
          return parseInt(value, 0);
        } else if (_.isInteger(value)) {
          return value;
        }
        return value.getTime();
      },
      parseLiteral(ast) {
        if (ast.kind === Kind.INT) {
          return new Date(parseInt(ast.value, 10));
        }
        return null;
      }
    });
}
复制代码

在定义具体数据类型的时候可以使用这个新类型

type Comment {
  id: Int!
  content: String
  creator: CommonUser
  feedbackId: Int
  gmtCreate: Date
  gmtModified: Date
}
复制代码

Directives 指令

GraphQL 的 Directive 类似与其他语言中的注解 (Annotation) 。可以通过 Directive 实现一些切面的事情,Graphql 内置了两个指令 @skip 和 @include ,用于在查询语句中动态控制字段是否需要返回。

在查询当前用户的时候,我们可能不需要返回当前人的消息列表,我们可以使用 Directive 实现动态的 Query Syntax。

query CurrentUser($withMessages: Boolean!) {
  currentUser {
    name
    messages @include(if: $withMessages) {
      content
    }
  }
}
复制代码

最新的 graphql-js 中,允许自定义 Directive,就像 Java 的 Annotation 在创建的时候需要指定 Target 一样,GraphQL 的 Directive 也需要指定它可以用于的位置。

DirectiveLocation enum

// Request Definitions -- in query syntax
QUERY: 'QUERY',
MUTATION: 'MUTATION',
SUBSCRIPTION: 'SUBSCRIPTION',
FIELD: 'FIELD',
FRAGMENT_DEFINITION: 'FRAGMENT_DEFINITION',
FRAGMENT_SPREAD: 'FRAGMENT_SPREAD',
INLINE_FRAGMENT: 'INLINE_FRAGMENT',
// Type System Definitions -- in type schema
SCHEMA: 'SCHEMA',
SCALAR: 'SCALAR',
OBJECT: 'OBJECT',
FIELD_DEFINITION: 'FIELD_DEFINITION',
ARGUMENT_DEFINITION: 'ARGUMENT_DEFINITION',
INTERFACE: 'INTERFACE',
UNION: 'UNION',
ENUM: 'ENUM',
ENUM_VALUE: 'ENUM_VALUE',
INPUT_OBJECT: 'INPUT_OBJECT',
INPUT_FIELD_DEFINITION: 'INPUT_FIELD_DEFINITION'
复制代码

Directive Resolver

Directive 的 resolver function 就像是一个 middleware ,它的第一个参数是 next,这样你可以在前后做拦截对数据进行处理。

对于入参和返回值,我们有时候需要对它设定默认值,下面我们创建一个 @Default 的directive。

directive @Default(value: Any ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
复制代码

next 是一个 Promise

const _ = require('lodash');

module.exports = {
  Default: (next, src, { value }, ctx, info) => next().then(v => _.defaultTo(v, value))
};
复制代码

那么之前的 MessageQuery 需要默认值时,可以使用 @Default

input MessageQuery {
  page: Int @Default(value: 1)
  pageSize: Int @Default(value: 15)
}
复制代码

Enumeration types

GraphQL 简单的定义一组枚举使用 enum 关键字。类似于其他语言每个枚举的 ordinal 值是它的下标。

enum Status {
  OPEN      # ordinal = 0
  CLOSE     # ordinal = 1
}
复制代码

在使用枚举的时候,我们很多时候需要把所有的枚举传给前台来做选择。那么我们需要自己创建 GraphQLEnumType 的对象来定义枚举,然后通过该对象的 getValues 方法获取所有定义。

// enum resolver.js

const { GraphQLEnumType } = require('graphql');
const status = new GraphQLEnumType({
  name: 'StatusEnum',
  values: {
    OPEN: {
      value: 0,
      description: '开启'
    },
    CLOSE: {
      value: 1,
      descirption: '关闭'
    }
  }
});

module.exports = {
  Status: status,
  Query: {
    status: status.getValues()
  }
};
复制代码

模块化

使用 GraphQL 有一个最大的优点就是在 Schema 定义中好所有数据后,通过一个请求可以获取所有想要的数据。但是当系统越来越庞大的时候,我们需要对系统进行模块化拆分,演变成一个分布式微服务架构的系统。这样可以按照模块独立开发部署。

Remote Schema

我们通过 Apollo Link 可以远程记载 Schema ,然后在进行拼接 (Schema stitching)。

import { HttpLink } from 'apollo-link-http';
import fetch from 'node-fetch';

const link = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch });

const schema = await introspectSchema(link);

const executableSchema = makeRemoteExecutableSchema({
  schema,
  link,
});
复制代码

Merge Schema

比如我们对博客系统进行了模块化拆分,一个用户服务模块,一个文章服务模块,和我们统一对外提供服务的 Gateway API 层。

import { HttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import fetch from 'node-fetch';

const userLink = new HttpLink({ uri: 'http://user-api.xxx.com/graphql', fetch });
const blogLink = new HttpLink({ uri: 'http://blog-api.xxx.com/graphql', fetch });

const userWrappedLink = setContext((request, previousContext) => ({
  headers: {
    'Authentication': `Bearer ${previousContext.graphqlContext.authKey}`,
  }
})).concat(userLink);

const userSchema = await introspectSchema(userWrappedLink);
const blogSchema = await introspectSchema(blogLink);

const executableUserSchema = makeRemoteExecutableSchema({
  userSchema,
  userLink,
});
const executableBlogSchema = makeRemoteExecutableSchema({
  blogSchema,
  blogLink,
});

const schema = mergeSchemas({
  schemas: [executableUserSchema, executableBlogSchema],
});
复制代码

resolvers between schemas

在合并 Schemas 的时候,我们可以对 Schema 进行扩展并添加新的 Resolver 。

const linkTypeDefs = `
  extend type User {
    blogs: [Blog]
  }

  extend type Blog {
    author: User
  }
`;
mergeSchemas({
  schemas: [chirpSchema, authorSchema, linkTypeDefs],
  resolvers: mergeInfo => ({
    User: {
      blogs: {
        fragment: `fragment UserFragment on User { id }`,
        resolve(parent, args, context, info) {
          const authorId = parent.id;
          return mergeInfo.delegate(
            'query',
            'blogByAuthorId',
            {
              authorId,
            },
            context,
            info,
          );
        },
      },
    },
    Blog: {
      author: {
        fragment: `fragment BlogFragment on Blog { authorId }`,
        resolve(parent, args, context, info) {
          const id = parent.authorId;
          return mergeInfo.delegate(
            'query',
            'userById',
            {
              id,
            },
            context,
            info,
          );
        },
      },
    },
  }),
});
复制代码

执行上下文

Apollo Server 提供了与多种框架整合的执行 GraphQL 请求处理的中间件。比如在 Egg.js 中,由于 Egg.js 是基于 koa 的,我们可以选择 apollo-server-koa。

npm install --save apollo-server-koa
复制代码

我们可以通过提供一个中间件来处理 graphql 的请求。

const { graphqlKoa, graphiqlKoa } = require('apollo-server-koa');

module.exports = (_, app) => {
  const options = app.config.graphql;
  const graphQLRouter = options.router;

  return async (ctx, next) => {
    if (ctx.path === graphQLRouter) {
      return graphqlKoa({
        schema: app.schema,
        context: ctx,
      })(ctx);
    }
    await next();
  };
};
复制代码

这里可以看到我们将 egg 的请求上下文传到来 GraphQL 的执行环境中,我们在 resolver function 中可以拿到这个 context。

graphqlKoa 还有一些其他参数,我们可以用来实现一些跟上下文相关的事情。

  • schema: the GraphQLSchema to be used
  • context: the context value passed to resolvers during GraphQL execution
  • rootValue: the value passed to the first resolve function
  • formatError: a function to apply to every error before sending the response to clients
  • validationRules: additional GraphQL validation rules to be applied to client-specified queries
  • formatParams: a function applied for each query in a batch to format parameters before execution
  • formatResponse: a function applied to each response after execution
  • tracing: when set to true, collect and expose trace data in the Apollo Tracing format

分布式全链路请求跟踪

在上面我们提到来如何实现基于 GraphQL 的分布式系统,那么全链路请求跟踪就是一个非常重要的事情。使用 Apollo GraphQL 只需要下面几步。

  1. 在每个模块系统中开启 tracing,也就是将上面的 graphqlKoa 的 tracing 参数设为 true
  2. 在请求入口中创建一个全局唯一的 tracingId,通过 context 以及 apollo-link-context 传递到每个模块上下文中
  3. 请求结束,每个模块将自己的 tracing data 上报
  4. 下面再用 graphql 对上报的监控数据做一个查询平台吧

写在最后

转眼已经 2018 年了,GraphQL 不再是一个新鲜的名词。Apollo 作为一个全栈 GraphQL 解决方案终于在今年迎来了飞速的发展。我们有幸在项目中接触并深度使用了 Apollo 的整套工具链。并且我们感受到了 Apollo 和 GraphQL 在一些方面的简洁和优雅,借此机会给大家分享它们的酸与甜。

你可能感兴趣的:(Apollo GraphQL 服务端实践)