让CDN成为高性能GraphQL网关
本文通过一个简单的天气查询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', '23035354');
url.searchParams.set('appsecret', '8YvlPNrz');
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按钮即可。