TypeScriptの普及に伴い、Node.jsでもDIを行うケースが増えています
TypeScriptでDIする場合クラスとデコレータを使うのが主流、というかほぼこれ一択です
一方でフロントを関数型で書くことが増え、バックエンドも関数だけで書きたいというニーズもあります
class使わなくても十分なのめっちゃわかります。
— 名人さん | マナリンクCTO (@Meijin_garden) October 9, 2020
Dependency Injectionも実はクラスとかデコレーター無しに実現できるので、Nestなどのフレームワーク側も近い将来クラスやデコレーターべったりなの改善されるのかなと思ったり。https://t.co/ETbPuLrY3Q https://t.co/sWaGt75YXU
そこでこの記事ではTypeScriptの関数だけでDIが出来る超軽量ライブラリ、[velona(ヴェローナ)](https://github.com/frouriojs/velona)を紹介します
先に特徴をまとめると
- クラスもデコレータも不要、純粋な関数として呼び出せる
- 関数にデフォルトの依存をあらかじめ注入するので既存コードの書き換えが少ない
- 全体で15行、型情報を除くと7行程度だからオーバーヘッドが少ない
というTypeScriptの強みを活かせるライブラリです
クラスもデコレータも不要、純粋な関数として呼び出せる
とりあえずインストール方法
TypeScript環境はすでにある想定です
$ npm install velona
次に、足し算をしてくれるchildFnに依存しているparentFnをDIなしで普通に定義してみます
const childFn = (a: number, b: number) => a + b
export const parentFn = (a: number, b: number, c: number) => childFn(a, b) * c // childFnに依存
2, 3, 4を渡して呼び出すと (2 + 3) * 4 という計算で20が表示されます
import { parentFn } from './parentFn'
console.log(parentFn(2, 3, 4)) // 20
parentFnについて、「childFnの返り値を第3引数で掛け算しているかどうか」をテストしたくなったと仮定します
childFnの実装がテスト結果に影響を与えないようにDIをしたいパターンですね
velonaを使ってparentFnの「定義だけ」を書き換えます
import { depend } from 'velona'
const childFn = (a: number, b: number) => a + b
export const parentFn = depend(
{ childFn }, // childFnを注入する
({ childFn }, a: number, b: number, c: number) => childFn(a, b) * c // こっちのchildFnは注入「された」関数
)
クラスもデコレータも使わず注入できました
既存のプロダクションコードは書き換える必要がなく純粋な関数として呼び出せます
import { parentFn } from './parentFn'
console.log(parentFn(2, 3, 4)) // 20
テストコードではchildFnを差し替えます
JestでもAVAでも好きなテストツールを使ってください
import { parentFn } from './parentFn'
test('childFnの返り値を第3引数で掛け算しているかどうか', () => {
const childReturnVal = 7
const injectedParentFn = parentFn.inject({ childFn: (_a, _b) => childReturnVal }) // テスト用のchildFnを注入
const thirdArgVal = 4
expect(injectedParentFn(2, 3, thirdArgVal)).toBe(childReturnVal * thirdArgVal) // Pass
})
childFnは常にchildReturnVal、つまり7を返してくるので第3引数をいくつか用意してparentFnの挙動をテストできます
これこそがvelonaが実現した関数型DIです
関数にデフォルトの依存をあらかじめ注入するので既存コードの書き換えが少ない
上記で書いた呼び出し側のコードをもう一度見てみましょう
import { parentFn } from './parentFn'
console.log(parentFn(2, 3, 4)) // 20
ここでchildFnの注入を行っていません
DI導入を決める前の既存部分100か所くらいでparentFnを呼んでいたとしても、DIのために書き換えるのは以下のparentFn定義1か所だけでいいのです
import { depend } from 'velona'
const childFn = (a: number, b: number) => a + b
export const parentFn = depend(
{ childFn }, // childFnを注入する
({ childFn }, a: number, b: number, c: number) => childFn(a, b) * c // こっちのchildFnは注入「された」関数
)
プロダクション用のデフォルト依存値を注入しておいて、テストするときだけ再度注入すれば良いという素晴らしい設計です
クラス+デコレータでDIする場合、インスタンス化している箇所全てで依存性注入を記述する必要があります
テスト駆動開発をしていればこれも問題にはなりませんが、現実にはとりあえずリリース優先で実装を行ってある程度価値検証が進んでからテストを書くケースがよくあると思います
velonaを使えば途中からDIを導入する際のコストを最小化できるのです
全体で15行、型情報を除くと7行程度だからオーバーヘッドが少ない
TypeScriptでDIが出来るフレームワークと言えばNest.jsが有名ですが、ベンチマークを見るとExpressベースのnest、Fastifyベースのnest-fastifyともに大きくスコアを落としています
Framework | Version | Requests/sec | Latency |
---|---|---|---|
fastify | 3.5.1 | 38,018 | 2.54 |
nest-fastify | 7.4.4 | 31,960 | 3.04 |
express | 4.17.1 | 8,239 | 11.98 |
nest | 7.4.4 | 7,311 | 13.54 |
出典:https://github.com/frouriojs/benchmarks
velonaは以下の15行が全てです
type Deps<T extends Record<string, any>> = {
[P in keyof T]: T[P] extends { _velona: boolean }
? (...args: Parameters<T[P]>) => ReturnType<T[P]>
: T[P]
}
export const depend = <T extends Record<string, any>, U extends any[], V>(
dependencies: T,
cb: (deps: Deps<T>, ...args: U) => V
) => {
const fn = (...args: U) => cb(dependencies, ...args)
fn._velona = true
fn.inject = (deps: Deps<T>) => (...args: U) => cb(deps, ...args)
return fn
}
型情報を削るとたった7行程度で非常に軽量です
velonaを使えば既存の実行速度をほぼ犠牲にすることなくDIの恩恵を受けることができます
ロゴがカワイイ
valonaは関数型サーバーフレームワーク「frourio」でDIを実現する方法を模索して生まれたスピンオフのライブラリです
なのでfrourioと同じカワイイロゴを使っています
気に入ったらGitHubにスターを押してやって下さい
GitHub - velona
TypeScript製の関数型DIライブラリ velona のREADMEを更新しました🎉
— Solufa (@m_mitsuhide) October 11, 2020
- DBアクセスを担う関数にORマッパー(Prisma)を依存性注入する例を追加
- ロゴデザインを frourio と統一
あらゆる関数でDIが可能になるのでぜひ試してみてくださいhttps://t.co/SC3dRYantR