DIの必要性もしくは言語的欠陥
DI (Dependency Injection) について知っている人と、いない人がいると思うkeroxp.icon2020/10/18
もしくは馴染みがあるか、ないか
DIがなぜ必要か、あるいは必要とされる場面はどういう箇所かということについてはあえて語らないことにする
それはプロジェクトの構造が違うのなら違う解決方法が多々あるため
今回は自分がNode.jsのプロジェクトでDIがあったほうがいいとはっきり思った理由と、そのアプローチを語る
DIとはモジュール化である
DIは大層な名前がついていたり実装が異なったりするせいで実態よりも遥かに複雑怪奇なものとして人の目に映るようだ
文章によって書いてあることがバラバラなのでDIが何なのか、何のためにあるのかよくわからない人も多いのではないか
端的に言えば、DIとはモジュール化の手法のひとつである
モジュール化とは、もっと端的に言えばファイルを分割することである
一つのファイルで大きなプログラムを書くことは難しいので、機能ごとにファイルを分割して再利用するのは普通である
こういったことを実現するために、大抵のプログラミング言語にはモジュール機能が存在する
JSならばこう
code:js
import { other } from "./other.js"
あるいはNode.jsならばこう
code:js
const { other } = require("./other")
どちらも隣り合う別のファイルから関数や変数を読み込んで使うための仕組みだ
何をimportできるか、どんな単位でimportするかは言語によって大きく異なる
JSは僕が知る限り最も小さい単位でモジュールをimportできる仕様だ
例えばC言語にはモジュールと呼べる仕組みがなく、#include というプリプロセッサマクロ(コンパイル時に文字列を置き換える)仕組みで強引に他ファイルのコードを利用する仕組みしかない
なのでC言語とグローバル変数は切っても切れない関係にあり、グローバル変数をモジュール代わりに使うこともあるようだ
ただ、どのような手法であっても本来の目的は同じで、プログラムは複数のファイルによって構成されるという大原則はかわらない
一般的にはDI=モジュールとは言われないが、実現したいことは同じであるということを覚えておいてほしい
言語的モジュールシステムの問題点
モダンな大抵の言語にはモジュールシステムがあるが、その殆どは機能として完璧であるとは言い難い
その理由はモジュールシステムのほぼ全てが実際のファイルをimportする仕組みになっているからだ
そんなこと当たり前じゃないかと言われるかもしれないが、実はこれが問題となる場面が多々起きる
大抵のモジュールシステムはプログラムが実行される前(コンパイル、JIT、インタプリタの解析)に静的にモジュール間の依存関係が解決される
これも当たり前のようなことだが、実際は言語的仕様の制約(コンパイルなど)であり当たり前とも言えない
Node.jsのrequire()は珍しく完全に動的なモジュールシステムで、プログラムの実行時にモジュールをimportする仕組みになっている
一方でECMA Scriptのimportは基本的には静的なシステムであり、対照的である
後にdynamic importというrequireと同じ仕組みが仕様化されたのではあるが
さてこのような仕組みの問題はというと、モジュールの解決をアプリケーションの都合で変えられないという点に尽きる
アプリケーションの都合で使われるモジュールが変えられるならモジュールの意味がないじゃないかと思うかもしれない
しかし、実際のアプリケーションというのは言語の仕様やライブラリなどとは異なり遥かに複雑でステートフルなものになってしまう
その最たるものがテストであると言えるだろう
テストとDIの関係性
テストは2020年になってもプログラマを悩ませる憂いの大きなタネである
その理由はどの言語でも「テストが書きにくい」それに尽きる
当たり前だが、アプリケーションの本番環境で実行されるコードというのは、本番環境以外では実行されない
本番環境にしかないデータ、接続先、その他設定などは多岐にわたる
ローカルで再現させられない環境で問題なく動くようにテストを書くことは事実上困難である
なのでテストを書く際にはどうにかして「それなり」の疑似環境を用意せざるを得ない
そのための疑似環境としてMockという仕組みが挙げられる
これは例えばあるクラウドにあるAPIを呼ぶAPIClientというクラスがあったとして、それを使っているServiceというクラスがあると考えよう
テストの際にも実際のクラウドにあるAPIを呼ぶことも考えられるが、テストの実行時間やネットワーク状況の問題でテストの実行性能が落ちることを懸念すれば、それはなかなか選びにくい選択肢だ
なので現実的な選択肢は、テストのときだけAPIClientの実行処理を嘘の処理にするという方法が考えられる
その他にも、ある処理が終わったあと、1日後に別の処理を実行するという現実の時間を伴う処理なども難しい
実際に一日待つわけには行かないので、そういう場合には時間の経過を嘘にする何らかの仕組みが必要になる
こういう制限させるのが難しい条件というものをテストのときだけ嘘に置き換えるのがMockの基本思想である
Mockの問題点
Mockは大抵の場合テストフレームワークが提供する機能だが、どれも基本的にマトモな方法ではない
言語のモジュールシステムを乗っ取ったり、グローバル変数を書き換えたり、実行時型情報を書き換えたりと、お世辞にも美しい方法とは言えない
言語の仕組みを超越した方法で実現されたテストというものは、当たり前のように壊れる
Mockの思想は正しいが、正しい方法で実現されたMockというものはついぞ見たことがない
これは先に述べたように、言語自体にモジュール解決をアプリケーションの都合で変えるという発想がないからである
言語が提供するモジュールの仕組みはとてもナイーブに作られており、実際のアプリケーションの開発では機能不全になることが多い
DIとMockの関係性
前置きが長くなってしまったが、DIという手法はMockと相性がとてもいい
DIの基本概念を示すためにとてもかんたんな例を示す
まずは非DIのコード
code:js
import { APIClient } from "./api-client"
class Service {
api = new APIClient()
foo() {
return this.api.boo()
}
}
このようなコードではテストのときにAPIClientの振る舞いを変えさせるためにはimport自体を置き換えるしかない
次はDI的コード
code:js
import {di} from "./di"
class Service {
foo() {
const api = di.getAPIClient()
return api.boo()
}
}
何が変わっているだろうか?
前者ではServiceがAPIClientのモジュールに依存しており、APIClientのインスタンスはServiceのインスタンス化時に作成されている
後者では、明示的にServiceはAPIClientのモジュールには依存しておらず、foo()メソッドの実行時にdiと呼ばれるなにかからAPIClientらしきものを取得して実行している
この違いはとても大きいが、Serviceを利用する他のモジュールからは関係がないというのが重要な点だ
DI的になっているコードは単体テストのときにも振る舞いを変えしやすい
code:js
import { di } from "./di"
import { Service } from "./service"
class DummyAPIClient {
boo() {
return "dummy"
}
}
test("foo()", () => {
di.setAPICclient(new DummyAPIClient())
const service = new Service()
const result = service.foo() // "dummy"
})
言語が提供する機能しか使っていないので、不透明な点も少なくなる
またテストフレームワークなどにも関わらないのでどこでも使うことができる
もちろんいい事ばかりではないのだが…
DIの問題点
DIの問題点は、モジュール間依存関係を静的に定義できないことと、動的に解決するために危険が伴うということである
言語のモジュールでは、実行時にモジュールがないということは基本的には起こらないのだが※、ユーザが管理するモジュールシステムでは自由に変えられるがゆえにそういった問題が起こりうる
※Node.jsのrequire()やC言語などの動的リンクライブラリなどは同じ問題が起こります
なので、あらゆる場面で使える手法ではないのだが、現実的なアプリケーション開発では言語的モジュールよりも有用になる場面が少なくない