kolmas.tech note

雑記と思索、偏った技術の覚え書き

SpringのDIっぽいものを雑に自作する

だいぶ昔に以下のようなJavaのコード1を初めて見た時には、何故こんなコードが動くのか、と思った。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class SampleRepository{
    @Autowired
    private JdbcTemplate jdbcTemplate;

    // このクラス内でjdbcTemplateを初期化していないのに、それを使う処理が書かれている。
}

懸案のjdbcTemplateフィールドのアクセス修飾子はprivateである。基本的な2Javaの知識だけで素直に考えたら、このクラス内で何かしら値を設定してやらねばどうにもならなかろうと思う。

このサンプルにおいては、Spring FrameworkのDI機能がJdbcTemplateクラスのインスタンスを当該フィールドによしなに設定してくれるから、が答えではある。そう聞けば、自分のJava言語の知識からも、そういった機能は実現可能であろうと想像できるし、そうすることの利点も理解できる。とはいえ、「想像」の部分が残っているのはずっと気持ち悪かったので実際に作ってみようという、これまたn番煎じなことを試みる。

尚、遍歴の内容に重複するが、私はここ数年Javaでのプログラミングはしていない3。最後にそれなりの規模のプログラムをJavaで作ったのは2017年頃だろうか。また、そもそも実務的なプログラミング経験は持っていない4。その点で、業務でバリバリJavaを書いている人からしたら頓珍漢なことを言っているのだろうが、ご容赦いただきたい。

そもそもDIとは何だったか

JavaにおけるDI=Dependency Injection・依存性注入というものに対して、素人考えとして、雑に以下のようなものであると考えている。

依存関係にある複数のクラス間において、依存元クラス中に依存先クラスに対する参照を直接記述せず、外部から動的に依存先クラスのインスタンスを設定してやるようにする。そうすることで、クラス間の結合が疎になり、それらクラスの再利用性が増すなどの利点が考えられる。

例えば以下の簡単なクラスは、インタフェースPersonに定義されたメソッドsayHelloを利用している=当該機能に依存している。ただ、インタフェースを定義してそれを用いる事で、そのインタフェースPersonを実装するクラスのインスタンスであれば何でも受け入れられる。すなわち、依存先のクラスがどのように実装されるかを感知せずに済むようになっている。依存先の実装を依存元から分離しているといえる。

public class DependencySource{
    private Person p;
    
    public void sampleMethod(){
        p.sayHello();
    }
}
public interface Person{
    public void sayHello();
}

ここで、インタフェースPersonを実装する具象クラスが以下のように二つあるとする。

public class PersonImpl1 implements Person{
    @Override
    public void sayHello(){
        System.out.println("Hello world!");
    }
}
public class PersonImpl2 implements Person{
    @Override
    public void sayHello(){
        System.out.println("Hello Java!");
    }
}

この時、依存元のクラスでインタフェースPersonを実装しているクラスのインスタンスを得るためには、普通は以下のように、対応する具象クラスを明示しなければならない。

public class SampleClass{
    // PersonImpl2を使うならここを書き換える。
    private Person p=new PersonImpl1();
    
    public void sampleMethod(){
        p.sayHello();
    }
}

ただこれでは、折角インタフェースを定義することで依存元クラスを依存先クラスから分離したにも関わらず、依存元において依存先の具象クラスの実装を認識していないといけない。その点で、依存元の実装から依存先を完全に分離する事はできていない。そのため、依存先の実装に関わらず依存元のクラスが再利用出来ているとは言い切れない5

より端的な例は冒頭のJdbcTemplate等、データベースといった外部リソースにアクセスするためのクラスである。データベースの接続設定などはプログラムのデプロイ環境によってコロコロ変わる。その設定によって毎度、それに依存するクラスを書き換える必要があるのでは、その依存元クラスの再利用性はとても高いとは言えまい。

そこでDIである。DIの発想では、依存元クラスの定義の際に、依存先の具象クラスを明示せず、後から動的に相当する具象クラスのインスタンスを「注入」してやる。注入する具象クラスの選定やその設定を、依存元クラスから分離でき、これにより依存元クラスの再利用性が高まる6

DIの、この依存性を注入する役割を担う機構がDIコンテナである。上掲のSpring FrameworkはDIコンテナを持っているし、他のフレームワークにも同様機能を持つものがある。

DIコンテナ作ってみよう

先述の機能性を満たすには、最低限のDIコンテナには以下の機能が必要そうである。

  1. 注入対象となる=依存先となるクラスを何かしらの設定情報に基づき選別して、動的にロードしインスタンス化する。
    • 対象の依存先具象クラスをインスタンス化する処理をJavaプログラム中に静的に書いていては、具象クラスの直接指定により再利用性が低下するクラスの役割をプログラム内で押し付けあっているだけであって、DIの考え方を活かせていまい。動的なロードとインスタンス化が必要。
  2. 上記の依存先クラスのインスタンスを求める依存元に対して、その依存性注入を行う。
    • その点では、依存元クラスのインスタンス化もDIコンテナにさせ、その際に必要な依存先を注入することになる。そう思えば、依存元と依存先の区別など明確には無く、再帰的なインスタンス化及び依存性注入があるだけ。

以上を踏まえて適当なDIコンテナMyDIContainerを作ってみよう。尚、実用するつもりの全くない個人的試作であるため、例外処理などは極めて適当である。

まずは対象のクラスを動的にロードする機能を作る。今回は、独自にManagedByMyDIアノテーションを定義し、指定されたパッケージに含まれるクラスの内、このアノテーションを付されたもののみをロードするようにする。ManagedByMyDIアノテーションの定義は以下。

package tech.kolmas.mydi;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ManagedByMyDI{
}

最初、Retentionアノテーションを付け忘れていて少し嵌った。動的ロードというくらいで、プログラム実行時にまでアノテーションが保持されるようにしておかねばどうにもならない。気付けば当然の話。

実際に対象クラスを動的ロードする処理は以下のloadClassesメソッドで記述。

package tech.kolmas.mydi;

import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class MyDIContainer{
    private static final MyDIContainer container=new MyDIContainer();
    
    public static MyDIContainer getInstance(){
        return container;
    }
    
    private final Map<String,Class<?>> loadedClasses;
    
    private MyDIContainer(){
        loadedClasses=new HashMap<String,Class<?>>();
    }
    
    public void loadClasses(String[] packageNames){
        String classPath=System.getProperty("java.class.path");
        for(String packageName:packageNames){
            Arrays.stream(new File(classPath+File.separator+packageName.replace('.',File.separatorChar)).listFiles())
                    .filter(file->file.getName().endsWith(".class"))
                    .map(classFile->classFile.getName())
                    .map(fileName->packageName+"."+fileName.substring(0,fileName.length()-6))
                    .filter(className->!loadedClasses.containsKey(className))
                    .forEach(className->{
                        try{
                            Class<?> clazz=Class.forName(className);
                            if(clazz.isAnnotationPresent(ManagedByMyDI.class)){
                                loadedClasses.put(className,clazz);
                                System.err.println("Loaded class: "+className);
                            }
                        }catch(ClassNotFoundException e){
                            e.printStackTrace();
                        }
                    });
        }
    }
}

対象パッケージの名前から相当するパスを組み立て、それを実行時クラスパスと合わせて関連する.classファイルを探しにいく7。見つけた.classファイルの名前と対照パッケージ名から対応するクラス名を組み立て直し、Class.forNameでそのClassインスタンスを取得する。それにManagedByMyDIアノテーションが付いていれば、当該Classインスタンスをクラス名と対にしてMapに記録しておく。

実験用にいくつか用意したクラス及びインタフェースが以下。MyDIContainerに読み込ませるクラスには、先述の通りManagedByMyDIアノテーションを付けている。

package tech.kolmas.mydi.sample1;

import tech.kolmas.mydi.ManagedByMyDI;

@ManagedByMyDI
public class SimpleClass{
    public void simpleMethod() {
        System.out.println("This is a simple test message.");
    }
}
package tech.kolmas.mydi.sample2;

import tech.kolmas.mydi.InjectedByMyDI;
import tech.kolmas.mydi.ManagedByMyDI;

@ManagedByMyDI
public class DependencySource{
    @InjectedByMyDI
    private Person p;
    
    public void sampleMethod(){
        p.sayHello();
    }
}
package tech.kolmas.mydi.sample2;

public interface Person{
    public void sayHello();
}
package tech.kolmas.mydi.sample2;

import tech.kolmas.mydi.ManagedByMyDI;

@ManagedByMyDI
public class PersonImpl1 implements Person{
    @Override
    public void sayHello(){
        System.out.println("Hello world!");
    }
}

うちDependencySourceクラスはPerson型の依存先を持つ。MyDIContainerによる依存性注入を求めるフィールド8に対しては、それと分かるように、以下に示すInjectedByMyDIアノテーション9を付けておくことにする。尚、冒頭の例に合わせてPersonはインタフェースであり、それを実装する具象クラスとしてPersonImpl1も準備。

package tech.kolmas.mydi;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectedByMyDI{
}

これを踏まえ、実際に依存性注入を行う機能をMyDIContainerに組み込んでいく。

package tech.kolmas.mydi;

import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class MyDIContainer{
    private static final MyDIContainer container=new MyDIContainer();
    
    public static MyDIContainer getInstance(){
        return container;
    }
    
    private final Map<String,Class<?>> loadedClasses;
    private final Map<Class<?>,Object> objectPool;
    
    private MyDIContainer(){
        loadedClasses=new HashMap<String,Class<?>>();
        objectPool=new HashMap<Class<?>,Object>();
    }
    
    public void loadClasses(String[] packageNames){
        // 省略
    }
    
    public Object getInstance(String className){
        if(loadedClasses.containsKey(className)){
            Class<?> clazz=loadedClasses.get(className);
            if(objectPool.containsKey(clazz)){
                return objectPool.get(clazz);
            }else{
                Object instance=createInstance(clazz);
                objectPool.put(clazz,instance);
                return instance;
            }
        }else{
            return null;
        }
    }
    
    private <T>T createInstance(Class<T> clazz){
        try{
            System.err.println("Instantiation: "+clazz.getCanonicalName());
            T instance=clazz.getConstructor().newInstance();
            Arrays.stream(clazz.getDeclaredFields())
                    .filter(field->field.isAnnotationPresent(InjectedByMyDI.class))
                    .forEach(field->{
                        Class<?> targetType=field.getType();
                        Class<?> injectType=null;
                        for(Class<?> loadedClass:loadedClasses.values()){
                            if(targetType.isAssignableFrom(loadedClass)){
                                injectType=loadedClass;
                                break;
                            }
                        }
                        if(injectType!=null){
                            field.setAccessible(true);
                            try{
                                field.set(instance,getInstance(injectType.getCanonicalName()));
                                System.err.println("Dependency injected: "+clazz.getCanonicalName()+" <- "+injectType.getCanonicalName());
                            }catch(IllegalArgumentException|IllegalAccessException e){
                                e.printStackTrace();
                            }
                        }
                    });
            System.err.println("Instantiation done: "+clazz.getCanonicalName());
            return instance;
        }catch(InstantiationException|IllegalAccessException|IllegalArgumentException|InvocationTargetException|NoSuchMethodException|SecurityException e){
            e.printStackTrace();
            return null;
        }
    }
}

getInstancecreateInstanceの二つのメソッドを追加。クラス名を引数に与えてgetInstanceを呼び出すと、そのクラスがMyDIContainerの管理下にあるものなら、対応するClassインスタンスについてcreateInstanceが呼び出される。createInstanceでは、当該ClassインスタンスgetConstructorを使って無引数コンストラクタを取得10し、newInstanceでそのままインスタンス化する。そして、そのインスタンスを呼び出し元のgetInstanceに返してやる。getInstanceでは、一度インスタンスを作成したクラスについてはそのインスタンスを保存しておくことで、当該クラスについて再度インスタンスを要求された際に以前のものを使い回せるようにしている。

createInstanceにおいて対象クラスをインスタンス化する際、そのクラスにInjectedByMyDIアノテーションが付いているフィールドがあれば、それに対し依存性注入を行う。実際に何を注入するかは、上述の処理で動的ロードされているクラスの中から、当該フィールドの型に適合するもの、即ち当該フィールドの型をgetTypeで取ってきて、そのisAssignableFromが真になるもの、を選ぶことによって11決定する。そのクラスについてgetInstance再帰的に呼び出し12、当該クラスのインスタンスを得て、件のフィールドにセットする。フィールドがprivateなど本来不可視であっても、setAccessibleでアクセス可能に設定する。

以上までで一旦雑に13完成させたMyDIContainerの挙動を、以下のシンプルなプログラムで試してみる。

package tech.kolmas.mydi;

import tech.kolmas.mydi.sample1.SimpleClass;
import tech.kolmas.mydi.sample2.DependencySource;

public class Main{
    public static void main(String[] args){
        MyDIContainer container=MyDIContainer.getInstance();
        container.loadClasses(new String[]{"tech.kolmas.mydi.sample1","tech.kolmas.mydi.sample2"});
        SimpleClass simple=(SimpleClass)container.getInstance("tech.kolmas.mydi.sample1.SimpleClass");
        simple.simpleMethod();
        DependencySource source=(DependencySource)container.getInstance("tech.kolmas.mydi.sample2.DependencySource");
        source.sampleMethod();
    }
}

実行結果は以下。

Loaded class: tech.kolmas.mydi.sample1.SimpleClass
Loaded class: tech.kolmas.mydi.sample2.DependencySource
Loaded class: tech.kolmas.mydi.sample2.PersonImpl1
Instantiation: tech.kolmas.mydi.sample1.SimpleClass
Instantiation done: tech.kolmas.mydi.sample1.SimpleClass
This is a simple test message.
Instantiation: tech.kolmas.mydi.sample2.DependencySource
Instantiation: tech.kolmas.mydi.sample2.PersonImpl1
Instantiation done: tech.kolmas.mydi.sample2.PersonImpl1
Dependency injected: tech.kolmas.mydi.sample2.DependencySource <- tech.kolmas.mydi.sample2.PersonImpl1
Instantiation done: tech.kolmas.mydi.sample2.DependencySource
Hello world!

要求したクラスのインスタンスが生成され、そのメソッドを呼び出せている。また他クラスへの依存性を持つDependencySourceクラスのインスタンスについては、その依存先インスタンスが注入されていることが、依存先のPersonインタフェースを実装しているPersonImpl1クラスのインスタンスメソッドが呼び出されている事から確認できる。

感想など

  • Reflection APIは昔から遊んでみたかったものの一つで、今回初めて触った。予想通り楽しい。
    • 皆DIコンテナ使うだけでなくて自作してみたら良いと思う。そもそもJavaに限らず、メタプログラミング的な話題は面白いし楽しい。
    • しかしまぁsetAccessible(true)とは何だ、カプセル化って何だったっけ?という気持ちにはなる。面白いけど。
      • 真面目に考えると、そもそもJavaにおいてprivate等のアクセス修飾子は、それらを使って記述されるカプセル化に反するような変なコードをプログラマに書かせない、より有り体に言えば、そのような変なコードを「コンパイラの段階で」検知するためのものと言えよう。そう思えば、コンパイラを通り越した実行時にそれを動的に変更できたところで構いやしない、という見方は成立するか。
      • それでも本来のカプセル化の思想からは外れる部分だから、乱用するのではなく、それこそDIコンテナのような基盤的機能が汚れ仕事的に引き受けておいて、それを使うアプリケーションよりのコードにおいては美しいJavaの世界を保つようにする、ということなのだろう。
  • その他にも久々にJavaを書いてみた上での諸々。
    • あんなにも多くのJavaプログラムをこれまで書いてきたのに、あまりにも久々すぎてpublic static void main(String[] args)をスラスラと書けなかった14ことには軽くショックを受けた。もっとも、最近のJavaには簡略記法があるらしいけど。
    • 導入時期を調べてみたところ、Stream APIは私が最後にJavaを触っていた時期には存在していたはず15だが、今回初めて触った。関数型的なアプローチでは普通のものだがmapfilterなどのリスト処理関連の高階関数はやっぱり便利。

  1. 詳細はぼかしている。
  2. 何をもって基本とするかによって解釈は変わろうが。
  3. 今回の話題は最初のエントリで書いた、「かつて知人に唆されて作るだけ作り、三日坊主未満の成果しかなかった」時に最初に書こうとしたものであった。
  4. 研究のために必要なプログラムを、必要に応じて作って動かしていたくらいである。そんな背景であっても、それなりの量のプログラムは書いていたと思うが。
  5. もちろん、依存先具象クラスをインスタンス化する箇所以外の部分については、共通的に使いまわせるのだが、少なくとも当該依存元クラスの再コンパイルは必要になる訳で。
  6. その他にもメリットはあるのだろうが、野良プログラマの私にとっては、実感を伴ってイメージできるのはそれくらい。
  7. 雑な自作なので、JARファイルなどは一旦考えない。
  8. 雑な自作なので、フィールドインジェクションのみを考える。セッターインジェクションやコンストラクタインジェクションは一旦考えない。
  9. 雑な自作なので、標準で存在するInjectアノテーションは一旦考えない。
  10. 雑な自作なので、無引数コンストラクタを持たないクラスの存在は一旦考えない。
  11. 雑な自作なので、該当クラスが複数存在した時の選択とか、外部の設定ファイル等による制御などといったことは一旦考えない。
  12. 雑な自作なので、相互参照的な依存関係を持つクラスを入れたら無限再帰してしまうなどといったことは一旦考えない。
  13. 上記の通り、ここまでに「雑な自作なので、〜〜は一旦考えない。」フォーマットの注釈を何度も入れている。
  14. コマンドライン引数を受ける仮引数String[] argsを書き忘れて実行時にmainがないと怒られた。
  15. 同時期に導入されたラムダ式は当時も使っていたし、今も覚えていた。