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
ヘルパ関数を使うコード例(同じではない)。service
と router
が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
関数が依存性を解決してくれる service
と router
以外に、other
を要求するような関数を定義している。inject
は other
を解決しない。ではどうするかというと、 inject
関数は service
と router
だけを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.definitions
を injector
にハードコーディングするのは避けたくなる。
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 に詳しく書かれているので、それを読もう。