🛣️

Apollo Server Cloud Functions (Node.js)のバージョンをSoft LaunchするGatewayを作る

2022/03/23に公開

概要

Cloud Functionsのバージョニングは毎回困るのですが、ただ切り替えるだけならデプロイし直したり新たに名前つけたりするだけで解決します。
しかしながら、エンドポイントを変えないまま確率的に一部ユーザーだけに新規バージョンをリリースするカナリアリリースや、特定のユーザーにだけ新規バージョンをリリースするSoft Launchをしたいときは困ってしまいます。

今回は、元々Apollo Server Cloud FunctionsでGraphQLのAPI Gatewayを作っていたところ、API Gateway自体の新規バージョンのリリースをSoft Launchにしたくなったので、Client側からの見た目はそのままに、複数のバージョンのGatewayをハンドリングするWrapper Gatewayを作りました。

前提

GraphQLサーバーのバージョニングに関しては公式から見解があります。
https://graphql.org/learn/best-practices/#versioning

Versioning
While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.
Why do most APIs version? When there's limited control over the data that's returned from an API endpoint, any change can be considered a breaking change, and breaking changes require a new version. If adding new features to an API requires a new version, then a tradeoff emerges between releasing often and having many incremental versions versus the understandability and maintainability of the API.
In contrast, GraphQL only returns the data that's explicitly requested, so new capabilities can be added via new types and new fields on those types without creating a breaking change. This has led to a common practice of always avoiding breaking changes and serving a versionless API.

要は「GraphQLはクライアントが欲しいプロパティを選べるから、プロパティを消す必要がなく、更新が必要なら新たなプロパティを足せばええんや」ということです。

個人的な考えとして、Schema自体の編集に関しては完全に同意するものの、resolverの実装の部分に関してはversioningしたいという気持ちがあります。本来的には別プロパティを立てて旧プロパティをdeprecateしていくということなのでしょうが、そこの副作用をサーバー内に隠蔽したいという場合もあるでしょう。

よって、「Schemaは共通でresolverが複数バージョンある際に、何らかの基準をもとに出し分ける」という実装を試みました。もちろん、新たにSchemaが増えた場合、過去のバージョンのresolverにはその実装はないためそれも考慮する必要があります(正常にnullを返す、など)。

環境

単体

普通に書くとこんな感じ

v1.ts
const singleFunction_v1 = functions.region('asia-northeast1')
.https.onRequest(new ApolloServer(
    {
        typeDefs: require('./pass-to-schema'),
        resolvers: require('./pass-to-resolver'),
        formatError: (err: GraphQLError) => {
            return err
        },
        context: context,
    }
).....

exports.singleFunction = singleFunction_v1;

実装

ローカル起動

Functions Framework を入れましょう
https://cloud.google.com/functions/docs/functions-framework?hl=ja

npm run build
functions-framework --target=singleFunction

Schema gen

Rover CLIを入れましょう

http://localhost:8080/ が立っている状態で、

rover graph introspect http://localhost:8080/ > schema.graphql

codegen用の綺麗なschemaファイルができます

codegen

GraphQL Codegenを入れましょう

graphql-codegen --config codegen.yml

codegen.ymlはこんな感じ

codegen.yml
overwrite: true
schema: "path/to/schema"
documents: null
generates:
  ./generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-resolvers"
      - "typescript-document-nodes"

綺麗なTypesができます

Gateway

functions.ts
import { Resolvers } from './generated/graphql'
const clonedeep = require('lodash/cloneDeep');

export interface StrKeyObject {
    [key: string]: any;
}

// 安定バージョン
const stable = 0
// 存在するバージョンのリスト
const versions = [0, 1, 2] 

// load versions
const resolverVersions: StrKeyObject = {}
versions.forEach(version => {
    resolverVersions[version] = require(`pass/to/resolver/v${version}`)
})

// クエリの引数によってバージョンを出し分ける
const versionMapper = (args: StrKeyObject): number => {
    // ここをいい感じに書く
} 

// init
const resolvers: Resolvers = {}

// set stable version
Object.assign(resolvers, clonedeep(resolverVersions[stable]))

// replace with other versions
for (const [parentKey, parentValue] of Object.entries(resolvers)) {
    for (const [key, value] of Object.entries(parentValue)) {
        if(value && value.constructor.name === "AsyncFunction"){
            Object.assign(parentValue,  Object.fromEntries([[key, async (parent:any, args:any, context:any, info:any) => {
		// 子クエリは親と同じバージョンにしたいのでバージョンの計算は初回のみ
                const version = context.version ? context.version : versionMapper(args);
                context.version = version
                console.log("Execute", parentKey + "." + key + ", version:", version)
                return await resolverVersions[version][parentKey][key](parent, args, context, info)
            }]]))
        }
    }
}

const schema = loadSchemaSync(join(__dirname, 'path/to/schema.graphql'), {
    loaders: [new GraphQLFileLoader()],
});
const schemaWithResolvers = addResolversToSchema({ schema, resolvers });

exports.gateway = functions.region('asia-northeast1')
.https.onRequest(
    new ApolloServer({
        schema: schemaWithResolvers, 
        formatError: (err: GraphQLError) => {
            return err
        },
        context: context,
        playground: true,
        introspection: true
    }).....
);

補足

結局やってることはschemaからwrapperとしてのresolverを生やしてその中で個別のresolverにルーティングしているだけなので、ロジックは好きに調整してください

Discussion