Apollo GraphQL 服務端實踐

阿里南京技術專刊發表於2018-05-22

如果喜歡我們的文章別忘了點選關注阿里南京技術專刊呦~ 本文轉載自 阿里南京技術專刊-知乎,歡迎大牛小牛投遞阿里南京前端/後端開發等職位,詳見 阿里南京誠邀前端小夥伴加入~

在最近的專案中,我們選擇了 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 在一些方面的簡潔和優雅,藉此機會給大家分享它們的酸與甜。

相關文章