天天看點

邊緣計算:讓 CDN 成為高性能 GraphQL 網關1.前言2.移植 Apollo GraphQL Server3.一個簡單的天氣查詢 GraphQL CDN 代理網關示例4.面向未來

 作者|亨睿 Alibaba F2E  8月26日

邊緣計算:讓 CDN 成為高性能 GraphQL 網關1.前言2.移植 Apollo GraphQL Server3.一個簡單的天氣查詢 GraphQL CDN 代理網關示例4.面向未來

1.前言

1.1 GraphQL 作為網關層

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

  • 官方網站
  • 講座與 Demo(第 3 場)
    • 用 GraphQL 控制你的特斯拉真車
  • 技術文章
    • 聊聊 GraphQL 和 Apollo 的工作流
    • 還在用 Redux,要不要試試 GraphQL 和 Apollo?
    • TypeScript + GraphQL = TypeGraphQL
    • 基于 GraphQL 的資料導出

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

邊緣計算:讓 CDN 成為高性能 GraphQL 網關1.前言2.移植 Apollo GraphQL Server3.一個簡單的天氣查詢 GraphQL CDN 代理網關示例4.面向未來

我們認為 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 成為高性能 GraphQL 網關1.前言2.移植 Apollo GraphQL Server3.一個簡單的天氣查詢 GraphQL CDN 代理網關示例4.面向未來

如上圖所示,将 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

中交代 

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 包。

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 時,自動使相關資料實體的緩存失效。

繼續閱讀