Cloudflare Workers で Dependency Injection
NOT A HOTEL ではガッツリ Cloudflare Workers の上でアプリケーションを動かしています。
本格的にアプリケーションを開発しようとすると、ある機能 A を作成するために、それに依存する機能 B、機能 C を事前にセットアップしなければいけません。これらをスッキリさせる手法として Dependency Injection(以下 DI)があります。
環境変数もセットアップに必要な機能です。うちではどのように DI を行なっているか、一つの事例として紹介します。言語は TypeScript です。
ディレクトリ構成
di.ts
を worker.ts と同じ階層に作成しています。
src
├── di.ts
└── worker.ts
di.ts
の中身
以下のようなコードを書いています。
使う時は new DIContainer(env, req)
もしくは new DIContainer(env)
と記述して利用できます。DIContainer
にリクエスト情報を依存させているのはログにその情報を決まったフォーマットで追記するためです。また、オプショナルにしているのは、このクラスが Cloudflare Queues のハンドラや Cron Triggers でも利用することを想定しているからです。
DIContainer
クラスは cleanup
メソッドを持っています。
これは Promise<void>
になっているので ExecutionContext
の waitUntil
メソッドを組み合わせて実行できます。なので後処理はレスポンス速度に依存しません。 ctx.waitUntil(di.cleanup())
この DI の設計は一度組み立てたものを使いまわせるところに優位性があります。
export type Env = {
LOGGING_QUEUE: Queue<LogEntry>;
GCP_SERVICE_ACCOUNT: string;
DIALOGFLOW_CX_ENV_ID: string;
TARGET_HOST: string;
}
export class DIContainer {
private _dialogflowCX?: DialogflowCX;
private _logger?: Logger;
private _chat?: Chat;
private _apiClient?: ApiClient
constructor(
private readonly env: Env,
private readonly req?: Request, // わざとオプショナル
) {}
async cleanup(): Promise<void> {
// 全体の処理の最後に実行したいメソッド一覧をここに書く
const callers = new Map<string, () => Promise<void>>()
callers.set("logger", this.logger.sendEntries)
for (const [caller, cleanup] of callers) {
try {
await cleanup()
} catch (err) {
// LogPush 頼り
console.error(`cleanup error at ${caller}:`, err)
}
}
}
get logger(): Logger {
return (
this._logger ??
(this._logger = new Logger({
broker: this.env.LOGGING_QUEUE,
serviceName: 'chat-api',
serviceAccount: this.env.GCP_SERVICE_ACCOUNT,
req: this.req,
}))
);
}
get dialogflowCX(): DialogflowCX {
const gcpLocation = 'asia-northeast2';
const agentId = 'a7593dda-7250-4307-9660-28671628377b';
return (
this._dialogflowCX ??
(this._dialogflowCX = new DialogflowCX(
this.env.GCP_SERVICE_ACCOUNT,
gcpLocation,
agentId,
this.env.DIALOGFLOW_CX_ENV_ID,
))
);
}
get chat(): Chat {
return (
this._chat ??
(this._chat = new Chat(
this.logger,
this.dialogflowCX,
))
);
}
get apiClient(): ApiClient {
return (
this._apiClient ??
(this._apiClient = new ApiClient(
this.logger,
this.env.TARGET_HOST,
))
);
}
}
honojs のミドルウェアを組み合わせる
honojs は Middleware を使って全てのパスに対して処理を挟むことができます。これらのコードも di.ts
に記述します。
type Variables = {
di: DIContainer;
};
export type HonoTypes = { Bindings: Env; Variables: Variables };
export const app = new Hono<HonoTypes>();
app.use('*', async (c, next) => {
const di = new DIContainer(c.env, c.req.raw);
c.set('di', di);
await next();
if (c.error) {
di.logger.write('error', c.error);
}
c.executionCtx.waitUntil(di.cleanup()); // お掃除
});
このおかげでハンドラでは簡単に DI を呼び出せるようになります。そして各ハンドラは cleanup 処理について気にする必要はありません。
import { app } from './di';
app.post('/chat/message', async (c) => {
const di = c.get('di');
const { message } = await c.req.json<{ message: string; }>();
await di.chat.sendMessage(message)
})
Queue や Cron での呼び出し
参考までに適当にでっちあげた例を載せておきます。
import { app, DIContainer, type Env } from './di';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return await app.fetch(request, env, ctx);
},
async queue(batch: MessageBatch<unknown>, env: Env) {
const di = new DIContainer(env);
await di.logger.sendLogs(batch.messages)
await di.cleanup()
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const di = new DIContainer(env);
const stock = await di.apiClient.getStock()
await env.STOCK_KV_NAMESPACE.put("cache-key", JSON.stringify(stock))
ctx.waitUntil(di.cleanup())
}
}
Discussion
こうした方がもっと便利だよ!を募集しています!