如果喜歡我們的文章別忘了點選關注阿里南京技術專刊呦~ 本文轉載自 阿里南京技術專刊-知乎,歡迎大牛小牛投遞阿里南京前端/後端開發等職位,詳見 阿里南京誠邀前端小夥伴加入~。
在最近的專案中,我們選擇了 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 在一些方面的簡潔和優雅,藉此機會給大家分享它們的酸與甜。