本連載では分散型マイクロブログ用ソフトウェアMisskeyの開発に関する紹介と、関連するWeb技術について解説を行っています。第2回はMisskey v13から採用されたNest.
Misskey v13よりNest.jsを採用
Nest.
TypeScriptで書かれているため型のサポートがあります。また、デコレータと呼ばれる構文を多用するのもポイントで、例えば次のように@
で始まる構文が出てきたらそれはデコレータです。
@Injectable()
export class FooService {
// ...
}
この例ではInjectable
がデコレータであり、クラスに対して追加の情報
DI(Dependency Injection)とは
DIとは、Dependency Injectionの略で、日本語でもっぱら
……といってもピンとこないと思いますので、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.
を直接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
に渡すことができ、関数が呼ばれたかどうかを呼び出し側から確認できるようになりました。また、実際にプッシュ通知を送信する書く必要もないのでテストに伴って
この
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の関数を用意して渡さなければならず、現実的ではありません。
そして、この問題を解決するのが
Nest.
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
になっています。 - また、
pushNotification
もPushNotificationService
クラスのメソッドsend
になっており、自クラスのPushNotificationService
インスタンス経由で呼び出しています。
こうすることで何が嬉しいのかということですが、Nest.
つまり、このクラスを利用する側で、いちいちすべての依存関係を用意してコンストラクタに渡す、ということをしなくてもよくなります。
これで、依存関係を外部から渡してもらうようにした際に、利用側が煩雑になってしまう問題が解決し、DIを行いつつも依存関係の管理の手間がなくなっています。
また、これはDIとは直接関係ないことですが、Misskey v13では基本的にすべての処理がこのようなクラスで表されるようになりました。個々のファイルをimportしたときの
テスト
実際にこのクラスのテストを書いてみましょう。
今回の例だと、まだテスト対象のクラスの依存関係が2つしかありません。よって、Nestの力を借りずに単に次のように書くこともできます
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();
});
});
このコードでは依存関係であるNotificationsRepository
やPushNotificationService
をテスト用の実装に置き換えて、それをNotificationService
のコンストラクタに渡しています。
しかし前述しましたが、依存関係の数が多くなってくるとこのように手動で管理するのが難しくなってきます。まだありがたみが実感しにくいと思いますが、Nest.
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.createTestingModule
を使ってテスト用にNestアプリケーションのインスタンスを作成しています。その際、useMocker
を利用することで任意の依存関係を置き換えることができ、テスト用のモックに置き換えています。
テスト対象のNotificationService
のインスタンスの取得は、app.
を利用すれば良いだけなので、依存関係を用意する手間はありません。
これで、いくら依存関係が増えたとしても、テストする際に煩雑になったりすることがなくなりました。
まとめ
今回は設計手法のひとつであるDIを利用することで、依存関係を外部から提供することができるようになり、テストがしやすくなることを解説しました。
ソフトウェアの規模が大きくなると、このようにクラスごと
また、そのような改修は途中から行うのは非常に大変になるので、設計の早い段階から検討するのをおすすめします
Nest.
Stay tuned!