让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遍布全球的节点空闲计算资源以及强大的加速与缓存能力,实现高可用性、高性能的分布式弹性计算。想了解更多信息请参见什么是边缘程序

让CDN成为高性能GraphQL网关

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中说明libtypes

{
  "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/'),
    },
    ...
  }
  ...
}

此外,您还需要手动安装包括assertbuffercrypto-browserifyos-browserifystream-browserifybrowserify-zlibutil等在内的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按钮即可。