この記事の内容はfrourio v0.12
以前のものであり、最新のfrourio v0.13
以降の情報は以下リンクを参照してください
frourio でフロントエンドとバックエンドを一緒に静的型検査する
フロントとサーバーの疎通をTypeScriptで静的に検査したい
フロント(React/Vue/Angular)とサーバー(Express/Nest)を両方TypeScriptで開発しているプロジェクトの情報を少しずつ見かけるようになりました
しかしながら、フロントのTSとサーバーのTSは緩く型で繋ぐ程度で両者のAPI疎通まで静的に型検査することは難しいです
せっかく両方TSで作っても、ブラウザを動かすとかテスト回すとかで動的に疎通チェックをしているのではないでしょうか
フロント開発で特に厄介なのが、HTTPリクエストで使うaxios/ky/fetchです
エンドポイントを文字列で指定するという性質上、パスやリクエスト・レスポンスの型検査が不可能でした
そこに新たな解決策を提示したのが「aspida」です
HTTPリクエストが静的検査可能になるaxios/ky/fetchのラッパーを自動生成するTS製のツール「aspida」
aspidaの登場により、フロントは最大の割れ窓であったHTTPリクエストの静的型問題を克服できました
一方サーバーはコントローラが管理しているAPIエンドポイントが仕様通りの実装であることを静的に検査する方法がありません
フロントがaspidaで正確なリクエストを投げたとしても、サーバーと期待通りに疎通するかは動かしてみるまでわからない
API周りの大きな割れ窓を解決し、フロントとサーバーのTypeScript大統一を果たすのがこの記事のゴールです
aspidaの型定義でコントローラの実装を強制する
例えばTodoアプリを作るとして、 /tasks
と /tasks/:taskId
というGETのAPIエンドポイント二つを作る場合、aspidaでは以下のように型定義します
export type Task = {
id: number
label: string
done: boolean
}
export type Methods = {
get: {
resBody: Task[]
}
}
import { Task } from '../'
export type Methods = {
get: {
resBody: Task
}
}
/apis
配下のディレクトリ名がエンドポイントのパスと一致します
(_
から始まるディレクトリ名はパス変数を意味していて、@number
が型、省略するとstring型)
フロントからは以下のようにエンドポイントを示すaspidaのプロパティ+メソッドでリクエストを書けるので静的検査ができます
const task = await api.tasks._taskId(1).$get()
// taskはTask型
これら /apis
配下に作ったエンドポイントの型定義を流用してサーバーのコントローラにも実装を強制すればAPI疎通を静的に検査可能になるという着想で開発したのが「frourio(フルーリオ)」です
- Expressベース
- オートルーティング
- TypeORM組み込み
- MulterでFormDataを自動パース
- class-validatorで入力値チェック
サーバーフレームワークに必要とされる機能は一通り揃っています
構成はこんな感じです
データベースを用意しておく
DBがなくても動かせますが、今回はMySQLを使うのであらかじめ準備しておきます
例として
- ポート:3306
- ユーザー名:root
- パスワード:root
- データベース名:test
という条件で空のデータベースを作成した前提で進めていきます
環境構築のストレスフリーなTypeScript大統一開発を始めよう
frourioの環境構築はcreate-frourio-appを使うとインストール直後から動きます
自分で組むのは難しいフロントフレームワーク(Next.js/Nuxt.js)との型連携からDBとの接続設定、frourio本体にはない認証システムとしてPassport、デーモン化のためのPM2、開発サーバー監視のnodemonまで揃ったTypeScriptフルスタック環境が3分で整います
$ npx create-frourio-app next-test
または
$ yarn create frourio-app next-test
ターミナル上で設問に回答した通りにフロントエンド(Next/Nuxt)とTypeORMが自動設定されます
回答したDB接続情報は server/.env
に書き込まれます
SERVER_PORT=8080
BASE_PATH=/api
TYPEORM_HOST=localhost
TYPEORM_USERNAME=root
TYPEORM_PASSWORD=root
TYPEORM_DATABASE=test
TYPEORM_PORT=3306
DBアプリ起動時にマイグレーションを行うので回答通りに空のDBを準備しておいてください
DBを使わない場合(Choose the database typeでNoneと回答)はプロジェクト内に生成される server/database.json
をデータベース代わりに使用するので準備不要です
インストールが完了したらディレクトリに移動してアプリを起動します
http://localhost:3000
でNextかNuxtのTodoアプリが表示されます
VSCodeのTypeScriptバージョンを3.9以上にする
frourioはTypeScript3.9で改善されたPromiseの型推論に依存しているため、3.9未満だと型エラーになります
もちろんcreate-frourio-appは3.9.5をインストールするので正常に動作しますが、エディタのTypeScriptが古いと見た目上エラーが出ます
VSCodeは2020/06/10のリリースで3.9.4が標準になったのでアップデートするのがおススメです
/server/api/**/index.tsの定義を変えるとフロントとサーバー両方で型エラーになる
/server/api
配下にはフロントとサーバーを繋ぐAPIの型定義ファイルがあります
ディレクトリ名がそのままエンドポイントのパスになります
「API疎通を静的に型検査できる」のがfrourioの特徴です
例えば、Todoのタスクリストを取得するエンドポイントを書き換えてみます
import { Task } from '~/server/types'
export type Methods = {
get: {
resBody: Task // <- 配列を消した
}
post: {
reqBody: Pick<Task, 'label'>
resBody: Task
}
}
すると、最初に同じ階層の /server/api/tasks/@controller.ts
で型エラーが出ます
次にフロントのAPIリクエスト部分 /pages/index.tsx
でも型エラー
resBodyだけでなくquery/headersはもちろん、ディレクトリ名を変えたりパス変数の型が違ってもフロント・サーバー両方でエラーが出るので静的に疎通検査が出来ていることがわかります
URLやデータの不整合でAPIが疎通しないというミスが大幅に減ることが想像できますね
frourioのファイル名をAPIエンドポイントに変換するルールはaspidaのサブセットです
/server/api/test.ts
はエンドポイントになりません
必ずディレクトリ+index.tsの形式 /server/api/test/index.ts
にしてください
ディレクトリがないとコントローラやミドルウェアを解釈できないのでこのルールにしました
ルートはNext or Nuxt、/server配下にaspidaとfrourio
全体のディレクトリ構成はNext/Nuxtの標準通りです
/server
配下にAPIサーバーの型定義と実装が存在します
$
から始まるファイルはfrourio/aspidaが自動変更するためユーザーが変更を加えることはできません
名前 | 用途 | |
---|---|---|
/server/api/ | aspidaでエンドポイント定義 | |
/server/public/ | 静的配信 | |
/server/types/ | 型定義とバリデータ | |
/server/index.ts | サーバーのエントリーポイント | |
/server/tsconfig.json | ||
/server/webpack.config.js | ||
/server/entity/ | DB選択時のみ | TypeORM専用 |
/server/migration/ | DB選択時のみ | TypeORM専用 |
/server/subscriber/ | DB選択時のみ | TypeORM専用 |
/server/ormconfig.ts | DB選択時のみ | TypeORM/CLI専用 |
/server/.upload/ | 自動生成 | アップロードファイルをmulterが保存 |
/server/$app.ts | 自動生成 | Expressの初期化処理 |
/server/index.js | 自動生成 | Webpackによるバンドル結果 |
/server/service | オプション |
/server/service
は消してもいいし、上記と衝突しない名前で好きなだけ /server
にディレクトリを作ってOKです
エントリーポイント
/server/index.ts
でサーバー起動処理を行っています
import { run } from './$app'
import {
SERVER_PORT,
BASE_PATH,
TYPEORM_HOST,
TYPEORM_USERNAME,
TYPEORM_PASSWORD,
TYPEORM_DATABASE,
TYPEORM_PORT
} from './service/envValues'
run({
port: Number(SERVER_PORT),
basePath: BASE_PATH,
cors: true,
typeorm: {
type: 'mysql',
host: TYPEORM_HOST,
username: TYPEORM_USERNAME,
password: TYPEORM_PASSWORD,
database: TYPEORM_DATABASE,
port: Number(TYPEORM_PORT),
migrationsRun: true,
synchronize: false,
logging: false
}
})
プロパティ名 | 型 | |
---|---|---|
port | number / string | Expressサーバーのポート |
basePath? | string | APIエンドポイントの |
helmet? | boolean / helmet.IHelmetConfigration | helmetの有効化 |
cors? | boolean / cors.CorsOptions | corsの有効化 |
typeorm? | typeorm.ConnectionOptions | typeormの設定 |
multer? | multer.Options | multerの設定 |
/server/apis/{path}/index.tsに型定義⇒/server/apis/{path}/@controller.tsに実装
export type Methods = {
get: {
resBody: string
}
}
import { createController } from './$relay'
export default createController({
get: () => ({ status: 200, body: 'Hello, world!' })
})
/server/api
配下に新たなディレクトリを作ると index.ts
@controller.ts
$relay.ts
が自動生成されます
$relay.ts
はfrourioが管理しつづける型中継ファイルです
$
から始まるファイルなので編集できません
index.ts
にエンドポイントの型定義を行います
エンドポイントの型定義ファイルを作成する - aspida
@controller.ts
で型定義通りにstatus/body/headersをreturnするとサーバーレスポンスになります
ブラウザで http://localhost:8080/api にアクセスすると Hello, world!
が表示されます
基本的なCRUDのサンプルは /server/api/tasks
シンプルにCRUDを行うGET/POST/PATCH/DELETEのエンドポイントが /server/api/tasks
配下にあります
Todoアプリのリクエストが全てここに飛んできます
バリデーション
class-validatorを使えます
- クラス名を
Valid
から始める -
/server/types/index.ts
で exportする - フロントと共有するクラスなのでサーバー固有のロジックを書かない
という条件を満たす必要があります
このクラスを index.ts
の query/reqHeaders/reqBody
に指定すると自動でバリデート処理が挿入されます
バリデートエラー時にはコントローラに届かずstatus:400が返却されます
import { MinLength } from 'class-validator'
export class ValidLoginBody {
@MinLength(2)
id: string
@MinLength(4)
pass: string
}
queryは数値型にならない
クエリのパースはExpressに任せているため、型でnumberを指定しても数値に変換されません
import { Task } from '~/server/types'
export type Methods = {
get: {
query: {
limit: number // <- ランタイムでstringのまま
}
resBody: Task[]
}
}
数値としてバリデートしたい場合は class-validator
の IsNumberString
を使うといいです
パス変数は数値に変換される
/server/api/tasks/_taskId@number/index.ts
のパス変数 taskId
はfrourioによって数値に変換されます
数値じゃない場合はバリデートエラーで400を返します
numberの指定がない場合は文字列のまま、バリデートはしません
マイグレーション
/server/index.ts
の migrationsRun: true
によってサーバー起動時に自動マイグレーションされます
synchronize: false
なので開発中にentityを追加するだけではマイグレーションは起こりません
/server/entity
配下にTypeORMの作法でエンティティを定義
⇒ $ npm run migration:generate
で /server/migration
にマイグレーションファイルを生成
⇒ サーバーがnodemonによって再起動するのでDBマイグレーションされる
意図せぬタイミングでマイグレーションファイルが生成されると困るので手動の操作を挟むようにしています
middlewareでPassport認証
画面右上のLOGINボタンでPassport認証の動作をチェックできます
.envに書いてある通り、
id -> id
pass -> pass
とこのまま入力するとログインできます
違う入力は弾かれます
認証状態をlocalStorageなどで永続化してないのでリロードしたらもとに戻ります
frourioのミドルウェアはExpressのミドルウェアそのままです
あるディレクトリ以下全てに適用する場合は @middleware.ts
ファイルを作ります
export type User = { ... }
と記述すると、配下全てのコントローラでuserの値が取得できます
(型を正しく当てるの間に合わなくてコメントアウトしました・・・)
import passport from 'passport'
import { createMiddleware } from './$relay'
import { getUserIdByToken } from '~/server/service/user'
export type User = {
id: string
}
passport.use(
// eslint-disable-next-line
new (require('passport-trusted-header').Strategy)(
{ headers: ['token'] },
// eslint-disable-next-line
(headers: { token: string }, done: Function) => {
done(null, getUserIdByToken(headers.token))
}
)
)
export default createMiddleware([
passport.initialize(),
passport.authenticate('trusted-header', { session: false })
])
特定のコントローラのみにミドルウェアを適用したい場合は、 @controller.ts
でmiddlewareをexportします
FormData
画像をFormDataで送信するサンプルはLOGIN後にファイルを選択ボタンで試せます
(アイコンが変わるだけ)
エンドポイントのindex.tsに reqFormat: FormData
を指定する以外に特別な記述は不要です
ファイルを送信する場合はBlob型を指定すると良いです
(Fileでも問題ないけど、MulterのFile型と混同しやすい)
export type Methods = {
post: {
reqFormat: FormData
reqBody: {
name: string
names: string[]
image: Blob
images: Blob[]
}
}
}
export default createController({
post: ({ body }) => console.log(body.image) // Express.Multer.File型
})
アップロードされたファイルは /server/.upload
に保存されるのでS3やpublicディレクトリに移すなどしてください
Multerの挙動を変えたい場合は /server/index.ts
で変えられます
run({
port: SERVER_PORT,
multer: {
dest: 'hoge/fuga'
}
})
フロントのリクエストはJSONの場合とほぼ変わりません
const editIcon = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return
setUserInfo(
await apiClient.user.$post({
headers: { token },
body: { icon: e.target.files[0] }
})
)
},
[token]
)
fileを配列で送信する場合には少し工夫が必要になります
await apiClient.user.$post({
body: { icons: Array.from(e.target.files) }
})
テスト
$ npm run lint
$ npm run typecheck
静的テスト二つは標準で用意されてます
動的なテストについては特定の作法はありません
export const createController = (methods: ServerMethods<Methods, Types>) => methods
import { createController } from './$relay'
export default createController({
get: () => ({ status: 200, body: 'Hello, world!' })
})
createControllerは型を付けるためだけに存在していて実体は何もしない関数です
好きなテストフレームワークを使って単純な関数としてコントローラをテストしてください
デプロイ
$ npm run start:front
と $ npm run start:server
でそれぞれのデプロイが可能です
$ npm start
は両方を同時に呼び出すだけのコマンドです
フロントのポートやモード(SSRとかSSGとか)はNext/Nuxtの作法で変更してください
サーバーのポートは .env
で変えられます
今後の機能追加
書きやすさを維持して依存性注入を行う方法を考え中です