LoginSignup
7

More than 5 years have passed since last update.

JavaScriptでRequireJSっぽい関数のDIフレームワークを実装する

Last updated at Posted at 2015-07-03

TL;DR; コンポーネントの名前の配列、それ以上の長さの仮引数リストを持つ関数、そしてそれらを引数にとるようなヘルパ関数を使ってDIを実現する。応用すると、コンストラクタの引数へDIしたり、コンポーネントの生成もDIフレームワークで面倒みたり、循環依存の検知したりなど色々できる。

前置き

Dependency injection in JavaScript の一部を自分なりに咀嚼して加筆修正したものなので、元記事が読めるなら元記事も読むことをおすすめします。内容自体は元記事そのままではなく、コード中の名前などの自分なりに意味が通るように変更しています。翻訳記事ではありません。

記事中のコードは Node.js v0.12.4 で軽く動作確認しています。

背景

Hipache というOSSをforkして自前のリバースプロキシLopacheを実装する過程で、どうやって薄いDIフレームワークを実装するかを悩んだので、参考のためRequireJSにおける関数のDIについてまとめた。

あの著名なRequireJSについて今更・・・という感じもするが、RequireJSにおけるDIに特化してまとめられた日本語の文章が見つからなかった書いてみる。

RequireJSっぽい関数のDI

RequireJSにおける関数のDIでは、関数の引数をDIする。そのために、DIしたいコンポーネントの名前の配列と、DI先の関数(少なくとも前述の配列以上の長さの仮引数リストを持つ)の二つを、依存性の解決をしてくれるヘルパ関数に渡す。

以下はRequireJSのような define ヘルパ関数を使うコード例(同じではない)。servicerouter がDIしたいコンポーネントの名前で、それが関数の引数を通して注入される。

define(['service', 'router'], function(service, router) {       
    // ...
});

ヘルパ関数の名前は define である必要はない。例えば RequireJSっぽいのDIを実現するために、以下のように injector オブジェクトの inject 関数を使うような実装も考えられる。

var doSomething = injector.inject(['service', 'router'], function(service, router) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
});
doSomething();

以下のコード例では、 inject 関数が依存性を解決してくれる servicerouter 以外に、other を要求するような関数を定義している。injectother を解決しない。ではどうするかというと、 inject 関数は servicerouter だけをbindした新しい関数を返す。コード例では doSomething がそれ。元の関数の第1引数と第2引数はbindされているので、第3引数だけが残る。だから、 doSomething は元の関数の第3引数だけをとる1引数の関数になる。

var doSomething = injector.inject(['service', 'router'], function(service, router, other) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");

実装

前述の「DIしたいコンポーネントの名前の配列」を deps、それ以上の長さの仮引数リストを持つDI先の関数を func とすると、 inject 関数は以下のように実装できる。

function(deps, func) {
    var args = [];
    for (var i=0; i<deps.length, d=deps[i]; i++) {
        args.push(this.dependencies[d]);
    }
    return function() {
        func.apply(null, args.concat(Array.prototype.slice.call(arguments, 0)));
    }
}

このままだと、「DI先の関数が要求する数のコンポーネントが与えられてない」というようなありがちなプログラミングエラーまたは設定ミスを起こした時、DI先の関数の実引数が undefined になってしまい、おなじみの TypeError: Cannot read property 'foo' of undefined を起こしてしまう。エラーをわかりやすくしたい、またはfail-fastさせたい場合は、エラー処理をする。

function(deps, func) {
    var args = [];
    for (var i=0; i<deps.length, d=deps[i]; i++) {
        if (this.dependencies[d]) {
            args.push(this.dependencies[d]);
        } else {
            throw new Error('Can\'t resolve ' + d);
        }
    }
    return function() {
        func.apply(null, args.concat(Array.prototype.slice.call(arguments, 0)));
    }        
}

応用: オブジェクトのコンストラクタに対するDI

さらに、このままだとこの関数をオブジェクトのコンストラクタとして使う場合に、bindされた this が捨てられてしまうので、捨てられないようにしたい場合は func.apply(this, ...) しておけばよい。

function(deps, func) {
    var args = [];
    for (var i=0; i<deps.length, d=deps[i]; i++) {
        if (this.dependencies[d]) {
            args.push(this.dependencies[d]);
        } else {
            throw new Error('Can\'t resolve ' + d);
        }
    }
    return function() {
        func.apply(this, args.concat(Array.prototype.slice.call(arguments, 0)));
    }
}

これらを踏まえると、最低限の injector は以下のように定義できる。

var injector = {
    dependencies: {
        service: {name:'service'},
        router: {name:'router'}
    },
    inject: function(deps, func) {
        var args = [];
       for (var i=0; i<deps.length, d=deps[i]; i++) {
            if (this.dependencies[d]) {
                args.push(this.dependencies[d]);
            } else {
               throw new Error('Can\'t resolve ' + d);
            }
       }
        return function() {
           func.apply(this, args.concat(Array.prototype.slice.call(arguments, 0)));
        }
   }
};

オブジェクトのコンストラクタを定義する場合は以下のように使う。

var Foo = injector.inject(['service', 'router'], function(service, router) {
    this.service = service;
    this.router = router;
});

new Foo;
//=> { service: { name: 'service' }, router: { name: 'router' } }

さらに、実用的には injector.dependencies に入るコンポーネントを定義するヘルパなどをつくることもあるかもしれない。
RequireJS では、以下のように define 関数の第一引数でモジュール名を明示したり

//Explicitly defines the "foo/title" module:
define("foo/title",
    ["my/cart", "my/inventory"],
    function(cart, inventory) {
        //Define foo/title object in here.
    }
);

ディレクトリとファイルの名前から暗黙的にモジュール名を決めてくれたりする

応用: 依存先コンポーネントの作成もDIフレームワークで

ここまでくるともはや元記事の範囲外だが、serviceやrouter自体の依存性もDIフレームワークで完結させたい場合もあるだろう。
その場合、以下のようにコンポーネントの定義を definitions、定義からコンポーネントを生成する関数を create として以下のような実装が考えられる。

var Service = function() {
    this.name = 'service';
};

var Router = function() {
    this.name = 'router';
};

var injector = {
    dependencies: {},
    definitions: {
       service: [ [], Service ],
       router: [ [], Router ]
    },
    create: function(name) {
        var definition = this.definitions[name];
        if (definition) {
            var ctor = this.inject.apply(this, definition);
            return new ctor();
        } else {
            throw new Error('Can\'t instantiate ' + name);
        }
    },
    resolve: function(name) {
        var dep;
        if (this.dependencies[name]) {
            dep = this.dependencies[name];
        } else {
            try {
                dep = this.create(name);
            } catch(e) {
                throw new Error('Can\'t resolve ' + name);
            }
        }
        return dep;
    },
    inject: function(deps, func) {
        var args = [];
       for (var i=0; i<deps.length, d=deps[i]; i++) {
            args.push(this.resolve(d));
       }
        return function() {
           func.apply(this, args.concat(Array.prototype.slice.call(arguments, 0)));
        }
   }
};

var Foo = injector.inject(['service', 'router'], function(service, router) {
    this.service = service;
    this.router = router;
});

new Foo;
//=> { service: { name: 'service' }, router: { name: 'router' } }

応用: 依存性の循環を検知してエラーにする

さらに、このままだ依存関係に循環があって解決しようがない場合に無限ループになってしまうので、循環があるときにはエラーにしたくなる。
今回は、依存関係のグラフにおいて、既に登場した未解決のコンポーネントが次に登場したときにエラーにする…という考え方でこれを実現してみる。

以下のコード例では、意図的にserviceとappの間に依存関係の循環をつくって、それを検知してエラーにする。

var Service = function() {
    this.name = 'service';
};

var Router = function() {
    this.name = 'router';
};

var App = function(service) {
    this.name = 'app';
    this.service = service;
};

var injector = {
    dependencies: {},
    definitions: {
       service: [ ['app'], Service ],
       router: [ [], Router ],
       app: [ ['service'], App ]
    },
    create: function(name, context) {
        var context = this.contextWithDefaults(context);
        var definition = this.definitions[name];
        if (definition) {
            var ctor = this.inject.apply(this, definition.concat([context]));
            return new ctor();
        } else {
            throw new Error('Can\'t instantiate ' + name);
        }
    },
    resolve: function(name, context) {
        var context = this.contextWithDefaults(context);
        var dep;

        if (context.unresolved[name]) {
            throw new Error('Cyclic dependency from and to ' + name + ' detected');
        } else {
            context.unresolved[name] = true;
        }

        if (this.dependencies[name]) {
            dep = this.dependencies[name];
        } else {
            try {
                dep = this.create(name, context);
            } catch(e) {
                throw new Error('Can\'t resolve ' + name + ': ' + e.message);
            }
        }
        return dep;
    },
    inject: function(deps, func, context) {
        var context = this.contextWithDefaults(context);
        var args = [];
       for (var i=0; i<deps.length, d=deps[i]; i++) {
            args.push(this.resolve(d, context));
       }
        return function() {
           func.apply(this, args.concat(Array.prototype.slice.call(arguments, 0)));
        }
   },
   contextWithDefaults: function(context) {
       return context || { unresolved: {} };
   }
};

var Foo = injector.inject(['service', 'router'], function(service, router) {
    this.service = service;
    this.router = router;
});

new Foo;
//=> Error: Can't resolve service: Can't resolve app: Cyclic dependency from and to service detected

最後の Error: Can't resolve service: Can't resolve app: Cyclic dependency from and to service detected というエラーを左から読むと、「serviceを解決できなかった原因はappを解決できなかったことで、その原因はserviceからserviceへの依存関係の循環が検知されたこと」となる。コード例ではserviceもappも循環しているといえるが、今回はserviceの循環が検知された。これは、今回は Foo の定義によってappより先にserviceを解決しようとするので、依存関係グラフにおいてまずserviceが登場するから。

コンポーネントの定義もヘルパ関数で

再利用性や保守性を考えると、 injector.definitionsinjector にハードコーディングするのは避けたくなる。

var injector = {
    definitions: {
       service: [ ['app'], Service ],
       router: [ [], Router ],
       app: [ ['service'], App ]
    },

ハードコーディングを避けるためには、コンポーネントを定義するヘルパ関数をつくるという方法が考えられる。
例えば、その関数を define という名前にしたとすると、

injector.define('service', [], Service);
injector.define('router', [], Router);

このように書けないだろうか。

というわけで、以下にその実装例を示す。

var Service = function() {
    this.name = 'service';
};

var Router = function() {
    this.name = 'router';
};

var injector = {
    dependencies: {},
    definitions: {},
    define: function(name, deps, func) {
        this.definitions[name] = [deps, func];
    },
    create: function(name, context) {
        var context = this.contextWithDefaults(context);
        var definition = this.definitions[name];
        if (definition) {
            var ctor = this.inject.apply(this, definition.concat([context]));
            return new ctor();
        } else {
            throw new Error('Can\'t instantiate ' + name);
        }
    },
    resolve: function(name, context) {
        var context = this.contextWithDefaults(context);
        var dep;

        if (context.unresolved[name]) {
            throw new Error('Cyclic dependency from and to ' + name + ' detected');
        } else {
            context.unresolved[name] = true;
        }

        if (this.dependencies[name]) {
            dep = this.dependencies[name];
        } else {
            try {
                dep = this.create(name, context);
            } catch(e) {
                throw new Error('Can\'t resolve ' + name + ': ' + e.message);
            }
        }
        return dep;
    },
    inject: function(deps, func, context) {
        var context = this.contextWithDefaults(context);
        var args = [];
       for (var i=0; i<deps.length, d=deps[i]; i++) {
            args.push(this.resolve(d, context));
       }
        return function() {
           func.apply(this, args.concat(Array.prototype.slice.call(arguments, 0)));
        }
   },
   contextWithDefaults: function(context) {
       return context || { unresolved: {} };
   }
};

injector.define('service', [], Service);
injector.define('router', [], Router);

var Foo = injector.inject(['service', 'router'], function(service, router) {
    this.service = service;
    this.router = router;
});

new Foo;
//=> { service: { name: 'service' }, router: { name: 'router' } }

まとめ

まとめると、

(1) コンポーネントの名前のリスト
(2) (1)以上の長さの仮引数リストを持つ関数

があって、(1)と(2)をとるようなヘルパ関数を使って、RequireJSっぽい関数のDIフレームワークを実装できる。

応用すると、コンストラクタの引数へDIしたり、コンポーネントの生成もDIフレームワークで面倒みたり、循環依存の検知したりなど色々できる。

もっと詳しく知りたい方へ

さまざまなDIの実装方法まで踏み込んだ解説は Dependency injection in JavaScript に詳しく書かれているので、それを読もう。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7