本文へ移動

InferDI ​

InferDI は、TypeScript 向けの依存性注入コンテナーです。デコレーター、リフレクション、実行時の依存関係を必要としません。@inferdi/hono ミドルウェアは、呼び出しごとにリクエストスコープを作成して c.var.di で公開し、ルートの処理が完了した後に破棄します。

コンテナーの型には依存関係グラフが含まれます。依存関係の不足、順序の誤り、不正なライフタイムの関係は TypeScript が報告します。

インストール ​

bash
npm install @inferdi/inferdi @inferdi/hono

NOTE

InferDI は JSR でも公開されています。Deno では、 deno add jsr:@inferdi/inferdi jsr:@inferdi/hono npm:hono を実行してください。

はじめに ​

1. コンテナーを構築する ​

リクエストデータは HTTP 境界で扱います。Hono の Context を依存関係グラフに含める代わりに、小さなアプリケーション用の型を定義してください。

ts
// container.ts
import { Container } from '@inferdi/inferdi'
import { connectDatabase, UserService } from './services'

export interface RequestContext {
  readonly requestId: string
  readonly userId: string | undefined
}

const config = {
  dsn: 'postgres://localhost/app',
} satisfies { readonly dsn: string }

export const root = new Container()
  .registerValue('config', config)
  .declareScopeInputs<{ request: RequestContext }>()
  .registerAsyncFactory(
    'db',
    (config: typeof config) => connectDatabase(config.dsn),
    ['config']
  )
  .registerClass('users', UserService, ['db', 'request'], 'scoped')

declareScopeInputs() は、型だけのスロットをグラフに追加し、値は登録しません。users は request に依存するため、スコープがその入力を提供するまで、InferDI はこのサービスの解決を許可しません。シングルトンはリクエストスコープのデータに依存できないため、users はスコープ単位のサービスです。

2. リクエストスコープを作成する ​

関数でアプリケーションが必要とする具体的なスコープを作成します。

ts
const openRequestScope = (request: RequestContext) =>
  root.createScope({ request })

type RequestScope = ReturnType<typeof openRequestScope>

3. ミドルウェアを追加する ​

InferdiHonoScopeEnv は、具体的な型を持つ準備済みの RequestScope を Hono のコンテキスト変数で公開します。

ts
import { Hono } from 'hono'
import { inferdiHono, type InferdiHonoScopeEnv } from '@inferdi/hono'

type AppEnv = InferdiHonoScopeEnv<RequestScope>

const app = new Hono<AppEnv>()

const createRequestScope = (userId: string | undefined) =>
  openRequestScope({
    requestId: crypto.randomUUID(),
    userId,
  })

app.use(
  '*',
  inferdiHono({
    container: root,
    createScope: (_root, c) =>
      createRequestScope(c.req.header('x-user-id')),
  })
)

ミドルウェアがルートコンテナーの引数なしの createScope() が返す型を使う場合は、InferdiHonoEnv<typeof root> が引き続き有用です。独自のスコープファクトリーは、この例のように、より具体的な型を返せます。そのため、この例では InferdiHonoScopeEnv<RequestScope> を使います。

4. サービスを解決する ​

準備済みの同期キーには get()、宣言的な非同期キーには getAsync() を使います。request 入力は同期ですが、users は db に依存するため非同期です。

ts
app.get('/users/:id', async (c) => {
  const request = c.var.di.get('request') // RequestContext
  const users = await c.var.di.getAsync('users') // UserService
  const user = await users.profile(c.req.param('id'))

  return c.json({ requestId: request.requestId, user })
})

export default app

c.get('di') は c.var.di と等価で、型も同じです。

非同期の依存関係 ​

registerAsyncFactory() は、Promise<Database> ではなく、最終的な Database 型をグラフに保存します。非同期という性質が UserService に伝播するため、どちらのサービスも getAsync() で解決します。getAsync() は準備済みの同期キーも受け付けますが、通常の同期サービスには get() を使ってください。

Promise を返す registerFactory() のコールバックは、意味が異なります。Promise 自体がサービスの値となり、グラフ上では同期のエントリーのままで、get() で解決します。

独自のコンテキストキー ​

key を渡すと、同じスコープを別の Hono コンテキスト変数で公開できます。独自のスコープファクトリーでも、宣言したリクエスト入力を提供する必要があります。

ts
type CustomEnv = InferdiHonoScopeEnv<RequestScope, 'container'>

const customKeyApp = new Hono<CustomEnv>()

customKeyApp.use(
  '*',
  inferdiHono({
    container: root,
    key: 'container',
    createScope: (_root, c) =>
      createRequestScope(c.req.header('x-user-id')),
  })
)

customKeyApp.get('/users/:id', async (c) => {
  const users = await c.var.container.getAsync('users')
  return c.json(await users.profile(c.req.param('id')))
})

オプション ​

inferdiHono は、次のオプションを受け付けます。

オプションデフォルト説明
container必須ルートコンテナー。ミドルウェアはこれを破棄しません。
key'di'c.var[key] と c.get(key) で使うコンテキスト変数。
createScoperoot.createScope()リクエストスコープを作成します。Hono から型付きの入力を渡せます。非同期でも構いません。
setupScopeなしスコープの作成後、ルートハンドラーの実行前に呼ばれます。非同期でも構いません。
disposeScopescope.dispose()リクエストスコープの破棄処理を置き換えます。非同期でも構いません。
autoDisposetrueアプリケーション側で破棄を行う場合は false に設定するか、false を返します。
onDisposeErrorconsole.errorリクエストスコープのクリーンアップ失敗を処理します。

ストリーミング ​

Hono の stream()、streamText()、streamSSE() は、コールバックが完了する前に Response を返す場合があります。レスポンスを返す前に skipInferdiDispose() を呼び出し、ストリームが終了した時点でスコープを破棄してください。

ts
import { streamText } from 'hono/streaming'
import { skipInferdiDispose } from '@inferdi/hono'

app.get('/users/:id/export', (c) => {
  skipInferdiDispose(c)

  const scope = c.var.di
  const id = c.req.param('id')

  return streamText(c, async (stream) => {
    try {
      const users = await scope.getAsync('users')
      const user = await users.profile(id)
      await stream.write(JSON.stringify(user) ?? 'null')
    } finally {
      await scope.dispose()
    }
  })
})

skipInferdiDispose() は、正常なレスポンスの場合にのみ自動クリーンアップを抑止します。レスポンスを返す前にルートが失敗した場合は、ミドルウェアがスコープを破棄します。

関連情報 ​

MIT ライセンスで公開されています。