サーバーサイド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