如果喜欢我们的文章别忘了点击关注阿里南京技术专刊呦~ 本文转载自 阿里南京技术专刊 - 知乎 https://zhuanlan.zhihu.com/p/35451395 , 欢迎大牛小牛投递阿里南京前端 / 后端开发等职位, 详见 阿里南京诚邀前端小伙伴加入~ https://zhuanlan.zhihu.com/p/36136510 .
在最近的项目中, 我们选择了 GraphQL 作为 API 查询语言替代了传统的 Restful 传参的方法进行前后端数据传递. 服务端选用了 egg.js + Apollo graphql-tools, 前端使用了 React.js + Apollo graphql-client. 这样的架构选择让我们的迭代速度有了很大的提升.
基于 GraphQL http://graphql.org/ 的 API 服务在架构上来看算是 MVC 中的 controller. 只是它只有一个固定的路由来处理所有请求. 那么在和 MVC 框架结合使用时, 在数据转换 (Convertor), 参数校验 ( Validator ) 等功能上, 使用 Apollo GraphQL https://www.apollographql.com/ 带来了一些新的处理方式. 下面会介绍一下在这些地方使用 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 提供了 interface 和 union 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 只需要下面几步.
在每个模块系统中开启 tracing, 也就是将上面的 graphqlKoa 的 tracing 参数设为 true
在请求入口中创建一个全局唯一的 tracingId, 通过 context 以及 apollo-link-context 传递到每个模块上下文中
请求结束, 每个模块将自己的 tracing data 上报
下面再用 graphql 对上报的监控数据做一个查询平台吧
写在最后
转眼已经 2018 年了, GraphQL 不再是一个新鲜的名词. Apollo 作为一个全栈 GraphQL 解决方案终于在今年迎来了飞速的发展. 我们有幸在项目中接触并深度使用了 Apollo 的整套工具链. 并且我们感受到了 Apollo 和 GraphQL 在一些方面的简洁和优雅, 借此机会给大家分享它们的酸与甜.
来源: https://juejin.im/post/5b03cdcd51882542c760eaef