ウェブアプリケーションフレームワーク(以降WAF)を自作するということ
※ここでいうWAFは、モデルやビューの機能を持つなどフルスタックなフレームワークのことを意味します。ExpressやHapiなどのマイクロなものではなく、もっと大きくスケルトン的な機能を持つフレームワークをイメージしてください。
私は最近、自作のyawfというWAFを作っています。なんで作り始めたかというと、
- Sails.jsというWAFのファンなのですが、Sails.jsはモダンなNode.jsの書き方や構成からずれている部分があり、そこを修正したWAFが欲しいと思っていた
- Node.jsを日常的に書き続ける動機づけが欲しかった(仕事では使えていないので)
上記の二点が主な理由です。今の時点では、広く使われるようなWAFを目指すというよりは、自分の楽しみとして作っている面が大きいです。
なぜWAFを作ることは楽しいのか
1. 自分の理想を作れる
まずは自分のために作るので、当然縛りなどありません。自分が思う、**「最強のWAF」**を好きなだけ作り込めます。これが楽しくないわけがありません。
2. 多くのことを学べる
多分世の中の大半のITエンジニアにとって、Node.jsというのはWebpackを駆動するためのツールでしかないと思います。自分も日常の業務では大体そうです。しかし、Node.jsというのはもっと色々な機能を持っていて、それを知りたいと思っています。その時、自分でWAFを作るというのは大いに勉強になります。
実際にWAFを作る流れ
私がどのようにNode.jsで動くWAFを構築していったのか、何を考えていたのかも含めて紹介します。
1. 全体の構成を決める
そもそも私はSails.jsをモダンにしたようなWAFが欲しかったので、構成もSails.jsを真似ようと思っていました。ただ、それでも多少は全体の構成検討は行いました。
サーバーとなる部分
WAFで一番基本となる部分はどこでしょうか?それは、サーバーとしての機能を提供してくれる部分だと思っています。サーバーというのはつまり、ネットワーク越しにリクエストが来るのを待ち受けて、そのレスポンスを返す、という部分です。この部分はNode.jsが提供している超基本のAPIでも出来ますが(とはいえ機能はめちゃくちゃPoorです)、既存のフレームワークに乗っかってしまうのが楽だと思っています。
例えば、Expressというサーバーの基本機能を提供してくれるフレームワークがあります。ExpressベースのWAFも色々ありますし、実際、エコシステムが一番巨大で歴史もあるので私も良いと思っています。他にもHapiやtrek、polka、microなど、結構種類があります。それらも、Expressとの互換性を維持していたり、独自だったりだと様々です。
基本となる部分が決まると、決まってくる部分
Expressをベースにする時点で、ある程度決められる部分が出てきます。例えばルーティングの書き方や、使えるビューエンジンの種類などです。もちろんこだわりがあれば自作しても良いですが、なければExpressで使えるもの・仕様に限定してしまうのが良いでしょう。
全体の骨格となる仕組み
ここはWAFにとって一番大事です。そして一番個性が出る部分だと思っています。例としてSails.jsの話をします。
Sails.jsはフックという機能拡張の仕組みを持っています。実際には、WAFのコア機能もフック経由で提供されており、フック自体が全体の骨格の基礎となっています。WAFとただのスケルトンプログラムの違いはここだと思っていて、汎用的な機能拡張の仕組みが提供されていることが、WAFを「フレームワークたらしめている」のです。
今回yawfを作るにあたって、私はSails.jsを真似ることを意識していた(そしてSails.jsのフックという仕組みには凄く感銘を受けていた)ので、最初はそのフックの仕組みをベースにした骨格をそっくり真似ることにしました。もちろん作っていくうちに微妙にずれてきている部分もあるのですが、基本的な考え方は変わっていません。
モデルの部分
モデルも、なかなか難しい部分です。例えばSails.jsではWaterlineというORMがモデル層のライブラリとして入っているのですが、正直独自性が強く、またActiveRecordの概念を持っていません(私は欲しいな〜と思っているのですが)。なので、別のライブラリをいくつか検討しました。最初はObjection.jsを色々触っていたのですが、途中でActiveRecord的な機能がないことに気付き、Sequelizeに切り替えました。
Node.js固有の部分
Node.jsで使える言語はJavaScriptであり、そのJavaScriptの仕様となるECMAScriptは日々進化しています。そのため大抵はBabelというトランスパイラを使います(実はサーバーサイドで使うNode.jsだとトランスパイラは挟まないことも多いのですが、今回はESModuleや他のECMAScriptの機能を使いたかったので利用しました)。
モノリシック・レポジトリ
モノリシック・レポジトリというのは、一つのGitリポジトリに複数のプロジェクトが入っていることを意味します。Node.jsだとLernaというツールがモノリシック・レポジトリを管理するデファクトスタンダードの方法になっています。最近はLernaベースで構築されているフレームワークやツールもたくさん見るようになってきたのですが、Sails.jsはやはり古いのでそうはなっていません。そのせいでコードを追う時めちゃくちゃめんどくさかったり、またちょっと修正を入れるのも大変だったりしたので、絶対に自分のプロジェクトではLernaベースにしようと考えていました。
Lernaを採用するかは自由であり、ある程度大きくなってきたところで導入するのもそれほど難しくないと思うので、それぞれの好みで採用して大丈夫だと思います。
テストフレームワーク
Node.jsで使えるテストフレームワーク(テストコードを実行するためのライブラリ)は、無数にあります。かつてはMochaを使ってテストコードの構成を決め、アサーションライブラリやモックライブラリは他のを使うということも多かったですが、今はJestという全部入り(アサーションからモックまで全部出来る)のテストフレームワークを使ってしまうのが一番楽だと思います。また、JestであればLernaとの組み合わせる場合も特に問題にハマりにくいと思います。
2. 作る
もうここからは、それぞれお好きなように、という感じなのですが、私は次の順番で作っていきました。順番を書いていますが、もちろん2が終わったからもうコア部分は手を付けない、なんてことはなく必要に応じて全てのレイヤーに手を入れて作っていきます。
- プロジェクト構成
-
npm init
やLerna、Babelの導入です - 慣れてないと一番大変な部分かもしれません
- 慣れてない場合は、既存のNode.jsのプロジェクトの構成を真似るのが良いと思います
-
- Expressをラップするコア部分
- ルーティングの設定や、ビューエンジンの設定など
- WAFの開始方法などもここで最低限作ります
- WAFの設定をする部分
- ここで設定ファイルの読み込みなど、ファイルシステム周りもある程度作り込みます
- ユーザー側のコード読み込みなども実装します
- フックの仕組み
- 汎用の機能拡張機能となる部分です
- フックとして導入できるORM
- コアはできるだけ薄くし、ほかは全てフックとして提供します
- フックとして導入できるアセット管理
- 今回はWebpackをそのままアセットコンパイラとして用いる
- コマンドラインプログラムの作成
-
yawf g app
みたいなコマンドを実行するとプロジェクトが生成される
-
3. 公開する
ここは必ずしも必要ではありません。が、例え自分のためだけのWAFだとしても公開することはモチベーションの向上になりますし、達成感があります。
npmに公開する場合は、公開するファイル(package.json
のfiles
キーで公開するファイルを指定できます)の漏れがないか注意してください。
4. 繰り返す
2と3をひたすら繰り返して、WAFを育てていきます。
まとめ
簡単に私がWAFを作った(今も作っている)ときの流れを紹介しました。実際作ると、Node.jsのAPIの勉強になったり、色んなライブラリの使い方が身についたりと、スキルアップできると思います。また、日頃使っているWAFに対する不満を思う存分ぶつけることも出来ます。ぜひ、やってみてください。オススメです。
付録:Sails.jsのフックとyawfのフック
Sails.jsのフック
上でも説明したように、Sails.jsのフックは、WAFの機能を拡張する汎用的な仕組みです。フックの仕様を見てもらうとわかりますが、フックが持つべきメソッドは、
- 初期設定を返すメソッド(
defaults/configure
) - 初期化メソッド(
initialize
) - アクションを返すメソッド(
registerActions
) - ルートとアクションの関連付けを返すメソッド(
routes
)
以上の4つが基本です。この中で好きなことを出来ます。例として、sails
グローバル変数にlrucacheの変数を生やすフックを示します。
module.exports = function defineLrucacheHook(sails) {
return {
/**
* Runs when a Sails app loads/lifts.
*
* @param {Function} done
*/
initialize: function (done) {
var LRU = require('lru-cache');
sails.lrucache = LRU(sails.config.lrucache);
return done();
}
};
};
yawfのフック
フックの親クラスのソースは以下のようになっています。フックはこの親クラスを継承して作ります。
export default class Hook /*:: implements InternalHookApi */ {
__name = null
__isLoaded = false
__err = null
__logger = null
defaults() {
return {}
}
configure() {
return {}
}
async initialize() {
}
registerMixins() {
return {}
}
registerActions() {
return {}
}
bindActionsToRoutes() {
return {}
}
async teardown() {
}
}
もとはSails.jsなので、ほぼ一緒です。ただ、registerMixin
とteardown
はSails.jsのフックにはない機能です。
registerMixins
とは
Sails.jsではsails
グローバル変数がどこからでもアクセス可能で、モジュールを超えて必要なものはsails
に生やす事で渡すことが出来ました。便利なので嫌いではないのですが、yawfでは出来るだけグローバル変数を避けようと考えたため、モジュールを超えて必要なものはMixinという形で渡せるようにしました。
Mixinとは、MDNの説明にあるように、多重継承が出来ないJSにおいて色んな共通機能を一つのクラスに継承させ利用する仕組みです。概念的には下のようなイメージです。
function SampleMixin(ParentClass) /*: sampleメソッドが追加されたParentClassが返される */ {
return class extends ParentClass {
sample() { console.log('hello, I am SampleMixin method.') }
}
}
const ChildClass = SampleMixin(class {})
const childClassInstance = new ChildClass()
childClassInstance.sample() /* => hello, I am SampleMixin method. */
感覚的にはReactのHoCとかと近いものだと思います。yawfではこの仕組みを取り入れて、フックはMixinを提供し、フックの機能を利用する側はMixinを使うことでフックの機能にアクセス出来るようにしました。
例えばSequelizeへのアクセスを提供するフックは下のように書けます。
export default class extends Hook {
sequelize /*: ?Sequelize */ = null
models /*: { [string]: Model<any, any, any> } */ = {}
defaults() {
return {...}
}
async initialize() {
...
this.models = models
this.sequelize = sequelize
}
registerMixins() {
const sequelize = this.sequelize
const models = this.models
return {
utilMixin(Base /*: Class<any> */) /*: Class<any> */ {
return class extends Base {
get $models() /*: { [string]: Model<any, any, any> } */ {
return models
}
get $db() /*: ?Sequelize */ {
return sequelize
}
$transaction(...args /*: any */) {
if (sequelize) sequelize.transaction(...args)
}
$query(...args /*: any */) {
if (sequelize) sequelize.query(...args)
}
}
}
}
}
}
このフックが提供するMixinを利用する場合、下記のようにします。
import { mixins } from '@yawf/yawf'
// NOTE: - mixinsのオブジェクトに、それぞれのフックのキーが作成され、そこにMixinが格納されています
// - yawfのMixinは、引数が空で呼び出されると自動で匿名クラスを作成します
export default class extends mixins.orm.utilMixin() {
test() {
console.log(this.$db)
}
}
非常に簡潔に書けるようになったと思います。@yawf/yawf
モジュール経由でMixinを読み込むことでグローバル変数の書き換えも無いですし、フックの機能を利用する場所がはっきりするので、良いなと思っています。
teardown
とは
yawfが終了するときに呼び出されるメソッドです。Sails.jsにありそうでなかったので追加しました。