今回、とあるAPIを試しに実装する機会があってPythonでFastAPIで作ろうかと思ったのだけれどせっかくなのでNest.jsを試してみることにしたのでそのメモ。
Nest.jsとは
NestJSというのはNode.jsを使ったサーバーサイドのアプリケーションを実装するためのフレームワーク。特徴については公式サイトではいろいろ書かれていますが個人的にはTypeScriptの完全サポート、これに尽きると思っています。
思想的にはAngularの影響を強く受けているそうです。
セットアップ
とりあえずいつものようにリモートコンテナの環境を用意する。
{ "name": "Nestjs Trial", "context": "..", "dockerFile": "./Dockerfile", "build": { "target": "dev" }, "settings": { "terminal.integrated.shell.linux": null }, "extensions": [ "visualstudioexptteam.vscodeintellicode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "hookyqr.beautify", "alefragnani.bookmarks", "lacroixdavid1.vscode-format-context-menu", "eamodio.gitlens", "oderwat.indent-rainbow", "ionutvmi.path-autocomplete", "chrmarti.regex", "humao.rest-client", "wayou.vscode-icons-mac" ], "forwardPorts": [ 3000 ], "mounts": [ "source=nestjs_trial_node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" ] }
MacでDockerはファイルシステムの問題で遅いので多少でもマシにするためにNamed Volumeを使ってます。node_modules以下をNamed Volumeにしてる感じです。
Dockerファイルはシンプルに。
FROM node:16.14.0 AS dev RUN apt update && apt install vim && apt clean RUN echo "source /usr/share/bash-completion/completions/git" >> ~/.bashrc
vimを入れてるのはvscodeのターミナルからコンテナ内のファイルをちょっと確認したりする時用です。
Nest.js自体はnpmで入れるだけです。これでTypeScript周りもまるっと入ってくれるので超簡単。
npm i -g @nestjs/cli
nest new nestjs-trial
ちなみに利用するパッケージマネージャを聞かれるがいつもどおり yarn
を選択した。そして僕のリモートコンテナの環境で上のコマンドを実行するとworkspaceの直下にフォルダが出来上がるので中身をまるっと一つ上の階層に mv
している。ここはお好みで
終わったらおもむろに起動します。
yarn start
http://localhost:3000/
にブラウザからアクセスするとHello World
とだけ書かれたページが表示するはずです。
yarn start
だとプロダクションで起動されるのでホットリロードが使えるような開発環境で起動する場合は yarn start:dev
です。
src/
には以下のようなファイルができあがってます。
ファイル | 用途 |
---|---|
app.controller.ts | 基本となるコントローラ |
app.controller.spec.ts | コントローラのユニットテスト |
app.module.ts | ルートモジュール |
app.service.ts | 基本サービス。1メソッドのみ |
main.ts | アプリケーションのエントリファイル |
main.ts
がNest.jsで作ったアプリケーションのエントリファイルとなるものでアプリケーションを起動するものでもある。
Fastifyを使う
Nest.jsはデフォルトでExpressを利用するんだけど、Fastifyを選択することもできる。Platform agnosticというコンセプトらしく他のライブラリとの互換性も提供するってことだそうだ。このあたりの詳細はドキュメントを読むといいと思うが大雑把にいうと個々のライブラリ固有の実装に対してプロキシするようなアダプタを実装してこれらを実現しているとのこと。
というわけでここではfastifyを使うことにした。理由は特になくてなんとなく。
Fastifyを使う場合、このmain.ts
でFastifyを使用するように変更する。
まずはFastify用のパッケージを入れる。
yarn add @nestjs/platform-fastify
そして先ほどのmain.ts
でFastifyを使うように書き換える。基本的には先ほど言ったようにFastifyのアダプタを使ってアプリケーションインスタンスを作るって感じかな。
import { NestFactory } from '@nestjs/core'; import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create<NestFastifyApplication>( AppModule, new FastifyAdapter(), ); await app.listen(3000); } bootstrap();
なお、デフォルトだと127.0.0.1
でListenするので外部からのリクエストを受け付けられるようにするには0.0.0.0
をlisten()
で指定する必要がある。
Controllerを実装してみる
いわゆるコントローラですね。リクエストとレスポンスをハンドリングする感じです。デコレータを使ってルーティング先を指定したりしていくというFastAPIとかでも使われている方式かな。デコレータに関してはいくつか用意されているものを組み合わせて使っていく。
@Controller()
はコントローラとなるクラスに必須。そのパスでハンドルする各ハンドラメソッドごとに@Get
とかで指定してく感じ。ファイル名はusers.controller.ts
みたいにするのがお作法っぽい。例えばこのusers.controller.ts
の場合は、@Controller('users')
と指定し、プロファイルを取得するAPIを用意するなら@Get('profile')
で/users/profileみたいにできる。実際には
/users/{user_id}/profile`みたいにするだろうけど。
コードはこんな感じ。
import { Controller, Get} from '@nestjs/common'; @Controller('users') export class UsersController { @Get() getAllUsers(): string { return 'All Users'; } @Get('/profile') getUserProfile(): string { return 'User profile'; } }
コントローラを作っただけでは動かない。モジュールにマップしてやる必要がある。これはapp.module.ts
に追加する。
import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersController } from './users.controller'; // <= 追加 @Module({ imports: [], controllers: [ AppController, UsersController, // <= 追加 ], providers: [AppService], }) export class AppModule {}
パスパラメータ
パスパラメータももちろんできる。@Get()
でパラメータとなるものを指定して@Param()
で受け取って参照する。さっきの例でいくと /users/1/profile
みたいなAPIを実装する場合はこんな感じになる。
import { Controller, Get, Param } from '@nestjs/common'; @Controller('users') export class UsersController { @Get() getAllUsers(): string { return 'All Users'; } @Get(':id/profile') getUserProfile(@Param() params): string { return `UserId #${params.id}'s profile`; } }
クエリパラメータ
クエリストリングの値を取得したい場合は@Queryデコレータを使用する。以下の例ではクエリパラメータとしてcountry
というのを受け取って表示している。これで/users?country=Japan
みたいなリクエストができるようになる。
実際には条件分岐したり、DBへのクエリを組み立てたりするだろう。あとバリデーションもね。
import { Controller, Get, Query } from '@nestjs/common'; @Controller('users') export class UsersController { @Get() getAllUsers(@Query('country') country): string { return `All ${country} Users`; } }
複数のクエリパラメータを扱いたいときはそれぞれ@Query()
で指定すればいい。
レスポンス
ちなみにここまではレスポンスとして適当に文字列を返しているがもちろんちゃんとJSONとかを返すこともできる。JSONへのシリアライザがデフォルトで組み込まれていて、標準ではオブジェクトもしくは配列を返したら自動でJSONにシリアライズしてくれる。
こんな感じで実装しておくと、
import { Controller, Get, Param } from '@nestjs/common'; type User = { id: number; name: string; age: number; }; @Controller('users') export class UsersController { @Get(':id') getUser(@Param() params): User { const res: User = { id: 1, age: 20, name: 'hoge', }; return res; }
こんなJSONをレスポンスとして受け取れる。
# curl -sS http://localhost:3000/users/1 | jq . { "id": 1, "age": 20, "name": "hoge" }
なお、ライブラリ固有のレスポンスもできてその場合はこの限りではない。
ステータスコード
201を使用するPOSTリクエストを除いて、レスポンスのステータスコードはデフォルトで常に200。ただし、これも@HttpCode(204)
みたいに指定することで変更できる。
リクエストボディ、リクエストペイロード
@Body()
を使用する。ただし、ペイロードの中身のスキーマを事前に定義しておく必要がある。Nest.js的にはこれをDTO(Data Transfer Object)と呼んでいて、TypeScriptのインターフェースを使うかクラスでいいそうだ。Nest.jsはクラスを使うことを推奨している。理由についてはドキュメント参照。ここでは推奨どおりクラスで用意してみる。
以下の内容でcreate-user.dto.ts
というファイルを作成する。
export class CreateUserDto { name: string; age: number; country: string; }
コントローラ側はこんな感じ。受け取ったペイロードの中身を取り出してレスポンスしているだけ。
import { Body, Controller, Post } from '@nestjs/common'; import { CreateUserDto } from './create-user.dto'; @Controller('users') export class UsersController { @Post() async create(@Body() createUserDto: CreateUserDto) { return `Added new user (${createUserDto.age}, ${createUserDto.name})`; } }
# curl -sS -d 'age=30&name=fuga' http://localhost:3000/users Added new user (30, fuga)
エラーレスポンス
ステータスコードを指定しつつ例外を投げるだけ。これもちゃんとJSON形式にシリアライズされる。
import { Controller, Get, HttpException, HttpStatus, Query, } from '@nestjs/common'; @Controller('users') export class UsersController { @Get() getAllUsers(@Query('contry') contry): string { throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); } }
なお、Nest.js外で発生してハンドリングされなかった例外はglobal exception filterという組み込みの仕組みでステータスコード500、Internal Server Errorとしてレスポンスされる模様。もちろんこれもJSON形式。
その他
その他にも @Header()
などのデコレータが多く用意されているのでドキュメントを参照してほしい。あと、パスにワイルドカード使えたりとか結構柔軟に設定ができる模様。
まとめ
とりあえず今回はここまで。基本中の基本を試してみたくらいだけど、個人的にはとてもわかりやすく扱えた。あとはAngular由来のDIが組み込まれてたりするらしいけどこのあたりは深く見ていない。また、モデルやORMとかとの組み合わせについてもまだやってない。試しに node-postgres
を使ってPostgreSQLにベタにつないでクエリとかはしてみたけどプロダクションではさすがにちょっとなので後日もう少しみていきたい。Prismaとの組み合わせとかね。
その他、ミドルウェアやインターセプタといった機構もあるのでもう少しいろいろ試してまたまとめようと思う。