Skip to main content

JavaScriptにおける依存性注入

著者

Jerry Ejonavi

feature buffer overflow

2022年11月17日

0 分で読めます

制御の反転(IoC)技法を使うと、開発者は従来のプログラミングの流れから抜け出し、コードをより柔軟に、より細かく制御できるようになります。IoCの一種である依存性注入は、オブジェクトの構築と使用を切り分けることを目的としたパターンです。

この記事では、依存性注入とは何か、どのような場合に使うべきか、また、どの人気JavaScriptフレームワークに実装されているかを解説します。

依存性注入とは?

依存性注入とは、オブジェクトや関数が、内部実装の詳細を気にせずに他のオブジェクトや関数(依存関係)を利用するソフトウェア設計パターンです。必要な場所に依存関係を提供する役割は、インジェクター(アセンブラー、プロバイダー、コンテナーとも呼ばれます)が担います。

たとえば、ビデオゲーム機は、互換性のあるディスクやカートリッジがあれば動作します。ディスクやカートリッジには、それぞれ異なるゲームの情報が収録されています。プレイヤーはゲーム機の内部構造を知る必要はなく、通常はゲームをプレイするためにディスクを挿入したり交換したりするだけです。

このようにソフトウェアを設計したりコードを書いたりすると、コンポーネントの削除や置き換え(上の例のように)、ユニットテストの作成、コードベースの定型コードの削減が容易になります。

依存性注入は、特にJava、C#、Pythonなどの言語では一般的に実装・利用されているパターンですが、JavaScriptではあまり見られません。以降では、このパターンのメリットとデメリット、人気のJavaScriptフレームワークでの実装方法、そして依存性注入を念頭に置いたJavaScriptプロジェクトの構築方法を紹介します。

依存性注入が必要な理由

依存性注入が役立つ理由を十分に理解するには、IoC技法がどのような問題を解決するのかを知ることが重要です。たとえば、先ほどのビデオゲーム機の例を考えてみましょう。その機能をコードで表現してみます。この例では、ゲーム機をNoSleepStationと呼び、次のように仮定します。

  • NoSleepStationは、NoSleepStation向けに設計されたゲームのみをプレイできます。

  • ゲーム機で使用できる入力ソースは、コンパクトディスクだけです。

この情報をもとに、NoSleepStationは次のように実装できます。

// The GameReader class
class GameReader {
  constructor(input) {
    this.input = input;
  }
  readDisc() {
    console.log("Now playing: ", this.input);
  }
}

// The NoSleepStation Console class
class NSSConsole {
  gameReader = new GameReader("TurboCars Racer");

  play() {
    this.gameReader.readDisc();
  }
}

// use the classes above to play 
const nssConsole = new NSSConsole();
nssConsole.play();

ここでは、ゲーム機の中核となるロジックがGameReaderクラスにあり、依存先としてNSSConsoleがあります。コンソールのplayメソッドは、GameReader インスタンスを使ってゲームを起動します。しかし、柔軟性やテストなど、いくつかの問題が見受けられます。

柔軟性

先ほどのコードには柔軟性がありません。ユーザーが別のゲームをプレイしたい場合、NSSConsoleクラスを変更する必要があります。これは、実際のゲーム機を分解するようなものです。中核となる依存関係であるGameReaderクラスが、NSSConsoleの実装にハードコードされているためです。

依存性注入は、クラスとその依存関係を切り離し、必要なときにだけ依存関係を提供することで、この問題を解決します。先ほどのコード例では、NSSConsoleクラスに必要なのは、GameReaderインスタンスのreadDisc()メソッドだけです。

依存性注入を使うと、先ほどのコードは次のように書き換えられます。

class GameReader {
  constructor(input) {
    this.input = input;
  }

  readDisc() {
    console.log("Now playing: ", this.input);
  }

  changeDisc(input) {
    this.input = input;
    this.readDisc();
  }
}

class NSSConsole {
  constructor(gameReader) {
    this.gameReader = gameReader;
  }

  play() {
    this.gameReader.readDisc();
  }

  playAnotherTitle(input) {
    this.gameReader.changeDisc(input);
  }
}

const gameReader = new GameReader("TurboCars Racer");
const nssConsole = new NSSConsole(gameReader);

nssConsole.play();
nssConsole.playAnotherTitle("TurboCars Racer 2");

このコードで最も重要な変更点は、NSSConsoleクラスとGameReader クラスが切り離されていることです。NSSConsoleが動作するには引き続きGameReaderインスタンスが必要ですが、それを明示的に作成する必要はありません。GameReaderインスタンスを作成してNSSConsoleに渡す役割は、依存性注入プロバイダーが担います。

大規模なコードベースでは、依存関係の作成と接続を依存性注入プロバイダーが処理するため、定型コードを大幅に削減できます。つまり、特定のクラスが必要とするクラスのインスタンスを作成する必要がありません。依存先がさらに依存関係を持つ場合は、特に役立ちます。

テスト

依存性注入の最も重要なメリットの一つは、ユニットテストにあります。クラスの依存関係を外部プロバイダーに用意してもらうことで、テスト対象ではないオブジェクトの代わりにモックやスタブを使用できます。

// nssconsole.test.js
const gameReaderStub = {
  readDisc: () => {
    console.log("Read disc");
  },
  changeDisc: (input) => {
    console.log("Changed disc to: " + input);
  },
};

const nssConsole = new NSSConsole(gameReaderStub);

依存関係のインターフェース(公開されているメソッドやプロパティ)が変わらなければ、実際のインスタンスの代わりに、同じインターフェースを持つシンプルなオブジェクトを使ってユニットテストを実施できます。

依存性注入の制約

依存性注入にもデメリットがあることに注意が必要です。通常、そのデメリットは、コードの定型部分を減らすという問題に依存性注入がどう取り組むかに関係しています。

依存性注入のメリットを最大限に得るには、特に大規模なコードベースの場合、依存性注入プロバイダーを設定する必要があります。この設定には多くの作業が必要となり、シンプルなプロジェクトでは余計な負担になることもあります。

依存性注入ライブラリやフレームワークを使えば、この問題を解決できます。ただし、設定方法に関して異なる依存性注入フレームワーク間で共通するAPIが少ないため、ほとんどの場合、依存性注入フレームワークを全面的に採用する必要があります。

人気のJavaScriptフレームワークにおける依存性注入

依存性注入は、多くの人気JavaScriptフレームワークに欠かせない機能です。特にAngular()、NestJS、Vue.jsで利用されています。

Angular

Angularは、Webアプリケーションの構築、テスト、保守に必要なツールがそろったクライアントサイドのWeb開発フレームワークです。依存性注入がフレームワークに組み込まれているため、設定は不要で、Angularを使った開発に推奨される方法です。

以下は、Angularで依存性注入がどのように機能するかを示すコード例です(Angularのドキュメントから引用)。

// logger.service.ts
import { Injectable } from '@angular/core';

@Injectable({providedIn: 'root'})
export class Logger {
  writeCount(count: number) {
    console.warn(count);
  }
}

// hello-world-di.component.ts
import { Component } from '@angular/core';
import { Logger } from '../logger.service';

@Component({
  selector: 'hello-world-di',
  templateUrl: './hello-world-di.component.html'
})
export class HelloWorldDependencyInjectionComponent  {
  count = 0;

  constructor(private logger: Logger) { }

  onLogMe() {
    this.logger.writeCount(this.count);
    this.count++;
  }
}

Angularでは、クラスに@Injectableデコレーターを付けることで、そのクラスを注入可能にします。注入可能なクラスを依存先で利用できるようにする方法は、次の3つです。

  • コンポーネントレベルで@Componentデコレーターのprovidersフィールドを使用する

  • NgModuleレベルで@NgModule デコレーターのprovidersフィールドを使用する

  • アプリケーションのルートレベルで@InjectableデコレーターにprovidedIn: 'root'を追加する(先ほどの例を参照)

Angularで依存関係を注入するには、クラスのコンストラクターでその依存関係を宣言するだけです。先ほどのコード例では、次の行がその方法を示しています。

constructor(private logger: Logger) // Angular injects an instance of the LoggerService class

NestJS

NestJSは、高効率で信頼性が高く、拡張性に優れたバックエンドアプリケーションの構築に適したアーキテクチャを持つJavaScriptフレームワークです。設計においてAngularから多くの影響を受けているため、NestJSの依存性注入はAngularとよく似た仕組みで動作します。

// logger.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class Logger {
  writeCount(count: number) {
    console.warn(count);
  }
}

// hello-world-di.component.ts
import { Controller } from '@nestjs/common';
import { Logger } from '../logger.service';

@Controller('')
export class HelloWorldDependencyInjectionController  {
  count = 0;

  constructor(private logger: Logger) { }

  onLogMe() {
    this.logger.writeCount(this.count);
    this.count++;
  }
}

NestJSとAngularでは、通常、依存関係はシングルトンとして扱われます。つまり、依存関係が見つかると、その値はキャッシュされ、アプリケーションのライフサイクルを通じて再利用されます。NestJSでこの動作を変更するには、@Injectableデコレーターのオプションで`scope`プロパティを設定します。Angularでは、@Injectableデコレーターのオプションで`providedIn`プロパティを設定します。

Vue.js

Vue.jsは、ユーザーインターフェースを構築するための宣言的でコンポーネントベースのJavaScriptフレームワークです。Vue.jsの依存性注入は、コンポーネントの作成時に`provide`と`inject`オプションを使って設定できます。

コンポーネントの子孫で利用できるデータを指定するには、provideオプションを使います。

export default {
provide: {
message: 'hello!',
}
}

必要なコンポーネントでは、inject オプションを使ってこれらの依存関係を注入できます。

export default {
  inject: ['message'],
  created() {
    console.log(this.message) // injected value
  }
}

AngularのprovidedIn: 'root'と同様に、アプリレベルの依存関係を提供するには、Vue.jsのアプリインスタンスからprovideメソッドを使います。

import { createApp } from 'vue'

const app = createApp({})

app.provide(/* key */ 'message', /* value */ 'hello!')

JavaScriptの依存性注入フレームワーク

ここまでは、JavaScriptフレームワークにおける依存性注入を見てきました。では、先ほどのフレームワークを使わずにプロジェクトを始めたい場合や、依存性注入コンテナー/リゾルバーを一から実装したくない場合はどうすればよいでしょうか。

このようなケースで依存性注入機能を提供するライブラリがいくつかあります。たとえばinjection-js、InversifyJS、TSyringeです。次のセクションではInversifyJSに焦点を当てますが、他のパッケージも確認して、ニーズにより合うものがないか検討するとよいでしょう。

InversifyJS

InversifyJSはJavaScript向けの依存性注入コンテナーです。実行時のオーバーヘッドをできるだけ抑えながら、優れたオブジェクト指向プログラミング(OOP)とIoCの実践を促進できるように設計されています。プロジェクトのリポジトリはGitHubで確認できます。

プロジェクトでInversifyJSを使うには、依存関係に追加する必要があります。ここでは、これまで使ってきたゲームの例にInversifyJSを設定します。まずnpm initで新しいプロジェクトを作成します。ターミナルを開き、次のコマンドを順番に実行してください。

mkdir inversify-di
cd inversify-di
npm init

次に、npmまたはYarnを使ってinversifyをプロジェクトの依存関係に追加します。

# Using npm
npm install inversify reflect-metadata

# or if you use yarn: 
# yarn add inversify reflect-metadata

InversifyJSはMetadata Reflection APIに依存しているため、reflect-metadataパッケージをポリフィルとしてインストールし、使用する必要があります。

次に、空のファイルを2つ作成して、NSSConsoleクラスとGameReaderクラスのコードを追加します。

touch game-reader.mjs nssconsole.mjs

続いて、それぞれのファイルに次のコードを追加します。

// game-reader.mjs
export default class GameReader {
  constructor(input = "TurboCars Racer") {
    this.input = input;
  }

  readDisc() {
    console.log("Now playing: ", this.input);
  }

  changeDisc(input) {
    this.input = input;
    this.readDisc();
  }
}
// nssconsole.mjs
export default class NSSConsole {
  constructor(gameReader) {
    this.gameReader = gameReader;
  }

  play() {
    this.gameReader.readDisc();
  }

  playAnotherTitle(input) {
    this.gameReader.changeDisc(input);
  }
}

最後に、InversifyJSを使うようプロジェクトを設定します。

touch config.mjs index.mjs
// config.mjs
import { decorate, injectable, inject, Container } from "inversify";
import GameReader from "./game-reader.mjs";
import NSSConsole from "./nssconsole.mjs";

// Declare our dependencies' type identifiers
export const TYPES = {
  GameReader: "GameReader",
  NSSConsole: "NSSConsole",
};

// Declare injectables
decorate(injectable(), GameReader);
decorate(injectable(), NSSConsole);

// Declare the GameReader class as the first dependency of NSSConsole
decorate(inject(TYPES.GameReader), NSSConsole, 0);

// Declare bindings
const container = new Container();
container.bind(TYPES.NSSConsole).to(NSSConsole);
container.bind(TYPES.GameReader).to(GameReader);

export { container };
// index.mjs
// Import reflect-metadata as a polyfill
import "reflect-metadata";
import { container, TYPES } from "./config.mjs";

// Resolve dependencies
// Notice how we do not need to explicitly declare a GameReader instance anymore
const myConsole = container.get(TYPES.NSSConsole);

myConsole.play();
myConsole.playAnotherTitle("Some other game");

プロジェクトのルートからnode index.mjsを実行すれば、新しい設定をテストできます。

シンプルな例ではありますが、InversifyJSを使う多くのプロジェクトにとって、よい出発点となるでしょう。

まとめ

この記事では、JavaScriptにおける依存性注入とそのメリット・デメリット、人気のJavaScriptフレームワークでの実装例、そしてバニラJavaScriptのプロジェクトでの使い方を紹介しました。

続きを読む

blog feature snyk container custom base recommendations
Blog

Evo Agentic AppSecのご紹介:AI開発のスピードに対応するセキュリティ

AI駆動型開発のスピードに、従来のセキュリティワークフローは追いついていません。SnykのAgentic AppSecは、セキュリティエージェントが脆弱性を継続的に検出、修正、検証し、人が基準を定められるようにします。

blog feature ai violet
Blog

VulnBump:AIエージェントはビルドを壊さずに脆弱な依存関係を修正できるか?

VulnBumpは、実際の依存関係アップグレード307件を対象にAIエージェントをテストします。脆弱性が解消され、ビルドとテストが成功した場合にのみ、修正としてカウントされます。

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。