FlowtypeでImmutable.Recordの型定義がつらい

先日 Gotanda.js という勉強会で Immutable.js と Flowtype についてLTさせてもらったのだけど、その補足というか詳細的な話でもあります。

speakerdeck.com

Immutable.Record + Flowtype

Immutable.jsのRecordのよく紹介される使い方として、継承してイミュータブルなモデルクラスを作るというものがある。

import { Record } from 'immutable';

const FooRecord = Record({
  a: '1',
  b: 2,
  c: true
});

class Foo extends FooRecord {
  getSquaredB() {
    return this.b * this.b;
  }
}

const foo = new Foo({
  a: '2',
  b: 3,
  c : false
});

foo.a // "2"
foo.set('a', '3').a // "3"
foo.getSquaredB() // 9
foo.remove('a').a // "1"

ここにFlowtypeの型アノテーションを追加したい。Immutable@4.0.0-rc.2, flow-bin@0.48.0, 型定義ファイルはImmutable.jsにバンドルされているものを使ってやっていく。

// @flow

import { Record } from 'immutable';

const FooRecord = Record({
  a: '1',
  b: 2,
  c: true
});

class Foo extends FooRecord {
  a: string;
  b: number;
  c: boolean;

  getSquaredB() {
    return this.b * this.b;
  }
}

const foo = new Foo({
  a: '2',
  b: 3,
  c: false
});

// エラー出てほしい
const a: number = foo.a;
const b: string = foo.getSquaredB();
const b2: string = foo.set('b', 4).getSquaredB();

しかし、これはうまくいかない。

$ flow 
src/im-flow.js:11
 11: class Foo extends FooRecord {
                       ^^^^^^^^^ function call. Expected polymorphic type instead of
 11: class Foo extends FooRecord {
                       ^^^^^^^^^ RecordClass


Found 1 error
error Command failed with exit code 2.

immtuable.js.flowRecordの定義部分を見てみる。

declare class Record {
  static <Values>(spec: Values, name?: string): RecordClass<Values>;
  constructor<Values>(spec: Values, name?: string): RecordClass<Values>;

  static isRecord: typeof isRecord;

  static getDescriptiveName(record: RecordInstance<*>): string;
}

declare interface RecordClass<T> {
  (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T;
  new (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T;
}

declare class RecordInstance<T: Object> {
...
}
  • Record のコンストラクタは RecordClass<T> のinterface型を、RecordClass<T> のコンストラクタは RecordInstance<T> を返す
  • そのためFooRecord やそれを継承した FooRecordClass<T> のinterface型として解釈される
  • FooRecordInstance<T> を継承したものとして解釈されてほしい
  • が、 RecordInstance<T> はexportされておらず外部から利用できない

ということっぽい。あまりやりたくはないが、RecordInstance<T> の定義だけ別ファイルとして .flowconfig のlibsに追加した上で、 FooRecord 継承前に Class<RecordInstance<FooRecordValues>> にキャストしてみる。(直接はキャストできないので一旦anyにキャストしている)

type FooRecordValues = {
  a: string;
  b: number;
  c: boolean;
}

class Foo extends ((FooRecord: any): Class<RecordInstance<FooRecordValues>>) {
...

結果

$ flow 
src/im-flow.js:27
                         v
 27: const foo = new Foo({
 28:   a: '2',
 29:   b: 3,
 30:   c: false
 31: });
     ^ unused function argument
       v----------------------------------------
    3: declare class RecordInstance<T: Object> {
    4:   size: number;
    5: 
  ...:
   50: }
       ^ default constructor expects no arguments. See lib: src/record.js.flow:3

src/im-flow.js:34
 34: const a: number = foo.a;
                       ^^^^^ string. This type is incompatible with
 34: const a: number = foo.a;
              ^^^^^^ number

src/im-flow.js:35
 35: const b: string = foo.getSquaredB();
                       ^^^^^^^^^^^^^^^^^ number. This type is incompatible with
 35: const b: string = foo.getSquaredB();
              ^^^^^^ string

src/im-flow.js:36
 36: const b2: string = foo.set('b', 4).getSquaredB();
                                        ^^^^^^^^^^^ property `getSquaredB`. Property not found in
 36: const b2: string = foo.set('b', 4).getSquaredB();
                        ^^^^^^^^^^^^^^^ RecordInstance


Found 4 errors
error Command failed with exit code 2.

いくつかは期待通りのエラーが得られたが、まだおかしい。

  • RecordInstance<T>は constructor の引数が定義されていない
  • setupdate など新しいインスタンスを返すメソッドにおいて、返り値の型は継承前の RecordInstance<FooRecordValues> となってしまう

ということで RecordInstance<FooRecordValues> を継承したクラス定義を別途用意して、それにキャストするようにした。

// @flow

import { Record } from 'immutable';

type FooRecordValues = {
  a: string;
  b: number;
  c: boolean;
}

declare class FooRecordInstance extends RecordInstance<FooRecordValues> {
  a: string;
  b: number;
  c: boolean;

  constructor($Shape<FooRecordValues>): this | void;
  getSquaredB(): number;
}

const FooRecord = Record({
  a: '1',
  b: 2,
  c: true
});

class Foo extends ((FooRecord: any): Class<FooRecordInstance>) {
  getSquaredB() {
    return this.b * this.b;
  }
}

const foo = new Foo({
  a: '2',
  b: 3,
  c: false
});

// エラー出てほしい
const a: number = foo.a;
const b: string = foo.getSquaredB();
const b2: string = foo.set('b', 4).getSquaredB();

かなりつらい書き方になってしまったが、一応は想定通りのエラーを得ることができた。

$ flow 
src/im-flow.js:39
 39: const a: number = foo.a;
                       ^^^^^ string. This type is incompatible with
 39: const a: number = foo.a;
              ^^^^^^ number

src/im-flow.js:40
 40: const b: string = foo.getSquaredB();
                       ^^^^^^^^^^^^^^^^^ number. This type is incompatible with
 40: const b: string = foo.getSquaredB();
              ^^^^^^ string

src/im-flow.js:41
 41: const b2: string = foo.set('b', 4).getSquaredB();
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ number. This type is incompatible with
 41: const b2: string = foo.set('b', 4).getSquaredB();
               ^^^^^^ string


Found 3 errors
error Command failed with exit code 2.

これでなんとか型定義できたように見えたが、大きな落とし穴があった。

// エラー出てほしい
const c: string = foo.get('c');
foo.set('c', 1);
$ flow 
No errors!
✨  Done in 0.21s.

こうなる理由は型定義がこのようになっているため。

get<K: $Keys<T>>(key: K): /*T[K]*/any;
set<K: $Keys<T>>(key: K, value: /*T[K]*/any): this;

getの返り値やsetのvalueが any と定義されているため。コメントアウト部分はお気持ちの表明で現状このような書き方はできない。 $PropertyTypeというUtilityはあるがstring literalしか引数に取れないとのこと。

flowtypeのUtility Typeについて その1

getterはプロパティアクセスを使うとしてもsetterはどうしようもなく、クソめんどくさい型定義を書いても型安全でないという厳しい結果になってしまった。

$ElementType について

PropertyTypeに関するIssueによると、 $ElementType なるUtilityを使うことで string literal じゃなくても value の型が取れる模様。Release note やドキュメントに記載はないが diff を見ると v0.49.0 から入っているようなので、 Flowtype のバージョンを上げて型定義を書き換えてみた。

get<K: $Keys<T>>(key: K): $ElementType<T, K>;
set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this;

これで改めてこの例を試す。

// エラー出てほしい
const c: string = foo.get('c');
foo.set('c', 1);

今度は想定通りのエラーを出すことができた。

$ flow 
src/im-flow.js:44
 44: const c: string = foo.get('c');
                       ^^^^^^^^^^^^ boolean. This type is incompatible with
 44: const c: string = foo.get('c');
              ^^^^^^ string

src/im-flow.js:45
 45: foo.set('c', 1)
                  ^ number. This type is incompatible with the expected param type of
 12:   set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this;
                                       ^^^^^^^^^^^^^^^^^^ boolean. See lib: src/record.js.flow:12


Found 2 errors
error Command failed with exit code 2.

ということで、getter/setter の問題に関しては $ElementType を使うことで解決できそうではある。

フロントエンド開発の初期に考えておきたいこと

Frontrend Advent Calendar 2014 - Qiita 18日目の記事です。

フレームワークや使用技術など

は方々で語られているので、それ以外の話をします!

個人的には最近Ampersand.jsが気に入ってます。

リポジトリ構成

フロントエンドのリソースはサーバー側のフレームワークassetsstaticといったディレクトリで管理される事が多いですが、フロントエンドが肥大化した今、リソースが混在することで

  • コミットやPRがどちらのものなのかわかりにくい
  • 問題が起きた時に切り分けがしにくい

といった難点があります。もしもサーバー側はAPIのみを提供してフロント側はシングルページのように疎結合な構成になっている場合、ネイティブアプリがそうであるようにリポジトリを分けて完全に別管理にしてしまうというのも一つの手です。その場合ローカル開発はGrunt.jsgulp.jsで別ポートにサーバー立ち上げるなどすると良いでしょう。

JSの依存解決手段

各モジュールを一つのグローバル変数にぶら下げてconcat等でまとめる方法は、規模が大きくなってくると連結の設定やモジュール間の依存解決が煩雑になりがちです。

// app.js
var app = {};

// foo.js
app.foo = 'foo';

// bar.js
app.bar = app.foo + 'bar';

ある程度ファイル数が多くなる事が予見できているなら、Browserify, webpack, components等を使用してCommonJSのモジュールシステムを使う事をおすすめします。

// foo.js
module.exports = 'foo';

// bar.js
module.exports = require('foo') + 'bar';

CSSの設計方針

同じような記述の乱立やスタイルの衝突といった事態を防ぐためには、OOCSS, SMACSS, BEMなどのパラダイムを取り入れ、初期設計をしっかり行っておく事が重要になります。 気をつけたいのはこれらはあくまで設計のルールであってフレームワークではないので、チーム開発で運用していくにはスタイルガイドなどを用意しておくと良いでしょう。

CSS設計に関してはこの本が非常におすすめです。
今見たらKindle版がセールで60%OFFの¥950になってました。

デプロイフロー

SPAなどAjaxを多用するアプリケーションのデプロイを稼働中に行う場合は注意が必要です。クライアントのリソースが更新されないため、API側の仕様変更があると不整合が、起きたり静的ファイルのバージョンにズレが生じてあるファイルだけ古い、といった事態を引き起こす可能性があります。これを防ぐためには、例えば

  1. デプロイはバージョンごとに/v1,/v2といったディレクトリを切る
  2. APIのレスポンスにクライアントのversionを追加
  3. クライアント側では通信毎にversionをチェック
  4. クライアント側で保持しているversionとdiffがあれば適当なタイミング(ルーティングの切り替え時だと自然)でリロードをかける

といった具合に、アプリケーションに合わせたリソース更新の仕組みを用意しておく必要があります。

まとめ

にうまく乗り切るためには、構成要素のモジュラビリティを高めておいた方が良さそう、という話でした。

最新のrspec-railsでコントローラのヘルパーメソッドをスタブする

パーフェクトRuby on Rails読んでてハマったところ。
RSpecでビューのテスト書くとき、こんな風にコントローラのヘルパーメソッドをスタブしているのだけど、

context '未ログインユーザがアクセスしたとき' do
  before do
    allow(view).to receive(:logged_in?) { false }
    allow(view).to receive(:current_user) { nil }
  end
  # 中略
end

最新バージョンのrspec-railsだと

Failure/Error: allow(view).to receive(:logged_in?) { false }
#<#<Class:0x007fa8613dc140> ....> does not implement: logged_in?

怒られるもよう。(rspec-rails v3.1.0で確認)

issueはいくつか上がってて、原因としては存在しないメソッドはスタブできないようになった為のようだ。

context '未ログインユーザがアクセスしたとき' do
  before do
    def view.logged_in?
    end

    def view.current_user
    end

    allow(view).to receive(:logged_in?) { false }
    allow(view).to receive(:current_user) { nil }
  end
  # 中略
end

もにょりつつ一旦こんな感じで凌いだ。

Promiseで逐次実行する時のテスト

var Promise = require('es6-promise').Promise;

var tasks = {
    first: function(value) {
        return new Promise(function(resolve, reject) {
            if (value < 2) {
                resolve(value + 1);
            } else {
                reject(new Error('failed at first'));
            }
        });
    },
    second: function(value) {
        return new Promise(function(resolve, reject) {
            if (value % 2) {
                resolve(value + 1);
            } else {
                reject(new Error('failed at second'));
            }
        });
    },
    third: function(value) {
        return new Promise(function(resolve, reject) {
            resolve(value + 1);
        });
    }
};

var waterfall = function(value) {
    return Promise.resolve(value)
        .then(tasks.first)
        .then(tasks.second)
        .then(tasks.third);
};

例えばこの関数waterfallが

  • 最後まで実行される
  • firstでrejectされる
  • secondでrejectされる

場合のテストケースをそれぞれ用意したい。
いろいろ試した結果、こんな感じになった。

var assert = require("power-assert");
var sinon = require('sinon');

describe('waterfall', function() {
    beforeEach(function() {
        sinon.spy(tasks, 'second');
        sinon.spy(tasks, 'third');
    });

    afterEach(function() {
        tasks.second.restore();
        tasks.third.restore();
    });

    it('passed', function(done) {
        return waterfall(0).then(function(value) {
            assert(value === 3);
        }).then(done, done);
    });

    it('failed at first', function(done) {
        return waterfall(2).then(function(value) {
            throw new Error(value);
        }, function(error) {
            assert(error.message === 'failed at first');
            assert(tasks.second.notCalled);
        }).then(done, done);
    });

    it('failed at second', function(done) {
        return waterfall(1).then(function(value) {
            throw new Error(value);
        }, function(error) {
            assert(error.message === 'failed at second');
            assert(tasks.third.notCalled);
        }).then(done, done);
    });
});

最初はspy.calledBeforeとかで実行順序全部チェックしてたけど、それはPromise自体のテストになってしまうので

  • 適切なエラーが投げられてる
  • reject後の関数が呼ばれてない

ことがテストできてれば良いかなと。 前提条件として投げるエラーはそれぞれ個別のmessageじゃないとrejectされた箇所を探すのは難しそう。
chaiならhttps://github.com/domenic/chai-as-promisedとか使うとスマートに書けるかも。

Ampersand.js所感

ちょっと前に紹介されてたAmpersand.jsを使ってみたので、ざっくりとした紹介。
Ampersand.jsはView, Model, Routerなど複数のモジュールに分割されていて、個別に読み込んで使うことができる。

$ npm i --save ampersand-model

var AmpModel = require('ampersand-model');

多くのモジュールはBackbone.jsのコードをベースにしているので、名前が同じものは概ね同じ感覚で使うことができる。
以下、全部ではないけど触ったモジュールのBackboneとの違いをざっくり紹介。

ampersand-state

後述するampersand-modelのベースになるモジュール。違いとしてはAjax周りの機能がない。

get/setメソッドを介さずにプロパティへのアクセスができる
var AmpersandState = require('ampersand-state');
var state = new AmpState();

state.foo = 1;
state.foo // => 1
プロパティの型やEnum, nullの許可など詳細な設定
var AppState = AmpersandState.extend({
    extraProperties: 'ignore',
    props: {
        foo: {
            type: 'number',
            default: 0,
            values: [0, 1, 2]
        },
        bar: {
            type: 'string',
            default: 'ampersand',
            required: true,
            allowNull: false
        }
    }
});
var state = new AppState();

state.foo = '1'; // => TypeError: Property 'foo' must be of type number. Tried to set 1 
state.foo = 3; // => TypeError: Property 'foo' must be one of values: 0, 1, 2. Tried to set 3
state.bar = null // => TypeError: Property 'bar' must be of type string (cannot be null). Tried to set null
sessions, derived

sessionsは一時的な値を保持するための、derivedは関数を設定できるプロパティ。どちらもtoJSONで列挙されない。

var AppState = require('ampersand-state').extend({
    sessions: {
        active: {
            type: 'boolean',
            default: true
        }
    },
    derived: {
        foobar: function() {
            return this.foo + this.bar;
        }
    }
});
var state = new AppState();

state.set({foo: 1, bar: 2});
state.foobar; // => 3
state.active = true;
state.toJSON(); // => {foo: 1, bar: 2}

ampersand-model

ampersand-stateにモジュールampersand-syncを追加し、fetchsaveを使えるようにしたもの。ajaxConfigでheaderやbeforeSendの詳細な設定ができたりする。

ampersand-collection

Backbone.Collectionに相当するが、ampersand-syncおよびUnderscore.jsを使用したメソッドは含まれない。(forEachやmapなど、ES5のメソッドは使える)

ampersand-rest-collection

ampersand-collectionsyncとUnderscore.js関連のメソッドの他、指定したidのモデルがあればそれを返し、なければfetchするfetchOrGetなどが追加されている。

ampersand-view

Backbone.Viewの機能に加えて、2-way-bindings,collectionView,subViewなど、backbone.stickitMarionette.jsなどでもおなじみの機能を含む。

ampersand-router

大部分はBackbone.Routerのコードが元になっているが、historyを残さずに遷移するredirectToなどが追加されている。

CLIツール

コマンドラインから使えるジェネレータもある。

$ ampersand gen model foo
new Model created as client/models/foo.js
new Collection for foo created as client/models/foo-collection.js

ドキュメントは普通に充実しているので、詳細はそちらを見てもらった方が早いです。
http://ampersandjs.com/docs
モジュールは上記以外にも色々ある。
tools.ampersandjs.com

まとめ

感想としてはモジュール化 + 人気のプラグインを取り込んで痒いところに手が届くようになったBackbone.js。モジュールが分散していることもあってgithubのstarはまだ少ないのだけれど、ちゃんとOrganizationがあって活発にメンテされているのも良い。大きなフレームワークを使っていてもプロダクトの要件にバッチリハマるみたいなことはそうそう無くて、結局は複数を組み合わせるとか、ラッパーを書くとかする必要が出てくるので、そうした面でも最初からモジュール化されているのは理にかなっていると思う。

自分は今のところReact.js + Model,CollectionをFluxアーキテクチャで言うところのStoreとして使っていて、他にもVue.jsと組み合わせるとか、純粋にBackbone.jsの上位互換のMVCフレームワークとしてとか、色々使い道はありそうなので試していきたい。

SPA(Single Page Application)制作時のチェックリスト

  • メモリリークは起きていないか
  • 初期化時に無駄な通信はないか
  • ページ移動時に保持するデータと破棄するデータの分別ができているか
  • 読まれたくないロジックを置いてないか
  • 直接叩かれて困るAPIやルーティングはないか
  • バリデーションの項目はサーバー側と揃っているか
  • 最低でもモデルのテストは書いているか
  • Ajaxリクエストが失敗した時のリカバリー処理はあるか
  • リリース時にユーザー側のリソースを更新させる仕組みはあるか
  • SPAにする必要性はあるか

semverとnpm

semverとは?

semver(Semantic Versioning)は依存関係のもつれを解決するべく制定されたバージョニングの標準仕様です。
Semantic Versioning 2.0.0
npmに登録されているモジュールのバージョンもsemverに沿った形式で設定されています。
The semantic versioner for npm

概要

バージョンをmajor.minor.patch(-prerelease, +build)の形式で記述し、

それぞれインクリメントします。

node-semver

バージョンがsemverに沿ったものであるかは、npm内部でも使用されているnode-semverで確認が可能です。

$ npm install -g semver
$ semver 1.01.0
# none
$ semver 1.1.0
1.1.0
$ semver -i patch 1.2.3
1.2.4

依存パッケージのバージョン指定

"dependencies": {
  "underscore": "^1.4.4"
}

npm install --save underscoreと打ったあとのpackage.jsonですが、この場合は1.4.4-0以上、2.0.0-0の間で最新のバージョンがインストールされます。minor, patchレベルのアップデートの範囲内なので、依存パッケージがsemverに従っていればエラーは起こらないことになります。

そんな事はなかった

残念ながら、意図的にしろそうでないにしろminorpatchレベルのアップデートで後方互換性が破壊されるケースをしばし見かけます。少し前ですが、Backbone.jsのようなメジャーなライブラリでも後方互換性のないminorアップデートが行われ、semverに準拠していないとして批判の声が上がっていました。
Follow SemVer #2888

そもそもの話として、majorバージョンが大きくなりすぎる事への懸念などから、厳格にsemverに従うかについては他のモジュールやnpm本体の中でも色々と議論があるようです。

まとめ

ビルドがこけたと思ったら後方互換のないpatchレベルのアップデートが原因だった時の憤りをバネに書きました!

個人的にはmajorバージョン大きくなってもいいからちゃんとsemverに沿っていて欲しいと思いますが、上記の議論があったり、完璧にやるのも難しい話なので、まだしばらくこうした問題は付いて回りそうですね。