無料で使えるシステムトレードフレームワーク「Jiji」 をリリースしました!

・OANDA Trade APIを利用した、オープンソースのシステムトレードフレームワークです。
・自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんに、自動取引を開始できます。

ReactとCordovaで、Web/モバイルのハイブリットアプリを作った話

ReactとCordovaを使って、ブラウザ向けのWebUI + Androidで動くスマホアプリ を提供するサービスを、一人で作ってみた話です。

サマリー

  • 作ったもの
  • 最大の課題:作業量
  • 一人で作りきるために意識したこと
  • 取り組み1: Cordovaを使って、Web UI/スマホアプリのコードを共通化する
  • 取り組み2: レイヤードアーキテクチャを採用し、共有できるコードを最大化する
  • 取り組み3: ユニットテストを書く
  • まとめ

作ったもの

jiji2.unageanu.net

自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんにFX自動取引を開始できる、システムトレードフレームワークです。

スクリーンショットをいくつか。

Web UI:

f:id:unageanu:20151209113018p:plain f:id:unageanu:20151209113019p:plain f:id:unageanu:20151209113020p:plain

スマホアプリ:

f:id:unageanu:20151209113043p:plain f:id:unageanu:20151209113044p:plain f:id:unageanu:20151209113045p:plain

コード規模

UI側のコード規模は以下の通り。テストケースを含めた合計で、31000行くらいです。

WebUI アプリ 合計
ソースコード 15222 4454 19676
テストケース 9676 1574 11250


また、これとは別に、サーバーのコード(ruby)が、ソースコード/テストケースあわせて、27000行くらいあります。

構成

  • UIは、いわゆるシングルページアプリケーションで、ECMAScript2015 で書いています。
  • マテリアルデザインを採用していて、Reactで動くUIライブラリの Material UI を利用しています。

前置きは以上。

最大の課題:作業量

開発にあたっての最大の課題は、なんといっても作業量でした。
初期の検討段階で、「Webアプリ or モバイルアプリのどちらかに注力できないか?」と考えましたが、

  • Push通知やモバイルでのシステム管理に対応した、スマホ時代のFXシステムトレードフレームワークにしたかった。

    • 同様の機能を提供するソフトはすでにあるのですが、モバイルでの取引状況の確認やアルゴリズムの管理に難があったのが、開発のきっかけにもなっています。
  • とはいえ、スマホで取引アルゴリズムを作成したり、取引結果を分析するのはツラい

    • Rubyのコードを作成するのは、やはりPCからになると思われる。(外出先からスマホアプリで緊急修正、というのはあるにしても)
    • 取引結果の分析も、広い画面で行いたい。

ということで、両方必要という結論に至りました。そうと決まれば、後は、如何にして作りきるかです。

一人で作りきるために意識したこと

以下の2点を、特に意識しました。

  • コードの共通化/再利用
    • Web UI/スマホアプリのコードを可能な限り共通化して、開発コストを削減する。
  • ユニットテスト
    • ユニットテストで個々のモジュールの動作を保障。
    • 機能追加や変更を、低コストで素早く行えるようにする。

そして、↑のための具体的な取り組みとして、以下を行いました。

  1. Cordovaを使って、Web UI/スマホアプリのコードを共通化する
  2. レイヤードアーキテクチャを採用し、共有できるコードを最大化する
  3. ユニットテストを書く

取り組み1: Cordovaを使って、Web UI/スマホアプリのコードを共通化する

スマホアプリをCordovaを使ったハイブリットアプリにして、Web UI/スマホアプリのコードを共通化しました。

  • Cordovaを使うことで、JavaScript + HTML + CSSスマホアプリを作ることができるので、同様の構成で作成するWeb UIのコードを再利用できるようになります。
  • React Native という選択肢もありましたが、UI側の開発に着手した時点ではAndroidには対応していなかったため、見送りました。
    • Cordovaは、Webでの情報が豊富で、実績も多くあった点がプラスでした。
    • また、Push通知や課金決済関連のプラグインがあったことも大きいです。
  • 採用に当たって懸念だった、UIのパフォーマンスについては、かんたんなプロトタイプを作って検証しました。
    • やはり、ネイティブアプリよりはもっさりしていますが、ゲームのようなパフォーマンスがアプリの価値に直結する性質のソフトウェアではないので、問題ないレベルと判断。
    • それよりも、リリースまでの開発工数を抑えること、また、リリース後の機能強化や改善をスピード感を持って行えること、を重視しました。 unageanu.hatenablog.com

「UIの反応速度とか使い勝手とか、実際どんなもんなの?」と気になる方は、ダウンロードしてお試し頂ければと思います。(まいど、ありがとうございます! / 30日の無料トライアル期間内に、定期利用を解除すれば請求は発生しないので大丈夫です)

なお、Crosswalkの効果は絶大でした。体感速度が劇的に向上したうえ、怪しい動きをしていた箇所も治ったり。 apkのサイズ/起動時間は増えますが、採用する価値はあります。

取り組み2: レイヤードアーキテクチャを採用し、共有できるコードを最大化する

MVVMのレイヤードアーキテクチャを採用して、Modelレイヤのコードを共通化。
ViewやViewModelも、変更の度合いに応じて最適なレイヤーでカスタマイズすることで、共有できるコードを最大化しました。

f:id:unageanu:20151209145903p:plain

  • Model/View/ViewModel + 通信などのInfrastructureで構成。
  • Model(UIに依存しない、アプリ共通のコアドメイン)は、Web UI/スマホアプリでそのまま共有。
  • スタイルの変更だけで済む場合は、CSSのレベルでカスタマイズ。
  • DOM構造を変える必要がある場合は、View(Reactコンポーネント)のコードを差し替えて対応。
  • Viewで管理するデータや機能がそもそも異なる場合は、ViewModel(Modelをラップして、Viewに依存するデータや操作を提供するクラス群)のレベルで、カスタマイズして再利用。
    • 例えば、WebUIでは、"通知の一覧を表示する機能"と"選択した通知の詳細を表示する機能"を同じ画面で提供していますが、スマホアプリでは別画面にしています。 このため、WebUI版では、通知の一覧画面のViewModelで通知一覧と選択状態を管理していますが、スマホアプリ版では2つのViewModelで管理する形に変更しています。

このほか、Infrastructureの通信部分やGoogle Analyticsでの利用状況解析も、WebUI/スマホアプリでカスタマイズして利用しています。

  • 通信部分は接続先REST APIを差し替える必要があるため(Web UIは自ホストに、スマホアプリはUIで設定したサーバーに接続)、URLResolverとして機能を切り出しカスタマイズしています。
  • 利用状況解析は、スマホアプリではJava APIを使うようカスタマイズしたクラスを用意して差し替え。

以下は、利用状況解析のコードの抜粋です。 インターフェイスを統一しつつ、実装を差し替える形で、別のクラスを用意しました。

WebUI版:

export default class GoogleAnalytics {

  // 略
  
  // Google Analytics のJavaScript版APIを呼び出す
  sendEvent( action, label="", value={} ) {
    this.run((ga) => ga('send', 'event', this.category, action, label, value));
  }

  sendTiming( category, timingVar, time, label ) {
    this.run((ga) => {
      ga('timing', category, timingVar, time, label);
    });
  }
  
  // 略
}

スマホアプリ版:

export default class GoogleAnalytics {

  // 略
  
  // Cordova google-analytics-plugin のAPIを呼び出す
  // https://github.com/danwilson/google-analytics-plugin
  sendEvent( action, label="", value={} ) {
    this.run((ga) => {
      ga.trackEvent(this.category, action, label, value,
        ()  => {},
        (e) => console.log(e));
    });
  }

  sendTiming( category, timingVar, time, label ) {
    this.run((ga) => {
      ga.trackTiming(category, time, timingVar, label,
        ()  => {},
        (e) => console.log(e.message));
    });
  }
  
  // 略
}

これら、コンポーネントの差し替えは、DI(Dependency Injection)コンテナを利用して、行うようにしました。 DIコンテナを使うことで、コンポーネントの外側で、環境ごとにどのクラスを使うのか宣言的に指定できるようになります。

WebUI版のコンポーネント定義:

binder.bind("googleAnalytics").to("utils.GoogleAnalytics")
  .withProperties({
    category: "web-ui",
    version:  "1.0"
  }).onInitialize("initialize");

スマホアプリ版のコンポーネント定義:

// スマホアプリ用のクラスを使うように変更
binder.bind("googleAnalytics").to("app.utils.GoogleAnalytics")
  .withProperties({
    category: "app",  // category, versionもカスタマイズ。
    version: "1.0.6"
  }).onInitialize("initialize");

利用側のコードは以下のような感じ。Injectとしてマークしておくと、コンポーネント定義で宣言されたコンポーネントが注入されるので、利用側はAPIを呼び出すだけです。

export default class RmtService extends AbstractService {

  constructor() {
    this.urlResolver     = ContainerJS.Inject;
    this.xhrManager      = ContainerJS.Inject;
    this.googleAnalytics = ContainerJS.Inject; 
    // コンポーネント定義で宣言されたコンポーネントが注入される
  }

  // 略

  putAgentSetting(settings) {
    this.googleAnalytics.sendEvent( "put rmt agent setting" ); // GAにイベントを送信
    return this.xhrManager.xhr(this.serviceUrl("agents"), "PUT", settings);
  }

}

取り組み3: ユニットテストを書く

MVVMアーキテクチャを採用したことで、View以外の部分はDOMに依存しなくなるため、容易にユニットテストができるようになります。 ユニットテストを意識的に用意したことで、一人でも何とか作りきることができたかな、と感じます。

  • 一人で開発したとこもあり、サーバー側の開発で1か月くらいrubyのコードばかり書いていると、自分が書いたコードでも忘れてしまって、変なバグを埋め込んだりします。テストコードがあることでデグレードが検知され、バグに気付くことが多くありました。
  • また、コードがどう動くか、についても、テストケースをみると思い出せるということもありました。

ユニットテストで確認された"動く"コードを少しずつ積み上げていく、という作り方は、心理的にも良かったと思います。 一人なので、開発期間はどうしても長くなりますし、やってもやってもタスクがなくならない、終わりが見えない感じになりますが、"動く"コードが積み上がっていくことで、毎日少しずつでもコードを書いていれば、いつか完成すると思えるようになります。

なお、初版リリースの段階ではViewのテストは省略しました。

  • 初版リリースでは、UIの精緻化などでDOMの構造を変更する可能性が高く、テストを作成しても、効果が変更コストに見合わないと考えました。
  • また、Viewの機能は「ViewModelの状態に応じて仮想DOMを生成する」だけで、テストすべきロジックが少ない && ViewModelのテストで、アプリケーションがこういう状態の時はこうなる、〇〇操作を実行するとこういう状態になる、といった機能は確認できている、というのも理由としてあります。
  • 同じ理由で、E2Eテストも行っていません。このあたりは、今後、サービスが利用されるようになって、メンテナンスの比重が大きくなってからの課題と考えています。

まとめ

  • Cordovaを使ってちゃんと作れば、一人でもサービスを作れる。
    • プロトタイプや最初のMVPは、Cordovaで素早くコストをかけずに作る、そして、手ごたえがあったらネイティブで作り直す、という選択肢は割とアリなのではと思います。
    • もちろん、パフォーマンスが重要なアプリの場合は、不相応な場合もありますが。
  • ユニットテスト重要。テスタビリティは最初から考慮して作ろう。
    • ユニットテストを用意することで、変更ややり直しのコストを最小にでき、トータルの開発コストを大きく削減できます。
    • ユニットテストで確認された"動く"コードを少しずつ積み上げていく、という作り方は、心理的にも効果大です。
  • Crosswalk速い。使おう。

Webアプリ向け高機能コードエディタ「Ace」をReactに組み込んで使ってみる

Ace とは?

Ace は、Webアプリケーション向けのリッチなコードエディタです。 シンタックスハイライトや正規表現検索に対応した高機能なコードエディタを、Webアプリにさくっと組み込めます。

機能一覧: (Aceのサイトより)

  • Java,Ruby等を含む、110言語のシンタックスハイライトに対応
  • 自動インデント
  • マルチカーソル
  • 正規表現での検索/置換
  • 関数やクラスの折り畳み機能(Code folding)
  • 「Ctrl + /」での一括コメント
  • ..etc..

おお、なんかすごい高機能だ。

Reactに組み込んで使う

React-Ace を使います。

$ npm install react-ace

jsのソースは以下。

import React     from 'react'
import AceEditor from 'react-ace'

// webpackで統合するので、必要なテーマや拡張モジュールは
// 明示的にimportしておく必要があります。
import 'brace/mode/ruby'
import 'brace/theme/github'
import 'brace/ext/searchbox'

class RubyEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  render() {
    return (
      <AceEditor
        mode="ruby"
        theme="github"
        value={this.props.source}
        onChange={this.onChange.bind(this)}
        name="editor"
      />
    );
  }

  onChange(newValue) {
    console.log(newValue); // とりあえず
  }
}
RubyEditor.propTypes =  {
  source: React.PropTypes.string.isRequired
};
RubyEditor.defaultProps =  {
  source: "# test. \nclass Foo\nend"
};

React.render(
  <RubyEditor />,
  document.body );

unageanu/sandbox/ace - GitHub にPushしているので、package.jsonなど他のソースはこちらを参照ください。

動いているところはこちら。

JavaScript Dateのタイムゾーン指定できない問題とその対策

JavaScriptのDate型では、タイムゾーンを外部から指定できない

  • JavaScriptのDate型は、タイムゾーンの情報を持ってはいますが、APIで外から変更することはできません。
    • getTimezoneOffset() はあるけど、setTimezoneOffset(timezone) はありません。
    • getTimezoneOffset() の値は実行環境のタイムゾーンになります。

  • getHours()やgetMinutes()はローカルのタイムゾーンにおける時刻表現を返すので、実行環境によって結果が異なり、問題になる場合があります。
    • Dateを表示用の文字列に変換する処理のテストを書いていたら、CI環境(CircleCI)で動かしたときに結果が違ってエラーに。
    • 変換APIの仕様としては、「実行環境に合わせた時刻表現を返す」のが期待の動作なので、動きとしては間違っていないのですが、テストでは困る・・
    • CI環境のタイムゾーンを変更する道もあるけど、タイムゾーンの設定によって影響が出る範囲を把握するためにテストケースはいろいろな環境で動かすようにした方がいいかな、ということで、環境はいじらない方向で対策を検討。
const date = new Date( '2015-05-10T12:00:00.000Z' );
expect( date.getHours() ).toEqual(21); 
// ローカルタイムゾーンがJSTの場合は動作するが、他の環境では違う結果になる。

対策

以下のようにしてみました。

  • Dateの代わりに、 date-with-offset を使う。
    • Dateにタイムゾーン指定機能を追加したライブラリ。DateのAPIと互換性があるのでそのまま置き換えられます。
  • DateWithOffsetをnewするときに、static変数からタイムゾーンを読み込んで使う。
    • static変数で指定されていればそちら、指定がなければローカルのタイムゾーンを使います。
    • これにより、ローカルタイムゾーンの影響を受けるテストの実行時にのみ、指定したタイムゾーンでテストを実行きるようになります。
    • 指定がない場合は普通のDateと同じ動きになるので、プロダクション環境では「実行環境に合わせた時刻表現を返す」動作となります。

具体的にはこんなユーティリティを作成。

import DateWithOffset from "date-with-offset"

const defaultTimezoneOffset = new Date().getTimezoneOffset()*-1;

export default class Dates {

  static date(iso8601String) {
    return new DateWithOffset(
      iso8601String, this.getTimezoneOffset() );
  }

  static getTimezoneOffset() {
    return this.timezoneOffset != null
        ? this.timezoneOffset
        : defaultTimezoneOffset;
  }
  static setTimezoneOffset(timezoneOffset) {
    this.timezoneOffset = timezoneOffset;
  }
  static resetTimezoneOffset() {
    this.timezoneOffset = null;
  }
}

ローカルタイムゾーンに依存するテストが動作するところは、以下のようにすることで、どの環境でも同じ結果が返るようになります。

beforeEach( ()=> Dates.setTimezoneOffset(540) );
afterEach(  ()=> Dates.resetTimezoneOffset() );

it("ローカルタイムゾーンに依存するテスト", () => {
  // このテスト内では、ローカルタイムゾーンがJSTになる。
  expect( Dates.date("2015-05-10T12:00:00.000Z").getHours() ).toEqual(21);
});

Cordova + React.js + Material UI で、 Material Design な Android アプリのプロトタイプを作ってみた

Cordova のお試しということで、 Cordova + React.js + Material UI で Material Design な Android アプリのサンプルを作ってみました。

  • HTML+CSS+JavaScript でネイティブアプリが作れるCordova ですが、やはり遅いという話をよく耳にするので、実際どうなのか確認してみるのが目的。
    • 使えそうなら、フロントはJavaScriptで書いて、せめてモデル層だけでも共通化したい。
  • UIは Material Designにしたいので、 Material UI フレームワークを使ってみました。
  • React Nativeも気になるところですが、Android版はまだらしいのでとりあえずスルー。Macも持ってないし。

プロトタイプの機能

f:id:unageanu:20150404202246p:plain

  • メイン画面には、Action Bar と Card を表示。
    • Cardは以下の3種類を用意。
      • テキストを流し込んだもの
      • 画像を張り付けたもの
      • 簡単なグラフを描画したキャンバスを張り付けたもの
    • 少しスクロールするよう、複数個貼り付けています。
  • Action Bar の左上のボタンをタップすると、Navigation Drawerが表示される。
    • メニュー選択時の処理は実装していないので、表示だけです。
  • パネルをタップすると、パネルが回転するアニメーションが走る
    • CSS transitionのパフォーマンスも見てみたいので。

コードと実行手順

コードは こちら で公開しています。 Node.js とAndroidのビルドツール一式(JDK, Android SDK, Ant) をインストールして、以下のコマンドを実行すれば動かせるはずです。

$ git clone https://github.com/unageanu/sandbox.git
$ cd sandbox/cordova/sample-app
$ npm install
$ npm run-script init
$ npm run-script build
$ npm run-script run-browser # ブラウザで実行
$ npm run-script run-android # Android(実機)で実行

所感

ややもっさり感はあるものの、シンプルなアプリであれば十分使えそう。

  • 画面のスクロールや、LeftNavi表示時のアニメーションは少しもたつく。
  • transition を使ったアニメーションは速い。
  • レンダリングも、特に汚いといったことはなさそう。
  • 起動は5~6秒といったところ。スプラッシュスクリーンを出しておけば大丈夫なレベルかな。

確認は手持ちの端末(Xperia Z)で行いました。2年前の端末なので、これで使えれば最近の端末でも大丈夫でしょう。さすがにゲームとかは厳しそうですが、とりあえず、jiji2で必要な機能なら十分実用に耐えるのではないかと。

unageanu.hatenablog.com

unageanu.hatenablog.com

React.js + Webpack + ContainerJSでTODOリストを作ってみた

React.js + Webpack + ContainerJS でTODOリストを作ってみたので、手順をまとめます。

ソース全体はこちらで公開しているので、あわせて参照ください。

ポイント

  • モジュールローダーを require.js → Webpack に

    • ソースはクラス単位で分割管理して、Webpackで1ファイルにまとめてリリースします。
    • WebpackだとAMDもCommonJSも両方扱えるので、npmのライブラリが使いやすくなるのは大きいかな。
    • あと、Loaderも便利ですね。
  • Viewモジュールを Knockout.js → React.js に

    • 流行ってますね。
    • React.js に合わせて、アーキテクチャも Flux にしてみます。 http://facebook.github.io/flux/img/flux-simple-f8-diagram-with-client-action-1300w.png
  • ES6で書いてみる

    • Webpack 使うなら、せっかくなので Loader も使ってみたい。
    • ということで、ES6で書いてBabel LoaderでES5にトランスパイルする感じに
  • インスタンスの生成と依存関係の解決 && 注入は ContainerJSで

  • テストはJasmine2で

    • 前回同様、なるべくテストできる構成にして、テスト書きます。
  • ビルドツールは gulp

サマリ

  1. ビルドツール(gulp,Webpack ..etc..)をインストールする
  2. ビルドタスクを書く
  3. container-js, React.jsをインストールする
  4. Storeを書く
  5. Storeのテストを書く
  6. Dispatcher, Action を書く
  7. Components を書く
  8. main.jsなどのGlueコードを書く
  9. HTML,CSSを用意する
  10. ビルドして実行してみる

ビルドツールをインストールする

前提として、node, npmがインストールされているものとします。

まずは、gulpやgulp-webpackなど必要なビルドツールをインストールします。

  • プロジェクトのルートディレクトリに package.json を作成して、必要なライブラリを記載します。
  • インストールするライブラリは以下。
    • gulp .. gulp 本体
    • ES6トランスパイラ関連
      • babel-core .. ES6のソースをES5に変換するトランスパイラ
      • babel-loader .. babel を Webpack から使うための Loader
    • Webpack関連
      • webpack .. Webpack 本体。
      • gulp-webpack ..gulpから Webpack を呼び出すときに使うプラグイン
    • テスト関連
      • jasmine-core .. jasmine 本体。
      • jasmine .. node 上で jasmine を使えるようにするラッパー
      • gulp-jasmine ..gulpから jasmine のテストを実行するときに使うプラグイン
    • その他
      • rimraf .. ディレクトリを一括削除するときに使うgulpプラグイン
  • モジュール名等は適当に。
{
  "name": "todo-list",
  "version": "1.0.0",
  "description": "container-js todo-list example with React.js and Webpack",
  "main": "",
  "dependencies": {
  },
  "devDependencies": {
    "babel-core": "^4.7.12",
    "babel-loader": "^4.1.0",
    "gulp": "^3.8.11",
    "gulp-jasmine": "^2.0.0",
    "gulp-webpack": "^1.3.0",
    "jasmine": "^2.2.1",
    "jasmine-core": "^2.2.0",
    "rimraf": "^2.3.1",
    "webpack": "^1.7.3"
  },
  "engines": {
    "node": ">=0.10.0"
  },
  .. 略
}

package.jsonを書いたら、npmでまとめてインストールします。

$ npm install
$ ls ./node_modules
babel-core  babel-loader  gulp  gulp-jasmine  gulp-webpack  jasmine  jasmine-core rimraf  webpack

ビルドタスクを書く

以下のタスクを用意します。

名前 機能
build ES6、JSXコードをトランスパイルしてWebpackで統合したjsファイルを生成します。
build-test テストをWebpackでビルドします。
test テストを実行します
clean ビルド成果物を削除します

 

gulpfile.jsが肥大化しないよう、タスクの実装は tasks/ 以下に配置して、gulpfile.jsで読み込んで使う形にしました。

gulpfile.js:

'use strict';

var requireDir = require('require-dir');
requireDir('./tasks', { recurse: true });

tasks/build.js:

  • config/webpack.js にWebpackの設定ファイルを用意して、Loaderの設定はそこから読み込んでいます。
'use strict';

var gulp    = require('gulp');
var uglify  = require('gulp-uglify');
var webpack = require('gulp-webpack');
var config  = require('../config/webpack.js').src;

gulp.task('build', ['copy-resources'], function () {
    return gulp.src(config.entry)
        .pipe(webpack(config))
        .pipe(gulp.dest("./build/app/js"));
});

config/webpack.js:

  • module.loaders で /node_modules 以外の jsファイルをbabel-loaderにかけるように設定。
    • babel-loaderはJSXのトランスパイルにも対応しているので、それもやらせます。
  • ContainerJSで動的なモジュール読み込みを使うので、 exprContext と unknownContext の設定をしています。
    • この設定を行うことで、src/js以下のモジュールは、明示的に import していなくても、リリースアーカイブに同梱され利用できるようになります。
"use strict";

var base = {
    module: {
        loaders: [{
            test:     /\.js$/, 
            exclude:  /node_modules/,
            loader:  'babel-loader'
        }],
        
        // container.jsで呼び出しているLoaderから、exprContextを生成する。
        // コンテキストが生成されると、application.jsのあるsrc/js以下のモジュール
        // が再帰的に探索して登録され、require('module'); でロードできるようになる。
        // entry で指定したmain.jsが2重登録されないよう、正規表現で除外。
        exprContextRecursive : true, 
        exprContextRegExp: /^\.\/(?!main)([a-zA-Z0-9\-\_\/]*)$/,
        exprContextCritical: false,
        exprContextRequest: '../../../src/js',
        
        // もう一つunknownContextもできるが、不要なので、無視する。
        unknownContextRegExp: /$^/, 
        unknownContextCritical: false
    }
};

module.exports = {
    src : {
        entry: './src/js/main.js',
        output: {
            filename: 'main.js'
        },
        resolve: {
            root: __dirname + '/src/js', 
        },
        module: base.module
    },
    
    test : {
        entry: './test/all-specs.js',
        output: {
            filename: 'all-specs.js'
        },
        resolve: {
            root: __dirname + '/test'
        },
        module: base.module
    }
}

残りのタスクは、コードを参照。

container-js, React.jsをインストールする

npm でインストールします。

$ npm install container-js react --save

Storeを書く

環境が整ったので、コードを書いていきます。 ファイル構成はこんな感じ。Fluxのチュートリアルと合わせています。

$ tree src/js
src/js
├── actions
│   └── todo-list-actions.js
├── components
│   ├── todo-input-form.js
│   ├── todo-list-view.js
│   └── todo-view.js
├── composing
│   └── modules.js
├── dispatchers
│   └── todo-list-dispatcher.js
├── main.js
└── stores
    ├── todo-list.js
    └── todo.js
  • MVVM版と同じく、Todo と TodoListを用意します。
  • ES6なら Object.observe() が使えるので、変更通知系の処理は削っています。

src/js/stores/todo-list.js:

import ContainerJS from 'container-js';
import Todo        from './todo';

export default class TodoList {
    
    constructor() {
        this.items = [];
    }
    
    get( id ) { 
        for ( let item of this.items ){
            if (item.id === id) return item;
        }
    }
    add( title ) { 
        const todo = new Todo(title);
        todo.attachTo(this);
        this.items.push(todo);
        return todo;
    }
    removeCompleted() {
        this.items = this.items.filter( item => !item.completed );
    }
    removeById(id) {
        this.items = this.items.filter( item => item.id !== id );
    }
}

src/js/stores/todo.js:

var sequence = 1;

export default class Todo {
    
    constructor(title, completed=false, now=new Date()) {
    
        this.validateTitle(title);
    
        this.id           = sequence++;
        this.title        = title;
        this.completed    = completed;
        this.createdAt    = now;
        this.lastModified = now;
        this.todoList     = null;
    }
    
    setTitle( value ) {
        this.validateTitle(value);

        this.title        = value;
        this.lastModified = new Date();
    }
    
    complete() {
        this.completed    = true;
        this.lastModified = new Date();
    }
    activate() {
        this.completed    = false;
        this.lastModified = new Date();
    }
    
    remove() {
        if (!this.todoList) throw new Error("illegal state. todoList is not set.");
        this.todoList.removeById( this.id );
    }
    
    attachTo( todoList ) {
        this.todoList = todoList;
    }
    
    validateTitle(title) {
        if (!title) throw new Error("title is not set.");
        if (title.length > 100) throw new Error("title is too long.");
    }
}

Storeのテストを書く

Storeのテストも書きます。

  • 更新通知系の処理がなくなったので、テストもだいぶ減りました。

test/specs/stores/todo-list-spec.js:

import TodoList from '../../../src/js/stores/todo-list';

describe('TodoList', () => {
    
    let todoList;
    let events = [];

    beforeEach( () => {
        todoList = new TodoList();
    });
    
    afterEach(() => {
        events = [];
    });
    
    describe('get', () => {
        it( "can retrieve a todo by id.", () => {
            const todo = todoList.add("test1");

            expect( todoList.get(todo.id) ).toBe( todo );
        });
    });
    
    describe('add', () => {
        it( "creates and add a new todo.", () => {
            todoList.add("test1");
            
            expect( todoList.items.length ).toEqual( 1 );
            expect( todoList.items[0].title ).toEqual( "test1" );
            
            todoList.add("test2");
            
            expect( todoList.items.length ).toEqual( 2 );
            expect( todoList.items[0].title ).toEqual( "test1" );
            expect( todoList.items[1].title ).toEqual( "test2" );
        });
    });
    
    ... 略
    
});

test/specs/stores/todo-spec.js:

import Todo    from '../../../src/js/stores/todo';

describe('Todo', () => {
    
    describe('new', () => {
        it( "can creates a new instance.", () => {
            
            jasmine.clock().mockDate(new Date(2013,0,1));
            
            let todo = new Todo("test");
            
            expect( todo.title ).toEqual( "test" );
            expect( todo.completed ).toEqual( false );
            expect( todo.createdAt ).toEqual( new Date(2013,0,1) );
            expect( todo.lastModified ).toEqual( new Date(2013,0,1) );
        });
        
        it( "can creates a new instance from arguments.", () => {
            
            jasmine.clock().mockDate(new Date(2013,0,1));
            
            let todo = new Todo("test", true, new Date(2014, 1, 1));
            
            expect( todo.title ).toEqual( "test" );
            expect( todo.completed ).toEqual( true );
            expect( todo.createdAt ).toEqual( new Date(2014, 1, 1) );
            expect( todo.lastModified ).toEqual( new Date(2014, 1, 1) );
        });

        it( "generate a new id.", () => {
            
            let todo1 = new Todo("test");
            let todo2 = new Todo("test");
            
            expect( todo1.id ).not.toEqual( todo2.id );
        });
    });

    ... 略
    
});

テストは以下のコマンドで実行できます。

$ gulp test                                                                                                       
[19:09:58] Using gulpfile todo-list-using-webpack/gulpfile.js
[19:09:58] Starting 'copy-test-resources'...
[19:09:58] Finished 'copy-test-resources' after 11 ms
[19:09:58] Starting 'build-test'...
[19:10:09] Version: webpack 1.7.3
       Asset    Size  Chunks             Chunk Names
all-specs.js  772 kB       0  [emitted]  main
[19:10:09] Finished 'build-test' after 12 s
[19:10:09] Starting 'test'...
...........

11 specs, 0 failures
Finished in 0 seconds
[00:00:00] Finished 'test' after 244 ms

node上で実行するほか、./build/test/test.html をブラウザに読み込ませることでも実行できるようにしてみました。

Dispatcher, Action を書く

Storeの操作をViewから呼び出す時の口となる、 Dispatcher と Action を用意します。

src/js/dispatchers/todo-list-dispatcher.js:

import ContainerJS from 'container-js';

export default class TodoListDispatcher {
    
    constructor() {
        this.callbacks = {};
        
        this.todoList = ContainerJS.Inject;
    }
    
    initialize() {
        this.registerCallbacks();
    }
    
    dispatch(payload) {
        const callback = this.callbacks[payload.actionType];
        if (callback) callback(payload);
    }
    
    registerCallbacks() {
        this.callbacks["add"] = 
            (payload) => this.todoList.add(payload.title);
        this.callbacks["removeCompleted"] = 
            (payload) => this.todoList.removeCompleted();
        
        this.callbacks["complete"] = (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.complete();
        };
        this.callbacks["activate"] =  (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.activate();
        };
        this.callbacks["remove"] =  (payload) => {
            let todo = this.todoList.get(payload.id);
            if (todo) todo.remove();
        };
    }
}

src/js/actions/todo-list-actions.js:

import ContainerJS from 'container-js';

export default class TodoListActions {
    
    constructor() {
        this.callbacks = {};
        
        this.dispatcher = ContainerJS.Inject;
    }
    
    add(title) {
        this.dispatcher.dispatch({
            actionType: "add",
            title:       title
        });
    }
    
    removeCompleted() {
        this.dispatcher.dispatch({
            actionType: "removeCompleted"
        });
    }
    .. 略
} 

Components を書く

次はViewですね。

  • 「TodoListView」「TodoView」「TodoInputForm」を用意します。
  • それぞれ、以下に対応します。( MVVM版 と同じです。)

f:id:unageanu:20130923144917p:plain

src/js/components/todo-list-view.js:

  • Todoリストアプリの root のビュー。
  • 新しいTodoの入力フォーム、各Todoの描画は、「TodoView」「TodoInputForm」に移譲しています。
  • stateでTodoの一覧を持ちます。
    • Object.observe() で TodoList の変更を監視し、追加や削除があった場合、自身のstateを更新します。
import React         from 'react';
import ContainerJS   from 'container-js';
import TodoView      from './todo-view';
import TodoInputForm from './todo-input-form';

export default React.createClass({
    
    propTypes: {
        todoList: React.PropTypes.object.isRequired,
        actions:  React.PropTypes.object.isRequired
    },
    
    getInitialState(){
        return this.retrieveState();
    },
    
    componentDidMount() {
        Object.observe(this.props.todoList, this.onChange, ['update']);
        Array.observe(this.props.todoList.items, this.onChange);
    },
    componentWillUnmount() {
        Object.unobserve(this.props.todoList, this.onChange);
        Array.unobserve(this.props.todoList.items, this.onChange);
    },
    
    render() {
        let enableToRemoveCompleted = false;
        const todos = [];
        
        this.state.todos.forEach((item) => {
           if ( item.completed ) enableToRemoveCompleted = true;
           todos.push(<TodoView todo={item} actions={this.props.actions} />)
        });

        return (
            <div>
              <TodoInputForm actions={this.props.actions} />
              <div className="todo-list">
                {todos}
              </div>
              <div className="buttons">
                <button 
                   onClick={this.removeCompleted} 
                   disabled={!enableToRemoveCompleted}>
                   remove completed
                </button>
              </div>
            </div>
        );
    },
    
    onChange() {
        this.props.todoList.items.forEach((todo)=> {
            Object.unobserve(todo, this.onTodoChange);
            Object.observe(todo, this.onTodoChange, ['update']);
        });
        
        Array.unobserve(this.props.todoList.items, this.onChange);
        Array.observe(this.props.todoList.items, this.onChange);
        
        this.setState(this.retrieveState());
    },
    onTodoChange() { 
        this.setState(this.retrieveState()); 
    },
    
    removeCompleted() {
         this.props.actions.removeCompleted();
    },
    retrieveState() {
        return { todos : this.props.todoList ? this.props.todoList.items : [] };
    }
});

src/js/components/todo-view.js:

  • 各Todoの描画を担当するビュー。
  • props の情報に基づいて、Todoを描画します。
import React from 'react';

export default React.createClass({
    
    propTypes: {
        todo:   React.PropTypes.object.isRequired,
        actions: React.PropTypes.object.isRequired
    },
    
    render() {
        const todo = this.props.todo;
        const icon = todo.completed
            ? <img src="../images/normal.png" alt="" />
            : <img src="../images/check.png" alt="checked" />;
        const command = todo.completed
            ? <a href="javascript:void(0)" onClick={this.activate}>
                activate
              </a>
            : <a href="javascript:void(0)" onClick={this.complete}>
                complete
              </a>;
              
        return (
          <div className="todo">
            {icon}
            <span className="title">{todo.title}</span>
            <span className="last-modified">{this.formatDate(todo.lastModified)}</span>
            <span className="commands">
              {command}
              <a href="javascript:void(0)" onClick={this.remove}>remove</a>
            </span>
          </div>
        );
    },
  
    complete() { this.props.actions.complete(this.props.todo.id) },
    activate() { this.props.actions.activate(this.props.todo.id) },
    remove()   { this.props.actions.remove(this.props.todo.id) }, 
  
    formatDate(d) {
        if (!d) return "";
        return d.getFullYear() 
                + "-" + (d.getMonth() + 1) 
                + "-" + d.getDate()
                + " " + d.getHours()
                + ":" + d.getMinutes()
                + ":" + d.getSeconds();
    }
});

src/js/components/todo-input-form.js:

  • 入力フォーム担当。
  • 独自のstateとして、エラー情報を持ちます。
import React       from 'react';
import ContainerJS from 'container-js';

export default React.createClass({
    
    propTypes: {
        actions:  React.PropTypes.object.isRequired
    },
    
    getInitialState(){
        return { error: "", title:"" };
    },
    
    render() {
        return (
            <div className="todo-input-form">
              title:
              &nbsp;
              <input 
                type="text" 
                className="title-field" 
                value={this.state.title}
                onChange={this.onChange}
              ></input>
              &nbsp;&nbsp;
              <button onClick={this.newTodo} className="add-button">
                add
              </button>
              <div className="error">{this.state.error}</div>
            </div >
        );
    },
    
    onChange(event) {
        this.setState({error:"", title: event.target.value});
    },
    
    newTodo(event) {
        try {
            this.props.actions.add( this.state.title );
            this.setState({error:"", title:""});
        } catch ( exception ) {
            this.setState({error:exception.message, title:""});
        }
    }
});

main.jsなどのGlueコードを書く

必要なソースが一式そろったところで、部品を組み合わせていく部分のコードを書きます。

  • インスタンスの生成と依存関係の解決 && 注入は DIコンテナでやるので、そのモジュール定義ファイル(modules.js)と、
  • 起動処理を行う、main.jsを用意します。

src/js/composing/modules.js:

  • 今回、Viewはコンテナ管理外なので、それ以外の部品を登録します。
"use strict";

const stores = ( binder ) => {
    binder.bind("todoList").to("stores.TodoList");
};
const actions = ( binder ) => {
    binder.bind("actions").to("actions.TodoListActions");
};
const dispatchers = ( binder ) => {
    binder.bind("dispatcher")
        .to("dispatchers.TodoListDispatcher")
        .onInitialize("initialize");
};

export default ( binder ) => {
    stores(binder);
    actions(binder);
    dispatchers(binder);
};

src/js/main.js:

  • コンテナを作って、ルートビューを描画します。
  • document.addEventListener ってIEで動かないんだっけ・・・・。まぁいいか。
import "babel-core/polyfill";

import React        from 'react';
import ContainerJS  from 'container-js';
import modules      from './composing/modules';
import TodoListView from './components/todo-list-view';

document.addEventListener( "DOMContentLoaded", ()=> {
    const container = new ContainerJS.Container( 
        modules, 
        ContainerJS.PackagingPolicy.COMMON_JS_MODULE_PER_CLASS,
        ContainerJS.Loaders.COMMON_JS
    );
    
    ContainerJS.utils.Deferred.when([
        container.get("todoList"),
        container.get("actions")
    ]).then(
        (components) => {
            try { 
                React.render( 
                    <TodoListView 
                        todoList={components[0]} 
                        actions={components[1]} />, 
                    document.body );
            } catch (e) { 
                console.log(e);
            }
        },
        (error) => console.log(error)
    );
});

HTML,CSSを用意する

最後に、CSSとHTMLを用意します。

  • CSSは前回と同じ
  • HTMLは、大部分がViewに移ったので、かなりシンプルに。

src/css/todo-list.css:

src/html/index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Todo List</title>
    <script type="text/javascript" src="../js/main.js">
    </script>
    <link rel="stylesheet" type="text/css" href="../css/todo-list.css">
  </head>
  <body id="todoapp">
  </body>
</html>

ビルドして実行してみる

ビルドすると、./build/app にリリース物が生成されます。

$ gulp build
[20:02:03] Using gulpfile todo-list-using-webpack/gulpfile.js
[20:02:03] Starting 'copy-resources'...
[20:02:03] Finished 'copy-resources' after 14 ms
[20:02:03] Starting 'build'...
[20:02:14] Version: webpack 1.7.3
  Asset    Size  Chunks             Chunk Names
main.js  766 kB       0  [emitted]  main
[20:02:14] Finished 'build' after 12 s

動作確認はこちらから。

感想

  • ES6良い

    • Arrow Function や Classes が入って、かなり書きやすくなりましたね!
    • あとは モジュールの仕組みが仕様に入ったのも大きいかと。
    • ただ、業務でがっつり使うなら、トランスパイルが足かせにならないかはちょっと心配。
      • それなりに時間がかかるので、書く→動かすのサイクルがどうしても1テンポ遅れる。最初雑に書いて、単体テスト書いて動かしながら洗練させてく派の人には致命傷になりかねない。
      • 差分ビルドや watch して自動ビルドする仕組みをしっかり作る必要がありそう。
      • あとは、なんかあった時のデバッグですね。Babel → Webpack で2重に処理しているので、なんかあった時は両方の出力をイメージしつつデバッグする覚悟が必要。なんかあった時だけですけど。
  • Reactは、この例では何とも

    • 例がシンプルすぎて、良さがいまいちわからなかった感じ。
    • もう少し大規模なアプリになれば、パフォーマンスやシンプルさで優位性を感じられるのかも。
  • Fluxは、アプリケーションアーキテクチャとしてはしっくりこないかな

    • Webアプリを整理する切り口としては、ドメインモデル(M)と抽象化したView(VM)で構成するMVVMの方がしっくりきます。(あくまで、個人的にはですが)。
    • ただ、「データフローを単方向にする」考え方は参考にできるかと。
      • MVVMでも気を付けて書かないと、データバインディングがスパゲッティになることはありますね。
      • フローが単方向になるように意識して書くのは重要。でも、そのために Dispatcher まではいらない感。もしくは、いてもいいけど、第一階層に置くのはちょっと、、、というところかな。

ContainerJSをnpmに登録したメモ

ContainerJS をnpmに登録したので、手順をメモしておきます。


npm - container.js


公開の手順は、こちらを参考にしました。

1. npmのアカウントを作成する

まずは、npmにユーザーを作成します。以下のコマンドを実行して、ユーザー名やメールアドレスを入力すればOK

$ npm adduser
  • 入力した内容は、 ~/.npmrc に保存されます。
  • 「npm config ls」でも確認できます。
$ npm config ls                                                                                                              
; cli configs
user-agent = "npm/2.5.1 node/v0.12.0 linux x64"

; userconfig /home/xxxx/.npmrc
//registry.npmjs.org/:always-auth = false
//registry.npmjs.org/:email = "masaya.yamauchi@gmail.com"
//registry.npmjs.org/:username = "unageanu"

2. package.json を用意する

次にモジュールの名前や説明を記入する package.json を用意します。
以下のコマンドで対話的に作成するのが簡単。もちろんテキストエディタで直接作っても大丈夫です(たぶん)。

$ npm init 

各項目の詳しい設定内容は公式ドキュメントを参考。

3. 公開しないファイルを指定する

プロジェクト内に配信不要なファイル(テストケースなど)がある場合、プロジェクトのルートに「.npmignore」を用意して記載します。

4. npmページのTopに表示される説明を用意する

npmのモジュールのページ(例:ContainerJSのページ)には、 README.md の内容が表示されます。
プロジェクトルートに README.md を作成して使い方などを記入しておきます。

  • ファイル名に注意
    • npmでは、「README.*」 なファイルを探して使用するようで、最初「README.ja.md」「README.md」を用意していたら、「README.ja.md」が使われてしまいました。
    • 「README_ja.md」にリネームすると解決しました。

5.パッケージを公開する

package.jsonができたら、package.jsonがあるディレクトリで「npm publish」すれば、公開できます。

$ npm publish

以下のURLにアクセスすれば、ページができているはず。

https://www.npmjs.com/package/<モジュール名>

6. パッケージをアップデートする

「npm version <更新タイプ>」でpackage.jsonのバージョンを上げてから、再度publishすればOK

$ npm version patch
$ npm publish
  • 更新タイプのところには、新しいバージョンか、アップしたいバージョンの種類(patch, minor, major ..etc..)を指定できます。
  • 例えば、patch を指定すると、 1.0.0 → 1.0.1 に上がります。
  • Git管理のプロジェクトで実行すると、Gitのタグ(v1.0.1)も同時に付与されます。

つぎは

npmでインストールして、Webpackと組み合わせて使うチュートリアルを書きます(たぶん)。

npmの基本的な使い方まとめ

npmの基本的な使い方のまとめです。忘れたときに見返す用。

  • npmのインストールから、モジュールを取ってきて使うところまで。
  • 環境は、CentOS7です。

インストール

node.jsをインストールすると、一緒に入ります。
ここを参考にソースからインストールしました。

$ sudo yum groupinstall 'Development tools'
$ wget http://nodejs.org/dist/v0.12.0/node-v0.12.0.tar.gz
$ tar xvf node-v0.12.0.tar.gz
$ cd node-v0.12.0/
$ ./configure --prefix=/usr/local
$ make
$ sudo make install

バージョンを確認。

$ node -v
v0.12.0
$ npm -v
2.5.1

モジュールのインストール

$ npm install <モジュール名>

例) $ npm install gulp
  • モジュール名を指定しない場合、package.json に書かれた依存モジュールをインストールします。
  • <モジュール名>@<バージョン> で任意のバージョンのモジュールをインストールできます。
  • 参考:npm - CLI Commands - install
ローカルインストールとグローバルインストール
  • ローカルインストール
    • 開発中のアプリが依存するライブラリをインストールする場合に利用します。
    • インストールしたモジュールは、プロジェクト内のみで使えます。
      • 同じマシンで開発している他のプロジェクトからは使えないので、それぞれで別のバージョンを使用したりできます。
      • モジュールは、/node_modules にインストールされます。
  • グローバルインストール
    • grunt のような、マシン全体で共有できるツールやライブラリをインストールする場合はこちら。
    • インストールしたモジュールは、マシン全体で共有されます。

試しに、gulp をインストールしてみます。

  • -gをつけるとグローパルインストールになります。
  • バージョンを明示して、それぞれ別バージョンをインストールしてみます。
$ mkdir -p test/app
$ cd test/app
$ sudo npm install -g gulp@3.8.10
$ gulp -v
[09:55:35] CLI version 3.8.10
$ npm install gulp@3.8.11
$ gulp -v
[10:02:46] CLI version 3.8.10
[10:02:46] Local version 3.8.11

インストールしたモジュールを使う

require して使います。
以下は、 uuid をインストールしてuuidを出力するサンプルです。

インストール

$ npm install uuid

uuidを使うプログラム test.js を書きます。

"use strict";

var uuid = require('uuid');
console.log(uuid.v1());

実行してみます。

$ node test.js
28bc71c0-c533-11e4-ad39-918ab091b1ed

モジュールのアンインストール

uninstallを実行すればOK

$ npm uninstall uuid

インストール済みモジュールを一覧表示する

ls でローカルにインストールしたモジュールの一覧を表示します。

$ npm ls
test/app
├─┬ gulp@3.8.11
│ ├── archy@1.0.0
│ ├─┬ chalk@0.5.1
│ │ ├── ansi-styles@1.1.0
│ │ ├── escape-string-regexp@1.0.3
│ │ ├─┬ has-ansi@0.1.0
│ │ │ └── ansi-regex@0.2.1
│ │ ├─┬ strip-ansi@0.3.0
│ │ │ └── ansi-regex@0.2.1
│ │ └── supports-color@0.2.0
│ ├── deprecated@0.0.1
│ ├─┬ gulp-util@3.0.4
│ │ ├── array-differ@1.0.0
.. 略