Dependency Injection (DI) とは
前提として、 Dependency Injection (DI) とは以下のようにあるクラスをインターフェースに依存させ、 実行時にそのインターフェースを実装したインスタンスを注入する実装テクニックを指します。 以下は Spring boot による 実装例です。
public interface Service {
void execute();
}
@Component
public class ServiceImpl implements Service {
@Override
public void execute() {
System.out.println("Service Executed");
}
}
@Component
public class Client {
private Service service;
// Inject dependency from the constructor
@Autowired
public Client(Service service) {
this.service = service;
}
public void doSomething() {
service.execute();
}
}
上記は静的型付け言語による実装ですが、動的型付け言語の場合明示的なインターフェースは不要です:
class Service {
execute() {
console.log("Service Executed");
}
}
class Client {
constructor(service) {
this.service = service;
}
doSomething() {
this.service.execute();
}
}
また動的型付け言語の場合はインターフェースに依存するクラスが使用しているメソッドさえ実装していれば すべてのインターフェースを実装していなくても実行できます。このような性質をダックタイピングと呼びます。
class RealService {
execute() {
console.log("Service Executed");
}
execute2() {
console.log("Service Executed 2");
}
}
class MockService {
execute() {
console.log("Mock Service Executed");
}
}
class Client {
// you can inject MockService here because the client only use the execute() method
constructor(service) {
this.service = service
}
doSomething() {
this.service.execute()
}
}
DI は複雑なロジックをデータベースとのインタラクションや公開 REST API との通信などのテストがしにくい 副作用から分離してテストをしやすくする際によく使用され、書き捨てのプログラム以外では必須のテクニックです。
// Production code
public interface Service {
void execute();
}
@Component
public class DatabaseService implements Service {
@Override
public void execute() {
System.out.println("Database Service Executed");
}
}
@Component
public class Client {
private final Service service;
@Autowired
public Client(Service service) {
this.service = service;
}
public void doSomething() {
service.execute();
}
}
// ClientTest.java
@SpringBootTest
public class ClientTest {
@Autowired
private Client client;
@Autowired
private StubService stubService;
@Configuration
static class TestConfig {
@Bean
public Service service() {
return new StubService();
}
@Bean
public Client client(Service service) {
return new Client(service);
}
}
@Test
public void testDoSomething() {
client.doSomething();
// assertions here
}
public class StubService implements Service {
private boolean executed = false;
@Override
public void execute() {
this.executed = true;
System.out.println("Stub Service Executed");
}
public boolean isExecuted() {
return executed;
}
}
}
フレームワークなしで DI をおこなう
Java や C# などのエンタープライズフィールドで成熟した言語ではフルスタックなフレームワークが提供されており そこに DI フレームワークも含まれています。しかしマイクロサービスでよく使用される言語や動的型付け言語では 選択肢が少なく、 DI フレームワークが使用できないことも多々あります。 そのような場合でもフルスクラッチで DI を行うことは可能です。素朴には、以下のように単にインターフェースに 依存しているクラスを初期化している場所でインターフェースを実装したクラスも一緒に初期化してコンストラクタに 渡してあげるだけです。
public class ManualDIExample {
public static void main(String[] args) {
Service service = new ServiceImpl(); // initiate interface implementation
Client client = new Client(service); // inject dependency at the constructor
client.doSomething();
}
}
しかし DI を無秩序にさまざまな場所で行うわけにはいきません。 DI を行う場所は一箇所に集中させなければいけません。 それはどこにすべきでしょうか?答えはエントリーポイントです。エントリーポイントとはプログラムが実行される時 一番最初に呼び出される関数やメソッドのことです。 JS や Python ではグローバルスコープに直接手続きが書けますので、 一番最初に呼び出されるスクリプトファイルと考えることもできます。
// index.js, executed by the command `node index.js`
const service = new Service();
const client = new Client(service);
client.doSomething();
エントリーポイント (多くの言語では main 関数) は汚れ仕事を引き受ける 関数です。手続型もしくは構造化プログラミングをおこない、極力シンプルに保ちます。
フレームワークの制約などによってエントリーポイントに手を加えることができない場合は、 各 REST API のエンドポイントのハンドラなど、極力処理が開始する最初の地点に集中させます。
以下は AWS Lambda 関数の例です。 Lambda 関数は関数の単位で様々なリクエストに応じて 起動されるため、エントリーポイントに相当するものが存在しません。
exports.handler = async (event) => {
const service = new Service();
const client = new Client(service);
client.doSomething();
return {
statusCode: 200,
body: JSON.stringify('Success'),
};
};
シングルトンクラスの初期化と注入
シングルトンクラスとはアプリケーションライフサイクル中唯一ひとつしかインスタンスが存在しないクラスのことです。 実務上より重要な観点から言えば、その結果としてすべてのスレッドから状態が共有されるインスタンスです。 典型的には DB コネクションなどがシングルトン経由で参照されます (DB コネクションは 10 - 100 程度しか確立できず、サーバーレス環境や水平スケールなどですぐに溢れます)。
シングルトンクラスの初期化と注入は言語やフレームワークごとにソリューションが異なりますが、 基本的には「コンテクスト」と呼ばれる機構がある場合が多いのでそれを利用します。
コンテクストは大体の場合エンドポイントのハンドラなどで取得できることが多いので、 インターフェースに依存しているクラスの初期化はハンドラで行うことになることが多いです。
from fastapi import FastAPI, Depends
class DatabaseConnection:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DatabaseConnection, cls).__new__(cls)
# init db connection
return cls._instance
def get_db():
return DatabaseConnection()
app = FastAPI()
@app.get("/items/")
def read_items(db: DatabaseConnection = Depends(get_db)):
# use database here
return {"message": "used database"}
実装上は依存クラス自体をシングルトンにしてしまい、エントリーポイントで依存性を解決して 初期化することも可能ですが、そのような実装は避けるべきです。 依存クラスはテストが必要だから分離しているはずです。テストをするためにはテストケースごとに 独立した状態を持つ必要がありますが、シングルトンクラスはすべてのテストケースをまたいで状態を共有します。
仮に現段階でシングルトンの依存クラスがテスト上問題となる状態を持たなかったとしても、 それを型として保証することはできないため、チームのマナーとして周知する程度しか安全性を保つ方法はありません。
たとえば Kotlin では object キーワードが付与されたクラスはシングルトンの性質を持つため
内部実装を見るまでわからない言語と比べれば幾分ましですが、依然としてシングルトン型のようなものがあるわけではないため
問題があることに変わりはありません。
// production code
interface Service {
fun execute()
}
object ClientSingleton {
private lateinit var service: Service
var called: Boolean = false
private set
fun init(service: Service) {
this.service = service
}
fun execute() {
service.execute()
called = true
}
}
// test
class ClientSingletonTest {
@Test
fun testExecuteSetsCalledTrue() {
ClientSingleton.init(StubService())
assertFalse(ClientSingleton.called) // randomly failed!
ClientSingleton.execute()
assertTrue(ClientSingleton.called)
}
@Test
fun testExecuteWithoutCalling() {
ClientSingleton.init(StubService())
assertFalse(ClientSingleton.called) // randomly failed!
}
class StubService: Service {
fun execute() {
println("StubService::execute() is called.")
}
}
}
エントリーポイントが複雑化してしまったとき
エントリーポイントが複雑化してしまった場合はビルダー関数を用意します。
function buildService() {
// complex code here...
return new Service();
}
function main() {
const service = buildService();
const client = new Client(service);
client.doSomething();
}
main();
複雑な実装の依存グラフ
あまり好ましくはありませんが、 DI する実装自体に複雑な依存関係があってそれを隠蔽したい場合などは、 インターフェースの実体をまとめたコンテナを用意してそれらに依存させるということもできるかもしれません。 自作の DI コンテナです。
package main
import "fmt"
type InterfaceA interface {
PerformActionA()
}
type ConcreteA struct{}
func (c *ConcreteA) PerformActionA() {
fmt.Println("Action A is performed")
}
type InterfaceB interface {
PerformActionB()
}
type ConcreteB struct{}
func (c *ConcreteB) PerformActionB() {
fmt.Println("Action B is performed")
}
type Container struct {
serviceA InterfaceA
serviceB InterfaceB
}
func NewContainer() *Container {
// long initiate processes here...
return &Container{
serviceA: &ConcreteA{},
serviceB: &ConcreteB{},
}
}
type ServiceProviderA interface {
GetInstanceA() InterfaceA
}
func (impl *Container) GetInstanceA() InterfaceA {
return &ConcreteA{}
}
type ServiceProviderB interface {
GetInstanceB() InterfaceB
}
func (impl *Container) GetInstanceB() InterfaceB {
return &ConcreteB{}
}
type Client struct {
srvProviderA ServiceProviderA
srvProviderB ServiceProviderB
}
func NewClient(srvProviderA ServiceProviderA, srvProviderB ServiceProviderB) *Client {
return &Client{
srvProviderA: srvProviderA,
srvProviderB: srvProviderB,
}
}
func (c *Client) ExecuteActions() {
c.srvProviderA.GetInstanceA().PerformActionA()
c.srvProviderB.GetInstanceB().PerformActionB()
}
func main() {
container := NewContainer()
client := NewClient(container, container)
client.ExecuteActions()
}
ただしこの手法は Go や Rust のように多重継承が容易な言語か、ダックタイピングが可能な動的型付け言語でないと 一部のインターフェースにしか依存していないクラスをテストしたい場合でもテスト時にすべてのインターフェースを 内包したコンテナを用意しなくてはいけないため注意が必要です。
// InterfaceA.java
public interface InterfaceA {
void performActionA();
}
// ConcreteA.java
public class ConcreteA implements InterfaceA {
@Override
public void performActionA() {
System.out.println("Action A is performed");
}
}
// InterfaceB.java
public interface InterfaceB {
void performActionB();
}
// ConcreteB.java
public class ConcreteB implements InterfaceB {
@Override
public void performActionB() {
System.out.println("Action B is performed");
}
}
// Container.java
public interface Container {
InterfaceA getInstanceA();
InterfaceB getInstanceB();
}
// ContainerImpl.java
public class ContainerImpl implements Container {
private InterfaceA serviceA = new ConcreteA();
private InterfaceB serviceB = new ConcreteB();
@Override
public InterfaceA getInstanceA() {
return serviceA;
}
@Override
public InterfaceB getInstanceB() {
return serviceB;
}
}
// Client.java
public class Client {
private InterfaceA serviceA;
public Client(Container container) {
this.serviceA = container.getInstanceA();
}
public void executeActions() {
serviceA.performActionA();
}
}
// ClientTest.java
class ClientTest {
@Test
void executeActions() {
Client client = new Client(new MockContainer());
client.executeActions();
}
private class MockContainer implements Container {
@Override
public InterfaceA getInstanceA() {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public InterfaceB getInstanceB() {
throw new UnsupportedOperationException("Not supported yet.");
}
}
}
フレームワークレスの DI はスケールするのか
私の意見: スケールする。
モノリスでハンドル可能な範囲までは充分にスケールします。 スタートアップではヒューマンリソースも流動的ですので、オンボーディングコストが低いコードの重要度は高いです。
そのような理由から、デファクトスタンダードのフレームワークがプロダクトに使用している言語にない場合は むしろ DI フレームワークの導入は避けたほうがよいというのが私の意見です。
議論していない考慮すべき事項
- DI ができない場合にテスタビリティを担保する
- React など制約が強いフレームワークでの DI
- コンテクスト機構が利用できない場合のシングルトンの参照パターン
- Go や Rust などデータと振る舞いを分離できる言語での効率的な実装