サーバーサイドTS【NestJS/FoalTS/frourio】でDI/API実装/Fastify連携/etcを比較した
概要
サーバーサイド TypeScriptのフレームワークで、個人的に有力視している以下の 3 フレームワークを比較します。
主な比較項目は以下のとおりです。
-
Controllerの書きやすさ(バリデーション含む) -
Dependency Injectionの書きやすさ、テストの実装のしやすさ - Fastify連携の対応可否
※frourio は Next.js をフロントに Integration したフルスタックフレームワークとしての一面がありますが、API 鯖としても便利だと思っているので、本記事は APIの活用にフォーカスして説明していきます。
実装する機能
ユーザーアカウントを作成する APIを実装していきます。
POST /userでユーザー情報を渡して実行するものとして、Controller に UseCase を DI する処理を書いたり Fastify 対応していきます。
Controller
Controller を実装します。リクエストのバリデーションをどのように型安全に書くか、ルーティングはどのように設定するかが主要なチェック項目です。
NestJS: ○ まあまあ
NestJS での Controllerは以下のように実装できます。
import { CreateUser } from "../../../packages/user-account/usecase/user/create-user";
import { Body, Controller, Post } from "@nestjs/common";
import { IsNumber, IsString } from "class-validator";
class CreateUserParams {
@IsNumber()
id: number;
@IsString()
email: string;
@IsString()
name: string;
@IsString()
image_url: string;
}
@Controller("/user")
export class CreateUserController {
constructor(private usecase: CreateUser) {}
@Post()
async index(@Body() params: CreateUserParams) {
await this.usecase.run(params);
}
}
NestJS による Controllerの特徴として以下が挙げられます。
- リクエストボディのバリデーションは
class-validatorを使っている - デコレータを多く使っている(
@Controller/@Post/@Body) -
DIしたいusecaseクラスはコンストラクタを通してInjectionしている
バリデーションに型を使っていることで、バリデーションの責務を Controllerから引き剥がすことができているのが見通しの良いポイントだと感じました。
ただ、デコレータが多いのが少し冗長に感じます。
FoalTS: △ 微妙
続いて FoalTS の Controllerです。
import {
Context,
dependency,
HttpResponseNoContent,
Post,
ValidateBody,
} from "@foal/core";
import { CreateUser } from "../../../packages/user-account/usecase/user/create-user";
export class CreateUserController {
@dependency
usecase: CreateUser;
@Post("/")
@ValidateBody({
additionalProperties: false,
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
image_url: { type: "string" },
},
required: ["id", "name", "email", "image_url"],
type: "object",
})
async index(ctx: Context) {
await this.usecase.run(ctx.request.body);
return new HttpResponseNoContent();
}
}
こちらの特徴は以下のとおりです。
- リクエストボディのバリデーションは
@ValidateBodyデコレータを使う。こちらは内部的にはajvを使っているとのことです - usecase の
Dependency Injectionは@dependencyデコレータを使う
ちなみに@Post('/')のように Path が/userとなっていない理由ですが、FoalTS には subcontrollerという概念が有って、以下のような親 Controllerのようなファイルを指定することでネストされたルーティングを実現するように公式 Doc では記載されています。
export class ApiController {
subControllers = [controller("/user", CreateUserController)];
}
このルーティングの癖が若干理解しにくいし書きにくいのと、バリデーションの書き方が ajvになっていてあまり型安全ではないところから、微妙な書き味に感じました。
frourio: ◎ 書きやすい
frourio の Controllerは、他の FW のそれとは一線を画しています。
import { defineController } from "./$relay";
import { createUser } from "$/service/user-account/create";
export default defineController(
{ createUser }, // defineControllerの第1引数にDIしたいクラス
({ createUser }) => ({
// こちらの引数に型が安全な状態でUseCaseをInjectionできる
post: async ({ body }) => {
await createUser(body);
return { status: 201, body: null };
},
})
);
特徴としては以下のとおりです。
- これまでのクラスを Export する書き方とはうってかわって、
defineControllerと呼ばれる関数の戻り値をReturnしている -
defineControllerは$relayと呼ばれるファイルからImportしている - デコレータは一切使っていない
- デコレータとクラス構文を使わずに型安全な DI を実現
こちら、ちょっと frourio の世界観が他と違いすぎて解説が大変なのですが、まず以下のような API の型定義ファイルを作成するところから frourio の API 開発はスタートします。この型定義ファイルは/user/index.tsのように、API のパスと一致した場所に置きます。
import { CreateUserParams } from "$/validators";
export type Methods = {
post: {
reqBody: CreateUserParams;
resBody: null;
};
};
validators/index.tsに Params の型定義を置きます。
import { IsString, IsNumber } from "class-validator";
export class CreateUserParams {
@IsNumber()
id: number;
@IsString()
email: string;
@IsString()
name: string;
@IsString()
image_url: string;
}
で、この状態でdev serverを起動すると、自動で$relay.tsが生成されます。$relay.tsには defineContrllerの型定義が含まれているのです。
API の型定義さえ書けば、それに沿った defineContrllerの型定義が生成されます。そうすると、その defineControllerを使って Controllerを実装すれば、型定義に沿っていない引数や戻り値を返す実装に対して Type Errorが出るようになります。
以上で説明したとおり、API の型定義ドリブンで型安全な実装を進めるスタイルが frourio の特徴です。
デコレータやクラス構文を一切使わずにシンプルな関数で Controllerを実現している、また、API の型定義を書けばルーティングや戻り値がすべてそちらで設定できることで Controllerの責務をスッキリさせているあたりに好感が持てます。
そもそもクラスって状態を持つためにある側面が強いので、ステートレスな処理しかない Controllerであれば関数で書くほうが紛れがないので明快です。
DI(UseCase の実装)
Dependency Injectionできるように実装するモジュールの書き味を確認するために、Controller に Injection できるユースケースを実装していきます。
NestJS: △ 微妙
NestJS は Dependency Injection したいクラスに@Injectableデコレータを付ける必要があります。
import { Injectable } from "@nestjs/common";
@Injectable()
export class CreateUser {
// ...略
}
また、app.module.tsというファイルに対して、provider のところに Injection対象となるクラスを指定する必要もあります。
@Module({
imports: [ConfigModule.forRoot(), LoggerModule.forRoot()],
controllers: [
// ...
],
providers: [
CreateUser,
// ...
],
})
export class AppModule {}
全体的にちょっと手間ですね。手間を掛けることで堅牢になるのなら良いのですが、この手順をたどることでより良い設計になる気はあまりしないです。
FoalTS: ◎ とても良い
FoalTS では、DI コンテナがよくできているので、NestJS のように専用のデコレータを付けたりする必要がありません。
@dependencyデコレータを呼び出したいクラスの方で使うとそれで終わりです。
この点は FoalTS が好きなところです。
frourio: ○ 良い
frourio は DI したいユースケースクラスの実装も独特です。
import { depend } from "velona";
export const createUser = depend(
{ HogeRepository },
async ({ HogeRepository }, params: HogeParams) => {
// ...
}
);
以上のように、dependというメソッドを使っています。
これは frourio の作者の方が個別のパッケージで提供している velona (GitHub) というライブラリを使っているのですが、仕組みとしては defineControllerと同じです。
第 1 引数に Dependencies、第 2 引数に関数の本体を指定します。
つまり、frourio の場合、本質的には Controllerで UseCaseを DI する仕組みも、UseCase 以降で Repository等を DI する仕組みも同一なのです。なので、書き方がコード全体で統一されていて学習コストが最初さえ乗り越えればシンプルだと思われます。また、depend メソッド自体はジェネリクスを使ったシンプルな型定義で済んでいることから、フレームワークに対して強く依存することをさほど気にしなくていい、とも言えます。
とはいえ独特な書き方を覚えないといけないので、◎ ではなく ○ としておきました。
Fastify 対応
ベンチマークが優秀なことで知られる Fastify ですが、フレームワーク側で容易に導入できるようにしておいてもらえると助かりますね。
NestJS: ○ 良い
NestJS では Fastifyを導入できますし、アクセスログもpinoを用いて JSON で流すことができます。
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
{
logger: false,
}
);
// pino logger
app.useLogger(app.get(Logger));
// Validation
app.useGlobalPipes(new ValidationPipe());
await app.listen(parseInt(process.env.PORT) || 3000, "0.0.0.0");
}
bootstrap();
FoalTS: ✗ できない
FoalTS は私の認識だと Fastify対応をやっていないはずです。
Ref:
frourio: ◎ 手間なし!
frourio はデフォルトでもともと fastifyを使っているので、特に対応するための手間はかかりません。
また、fastify はオプションで logger: true などと設定することで pino を利用できるので、JSON のアクセスログも得ることができます。
Ref:
その他
AWS S3 との相性
FoalTS には AWS S3 のストレージと連携しやすいクラスが提供されており、S3 と連携するアプリケーションを実装するときは少し便利です。
(とはいえ、自分で AWS SDK 使ったラッパーを組めば、FW 側で DI ちゃんと用意してくれたらテスタブルに書けますよね)
frourio でのモック作成
frourio でのテストコードを書くときは、velona 経由で生成されたオブジェクトは inject メソッドを有しており、簡単にテスト時にモックに差し替えることができます。
Laravel でいう Facade のようなオブジェクトですね。
import controller from "$/api/user/controller";
import { createUser } from "$/service/user-account/create";
test("dependency injection into controller", async () => {
const injectedController = controller.inject({
createUser: createUser.inject({}),
})();
const res = await injectedController.post({
body: {
id: 1555,
name: "taro",
email: "hogehoge@hoge.com",
image_url: "https://hogehoge.com",
},
});
expect(res.status).toBe(201);
});
総評
総じて、frourio の DI がとても優秀で、実装も慣れればやりやすいし、テストコードも書きやすいので良い感じだと思います。
ちょっとフレームワークへの依存が強くなってしまいがちに見えますが、気になる方は defineControllerや dependの第 2 引数だけ切り出すことができるので、それで依存を切り離すことができると考えれば良いのではないでしょうか。また、depend 自体はジェネリクスをもりもり使った 1 ファイルで実装された DI の仕組みなので、フレームワークの利用をやめたくても velonaだけ使うなどもできるので、つぶしが効くような気もします。
現在私は会社では NestJS を使っているのですが、機を見て frourio にごっそり差し替えるのもアリかなと考えているところです。注意点として、frourio は 2020 年 12 月現在バージョンが 0.X.Y のため、破壊的変更が入る可能性が高いです。会社で NestJS 使っているのが割とコアな部分なので、一旦導入は先送りしつつ、いつでも打席に送り出せるようにしておこうと思っています。
お願い
もし記事が参考になれば、Zenn でサポートいただければ嬉しいです。
技術書・カンファレンス等に当てさせていただきます!ちょっとお高めの書籍やカンファレンスに躊躇なく手が出せるようになりたい(切実)
お願いその 2
私が CTO を務めている「オンライン家庭教師マナリンク」では、エンジニアを募集しております。
2020 年 12 月 現在、Web エンジニア及び、React Native エンジニアを探しております。Nuxt.tsやReact、Firebase Cloud Functions等に積極的にTypeScriptを導入しています!
マナリンクでは、オンライン家庭教師の先生方のために、サイト上で自身のプロフィールを魅力的に発信できるようにしたり、オンライン指導専用アプリをリリースするなど、次々にプロダクトを開発しています。日々新しい技術を勉強して、試す機会を探している方にはうってつけな環境です。
興味あれば 上記の Twitter に DM でご連絡をください!
Discussion