Apollo GraphQL 服務端實踐

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

在最近的項目中,咱們選擇了 GraphQL 做爲 API 查詢語言替代了傳統的 Restful 傳參的方法進行先後端數據傳遞。服務端選用了 egg.js + Apollo graphql-tools,前端使用了 React.js + Apollo graphql-client。這樣的架構選擇讓咱們的迭代速度有了很大的提高。node

基於 GraphQL 的 API 服務在架構上來看算是 MVC 中的 controller。只是它只有一個固定的路由來處理全部請求。那麼在和 MVC 框架結合使用時,在數據轉換 ( Convertor )、參數校驗 ( Validator ) 等功能上,使用 Apollo GraphQL 帶來了一些新的處理方式。下面會介紹一下在這些地方使用 Graphql 帶來的一些優點。git

什麼是 GraphQL:

GraphQL 是由 Facebook 創造的用於描述複雜數據模型的一種查詢語言。這裏查詢語言所指的並非常規意義上的相似 sql 語句的查詢語言,而是一種用於先後端數據查詢方式的規範。sql

什麼是 Apollo GraphQL:

Apollo GraphQL 是基於 GraphQL 的全棧解決方案集合。從後端到前端提供了對應的 lib 使得開發使用 GraphQL 更加的方便express

Type System

在描述一個數據類型時,GraphQL 經過 type 關鍵字來定義一個類型,GraphQL 內置兩個類型 Query 和 Mutation,用於描述讀操做和寫操做。npm

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 來處理。api

  • interface: 相似與其餘語言中的接口,可是屬性並不會被繼承下來
  • union types: 相似與接口,它不須要有任何繼承關係,更像是組合

以上面的 Message 類型爲例,咱們可能有多種消息類型,好比通知、提醒bash

interface Message {
  content: String
}

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

type Remind implements Message {
  content: String
  endTime: Date
}
複製代碼

可能在某個查詢中,須要一塊兒返回未讀消息和未讀郵件。那麼咱們能夠用 unionmarkdown

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 在一些方面的簡潔和優雅,藉此機會給你們分享它們的酸與甜。

相關文章
相關標籤/搜索