こんにちは、三宅です。
1月からのプロジェクトで開発しているアプリケーションでは、サーバサイドもクライアントサイドもJavaScriptを採用。
その中でソフトウェアアーキテクチャやコーディングスタイルなど、いろいろなことを試してみました。
アプリケーションの特徴
コンテンツ管理がメインユースケースのアプリケーションです。
現時点ではそれほど複雑な構造にはなっていませんが、タグ付けやユーザやロールに応じたアクセス権限を実装することが将来的にあるかもしれない、というようなものです。
開発の方針
開発に際して、以下の方針を立てました。
- ドメイン層を明確に分離する
- アプリケーションで扱う概念をモデルとして表現する
- ドメイン層は特定のフレームワークやインフラに依存しない
- クラスベースで記述する
- ドメインモデルをクラスとして宣言的に定義したい
- ECMAScript2015(ES6)でクラス定義や継承などが直感的に記述できるようになったため
- サーバサイドとクラインとサイドは同じモデルを利用する
- バリデーションなどはモデルに持たせ、それらのコードが分散することを防ぐ
また、一般的なことではありますが、できるだけコードの重複を防ぐ、外部の状態に依存するコードを書かない、ということを意識しました。
フレームワークやミドルウェア
MongoDB、Express、AngularJS、Node.jsといういわゆるMEANスタックを採用しました。
ORMとしてMongoose、テンプレートエンジンにはjadeを利用しています。
コードは基本的にES6で記述し、サーバサイドはBabelを用いてファイルごとにES5に変換、クライアントサイドはBrowserifyを利用し、画面ごとにファイルを生成するようにしました。
ビルドタスクやディレクトリ構成などは、Nodeyardをほぼそのまま利用しています。
試したこといろいろ
少しいろいろなレベル感の話が出てきてしまうかもしれませんが、開発の上で試してみて良さそうだったことを書いていきます。
constでの変数宣言
ES6では「let」と「const」という新しい変数宣言の方法が追加されました。参考
letとconstはブロックスコープです。そしてletは再代入が可能、constは再代入が不可能な変数となります。
可能な限りconstを用いることで、意図しない値の再代入を防ぐことができます。
また、varではなくletを用いることで、JavaScript特有のfor文の中などで宣言した変数の意図しない挙動を防ぐことができます。ただ、繰り返し処理は基本的にイテレータを使っているので、そこまでスコープを意識する機会はありませんが。
アローファンクションの利用
アローファンクションという記述で関数を宣言できるようになりました。
const sample = (a) => {
return a + 1;
};
この記述は単なる「function」のショートハンドではなく、「this」のスコープがレキシカルスコープになるという大きな違いがあります。
JavaScriptのfunction内のthisはダイナミックスコープで、呼び出し元によって参照先が変わるという大きな特徴がありました。
アローファンクションを用いることで、例えばクラス内で定義した関数内のthisは常にクラスインスタンスになるため、挙動を予測しやすいコードになります。
オブジェクトリテラルの拡張
オブジェクトをリテラルで作成する際の構文が拡張されました。参考
その中でも、変数を設定する際のショートハンドを多用しました。
const name = req.body.name;
const password = req.body.password;
const params = { name, password };
上記のparamsは、以下のオブジェクトの宣言と同じです。
const params = {
name: name, password: password
};
変数名を何回も書く必要がなく、タイポの可能性を低減することができます。
定義したモデルの拡張
モデルをクラスとして定義するのですが、画面表示名の取得メソッドなど、ビジネスロジック以外のメソッドが欲しくなることがあります。
そのようなメソッドはコアとなるクラス定義とは別に行うようにしました。
SwiftのExtensionsのようなイメージです。
/core/entities/a.js
class A {}
/extensions/entities/a.js
import A from '../core/entities/a.js';
A.prototype.getDisplayName = () => {
return `A: ${this.name}`;
};
やっていることは、従来のJavaScriptでもあったプロトタイプ拡張です。
上記のように整理することで、コアのコードに特定の環境のみでしか利用しないメソッドを含めないようにすることができると考えています。
サーバサイドとクライアントサイドでのオブジェクトの共有
共有するモデルに対してシリアライザとデシリアライザを追加し、サーバでのレンダリング時に設定した初期値をブラウザの上で復元できるようにしました。
テンプレートをレンダリングする際に、以下のように初期値を渡すようにしました。
const initial = { user: user.seriallize() };
res.render('template', { initial });
script.
window.__INITIAL__ = JSON.parse(unescape("!{escape(JSON.stringify(initial))}"));
window.INITIAL に初期値を代入する際に、XSSを防ぐためにエスケープしてレンダリングするようにしています。
今回はAngularJSを利用しましたが、Reactのサーバサイドレンダリングでも同様に初期値を渡すことができると思います。
渡された初期値をでシリアライズして利用します。
const user = User.deserialize(window.__INITIAL__.user);
今回はモデルごとに定義しましたが、シリアライザは少し工夫すれば汎用的な関数を作れそうな気がします。
MongooseのModelの継承
Mongooseには、discriminatorというModelの継承を行うための機能があります。
The model.discriminator() function
この機能を用いて、Userというモデルに対してManagementUser、SubscriptionUserのようなユーザの種類に応じたサブタイプを定義しました。
実際に保存されるデータにはサブタイプを識別するための項目が追加されるだけなのですが、サブタイプを宣言的に定義することができること、サブタイプを指定したクエリを実行できる点で採用しました。
インスタンスが特定のクラスのオブジェクトであるかの判定
あるクラスのオブジェクトをインスタンスを返却するメソッドがあり、そのインスタンスがどのサブクラスのオブジェクトであるかによって、処理を分けたいというような場面。
ES6のクラス定義は実際には継承とプロトタイプチェーンのシンタックスシュガーであるため、instanceofが使えます。
プロジェクトでは、インスタンスを判定するための関数を定義して利用しました。
const instanceOf = function(instance, ComparisonClass) {
if (!(instance instanceof ComparisonClass)) {
return false;
} else {
return true;
}
}
Errorクラスの継承
例えばApplicationErrorのような独自のエラーを定義して、そのエラーの時だけ処理を変えたい、というような場面。
クラス継承ができるのなら…と以下のように書いていたのですが、instanceofでErrorのインスタンスとしか判断することができませんでした。
class ApplicationError extends Error {
constructor(message = 'ApplicationError', params = {}) {
super(message);
Object.keys(params).forEach((key) => {
this[key] = params[key];
});
}
}
正確には、nodeコマンドで直接実行した場合はinstanceofでApplicationErrorと判定できるのですが、Babelで変換されたコードでは「error instanceof ApplicationError」でfalseが返されました。
暫定処理として、typeプロパティを生やしてそれで判断するようにしましたが、この部分は少し調べて見る予定です。
ざっと試してみたことを書きましたが、やはりJavaScriptではイミュータブルで型に基づくプログラミングには限界がある…という印象です。
継承によるサブクラスの利用はある程度使えるのですが、多重継承ができないこと、インタフェースの定義ができないことが、大きな足かせになります。
そのようなプログラミングスタイルを取るのであれば、TypeScriptを採用した方がいいということを実感しました。
また、クライアントサイドとサーバサイドでのオブジェクトの共有で、シリアライズする際にコンストラクタなどの情報を持っていないと、デシリアライズする際にサブクラスまで復元できないという問題もありました。今回はその部分は妥協して、基底クラスのオブジェクトとして復元して利用するような形をとりました。
サーバサイドだけであれば、TypeScriptで型に基づくたプログラミングができるのですが、クライアントサイドでもコードを共有するようなパターンだと、パラメータのみのインタフェースを定義し、それに準拠したオブジェクトを処理する関数を用意する、という方式の方が楽かもしれません。今後もそのあたりはいろいろと試してみたいと考えています。