本文通過一個簡單的天氣查詢GraphQL CDN代理網關示例,讓您快速了解邊緣計算Serverless技術方案EdgeRoutine結合GraphQL網關可以實現高性能的API網關服務。實踐方案同樣適用于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按鈕即可。