🐣

そうだ Next.js 13でDependency Injection、しよう。

2023/05/23に公開

はじめに

最近、Next.js 13のプロジェクトにDependency Injection(DI)を導入してみました。現時点ではNext.js 13 + DIについて、まだあまり情報が見当たらない気がしたので記事にしてみました。

誰向けなのか

  • DIは知っている
  • Next.js 13でDIしたい

InversifyJSの導入

いきなりですがInversifyJSを使います

InversifyJSはTypeScriptでもっとも利用者が多いといわれているDIライブラリーです。
実は今回いくつかのDIライブラリーを試したのですが、あくまで現時点ではありますが、Next.js 13への導入についてはInversifyJSがいちばんスムーズに感じました。そのため、この記事ではこちらをAPIで動作確認する部分まで紹介できればと思います。(TSyringeも動くことは動きました)
https://inversify.io/

プロジェクトを用意する

既存プロジェクトの場合は該当ディレクトリに移動します。

$ cd ./my-app

新規プロジェクトの場合はnpx create-next-appし、プロジェクトのディレクトリに移動します。

$ npx create-next-app
√ What is your project named? ... my-app
√ Would you like to use TypeScript with this project? ... No / Yes
√ Would you like to use ESLint with this project? ... No / Yes
√ Would you like to use Tailwind CSS with this project? ... No / Yes
√ Would you like to use `src/` directory with this project? ... No / Yes
√ Use App Router (recommended)? ... No / Yes
√ Would you like to customize the default import alias? ... No / Yes
$ cd ./my-app

必要なパッケージをインストールする

npm install inversify reflect-metadata

なお、この時点での各バージョンは以下になります。

{
  "dependencies": {
    // ...
    "inversify": "^6.0.1",
    "next": "13.4.3",
    // ...
    "reflect-metadata": "^0.1.13",
    // ...
    "typescript": "5.0.4"
  },
}

tsconfig.jsonを更新する

tsconfig.jsonにてcompilerOptions.experimentalDecoratorscompilerOptions.emitDecoratorMetadataを有効にします。

./tsconfig.json

{
  "compilerOptions": {
    // ...
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // ...
  },
}

インターフェイスと型を宣言する

なにか適当にインターフェイスと型を用意します。ここでは公式に習ってinterfaces.tstypes.tsを作ります。

./src/interfaces.ts

export interface Warrior {
  fight(): string
  sneak(): string
}

export interface Weapon {
  hit(): string
}

export interface ThrowableWeapon {
  throw(): string
}

./src/types.ts

const TYPES = {
  Warrior: Symbol.for('Warrior'),
  Weapon: Symbol.for('Weapon'),
  ThrowableWeapon: Symbol.for('ThrowableWeapon'),
}

export { TYPES }

クラスを用意してデコレーターで依存関係を宣言する

なにか適当にクラスを用意し、依存関係を宣言します。ここも公式に習って以下のentities.tsを作ります。なお、プロジェクト構成の関係でimport周りが少しだけ変わっています。

./src/entities.ts

import { injectable, inject } from 'inversify'
import type { Weapon, ThrowableWeapon, Warrior } from './interfaces'
import { TYPES } from './types'

@injectable()
class Katana implements Weapon {
  public hit() {
    return 'cut!'
  }
}

@injectable()
class Shuriken implements ThrowableWeapon {
  public throw() {
    return 'hit!'
  }
}

@injectable()
class Ninja implements Warrior {
  private _katana: Weapon
  private _shuriken: ThrowableWeapon

  public constructor(
    @inject(TYPES.Weapon) katana: Weapon,
    @inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
  ) {
    this._katana = katana
    this._shuriken = shuriken
  }

  public fight() {
    return this._katana.hit()
  }
  public sneak() {
    return this._shuriken.throw()
  }
}

export { Ninja, Katana, Shuriken }

DIコンテナーを作成して構成する

DIコンテナーを構成するファイルinversify.config.tsを作ります。ここでは、これまで作ったインターフェイス・型・クラスを用いて、myContainerを生成するように構成します。

./src/inversify.config.ts

import { Container } from 'inversify'
import { TYPES } from './types'
import { Warrior, Weapon, ThrowableWeapon } from './interfaces'
import { Ninja, Katana, Shuriken } from './entities'

const myContainer = new Container()
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja)
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana)
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken)

export { myContainer }

サーバー起動時にDIコンテナーを初期化する

現行Next.js 13.4では、新しいInstrumentation機能を有効にすることでサーバー起動時に任意の処理を行うことができるようです。

まずはnext.config.jsexperimental.instrumentationHooktrueにします。

./next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    instrumentationHook: true
  }
}

module.exports = nextConfig

次にinstrumentation.tsを作成し、reflect-metadatainversify.configimportするように構成します。

./src/instrumentation.ts

import 'reflect-metadata'

export async function register() {
  if (process.env['NEXT_RUNTIME'] === 'nodejs') {
    await import('./inversify.config')
  }
}

これでinversify.configはサーバー起動時に読み込まれ、myContainerが生成されるようになります。今回のDIコンテナーの構成そのままだと有効性はあまりない気がしますが、例えばサーバー起動時に予め生成したインスタンスを登録しておきたいケースなどはありがちかなと想像しており、そういった場合は有効性が出てくるかと思います。

なお、inversify.configは副作用があるためNEXT_RUNTIMEnodejsの場合にawaitしています。詳しくは以下をご参照ください。
https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation

APIで動作確認する

なにか適当にAPIを用意します。ここでは/api/helloで呼び出せるAPIができるよう、以下のhello.tsファイルを作ります。

./src/pages/api/hello.ts

import { NextApiRequest, NextApiResponse } from 'next'
import { myContainer } from '../../inversify.config'
import { Warrior } from '../../interfaces'
import { TYPES } from '../../types'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ninja = myContainer.get<Warrior>(TYPES.Warrior)
  res.status(200).json({
    ninja: {
      fight: ninja.fight(),
      sneak: ninja.sneak(),
    },
  })
}

次にdevサーバーを起動します。

$ npm run dev

最後にhttp://localhost:3000/api/helloにGETリクエストを投げてレスポンスを確認します。

$ curl http://localhost:3000/api/hello
{"ninja":{"fight":"cut!","sneak":"hit!"}}

ご覧のとおり、Katanaと はShuriken正常に解決され、Ninjaに挿入されました。

サンプルプロジェクト

今回のサンプルプロジェクトは以下に置いてあります。
https://github.com/yopiidev/nextjs13-di

所感

DIはプロジェクトの規模や状況によっては必須ではないと思いますが、直近のプロジェクトではDIの導入により、プロジェクト全体の見通しが向上したと感じます。Next.js 13はまだ新しいバージョンであり、情報が不足していると感じますが、これからの成長が楽しみなフレームワークの1つです。また新しいトピックや面白そうな情報があれば共有していければと思います。この記事が誰かの役に立つことがあれば幸いです。

Discussion