GraphQL 落地背後:利弊取捨

此文是做者考慮 GraphQL 在 Node.js 架構中的落地方案後所得。從最初考慮能夠(之內置中間件)加入基礎服務並提供完整的構建、發佈、監控支持,到最終選擇不改動基礎服務以提供獨立包適配,不限制實現技術選型,交由業務團隊自由選擇的輕量方式落地。中間經歷瞭解除誤解,對收益疑惑,對最初定位疑惑,最終完成利弊權衡的過程。前端

文章會從解除誤解,技術選型,利弊權衡的角度,結合智聯招聘的開發現狀進行交流分享。git

文章會以 JavaScript 生態和 JavaScript 客戶端調用與服務端開發體驗爲例。github

對入門知識不作詳細闡述,可自行查閱學習指南中文(https://graphql.cn/learn/)/英文(https://graphql.org/learn/),規範中文(https://spec.graphql.cn/)/英文(https://github.com/graphql/graphql-spec/tree/master/spec),中文文檔有些滯後,但不影響了解 GraphQL。ajax

全貌

GraphQL 是一種 API 規範。不是拿來即用的庫或框架。不一樣對 GraphQL 的實如今客戶端的用法幾乎沒有區別,但在服務端的開發方式則天差地別。npm

GraphQL 模型

一套運行中的 GraphQL 分爲三層:編程

  • 左側是客戶端和發出的 Document 和其餘參數。
  • 中間是主要由 Schema 和 Resolver 組成的 GraphQL 引擎服務。
  • 右側是 Resolver 對接的數據源。

僅僅有客戶端是沒法工做的。後端

初識

GraphQL 的實現能讓客戶端獲取以結構化的方式,從服務端結構化定義的數據中只獲取想要的部分的能力。緩存

符合 GraphQL 規範的實現我稱之爲 GraphQL 引擎。bash

這裏的服務端不只指網絡服務,用 GraphQL 做爲中間層數據引擎提供本地數據的獲取也是可行的,GraphQL 規範並無對數據源和獲取方式加以限制。網絡

  • 操做模型:GraphQL 規範中對數據的操做作了定義,有三種,query(查詢)、mutation(變動)、subscription(訂閱)。

客戶端

咱們把客戶端調用時發送的數據稱爲 Query Document(查詢文檔),是段結構化的字符串,形如:

# 客戶端發送
query {
  contractedAuthor: {
    name
    articles {
      time
      title
    }
  }
  updateTime
}
# 或
mutation {
  # xxxxxx
}

須要注意的是 Query Document 名稱中的 Query 和操做模型中的 query 是沒有關係的,像上述示例所示,Query Document 也能夠包含 mutation 操做。因此爲了不誤解,後文將把 Query Document(查詢文檔)稱爲 Document 或文檔。一個 Document 中可包含單個或多個操做,每一個操做均可以查詢補丁數量的跟字段。

其中 query 下的 updateTime、contractedAuthor 這種操做下的第一層字段又稱之爲 root field(根字段)。其餘具體規範請自行查閱文檔。

Schema

服務端使用名爲 GraphQL Schema Language(或 Schema Definition LanguageSDL )的語言定義 Schema 來描述服務端數據。

# 服務端 schema
type Query {
  contractedAuthor: Author
  unContractedAuthor: Author
  updateTime: String
}

type Mutation{
  # xxx
}

type Subscription {
  # xxx
}

type Author {
  name: String
  articles: [Article]
}

type Article {
  time: String
  title: String
  content: String
}

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

能夠看到,因爲 GraphQL 是語言無關的,因此 SDL 帶有本身簡單的類型系統。具體與 JavaScript、Go 其餘語言的類型如何結合,要看各語言的實現。

從上面的 Schema 中咱們能夠獲得以下的一個數據結構,這就是服務可提供的完整的數據的 Graph(圖):

{
  query: {
    contractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    unContractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    updateTime: String
  }
  mutation: {
    # xxx
  }
  subscription: {
    # xxx
  }
}

在 Schema 定義中存在三種特殊的類型 Query、Mutation、Subscription,也稱之爲 root types(根類型),與 Document 中的操做模型一一對應的。

結合 Document 和 Schema,能夠直觀的感覺到 Document 和 Schema 結構的一致,且 Document 是 Schema 結構的一部分,那麼數據就會按照 Document 的這部分返回,會獲得以下的數據:

{
  errors: [],
  data: {
    contractedAuthor: {
      name: 'zpfe',
      articles: [
        {
          time: '2020-04-10',
          title: '深刻理解GraphQL'
        },
        {
          time: '2020-04-11',
          title: 'GraphQL深刻理解'
        }
      ]
    },
    updateTime: '2020-04-11'
  }
}

預期數據會返回在 data 中,當有錯誤時,會出現 errors 字段並按照規範規定的格式展現錯誤。

跑起來的 Schema

如今 Document 和 Schema 結構對應上了,那麼數據如何來呢?

  • Selection Sets 選擇集:

    query {
      contractedAuthor: {
        name
        articles {
          time
          title
        }
        honour {
          time
          name
        }
      }
      updateTime
    }

    如上的查詢中存在如下選擇集:

    # 頂層
    {
      contractedAuthor
      updateTime
    }
    # 二層
    {
      name
      articles
      honour
    }
    # articles:三層 1
    {
      time
      title
    }
    # honour:三層 2
    {
      time
      name
    }
  • Field 字段:類型中的每一個屬性都是一個字段。

省略一些如校驗、合併的細節,數據獲取的過程以下:

  • 執行請求:GraphQL 引擎拿到 Document 並解析並處理以後,獲得一個新的結構化的 Document(固然本來的 Document 也是結構化的,只不過是個字符串)。
  • 執行操做:引擎會首先分析客戶端的目標操做,如是 query 時,則會去 Schema 中找到 Query 類型部分執行,由前文所說 Query、Mutation、Subscription 是特殊的操做類型,因此如 query、mutation、subscription 字段是不會出如今返回結果中的,返回結果中的第一層字段是前文提到的 root field(根字段)。
  • 執行選擇集:此時已經明確的知道客戶端但願獲取的 Selection Sets(選擇集)。query 操做下,引擎通常會以廣度優先、同層選擇集並行執行獲取選擇集數據,規範沒有明確規定。mutation 下,由於涉及到數據修改,規範規定要按照由上到下按順序、深度優先的方式獲取選擇集數據。
  • 執行字段:

    • 肯定了選擇集的執行順序後開始真正的字段值的獲取,很是簡化的講,Schema 中的類型應該對其每一個字段提供一個叫作 Resolver 的解析函數用於獲取字段的值。那麼可執行的 Schema 就形如:

      type Query {
        contractedAuthor () => Author
      }
      type Author {
        name () => String
        articles () => [Article]
      }
      type Article {
        time () => String
        title () => String
        content () => String
      }

      其中每一個類型方法都是一個 Resolver。

    • 在執行字段 Resolver 以後會得字段的值,若是值的類型爲對象,則會繼續執行其下層字段的 Resolver,如 contractedAuthor() 後獲得值類型爲 Author,會繼續執行 name ()articles() 以獲取 name 和 articles 的值,直到獲得類型爲標量(String、Int等)的值。
    • 同時雖然規範中沒有規定 Resolver 缺乏的狀況,但引擎實現時,通常會實現一個向父層字段(即字段所在對象)取與本身同名的屬性的值的 Resolver。如未提供 Artical 對象 time 字段的 Resolver,則會直接取 artical.time。

至此由 Schema 和 Resolver 組合而成的可執行 Schema 就誕生了,Schema 跑了起來,GraphQl 引擎也就跑了起來。

GrahpQL 服務端開發的核心就是定義 Schema (結構)和實現相應的 Resolver(行爲)

其餘定義

固然,在使用 GraphQL 的過程當中,還能夠:

  • 使用 Variables(變量)複用同一段 Document 來動態傳參。
  • 使用 Fragments(片斷)下降 Document 的複雜度。
  • 使用 Field Alias(字段別名)進行簡單的返回結果字段重命名。

這些都沒有什麼問題。

可是在 Directives(指令)的支持和使用上,規範和實現是有衝突的。

  1. 規範內置指令:規範中只規定了 GraphQL 引擎須要實現 Document 中可用的 @skip(條件跳過)、@include(條件包含),在服務端 Schema 部分可用的 @deprecated(字段已廢棄)指令。
  2. 自定義指令支持:在我查到的資料中,Facebook 與 graphql-js(Facebook提供實現)官方有不支持自定義指令的表態1(https://github.com/graphql/graphql-js/issues/446)2(https://github.com/graphql-rust/juniper/issues/156)3(https://github.com/graphql/graphql-js/issues/41)。在 Apollo 實現的 Graphql 生態中則是支持自定義 Schema 端可用的指令,對 Document 端的自定義指令實現暫不支持且不建議支持

而在研究 GraphQL 時發生的的誤解在於:

  • 規範、教程提到 query(查詢)時,沒法確認是指客戶端側客戶端發出的 Query Document 整個操做仍是,Document 中的 query 操做,亦或是服務端側定義在 Schema 中的 Query 類型。
  • 或如講到 Arguments、Variables 等概念,其原則、寫法是位於三層的那部分。

實現與選型

GraphQL 的典型實現主要有如下幾種:

  • graphql-js:由 Facebook 官方提供的實現。幾乎是
  • Apollo GraphQL: Apollo 提供的實現和 GraphQL 生態,內容豐富,不止一套引擎,還提供了純客戶端使用(不侷限JavaScript)多種工具。
  • type-graphql:強依賴 TypeScript 開發的實現,主要是輸出可執行 Schema。

graphql-js 能夠說是其餘實現的基礎。

可執行 Schema 的建立方式是這幾種實現最大的不一樣,下面將就這部分進行展現。

graphql-js

npm install --save graphql
  • 建立可執行 Schema

    import {
      graphql,
      GraphQLList,
      GraphQLSchema,
      GraphQLObjectType,
      GraphQLString,
    } from 'graphql'
    
    const article = new GraphQLObjectType({
      fields: {
        time: {
          type: GraphQLString,
          description: '寫做時間',
          resolve (parentValue) {
            return parent.date
          }
        },
        title: {
          type: GraphQLString,
          description: '文章標題',
        }
      }
    })
    
    const author = new GraphQLObjectType({
      fields: {
        name: {
          type: GraphQLString,
          description: '做者姓名',
        },
        articles: {
          type: GraphQLList(article),
          description: '文章列表',
          resolve(parentValue, args, ctx, info) {
            // return ajax.get('xxxx', { query: args })
          },
        }
      },
    })
    
    const schema = new GraphQLSchema({
      query: new GraphQLObjectType({
        name: 'RootQuery',
        fields: {
          contractedAuthor: {
            type: author,
            description: '簽約做者',
            resolve(parentValue, args, ctx, info) {
              // return ajax.get('xxxx', { query: args })
            },
          },
        },
      }),
    })

    能明確的看到,graphql-js 實現經過 GraphQLSchema 建立出的 schema 中,field 和 resolver 和他們一一對應的關係,同時此 schema 就是可執行 Schema。

  • 執行

    import { parse, execute, graphql } from 'graphql'
    import { schema } from '上面的schema'
    
    // 實際請求中,document 由 request.body 獲取
    const document = `
    query {
      contractedAuthor {
        name
        articles {
          title
        }
      }
    }`
    // 或使用導入的 graphql 方法執行
    const response = await execute({
      schema,
      document: parse(document),
      // 其餘變量參數等
    })

    傳入可執行 schema 和解析後的 Document 便可獲得預期數據。

Apollo

Apollo 提供了完整的 GraphQL Node.js 服務框架,可是爲了更直觀的感覺可執行 Schema 的建立過程,使用 Apollo 提供的 graphql-tools 進行可執行 Schema 建立。

npm install graphql-tools graphql

上面是 Apollo 給出的依賴安裝命令,能夠看到 graphql-tools 須要 graphql-js(graphql)做爲依賴 。

  • 建立可執行 Schema

    import { makeExecutableSchema } from 'graphql-tools'
    
    const typeDefs = `
    type Article {
      time: String
      title: String
    }
    
    type Author {
      name: String
      articles: [Article]
    }
    
    type Query {
      contractedAuthor: Author
    }
    
    schema {
      query: Query
    }
    `
    const resolvers = {
      Query: {
        contractedAuthor (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Author: {
        articles (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Article: {
        time (article) {
          return article.date
        }
      }
    }
    const executableSchema = makeExecutableSchema({
      typeDefs,
      resolvers,
    })

    resolvers 部分以類型爲維度,以對象方法的形式提供了 Resolver。在生成可執行 Schema 時,會將 Schema 和 Resolver 經過類型映射起來,有必定的理解成本。

type-graphql

這部分涉及 TypeScript,只作不完整的簡要展現,詳情自行查閱文檔。

npm i graphql @types/graphql type-graphql reflect-metadata

能夠看到 type-graphql 一樣須要 graphql-js(graphql)做爲依賴 。

  • 建立可執行 Schema

    import 'reflect-metadata'
    import { buildSchemaSync } from 'type-graphql'
    
    @ObjectType({ description: "Object representing cooking recipe" })
    class Recipe {
      @Field()
      title: string
    }
    
    @Resolver(of => Recipe)
    class RecipeResolver {
    
      @Query(returns => Recipe, { nullable: true })
      async recipe(@Arg("title") title: string): Promise<Recipe> {
        // return await this.items.find(recipe => recipe.title === title);
      }
    
      @Query(returns => [Recipe], { description: "Get all the recipes from around the world " })
      async recipes(): Promise<Recipe[]> {
        // return await this.items;
      }
    
      @FieldResolver()
      title(): string {
        return '標題'
      }
    }
    const schema = buildSchemaSync({
      resolvers: [RecipeResolver]
    })

    type-graphql 的核心是類,使用裝飾器註解的方式複用類生成 Schema 結構,並由 reflect-metadata 將註解信息提取出來。如由 @ObjectType()@Field 將類 Recipe 映射爲含有 title 字段的 schema Recipe 類型。由 @Query 註解將 reciperecipes 方法映射爲 schema query 下的根字段。由 @Resolver(of => Recipe)@FieldResolver()title() 方法映射爲類型 Recipe 的 title 字段的 Resolver。

關聯與差別

同:在介紹 Apollo 和 type-graphql 時,跳過了執行部分的展現,是由於這兩種實現生成的可執行 Schema 和 graphql-js 的是通用的,查看這二者最終生成的可執行 Schema 能夠發現其類型定義都是使用的由 graphql-js 提供的 GraphQLObjectType 等, 能夠選擇使用 graphql-js 提供的執行函數(graphql、execute 函數),或 apollo-server 提供的服務執行。

異:

  • 結構:直接可見的是結構上的差別,graphql-js 做爲官方實現提供告終構(Schema)和行爲(Resolver)不分離的建立方式,沒有直接使用 SDL 定義 Schema,好處是理解成本低,上手快;apollo 實現則使用結構和行爲分離的方式定義,且使用了 SDL,結構和行爲使用類名造成對應關係,有必定的理解成本,好處是 Schema 結構更直觀,且使用 SDL 定義 Schema 更快。
  • 功能:

    • graphql-js:graphql-js 是繞不過的基礎。提供了生成可執行 Schema 的函數和執行 Schema 生成返回值的函數(graphql、execute 函數),使用執行方法可快速將現有 API 接口快速改造爲 GraphQL 接口。適合高度定製 GraphQL 服務或快速改造。
    • apollo:提供了開箱即用的完整的 Node.js 服務;提供了拼接 Schema(本地、遠端)的方法,使 GraphQL 服務拆分紅爲可能;提供了客戶端可用的數據獲取管理工具。當遇到問題在 apollo 生態中找一找通常都會有收穫。
    • type-grahpql:當使用 TypeScript 開發 GraphQL 時,通常要基於 TypeScript 對數據定義模型,也要在 Schema 中定義數據模型,此時 type-graphql 的類型複用的方式就比較適合。同時 type-grahpql 只純粹的負責生成可執行 Schema,與其餘服務實現不衝突,可是這個實現的穩定性還有待觀察。

利弊

對 GraphQL 的直觀印象就是按需、無冗餘,這是顯而易見的好處,那麼在實際應用中真的這麼直觀美好麼?

  • 聲明式的獲取數據:結構化的 Document 使得獲得數據後,對數據的操做提供了必定便利(若是能打通服務端和客戶端的類型公用,使得客戶端在開發時提供代碼智能提示更好)。
  • 調用合併:常常提到的與 RESTful 相比較優的一點是,當須要獲取多個關聯數據時,RESTful 接口每每須要屢次調用(併發或串行),而基於 GraphQL 的接口調用則能夠將調用順序體如今結構化的查詢中,一次獲取所有數據,減小了接口往返順序。但同時也有一些注意事項,要真正減小調用次數,要在前端應用中集中定義好應用全局的數據結構,統一獲取,若是仍然讓業務組件就近獲取(只讓業務組件這種真正的使用方知曉數據結構),這個優點並不存在。
  • 無冗餘:按需返回數據,在網絡性能上確實有必定優化。
  • 文檔化:GraphQL 的內省功能能夠根據 Schema 生成實時更新的 API 文檔,且沒有維護成本,對於調用方直觀且準確。
  • 數據 Mock:服務端 Schema 中包含數據結構和類型,因此在此基礎上實現一個 Mock 服務並不困難,apollo-server 就有實現,能夠加快前端開發介入。
  • 強類型(字段校驗):因爲 JS 語言特性,強類型只能稱爲字段強類型校驗(包括入參類型和返回結果),當數據源返回了比 Schema 多或少的字段時,並不會引起錯誤,而就算採用了 TypeScript 因爲沒有運行時校驗,也會有一樣的問題。可是字段類型校驗也會有必定的幫助。
  • 調試:因爲咱們調用 GraphQL 接口時(如:xxx/graphql/im)沒法像 RESTful 接口那樣(如:xxx/graphql/im/messagexxx/graphql/im/user)從 URL 直接分辨出業務類型,會給故障排查帶來一些不便。

上面提到的點幾乎都是出於調用方的視角,能夠看到,做爲 GraphQL 服務的調用方是比較舒服的。

因爲智聯招聘前端架構Ada中包含基於 Node.js 的 BFF(Backends For Frontends 面向前端的後端)層,前端開發者有能力針對具體功能點開發一對一的接口,有且已經進行了數據聚合、處理、緩存工做,也在 BFF 層進行過數據模型定義的嘗試,同時已經有團隊在現有 BFF 中接入了 GraphQL 能力並穩定運行了一段時間。因此也會從 GraphQL 的開發者和二者間的角度談談成本和收益。

  • BFF:GraphQL 能夠完成數據聚合、字段轉換這種符合 BFF 特徵的功能,提供了一種 BFF 的實現選擇。
  • 版本控制:客戶端結構化的查詢方式可讓服務追蹤到字段的使用狀況。且在增長字段時,根據結構化查詢按需查詢的特色,不會影響舊的調用(雖然 JavaScript 對多了個字段的事情不在乎)。對於服務的迭代維護有必定便利。
  • 開發成本:毫無疑問 Resolver(業務行爲)的開發在哪一種服務模式下都不可缺乏,而 Schema 的定義必定是額外的開發成本,且主觀感覺是 Schema 的開發過程仍是比較耗費精力的,數據結構複雜的狀況下更爲如此。同時考慮到開發人員的能力差別,GraphQL 的使用也會是團隊長期的人員成本。像咱們在 BFF 層已經有了徹底針對功能點一對一的接口的狀況下,接口一旦開發完成,後續迭代要麼完全重寫、要麼再也不改動,這種狀況下是用不到 GraphQL 的版本控制優點,將每一個接口都實現爲 GraphQL 接口,收益不高。
  • 遷移改造:提供 GraphQL 接口有多種方式,能夠徹底重寫也能夠定義 Schema 後在 Resolver 中調用現有接口,僅僅把 GraphQL 看成網關層。
  • 調用合併:GraphQL 的理念就是將多個查詢合併,對應服務端,一般只會提供一個合併後的「大」的接口,那麼本來以 URL 爲粒度的性能監控、請求追蹤就會有問題,可能須要改成以 root field(根字段)爲粒度。這也是須要額外考慮的。
  • 文檔化:在智聯招聘所推行的開發模式中,一般 BFF 接口和前端業務是同一我的進行開發,對接口數據格式是熟知的,且接口調用方惟1、無複用,GraphQL 的文檔化這一特性帶來的收益也有限。
  • 規範:因爲 GraphQL Schema 的存在,使得數據模型的定義成爲了必要項。在使用 JavaScript 開發接口服務時,相對其餘各類數據模型定義的嘗試,提供了定義數據模型的統一實踐和強規範,也算是收益之一。同時 Resolver 的存在強化了只在前端作 UI、交互而在 BFF 層處理邏輯的概念。

總結

綜合來看,可用的 GraphQL 服務(不考慮拿 GraphQL 作本地數據管理的狀況)的重心在服務提供方。做爲 GraphQL 的調用方是很爽的,且幾乎沒有弊端。那麼要不要上馬 GraphQL 就要重點衡量服務端的成本收益了。就個人體會而言,有如下幾種狀況:

  1. 服務自己提供的就是針對具體功能的接口,接口只有單一的調用方,不存在想要獲取的數據結構不固定的狀況,或者說是一次性接口,發佈完成後不用再迭代的,那麼不必使用 GraphQL。
  2. 服務自己是基礎服務,供多方調用,需求不一但對外有統一的輸出模型的狀況下(如:Github 開放接口,沒法肯定每一個調用者需求是什麼),可使用 GraphQL。
  3. 在 Node.js(JavaScript)中,因爲面向對象、類型的支持程度問題,開發者編程思惟問題,實現成本比 Java 等其餘語言更高,要謹慎考慮成本。
  4. 沒有 BFF 層時,因爲 GraphQL 對於實現數據聚合、字段轉換提供了範式,能夠考慮使用 GraphQL 服務做爲 BFF 層,或者結合一、2點,將部分接口實現爲 GraphQL,做爲 BFF 層的一部分,其餘接口還能夠採起 RESTful 風格或其餘風格,並不衝突。
  5. 當前端開發自己就要基於 Node.js 進行 BFF 層開發,團隊對規範、文檔有更高優先級的需求時,能夠考慮使用 GraphQL 進行開發。
相關文章
相關標籤/搜索