複数のCloud Functions entrypointsを1 GitHub repository(NodeJS with TypeScript)で管理する <usecaseを添えて>
この記事はGoogle Cloud Japan Customer Engineer Advent Calendar 2019, 4日目の記事です。
TL;DR
皆様こんにちは、Google Cloud Customer EngineerのArashiと申します。
このエントリーでは、同一の機能要件内でCloud Functionsを分けたい時、どのように1 GitHub repositoryでコードを管理するかを実際のユースケースをまじえて紹介していきます。
今すぐ完成品を!という方はこちら https://github.com/jupemara/gcp-advent-calendar-2019-001。
※usecaseとユースケースという表記が出てきますが、usecaseはオニオンアーキテクチャで言うところのapplication service, クリーンアーキテクチャで言うところのusecaseを表し、ユースケースはビジネスの機能要件を満たすひとつのケースを示します。
今回のユースケース
今回のユースケースは”ユーザ登録を行う。”という非常にシンプルなものですが、これを分解して以下のパターンで実装してみましょう。
パターン1: 同期で行う
- HTTP Triggers にてrequestを受け取ってresponseを返した時点でユーザ登録が完了している
パターン2: 非同期で行う(いわゆる結果整合ですね)
使い所としては、同期で行った際に重い処理の部分だけを非同期で行うようにして、別のCloud Functionsで行うというパターンを想定しています。
- requestを受け取ったらCloud Pub/Subにユーザ情報とユーザ登録を行う旨をPublishする
- Pub/Sub Triggers を用いてCloud Functionsがユーザを登録する
- ユーザの登録が完了したらUIに通知を送る
それでは実際に開発環境の準備、同期で実行するパターン、非同期で実行するパターンの順で実装を見ていきましょう。
step0: 開発環境の準備
こちら https://github.com/jupemara/cloud-functions-typescript-boilerplate のrepositoryに
- HTTP Triggers Cloud Functions の entrypoint のみの実装
- Pub/Sub Triggers Cloud Functions の entrypoint のみの実装
- deploy用スクリプト
- Functions Framework を用いたローカルでの開発用スクリプト
- Functions FrameworkをPub/Sub Triggersとして起動した時のevent payloadを流し込むスクリプト(擬似的にPub/Sub Topicからのメッセージを受け取った時と同じ挙動をさせるためのスクリプト)
を用意したので、これらを使って各ユースケース、複数entrypointsの管理について解説を行います。
step1: パターン1の実装 HTTP Triggers にて同期でユーザを登録する
同期でユーザを登録するCloud Functionsを作成します。まずは機能要件を洗い出して、洗い出した機能要件をコードに落とし込んでいきましょう。ここでは、
- ユーザIDは英数文字+一部の記号(
-
,_
,.
)を用いて登録することができる - ユーザIDは英字から始まっていなければいけない
- ユーザIDは最大で16文字の入力が可能
- ユーザIDは最小で4文字の長さが必要
という機能要件があるとしましょう。DDDの観点に沿って、ユーザ登録のためのユースケース、ドメインモデルを作成していきましょう。
src/usecase/register-user.ts
src/domain/model/user.ts
またUser.userId
としてUserオブジェクトのフィールドのひとつとしてUserId
をバリューオブジェクトとしてsrc/domain/model/user-id.ts
を作成します。
export class UserId {
constructor(public readonly value: string) {
if (
![
value.length >= 4,
value.length <= 16,
/^[a-zA-Z]/.test(value),
/[\W-.]*/.test(value),
].every(v => v)
) {
assertionError();
}
}
}
constructorの内部で、上記の機能要件を満たしているかのバリデーションを行っています。
永続化層の実装
Repository Patternを使ってUserRepository
(今回はbackendとして扱うのはfirebaseですね)を実装していきます。
UserRepository
向けのInterfaceをdomain/model
配下に定義UserRepository
Interface向けの実態としてFirestoreRepository
を実装
ここまで実装が終わればusecaseに組み込んでいけるのでsrc/usecase/register-user.ts
配下のRegisterUserUsecase
に組み込んでいきましょう。
import { IUserRepository } from '../domain/model/user/user-repository';
import { User } from '../domain/model/user/User';
import { UserId } from '../domain/model/user/user-id';export class RegisterUserCase {
constructor(private readonly userRepository: IUserRepository) {}
public async execute(userId: string): Promise<void> {
const user = new User(new UserId(userId));
await this.userRepository.store(user);
}
}
実装の詳細についてはGitHub repositoryを見ていただくとして、ここではユースケースとして、
userId
をstring
型で受け取るUserId
バリューオブジェクトを作成(この時同時にビジネス条件のvalidationも行われています)User
を作成- UserRepositoryを使って、なんらかの方法で作成されたユーザを永続化
上記の内容が実装されていることがわかります。
つなぎこみ(Controller)
ここまで来てようやく内部実装が終わったので、外部からのインプットをハンドリングして、内部のユースケースにつなぎこむことが可能になります。
src/adapter/controller/cloud-functions-http/register-user.ts
としてHTTP Trigger用のcontrollerを定義してユーザ登録ユースケースを実行する
公式ドキュメントにも記載がありますが、Cloud Functions HTTP Triggerでハンドルされる関数、function(req, res) {}
の引数に入ってくるreq
,res
はexpress
で使用されているrequestとresponseオブジェクトです。そこで、型情報をnpm install --save-dev types/npm-express
から拝借しましょう。
import { RegisterUserUseCase } from '../../../usecase/register-user';
import { Response } from 'express';export class RegisterUserController {
constructor(private readonly usecase: RegisterUserUseCase) {}
public async handle(req: Request, res: Response): Promise<void> {
try {
const userId = req?.body?.userId;
await this.usecase.execute(userId);
res.status(200).send('');
} catch (e) {
console.warn(e.stack);
res.status(400).send('');
}
}
}interface Request {
body: {
userId: string;
};
}
controllerの実装はCloud Functionsから渡されるデータとusecase(ビジネスコンテキストの中でだけ使用される)内で使用されるデータの型変換のみにとどめます。
つなぎこみ(src/index.ts
)
さて、ここで
- 機能要件を満たすドメインレベルのコード(ドメインモデルとユースケース)
- 永続化層
- HTTPレイヤーでのハンドリング
と一通り揃いましたので、src/index.ts
内でそれぞれのコードに対してDIを行い、実際に動くhandlerを用意します。
import { RegisterUserController } from './adapter/controller/cloud-functions-http/register-user';
import { RegisterUserUseCase } from './usecase/register-user';
import { UserFirestoreRepository } from './adapter/repository/user/firestore';
import * as admin from 'firebase-admin';admin.initializeApp({
credential: admin.credential.applicationDefault(),
});const controller = new RegisterUserController(
new RegisterUserUseCase(
new UserFirestoreRepository(admin.firestore().collection('user')),
),
);export const registerUserSync = async function(req, res) {
await controller.handle(req, res);
};
これで実際に動くコードが出来上がったので、npm run build && npm run registerUserSync:start
とすれば、localhost:8080にてユーザ登録関数が実行されます。まずはstep1完了ですね!!
step1のコードはこちらにtagを切っておきました。
step2: パターン2の実装 HTTP TriggerとPub/Sub Triggerを使って非同期でユーザを登録する
前置きが少し長くなってしまいましたが、さてパターン2の実装です。
- HTTP requestを受け取ったらPub/SubにメッセージをPublishする
- Cloud Functions Pub/Sub Triggerを実装
- UIからFirestoreの変更を取得
Pub/Sub TopicにメッセージをPublishする
まずは既存のユースケースを非同期化していくために、Pub/Sub TopicにPublishするためのInterfaceを作成しましょう。
export interface IPublisher {
publish(user: User): Promise<void>;
}export interface RegisterUserMessage {
userId: string;
}
ユーザ登録に必要なデータを受け取って、Cloud Pub/Sub Topicにメッセージを投げるだけのインターフェイスです。またこのインターフェイスを満たす実態としてPub/Sub Publisherを実装し(src/adapter/repository/user/publisher.ts
)、既存のユースケース内で永続化していた部分と差し替えていきます。
Cloud Functions Pub/Sub Triggerの実装
src/adapter/controller/cloud-functions-pubsub/register-user.ts
としてPub/Sub Trigger用のcontrollerを定義してユーザ登録ユースケースを実行する
公式ドキュメントにも記載がありますが、引数に与えられたevent
オブジェクトは
{
"@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage",
"attributes": {
"KEY": "VALUE"
},
"data": "ENCODED_BY_BASE64_STRING"
}
のような形を取ります。これらのうち現状の実装で必要なフィールドはevent.data
のみなので、型定義をしながらPub/Sub Trigger用のcontrollerを作成していきましょう。event.data
には例えば{"userId": "example-userid_001"}
という値をJSONで送信したとしてもbase64でエンコードされた文字列が入ります。デコードとJSON.parse()
を忘れないようにしましょう。
export class RegisterUserSubscriber {
constructor(private readonly usecase: RegisterUserUseCase) {}
public async handle(event: Event) {
const userId = this.toPayload(event).userId;
await this.usecase.execute(userId);
}
private toPayload(event: Event): Payload {
return JSON.parse(Buffer.from(event.data, 'base64').toString()) as Payload;
}
}interface Event {
data: string;
}interface Payload {
userId: string;
}
HTTP Trigger Functionsのときと同様、Pub/Sub Triggerのcontrollerの責務は、外部から受け取ったデータをusecaseに渡せるよう型変換をしているだけになります。もしここでeventデータのパースに失敗すれば、このCloud Functionsは失敗しますし、成功すればusecaseに変換後のデータが渡されることになります。
またここでcontroller(外部から内部へのインターフェイス), usecase(ドメインロジックを実行するレイヤ)の責務を明確に分けたことで、同期実行していた際のユースケースと同じものが非同期実行の際にも変更なしで、使えている点に着目していただきたいです。
step2に関してはこちらにタグを切っておきました。
UIからFirestoreの変更を取得
以下のようにフォームに新規ユーザIDを入力するとPOSTを行い、先のregisterUserRequest
Cloud Functions HTTP Triggerに対してリクエストを送る簡単なUIを作りました。step2のフロー図にあるように、UIがFirestoreの変更を検知して、値が存在すれば画面上に表示させます。
multiple entrypoints on single repository
それではここまでで
- Pub/Subにメッセージを送信するためのCloud Functions HTTP Trigger
- Pub/Subからメッセージを受け取ってユーザを作成するためのCloud Functions Pub/Sub Trigger
とふたつのentrypointが作成されたことがわかります。コードを追うと最終的にsrc/index.ts
内部で
// entrypoint用の関数だけを部分抜粋しています
export const registerUserRequest = async function(req, res) {
await controller.handle(req, res);
};export const registerUser = async function(event, _) {
await subscriber.handle(event);
};
とふたつのentrypointを関数オブジェクトしてexport
していることがわかります。またここで、scripts/http/deploy.sh
またはscripts/event/deploy.sh
を見ていただくと
#!/usr/bin/env shgcloud functions deploy registerUserRequest \
--entry-point=registerUserRequest \
--runtime=nodejs10 \
--trigger-http \
--region=asia-northeast1
gcloud functions deploy
の後ろのregisterUserRequest
がCloud Functionsに登録される関数名(Google Cloud Platform Console上に表示される関数名)で--entry-point
オプションによってどの実装(コード上でexportしているもの)とどのCloud Functionsを結びつけているかが伺えます。こちらが複数のentrypointsをひとつのGitHub repositoryで動かすということの実態です。
まとめ(私がこのエントリーで言いたいこと)
実際にやっていることとしてはコードをまとめて、deploy用のscriptとentrypointを分けているだけと、非常にシンプルですが、私がこのエントリーで言いたいことは機能としてコードをひとつのrepositoryにまとめることが可能ということだけではありません。ここまでの文章を全て前置きだとすると、このようにアプリケーションの境界線が多様になるケースでは、境界づけられたコンテキスト単位でリポジトリを分けることを強くオススメしたいということです。マイクロサービスのように機能単位で使用するコンポーネントが分かれる場合、機能単位でコードrepositoryを分けがちです。(今回の例で言うと、registerUserRequest
, registerUser
のようにCloud Functions単位でコードrepositoryを分ける)
しかし、実際には新規ユーザを登録する
というユースケースに基づいて、同一のコンテキストで管理レベルを統一していくことがよりビジネス要件に近いレベルでコードを管理できると考えております。機能は突き詰めて言えば、特定のことができるorできないです。実際のユースケースではできるorできないではなく、このビジネスは何をやろうとしているのか
が最も重要なはずです。つまり、実現方法がマイクロサービスになろうと、サーバレスになろうと、コードの分割単位はできる限り境界づけられたコンテキスト単位で切り出すことを強くオススメしております。それができないくらいの規模になっているのであれば、そこではConsumer-Driven Contracts
(コンシューマー駆動契約, 略してCDCと呼ばれることもあります)を使ってサービス同士の結合点に対して契約を結ぶか、境界づけられたコンテキストを見直すいい機会だと私は考えています。
最後まで読んでいただきありがとうございます。それでは、よいクリスマス、年末年始、よいコードアーキテクチャを!
明日2019–12–05はToruさんによる”GCP のログ大全2019"です。乞うご期待を!!