複数のCloud Functions entrypointsを1 GitHub repository(NodeJS with TypeScript)で管理する <usecaseを添えて>

jumpeiarashi
google-cloud-jp
Published in
18 min readDec 4, 2019

--

この記事は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を返した時点でユーザ登録が完了している
syncronouse execution with HTTP Triger

パターン2: 非同期で行う(いわゆる結果整合ですね)

使い所としては、同期で行った際に重い処理の部分だけを非同期で行うようにして、別のCloud Functionsで行うというパターンを想定しています。

  • requestを受け取ったらCloud Pub/Subにユーザ情報とユーザ登録を行う旨をPublishする
  • Pub/Sub Triggers を用いてCloud Functionsがユーザを登録する
  • ユーザの登録が完了したらUIに通知を送る
asynchronous execution with HTTP Triger and Pub/Sub Trigger

それでは実際に開発環境の準備、同期で実行するパターン、非同期で実行するパターンの順で実装を見ていきましょう。

step0: 開発環境の準備

こちら https://github.com/jupemara/cloud-functions-typescript-boilerplate のrepositoryに

  1. HTTP Triggers Cloud Functions の entrypoint のみの実装
  2. Pub/Sub Triggers Cloud Functions の entrypoint のみの実装
  3. deploy用スクリプト
  4. Functions Framework を用いたローカルでの開発用スクリプト
  5. Functions FrameworkをPub/Sub Triggersとして起動した時のevent payloadを流し込むスクリプト(擬似的にPub/Sub Topicからのメッセージを受け取った時と同じ挙動をさせるためのスクリプト)

を用意したので、これらを使って各ユースケース、複数entrypointsの管理について解説を行います。

step1: パターン1の実装 HTTP Triggers にて同期でユーザを登録する

同期でユーザを登録するCloud Functionsを作成します。まずは機能要件を洗い出して、洗い出した機能要件をコードに落とし込んでいきましょう。ここでは、

  1. ユーザIDは英数文字+一部の記号(-,_,.)を用いて登録することができる
  2. ユーザIDは英字から始まっていなければいけない
  3. ユーザIDは最大で16文字の入力が可能
  4. ユーザ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ですね)を実装していきます。

  1. UserRepository向けのInterfaceをdomain/model配下に定義
  2. 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を見ていただくとして、ここではユースケースとして、

  1. userIdstring型で受け取る
  2. UserIdバリューオブジェクトを作成(この時同時にビジネス条件のvalidationも行われています)
  3. Userを作成
  4. UserRepositoryを使って、なんらかの方法で作成されたユーザを永続化

上記の内容が実装されていることがわかります。

つなぎこみ(Controller)

ここまで来てようやく内部実装が終わったので、外部からのインプットをハンドリングして、内部のユースケースにつなぎこむことが可能になります。

  1. src/adapter/controller/cloud-functions-http/register-user.tsとしてHTTP Trigger用のcontrollerを定義してユーザ登録ユースケースを実行する

公式ドキュメントにも記載がありますが、Cloud Functions HTTP Triggerでハンドルされる関数、function(req, res) {}の引数に入ってくるreq,resexpressで使用されている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の実装です。

  1. HTTP requestを受け取ったらPub/SubにメッセージをPublishする
  2. Cloud Functions Pub/Sub Triggerを実装
  3. 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の実装

  1. 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"です。乞うご期待を!!

--

--

jumpeiarashi
google-cloud-jp

All posts here are my personal opinion, not endorsed by any organization.