GraphQL 技術淺析

ES2049發表於2018-09-14

背景

7月份我們前端團隊推動落地了一個 toB 型別的系統,由於服務端也由我們前端工程師來承接,所以服務端技術選型上我們有了話語權,API 這一塊兒我們選擇了 GraphQL 。本文將闡述我學習 GraphQL 這門技術的一些思考。

GraphQL 在解決什麼問題

學習一門新技術,首先要把問題域弄清楚。社群有大量 GraphQL 與傳統 API 解決方案(含 REST API)對比文章,總結下來,傳統 API 存在以下問題:

  • 介面數量眾多維護成本高:介面的數量通常由業務場景的數量決定,為了儘量減少介面數量,服務端工程師通常會對業務做抽象,首先構建粒度較小的資料介面,再根據業務場景對資料介面進行組合,對外暴露業務介面,即便這樣,服務端對前端暴露的介面數量還是非常多,因為業務總是多變的。
  • 介面擴充套件成本高:出於頻寬的考慮移動端我們要求介面返回儘量少的欄位,PC 端通常要展現更多欄位;考慮首屏效能,我們又要求對介面做合併;傳統 API 應對這些需求,前後端都面臨改造,成本較高。
  • 介面響應的資料格式無法預知:由於介面文件幾乎總是不能及時更新,前端工程師無法預知介面響應的資料格式,影響前端開發進度。

針對以上問題,GraphQL 給出了較為完善的解決方案。

GraphQL 如何解決問題

接下來我通過一個例項講解 GraphQL 解決問題的思路,客戶端的述求:根據性別查詢團隊成員列表,返回 idgendernamenickName ,GrahpQL 的處理過程如下圖:

image.png

請求引數在傳送到服務端之前會先經過 GraphQL Client 轉換成客戶端 Schema,這段 Schema 其實是一段 query 開頭的字串,描述了客戶端的對資料的述求:呼叫哪個方法,傳遞什麼樣的引數,返回哪些欄位。服務端拿到這段 Schema 之後,通過事先定義好的服務端 Schema 接收請求引數並執行對應的 resolve 函式提供資料服務。整個過程可以想象成我們吃自助餐的過程,服務端 Schema 就好比自助餐線,擺上我們能提供的所有食物;客戶端 Schema 就描述了我們想要吃的食物,按需獲取就好了。

講到這裡,好奇心強的同學可能已經開始思考這個問題了:客戶端 Schema 本質上就是一段字串,服務端如何識別並響應這段字串?

graphql-js

識別與響應客戶端 Schema 依賴於官方類庫 graphql-js ,服務端拿到客戶端 Schema 字串後會做如下處理:

image.png

  • 解析階段 為了識別客戶端 Schema, graphql-js 定義了一系列的特徵識別符號:
export const TokenKind = Object.freeze({
    BANG: '!',
    DOLLAR: '$',
    PAREN_L: '(',
    PAREN_R: ')',
    SPREAD: '...',
    COLON: ':',
    EQUALS: '=',
    BRACKET_L: '[',
    BRACKET_R: ']',
    ...
});
複製程式碼

並定義了 AST 語法樹規範,規定語法樹支援以下節點:

/**
 * The set of allowed kind values for AST nodes.
 */
export const Kind = Object.freeze({
  // Name
  NAME: 'Name',

  // Document
  DOCUMENT: 'Document',
  OPERATION_DEFINITION: 'OperationDefinition',
  VARIABLE_DEFINITION: 'VariableDefinition',
  VARIABLE: 'Variable',

  // Values
  INT: 'IntValue',
  FLOAT: 'FloatValue',
  STRING: 'StringValue',
  BOOLEAN: 'BooleanValue',
  ...
});
複製程式碼

有了特徵字串與 AST 語法樹規範,GraphQL Server 對客戶端 Schema 進行逐字元掃描(charCodeAt),最終解析階段的產出物為 document ,上文示例中的客戶端 Schema 解析完成之後的部分 document

{
  "kind":"Document",
  "definitions":[
  {
    "kind":"OperationDefinition",
    "operation":"query",
    "name":{
      "kind":"Name",
      "value":"DisplayMember",
      "loc":{
        "start":13,
        "end":26
      }
    },
    "selectionSet":{
      "kind":"SelectionSet",
      "selections":[
        {
          "kind":"Field",
          "alias":null,
          "name":{
            "kind":"Name",
            "value":"fetchByGender",
            "loc":{
              "start":37,
              "end":50
            }
          },
          "arguments":[
            {
              "kind":"Argument",
              "name":{
                "kind":"Name",
                "value":"gender",
                "loc":{
                  "start":51,
                  "end":57
                }
              },
              "value":{
                "kind":"StringValue",
                "value":"M",
                "loc":{
                  "start":59,
                  "end":62
                }
              },
              "loc":{
                "start":51,
                "end":62
              }
            }
          ],
...
複製程式碼

如果客戶端 Schema 不符合服務端定義的 AST 規範,解析過程會直接丟擲語法異常 Syntax Error ,拿上文的示例舉例,我將客戶端 Schema 中的 fetchByGender(gender: "M") 改為 fetchByGender(gender) ,只傳遞引數名,不傳遞引數值,則服務端會響應:

{
    "errors":[
        {
            "message":"Syntax Error GraphQL request (3:29) Expected :, found )

2: query DisplayMember {
3: fetchByGender(gender) {
^
4: list {
",
            "locations":[
                {
                    "line":3,
                    "column":29
                }
            ]
        }
    ]
}
複製程式碼

結構化的報錯資訊也是 GraphQL 的一大特點,定位問題非常方便。只要語法沒問題解析階段就能順利完成,然後進入校驗階段。

  • 校驗階段

校驗階段用於驗證客戶端 Schema 是否按照服務端 Schema 定義好的方式獲取資料,比如:獲取資料的方法名是否有誤,必填項是否有值等等,校驗範圍一共有幾十種,我沒有辦法一一舉例。拿上文的示例舉例,我將客戶端 Schema 中的 fetchByGender 改為 fetchByGenfetchByGen 在服務端根本沒有定義,則服務端會響應:

{
    "errors":[
        {
            "message":"Cannot query field "fetchByGen" on type "Query". Did you mean "fetchByGender"?",
            "locations":[
                {
                    "line":3,
                    "column":9
                }
            ]
        }
    ]
}
複製程式碼

不僅返回結構化的報錯資訊,還非常人性化的告訴你正確的呼叫方式是什麼。校驗階段通過之後會進入執行階段

  • 執行階段

執行階段依賴的輸入為:解析階段的產出物 document ,服務端 Schema;其中 document 準確描述了客戶端對資料的述求:請求哪個方法,引數是什麼,需要哪些欄位;服務端 Schema 描述了提供資料的方式;拿上文的示例舉例,服務端 Schema 需要這樣定義:

const graphqlApi = require('graphql');
const {
  GraphQLObjectType,
  GraphQLList,
  GraphQLNonNull,
  GraphQLSchema,
  GraphQLString,
} = graphqlApi;

const dataSource = require('./dataSource');

const memType = new GraphQLObjectType({
  name: 'Male',
  description: 'A member gender is Male.',
  fields: () => ({
    id: {
      type: new GraphQLNonNull(GraphQLString),
      description: 'The id of member',
    },
    name: {
      type: GraphQLString,
      description: 'The name of the character.',
    },
    nickName: {
      type: GraphQLString,
      description: 'The nickName of the character.',
    },
    gender: {
      type: GraphQLString,
      description: 'The gender of the character.',
    },
    list: {
      type: new GraphQLList(memType),
      description: 'The mems list by gender.',
    },
  })
});

const queryType = new GraphQLObjectType({
  name: 'Query',
  fields: () => ({
    fetchByGender: {
      type: memType,
      args: {
        gender: {
          description: 'gender of the human',
          type: new GraphQLNonNull(GraphQLString),
        },
      },
      resolve: (root, { gender }) => {
        // 訪問資料庫或三方 API 查詢成員列表
        return {
          list: dataSource.getMembers(gender),
        };
      },
    },
  }),
});

module.exports = new GraphQLSchema({
  query: queryType,
  types: [memType],
});
複製程式碼

執行服務端 Schema 中的 resolve 函式,得到執行階段的輸出:

{
    "data":{
        "fetchByGender":{
            "list":[
                {
                    "id":"1",
                    "gender":"M",
                    "name":"童開巨集",
                    "nickName":"慕冥"
                }
            ]
        }
    }
}
複製程式碼

當然要完成服務端 Schema 的定義,你需要學習 GraphQL 的 型別系統 ,大家翻閱 API 文件即可。

技術邊界

原理弄清楚之後我們需要對 GraphQL 這門技術的邊界有一個清醒的認識:

  • 客戶端邊界:核心能力是將請求引數按照服務端定義好的 AST 語法樹規範拼裝成客戶端 Schema 字串,實現方案大家可參考apollo提供的 Webpack 外掛 ,當然也有一些 GraphQL 客戶端連傳送 Ajax 請求的活兒也幹了,無非是在底層呼叫其他類庫比如 axios 發請求。

  • 服務端邊界:核心能力是識別客戶端 Schema 字串,並通過服務端 Schema 呼叫底層的資料服務按需返回使用者想要的資料,至於底層資料來源來自哪裡(資料庫或者三方介面),以何種方式獲取資料(直連資料庫或者 ORM 方法呼叫),這些不屬於 GraphQL 關心的範疇。

問題解決的怎麼樣

由於 GraphQL 通過客戶端 Schema 而不是通過 URL 描述資料述求,所以理論上服務端只需要對客戶端暴露一個地址即可,解決了介面數量眾多維護成本高的問題;同時,服務端提供的是全量欄位,客戶端可按需獲取,面對介面擴充套件的需求,服務端沒有開發成本;最後,通過 GraphiQL 視覺化除錯介面展現服務端能提供的所有資料,開發過程不再依賴介面文件:

image.png

GraphQL 社群在忙什麼

GraphQL 官方提供核心能力:

  • graphql-js :GraphQL 理念的 JavaScript 實現,該類庫可同時執行在瀏覽器環境與 Node 環境,該類庫的原理我在上文中已經講過了。
  • graphiql :提升除錯體驗,我在上文中提過。
  • dataloader :提升效能,通過合併請求儘量減少資料庫查詢次數。
  • Relay :前端框架,使 GraphQL 與 React 很好的融合在一起,嵌入性較強,需要 GraphQL Server 配合。

我們還缺什麼?

  • 服務端 官方只提供了 JavaScript 語言支援,社群愛好者很快在不同程式語言中實現了 GraphQL 的理念:JAVA.NET 等等,更多語言支援,請檢視 官網

  • 客戶端 官方提供的 Relay 解決了 GraphQL 與 React 相結合的問題,Apollo Client 提供了與其他前端框架融合的解決方案,比如 Vue、Angular 等等。

  • 開發體驗

    • graphql-tools :在上文示例程式碼的服務端 Schema 中,我們將型別的定義(typeDefs)與處理函式的定義(resolvers)放在同一個檔案中,職責上不夠單一,藉助 graphql-tools 我們可以將二者分不同的檔案定義;

    • egg-graphql :與 Node 框架 egg 相結合,制定 目錄規範 並提供語法糖提高開發效率;

總結

GraphQL 的優點上文已經講過了,真的是從業務痛點出發,解決了傳統 API 存在的問題,但是 GraphQL 在解決問題的同時也帶了一些新的問題,這些問題在某種程度上阻礙了這門技術的普及:

  • 資料庫效能:GraphQL 將資料描述成一張巨大的網,理論上客戶端 Schema 可以寫出任意巢狀層級的查詢語句,比如:
query IAmEvil {
  author(id: "abc") {
    posts {
      author {
        posts {
          author {
            posts {
              author {
                # that could go on as deep as the client wants!
              }
            }
          }
        }
      }
    }
  }
}
複製程式碼

這樣的查詢語句會給資料庫帶來很大的效能開銷,服務端不得不做 限流 來規避這樣的問題,這也帶來了額外的開發成本。

  • 侵入性:GraphQL 受益最大的是前端,卻需要服務端鼎力支援,特別是老系統遷移,服務端與前端都面臨較大的改造。
  • 學習成本:GraphQL 是一套全新的理念,需要前後端同學都學習新的知識才能掌握這門技術,這也帶來較大的學習成本。

任何技術都有利弊,大家要結合自己的場景權衡收益做出適合自己的技術選型。

參考文件

文章可隨意轉載,但請保留此 原文連結。 非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com

相關文章