如果喜欢我们的文章别忘了点击关注阿里南京技术专刊呦~ 本文转载自 阿里南京技术专刊-知乎,欢迎大牛小牛投递阿里南京前端/后端开发等职位,详见 阿里南京诚邀前端小伙伴加入~。
在最近的项目中,我们选择了 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 提供了 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 在一些方面的简洁和优雅,借此机会给大家分享它们的酸与甜。