Misskey & Webテクノロジー最前線

MisskeyにおけるNest.jsを使ったDI

本連載では分散型マイクロブログ用ソフトウェアMisskeyの開発に関する紹介と、関連するWeb技術について解説を行っています。第2回はMisskey v13から採用されたNest.jsおよびDependency Injection(DI)と呼ばれる設計手法について紹介します。

Misskey v13よりNest.jsを採用

Nest.jsは、Node.js用のサーバーサイドフレームワークで、拡張可能で信頼性が高いアプリケーションを作成できることが特徴です。Misskeyでは先日リリースのv13から採用しています。Nest.jsを採用した理由としては、今回紹介するDIをMisskeyに導入するためです。

TypeScriptで書かれているため型のサポートがあります。また、デコレータと呼ばれる構文を多用するのもポイントで、例えば次のように@で始まる構文が出てきたらそれはデコレータです。

@Injectable()
export class FooService {
  // ...
}

この例ではInjectableがデコレータであり、クラスに対して追加の情報(=Inject可能)をアノテートするものになります。

DI(Dependency Injection)とは

DIとは、Dependency Injectionの略で、日本語でもっぱら「依存性の注入」と訳される設計手法になります。DIを用いると、外部の処理に依存せず対象となる処理のテストを書けるようになります。

……といってもピンとこないと思いますので、DIを導入する前と後の、実際のMisskeyのコードの一部を比較しながら解説します。

DIを用いないコードは次のようになります(簡単のため、一部省略・簡略化しています⁠⁠。

import { Notifications } from '@/models.js';
import { pushNotification } from '@/services/push-notification.js';

// 通知を作成します。
export async function createNotification(
	notifieeId: User['id'],
	type: Notification['type'],
	data: Partial<Notification>
) {
	// DBにレコードを挿入
	const notification = await Notifications.insert({
		notifieeId: notifieeId,
		type: type,
		...data,
	};

	// ユーザーにプッシュ通知を送信
	pushNotification(notifieeId, notification);
}

このコードにあるcreateNotification関数は、データベースに通知レコードを挿入し、さらにユーザーにプッシュ通知を送信します。さて、こののコードにはどんな問題があるでしょうか?

ここで、この関数のテストを書く場合を考えてみます。この関数のテストを書く場合、データベースにレコードが挿入されることや、pushNotificationが呼ばれることを保証したいですが、後者について言うと上記のコードだとpush-notification.jsを直接importしそれを使っているので、pushNotificationをspy関数に置き換えたりすることができず、関数が呼ばれたかどうかを外部から確認する術がありません。また、pushNotificationはプッシュ通知を送信する関数のため、テストを行うたびにプッシュ通知が送信されることになりかねません(これを副作用と言います⁠⁠。

これらを解決するには、pushNotification関数を外部から提供できるようになればいいわけです。たとえば次のようになります。

export async function createNotification(
	notifieeId: User['id'],
	type: Notification['type'],
	data: Partial<Notification>,
	pushNotification: Functin,
) {
	// DBにレコードを挿入
	const notification = await Notifications.insert({
		notifieeId: notifieeId,
		type: type,
		...data,
	};

	// ユーザーにプッシュ通知を送信
	pushNotification(notifieeId, notification);
}

createNotification関数の引数としてpushNotification関数を受け取っていることに注目してください。

これで、型さえ一致していれば外部からどんな内容の関数でもcreateNotificationに渡すことができ、関数が呼ばれたかどうかを呼び出し側から確認できるようになりました。また、実際にプッシュ通知を送信する書く必要もないのでテストに伴って「副作用」が発生することもありません。

この「依存関係を内部で持つのではなく、外部から渡す」設計手法のことをDependency Injection(依存性の注入)と呼びます。

Nest.jsでのDI

しかし書き換えたコードでもまだ問題があります。現段階で、他の処理への依存はpushNotificationやNotificationリポジトリのみですが、今後増えることも十分考えられます。極端な例ですが、必要になる関数が10個になったとすると、次のようになるでしょう。

export async function createNotification(
	notifieeId: User['id'],
	type: Notification['type'],
	data: Partial<Notification>,
	pushNotification: Functin,
	anotherDependency1: any,
	anotherDependency2: any,
	anotherDependency3: any,
	anotherDependency4: any,
	anotherDependency5: any,
	anotherDependency6: any,
	anotherDependency7: any,
	anotherDependency8: any,
	anotherDependency9: any,
) {
	// ...
}

こうなると、関数の定義自体が冗長になりますし、呼び出し側でもいちいち10の関数を用意して渡さなければならず、現実的ではありません。

そして、この問題を解決するのが「DIコンテナ」と呼ばれるフレームワーク(ライブラリ)で、Nest.jsがそのひとつです。

Nest.jsを使うと次のように書くことができます。

import { Injectable } from '@nestjs/common';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';

@Injectable()
export class NotificationService {
	constructor(
		private notificationsRepository: NotificationsRepository,
		private pushNotificationService: PushNotificationService,
	) {
	}

	public async create(
		notifieeId: User['id'],
		type: Notification['type'],
		data: Partial<Notification>,
	) {
		// DBにレコードを挿入
		const notification = await this.notificationsRepository.insert({
			notifieeId: notifieeId,
			type: type,
			...data,
		});

		// ユーザーにプッシュ通知を送信
		this.pushNotificationService.send(notifieeId, notification);
	}
}

今までとはかなり変わりましたのでひとつずつ説明します。

  • まず、全体がInjectableデコレータの付いたNotificationServiceクラスで表され、依存関係がコンストラクタで定義されています。
  • 今までのcreateNotification関数はクラスのパブリックメソッドcreateになっています。
  • また、pushNotificationPushNotificationServiceクラスのメソッドsendになっており、自クラスのPushNotificationServiceインスタンス経由で呼び出しています。

こうすることで何が嬉しいのかということですが、Nest.jsがこのクラスを認識すると、コンストラクタで定義された依存関係を、自動的にインスタンスを生成してコンストラクタに渡してくれます(依存関係の解決⁠⁠。

つまり、このクラスを利用する側で、いちいちすべての依存関係を用意してコンストラクタに渡す、ということをしなくてもよくなります。

これで、依存関係を外部から渡してもらうようにした際に、利用側が煩雑になってしまう問題が解決し、DIを行いつつも依存関係の管理の手間がなくなっています。

また、これはDIとは直接関係ないことですが、Misskey v13では基本的にすべての処理がこのようなクラスで表されるようになりました。個々のファイルをimportしたときの「副作用」がなくなったので、この点においてもテストが容易になっています。

テスト

実際にこのクラスのテストを書いてみましょう。

今回の例だと、まだテスト対象のクラスの依存関係が2つしかありません。よって、Nestの力を借りずに単に次のように書くこともできます(importなどは省略しています⁠⁠。

describe('NotificationService', () => {
	let notificationsRepository: jest.Mocked<NotificationsRepository>;
	let pushNotificationService: jest.Mocked<PushNotificationService>;
	let notificationService: NotificationService;

	beforeAll(() => {
		notificationsRepository = { insert: jest.fn() };
		pushNotificationService = { send: jest.fn() };
		notificationService = new NotificationService(notificationsRepository, pushNotificationService);
	});

	test('createを呼び出すと、レコードが挿入され、プッシュ通知が送信される', async () => {	
		await notificationService.create('foo', 'bar', {});

		expect(notificationsRepository.insert).toHaveBeenCalled();
		expect(pushNotificationService.send).toHaveBeenCalled();
	});
});

このコードでは依存関係であるNotificationsRepositoryPushNotificationServiceをテスト用の実装に置き換えて、それをNotificationServiceのコンストラクタに渡しています。

しかし前述しましたが、依存関係の数が多くなってくるとこのように手動で管理するのが難しくなってきます。まだありがたみが実感しにくいと思いますが、Nest.jsを使ってテストを書き直すと次のようになります。

const moduleMocker = new ModuleMocker(global);

describe('NotificationService', () => {
	let app: TestingModule;
	let notificationsRepository: jest.Mocked<NotificationsRepository>;
	let pushNotificationService: jest.Mocked<PushNotificationService>;
	let notificationService: NotificationService;

	beforeAll(async () => {
		app = await Test.createTestingModule({
			providers: [
				NotificationService,
			],
		})
			.useMocker((token) => {
				if (token === NotificationsRepository) {
					return { insert: jest.fn() };
				}
				if (token === PushNotificationService) {
					return { send: jest.fn() };
				}
				if (typeof token === 'function') {
					const mockMetadata = moduleMocker.getMetadata(token);
					const Mock = moduleMocker.generateFromMetadata(mockMetadata);
					return new Mock();
				}
			})
			.compile();

		notificationsRepository = app.get(NotificationsRepository);
		pushNotificationService = app.get(PushNotificationService);
		notificationService = app.get<NotificationService>(NotificationService);
	});

	afterAll(async () => {
		await app.close();
	});

	test('createを呼び出すと、レコードが挿入され、プッシュ通知が送信される', async () => {	
		await notificationService.create('foo', 'bar', {});

		expect(notificationsRepository.insert).toHaveBeenCalled();
		expect(pushNotificationService.send).toHaveBeenCalled();
	});
});

このコードでは、Nest.jsのcreateTestingModuleを使ってテスト用にNestアプリケーションのインスタンスを作成しています。その際、useMockerを利用することで任意の依存関係を置き換えることができ、テスト用のモックに置き換えています。

テスト対象のNotificationServiceのインスタンスの取得は、app.getを利用すれば良いだけなので、依存関係を用意する手間はありません。

これで、いくら依存関係が増えたとしても、テストする際に煩雑になったりすることがなくなりました。

まとめ

今回は設計手法のひとつであるDIを利用することで、依存関係を外部から提供することができるようになり、テストがしやすくなることを解説しました。

ソフトウェアの規模が大きくなると、このようにクラスごと(機能ドメインごと)のテストは必須になってきます。そのためにも、このような設計手法やフレームワークを活用して、テストを行いやすい/書きやすい環境を作っていくことが必要です。

また、そのような改修は途中から行うのは非常に大変になるので、設計の早い段階から検討するのをおすすめします(Misskeyは途中から導入したので、バックエンドはほぼ書き直しに近い改修になり、とても大変でした……⁠⁠。

Nest.jsは単にDIコンテナとしてだけではなく、他にも様々なバックエンドアプリケーションのための機能を提供しています。Misskeyが利用しているのは主にDI部分のみですが、その他の機能も機会があれば紹介したいと思います。

Stay tuned!

おすすめ記事

記事・ニュース一覧