熟悉 Apollo GraphQL 的同學可直接跳過這一章,從 實踐 一章看起。
GraphQL 作為 FaceBook 2015年推出的 API 定義/查詢 語言,在歷經了兩年的發展之後,社群已相對發達和完善。對於 GraphQL 的一些基礎概念,本文不再一一贅述,目前社群相關的文章已經很多,有興趣的同學可以去 google,或者直接看GraphQL 官方教程 Apollo GraphQL Server 官方文件。
而 Apollo GraphQL 作為目前社群最流行的 GraphQL 解決方案提供商,提供了從 client 到 server 的一整套完整的工具鏈。在這裡我也準備以 Apollo 為例,通過一步步搭建 Apollo GraphQL Server 的方式,來給大家展示 GraphQL 的特點,以及我的一些思考(主要是我的思考)。
setup
建立基於 express 的 GraphQL server
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// server.js import express from 'express'; import { graphiqlExpress, graphqlExpress } from 'apollo-server-express'; import schema from './models'; const PORT = 8080; const app = express(); ... app.use('/graphql', graphqlExpress({ schema })); app.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' })); if (process.env.NODE_ENV === 'development') { glob(path.resolve(__dirname, './mock/**/*.js'), {}, (er, modules) => modules.forEach(module => require(module).default(app))); } app.listen(PORT, () => console.log(`> Listening at port ${PORT}`)); |
執行 node server.js
,這樣我們就能啟動一個 GraphQL server 了。
注意我們這裡使用了 apollo-server-express
提供的 graphiqlExpress
外掛,graphiql 是一個用於瀏覽器端除錯 graphql 介面的 GUI 工具。服務啟動後,我們在瀏覽器開啟 http://localhost:8080/graphiql
就可以看到這樣一個頁面
定義 API schema
我們在 server.js 中定義了這樣一個 endpoint : app.use('/graphql', graphqlExpress({ schema }));
這裡傳入的 schema 是什麼呢?它大概長這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import { makeExecutableSchema } from 'graphql-tools'; // The GraphQL schema in string form const typeDefs = ` type User { id: ID! name: String age: Int } type Query { user(id: ID!): User } schema { query: Query } `; // The resolvers const resolvers = { Query: { user({id}) { return http.get(`/users/${id}`)}} }; // Put together a schema const schema = makeExecutableSchema({ typeDefs, resolvers }); app.use('/graphql', graphqlExpress({ schema })); |
這裡的關鍵是用了 graphql-tools
這個庫提供的 makeExecutableSchema
組合了 schema 定義和對應的 resolver。resolver 是 Apollo GraphQL 工具鏈中提出的一個概念,什麼用呢?就是在我們客戶端請求過來的 schema 中的 field 如果在 GraphQL Server 中有對應的 resolver,那麼在返回資料時候,這些 field 就由對應的 resolver 的執行結果填充(支援返回 promise)。
客戶端請求
這裡藉助 graphiql 皮膚的功能來傳送請求:
看一下 http request payload 資訊:
響應體:
也就是說,無論你是用你熟悉的 http lib 還是社群的 apollo client,只要按照 GraphQL Server 要求的既定格式發請求就 ok 了。
這裡我們使用了 GraphQL 中的 variable 語法,事實上在這種需要傳參的動態查詢場景下,我們應該總是使用這種方式傳送請求:即一個 static query + variable 的方式,而不是在執行時動態的生成 query string。這也是官方建議的最佳實踐。
更復雜的巢狀查詢場景
假設我們有這樣一個場景,即我們需要取到 User Entity 下的 nick 欄位,而 nick 資料並不來自於 user 介面,而是需要根據 userId 呼叫另一個介面取得。這時候我們服務端的程式碼需要這樣寫。
1 2 3 4 5 6 7 8 |
// schema type User { id: ID! name: String age: Int nick: String } |
1 2 3 4 5 6 |
// resolver User: { nick({ id }) { return getUserNick(id); } } |
resolver 的引數列表中包含了當前所在 Entity 已有的資料,所以這裡可以直接在函式的入參裡取到已查詢出來的 userId。
看下效果:
服務端的請求:
可以看到,這裡多出了查詢 nick 的請求。也就是說,GraphQL Server 只有在客戶端提交了包含相應欄位的 query 時,才會真正去傳送相應的請求。更多 resolver 說明可以看這裡。
其他
在真實的生產環境中,我們通常會有更多更復雜的場景,比如介面的許可權認證、分頁、快取、批量提交、schema 模組化等需求,好在社群都有相對應的一些解決方案,這不是本文的重點所以不在這裡一一介紹了,有興趣的可以去看下我之前寫的 graphql-server-startkit,或者官方的 demo。
實踐
如果你真實的使用過 Apollo GraphQL,你會經歷如下過程:
- 定義一個 schema 用於描述查詢入口
123456789101112131415// schema.graphqltype User {id: ID!name: Stringnick: Stringage: Intgender: String}type Query {user(id: ID!): User}schema {query: Query} - 編寫 resolver 解析對應型別
123456789101112const resolvers = {Query: {user(root, { id }) {return getUser(id);}},User: {nick({ id }) {return getUserNick(id);}}};
- 編寫客戶端請求程式碼呼叫 GraphQL 介面,通常我們會封裝一個 get 方法
1234function getUser(id) {// 以 axios 為例return axios.post('/graphql', { query: 'query userQuery($id: ID!) {↵ user(id: $id) {↵ id↵ name↵ nick↵ }↵}', operationName: "userQuery", variables: {id}});}
如果你的專案中加入了靜態型別系統,那麼你的程式碼可能就會變成這樣:
1234567891011// 以 ts 為例interface User {id: numbername: stringnick: stringage: numbergender: string}function getUser(id: number): User {return axios.post('/graphql', { query: 'query userQuery($id: ID!) {↵ user(id: $id) {↵ id↵ name↵ nick↵ }↵}', operationName: "userQuery", variables: {id}});}
寫到這裡你可能已經發現,不僅是 entity 型別定義,就連線口的封裝,我們在服務端和客戶端都重複了一遍(雖然一個用的 GraphQL Type Language 一個用的 TS)… 這還是最簡單的場景,如果業務模型複雜起來,你在兩端需要重複的程式碼會更多(比如型別的巢狀定義和 resolve)。這時候你可能會想起 DRY 原則,然後開始思考有沒**有什麼方式可以使得型別及介面定義能兩端複用,或者根據一端的定義自動生成另一端的程式碼?**甚至你開始懷疑,到底有沒有引入 GraphQL 的必要?
思考
GraphQL 作為一個標準化並自帶型別系統的 API Layer,其工程價值我也不再過多廣告了。只是在實踐過程中,既然我們無法完全避免服務端與客戶端的實體與介面定義重複(使用 apollo-codegen 可以避免一部分),而且對於大部分小團隊而言,運維一個 productive nodejs system 實際上都是力有未逮。**那麼我們是不是可以考慮在純客戶端構建一個類 GraphQL 的 API Layer 呢?**這樣既可以有效的避免編碼重複,也能大大的降低對團隊的要求,可操作的空間也比增加一個 nodejs 中間層大得多。
我們可以回憶一下,通常對於一個前端而言,促使我們需要一個 API Layer 的原因是什麼:
- 後端介面設計不夠 restful,命名垃圾,用的時候看見那個*一樣的 url 就難受。
- 後端同學只願意寫 microservice,提供聚合服務的 web api 被認為沒有技術含量,不願意寫。你需要一個資料,他告訴你需要調 a、b、c 三個介面,然後根據 id 組裝合併。
- 介面返回的資料格式各種巢狀及不合理,不是前端想要的結構。
- 介面返回的資料欄位命名隨意或者風格不統一,我有強迫症用這種介面會發瘋。
- 後端返回的 資料格式/欄位名 一旦變了,前端檢視繫結部分的程式碼需要修改。
通常情況下,碰到這些問題,你可能去跟後端同學據理力爭,要求他們提供呼叫體驗更良好設計更優雅的介面。沒錯這很好,畢竟為了追求完美去跟各種人撕(跟後端撕、跟產品撕、跟UI撕)是一個前端工程師基本的職業素養。但是如果你每天都被撕逼弄得心力交瘁,甚至是你根本找不到撕的物件(比如資料來源介面來著幾個不同部門,甚至是一些祖傳的沒人敢動的介面),這些時候大概就是你迫切希望有一個 API Layer 的時候了。
如何在客戶端實現一個 API Layer
其實很簡單,你只需要在客戶端把 Apollo Server 中要寫的 resolvers 寫一遍,然後配上一些效能提升手段(如快取等),你的 API Layer 就完成了。
比如我們在src
下新建一個 loaders/apis
目錄,所有的資料拉取介面都放在這裡。比如這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// UserLoader.ts export interface User { id: number name: string nick: string } export default class UserLoader { async getUser(id: number): User { const base = await Promise.all([http.get('//xxx.com/users/${id}'), this.getUserNick(id)]); const user = base.reduce((acc, info) => ({...acc, ...info}), {}); return user; } getUserNick(id: number): string { return http.get(`//xxx.com/nicks/${id}`); } } |
然後在你業務需要的地方注入相應 loader 呼叫介面即可,如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { inject } from 'mmlpx'; import UserLoader from './UserLoader'; // Controller.ts export default class Controller { @inject(UserLoader) userLoader = null; async doSomething() { // ... const user = await this.userLoader.getUser(this.id); // ... } } |
如果你不喜歡依賴注入的方式,loaders/apis 層直接 export function getUser
也可以。
如果你碰到了上面描述的第 3、4 、5 三種問題,你可能還需要在這一層做一下資料格式化。比如這樣:
1 2 3 4 5 6 7 8 9 10 |
async getUser(id: number): User { const base = await Promise.all([http.get('//xxx.com/users/${id}'), this.getUserNick(id)]); const user = base.reduce((acc, info) => ({...acc, ...info}), {}); return { id: user.id, name: user.user_name, // 重新命名欄位 nick: user.nick.userNick // 剔除原始資料中無意義的層次結構 }; } |
經過這一層的資料處理,我們就能確保我們的應用執行在前端自己定義的資料模型之下。這樣之後後端介面不論是資料結構還是欄位名的變更,我們只需要在這一層做簡單調整即可,而不會影響到我們上層的業務及檢視。相應的,我們的業務層邏輯不再會直接對接介面 url,而是將其隱藏在 API Layer 下,這樣不僅能提升業務程式碼的可讀性,也能做到眼不見為淨。。。
總結
熟悉 GraphQL 的同學可能會很快意識到,我這不過是在客戶端做了一個簡單的 API 封裝嘛,並不能解決在 GraphQL 出現之前的 lots of roundtrips 及 overfetching 問題。但事實上是 roundtrip 的問題我們可以通過客戶端快取來緩解(如果你用的是 axios 你可能需要 axios-extensions ),而且 roundtrip 的問題其實本質上我們不過是將客戶端的 http 開銷轉移到服務端了而已。在客戶端與服務端均不考慮快取的情況,客戶端反而會少一個請求。。。overfetching 問題則取決於 backend service 的粒度,如果 endpoint 不夠 micro,即便是 GraphQL,也會出現介面資料冗餘問題,畢竟 GraphQL 不生產資料,它只是資料的搬運工。。。而如果 endpoint 粒度足夠小,那麼我在客戶端 API 層多開幾個介面(換成 Apollo 也要多寫幾個 resolver),一樣可以按需取資料。服務端 API Layer 只有一個不可替代的優勢就是,如果我們的資料來源介面是不支援跨域或者僅內網可見的,那麼就只能在服務端開個口子做代理了。另外一個優勢就是,GraphQL Server 的 http 開銷是可控的,畢竟機器是我們自己控制,而客戶端的環境則不可控(受限於終端裝置及網路環境,比如低版本瀏覽器或者低速網路,均會導致 http 開銷的效能權重增大)。
可能有同學會說,服務端 API Layer 部署一次任何系統都可以共享其服務,而客戶端 API Layer 的作用域只在某一專案。其實,如果我們把某一專案需要共享的 API Layer 打成一個 npm 包釋出出去,不也能達到同樣的效果嗎,很多平臺的 js sdk 不都是這個思路麼(這裡只討論 web 開發範疇)。
在我看來,不論你是否會搭建一個服務端的 API Layer,**我們其實都需要有一個客戶端 API Layer 從資料來源頭來保證客戶端資料的模型統一及一致性,從而有足夠的能力應對介面的變遷。**如果你考慮的再遠一點,在 API Layer 服務的業務模型層,我們同樣需要有一套獨立的 Service/Model Layer 來應對檢視框架的變遷。這個暫且按下不表,後面會再寫篇文字來詳細說一下我的思路。
事實上,對於大部分團隊而言,客戶端 API Layer 已經夠用了,增加一層 GraphQL 並不是那麼必要。而且如果沒有很好的支援將客戶端介面轉換成 GraphQL Schema 和 resolver 的工具時,我們並不能很愉快的 coding,畢竟兩端重複的工作還是有點多。