node.js でコードを書いている時にハマった 循環参照 という問題とその対処法について紹介します。
循環参照
この問題は circular dependency, cyclic dependency とも呼ばれます。これはどういう問題かというと、 あるファイルを require したときにその結果が空のオブジェクトとして返される問題 です。
この問題に直面した時はなんでこうなるのか全く理解できなかったのですが、よくよくコードを見てみると、あるファイルとあるファイルがお互いにrequireし合っていることに気づき、色々調べてみるとこういう問題があることがわかりました。
以下にこの問題を再現させるサンプルコードを示します。
// a.js
"use strict";
var B = require('./b');
module.exports = (function() {
var id, bInstance;
return {
init: function init(val) {
id = val;
bInstance = new B();
return this;
},
doStuff: function doStuff() {
bInstance.stuff();
return this;
},
getId: function getId() {
return id;
}
};
}());
// b.js
"use strict";
var A = require('./a');
module.exports = function() {
return {
stuff: function stuff() {
console.log('I got the id: ', A.getId());
}
};
};
// main.js
"use strict";
var A = require('./a')
A.init(1234).doStuff();
これらのコードを見ると、main.jsがa.jsを、a.jsがb.jsを、b.jsがa.jsをそれぞれrequireしていますね。
main.js を実行してみると、以下のように実行エラーになります。循環参照のため A というオブジェクトが空オブジェクトになっています。空なので、getId というキーでアクセスしても undefined が返っています。
$ node main.js
/Users/yohei/circularDependency/example/b.js:8
console.log('I got the id: ', A.getId());
^
TypeError: undefined is not a function
at Object.stuff (/Users/yohei/circularDependency/example/b.js:8:39)
at Object.doStuff (/Users/yohei/circularDependency/example/a.js:16:17)
at Object.<anonymous> (/Users/yohei/circularDependency/example/main.js:4:14)
...
at node.js:809:3
この問題は、モジュールAが読み込まれる前にそのモジュール自身が読み込まれた時に発生します。
上のサンプルコードの require の流れに着目してみましょう。
main.js(2行目)var A = require('./a');
→→a.js(3行目)var B = require('./b');
→→→→b.js(3行目)var A = require('./a');
→→→→b.js 読み込み完了
→→a.js 読み込み完了
流れを見てわかるように、a.js の読み込みが完了する前に b.js の中で a.js が読み込まれています。a.js の読み込みは完了していないので、b.js の3行目では空のオブジェクトが返っています。
ではどうすべきか
この問題はかなりデバッグしづらいです。できれば事前にみつけたいですね。この問題を事前に見つけるためには、 CIで自動テストを継続的に実行させることが重要ですね。ソースコードを眺めていてもこの問題を見つけるのが難しいためです。
こういう問題が起こること自体、ある意味モジュール化の仕方に問題があるとも言えるのですが、がっつり設計し直すことも難しい状況もあると思うので、
大きく構造を変えずにこの問題を解決させる方法について紹介してみます。方法の名前は勝手に命名していますw
方法1:module.exports first
require('./b') する前に module.exports を書く方法です。
// a-1.js: module.exports first
"use strict";
var A = module.exports = {}; // 注目!
var B = require('./b');
var id, bInstance;
A.init = function init(val) {
id = val;
bInstance = new B();
return this;
};
A.doStuff = function doStuff() {
bInstance.stuff();
return this;
};
A.getId = function getId() {
return id;
};
オリジナルのa.jsと上のコードを比べてみると、真っ先にmodule.exportsにオブジェクトを代入しています。requireよりも先にモジュールAをexportすることで、circluar dependencyを回避しています。
この方法でコードを書き換える場合、コーディングスタイルによっては上の例のように大幅なファイル変更が必要になる場合があります。ちなみに、 普段からmodule.exports firstでコードを書く癖をつけておくと、circluar dependencyの問題にハマる可能性もグッと下がるのでオススメです。
方法2:lazy require
利用したいモジュールを先頭で require するのではなく、必要なときに初めてrequireする方法です。
// a-2.js: lazy require
"use strict";
module.exports = (function() {
var id, bInstance;
return {
init: function init(val) {
id = val;
bInstance = (new require('./b'))(); // 注目!
return this;
},
doStuff: function doStuff() {
bInstance.stuff();
return this;
},
getId: function getId() {
return id;
}
};
}());
この方法は最も既存のコードへの影響が少ない方法です。
このファイルがrequireされるときに、init関数自体は実行されないので、循環参照は回避出来ています。init関数が呼ばれて初めて、b.jsが読み込まれます。
関数呼び出しのたびに、requireされるのでオーバーヘッドが若干気になりますが、
requireされた結果はキャッシュされるので大きなパフォーマンスの低下はないはずです。
方法3:dependency injection
dependency injection(DI; 依存性の注入)は、モジュール間の結合度を下げるためのデザインパターンの一種です。
// a-3.js: DI
"use strict";
var B = require('./b');
module.exports = (function() {
var id, bInstance;
return {
init: function init(val) {
id = val;
bInstance = new B(this); // 注目!
return this;
},
doStuff: function doStuff() {
bInstance.stuff();
return this;
},
getId: function getId() {
return id;
}
};
}());
// b-3.js: DI
"use strict";
module.exports = function(A) { // 注目!
var DI = A;
return {
stuff: function stuff() {
console.log('I got the id: ', DI.getId());
}
};
};
「モジュールBがモジュールAを利用する」という依存関係をソースコードにハードコードするのではなく、外部からモジュールBの引数に注入しています。こうすることで、b.jsはa.jsをrequireする必要がなくなるということです。
上の例だとモジュールのI/Fに若干の変更は入りますが、DIのテクニックを活用することで、モジュール間の結合度が下がり、モジュールのテストが書きやすくなるので、こちらもいい方法なのかなと思います。
まとめ
node.js における循環参照 という問題とその対処法について紹介しました。一度この問題にハマって解決方法を知るとそれ以降は用心深くなれるのですが、知らないとかなり苦労します。まだこの問題にハマっていない方も是非知っておいて損はないと思います!