LoginSignup
42
13

More than 3 years have passed since last update.

まだクラスへの依存性注入で消耗してるの?TS製の関数型DIヘルパー「velona」のススメ

Last updated at Posted at 2020-10-11

TypeScriptの普及に伴い、Node.jsでもDIを行うケースが増えています
TypeScriptでDIする場合クラスとデコレータを使うのが主流、というかほぼこれ一択です

一方でフロントを関数型で書くことが増え、バックエンドも関数だけで書きたいというニーズもあります




そこでこの記事ではTypeScriptの関数だけでDIが出来る超軽量ライブラリ、velona(ヴェローナ)を紹介します

先に特徴をまとめると

  1. クラスもデコレータも不要、純粋な関数として呼び出せる
  2. 関数にデフォルトの依存をあらかじめ注入するので既存コードの書き換えが少ない
  3. 全体で15行、型情報を除くと7行程度だからオーバーヘッドが少ない

というTypeScriptの強みを活かせるライブラリです

クラスもデコレータも不要、純粋な関数として呼び出せる

とりあえずインストール方法
TypeScript環境はすでにある想定です

$ npm install velona

次に、足し算をしてくれるchildFnに依存しているparentFnをDIなしで普通に定義してみます

parentFn.ts
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が表示されます

index.ts
import { parentFn } from './parentFn'

console.log(parentFn(2, 3, 4)) // 20

parentFnについて、「childFnの返り値を第3引数で掛け算しているかどうか」をテストしたくなったと仮定します
childFnの実装がテスト結果に影響を与えないようにDIをしたいパターンですね
velonaを使ってparentFnの「定義だけ」を書き換えます

parentFn.ts
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は注入「された」関数
)

クラスもデコレータも使わず注入できました
既存のプロダクションコードは書き換える必要がなく純粋な関数として呼び出せます

index.ts
import { parentFn } from './parentFn'

console.log(parentFn(2, 3, 4)) // 20

テストコードではchildFnを差し替えます
JestでもAVAでも好きなテストツールを使ってください

index.test.ts
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です

関数にデフォルトの依存をあらかじめ注入するので既存コードの書き換えが少ない

上記で書いた呼び出し側のコードをもう一度見てみましょう

index.ts
import { parentFn } from './parentFn'

console.log(parentFn(2, 3, 4)) // 20

ここでchildFnの注入を行っていません
DI導入を決める前の既存部分100か所くらいでparentFnを呼んでいたとしても、DIのために書き換えるのは以下のparentFn定義1か所だけでいいのです

parentFn.ts
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と同じカワイイロゴを使っています
velona-github-ogp.png
気に入ったらGitHubにスターを押してやって下さい
GitHub - velona

42
13
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
13