ファイルベースルーティングで関数型なTypeScriptフルスタックフレームワークfrourio
TypeScriptで稼ぐフリーランスなら誰もがぶつかる「年収1,500万円の壁」を突破するためのフレームワークとして少し話題になっている frourio を開発者自身が紹介する記事です。
Next.jsの進化やtRPCの登場でフルスタックTypeScriptの時代がやってきそうな予感がしています。TypeScriptでサーバーを書く場合のメジャーな選択肢は
- Express
- NestJS
- Fastify
あたりでしょうか。
これから紹介するfrourioは3年前に筆者が「DIすることを目的化し過ぎて他の開発者体験を犠牲にしたデコレータ依存なNestJS」に疲れ果ててFastifyをaspidaの型定義で縛る関数型フレームワークとして設計したものです。
(一応、frourio-expressも公開しています)
frourioの特徴
- Next.jsのAPI RoutesのようにファイルシステムベースでREST APIを定義
- 型定義駆動でフロントとバックエンドを開発
- コントローラー、依存性注入、バリデーション(Zod)、ORM(Prisma)含めて全て関数で記述
- コアがFastifyでオーバーヘッドが少ないので実効速度は十分に高速
- 開発当初はaspida-serverという名前だったくらいaspidaフレンドリー
なによりロゴがめちゃくちゃ可愛い。
イケてるベンチャー界隈ではRPCやGraphQLが人気ですが、年間約2兆円あるWeb開発市場全体で見るとまだまだREST APIが主流です。
筆者自身、今年の春くらいから仕事もプライベートもほぼ全てfrourioを使っていてREST APIの案件なら一度は触れてみる価値があります。
便宜上フルスタックフレームワークと呼んでいますが実態はフロントからバックエンドまでのベストプラクティスなライブラリを型安全に繋ぐための関数定義ファイルを自動生成するだけのCLIツールに過ぎません。
frourioの基本構成
- フロント:Next.js or Nuxt2 (Nuxt3は試してみたけど無理っぽい)
- APIリクエスト:aspida
- サーバー:Fastify or Express
- バリデーション:Zod
- ORM:Prisma or TypeORM
- 依存性注入:velona(関数型DIライブラリ)
/server/api
配下のaspida型定義を利用して型安全なdefineController関数を生成するのがfrourioです。
型安全なdefineController関数
例えばaspidaの定義を
type Article = {
userId: string
articleId: string
content: string
}
export type Method = {
get: {
resBody: Article[]
}
post: {
reqBody: {
content: string
}
resBody: Article
}
}
と書くと自動で $relay.ts
というリレーファイルが生成されてControllerを以下のように書けます。
import z from 'zod'
import { defineController } from './$relay'
import { articleModel } from '$/domain/article/model'
import { articleRepository } from '$/domain/article/repository'
export default defineController(() => ({
// バリデーション不要なら関数のみ
get: async ({ user }) => {
const articles = await articleRepository.findAllById(user.id)
return { status: 200, body: articles }
},
// バリデーションするならオブジェクト
post: {
validators: {
body: z.object({ content: z.string().min(1).max(140) })
},
handler: async ({ user, body }) => {
const article = await articleModel.create(user.id, body.content)
await articleRepository.save(article)
return ({ status: 204, body: article })
}
}
}))
リレーファイルからimportした defineController
が同じディレクトリにあるaspidaの型定義を知っているので上記にあるもの全て型検査が可能です。
user
は上位ディレクトリの hooks.ts
に認証ロジックと型定義を書いた想定です。
import { defineHooks } from './$relay'
export type AdditionalRequest = {
user: {
id: string
}
}
export default defineHooks(() => ({
onRequest: (req, _, done) => {
req.user = { id: 'taro' }
done()
}
}))
ツールで一発環境構築
大型フレームワークで良くある環境構築ツールがfrourioにもあって
$ yarn create frourio-app # or npm
のあと自動表示されるGUIで好きなライブラリ構成を選んでスタート出来ます。
お試しで触るだけならデータベースの項目はSQLiteを選択するとラクです。
PostgreSQLとMySQLは事前にDBを用意しておく必要があります。
StartするとNext.js v13のTodoアプリが起動します。
CRUD操作と認証、ファイル投稿が簡易に動く状態なのでaspidaに慣れていればドキュメントを読まなくても雰囲気で使い方がわかるかもしれません。
ツールに頼らずfrourioを自力で構築するならこの記事が参考になるかもしれません。
ファイルシステムベースでREST APIを定義
開発サーバーが動いている状態で /server/api
の配下にAPIエンドポイントのディレクトリを作る度にfrourioが管理するリレーファイルとは別にユーザーが書くべき型定義とControllerとパス変数バリデーションのテンプレートも自動生成されます。
サービスの品質に直接関係ない機能ですが、APIを気軽に増やしていけるので私は非常に気に入っています。
export type Methods = {
get: {
resBody: string
}
}
import { defineController } from './$relay'
export default defineController(() => ({
get: () => ({ status: 200, body: 'Hello' })
}))
パス変数があるときは以下のように変数名を反映したバリデーションが別ファイルで生成されます。さらに細かく制約をかけたい場合は自分で編集できます。
import { z } from 'zod'
import { defineValidators } from './$relay'
export default defineValidators(() => ({
params: z.object({ articleId: z.string() })
}))
APIリクエストはaspida任せ
/server/api
配下にファイルを増やすとaspidaの型定義も更新されていくのでいつも通りフロントで使えます。
const articles = await apiClient.articles._articleId('hoge').$get()
aspidaについての詳細は過去の記事を参考にしてください。
あらゆる関数に依存性注入が可能
frourioとは別にvelonaという関数型DIライブラリを公開しています。
内部はたった15行の小さなライブラリですが関数にオブジェクトや関数を注入できます。
import { depend } from 'velona'
const add = (a: number, b: number) => a + b
export const basicFn = depend(
{ add },
({ add }, a: number, b: number, c: number) => add(a, b) * c
)
console.log(basicFn(2, 3, 4)) // 20
import { basicFn } from './'
const injectedFn = basicFn.inject({ add: (a, b) => a * b })
expect(injectedFn(2, 3, 4)).toBe(2 * 3 * 4) // pass
expect(basicFn(2, 3, 4)).toBe((2 + 3) * 4) // pass
「抽象に依存せよ」という原則を完全に守っているわけではありませんが、DIが効果を発揮するのはテスト時だけであり「テスト時に依存するモジュールを切り替えたい」という目的はvelonaで達成できます。
velonaは関数に依存の初期値を与えてアプリケーションコードでは初期値を使いテスト時に注入して差し替えるようになっているのです。
frourioはこの機能を defineController
に統合していてimport無しで即座に使えます。
import z from 'zod'
import { defineController } from './$relay'
import { articleModel } from '$/domain/article/model'
import { articleRepository } from '$/domain/article/repository'
export default defineController(
{ articleModel, articleRepository }, // 依存の初期値
({ articleModel, articleRepository }) => ({ // 注入された依存モジュール
get: async ({ user }) => {
const articles = await articleRepository.findAllById(user.id)
return { status: 200, body: articles }
},
post: {
validators: {
body: z.object({ content: z.string().min(1).max(140) })
},
handler: async ({ user, body }) => {
const article = await articleModel.create(user.id, body.content)
await articleRepository.save(article)
return ({ status: 204, body: article })
}
}))
Fastify(or Express)のプラグイン導入が簡単
frourio自体はCORSや認証に関心がないのでFastify本体に頼ることになります。
/server/service/app.ts
にFastify自体の初期化処理が書いてあるので好きなプラグインを導入できます。
import server from '$/$server'
import { API_BASE_PATH, CORS_ORIGIN } from '$/service/envValues'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import type { FastifyRequest, FastifyServerFactory } from 'fastify'
import Fastify from 'fastify'
export const init = (serverFactory?: FastifyServerFactory) => {
const app = Fastify({ serverFactory })
app.register(helmet)
app.register(cors, CORS_ORIGIN ? { origin: CORS_ORIGIN } : undefined)
server(app, { basePath: API_BASE_PATH })
return app
}
frourio v0.30
までは Fastify v3
に依存していて
frourio v0.31
以降は Fastify v4
に依存している
のでプラグインのバージョンに注意してください。
恐らくですが、公式プラグインの場合は名前が fastify-xxx
だと Fastify v3
で @fastify/xxx
だと Fastify v4
のようです。
今後の展望
この先は @su8ru が毎月機能追加してくれている公式ドキュメントを読んでください。
ご丁寧にチュートリアルもあるし、英語版の方は最近ダークテーマに対応しました。
記事の冒頭で「REST APIの案件なら最高の選択肢」と書きましたが、異常系の型定義が出来ないのは惜しいところです。
これはaspida v2の完成が必須条件で、進捗としては予定より1年半以上遅れていますが来年こそなんとかしたい・・・
frourioにスターを押してくれるとOSS開発の励みになります。
Discussion