邊緣計算:讓 CDN 成為高性能 GraphQL 網(wǎng)關(guān)

邊緣程序(EdgeRoutine,簡稱ER)是一個運行在阿里云全球邊緣節(jié)點上的JavaScript代碼運行環(huán)境,支持ES6語法和標準的Web Service Worker API。用戶可以將自行開發(fā)的JavaScript代碼發(fā)布至全球邊緣程序運行,在全球邊緣節(jié)點上就近地處理客戶端的請求。
ER可以適用于圖文頁面渲染、灰度/AB測試、海量日志的清洗和匯聚、源站可用性健康檢查、IoT場景數(shù)據(jù)清洗、GEO打點,甚至托管個人站點等各類場景,具有非常大的想象和應用空間。
本文主要講述ER在邊緣網(wǎng)關(guān)上的應用實踐,GraphQL充分利用 CDN 的特性,將查詢類請求分散到遍布全球的節(jié)點中,顯著降低主應用程序的 QPS。
作者:淘系前端團隊的亨睿,以下為全文:

1.1 GraphQL 作為網(wǎng)關(guān)層
如果你對 GraphQL 還不了解,可以通過我們團隊的講座和文章進一布了解:
官方網(wǎng)站
GraphQL 官網(wǎng)(https://graphql.cn/)
Apollo 官網(wǎng)(https://apollographql.com/)
講座與 Demo(第 3 場)
用 GraphQL 控制你的特斯拉真車
https://www.yuque.com/zaotalk/posts/s9
技術(shù)文章
聊聊 GraphQL 和 Apollo 的工作流
https://zhuanlan.zhihu.com/p/115068436
還在用 Redux,要不要試試 GraphQL 和 Apollo?
https://zhuanlan.zhihu.com/p/34238617
TypeScript + GraphQL = TypeGraphQL
https://zhuanlan.zhihu.com/p/56516614
基于 GraphQL 的數(shù)據(jù)導出
https://zhuanlan.zhihu.com/p/46141806
通過我們團隊 4 年的持續(xù)努力,現(xiàn)如今在 CCO 技術(shù)部,GraphQL 已經(jīng)成為了 API 對內(nèi)對外描述、暴露及調(diào)用的唯一標準。而在國外,F(xiàn)acebook、Netflix、Github、Paypal、微軟、大眾、沃爾瑪?shù)绕髽I(yè)也在大規(guī)模使用 GraphQL 中,甚至讓以 GraphQL 為生的 Apollo 公司成功拿下了 1.3 億美元的 D 輪融資。在面向全球前端開發(fā)者調(diào)研問卷中,GraphQL 也成為最受關(guān)注的技術(shù)和最想學習的技術(shù)。Github 上有一份持續(xù)更新的 GraphQL 公開服務列表。

我們認為 GraphQL 最適合的場景莫過于作為 BFF(Backend for Frontend)的網(wǎng)關(guān)層,即根據(jù)客戶端的實際需要,將后端的原始 HSF 接口、第三方 RESTful 接口進行整合和封裝形成自己的 Service Fa?ade 層。GraphQL 自身的特性由、使得其非常容易與 RESTful、MTOP/MOPEN 等基于 HTTP 的現(xiàn)有網(wǎng)關(guān)進行集成,而另一方面,在國外很多文章中都提到 GraphQL 非常適合作為 Serverless/FaaS 的網(wǎng)關(guān)層,你甚至只需要唯一一個 HTTP Trigger 就能實現(xiàn)代理所有背后的 API。
1.2 GraphQL 網(wǎng)關(guān)與 CDN 邊緣計算
EdgeRoutine 邊緣計算 是阿里云 CDN 團隊推出的新一代 Serverless 計算平臺,它提供了一個類似 W3C 標準的 ServiceWorker 容器,可以充分利用 CDN 遍布全球的節(jié)點空閑計算資源以及強大的加速與緩存能力,實現(xiàn)高可用性、高性能的分布式彈性計算。
在 1.1 節(jié)中我們提到 GraphQL 非常適合作為 BFF 網(wǎng)關(guān)層,而結(jié)合電商后臺業(yè)務的特點我們發(fā)現(xiàn):
Query 類的請求占了大量的比例,而這些只讀類查詢請求,通常響應結(jié)果在相當長的時間范圍甚至是永遠都不會發(fā)生變化,盡管如此,每一次 API 調(diào)用時我們還是將請求發(fā)送到了后端的應用 / 服務器上。
這讓我們產(chǎn)生了一個全新的思路:

如上圖所示,將 CDN EdgeRoutine 作為 GraphQL Query 類請求的代理層,首次執(zhí)行 Query 時,我們將請求先從 CDN 代理到 GraphQL 網(wǎng)關(guān)層,再通過網(wǎng)關(guān)層代理到實際的應用服務(例如通過 HSF 調(diào)用),然后將獲得的返回結(jié)果緩存在 CDN 上,之后的請求可以根據(jù) TTL 業(yè)務規(guī)則動態(tài)決定走緩存還是去 GraphQL 網(wǎng)關(guān)層。這樣我們可以充分利用 CDN 的特性,將查詢類請求分散到遍布全球的節(jié)點中,顯著降低主應用程序的 QPS。
Apollo GraphQL Server 是目前使用最廣泛的開源 GraphQL 服務,它的 Node.js 版本 更是被 BFF 類應用廣為使用。但是遺憾的是 apollo-server 是一個面向 Node.js 技術(shù)棧開發(fā)的項目,而前文中提到 EdgeRoutine 提供的是一個類似 Service Worker 的 Serverless 容器,因此我們首先需要做的就是將 apollo-server-core 移植到 EdgeRoutine 中。為此,我開發(fā)了 apollo-server-edge-routine,本章節(jié)將簡述設計和實現(xiàn)思路。
2.1 構(gòu)建 TypeScript 開發(fā)環(huán)境和腳手架
首先,我們需要構(gòu)建一個 EdgeRoutine 容器的 TypeScript 環(huán)境,此前我已經(jīng)開發(fā)了 EdgeRoutine TypeScript 描述和 EdgeRoutine TypeScript 腳手架及本地模擬器(在 EdgeRoutine 正式上線后,我會開源到 Github 上),因此可以快速構(gòu)建一個本地開發(fā)環(huán)境。這里簡單解釋一下,我實際上是用 Service Worker 的 TypeScript 庫來模擬編譯時環(huán)境,同時將 Webpack 作為本地調(diào)試服務器,并用瀏覽器的 Service Worker 來模擬運行 edge.js 腳本,用 Webpack 的 socket 通訊實現(xiàn) Hot Reload 效果。
2.2 為 EdgeRoutine 環(huán)境實現(xiàn)自己的 ApolloServer
Apollo 官方似乎并沒有給出如何移植 Apollo Server 的文檔,不過簡單研究了一下 ApolloServerBase 的代碼,不難發(fā)現(xiàn)其實它已經(jīng)是一個功能完備的服務器了,只是缺少與 HTTP 服務器的連接。因此,我們只要集成該類,并實現(xiàn)一個自己的 listen(path: string) 方法即可,這里的 listen() 方法與傳統(tǒng) HTTP 服務器不同,我們需要指定的不是 port 而是一個 path,也就是需要偵聽 GraphQL 請求的路徑。下面是我實現(xiàn)的一個簡單版本:
import { ApolloServerBase } from 'apollo-server-core';import { handleGraphQLRequest } from './handlers';/*** Apollo GraphQL Server 在 EdgeRoutine 上的實現(xiàn)。*/export class ApolloServer extends ApolloServerBase {/*** 在指定的路徑上,偵聽 GraphQL Post 請求。* @param path 指定要偵聽的路徑。*/async listen(path = '/graphql') {// 如果在未調(diào)用 `start()` 方法前,錯誤的先使用了 `listen()` 方法,則拋出異常。this.assertStarted('listen');// addEventListenr('fetch', (FetchEvent) => void) 由 EdgeRoutine 提供。addEventListener('fetch', async (event: FetchEvent) => {// 偵聽 EdgeRoutine 的所有請求。const { request } = event;if (request.method === 'POST') {// 只處理 POST 請求const url = new URL(request.url);if (url.pathname === path) {// 當路徑相符合時,將請求交給 `handleGraphQLRequest()` 處理const options = await this.graphQLServerOptions();event.respondWith(handleGraphQLRequest(this, request, options));}}});}}
接下來,我們需要實現(xiàn)核心的 handleGraphQLRequest() 方法,該方法實際上是一個通道模式,負責將 HTTP 請求轉(zhuǎn)換成 GraphQL 請求發(fā)送到 Apollo Server,并將其返回的 GraphQL 響應轉(zhuǎn)換回 HTTP 響應。Apollo 官方其實是有一個名為 runHttpQuery() 的類似方法,但是該方法用到了 buffer 等 Node.js 環(huán)境內(nèi)置的模塊,因此無法在 Service Worker 環(huán)境中編譯通過。這里給出一個我自己的簡單實現(xiàn):
import { GraphQLOptions, GraphQLRequest } from 'apollo-server-core';import { ApolloServer } from './ApolloServer';/*** 從 HTTP 請求中解析出 GraphQL 查詢并執(zhí)行,再將執(zhí)行的結(jié)果返回。*/export async function handleGraphQLRequest(server: ApolloServer,request: Request,options: GraphQLOptions,): Promise<Response> {let gqlReq: GraphQLRequest;try {// 從 HTTP request body 中解析出 JSON 格式的請求。// 該請求是一個 GraphQLRequest 類型,包含 query、variables、operationName 等。gqlReq = await request.json();} catch (e) {throw new Error('Error occurred when parsing request body to JSON.');}// 執(zhí)行 GraphQL 操作請求。// 當執(zhí)行失敗時不會拋出異常,而是返回一個包含 `errors` 的響應。const gqlRes = await server.executeOperation(gqlReq);const response = new Response(JSON.stringify({ data: gqlRes.data, errors: gqlRes.errors }), {// 永遠確保 content-type 為 JSON 格式。headers: { 'content-type': 'application/json' },});// 將 GraphQLResponse 中的消息頭復制到 HTTP Response 中。for (const [key, value] of Object.entries(gqlRes.http.headers)) {response.headers.set(key, value);}return response;}
3.1 我們要做什么
在這個 Demo 里,我們假設要對第三方天氣服務進行二次封裝。我們將會為天氣 API 網(wǎng)(tianqiapi.com)開發(fā)一個 GraphQL CDN 代理網(wǎng)關(guān)。天氣 API 網(wǎng)對免費用戶的 QPS 有一定的限制,每天只能 300 次查詢,既然天氣預報一般變化頻率較低,我們假設希望在首次查詢某一個城市天氣的時候,將會真正訪問到天氣 API 網(wǎng)的服務,而此后的同一城市天氣查詢將走 CDN 緩存。
3.2 天氣 API 網(wǎng)接口簡介
天氣 API 網(wǎng)(tianqiapi.com)對外提供商業(yè)級的天氣預報服務,據(jù)說每天有千萬級的 QPS。這里也可以設想一下如果它們使用 GraphQL 來定義、暴露 API 接口將會帶來多大的便利性,并且都沒有必要寫 API 接口文檔了。
根據(jù)它的官方 API 文檔,我們可以通過下面的 API 獲得當前某一個城市的天氣(這里以筆者所在城市南京為例):
HTTP 請求
Request URL: https://www.tianqiapi.com/free/day?appid={APP_ID}&appsecret={APP_SECRET}&city=%E5%8D%97%E4%BA%ACRequest Method: GETStatus Code: 200 OKRemote Address: 127.0.0.1:7890Referrer Policy: strict-origin-when-cross-origin
其中 {APP_ID} 和 {APP_SECRET} 為你申請的 API 賬號。
HTTP 響應
HTTP/1.1 200 OKServer: nginxDate: Thu, 19 Aug 2021 06:21:45 GMTContent-Type: application/jsonTransfer-Encoding: chunkedConnection: keep-aliveVary: Accept-EncodingAccess-Control-Allow-Origin: *Access-Control-Allow-Credentials: trueContent-Encoding: gzip{air: "94",city: "南京",cityid: "101190101",tem: "31",tem_day: "31",tem_night: "24",update_time: "14:12",wea: "多云",wea_img: "yun",win: "東南風",win_meter: "9km/h",win_speed: "2級"}
這里的命名和大小寫實在要吐槽一下。
這里給出一份最簡單的 API 客戶端實現(xiàn):
export async function fetchWeatherOfCity(city: string) {// URL 類在 EdgeRoutine 中有對應的實現(xiàn)。const url = new URL('http://www.tianqiapi.com/free/day');// 這里我們直接采用官方示例中的免費賬戶。url.searchParams.set('appid', '23035354');url.searchParams.set('appsecret', '8YvlPNrz');url.searchParams.set('city', city);const response = await fetch(url.toString);return response;}
3.3 定義我們的 GraphQL SDL
讓我們用 GraphQL SDL 語言定接下來要實現(xiàn)接口的 Schema:
type Query {"查詢當前 API 的版本信息。"versions: Versions!"查詢指定城市的實時天氣數(shù)據(jù)。"weatherOfCity(name: String!): Weather!}"""城市信息"""type City {"""城市的唯一標識"""id: ID!"""城市的名稱"""name: String!}"""版本信息"""type Versions {"""API 版本號。"""api: String!"""`graphql` NPM 版本號。"""graphql: String!}"""天氣數(shù)據(jù)"""type Weather {"當前城市"city: City!"最后更新時間"updateTime: String!"天氣狀況代碼"code: String!"本地化(中文)的天氣狀態(tài)"localized: String!"白天氣溫"tempOfDay: Float!"夜晚氣溫"tempOfNight: Float!}
3.4 實現(xiàn) GraphQL Resolvers
Resolvers 的實現(xiàn)思路很簡單,詳見注釋:
import { version as graphqlVersion } from 'graphql';import { apiVersion } from '../api-version';import { fetchWeatherOfCity } from '../tianqi-api';export function versions() {return {// EdgeRoutine 的部署不像 FaaS 那么及時。// 因此每次部署前,我都會手工的修改 `api-version.ts` 中的版本號,// 查詢時看到 api 版本號變了,就說明 CDN 端已經(jīng)部署成功了。api: apiVersion,graphql: graphqlVersion,};}export async function weatherOfCity(parent: any, args: { name: string }) {// 調(diào)用 API 并將返回的格式轉(zhuǎn)換為 JSON。const raw = await fetchWeatherOfCity(args.name).then((res) => res.json());// 將原始的返回結(jié)果映射到我們定義的接口對象中。return {city: {id: raw.cityid,name: raw.city,},updateTime: raw.update_time,code: raw.wea_img,localized: raw.wea,tempOfDay: raw.tem_day,tempOfNight: raw.tem_night,};}
3.5 創(chuàng)建并啟動服務器
現(xiàn)在我們已經(jīng)有了 GraphQL 的接口大綱和 Resolvers,接下來就可以像 Node.js 里那樣創(chuàng)建和啟動我們的 Server 了。
// 注意這里不再是 `import { ApolloServer } from 'apollo-server'` 了。import { ApolloServer } from '@ali/apollo-server-edge-routine';import { default as typeDefs } from '../graphql/schema.graphql';import * as resolvers from '../resolvers';// 創(chuàng)建我們的服務器const server = new ApolloServer({// `typeDefs` 是一個 GraphQL 的 `DocumentNode` 對象。// `*.graphql` 文件被 `webpack-graphql-loader` 加載后就變成了 `DocumentNode` 對象。typeDefs,// 即 3.4 章節(jié)中的 Resolversresolvers,});// 先啟動服務器,然后監(jiān)聽,一行代碼全部搞定!server.start().then(() => server.listen());
是的,就是這么簡單,創(chuàng)建一個 server 對象,然后將它啟動并使其偵聽指定的路徑(在本例中沒有傳遞 path 參數(shù),使用的是默認的 /graphql)。
到目前為止,主要的 TypeScript 和 GraphQL 代碼已經(jīng)全部完成了!
3.6 工程化配置
為了讓 TypeScript 明白我們在 EdgeRoutine 環(huán)境中寫代碼,我們需要在 tsconfig.json 中交代 lib 和 types:
{"compilerOptions": {"alwaysStrict": true,"esModuleInterop": true,"lib": ["esnext", "webworker"],"module": "esnext","moduleResolution": "node","outDir": "./dist","preserveConstEnums": true,"removeComments": true,"sourceMap": true,"strict": true,"target": "esnext","types": ["@ali/edge-routine-types"]},"include": ["src"],"exclude": ["node_modules"]}
再次強調(diào)一遍,與 Serverless / FaaS 不同,我們的程序并不是跑在 Node.js 環(huán)境中,而是跑在類似 ServiceWorker 環(huán)境 中。從 Webpack 5 開始,在 browser 目標環(huán)境中不再會自動注入 Node.js 內(nèi)置模塊的 polyfills,因此在 Webpack 的配置中我們需要手工加上:
{...resolve: {fallback: {assert: require.resolve('assert/'),buffer: require.resolve('buffer/'),crypto: require.resolve('crypto-browserify'),os: require.resolve('os-browserify/browser'),stream: require.resolve('stream-browserify'),zlib: require.resolve('browserify-zlib'),util: require.resolve('util/'),},...}...}
當然,你還需要手工安裝包括 assert、buffer、crypto-browserify、os-browserify、stream-browserify、browserify-zlib 及 util 等在內(nèi)的 polyfills 包。
3.7 添加 CDN 緩存
最后,讓我們把 CDN 緩存加上,由于 EdgeRoutine 在筆者截稿前還處于 beta 階段,因此我們只能用 Experimental 的 API 來實現(xiàn)緩存,讓我們重新實現(xiàn)一下 fetchWeatherOfCity() 方法。
export async function fetchWeatherOfCity(city: string) {const url = new URL('http://www.tianqiapi.com/free/day');url.searchParams.set('appid', '23035354');url.searchParams.set('appsecret', '8YvlPNrz');url.searchParams.set('city', city);const urlString = url.toString();if (isCacheSupported()) {const cachedResponse = await cache.get(urlString);if (cachedResponse) {return cachedResponse;}}const response = await fetch(urlString);if (isCacheSupported()) {cache.put(urlString, response);}return response;}
在全局(globalThis)中提供的 cache 對象,本質(zhì)上是一個通過 Swift 實現(xiàn)的緩存器,它的鍵必須是一個 HTTP Request 對象或一個 HTTP 協(xié)議(非 HTTPS)的 URL 字符串,而值必須是一個 HTTP Response 對象(可以來自 fetch() 方法)。雖然 EdgeRoutine 的 Serverless 程序每隔幾分鐘或者 1 小時就會重啟,我們的全局變量會隨之銷毀,但是有了 cahce 對象的幫助,可以幫我們實現(xiàn) CDN 級別的緩存。
在阿里云的 EdgeRoutine KV 數(shù)據(jù)庫上線后,我們會更新這個示例,實現(xiàn)更強大的緩存。
3.8 添加 Playground 調(diào)試器
為了更好的調(diào)試 GraphQL 我們還可以添加一個官方的 Playground 調(diào)試器,它是一個單頁面應用,因此我們可以通過 Webpack 的 html-loader 加載進來。
addEventListener('fetch', (event) => {const response = handleRequest(event.request);if (response) {event.respondWith(response);}});function handleRequest(request: Request): Promise| void {const url = new URL(request.url);const path = url.pathname;// 為了方便調(diào)試,我們把所有對 `/graphql` 的 GET 請求都處理為返回 playground。// 而 POST 請求則為實際的 GraphQL 調(diào)用if (request.method === 'GET' && path === '/graphql') {return Promise.resolve(new Response(rawPlaygroundHTML, { status: 200, headers: { 'content-type': 'text/html' } }));}}
最后讓我們在瀏覽器中訪問 /graphql,看到的就是下面的界面:
在其中輸入一段查詢語句:
query CityWeater($name: String!) {versions {apigraphql}weatherOfCity(name: $name) {city {idname}codeupdateTimelocalizedtempOfDaytempOfNight}}
將 Variables 設置為 { "name": "杭州" },點擊中間的 Play 按鈕即可。
3.9 完整的項目代碼
后續(xù),我會將上述 NPM 包和 Demo 在 我的 Github 上開源。
在這個簡單的公開示例中,我們沒有辦法完整的演示如何將 EdgeRoutine 作為 GraphQL 網(wǎng)關(guān)的二級代理網(wǎng)關(guān),你可以訪問 graphcdn.io 通過視頻了解更多關(guān)于 GrpahQL CDN 網(wǎng)關(guān)的信息。在可預見的將來,我們將利用 CDN 的邊緣 KV 數(shù)據(jù)庫實現(xiàn)對 Query 結(jié)果的緩存,并通過對 GraphQL 的語法解析和單類型中 ID 唯一的特性實現(xiàn)當發(fā)生 Mutations 時,自動使相關(guān)數(shù)據(jù)實體的緩存失效。
