GraphQL到底怎麼使?看看智聯前端團隊技術沉澱

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

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

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

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

全貌

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

GraphQL 模型

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

  • 左側是客戶端和發出的 Document 和其他參數。

  • 中間是主要由 Schema 和 Resolver 組成的 GraphQL 引擎服務。

  • 右側是 Resolver 對接的數據源。

僅僅有客戶端是無法工作的。

初識

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

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

這裏的服務端不僅指網絡服務,用 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 接口和前端業務是同一個人進行開發,對接口數據格式是熟知的,且接口調用方唯一、無複用,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 層,或者結合1、2點,將部分接口實現爲 GraphQL,作爲 BFF 層的一部分,其他接口還可以採取 RESTful 風格或其他風格,並不衝突。

  5. 當前端開發本身就要基於 Node.js 進行 BFF 層開發,團隊對規範、文檔有更高優先級的需求時,可以考慮使用 GraphQL 進行開發。

❤️ 看完三件事

如果你覺得這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 關注我的博客 https://github.com/SHERlocked93/blog,讓我們成爲長期關係

  3. 關注公衆號「前端下午茶」,持續爲你推送精選好文,也可以加我爲好友,隨時聊騷。

在看點這裏

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章