邊緣計算:讓 CDN 成爲高性能 GraphQL 網關

1. 前言

1.1 GraphQL 作爲網關層

如果你對 GraphQL 還不瞭解,可以通過我們團隊的講座和文章進一布瞭解:

通過我們團隊 4 年的持續努力,現如今在 CCO 技術部,GraphQL 已經成爲了 API 對內對外描述、暴露及調用的唯一標準。而在國外,Facebook、Netflix、Github、Paypal、微軟、大衆、沃爾瑪等企業也在大規模使用 GraphQL 中,甚至讓以 GraphQL 爲生的 Apollo 公司成功拿下了 1.3 億美元的 D 輪融資。在面向全球前端開發者調研問卷中,GraphQL 也成爲最受關注的技術和最想學習的技術。Github 上有一份持續更新的 GraphQL 公開服務列表。

我們認爲 GraphQL 最適合的場景莫過於作爲 BFF(Backend for Frontend)的網關層,即根據客戶端的實際需要,將後端的原始 HSF 接口、第三方 RESTful 接口進行整合和封裝形成自己的 Service Façade 層。GraphQL 自身的特性由、使得其非常容易與 RESTful、MTOP/MOPEN 等基於 HTTP 的現有網關進行集成,而另一方面,在國外很多文章中都提到 GraphQL 非常適合作爲 Serverless/FaaS 的網關層,你甚至只需要唯一一個 HTTP Trigger 就能實現代理所有背後的 API。

1.2 GraphQL 網關與 CDN 邊緣計算

EdgeRoutine 邊緣計算 是阿里雲 CDN 團隊推出的新一代 Serverless 計算平臺,它提供了一個類似 W3C 標準的 ServiceWorker 容器,可以充分利用 CDN 遍佈全球的節點空閒計算資源以及強大的加速與緩存能力,實現高可用性、高性能的分佈式彈性計算,更重要的是目前對於彈內用戶來說它是完全免費的,當然截止至筆者發稿時 EdgeRoutine 還處在試用階段。EdgeRoutine 將在 8 月底 9 月初正式對外發布!

在 1.1 節中我們提到 GraphQL 非常適合作爲 BFF 網關層,而結合電商後臺業務的特點我們發現:

Query 類的請求佔了大量的比例,而這些只讀類查詢請求,通常響應結果在相當長的時間範圍甚至是永遠都不會發生變化,儘管如此,每一次 API 調用時我們還是將請求發送到了後端的應用 / 服務器上。

這讓我們產生了一個全新的思路:

如上圖所示,將 CDN EdgeRoutine 作爲 GraphQL Query 類請求的代理層,首次執行 Query 時,我們將請求先從 CDN 代理到 GraphQL 網關層,再通過網關層代理到實際的應用服務(例如通過 HSF 調用),然後將獲得的返回結果緩存在 CDN 上,之後的請求可以根據 TTL 業務規則動態決定走緩存還是去 GraphQL 網關層。這樣我們可以充分利用 CDN 的特性,將查詢類請求分散到遍佈全球的節點中,顯著降低主應用程序的 QPS。

2. 移植 Apollo GraphQL Server

Apollo GraphQL Server 是目前使用最廣泛的開源 GraphQL 服務,它的 Node.js 版本 更是被 BFF 類應用廣爲使用。但是遺憾的是 apollo-server 是一個面向 Node.js 技術棧開發的項目,而前文中提到 EdgeRoutine 提供的是一個類似 Service Worker 的 Serverless 容器,因此我們首先需要做的就是將 apollo-server-core 移植到 EdgeRoutine 中。爲此,我開發了 apollo-server-edge-routine,本章節將簡述設計和實現思路。

2.1 構建 TypeScript 開發環境和腳手架

首先,我們需要構建一個 EdgeRoutine 容器的 TypeScript 環境,此前我已經開發了 EdgeRoutine TypeScript 描述和 EdgeRoutine TypeScript 腳手架及本地模擬器(在 EdgeRoutine 正式上線後,我會開源到 Github 上),因此可以快速構建一個本地開發環境。這裏簡單解釋一下,我實際上是用 Service Worker 的 TypeScript 庫來模擬編譯時環境,同時將 Webpack 作爲本地調試服務器,並用瀏覽器的 Service Worker 來模擬運行 edge.js 腳本,用 Webpack 的 socket 通訊實現 Hot Reload 效果。

2.2 爲 EdgeRoutine 環境實現自己的 ApolloServer

Apollo 官方似乎並沒有給出如何移植 Apollo Server 的文檔,不過簡單研究了一下 ApolloServerBase 的代碼,不難發現其實它已經是一個功能完備的服務器了,只是缺少與 HTTP 服務器的連接。因此,我們只要集成該類,並實現一個自己的 listen(path: string) 方法即可,這裏的 listen() 方法與傳統 HTTP 服務器不同,我們需要指定的不是 port 而是一個 path,也就是需要偵聽 GraphQL 請求的路徑。下面是我實現的一個簡單版本:

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;
}

3. 一個簡單的天氣查詢 GraphQL CDN 代理網關示例

3.1 我們要做什麼

在這個 Demo 裏,我們假設要對第三方天氣服務進行二次封裝。我們將會爲天氣 API 網(tianqiapi.com)開發一個 GraphQL CDN 代理網關。天氣 API 網對免費用戶的 QPS 有一定的限制,每天只能 300 次查詢,既然天氣預報一般變化頻率較低,我們假設希望在首次查詢某一個城市天氣的時候,將會真正訪問到天氣 API 網的服務,而此後的同一城市天氣查詢將走 CDN 緩存。

3.2 天氣 API 網接口簡介

天氣 API 網(tianqiapi.com)對外提供商業級的天氣預報服務,據說每天有千萬級的 QPS。這裏也可以設想一下如果它們使用 GraphQL 來定義、暴露 API 接口將會帶來多大的便利性,並且都沒有必要寫 API 接口文檔了。

根據它的官方 API 文檔,我們可以通過下面的 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;
}

3.3 定義我們的 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!
}

3.4 實現 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,
  };
}

3.5 創建並啓動服務器

現在我們已經有了 GraphQL 的接口大綱和 Resolvers,接下來就可以像 Node.js 裏那樣創建和啓動我們的 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';
// 創建我們的服務器
const server = new ApolloServer({
  // `typeDefs` 是一個 GraphQL 的 `DocumentNode` 對象。
  // `*.graphql` 文件被 `webpack-graphql-loader` 加載後就變成了 `DocumentNode` 對象。
  typeDefs,
  // 即 3.4 章節中的 Resolvers
  resolvers,
});
// 先啓動服務器,然後監聽,一行代碼全部搞定!
server.start().then(() => server.listen());

是的,就是這麼簡單,創建一個 server 對象,然後將它啓動並使其偵聽指定的路徑(在本例中沒有傳遞 path 參數,使用的是默認的 /graphql)。

到目前爲止,主要的 TypeScript 和 GraphQL 代碼已經全部完成了!

3.6 工程化配置

爲了讓 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 包。

3.7 添加 CDN 緩存

最後,讓我們把 CDN 緩存加上,由於 EdgeRoutine 在筆者截稿前還處於 beta 階段,因此我們只能用 Experimental 的 API 來實現緩存,讓我們重新實現一下 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 對象,本質上是一個通過 Swift 實現的緩存器,它的鍵必須是一個 HTTP Request 對象或一個 HTTP 協議(非 HTTPS)的 URL 字符串,而值必須是一個 HTTP Response 對象(可以來自 fetch() 方法)。雖然 EdgeRoutine 的 Serverless 程序每隔幾分鐘或者 1 小時就會重啓,我們的全局變量會隨之銷燬,但是有了 cahce 對象的幫助,可以幫我們實現 CDN 級別的緩存。

在阿里雲的 EdgeRoutine KV 數據庫上線後,我們會更新這個示例,實現更強大的緩存。遺憾的是,截止至筆者發稿時該功能暫未上線,本人十分期待!

3.8 添加 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 按鈕即可。

3.9 完整的項目代碼

在 EdgeRoutine 正式發佈後,我會將上述 NPM 包和 Demo 在 我的 Github 上開源。

4. 面向未來

在這個簡單的公開示例中,我們沒有辦法完整的演示如何將 EdgeRoutine 作爲 GraphQL 網關的二級代理網關,你可以訪問 graphcdn.io 通過視頻瞭解更多關於 GrpahQL CDN 網關的信息。在可預見的將來,我們將利用 CDN 的邊緣 KV 數據庫實現對 Query 結果的緩存,並通過對 GraphQL 的語法解析和單類型中 ID 唯一的特性實現當發生 Mutations 時,自動使相關數據實體的緩存失效。

作者所在團隊 CCO 技術部坐落在杭州西溪 B 園區南京建鄴奧體新城科技園區,前端技術棧是 TypeScript + GraphQL + React + AntDesign,致力於努力改善淘系及非淘系的消費者、商家及服務小二的用戶體驗,並將中臺化的服務 OS 產品推向千萬商家市場!

歡迎大家在與我交流討論(https://www.zhihu.com/people/henry-li-03)。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/o7evv4Br9ocDLclMLcDM5w