バックエンド: 依存性注入
今回バックエンドの構築にクリーンアーキテクチャっぽいものを導入してみました。 たぶんクリーンアーキテクチャと言うと怒られる (※) ので、依存性注入 (dependency injection) をやったよ!と言う程度に言っておきます。
注意: 初心者です
※クリーンアーキテクチャをきちんと体系的に勉強したわけではないので間違っていることを言っている可能性があります。早くしっかり勉強しよう!
依存性注入とは
依存性注入 (dependency injection)とは、部品を交換可能にして、コードを整理しやすくする設計方法です。 例えば、車の部品を交換するように、データベースの種類を簡単に変えることができるようになります。
クリーンアーキテクチャとは
クリーンアーキテクチャは、ソフトウェアを「中心に大事なルールや動き、外側に道具や技術」という形で整理する考え方です。これにより、仕組みが分かりやすくなり、修正や新しい機能の追加がしやすくなります。
自分はこういうイメージで使いました。
Loading diagram...
ディレクトリ構成
画像生成サービス (api.trpfrog.net/icongen
) は次のようなディレクトリ構成になっています。
クリーンアーキテクチャを意識しています。
.
├── README.md
├── drizzle.config.ts
├── eslint.config.mjs
├── package.json
├── bin/
├── coverage/
├── migrations/
├── src
│ ├── index.ts # サービスのエントリーポイント
│ ├── client.ts # 他のサービスからこれを叩くための RPC クライアントを export している
│ ├── env.ts # Hono の Bindings などを定義している
│ ├── wire.ts # 依存性注入をする
│ ├── lib/ # 雑多な便利関数など
│ ├── controller/ # Hono のルータが入っている
│ ├── domain/ # このサービス内で使うインタフェース等のドメインモデルが入っている
│ ├── infra/ # このサービスが外部とやり取りするためのインフラ実装が入っている
│ └── usecases/ # ユースケースが入っている
├── tsconfig.json
├── vitest.config.mjs
├── worker-configuration.d.ts
└── wrangler.toml
コントローラ層
コントローラ層とは、ユーザーからのリクエストを受け取り、処理を振り分ける場所です。 ここでは Web フレームワークである Hono を使っています。
例えば src/controller には次のような実装が入っています。
export function createApp(ucs: UseCases) {
return new Hono<Env>()
.basePath(services.imageGeneration.basePath)
.use(contextStorage())
.use(prettyJSON())
.use(trimTrailingSlash())
.use(cors())
.get('/current', async c => {
const arrayBuffer = await ucs.currentImage()
return c.newResponse(arrayBuffer)
})
.get('/current/metadata', async c => {
const data = await ucs.currentMetadata()
if (data == null) {
return c.json({ error: 'No metadata found' }, 404)
}
return c.json(data)
})
.post('/update', requiresApiKey(), async c => {
const result = await ucs.refreshImageIfStale({
forceUpdate: c.req.query('force') === 'true',
})
return result.updated
? c.json({ status: 'updated' }, 201) // 201 Created
: c.json({ status: 'skipped', message: result.message })
})
ここは実際のルーティングをする層です。 ここにはロジックを書かず、ユースケースを呼び出すだけにしています。 リクエストからデータを取り出して、ユースケースに渡しています。
ここにロジックを書かないことによって、今後 Web フレームワークを変更する機会があっても、ルーティングとデータ加工の部分だけを置き換えるだけで移行が完了します。 ロジックのことを考えて移行をしなくて良いのがメリットです。
ユースケース層
ユースケース層とは、サービス内で使う純粋なロジックを行う場所です。使う外部サービス等に依存しないロジックを書きます。ビジネスロジックと言ったりします。
あとで説明するドメイン層で定義した型・インタフェースを使ってロジックを書くので、外部サービスに依存しません。
次の例は画像を DB とオブジェクトストレージにアップロードするためのユースケースです。
interface UploadNewImageUseCaseDependencies {
imageStoreRepo: ImageStoreRepo
imageMetadataRepo: ImageMetadataRepo
}
export function uploadNewImageUseCase(deps: UploadNewImageUseCaseDependencies) {
return async (
imageData: ArrayBuffer,
metadata: {
prompt: ImagePrompt
modelName: string
createdAt?: Date
imageExtension: string
},
) => {
// ここからロジック
const { prompt, modelName, createdAt = new Date(), imageExtension: ext } = metadata
const filename = `${format(createdAt, 'yyyy-MM-dd-HH-mm-ss')}${ext}`
const imageUri = await deps.imageStoreRepo.upload(filename, imageData)
try {
const metadataRecord = { id: uuidv7(), prompt, modelName, createdAt, imageUri }
await deps.imageMetadataRepo.add(metadataRecord)
} catch (e) {
console.error(e)
await deps.imageStoreRepo.delete(filename)
throw e
}
}
}
ここでは
- 画像データとメタデータを受け取る
- 画像データをオブジェクトストレージにアップロード
- 画像URLとIDを含めたメタデータを DB に保存
- 途中でエラーが発生した場合はロールバック
という処理を書いています。ここで、画像ストアやメタデータストアの具体的な実装は引数 deps
によって渡されます。
具体的な実装を uploadNewImageUseCase
に渡すと、"依存"の注入されたユースケース本体の関数が得られます。
export function uploadNewImageUseCase(deps: UploadNewImageUseCaseDependencies) {
return async (...args) => {
// 依存注入後の関数
}
}
この deps: UploadNewImageUseCaseDependencies
の一部である ImageStoreRepo
の定義 (ドメイン層) は次のようになっています。
ドメイン層とは、このサービスで扱う中心的なデータやルールを定義する場所です。
export interface ImageStoreRepo {
upload: (filename: string, imageData: ArrayBuffer) => Promise<string> // 戻り値はURI
download: (filename: string) => Promise<ArrayBuffer> // 画像データを取得
delete: (filename: string) => Promise<void> // 画像データを削除
}
このようにインターフェースのみが書かれていることが分かります。 DBやオブジェクトストレージに関する実装は含まれておらず「upload を呼べばアップロードできる」「download を呼べばダウンロードできる」ということだけが書かれています。 このようにすることで、ロジックと使う DB などのインフラを分離できます。
私は一度、つまみネットにツイート一覧をみるページを作ったことがあるのですが、使っていた PlanetScale という DB のサービスの無料プランが終了したことから、DB の移行を余儀なくされました。しかし、このようにインフラとロジックがガチガチに結びついていたために改修がめんどくなり、今も移行を放置している状況です……。このような状況を回避するためにも依存性注入は重要だと思っています。
インフラ層
インフラ層とは、データベースや外部サービスなど、具体的な技術の実装を行う場所です。
ユースケース層では、ドメイン層で定義したインターフェースを使ってロジックを書きました。 今度は DB などの外部サービスを "ドメイン層に合わせるように" 実装します。
今回は画像ストアには Cloudflare R2 (オブジェクトストレージ) を使っています。次のような実装になっています。
import { getContext } from 'hono/context-storage'
import { ImageStoreRepo } from '../../domain/repos/image-store-repo'
export const imageStoreRepoCloudflareR2: ImageStoreRepo = { // ImageStoreRepo を実装
upload: async (filename: string, imageData: ArrayBuffer) => {
const c = getContext<Env>()
const filePath = toR2Key(filename)
await c.env.BUCKET.put(filePath, imageData)
return `https://media.trpfrog.net/${filePath}`
},
download: async (filename: string) => {
const c = getContext<Env>()
const filePath = toR2Key(filename)
const res = await c.env.BUCKET.get(filePath)
if (res === null) {
throw new Error('Not found')
}
return await res.arrayBuffer()
},
delete: async (filename: string) => {
const c = getContext<Env>()
const filePath = toR2Key(filename)
await c.env.BUCKET.delete(filePath)
},
}
これを uploadNewImageUseCase
に渡すことで、画像のアップロードができるようになりました。
このように依存を注入するアーキテクチャを使うことで「コントローラ」「ロジック」「インフラ」の実装を分離してあげることができて嬉しいです。
インフラ部分を分けることにはもう一つ嬉しいことがあって、テストが描きやすくなると言うメリットがあります。
例えば、uploadNewImageUseCase
のテストをしたい場合は、要求している依存のインタフェースを実装したダミーオブジェクトを注入してあげると、
実際の DB とやり取りせずに、純粋なロジックのテストが可能になります。依存性注入で一番嬉しかったポイントです。
また、ユースケースのテストだけでなく、コントローラのテストにもこの手法は使えるので、モックに頭を悩ますことが減ってとても楽になりました。
hono/context-storage
Cloudflare D1 や R2 は "Bindings" として提供されています。
const app = new Hono()
.get('/current', async c => {
// `c.env.BUCKET` で R2 にアクセスできる
const arrayBuffer = await c.env.BUCKET.get('current')
return c.newResponse(arrayBuffer)
})
せっかく Bindings から簡単にアクセスできるようになっているのに、このように Hono のコントローラ部分とインフラ部分を分けてしまうと Bindings を使うことが難しくなってしまいます。
そこで最近 Hono に追加された hono/context-storage を使うと簡単に Bindings にアクセスできるようになりました。
import { contextStorage } from 'hono/context-storage'
const app = new Hono()
.use(contextStorage())
.get('/current', async c => {
// ...
})
import { getContext } from 'hono/context-storage'
export const imageStoreRepoCloudflareR2: ImageStoreRepo = {
upload: async (filename: string, imageData: ArrayBuffer) => {
const c = getContext<Env>()
const filePath = toR2Key(filename)
await c.env.BUCKET.put(filePath, imageData)
// ...
},
// ...
}
今回分離する上でとても助かりました!便利〜
全部をつなげる
現状では
- ユースケース層はドメイン層のインターフェースを使っている
- インフラ層はドメイン層のインタフェースを見ている
- コントローラはユースケース層の型だけを見ている
という状況で、実装が注入されていません。型という虚像だけを見ている状態なので、うまく繋げてあげる必要があります。
今回私はコントローラを作るための関数、createApp
を次のように作りました。
import { UseCases } from '../wire'
export function createApp(ucs: UseCases) {
return new Hono<Env>()
// ...
.get('/current', async c => {
const arrayBuffer = await ucs.currentImage()
return c.newResponse(arrayBuffer)
})
// ...
}
依存注入後のユースケース一覧を引数として与えると、依存の注入が完了したコントローラができます。
ユースケースへの依存注入は気合いでやります。
気合いで依存注入
import { z } from 'zod'
import { createApp } from './controller'
import { prepareUsecasesBuilder } from './wire'
// インフラ層の実装
import { imageMetadataRepoCloudflareD1WithKV } from './infra/repos/imageMetadataRepoCloudflareD1WithKV'
import { imageStoreRepoCloudflareR2 } from './infra/repos/imageStoreRepoCloudflareR2'
import { createOpenAIChatLLMJson } from './infra/services/llm'
import { randomWordApi } from './infra/services/random-words'
import { createHfImageGenerator } from './infra/services/text-to-image'
const env = z
.object({
HUGGINGFACE_TOKEN: z.string(),
OPENAI_API_KEY: z.string(),
})
// eslint-disable-next-line n/no-process-env
.parse(process.env)
const app = createApp(
prepareUsecasesBuilder({
imageStoreRepo: imageStoreRepoCloudflareR2,
imageMetadataRepo: imageMetadataRepoCloudflareD1WithKV,
textToImage: createHfImageGenerator({
modelName: 'Prgckwb/trpfrog-sd3.5-large-lora',
hfToken: env.HUGGINGFACE_TOKEN,
}),
jsonChatbot: createOpenAIChatLLMJson({
model: 'gpt-4o-2024-11-20',
temperature: 0.9,
apiKey: env.OPENAI_API_KEY,
}),
generateSeedWords: () => randomWordApi(10),
})
)
import * as rawUsecases from './usecases'
// 共通の依存関係を受け取る型を定義
type CommonDependencies = {
imageStoreRepo: ImageStoreRepo
imageMetadataRepo: ImageMetadataRepo
textToImage: TextToImage
jsonChatbot: ChatLLMJson
generateSeedWords: () => Promise<string[]>
}
// ユースケースを手動で組み立てる関数
export function prepareUsecasesBuilder(common: CommonDependencies): UseCases {
const { imageStoreRepo, imageMetadataRepo, textToImage, jsonChatbot, generateSeedWords } = common
// 基本的なユースケースのインスタンスを作成
const currentImage = rawUsecases.currentImageUseCase({ imageMetadataRepo, imageStoreRepo })
const currentMetadata = rawUsecases.currentMetadataUseCase({ imageMetadataRepo })
const generateImage = rawUsecases.generateImageUseCase({ textToImage })
const generatePromptFromWords = rawUsecases.generatePromptFromWordsUseCase({ jsonChatbot })
const generateRandomWords = rawUsecases.generateRandomWordsUseCase({ generateSeedWords })
const uploadNewImage = rawUsecases.uploadNewImageUseCase({ imageMetadataRepo, imageStoreRepo })
const queryImageMetadata = rawUsecases.queryImageMetadataUseCase({ imageMetadataRepo })
// 依存関係を持つ派生ユースケースのインスタンスを作成
const generateRandomImage = rawUsecases.generateRandomImageUseCase({
generateImage: (prompt: string) => generateImage(prompt, { numberOfRetries: 3 }),
generatePromptFromSeedWords: (seedWords: string[]) => generatePromptFromWords(seedWords),
generateSeedWords: generateRandomWords,
})
const refreshImageIfStale = rawUsecases.refreshImageIfStaleUseCase({
imageMetadataRepo,
uploadImage: uploadNewImage,
imageGenerator: generateRandomImage,
})
return {
currentImage,
currentMetadata,
generateImage,
generatePromptFromWords,
generateRandomWords,
uploadNewImage,
queryImageMetadata,
generateRandomImage,
refreshImageIfStale,
}
}
実際はこれは嘘で、もう少し簡単に書けるようなライブラリを作って使っています。
とはいえ試作段階であまり納得いってないのでもっと別の方法を探しています。 やはり大人しく DI コンテナ (依存性注入を自動化するやつ) のライブラリを導入すべきか……自動生成コードのアンチなので、TypeScript の型をうまく使って快適に依存注入を書く方法がないか考えています。 とはいえ型はランタイムに干渉できないので、自動生成に手をつけるしかないのか、グヌヌ……
メリットとデメリット
ということで画像生成サービスにクリーンアーキテクチャ (だと思う) を導入してみました。
さっきの図のようなことができるようになりました。
Loading diagram...
メリットとして、クリーンアーキテクチャのフレームワークに乗っかったことによって自然と疎結合なコードを書けるようになったことが挙げられます。
始めはコントローラ・ドメイン・インフラで型を共有しないことに DRY っぽさを感じて嫌だったのですが、よく考えれば外部サービスや外部ライブラリに依存する型は、今後の改修によって変わってしまう可能性があります。Request で受けたデータをドメイン層の型に詰め替える作業をすることで、外部の依存の変更に対しても柔軟に対応できるようになったんじゃないかなあと思います。PlanetScale 無料枠サ終からの移行困難という悲劇を経験した私にとっては、かなり嬉しい気持ちがあります。
デメリットとしては実装コストがデカすぎることが挙げられます。 もう Hono のルータの中に全部書いてしまえば良さそうなものを、わざわざ別ファイルに実装することは効率の悪さを感じます。 ただ、これは最初の実装時に感じるつらさであって、メンテ時はこの構造のおかげで楽をできるようになるはずなのでやって良かったと思っています。たぶん、多分やって良かったです。
今受けられている大きな恩恵としては、先ほども書きましたがテストを書きやすくなったことです。今までは vi.spy
とかして無理やりモックしていた箇所を、正当な方法でモックできるようになったのが嬉しいです。わりとこれだけでもやった価値はあったと思います。