🤖

Node.js / TypeScript / GraphQL で Web サービスを作ってきて、サーバーサイドはこの構成に落ち着きました

2021/12/21に公開

結論

下記の書き味が安定して良いと思いました。

  • Knex/Kysely (Query Builder)
  • Fastify (Web server)
  • Nexus.js (GraphQL Schema Builder)
    • 追記(2024-07): 新規では gqtx 使ってます
  • BullMQ (Async job queue)
  • ESBuild, esbuild-register (Transpiler)
  • AVA (Test framework)
  • PostgreSQL (Database)

基準は

  • Boilerplate code が少ない
  • 型安全に書ける
  • マジックなコードが少ない。ダサくて良いから、辿れば分かるようにする
  • テスト書きやすい
  • ビルド・実行速度ともにパフォーマンス良い

です。

追記(2024-07): Bun も可能な範囲で使い始めたが、 Node を前提としたパッケージがまだまだ多くて本番運用は難しそう

今までに試したもの

  • Jest
    • デファクトスタンダードになっているが...
    • ちゃんとやらないとテスト終了しなくて面倒
    • global に定義されるのが個人的に好きではない
  • Type-GraphQL
    • デコレータ重すぎて挫折
    • クラス強制がつらい。
      • 具体的には、継承されたメソッドを override した場合、型推論が効かない。
  • TypeDI/tsyringe
    • Type-GraphQL と同じ。
    • Node では DI するメリットないと思う
  • ts-node
    • 唯一 TypeScript のデコレーターを ( emitDecoratorMetadata, reflect-metadata 含め) 解釈できるが、 transpile-only でもめちゃ重い
    • かといって SWC をトランスパイラに使うと、デコレーターを解釈できない。これは ESBuild も同じ
      • 正確には、 Primitive な値は解釈できるが、デコレートされた class は解釈されなかった。
  • Express
  • Koa
    • 速いけど薄い
    • これはこれで良さそうな気がしている
    • 確か起動は Fastify より速かったはず
  • NestJS
    • 良さげだけど、コードが多くなると Webpack でのビルドが遅いという噂によって断念
    • Angular 使ってたら似てるので良いかも
  • Objection.js
    • 愛用していたが、 TypeScript との相性が悪い部分があり、離脱
      • 具体的には、 where 句で型安全にならない、一部の処理で any になってしまうなど
      • 無理やり patch していたが、逆に分かりにくいなと
    • ORM いらないという結論になった
  • TypeORM
    • with が in じゃなくて Join でしかできないのがつらみ。
    • デコレーター重い
  • Mikro-orm
    • デコレーター重い
    • whereany になる
  • 素の GraphQL
    • 型安全性が...
    • graphql-code-generator でも Custom Model との Mapping の調整が面倒
  • class 自体、ほぼ使わなくなった
    • function だけで全く困らない

残ったもの

  • Knex/Kysely
    • 個人的に ORM は不要と思っているので、クエリビルダーくらいの抽象化で止めている
    • Kysely の方が型安全でシンプルなので、新規プロジェクトではそっちを使っている
  • Fastify
    • 薄いのに意外と機能が多い
    • そして速い
    • Mcollina さんがアクティブに開発している
    • 周辺ライブラリがめちゃめちゃ充実している
  • esbuild
    • デコレーターを諦めれば、いい感じに使える
    • ts-node-transpile-only より圧倒的に速いので嬉しい
    • esbuild-register もあって使いやすい
    • 追記: 最近 tsx 等もあるけど、それよりなぜか速い。
  • Nexus
    • 文法や Codegen は好みがありそうだが、今んとこいい感じ
    • Codegen には不満を持っている人も多そうだが、 TypeScript の型定義と戦うより、 Codegen の方が扱いやすいかな
    • デコレーター使わないから、速い
    • Entity と Schema を分離できるのは、やや二度手間だけど、実際やってみると気になならない
    • 追記(2024-07): 新規では gqtx 使ってます。 Nexus は感覚的にやや遅いのと、エディタとの相性が gqtx の方が良いので
  • AVA
    • 常に素直に動いてくれる
    • グローバル定義もないし、明示的に書ける
    • ドキュメントも充実
    • Tap や Tape でも 良いが...
    • 追記(2024-07): Worker を使うようになって、ひと工夫しないと強制終了しなくなった
  • BullMQ
    • 非同期ジョブのデファクト
    • Bull Board が良い

ディレクトリ構成はこんな感じ

src/
  |-- app.ts
  |-- async-jobs                  # 非同期ジョブ
  |   `-- onUserCreatedJob.ts
  |-- bull-board.ts
  |-- config                      # 環境変数を読み込む
  |   `-- index.ts
  |-- entities                    # エンティティのドメインロジックを記述する
  |   |-- User.test.ts
  |   `-- User.ts
  |-- graphql                     # GraphQLに関するもの
  |   |-- context.ts
  |   |-- __generated__           # Nexus が生成したファイル
  |   |   |-- nexus-typegen.ts
  |   |   `-- schema.graphql
  |   |-- schema
  |   |   |-- UserTypeEnum.ts
  |   |   |-- UserType.ts
  |   |   |-- QueryType.ts
  |   |   `-- MutationType.ts
  |   |-- schema.test
  |   |   |-- helper.ts
  |   |   |-- createUser.test.ts
  |   |   `-- UserType.test.ts
  |   |-- schema.ts
  |   `-- shared                  # 共通して利用するロジックを記述
  |       `-- ensureAdmin.ts
  |-- main.ts
  |-- infrastructure              # 外部サービスへの Gateway
  |   |-- db.ts
  |   |-- redis.ts
  |   `-- twilio.ts
  |-- register.js                 # esbuild-register などを読み込むファイル
  |-- repositories                # DB からの取得ロジック
  |   `-- UserRepository.ts
  |-- routes                      # REST のルーティング
  |   `-- UserRoute.ts
  |-- use-cases                   # ビジネスロジックとなる一連操作を記述
  |   |-- findOrCreateUser.ts
  |   `-- someUseCase.ts
  `-- utils                       # ユーティリティ
      |-- asyncIterator.ts
      `-- db.ts

コード例

ビジネスロジックの記述はこんな感じ

Entity

export interface User extends BaseModel {
  firebaseUid: string;
  email: string;
  name: string;
  isAdmin: boolean;
  isTestUser: boolean;
}

export function isInternal(user: Pick<User, 'isAdmin' | 'isTestUser'>) {
  return user.isAdmin || user.isTestUser;
}

Use Case

export CreateUserUseCaseArgs {
  firebaseUid: string;
  email: string;
  name: string;
}

export async function createUserUseCase(props: CreateUserUseCaseArgs) {
  // ...
}

Discussion