歡迎光臨
每天分享高質量文章

GraphQL 在微服務架構中的實踐

在過去的將近半年的時間裡,作者一直在使用 GraphQL 這門相對新興的技術開發 Web 服務,與更早出現的 SOAP 和 REST 相比,GraphQL 其實提供的是一套相對完善的查詢語言,而不是類似 REST 的設計規範,所以需要語言的生態提供相應的框架支援,但是由於從它開源至今也只有兩三年的時間,所以在使用的過程中,尤其是在微服務架構中實踐時確實還會遇到很多問題。
這篇文章中,首先會簡單介紹 GraphQL 是什麼,它能夠解決的問題;在這之後,我們會重點分析 GraphQL 在微服務架構中的使用以及在實踐過程中遇到的棘手問題,在最後作者將給出心中合理的 GraphQL 微服務架構的設計,希望能為同樣在微服務架構中使用 GraphQL 的工程師提供一定的幫助,至於給出的建議是否能夠滿足讀者在特定業務場景下的需求就需要讀者自行判斷了。
GraphQL

簡單物件訪問協議(SOAP)從今天來看已經是一門非常古老的 Web 服務技術了,雖然很多服務仍然在使用遵循 SOAP 的介面,但是到今天 REST 風格的面向資源的 API 介面已經非常深入人心,也非常的成熟;但是這篇文章要介紹的主角其實是另一門更加複雜、完備的查詢語言 GraphQL。
作為 Facebook 在 2015 年推出的查詢語言,GraphQL 能夠對 API 中的資料提供一套易於理解的完整描述,使得客戶端能夠更加準確的獲得它需要的資料,目前包括 Facebook、Twitter、GitHub 在內的很多公司都已經在生產環境使用 GraphQL 提供 API;其實無論我們是否決定生產環境中使用 GraphQL,它確實是一門值得學習的技術。
型別系統
GraphQL 的強大表達能力主要還是來自於它完備的型別系統,與 REST 不同,它將整個 Web 服務中的全部資源看成一個有連線的圖,而不是一個個資源孤島,在訪問任何資源時都可以透過資源之間的連線訪問其它的資源。
如上圖所示,當我們訪問 User 資源時,就可以透過 GraphQL 中的連線訪問當前 User 的 Repo 和 Issue 等資源,我們不再需要透過多個 REST 的介面分別獲取這些資源,只需要透過如下所示的查詢就能一次性拿到全部的結果:
{
    user {
        id
        email
        username
        repos(first: 10) {
            id
            url
            name
            issues(first: 20) {
                id
                author
                title
            }
        }
    }
}

GraphQL 這種方式能夠將原有 RESTful 風格時的多次請求聚合成一次請求,不僅能夠減少多次請求帶來的延遲,還能夠降低伺服器壓力,加快前端的渲染速度。它的型別系統也非常豐富,除了標量、列舉、串列和物件等型別之外,還支援介面和聯合型別等高階特性。
為了能夠更好的表示非空和空欄位,GraphQL 也引入了 Non-Null 等標識代表非空的型別,例如 String! 表示非空的字串。
schema {
  query: Query
  mutation: Mutation
}

Schema 中絕大多數的型別都是普通的物件型別,但是每一個 Schema 中都有兩個特殊型別:query 和 mutation,它們是 GraphQL 中所有查詢的入口,在使用時所有查詢介面都是 query 的子欄位,所有改變伺服器資源的請求都應該屬於 mutation 型別。
集中式 vs 分散式
GraphQL 以圖的形式將整個 Web 服務中的資源展示出來,其實我們可以理解為它將整個 Web 服務以 “SQL” 的方式展示給前端和客戶端,服務端的資源最終都被聚合到一張完整的圖上,這樣客戶端可以按照其需求自行呼叫,類似新增欄位的需求其實就不再需要後端多次修改了。
與 RESTful 不同,每一個的 GraphQL 服務其實對外只提供了一個用於呼叫內部介面的端點,所有的請求都訪問這個暴露出來的唯一端點。
GraphQL 實際上將多個 HTTP 請求聚合成了一個請求,它只是將多個 RESTful 請求的資源變成了一個從根資源 Post 訪問其他資源的 Comment 和 Author 的圖,多個請求變成了一個請求的不同欄位,從原有的分散式請求變成了集中式的請求,這種方式非常適合單體服務直接對外提供 GraphQL 服務,能夠在資料源和展示層建立一個非常清晰的分離,同時也能夠透過一些強大的工具,例如 GraphiQL 直接提供視覺化的檔案;但是在業務複雜性指數提升的今天,微服務架構成為瞭解決某些問題時必不可少的解決方案,所以如何在微服務架構中使用 GraphQL 提高前後端之間的溝通效率並降低開發成本成為了一個值得考慮的問題。
Relay 標準
如果說 RESTful 其實是客戶端與服務端在 HTTP 協議通訊時定義的固定標準,那麼 Relay 其實也是我們在使用 GraphQL 可以遵循的一套規範。
這種標準的出現能夠讓不同的工程師開發出較為相似的通訊介面,在一些場景下,例如標識物件和分頁這種常見的需求,引入設計良好的標準能夠降低開發人員之間的溝通成本。
Relay 標準其實為三個與 API 有關的最常見的問題制定了一些規範:
  1. 提供能夠重新獲取物件的機制;

  2. 提供對如何對連線進行分頁的描述;

  3. 標準化 mutation 請求,使它們變得更加可預測;

透過將上述的三個問題規範化,能夠極大地增加前後端對於介面制定和對接時的工作效率。

物件識別符號
Node 是 Relay 標準中定義的一個介面,所有遵循 Node 介面的型別都應該包含一個 id 欄位:
interface Node {
  id: ID!
}

type Faction : Node {
  id: ID!
  name: String
  ships: ShipConnection
}

type Ship : Node {
  id: ID!
  name: String
}

Faction 和 Ship 兩個型別都擁有唯一識別符號 id 欄位,我們可以透過該識別符號重新從服務端取回對應的物件,Node 介面和欄位在預設情況下會假定整個服務中的所有資源的 id 都是不同的,但是很多時候我們都會將型別和 id 系結到一起,組合後才能一個型別特定的 ID;為了保證 id 的不透明性,傳回的 id 往往都是 Base64 編碼的字串,GraphQL 伺服器接收到對應 id 時進行解碼就可以得到相關的資訊。

連線與分頁
在一個常見的資料庫中,一對多關係是非常常見的,一個 User 可以同時擁有多個 Post 以及多個 Comment,這些資源的數量在理論上不是有窮的,沒有辦法在同一個請求全部傳回,所以要對這部分資源進行分頁。
query {
  viewer {
    name
    email
    posts(first: 1) {
      edge {
        cursor
        node {
          title
        }
      }
    }
  }
}

Relay 透過抽象出的『連線模型』為一對多的關係提供了分片和分頁的支援,在 Relay 看來,當我們獲取某一個 User 對應的多個 Post 時,其實是得到了一個 PostConnection,也就是一個連線:
{
  "viewer": {
    "name""Draveness",
    "email""i@draveness.me",
    "posts": {
      "edges": [
        "cursor""YXJyYXljb25uZWN0aW9uOjI=",
        "node": {
          "title""Post title",
        }
      ]
    }
  }
}

在一個 PostConnection 中會存在多個 PostEdge 物件,其中的 cursor 就是我們用來做分頁的欄位,所有的 cursor 其實都是 Base64 編碼的字串,這能夠提醒呼叫方 cursor 是一個不透明的指標,拿到當前 cursor 後就可以將它作為 after 引數傳到下一個查詢中:
query {
  viewer {
    name
    email
    posts(first: 1, after: "YXJyYXljb25uZWN0aW9uOjI=") {
      edge {
        cursor
        node {
          title
        }
      }
    }
  }
}

當我們想要知道當前頁是否是最後一頁時,其實只需要使用每一個連線中的 PageInfo 物件,其中包含了很多與分頁相關的資訊,一個連線物件中一般都有以下的結構和欄位,例如:Edge、PageInfo 以及遊標和節點等。
PostConnection
├── PostEdge
│   ├── cursor
│   └── Post
└── PageInfo
    ├── hasNextPage
    ├── hasPreviousPage
    ├── startCursor
    └── endCursor

Relay 使用了非常多的功能在連線周圍構建抽象,讓我們能夠更加方便地管理客戶端中的遊標,整個連線相關的規範其實特別複雜,可以閱讀 Relay Cursor Connections Specification 瞭解更多與連線和遊標有關的設計。

可變請求
每一個 Web 服務都可以看做一個大型的複雜狀態機,這個狀態機對外提供兩種不同的介面,一種介面是查詢介面,它能夠查詢狀態機的當前狀態,而另一種介面是可以改變伺服器狀態的可變操作,例如 POST、DELETE 等請求。
按照約定,所有的可變請求都應該以動詞開頭並且它們的輸入都以 Input 結尾,與之相對應的,所有的輸出都以 Payload 結尾:
input IntroduceShipInput {
  factionId: ID!
  shipName: String!
  clientMutationId: String!
}

type IntroduceShipPayload {
  faction: Faction
  ship: Ship
  clientMutationId: String!
}

除此之外,可變請求還可以透過傳入 clientMutationId 保證請求的冪等性。

小結
Facebook 的 Relay 標準其實是一個在 GraphQL 上對於常見領域問題的約定,透過這種約定我們能夠減少工程師的溝通成本和專案的維護成本併在多人協作時保證服務對外提供介面的統一。
N + 1 問題
在傳統的後端服務中,N + 1 查詢的問題就非常明顯,由於資料庫中一對多的關係非常常見,再加上目前大多服務都使用 ORM 取代了資料層,所以在很多時候相關問題都不會暴露出來,只有真正出現效能問題或者慢查詢時才會發現。
SELECT * FROM users LIMIT 3;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;

SELECT * FROM users LIMIT 3;
SELECT * FROM posts WHERE user_id IN (123);

GraphQL 作為一種更靈活的 API 服務提供方式,相比於傳統的 Web 服務更容易出現上述問題,類似的問題在出現時也可能更加嚴重,所以我們更需要避免 N + 1 問題的發生。
資料庫層面的 N + 1 查詢我們可以透過減少 SQL 查詢的次數來解決,一般我們會將多個 = 查詢轉換成 IN 查詢;但是 GraphQL 中的 N + 1 問題就有些複雜了,尤其是當資源需要透過 RPC 請求從其他微服務中獲取時,更不能透過簡單的改變 SQL 查詢來解決。
在處理 N + 1 問題之前,我們要真正瞭解如何解決這一類問題的核心邏輯,也就是將多次查詢變成一次查詢,將多次操作變成一次操作,這樣能夠減少由於多次請求增加的額外開銷 —— 網路延遲、請求解析等;GraphQL 使用了 DataLoader 從業務層面解決了 N + 1 問題,其核心邏輯就是整個多個請求,透過批次請求的方式解決問題。
微服務架構

微服務架構在當下已經成為了遇到業務異常複雜、團隊人數增加以及高併發等需求或者問題時會使用的常見解決方案,當微服務架構遇到 GraphQL 時就會出現很多理論上的碰撞,會出現非常多的使用方法和解決方案。
在這一節中,我們將介紹在微服務架構中使用 GraphQL 會遇到哪些常見的問題,對於這些問題有哪些解決方案需要權衡,同時也會分析 GraphQL 的設計理念在融入微服務架構中應該註意什麼。
當我們在微服務架構中融入 GraphQL 的標準時,會遇到三個核心問題,這些問題其實主要是從單體服務遷移到微服務架構這種分散式系統時引入的一系列技術難點,這些技術難點以及選擇之間的折衷是在微服務中實踐 GraphQL 的關鍵。
Schema 設計
GraphQL 獨特的 Schema 設計其實為整個服務的架構帶來了非常多的變數,如何設計以及暴露對外的介面決定了我們內部應該如何實現使用者的認證與鑒權以及路由層的設計。
從總體來看,微服務架構暴露的 GraphQL 介面應該只有兩種;一種介面是分散式的,每一個微服務對外暴露不同的端點,分別對外界提供服務。
在這種情況下,流量的路由是根據使用者請求的不同服務進行分發的,也就是我們會有以下的一些 GraphQL API 服務:
http://draveness.me/posts/api/graphql
http://draveness.me/comments/api/graphql
http://draveness.me/subscriptions/api/graphql

我們可以看到當前部落格服務總共由內容、評論以及訂閱三個不同的服務來提供,在這時其實並沒有充分利用 GraphQL 服務的好處,當客戶端或前端同時需要多個服務的資源時,需要分別請求不同服務上的資源,並不能透過一次 HTTP 請求滿足全部的需求。
另一種方式其實提供了一種集中式的介面,所有的微服務對外共同暴露一個端點,在這時流量的路由就不是根據請求的 URL 了,而是根據請求中不同的欄位進行路由。
這種路由的方式並不能夠透過傳統的 nginx 來做,因為在 nginx 看來整個請求其實只有一個 URL 以及一些引數,我們只有解析請求引數中的查詢才能知道客戶端到底訪問了哪些資源。
http://draveness.me/api/graphql

請求的解析其實是對一顆樹的解析,這部分解析其實是包含業務邏輯的,在這裡我們需要知道的是,這種 Schema 設計下的請求是按照 field 進行路由的,GraphQL 其實幫助我們完成瞭解析查詢樹的過程,我們只需要對相應欄位實現特定的 Resolver 處理傳回的邏輯就可以了。
然而在多個微服務提供 Schema 時,我們需要透過一種機制將多個服務的 Schema 整合起來,這種整合 Schema 的思路最重要的就是需要解決服務之間的重覆資源和衝突欄位問題,如果多個服務需要同時提供同一個型別的基礎資源,例如:User 可以從多種資源間接訪問到。

{
  post(id1) {
    user {
      id
      email
    }
    id
    title
    content
  }

作為微服務的開發者或者提供方來講,不同的微服務之間的關係是平等的,我們需要一個更高階別或者更面向業務的服務對提供整合 Schema 的功能,確保服務之間的欄位與資源型別不會發生衝突。

字首
如何解決衝突資源從目前來看有兩種不同的方式,一種是為多個服務提供的資源新增名稱空間,一般來說就是字首,在合併 Schema 時,透過新增字首能夠避免不同服務出現重覆欄位造成衝突的可能。
SourceGraph 在實踐 GraphQL 時其實就使用了這種增加字首的方式,這種方式的實現成本比較低,能夠快速解決微服務中 Schema 衝突的問題,讀者可以閱讀 GraphQL at massive scale: GraphQL as the glue in a microservice architecture 一文瞭解這種做法的實現細節;這種增加字首解決衝突的方式優點就是開發成本非常低,但是它將多個服務的資源看做孤島,沒有辦法將多個不同服務中的資源關係串聯起來,這對於中心化設計的 GraphQL 來說其實會造成一定體驗上的丟失。

粘合
除了增加字首這種在工程上開發成本非常低的方法之外,GraphQL 官方提供了一種名為 Schema Stitching 的方案,能夠將不同服務的 GraphQL Schema 粘合起來並對外暴露統一的介面,這種方式能夠將多個服務中的不同資源粘合起來,能夠充分利用 GraphQL 的優勢。
為了打通不同服務之間資源的壁壘、建立合理並且完善的 GraphQL API,我們其實需要付出一些額外的工作,也就是在上層完成對公共資源的處理;當對整個 Schema 進行合併時,如果遇到公共資源,就會選用特定的 Resolver 進行解析,這些解析器的邏輯是在 Schema Stitching 時指定的。
const linkTypeDefs = `
  extend type User {
    chirps: [Chirp]
  }
`
;

我們需要在服務層上的業務層對服務之間的公共資源進行定義,併為這些公共資源建立新的 Resolver,當 GraphQL 解析當公共資源時,就會呼叫我們在合併 Schema 時傳入的 Resolver 進行解析和處理。
const mergedSchema = mergeSchemas({
  schemas: [
    chirpSchema,
    authorSchema,
    linkTypeDefs,
  ],
  resolvers: {
    User: {
      chirps: {
        fragment: `... on User { id }`,
        resolve(user, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: chirpSchema,
            operation: 'query',
            fieldName: 'chirpsByAuthorId',
            args: {
              authorId: user.id,
            },
            context,
            info,
          });
        },
      },
    },
  },
});

在整個 Schema Stitching 的過程中,最重要的方法其實就是 mergeSchemas,它總共接受三個引數,需要粘合的 Schema 陣列、多個 Resolver 以及型別出現衝突時的回呼:
mergeSchemas({
  schemas: Array<string | GraphQLSchema | Array>;
  resolvers?: Array | IResolvers;
  onTypeConflict?: (
    left: GraphQLNamedType,
    right: GraphQLNamedType,
    info?: {
      left: {
        schema?: GraphQLSchema;
      };
      right: {
        schema?: GraphQLSchema;
      };
    },
  ) => GraphQLNamedType;
})

Schema Stitching 其實是解決多服務共同對外暴露 Schema 時比較好的方法,這種粘合 Schema 的方法其實是 GraphQL 官方推薦的做法,同時它們也為使用者提供了 JavaScript 的工具,但是它需要我們在合併 Schema 的地方手動對不同 Schema 之間的公共資源以及衝突型別進行處理,還要定義一些用於解析公共型別的 Resolver;除此之外,目前 GraphQL 的 Schema Stitching 功能對於除 JavaScript 之外的語言並沒有官方的支援,作為一個承載了服務發現以及流量路由等功能的重要元件,穩定是非常重要的,所以應該慎重考慮是否應該自研用於 Schema Stitching 元件。

組合
除了上述的兩種方式能夠解決對外暴露單一 GraphQL 的問題之外,我們也可以使用非常傳統的 RPC 方式組合多個微服務的功能,對外提供統一的 GraphQL 介面:
當我們使用 RPC 的方式解決微服務架構下 GraphQL Schema 的問題時,內部的所有服務元件其實與其他微服務架構中的服務沒有太多區別,它們都會對外提供 RPC 介面,只是我們透過另一種方式 GraphQL 整合了多個微服務中的資源。
使用 RPC 解決微服務中的問題其實是一個比較通用同時也是比較穩定的解決方案,GraphQL 作為一種中心化的介面提供方式,透過 RPC 呼叫其他服務的介面併進行合併和整合其實也是一個比較合理的事情;在這種架構下,我們其實可以在提供 GraphQL 介面的情況下,也讓各個微服務直接或者透過其他業務元件對外暴露 RESTful 介面,提供更多的接入方式。
雖然 RPC 的使用能為我們的服務提供更多的靈活性,同時也能夠將 GraphQL 相關的功能拆分到單獨的服務中,但是這樣給我們帶來了一些額外的工作量,它需要工程師手動拼接各個服務的介面並對外提供 GraphQL 服務,在遇到業務需求變更時也可能會導致多個服務的修改和更新。

小結
從使用字首、粘合到使用 RPC 組合各個微服務提供的介面,對外暴露的 Schema 其實是一個由點到面逐漸聚合的過程,同時實現的複雜度也會逐步上升。
在這三種方式中,作者並不推薦使用字首的方式隔離多個微服務提供的介面,這種做法並沒有充分利用 GraphQL 的好處,不如使用 RESTful 將多個服務的介面直接解耦,使用 GraphQL 反而是有一些濫用的感覺。
除了使用字首的做法之外,無論是粘合還是組合都能夠提供一個完整的 GraphQL 介面,它們兩者都需要在直接對接使用者的 GraphQL 服務中對各個微服務提供的介面進行整合,當我們使用 Schema Stitching 時,其實對後面的服務提出了更多的要求 —— 開發微服務的工程師需要掌握 GraphQL Schema 的設計與開發方法,與此同時,各個微服務之間的型別也可能出現衝突,需要在上層進行解決,不過這也減少了一些最前面的 GraphQL 服務的工作量。
在最後,使用組合方式就意味著整個架構中的 GraphQL 服務需要透過組合 RPC 的方式處理與 GraphQL 相關的全部邏輯,相當於把 GraphQL 相關的全部邏輯都抽離到了最前面。
經過幾次架構的重構之後,在微服務架構中,作者更傾向於使用 RPC 組合各個微服務功能的方式提供 GraphQL 介面,雖然這樣帶來了更多的工作量,但是卻能擁有更好的靈活性,也不需要其他微服務的開發者瞭解 GraphQL 相關的設計規範以及約定,讓各個服務的職責更加清晰與可控。
認證與授權
在一個常見的 Web 服務中,如何處理使用者的認證以及鑒權是一個比較關鍵的問題,因為我們需要瞭解在使用 GraphQL 的服務中我們是如何進行使用者的認證與授權的。
如果我們決定 Web 服務作為一個整體對外暴露的是 GraphQL 的介面,那麼在很大程度上,Schema 設計的方式決定了認證與授權應該如何組織;作為一篇介紹 GraphQL 在微服務架構中實踐的文章,我們也自然會介紹在不同 Schema 設計下,使用者的認證與授權方式應該如何去做。
上一節中總共提到了三種不同的 Schema 設計方式,分別是:字首、粘合和組合,這些設計方式在最後都會給出一個如下所示的架構圖:
使用 GraphQL 的所有結構最終都會由一個中心化的服務對外接受來自客戶端的 GraphQL 請求,哪怕它僅僅是一個代理,當我們有了這張 GraphQL 服務的架構圖,如何對使用者的認證與授權進行設計就變得非常清晰了。

認證
首先,使用者的認證在多個服務中分別實現是大不合理的,如果需要在多個服務中處理使用者認證相關的邏輯,相當於將一個服務的職責同時分給了多個服務,這些服務需要共享使用者認證相關的表,users、sessions 等等,所以在整個 Web 服務中,由一個服務來處理使用者認證相關的邏輯是比較合適的。
這個服務既可以是作為閘道器代理的 GraphQL 服務本身,也可以是一個獨立的使用者認證服務,在每次使用者請求時都會透過 RPC 或者其他方式呼叫該服務提供的介面對使用者進行認證,使用者的授權功能與認證就有一些不同了。

授權
我們可以選擇在 GraphQL 服務中增加授權的功能,也可以選擇在各個微服務中判斷當前使用者是否對某一資源有許可權進行操作,這其實是集中式跟分散式之間的權衡,兩種方式都有各自的好處,前者將鑒權的權利留給了各個微服務,讓它們進行自治,根據其業務需要判斷請求者是否可以訪問後者修改資源,而後者其實把整個鑒權的過程解耦了,內部的微服務無條件的信任來自 GraphQL 服務的請求並提供所有的服務。
上面的設計其實都是在我們只需要對外提供一個 GraphQL 端點時進行的,當業務需要同時提供 B 端、C 端或者管理後臺的介面時,設計可能就完全不同了。
在這時,如果我們將鑒權的工作分給多個內部的微服務,每個服務都需要對不同的 GraphQL 服務(或者 Web 服務)提供不同的介面,然後分別進行鑒權;但是將鑒權的工作交給 GraphQL 服務就是一種比較好的方式了,內部的微服務不需要關心呼叫者是否有許可權訪問該資源,鑒權都由最外層的業務服務來處理,實現了比較好的解耦。
當然,完全的信任其他服務的呼叫其實是一個比較危險的事情,對於一些重要的業務或者請求呼叫可以透過外部的風控系統進行二次檢查判斷當前請求方呼叫的合法性。
如何實現一個完備並且有效的風控系統並不是這篇文章想要主要介紹的內容,讀者可以尋找相關的資料瞭解風控系統的原理以及模型。

小結
認證與授權的設計本來是系統中一件比較靈活的事情,無論我們是否在微服務架構中使用 GraphQL 作為對外的介面,將這部分邏輯交由直接對外暴露的服務是一種比較好的選擇,因為直接對外暴露的服務中掌握了更多與當前請求有關的背景關係,能夠更容易地對來源使用者以及其許可權進行認證,而重要或者高危的業務操作可以透過額外增加風控服務管理風險,或者在路由層對 RPC 的呼叫方透過白名單進行限制,這樣能夠將不同的功能解耦,減少多個服務之間的重覆工作。
路由設計
作為微服務中非常重要的一部分,如何處理路由層的設計也是一個比較關鍵的問題;但是與認證與鑒權相似的是,Schema 的設計最終其實就決定了請求的路由如何去做。
GraphQL Schema Stitching 其實已經是一套包含路由系統的 GraphQL 在微服務架構的解決方案了,它能夠在閘道器伺服器 Resolve 請求時,透過 HTTP 協議將對應請求的片段交由其他微服務進行處理,整個過程不需要手動介入,只有在型別出現衝突時會執行相應的回呼。
而組合的方式其實就相當於要手動實現 Schema Stitching 中轉發請求的工作了,我們需要在對外暴露的 GraphQL 服務中實現相應欄位的解析器呼叫其他服務提供的 HTTP 或者 RPC 介面取到相應的資料。
在 GraphQL 中的路由設計其實與傳統微服務架構中的路由設計差不多,只是 GraphQL 提供了 Stitching 的相關工具用來粘合不同服務中的 Schema 並提供轉發服務,我們可以選擇使用這種粘合的方式,也可以選擇在 Resolver 中透過 HTTP 或者 RPC 的方式來自獲取使用者請求的資源。
架構的演進

從今年年初選擇使用 GraphQL 作為服務對外暴露的 API 到現在大概有半年的事件,服務的架構也在不斷演進和改變,在這個過程中確實經歷了非常多的問題,也一次一次地對現有的服務架構進行調整,整個演進的過程其實可以分為三個階段,從使用 RPC 組合的方式到 Schema Stitching 最後再回到使用 RPC。
雖然在整個架構演進的過程中,最開始和最終選擇的技術方案雖然都是使用 RPC 進行通訊,但是在實現的細節上卻有著很多的不同以及差異,這也是我們在業務變得逐漸複雜的過程發現的。
中心化 Schema 與 RPC
當整個專案剛剛開始啟動時,其實就已經決定了使用微服務架構進行開發,但是由於當時選擇使用的技術棧是 Elixir + Phoenx,所以很多基礎設施並不完善,例如 gRPC 以及 Protobuf 就沒有官方版本的 Elixir 實現,雖然有一些開源專案作者完成的專案,但是都並不穩定,所以最終決定了在 RabbitMQ 上簡單實現了一個基於訊息佇列的 RPC 框架,並透過組合的方式對外提供 GraphQL 的介面。
RabbitMQ 在微服務架構中承擔了訊息匯流排的功能,所有的 RPC 請求其實都被轉換成了訊息佇列中的訊息,服務在呼叫 RPC 時會向 RabbitMQ 對應的佇列投遞一條訊息並持續監聽訊息的回呼,等待其他服務的響應。
這種做法的好處就是 RabbitMQ 中的佇列承擔了『服務發現』的職能,透過佇列的方式將請求方與服務方解耦,對 RPC 請求進行路由,所以下游的消費者(服務方)可以水平擴充套件,但是這種方式其實也可以由負載均衡來實現,雖然負載均衡由於並不清楚服務方的負載,所以在轉發請求時的效果可能沒有服務方作為消費者主動拉的效率高。
最關鍵的問題是,手搓的 RPC 框架作為基礎服務如果沒有經過充分的測試以及生產環境的考驗是不成熟的,而且作為語言無關的一種呼叫方式,我們可能需要為很多語言同時實現 RPC 框架,這其實就帶來了非常高的人力、測試和維護成本,現在來看不是一個非常可取的方法。
如果我們拋開語言不談,在一個比較成熟的語言中使用 RPC 的方式進行通訊,確實能降低很多開發和維護的成本,但是也有另外一個比較大的代價,當業務並不穩定需要經常變更時,內部服務會經常為對外暴露的 RPC 介面新增額外的欄位,而這也會要求最前面的 GraphQL 服務做額外的工作:
每一次服務的修改都會導致三個相關服務或倉庫進行更新,這雖然是在微服務架構中是一件比較正常合理的事情,但是在專案的早期階段這會導致非常多額外的工作量,這也是我們進行第一次架構遷移的主要原因。
去中心化管理的 Schema
這裡的去中心化其實並不是指 GraphQL 對外暴露多個端點,而是指 GraphQL 不同 field 的開發過程去中心化,為瞭解決中心化的 Schema 加上 RPC 帶來的開發效率問題並且實踐 GraphQL 官方提供的 Schema Stitching 解決方案,我們決定將 Schema 的管理去中心化,由各個微服務對外直接暴露 GraphQL 請求,同時將多個服務的 Schema 進行合併,以此來解決開發的效率問題。
使用 Schema Stitching 的方式能夠將多個服務中不同的 GraphQL Schema 粘合成一個更大的 Schema,這種架構下最關鍵的元件就是用於 Schema 粘合的工具,在上面已經說到,除了 Javascript 之外的其他語言並沒有官方的工具支援,也沒有在生產環境中大規模使用,同時因為我們使用的也是一個比較小眾的語言 Elixir,所以更不存在一個可以拆箱即用的工具了。
經過評估之後,我們決定在 GraphQL Elixir 實現 Absinthe 上進行一層包裝,並對客戶端的請求進行語法與語意的解析,將欄位對應的樹包裝成子查詢傳送給下游的服務,最終再由最前面的 GraphQL 服務組合起來:
GraphQL 前端服務總共包含兩個核心元件,分別是 GraphQL Stitcher 和 Dispatcher,其中前者負責向各個 GraphQL 服務請求 IntrospectionQuery 並將獲得的所有 Schema 粘合成一顆巨大的樹;當客戶端進行請求時,Graphql Dispatcher 會透過語法解析當前的請求,並將其中不同的欄位以及子欄位轉換成樹後轉發給對應的服務。
在實現 GraphQL Stitcher 的過程中,需要格外註意不同服務之間型別衝突的情況,我們在實現的過程中並沒有支援型別衝突以及跨服務資源的問題,而是採用了改寫的方式,這其實有很大的問題,內部的 GraphQL 服務其實並不知道整個 Schema 中有哪些型別是已經被使用的,所以經常會造成服務之間的型別衝突,我們只有在發現時手動增加字首來解決衝突。
增加字首是一個比較容易的解決衝突的辦法,但是卻並不是特別的優雅,使用這種方式的主要原因是,我們發現了由於許可權系統的設計缺陷 —— 在引入 B 端使用者時無法優雅的實現鑒權,所以選擇使用一種比較簡單的辦法臨時解決型別衝突的問題。
在開發各種內部服務時,我們透過 scope 的方式對使用者是否有許可權讀寫資源做了限制,內部服務在執行操作前會先檢查請求的使用者是否能夠讀寫該資源,然後開始處理真正的業務邏輯,也就是說使用者鑒權是發生在所有的內部服務中的。
當我們對外暴露的 GraphQL 服務僅僅是面向 C 端使用者的時候,使用 scope 並且讓內部服務進行鑒權其實能夠滿足 C 端對於介面的需求,但是當我們需要同時為 B 端使用者提供 GraphQL 或者 RESTful 介面時,這種鑒權方式其實就非常尷尬了。
在微服務架構中,由於各個服務之間的資料庫是隔離的,對於一條資料庫記錄來說,很多內部服務都只能知道當前記錄屬於哪個使用者或者那些使用者,所以對於 scope 來說傳遞資源、讀寫請求加上來源使用者就能夠讓處理請求的服務判斷當前的來源使用者是否有許可權訪問該條記錄。
這種結論基於我們做的一條假設 —— 微服務收到的所有請求其實都要求讀寫來源使用者擁有的資源,所以在引入 B 端使用者時就遇到了比較大的困難,我們採用的臨時解決方案就是在當前使用者的 scope 中新增一些額外的資訊併在內部服務中新增新的介面滿足 B 端查詢的需要,但是由於 B 端對於資源的查詢要求可能非常多樣,當我們需要為不同的查詢介面進行不同的許可權限制,並且在 B 端使用者也不能訪問全部使用者的資源時,scope 的方式就很難表現這種複雜的鑒權需求。
在這種 Schema 管理去中心化的架構中,我們遇到了兩個比較重要的問題:
  1. 用於 Schema Stitching 的元件對於 Elixir 語言並沒有官方或者大型開源專案的支援,手搓的元件在承載較大的服務負載時會有很大的壓力,同時功能也有非常多不完善的地方;

  2. 在內部服務對於整個請求沒有太多背景關係的情況下,一旦遇到複雜的鑒權需求時,將鑒權交給內部服務的的設計方式會導致服務之間的耦合增加 —— 微服務之間需要不斷傳遞請求的背景關係用於鑒權,同時也增加了開發的成本;


服務網格與 RPC
使用去中心化管理的 Schema 雖然在一定程度上減少了開發的工作,但是在這種架構下我們也遇到了兩個不能接受的問題,為瞭解決這些問題,我們準備對當前的技術架構做出以下的修改,讓各個服務能夠更加靈活的通訊:
最新的架構設計中,我們使用 linkerd 來處理服務之間的通訊,所有的內部服務不在獨立對來源請求進行鑒權,它們只負責對外提供 RPC 介面,在這裡使用 gRPC 和 Protobuf 對不同服務的介面進行管理,所有的鑒權都發生在最外層的 Web 服務中,面向 C 端使用者的 GraphQL 服務以及面向 B 端使用者的 Web 服務,分別會對來源的請求進行鑒權,透過鑒權後再向對應服務發起 RPC 請求,請求的路由和流量的轉發都由 linkerd 完成。
linkerd 是服務網格(Service Mesh)技術的一個實現,它是一個開源的網路代理,能夠在不改變現有服務的基礎上為服務提供服務發現、管理、監控等功能,我們在這篇文章中並不會展開介紹服務網格這門技術,有興趣的讀者可以查詢相關的資料。
由於面向 B 端使用者可能涉及到較多的查詢請求,並且這些請求非常複雜,我們可以選擇使用從庫的方式同步其他服務的資料,在服務內部實現相應的查詢功能,當然也可以使用資料中心或者倉庫的方式將資料處理後提供給面向 B 端使用者的外部服務。
這種服務組織方式其實更像是對第一版架構的修改,透過引入 linkerd 解決服務發現、路由以及治理的問題,將一些微服務通用的基礎設施交給相對成熟的開源專案負責,而鑒權邏輯被上移到了幾個直接對外暴露的 Web 服務中,內部的服務不再承擔鑒權的工作,雖然在這時依然會存在一次服務介面的改動,會導致多處進行修改的問題,但是從現在來看這是為了保持服務的靈活帶來的代價。
總結

從剛開始使用 GraphQL 到現在已經過去了將近半年的時間,在微服務中實踐 GraphQL 的過程中,我們發現了微服務與 GraphQL 之間設計思路衝突的地方,也就是去中心化與中心化。
作為一門中心化的查詢語言,GraphQL 在最佳實踐中應該只對外暴露一個端點,這個端點會包含當前 Web 服務應該提供的全部資源,並把它們合理的連線成圖,但是微服務架構恰恰是相反的思路,它的初衷就是將大服務拆分成獨立部署的服務,所以在最後對架構進行設計時,我們分離了這兩部分的邏輯,使用微服務架構對服務進行拆分,透過 GraphQL 對微服務介面進行組合併完成鑒權功能,同時滿足了兩種不同設計的需求。
到最後,我們會發現在微服務架構中,GraphQL 其實只是整個鏈路中的一環,或許官方提供的一些工具與微服務中的一些問題有關,但是從整個架構來看對外是否使用 GraphQL 其實不是特別的重要,將服務之間的職責進行解耦並對外提供合理的接口才是最關鍵的,只要架構上的設計合理,我們可以隨時引入一個 GraphQL 服務來組合其他服務的功能。
在架構演進的過程中,我們遇到了很多設計不合理的地方,也因為沒有預見到業務擴充套件帶來需求改動,由此導致架構上無法優雅地實現新的需求;最後選擇使用服務網格(Service Mesh)的方式對現有的架構進行重構,也是因為微服務治理相關的事情應該由統一的中間層來做,自己重新實現服務治理相關的邏輯成本也非常高,使用服務網格已經與 GraphQL 沒有太多的聯絡了,GraphQL 服務也只是作為一個對外暴露的端點組合內部服務提供的介面,我們也可以將介面換成 RESTful 或者其他形式,這對於整體的架構設計沒有太多的影響;回過頭來看,當專案剛剛啟動時不應該將 GraphQL 介面擺在一個特別重要的位置上,劃分服務之間的邊界併進行合理的解耦才是影響比較深遠的事情。
原文連結:https://draveness.me/graphql-microservice

Kubernetes專案實戰訓練營

Kubernetes專案實戰訓練將於2018年8月17日在深圳開課,3天時間帶你係統掌握Kubernetes本次培訓包括:Docker介紹、Docker映象、網路、儲存、容器安全;Kubernetes架構、設計理念、常用物件、網路、儲存、網路隔離、服務發現與負載均衡;Kubernetes核心元件、Pod、外掛、微服務、雲原生、Kubernetes Operator、叢集災備、Helm等,點選下方圖片檢視詳情。

贊(0)

分享創造快樂