この記事はNestJS AdventCalendar 2023の24日の記事です。
結論
NestJSでResponseオブジェクトをDIして使う場合は、2重でレスポンスしないようにしましょう。
例
import { ExpressAdapter } from "@nestjs/platform-express";
export class CustomAdapter extends ExpressAdapter {
public reply(response: any, body: any, statusCode?: number) {
if (body !== undefined) {
return super.reply(response, body, statusCode);
}
}
}
何がしたいの?
ControllerとPresenterを分けたいと考えました。
NestJSのControllerの挙動
NestJSでは、Controllerのreturn値がそのままHttpResponseになります。
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello() {
return {hello: 'world'};
}
}
例えば、上記のように実装したControllerでは以下のようなレスポンスが得られます。
{
"hello": "world"
}
これは呼び出されるメソッドがvoidの場合でも動作します。
その時のレスポンスbodyは空でレスポンスを返そうとします。
以下のようなメソッドでも空のレスポンスを返すエントリーポイントが作成できます。
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello() {}
}
表示ロジックを分離する
表示に関わるロジックが複雑な場合、表示用のロジックのみ別の場所に切り出してテストしやすい構造にしたいと考えるかもしれません。
その場合、手取り早くAppModuleでResponseオブジェクトを登録してPresenterを作ることができそうです。
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppPresenter } from './app.presenter';
import { Request } from 'express';
import { REQUEST } from '@nestjs/core';
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
AppPresenter,
{
provide: 'RESPONSE',
useFactory: (request: Request) => request.res,
inject: [REQUEST],
},
],
})
export class AppModule {}
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Response } from 'express';
@Injectable({scope: Scope.REQUEST})
export class AppPresenter {
constructor(@Inject('RESPONSE') private readonly response: Response) {}
output(value: number) {
this.response.send({value});
}
}
ただ、この時に何かの非同期処理をしていたりすると、おそらく以下のようなエラーが出ます。
Error: Cannot set headers after they are sent to the client
なぜならば、Adapterからもresponseを返しているにも関わらず、Presenterからもresponseを返してしまうためです。
これは、上述の説明の通りControllerで呼び出されたメソッドがvoidとして空のレスポンスを返し、その後PresenterでもResponseを返しているため多重呼び出しとなります。
これを解消するためには、Controllerのレスポンスを受け取ってレスポンスを生成しているHttpServerのreplyメソッドでresponseを返さないようにすれば良さそうです。
なぜResponse Decoratorは動くのか
NestJSにはResponse Decoratorがあります。
それを使うとresponseオブジェクトを取り出して以下のように書くことができます。
import { Controller, Get, Response } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(@Response() res) {
setTimeout(() => {
res.send({hello: 'world'});
}, 1000);
}
}
この時もgetHelloはvoidです。
しかし、同様のエラーは出力されません。
これはResponseデコレータを利用するとpassthroughというフラグが立ち、voidの時はresponseをsendしないようになるためです。
つまり、Responseデコレータを呼び出すとControllerのメソッドからの返り値を無視してレスポンスを自由にDIしても良さそうです。
結論
NestJSでResponseオブジェクトをDIして使う場合は、2重でレスポンスしないようにしましょう。
例えば、愚直にHTTP Adapterを継承してbodyがundefinedである場合はレスポンスを返さないようにするのも良いかもしれないです。
import { ExpressAdapter } from "@nestjs/platform-express";
export class CustomAdapter extends ExpressAdapter {
public reply(response: any, body: any, statusCode?: number) {
if (body !== undefined) {
return super.reply(response, body, statusCode);
}
}
}
明示的にResponseを別で逃がしていることがわかるようにResponse decoratorを握り潰すのも良いかもしれないです。
import { Controller, Get, Response } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(@Response() _) {
this.appService.exec();
}
}
わざわざDIせずとも、Controllerで複雑なロジックを処理するために切り出した関数やクラスを呼び出せばこんなトリッキーなことしなくても良いのでそのようなアプローチを考えた方が筋が良さそう。