Skip to content

Implement dependency injection without framework

Published: at 02:30 PM

Translates:

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 フレームワークの導入は避けたほうがよいというのが私の意見です。

議論していない考慮すべき事項