Expressからの...NestJS

続き。何がしたいかというと、REST-fulなAPIをテストしやすく拡張しやすい手法で作りたいだけ。しかしAWSサーバレスの上でTypeScriptにと縛っていくとなかなか答えにたどり着けなかったのです。

NestJS

nestjs.com

サーバサイドのフレームワークとしてLoopBack 4の代わりを探していたら検索チェーンの果てで突き当たる。aws-serverless-expressに載せようとしてる中で、おそらくそれら用途で Expressのインスタンスを触れる方法 が用意されていました。さほどExpress感は無いが確かにExpressだったのと、バリバリTypeScript前提な作り。流行りなのかCLIツールも備えてますがまずは無視してフルスクラッチに行きます。LoopBack 4では初期ドキュメントを追っかける限りではCLIを無視できなかったので、これは良い。

// lambda.ts
import { createServer, proxy } from 'aws-serverless-express';
import { APIGatewayProxyEvent, Context } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { Server } from 'http';
import * as express from 'express';
import module from './module';
// 追記(後述):実はこの無名クラス取り込みはダメ!!!
// 変更例としては「import { UserModule } from './module';」 

let server: Server;
export default function (event: APIGatewayProxyEvent, context: Context) {
    if (server) {
        return proxy(server, event, context);
    }
    (async () => {
        const app = express();
        const nest = await NestFactory.create(module, app);
        await nest.init();
        server = createServer(app);
        proxy(server, event, context);
    })();
}

NestFactory.createおよびinitが非同期なのでめんどくさい。コールドスタートした時にハンドラが先に動かないようにしないと。一方でホットスタートした時にまた非同期の初期化作業はさせたくなくこんな作り。もっと上手い書き方ありそうなので考え続けてみます。ExpressもLambdaも出てくるのはここまで。これから先はほとんど全てNestJSの上だけで作っていくことが可能。

// /module/user.ts
import * as nest from '@nestjs/common';
import * as express from 'express';

@nest.Controller('user')
export default class {
// 追記(後述):実はこの無名クラスはダメ!!!変更例「export class UserController {」
    // 非同期もPromiseを返すだけでOK。簡単に対応できる
    @nest.Get()
    async findAll(): Promise<string[]> {
        return Promise.resolve(['a', 'b', 'c', 'd', 'e']);
    }

    @nest.Get('ping')
    ping(@nest.Response() res: express.Response) {
        res.send('PING!');
    }

    @nest.Get(':id')
    findOne(@nest.Param('id') id: string): string {
        return `This action returns a #${id} user`;
    }
}

デコレータが出てきちゃいました。LoopBack 4でも出てきましたがこいつは10年以上前のJavaフレームワーク繚乱時代を思い出させます。そこではクラスに付加情報としてJavaではアノテーションと呼ばれていた言語機能を活用してフレームワークIDEによる介入を行なっていました。上記では@Controllerと@Getで実はルーティングを表現できちゃってる。すなわちfindAllメソッドは、/userで呼び出され、findOneメソッドは、/user/:id で。

pingメソッドでは引数に@Responseデコレータを用いてExpress由来のオブジェクトをDependency Injectionしています。まさにJavaでよくやられた懐かしい手法です。

// /module/index.ts
import * as nest from '@nestjs/common';
import user from './user';

@nest.Module({
    controllers: [user],
})
export default class {
// 追記(後述):実はこの無名クラスはダメ!!!変更例、「export class UserModule {」とすべき。
}

NestJS、シンプルにとてもいいんじゃないですかね。LoopBack 4よりは洗練されている気がする。ただ、モジュール間の依存性をDIで解決と言ってるのだけど、特にインターフェイス疎結合にして実装を分離するのではなく、実クラスをそのままとり回してるのだけどそういうものなのか?Angular由来というDIが昔のSpringやSeasarで馴染んでたDIと狙いというか概念がちょっと違う気がして戸惑っている。まあ、DIの根幹としてコンストラクターを直で呼ばせずにフレームワークインスタンス生成して、それがちゃんとシングルトンで管理されていれば良いってことかな。

無名クラスを複数個並べた場合の問題

@Module({imports})や@Module({controllers})の値が配列をとるので当然モジュールがグラフ状に広がる作りが可能なはずです。しかし、ここまで私の手元ではそれぞれ一つのモジュールに一つのコントローラーでサンプル程度を作ってる時には動いていましたが、二つ目のモジュールやコントローラを追加すると期待した通りには動かない、DIがきちんと動かないことがありました。

当初、非Lambdaブートストラップ版を書いたり、それをwebpackでビルドして動かしてみたり、公式のサンプルをいくつか持ってきたりして実験していましたが、どうにもうまく動いたり動かなかったり。。。と、しばらくしてトランスパイルされた結果を眺めていて突然閃く。

上記記事で書いた私のコードではモジュールやコントローラを無名クラスで書いてexport defaultしていたのです。これがダメ。NestJSのサンプルもドキュメントも全てそうは書かれてなかったのですが、私のいつもの好む書き癖から無名クラスで書いちゃってました。で、これがNestJSのDIの仕組みの中で一つ目のdefault exportな無名クラスに「default_1」という名前がつけられるのは良いとして、二つ目の無名クラスも同じ名前で被らせてしまうみたいなのです。おそらくここで複数ソースコードに渡って管理されていて「default_2」とでも添字をインクリメントするか、importでつけた名前に書き換えてくれれば問題ないんですけどね。これはトランスパイラーの仕様由来なのかな?現象の再現方法がわかったのでNestJSのコードを追って原因究明とフィードバックをいつかの課題としておきたいと思います。

おそらく私以外にもハマる人いるはず!いないかな?NestJSのインジェクション対象(自分でコンストラクター呼び出しでインスタンスを作らないもの)は無名クラスはダメ。お気をつけください。

supertestとnock

github.com

自然な流れで、supertest。結構軽量な作りでhttp.Serverベースのアプリケーションを直接テスト内でアクセスするアサーションライブラリ。

github.com

こちらのnockは外部APIをモック化するライブラリ。

常用のjestにてsupertestとnockを組み合わせたら便利じゃないかなと想像している。まだ必要になるところまで届いていないので、想像だけ、備忘録としてだけ。