本文通過一個簡單的天氣查詢GraphQL CDN代理網關樣本,讓您快速瞭解邊緣計算Serverless技術方案EdgeRoutine結合GraphQL網關可以實現高效能的API Gateway服務。實踐方案同樣適用於DCDN服務。
背景資訊
隨著互連網飛速發展,國內外越來越多的企業在大規模使用GraphQL,當前在阿里巴巴集團CCO技術部,GraphQL已經成為了API對內對外描述、暴露及調用的唯一標準,而在國外,Facebook、Netflix、Github、Paypal、微軟、福士、沃爾瑪等企業也在大規模使用GraphQL中,在面向全球前端開發人員調研問卷中,GraphQL也成為最受關注的技術和最想學習的技術。GraphQL最適合的情境莫過於作為BFF(Backend for Frontend)的網關層,即根據用戶端的實際需要,將後端的原始HSF介面、第三方RESTful介面進行整合和封裝形成自己的Service Facade層。GraphQL自身的特性使得其非常容易與RESTful、MTOP/MOPEN等基於HTTP的現有網關進行整合。而另一方面,GraphQL非常適合作為Serverless/FaaS的網關層,只需要唯一一個HTTP Trigger就能實現代理所有背後的API。
GraphQL既是一種用於API的查詢語言,也是一個滿足您資料查詢的運行時。GraphQL對您的API中的資料提供了一套易於理解的完整描述,使得用戶端能夠準確地獲得它需要的資料,而且沒有任何冗餘,也讓API更容易地隨著時間推移而演化,還能用於構建強大的開發人員工具。更多關於GraphQL的詳細資料,請參見GraphQL官網。
GraphQL網關與CDN邊緣計算
EdgeRoutine邊緣計算是阿里雲CDN團隊推出的新一代Serverless計算平台,它為您提供了一個類似W3C標準的ServiceWorker容器,可以充分利用CDN遍布全球的節點空閑計算資源以及強大的加速與緩衝能力,實現高可用性、高效能的分布式彈性計算。想瞭解更多資訊請參見什麼是邊緣程式。

GraphQL非常適合作為BFF網關層,Query類的請求佔了大量的比例,而這些唯讀類查詢請求,通常響應結果在相當長的時間範圍甚至是永遠都不會發生變化。
如上圖所示,將CDN EdgeRoutine作為GraphQL Query類請求的代理層,首次執行Query時,系統將請求先從CDN代理到GraphQL網關層,再通過網關層代理到實際的應用服務(例如,通過HSF調用),然後將獲得的返回結果緩衝在CDN上,之後的請求可以根據TTL商務規則動態決定走緩衝還是去GraphQL網關層。這樣我們可以充分利用CDN的特性,將查詢類請求分散到遍布全球的節點中,顯著降低主應用程式的QPS。
移植Apollo GraphQL Server
Apollo GraphQL Server是目前使用最廣泛的開源GraphQL服務,它的Node.js版本更是被BFF類應用廣為使用。但遺憾的是apollo-server是一個面向Node.js技術棧開發的專案,而EdgeRoutine提供的是一個類似Service Worker的Serverless容器,因此我們首先需要將apollo-server-core移植到EdgeRoutine中。
步驟一:構建TypeScript開發環境和腳手架
首先,需要構建一個EdgeRoutine容器的TypeScript環境,用Service Worker的TypeScript庫來類比編譯時間環境,同時將Webpack作為本地調試伺服器,並用瀏覽器的Service Worker來類比運行edge.js指令碼,用Webpack的socket通訊實現Hot Reload效果。
步驟二:建立與HTTP伺服器的串連
通過以下方法,整合ApolloServerBase類建立與HTTP伺服器的串連,為EdgeRoutine環境實現自己的ApolloServer:
import { ApolloServerBase } from 'apollo-server-core';
import { handleGraphQLRequest } from './handlers';
/**
* Apollo GraphQL Server 在 EdgeRoutine 上的實現。
*/
export class ApolloServer extends ApolloServerBase {
/**
* 在指定的路徑上,偵聽 GraphQL Post 請求。
* @param path 指定要偵聽的路徑。
*/
async listen(path = '/graphql') {
// 如果在未調用 `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));
}
}
});
}
}步驟三:實現handleGraphQLRequest()方法
該方法實際上是一個通道模式,負責將HTTP請求轉換成GraphQL請求發送到Apollo Server,並將其返回的GraphQL響應轉換回HTTP響應。Apollo官方有一個名為runHttpQuery()的類似方法,但是該方法用到了buffer等Node.js環境內建的模組,因此無法在Service Worker環境中編譯通過。通過以下方法可實現:
import { GraphQLOptions, GraphQLRequest } from 'apollo-server-core';
import { ApolloServer } from './ApolloServer';
/**
* 從 HTTP 要求中解析出 GraphQL 查詢並執行,再將執行的結果返回。
*/
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.');
}
// 執行 GraphQL 操作請求。
// 當執行失敗時不會拋出異常,而是返回一個包含 `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;
}天氣查詢GraphQL CDN代理網關樣本
在這個Demo裡對第三方天氣服務進行二次封裝,為天氣API網(tianqiapi.com)開發一個GraphQL CDN代理網關。
天氣API網對免費使用者的QPS有一定的限制,每天只能查詢300次。天氣預報一般變化頻率較低,我們假設希望在首次查詢某一個城市天氣的時候,將會真正訪問到天氣API網的服務,而此後的同一城市天氣查詢將通過CDN緩衝通道。
天氣API網簡介
天氣API網(tianqiapi.com)對外供應商業級的天氣預報服務,每天有千萬級的QPS。可以通過下面的API獲得當前某一個城市的天氣,此處以南京為例:
HTTP請求
Request URL: https://www.tianqiapi.com/free/day?appid={APP_ID}&appsecret={APP_SECRET}&city=%E5%8D%97%E4%BA%AC
Request Method: GET
Status Code: 200 OK
Remote Address: 127.0.0.1:7890
Referrer Policy: strict-origin-when-cross-origin其中{APP_ID}和{APP_SECRET}為您申請的API帳號。
HTTP響應
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 19 Aug 2021 06:21:45 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Content-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用戶端實現
export async function fetchWeatherOfCity(city: string) {
// URL 類在 EdgeRoutine 中有對應的實現。
const url = new URL('http://www.tianqiapi.com/free/day');
// 這裡我們直接採用官方樣本中的免費賬戶。
url.searchParams.set('appid', '2303****');
url.searchParams.set('appsecret', '8Yvl****');
url.searchParams.set('city', city);
const response = await fetch(url.toString);
return response;
}步驟一:自訂GraphQL SDL
用GraphQL SDL語言定義將要實現介面的Schema:
type Query {
"查詢當前 API 的版本資訊。"
versions: Versions!
"查詢指定城市的即時天氣資料。"
weatherOfCity(name: String!): Weather!
}
"""
城市資訊
"""
type City {
"""
城市的唯一標識
"""
id: ID!
"""
城市的名稱
"""
name: String!
}
"""
版本資訊
"""
type Versions {
"""
API 版本號碼。
"""
api: String!
"""
`graphql` NPM 版本號碼。
"""
graphql: String!
}
"""
天氣資料
"""
type Weather {
"當前城市"
city: City!
"最後更新時間"
updateTime: String!
"天氣狀況代碼"
code: String!
"本地化(中文)的天氣狀態"
localized: String!
"白天氣溫"
tempOfDay: Float!
"夜晚氣溫"
tempOfNight: Float!
}步驟二:實現GraphQL Resolvers
Resolvers實現如下:
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 端已經部署成功了。
api: apiVersion,
graphql: graphqlVersion,
};
}
export async function weatherOfCity(parent: any, args: { name: string }) {
// 調用 API 並將返回的格式轉換為 JSON。
const raw = await fetchWeatherOfCity(args.name).then((res) => res.json());
// 將原始的返回結果映射到我們定義的介面對象中。
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,
};
}步驟三:建立並啟動伺服器
建立一個server對象,然後將它啟動並使其偵聽指定的路徑/graphql。
// 注意這裡不再是 `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';
// 建立我們的伺服器
const server = new ApolloServer({
// `typeDefs` 是一個 GraphQL 的 `DocumentNode` 對象。
// `*.graphql` 檔案被 `webpack-graphql-loader` 載入後就變成了 `DocumentNode` 對象。
typeDefs,
// 即步驟二中的 Resolvers
resolvers,
});
// 先啟動伺服器,然後監聽,一行代碼全部搞定!
server.start().then(() => server.listen());步驟四:工程化配置
為了讓TypeScript識別出我們在EdgeRoutine環境中寫代碼,需要在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"]
}與Serverless/FaaS不同,該程式並不是運行在Node.js環境中,而是運行在類似ServiceWorker環境中。從Webpack 5開始,在browser目標環境中不再會自動注入Node.js內建模組的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等在內的polyfills 包。
步驟五:添加CDN緩衝
通過Experimental的API添加緩衝,重新實現fetchWeatherOfCity()方法:
export async function fetchWeatherOfCity(city: string) {
const url = new URL('http://www.tianqiapi.com/free/day');
url.searchParams.set('appid', '2303****');
url.searchParams.set('appsecret', '8Yvl****');
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對象,本質上是一個通過Swift實現的緩衝器,它的鍵必須是一個HTTP Request對象或一個HTTP協議(非HTTPS)的URL字串,而值必須是一個HTTP Response對象(可以來自fetch()方法)。雖然EdgeRoutine的Serverless程式每隔幾分鐘或1小時就會重啟,全域變數會隨之銷毀,但是有了cache對象的協助,可以實現CDN層級的緩衝。
步驟六:添加Playground調試器
為了更好的調試GraphQL,您還可以添加一個官方的Playground調試器。它是一個單頁面應用,因此您可以通過Webpack的html-loader進行載入。
addEventListener('fetch', (event) => {
const response = handleRequest(event.request);
if (response) {
event.respondWith(response);
}
});
function handleRequest(request: Request): Promise<Response> | void {
const url = new URL(request.url);
const path = url.pathname;
// 為了方便調試,我們把所有對 `/graphql` 的 GET 請求都處理為返回 playground。
// 而 POST 請求則為實際的 GraphQL 調用
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 {
api
graphql
}
weatherOfCity(name: $name) {
city {
id
name
}
code
updateTime
localized
tempOfDay
tempOfNight
}
}將Variables設定為{"name": "杭州"},單擊中間的Play按鈕即可。