作って理解するDIコンテナ

DIコンテナ使ってるけど、アノテーションってなんなの!って聞かれて、作ってみたらわかるよと答えてみたので、自分でも作ってみました。
よくわかった。


「DIコンテナ使うと何がいいの?」ということも、作ってみるとわかります。あと「DIって何がいいの?」に関しては、「DIはちょっとコードを書くのが楽になるだけで、それだけあっても仕方ない、大事なのはコンテナ」と答えるようにしてますが、コード比率からもそれがよくわかります。

続編としてWebフレームワークも作っているので参考まで。
作って理解するWebフレームワーク - きしだのHatena

まずはコンテナを作る

とりあえず1ソースの状態で。
こんな感じで、管理する型を登録できるようにします。

        static Map<String, Class> types = new HashMap<>();
        static void register(String name, Class type) {
            types.put(name, type);
        }


それで、名前を指定してオブジェクトを取り出せるようにする、と。

        static Map<String, Object> beans = new HashMap<>();
       
        static Object getBean(String name) {
            return beans.computeIfAbsent(name, key -> {
                Class type = types.get(name);
                Objects.requireNonNull(type, name + " not found.");
                return type.newInstance();
            });
        }


あとはクラスを登録して。

        Context.register("foo", Foo.class);
        Context.register("bar", Bar.class);


オブジェクトを取り出す。

        Bar bar = (Bar) Context.getBean("bar");


この時点では単なるオブジェクトプールですね。
tinydi/Main.java at da602927261402d9c9c1a197ccd12c41c5fcbb33 · kishida/tinydi

DIしてみる

さて、getBeanとかいちいちやるのは面倒なので、関連するオブジェクトは勝手に用意しておくようにしましょう。
ここでは、標準アノテーションの@Injectを使います。


オブジェクトを用意するときに、リフレクションでフィールドをとってきて、@Injectアノテーションがついてたらフィールド名でオブジェクトを取得する、と。

    private static <T> T createObject(Class<T> type)  {
        T object = type.newInstance();
        for (Field field : type.getDeclaredFields()) {
            if (!field.isAnnotationPresent(Inject.class)) {
                continue;
            }
            field.setAccessible(true);
            field.set(object, getBean(field.getName()));
        }
        return object;
    }


それでこんな感じのインジェクションができるようになります。

    @Inject
    Foo foo;


Inject! · kishida/tinydi@8bcecd7

自動登録する

registerとかやるのも面倒なので、アノテーションがついたクラスを自動で登録できるようにします。

    public static void autoRegister() {
        URL res = Context.class.getResource(
                "/" + Context.class.getName().replace('.', '/') + ".class");
        Path classPath = new File(res.toURI()).toPath().resolve("../../..");
        Files.walk(classPath)
             .filter(p -> !Files.isDirectory(p))
             .filter(p -> p.toString().endsWith(".class"))
             .map(p -> classPath.relativize(p))
             .map(p -> p.toString().replace(File.separatorChar, '.'))
             .map(n -> n.substring(0, n.length() - 6))
             .forEach(n -> {
                Class c = Class.forName(n);
                if (c.isAnnotationPresent(Named.class)) {
                    String simpleName = c.getSimpleName();
                    register(simpleName.substring(0, 1).toLowerCase() + simpleName.substring(1), c);
                }
             });
        }
    }

まず自分のクラスのクラスファイルをリソースとしてとってきて、そこからクラスパスのフォルダを取得して、その下の.classという拡張子のファイルを全部とってきて、そこからクラス名を作って読み込んでみて、@Namedがついてれば登録する、と、なんという素敵な処理。
これで、@Namedがついたクラスが自動的に登録されます。


まあ、これでDIというものができるようになったのですが、これ、全然素敵ではないですよね。
これ以降、どれだけ機能拡張していっても、DIという機能はコンストラクタインジェクションとかバリエーションが増えるだけでほとんど変わらないです。つまり、DIだけできても何もうれしくないということです。
Auto register! · kishida/tinydi@4449c53

AOPしてみる

ちょっと嬉しさを出すためにAOPしてみましょう。とはいえ汎用のAOPフレームワークにするのは大変なので、ここではアノテーションがついたメソッドが呼び出されるときに時刻とメソッド名を出力する、ということをやってみます。


それをどうやって実装するか、なのですが、登録したクラスを継承したクラスを作って、ログを出すメソッドをオーバーライドしてログ出力処理をはさむ、という方針です。

class Foo{
  @InvokeLog
  void hoge(){
    some();
  }
}

というクラスが登録されたときに

class Foo$$ extends Foo{
  void hoge() {
    log();
    super.hoge();
  }
}

というクラスのオブジェクトを作って返すようにするわけです。


ここで裏側でクラスを作らないといけません。
標準のJavaでは、Proxyという仕組みがあって求める動作が実現可能ですが、インタフェースとして登録する必要があります。
古いDIコンテナがインタフェースを必要としていたのは、そういった実装上の制約によるものです。そして実装上の制約にもかかわらず、そんなのめんどうという声に対して「インタフェースに対して実装を行うのだ!」みたいに設計方針をかかげて正当化していたわけですね。


しかしぼくたちはもう目が覚めてしまっているので、そういうたわごとにはだまされませんよ。
とはいえ、じゃあどうやって実現するかですが、バイトコード操作という闇の技術が開発されて、実行時にバイトコードを生成することができるcglibやjavassistといったライブラリが出てきました。このバイトコード操作技術が、今のJavaの「Ease of Development」を支えているといっても過言ではないと思います。


ここではJavassistを使います。
クラス名に「$$」をつけた新たなクラスを作って、元のクラスを継承させます。

            CtClass cls = pool.makeClass(type.getName() + "$$");
            cls.setSuperclass(orgCls);


で、printlnしつつオーバーライド元を呼び出すメソッドを作って追加するわけです。

                newMethod.setBody(
                                  "{"
                                + "  System.out.println(java.time.LocalDateTime.now() + "
                                          + "\":" + method.getName() + " invoked.\"); "
                                + "  return super." + method.getName() + "($$);"
                                + "}");
                cls.addMethod(newMethod);


このように、フレームワーク内でリフレクションやバイトコード操作を行ってJavaの標準的な仕組みではできないことを実現することで、言語仕様の制約をはずれた処理を、アプリケーションとしては言語仕様内で書けるようにする、というのがアノテーションやDIコンテナの利用価値だと思います。
AOP! · kishida/tinydi@18f200d

スコープを定義する

最後に、スコープを実装してみます。
スコープとしては、アプリケーションスコープとリクエストスコープが実装できれば、中間的なスコープは応用可能なので、今回はその2つのスコープを実装します。
とりあえず、何にもついてなければアプリケーションスコープということにします。また、今回は特にWebアプリケーションを作ってるわけでもないので、スレッドごとのスコープをリクエストスコープということにしておきます。


オブジェクトの保持に関しては、ThreadLocalでオブジェクト格納場所を用意すればいいです。

    static ThreadLocal<Map<String, Object>> requestBeans = new InheritableThreadLocal<>();


あとは、RequestScopedがついていればそちらにオブジェクトを登録するようにすれば大丈夫。

        if (type.isAnnotationPresent(RequestScoped.class)) {
            scope = requestBeans.get();
            if (scope == null) {
                scope = new HashMap<>();
                requestBeans.set(scope);
            }
        }


と、単にオブジェクトをスコープ単位で保持するだけならこれだけでいいのですが、DIを考えるとこれでは問題があります。
例えば、こんな感じで、RequestScopedなクラスを登録します。通常ならLombok使うところですが、ここではわかりやすさのためにsetter/getterをちゃんと書いてます。

@Named
@RequestScoped
public class Now {
    private LocalDateTime time;

    public LocalDateTime getTime() {
        return time;
    }

    public void setTime(LocalDateTime time) {
        this.time = time;
    }
    
}


そして、アプリケーションスコープのクラスにインジェクトします。

@Named
public class Bar {
    
    @Inject
    Now now;
 
    void longProcess() {
        now.setTime(LocalDateTime.now());
        System.out.println("start:" + now.getTime());
        try {
            Thread.sleep(5000);
        } catch (InterruptedException ex) {
        }
        System.out.println("end  :" + now.getTime());
        
    }
}


2秒置いて別スレッドでlongProcessメソッドを呼び出してみます。

    public static void main(String[] args) {
        Context.autoRegister();
        Bar bar = (Bar) Context.getBean("bar");
        
        ExecutorService es = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 2; ++i) {
            es.execute(() -> {
                bar.longProcess();
            });
            Thread.sleep(2000);
        }
        es.shutdown();
    }


これを単純に実装している状態だと、一度nowフィールドに値を設定するとその後は値が更新されないので、最初のnowオブジェクトが使い続けられてしまいます。それでは、と2スレッド目で値を更新すると、nowフィールドはThreadLocalなどではない普通のフィールドなので、最初のスレッドで見るnowフィールドも置き換わってしまいます。
さて、どうしましょう?


ということで、ここでは裏側でこんな感じのクラスを作ってThreadLocalに保持したオブジェクトに委譲すればいけそう。

class Now$$_ extends Now{
  ThreadLocal<Now> obj;
  Now getObj(){
     if (obj.get() == null) {
       obj.set(Context.getBean("now"));
     }
     return obj.get();
  }

  public LocalDateTime getTime() {
    return getObj().getTime();
  }

  public void setTime(LocalDateTime time) {
    getObj().setTime(time);
  }
}


そこでまずオブジェクト保持用のクラスを作ります

public class LocalCache<T> {
    private ThreadLocal<T> local = new InheritableThreadLocal<>();
    private String name;

    public LocalCache(String name) {
        this.name = name;
    }
    
    public T get() {
        T obj = local.get();
        if (obj == null) {
            obj = (T) Context.getBean(name);
            local.set(obj);
        }
        return obj;
    }
}


あとは寿命の長いオブジェクトがインジェクトされるときに$$_をつけたクラスを作って

                cls = pool.makeClass(type.getName() + "$$_");
                cls.setSuperclass(orgCls);

ローカルオブジェクトを保持するフィールドを用意して

                CtClass tl = pool.get(LocalCache.class.getName());
                CtField org = new CtField(tl, "org", cls);
                cls.addField(org, "new " + LocalCache.class.getName() + "(\"" + name + "\");");

各メソッドに対応する委譲メソッドを作ればおっけー。

                    override.setBody(
                              "{"
                            + "  return ((" + type.getName() + ")org.get())." + method.getName() + "($$);"
                            + "}");
                    cls.addMethod(override);


こうすることで、寿命の長いオブジェクトに寿命の短いオブジェクトをインジェクトすることができるようになります。
ただし、メソッドをラップしているだけなので、フィールドを直接アクセスしてしまうとダメなわけです。


ここでがんばったことは、ThreadLocalをいかにうまく扱うかということです。つまり、DIコンテナでのスコープというのは、ThreadLocalを隠蔽する仕組みということですね。
RequestScope! · kishida/tinydi@6061739

まとめ

実装してみると、DIコンテナというのは、Javaのリフレクションやバイトコード操作、ThreadLocalといった、あまり美しくない部分を覆い隠してきれいなコードでアプリケーションを構築するための仕組みということがわかります。
このように、Javaの標準の言語仕様や仕組みでは対応できない部分を代わりに行ってくれるという仕組みであるために、Java以外の言語を使っている人に抽象的な言葉でDIコンテナのメリットを説明しても伝わらないわけですね。


もちろん、ただ便利な仕組みを作りこんでいけば便利になるかというとそうではなくて、やはりそこへの抽象的な意味付けとそこから導かれる「あるべき論」というのは大切で、Springはそのあたりがしっかりしているために多く使われてきたし、ここまで発展したのだと思います。


ということで、みんなオレオレDIコンテナとオレオレ方法論作ればいいと思うよ!

Javaフレームワーク開発入門

Javaフレームワーク開発入門

  • 作者:木村 聡
  • 発売日: 2010/07/28
  • メディア: 単行本