リズムのじかん

javascript、typescriptなど中心に書きます。

ElectronアプリをカスタムURIで起動する

はじめに

webブラウザからリンクをクリックして、KindleiTunesを起動したことありますよね。

このときリンク先アドレスは以下のようになっています。

  • kindle://home/?action=refresh
  • itmss://itunes.apple.com/jp/album/liang-cheng-bai/id1065727732

カスタムURIですね。

自分で作ったElectronアプリもカスタムURIで起動したいと思います。 完全なソースはGitHubにあります。

github.com

こんな感じで動きます。

f:id:chords:20160221020141g:plain

カスタムURIの登録

MacWindowsに分けて書きます。

MacでのカスタムURIの登録

Macの場合は、アプリのinfo.plistにCFBundleURLNameを登録することで、カスタムURIでアプリを起動できるようになります。 https://developer.apple.com/library/mac/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html

具体的にElectronアプリにどう組み込むかは簡単で、electron-packagerのオプション(protocol-name, protocol)にカスタムURIを指定するだけです。

// package.json
{
  ...
  "scripts": {
    ...
    "pack": "npm run pack:osx && npm run pack:win32 && npm run pack:win64",
    "pack:osx": "electron-packager ./dist \"MyApp\" --out=dist/osx --platform=darwin --arch=x64 --version=0.36.8 --protocol-name=\"myapp-protocol\" --protocol=\"myapp\"",
    "pack:win32": "electron-packager ./dist \"MyApp\" --out=dist/win32 --platform=win32 --arch=ia32 --version=0.36.8,
    "pack:win64": "electron-packager ./dist \"MyApp\" --out=dist/win64 --platform=win32 --arch=x64 --version=0.36.8,
    ...
  },
  ...

パッケージ後の出力は以下のようになります。

// MyApp.app/Contents/info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    ...
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLName</key>
        <string>myapp-protocol</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>myapp</string>
        </array>
      </dict>
    </array>
  </dict>
</plist>

WindowsでのカスタムURIの登録

Windowsの場合はレジストリにカスタムURIを登録する必要があります。 https://msdn.microsoft.com/ja-jp/library/aa767914(v=vs.85).aspx

レジストリ登録(削除)のタイミングは以下がいいですね。

インストールスクリプト作成

まずNSISでインストールスクリプトを作成します。 テンプレートはhttps://github.com/loopline-systems/electron-builder/blob/master/templates/win/installer.nsi.tpl にあるので、カスタマイズしましょう。

// installer.nsi.tpl
!define APP_NAME "<%= name %>"
...
# default section start
Section
  SetShellVarContext all
  ...
  DetailPrint "Register MyApp URI Handler"
  DeleteRegKey HKCR "myapp"
  WriteRegStr HKCR "myapp" "" "URL:myapp"
  WriteRegStr HKCR "myapp" "URL Protocol" ""
  WriteRegStr HKCR "myapp\DefaultIcon" "" "$INSTDIR\${APP_NAME}.exe"
  WriteRegStr HKCR "myapp\shell" "" ""
  WriteRegStr HKCR "myapp\shell\Open" "" ""
  WriteRegStr HKCR "myapp\shell\Open\command" "" "$INSTDIR\${APP_NAME}.exe %1"
  ...
SectionEnd

# create a section to define what the uninstaller does
Section "Uninstall"
  ...
  DetailPrint "delete MyApp URI Handler"
  DeleteRegKey HKCR "myapp"
SectionEnd

"myapp\shell\Open\command"の行を見てもらうとわかると思いますが、 カスタムURIで起動した場合、アプリには起動引数としてURIを渡します。

electron-builderの設定

次にelectron-builderのconfigファイルを用意します。nsiTemplateに先ほど作成したinstaller.nsi.tplを指定します。

// builder.json
{
  "osx" : { ... },
  "win" : {
    "title" : "MyApp",
    "icon" : "resources/win/icon.ico",
    "nsiTemplate" : "resources/win/installer.nsi.tpl"
}

あとはelectron-builder実行時にbuilder.jsonを指定するだけです。

// package.json
{
  ...
  "scripts": {
    ...
    "installer": "npm run installer:osx && npm run installer:win32 && npm run installer:win64",
    "installer:osx": "electron-builder \"dist/osx/MyApp-darwin-x64/MyApp.app\" --platform=osx --out=\"dist/osx\" --config=builder.json",
    "installer:win32": "electron-builder \"dist/win32/MyApp-win32-ia32\" --platform=win --out=\"dist/win32\" --config=builder.json",
    "installer:win64": "electron-builder \"dist/win64/MyApp-win32-x64\" --platform=win --out=\"dist/win64\" --config=builder.json",
    ...
  },
  ...

インストール後のレジストリは以下のようになります。

f:id:chords:20160221102147p:plain

アプリ側で起動URIをハンドル

起動URIのハンドルの仕方もMacWindowsで少し違います。

イベントリスナー登録

  • Macの場合は、Electron.appの"open-url"イベントで起動URLを取得できます。
  • Windows(とLinux)の場合は、起動引数で起動URLを取得できます。
// main.ts or main.js
import {app} from 'electron';
import {ready, handleOpenUri} from './mainWindow';

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('ready', ready);

app.on('will-finish-launching', () => {

  // For OSX
  app.on('open-url', (e, url) => {
    e.preventDefault();
    handleOpenUri(url);
  })

  // For Windows
  process.argv.forEach(arg => {
    if (/myapp:\/\//.test(arg)) {
      handleOpenUri(arg);
    }
  })
});

ポイントは https://github.com/atom/electron/blob/master/docs/api/app.md#user-content-event-will-finish-launching に記載されている通り、"will-finish-launching"イベントで"open-url"のイベントリスナーを登録することです。

ここまでは特に難しくないですね。

注意すること

ここで少し注意が必要です。

前提

Electron.appのイベントの発生順序は以下となります。(Macの場合)

  1. will-finish-launching
  2. open-url
  3. ready

BrowserWindow(≒画面、Rendererプロセス)のインスタンス作成はreadyイベントで行います。 ちなみにreadyイベントより前ではBrowserWindowのインスタンスを作成できません。

問題

open-urlイベントで起動URIを取得したときに、画面の表示を変えようとMainプロセスからRendererプロセスに通信すると、 BrowserWindowのインスタンスがまだできてないので、大抵ぬるぽで落ちます。

Windowsの場合も、will-finish-launchingイベントの中で同期的に起動URIを取得するので、同じことが起きます。

対策

サンプルでは、"BrowserWindowの準備完了"と"起動URIの取得"をそれぞれPromise化し、Promise.allで同期をとって(両方の処理が完了して)から通信するようにしています。

https://github.com/masahirompp/electron-open-url-sample/blob/master/appsrc/main/main.ts (コードが少し冗長な感じがする)

現実的には、 起動URIを取得したあとに(Mainプロセスで)サーバから必要なデータを取得している間に、 Rendererプロセスの準備ができていることがほとんどだと思います。 なので、どこまでやるかは状況に合わせて決めてください。

その他

AutoUpdaterでレジストリを書き換える方法は検証していません。

まとめ

Electronはwebと親和性の高いフレームワークです。 そのうちwebとシームレスに使えるアプリとか出てくる気がしています(・∀・)

Enjoy Electron!!

Mithril.jsでがっつりSPAを作った話 - コード譜共有サイト「ChordKitchen」

Mithril.jsを使って、がっつりSPA(シングルページアプリケーション)を作ったので紹介します。 Mithril使いやすいので広まって欲しいです。

作ったもの

コード譜をweb上で作成して共有できるサービスを作りました。

コード譜共有サイト ChordKitchen http://chordkitchen.net

サービス紹介動画です。

www.youtube.com

開発環境

プログラミング言語

  • typescript(versionは1.4)

typescriptいいよ!! コードはできるだけjavascriptっぽく書いて、 コンパイル時にケアレスミスを検出する感じに使いました。

スクランナー

  • gulp
  • webpack(後述)

クライアントサイド

使用しているライブラリ

  • Mithril.js
  • underscore.js

Mithril.jsについて

公式サイト

https://lhorie.github.io/mithril/

今度オライリーから本が出るみたいです!タイムリーですね。

O'Reilly Japan - Mithril

日本ではあまり有名ではありませんが(ググると日本語の技術ブログが数件ヒットする程度)、 海外で評判の良いライブラリです。

仮想DOM、router、ajax機能など、SPAを作るための一通りの機能が揃っていて、 すごく軽量 です。

ソースは5KBで(ver. 2.0から実装が増えたので、今は5KBには収まっていない)、 APIが少ないので学習が容易です。 ソースコードは1500行くらいなので、困ったときにソースを追っても苦になりません。

SPAの構造

このアプリケーションはSPAです。

ヘッダー・フッターの枠は静的HTMLですが、メイン部分はMithrilで動的に生成しています。ページ遷移してもページの再読み込みはありません。

f:id:chords:20150803012228j:plain

ヘッダーのメニュー部分は、メイン部分とは独立した仮想DOMです。 メニューの「コード譜を探す」をクリックしたときなどは、 MithrilのRouterでメイン部分と連携するので、ページの再読み込みはありません。

(左上のロゴをクリックしたときだけ、 メモリリフレッシュと、 万が一のエラーからの復帰のため、 ページを再読み込みするようにしています。)

Viewの実装

Viewの実装はReact.jsのjsxをフォークしたmsxを使っています。 msxはjsxのシンタックスが使えて、コンパイルするとMithrilのviewオブジェクト(仮想DOMオブジェクト)を出力します。

以下はメニュー部分のViewの実装(抜粋)です。jsx(msx)で書くとすっきりしていいですね。

menu.jsx

ちなみにjQueryは使っていません。

仮想DOMを扱うMithril.jsと生DOMを扱うjQueryは相性が悪いためです。 生DOMの操作も一部ありますが、jQueryを使うには冗長なので、生のjavascriptで実装しています。

jsxでできないこと

jsxでは名前空間のimportが必要なタグ・属性は使えないようです。 譜面部分はHTML5のinline svgで出力していますが、 上記仕様により

<use xlink:href="#bar" />

は、jsxではコンパイルできません。 (このためReact.jsは使いませんでした)

mithrilはview部分について、

m("use", {"xlink:href":"#bar"})

とか

{tag:"use", attrs:{"xlink:href":"#bar"}}

という書き方ができ、どんなタグ・属性でも出力できます。 svg部分は後者の書き方で実装しました。

ちなみに前者がMithrilの標準的な書き方で、後者はMithril内部のプリプロセス後の形式です。 プリプロセスが不要な分、後者のほうが実行が早いです。 msxコンパイル後の形式も後者になります。

webpackさまさま

typescriptやmsxコンパイルはwebpackを使っています。

webpackかしこ過ぎます!!まだ使っていない人は絶対使ったほうがいいです!!

typescript-loaderは、webpackのwatchと合わせて使うと、 ちゃんと差分コンパイルしてくれるので、無駄な時間がかかりません。

扱うファイルが、.ts(typescript)、.jsx(msx)、js(外部ライブラリ)と混ざっていても、 当たり前のように1ファイルに結合してくれます。

inline-source-mapも便利です。 コンパイル後のjsファイル内にsource-mapの情報が出力されるので、 .js.mapみないなファイルを作らなくてもsource-map機能が使えます。 chromeなら、ブラウザ上でtypescriptやjsxソースのデバッグができます。

f:id:chords:20150803012637p:plain

Mithril.jsによるSPAの実装

MithrilでSPAを実装すると、コードが全体的に関数型っぽくなります。

関数型プログラミングに詳しくないので、深く突っ込まないでください。。。

Mithrilの代表的なAPIm.propがあります。

var title = m.prop('test'); // m.propはgetter-setterオブジェクトのfactory

title(); // 引数なしで実行するとgetter
// 出力:'test'

title('hoge'); // 引数ありで実行するとsetter

title();
// 出力:'hoge'

このAPIで生成したオブジェクトは、画面からの入力を受け取り、かつ、画面への出力を担います。

var title = 'hoge'でいいじゃんと思いがちですが、これをやると失敗します。(何度も失敗しました。。)

変数に状態を持たせると、(使い方を誤ると、)

SPA初期化時に関数に引数が束縛され、画面への入力で値を変更したつもりでも、 束縛された値が画面に出力され、画面に値が反映されない

という自体に陥ります。←説明にあまり自信がない。。詳しい方お願いします。

これを防ぐには、入力が出力まで伝わるよう、 値を変換する関数をいくつもつなげるように(組み合わせるように)実装する必要があります。 関数型プログラミングっぽい雰囲気が出てきましたね。

また単純に関数をつなげるだけだと計算量増えてブラウザが嫌がるので (特に今回は譜面の描画部分)、

  • (諸々考慮した上で)計算結果を変数にキャッシュする
  • カリー化
  • メモ化

などのテクニックを使うといいみたいです。あまり詳しk(ry

あとコードをすっきりさせるには関数合成がよいみたいです。あm(ry

このあたりのことは、「JavaScriptで学ぶ関数型プログラミング」に参考になることがたくさん書かれていました。

JavaScriptで学ぶ関数型プログラミング

JavaScriptで学ぶ関数型プログラミング

SPAと履歴管理(History API)とSEO対策

SPAなので、明示的にpushstateするなどしないと、ブラウザに履歴が残りません。 試行錯誤した結果以下のように落ち着きました。

  • 履歴機能はMithrilのRouterに任せる
    • Routerが内部的にpushstateを呼び出す
      • routeが切り替わるたびにURLを履歴にpush
      • Mithril.jsの公式サイトにもあまり詳しく書かれていなかったのでソースを読んだ
  • アプリはどのURLでアクセスされても画面が正常に表示できるように実装
    • クライアントサイドをステートレスに実装するイメージ

SPAのSEO対策は結局ベストプラクティスがよくわかりませんでしたが、、、 (そもそもSEO対策自体初めてだし、、、)

上の履歴の実装をしたところ、 GoogleAnalyticsにページごとのアクセス結果が出ているので、 とりあえず大丈夫でしょう。

SPAのメリデリ

上にいっぱい書いたので細かいことは省略しますが、

メリット

  • 何より動きが快適!!さくさく!!
  • クライアントサイドに業務ロジックが集約する
    • 開発後半の機能追加・修正が局所的で開発スピード◎
    • サーバサイドの役割が簡潔

デメリット

  • ノウハウが十分に公開されていない
    • 開発初期の方式を決める部分で試行錯誤(時間かかる)
  • クライアント環境依存の問題
    • とりあえず古いブラウザは諦めた
  • トラブルシューティング
    • クライアント側でエラーが発生した場合どうやって追跡するか

サーバーサイド

構成

  • nginx
  • node
    • express
    • mongoose
  • mongodb

nginxは静的コンテンツの配信用、nodeはステートレスでRESTFULなAPIサーバです。

さくらVPSで動いています。

まとめ

週末プログラマとして作って、1年ちょっとかかりました。

終盤はMithrilにがっつり乗っかったことで、開発スピードが上がりました。 最初のプロトタイプはjQuery + d3.jsで作っていたのですが、SPA向きではなかった気がします。

これからSPAを作りたい人には、学習が容易なMithril.jsおすすめします。

p.s.

typescriptもいいよ(^^ゞ

ES6で関数をカリー化する関数を書く

pluck

underscore.jsにpluckという関数があります。 この関数は、オブジェクトの配列から指定した属性を取り出した配列を生成します。

pluckの例1

以下のようなキャリアとスマホのデータから、キャリア名を取り出します。

var data = [{
  carrier: 'docomo',
  sp: [{manufacturer: 'sony', product: 'Xperia'}, {manufacturer: 'samsung', product: 'GALAXY'}]
}, {
  carrier: 'au',
  sp: [{manufacturer: '京セラ', product: 'INFOBAR'}, {manufacturer: 'sharp', product: 'AQUOS'}]
}, {
  carrier: 'softbank',
  sp: [{manufacturer: 'sharp', product: 'AQUOS'}, {manufacturer: 'Apple', product: 'iPhone'}]
}];

_.pluck(data, 'carrier'); 
// -> ["docomo", "au", "softbank"]

pluckの例2

スマホの製品名を取り出して重複を省きます。

_.uniq(_.pluck(_.flatten(_.pluck(data, 'sp')), 'product'))
// -> ["Xperia", "GALAXY", "INFOBAR", "AQUOS", "iPhone"]

underscoreの関数に馴染みがない人は以下を参照してください。

http://underscorejs.org

※「括弧が多くて見づらいから関数合成使って」というご指摘はごもっともですが、今はお待ちください。

pluckをカリー化する

  • 例で使用した○○を取り出す関数を複数箇所で使うので共通化したい。
  • 例では日本のデータを使用しているが、海外のデータもある。データを引数に取るようにしたい。

などの理由で、pluckをカリー化して共通化します。

// 引数を2つ取る関数を右からカリー化する関数
var curry2 = function (fun) {
  return function (arg2) {
    return function (arg1) {
      return fun(arg1, arg2);
    };
  };
};

var pluck = curry2(_.pluck); // _.pluckをカリー化
var pluckCarrier = pluck("carrier"); // キャリアを取り出す関数
var pluckSp = pluck("sp") // スマホを取り出す関数

// 使用例:データからキャリアを取り出す
pluckCarrier(data)
// -> ["docomo", "au", "softbank"]

カリー化する関数をES6で書く

本題です。本題は1行だけで済むのですが、なんかさみしかったので、長々と書きました。すみません。

// ES6
var curry2 = fun => arg2 => arg1 => fun(arg1, arg2);

「これだ!!」感がありますよね(・∀・)

BabelでES5へ変換すると、上で書いたcurry2と全く同じものができます。

Babel · The transpiler for writing next generation JavaScript

ちなみにtypescriptとcoffeescriptでも同じように書けました。

おまけ

例2をカリー化と関数合成を使って書くと以下のようになります。

var curry2 = fun => arg2 => arg1 => fun(arg1, arg2);
var pluck = curry2(_.pluck);
var products = _.compose(_.uniq, pluck('product'), _.flatten, pluck('sp'));
products(data);
// -> ["Xperia", "GALAXY", "INFOBAR", "AQUOS", "iPhone"]

【javascript】フレームワークを使わずにMVC

javascriptで、フレームワークを使わずにMVCしているサンプルがなかったので、作ってみました。

あくまで参考です。(業務ではちゃんとフレームワークを使ったほうが良いと思います。)

作ったもの

よくあるTodoアプリです。以下で試せます。

http://jsfiddle.net/54sjqL6z/

機能は以下だけです。

  • Enterキー押下でTodo追加
  • 各Todoをクリックして完了にする

コード

index.html

フレームワークは使わないけれど、underscorejsは使います。

あとjQueryも使いません。(最近できるだけ使わないようにしている。これ本当にjQuery必要なの?ってことが多いため。)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Todo MVC Sample</title>
  <style>
  .completed {
    text-decoration:line-through;
  }
  </style>
</head>
<body>
  <div>
    <input type="text" id="todoInput">
  </div>
  <div>
    <ul id="todoList"></ul>
  </div>
  <script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore.js"></script>
  <script src="./util.js"></script>
  <script src="./model.js"></script>
  <script src="./view.js"></script>
  <script src="./controller.js"></script>
</body>
</html>

util.js

ユーティリティです。オブザーバパターン用のeventsオブジェクトを返します。

(function(_, exports) {
  'use strict';

  exports.events = function() {
    var events = {};
    return {
      on: function(name, func) {
        (events[name] || (events[name] = []))
          .push(func);
        return this;
      },
      trigger: function(name /*, ...args */ ) {
        var funcs = events[name] || [];
        var args = _.rest(arguments);
        funcs.forEach(function(func) {
          setTimeout(function() {
            func.apply(null, args);
          }, 0);
        });
        return this;
      }
    };
  };

})(this._, this.util = this.util || {});

model.js

モデルです。ここではTodoモデルとTodoListモデルを定義します。

(function(_, util, exports) {
  'use strict';

  // Model
  var Todo = _.extend(function(id, description) {
    this.id = id;
    this.description = description;
    this.complete = false;
  }, util.events());

  Todo.prototype.done = function() {
    this.complete = true;
    Todo.trigger('change', this); // 変更を通知
  };

  // Collection
  var TodoList = _.extend([], util.events());

  TodoList.add = function(todo) {
    this.push(todo);
    this.trigger('change', TodoList); // 変更を通知
  };

  // export
  exports.Todo = Todo;
  exports.TodoList = TodoList;

})(this._, this.util, this.todo = this.todo || {});

view.js

ビューです。何か長いですが、、、DOMの更新とコントローラへの通知ロジックを実装しているだけです。

(function(_, util, exports) {
  'use strict';

  // モデルの参照
  var Todo = exports.Todo;
  var TodoList = exports.TodoList;

  // DOM参照
  var input = window.document.getElementById('todoInput');
  var ul = window.document.getElementById('todoList');

  var View = _.extend({}, util.events());
  var todoHtml = _.template('<li id="todo_<%= id %>" <% if(complete) { %>class="completed" <% }%>><%= description %></li>');

  // DOM操作 TodoListを描画
  function render(todoList) {
    ul.innerHTML = todoList.reduce(function(html, todo) {
      return html + todoHtml(todo);
    }, '');
  }

  // DOM操作 Todoの状態を更新
  function complete(todo) {
    var target = window.document.getElementById('todo_' + todo.id);
    target.className = todo.complete ? 'completed' : '';
  }

  // DOM操作 入力をクリア
  function clearInput() {
    input.value = '';
  }
  View.clearInput = clearInput; // export

  // モデルを監視
  Todo.on('change', complete);
  TodoList.on('change', render);

  // Event通知
  input.addEventListener('keydown', function(e) {
    if (e.keyCode === 13 && e.target.value) View.trigger('add', e.target.value); // inputでENTERが押下された場合に通知
  });
  ul.addEventListener('click', function(e) {
    if (e.target.tagName.toLowerCase() === 'li') {
      View.trigger('change', _.last(e.target.id.split('_'))); // 各アイテムがクリックされた時に通知
    }
  });

  // export
  exports.View = View;

})(this._, this.util, this.todo);

controller.js

最後はコントローラです。viewの通知を受け取って、modelを更新したりviewを変更したりします。

(function(todo) {
  'use strict';

  // viewの追加イベントを監視。TodoListに新規Todoを追加する。
  todo.View.on('add', function(description) {
    todo.TodoList.add(new todo.Todo(todo.TodoList.length, description));
    todo.View.clearInput();
  });

  // viewの変更イベントを監視。モデルの状態を更新を命令。
  todo.View.on('change', function(index) {
    todo.TodoList[index].done();
  });

})(this.todo);

おまけ

上のコードをgistに置いてます。

https://gist.github.com/masahirompp/0fc24cd168651bdcc2f4

クラスをインタフェースとして使う。mongooseのモデルに適用してみる。

この記事はTypeScript Advent Calendar 2014の25日目の記事です。 少し早めの公開です。

最後の残り一枠が最終日でした(;・∀・)最終日がんばります。

クラスをインタフェースとして使う

typescriptの言語仕様書にこんなことが書かれています。

7.3 Interfaces Extending Classes

When an interface type extends a class type it inherits the members of the class but not their implementations. It is as if the interface had declared all of the members of the class without providing an implementation. Interfaces inherit even the private and protected members of a base class. When a class containing private or protected members is the base type of an interface type, that interface type can only be implemented by that class or a descendant class.

なんと、typescriptのインタフェースはクラスを継承できるらしいです!!

このときprivateメンバも継承する?

(自分はまったく知らなかったけど、実は有名な話だったらごめんなさい。。。)

言語仕様書のサンプルにもありますが、こんなことができます。

class Control {
 private state: any;
}
interface SelectableControl extends Control {
 select(): void;
}

なんかすごいけど、これだけじゃ分からない。そこでいろいろ試してみました。

mongooseのモデルに適用してみる

前置き(余談)

MongoDBのODMとしてmongooseを使っています。typescript + mongooseは個人的に少し使いづらいところがあります。まずはその説明をします。

mongooseの公式サイトのトップにあるサンプルを見てください。(下記)

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

var Cat = mongoose.model('Cat', { name: String });

var kitty = new Cat({ name: 'Zildjian' });
kitty.save(function (err) {
  if (err) // ...
  console.log('meow');
});

DBに接続して、スキーマ定義して、新しいドキュメント生成してるだけです。シンプルでいいですね。

ただこれをtypescriptでコーディングしていくと、いろいろと躓くのです。 下記はModelのコンストラクタ(上記のサンプルで言うと6行目)の型定義ですが、

export interface Model<T extends Document> {
  new(doc: Object): T;
}

ModelをnewするとDocument(を継承したインスタンス)が返ってきます。

(;・∀・)あれれ、おかしいぞ〜

って感じですね。JavaとかC#とかやってる人から見たらあり得ないですね。 ModelをnewしたらModelのインスタンスが返ってきて欲しい。。

javascriptだからこんなことができるのか。javascriptはやっぱり面白い。

で、mongooseは基本的に、

  1. コレクションに対する操作はModelクラスのメソッドとして実装されています。

  2. ドキュメントに対する操作はDocumentクラスのメソッドとして実装されています。

個人的には、1はDocumentクラスのstaticメソッドとして実装して欲しいです。そうするとRailsActiveRecordや.netのEntityFrameworkに近い形になりますね。

mongooseとの格闘

1. サンプル通り

まず、mongooseの公式サイトにあるサンプルを参考にして、そのままtypescriptに移植すると、インタフェースが複数できたり、クラスが複数できたりと、なぜかどんどん複雑になり、何度も心が折れます。(ちょっと違うけど過去の記事を参照。頑張った割にはあまりきれいにできない。。)

2. typescriptっぽく

サンプルはtypescriptっぽくないんだとやっと気付いて、typescriptっぽく書こうと試みます。

mongooseはDAOっぽい位置づけにして、自分でモデルのクラスを作って、モデルのstaticメソッドにDBのCRUD系ロジックを集約して(mongooseのラッパーみたいなイメージ)、モデルのinstanceメソッドに業務ロジックを集約しよう、と構想します(もちろん一部の業務ロジックはstaticメソッド側にも実装しますが)。

もうインタフェースとかクラスが複数になるのは勘弁、、シンプルにすっきりしたい。

でこれなんですが、もう細かい話は省きますが、できそうでなかなかできないんです。やっとそれっぽい形になったのがこれです。ただまだ冗長な感じがあります。

この時期になると「というか、javascriptでやったらこんなに悩まなくて良かったんじゃない?」(悪魔のささやき)という気分になります。

3. クラスをインタフェースとして使ってみた結果

タイトルに戻って、クラスをインタフェースとして使うっていうのを見つけて、これを適用してみたところ、今までで一番シンプルに書けました。以下がそのコードです。

/// <reference path="../tsd/tsd.d.ts" />

import mongoose = require('mongoose');
import passport = require('passport');
import util = require('../util/Util');

/**
 * MongooseSchema
 * @type {"mongoose".Schema}
 * @private
 */
var _schema: mongoose.Schema = new mongoose.Schema({
    provider: {
      type: String,
      require: true
    },
    id: {
      type: String,
      require: true
    },
    authorId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Author'
    },
    displayName: {
      type: String
    },
    emails: {
      type: mongoose.Schema.Types.Mixed
    },
    photos: {
      type: mongoose.Schema.Types.Mixed
    },
    show: Boolean,
    created: {
      type: Date,
      default: Date.now
    },
    updated: {
      type: Date,
      default: Date.now
    }
  })
  .pre('save', function(next) {
    this.updated = new Date();
    next();
  });

interface IUser extends mongoose.Document, User {}

var _model = mongoose.model < IUser > ('User', _schema);

class User {
  provider: string;
  id: string;
  authorId: string;
  displayName: string;
  emails: any;
  photos: any;
  show: boolean;
  private created: Date;
  private updated: Date;

  /**
   * static ユーザが存在しなければ作成して返す。
   * @param passport.Profile
   * @returns {Promise<User>}
   */
  static findOrCreate(profile: passport.Profile): Promise < User > {
    return new Promise < User > ((resolve, reject) => {
      _model.findOne({
          provider: profile.provider,
          id: profile.id
        })
        .exec()
        .then(user => {
          if (user) {
            return resolve(new User(user));
          }
          _model.create({
              provider: profile.provider,
              id: profile.id,
              displayName: profile.displayName,
              emails: profile.emails,
              photos: profile.photos
            })
            .onResolve((err, user) => {
              err ? reject(err) : resolve(new User(user));
            });
        });
    });
  }

  /**
   * static idからUserオブジェクトを取得
   * @param id
   * @returns {Promise<User>}
   */
  static findById(id: string): Promise < User > {
    return new Promise < User > ((resolve, reject) => {
      _model.findById(id)
        .exec()
        .onResolve((err, user) => {
          // debug
          console.log(user.created); // コンパイルOK
          // console.log(user.findOrCreate);  // コンパイルNG
          err ? reject(err) : resolve(new User(user));
        });
    })
  }

  /**
   * コンストラクタ
   * @param mongoose.Document<User>
   */
  constructor(user: IUser) {
    util.extend(this, user.toObject());
  }

  get image(): string {
    if (Array.isArray(this.photos)) {
      return this.photos.length > 0 ? this.photos[0] : null;
    }
    return this.photos;
  }
}

export = User;

ポイントは以下です。

  • 49行目でインタフェースがクラスを継承しています。このインタフェースは、mongoose.DocumentとUserクラスのプロパティ・メソッドを使えます。

  • 104〜106行目は検証用のデバッグログを出力しています。105行目で、privateなプロパティもインタフェースに継承されました。これはこういう仕様ということでしょう。

  • 106行目で、Userクラスのstaticメソッドはインタフェースに継承されませんでした。これは賢い!!

本題とは関係ないですが、その他のポイントとして、

最後に上記のコードで満足していないところは以下です。

  • Userクラスのプロパティがpublicになっている。privateにしてgetterを用意しても良いが、プロパティと同じ名前で定義できない。 →個人的には値を再代入しないというルール決めで対応。

  • ここまで触れませんでしたが、「クラスをインタフェースとして使う」ということに若干の違和感を感じる。←今更(笑)

今回はインタフェースがクラスを継承しましたが、クラスがクラスをimplementsすることとかができるのかな?。scalaのtraitっぽいことができたら夢が広がります。これはまた今度。

今年一年お疲れさまでしたノシ

【小ネタ】typescriptで文字列が数値かどうか判定→変換

小ネタです。

文字列が数値かどうか判定して、数値ならその値を返し、数値でなければnullを返すとします。

javascriptで書くと以下です。

function(num){
  return isNaN(num) ? null : Number(num);
}

これをtypescriptでそのまま書くとコンパイルが通りません。

static toNumber(num:string):number {
  return isNaN(num) ? null : Number(num); //コンパイルエラー
}

理由はなんと、lib.d.tsではisNaNの引数がnumberになっていました。

/**
  * Returns a Boolean value that indicates whether a value is the reserved value NaN (not a number). 
  * @param number A numeric value.
  */
declare function isNaN(number: number): boolean;

なので、以下のように書いて解決です。

static toNumber(num: string): number {
  return ((n:number) => isNaN(n) ? null : n)(Number(num));
}

mongooseでPromise(qとか)

つい最近知ったのですが、mongooseは標準でPromiseの機能持ってるんですね。

Mongoose API v3.8.19

これを使って非同期処理してみます。 あとmongooseのPromiseをqのPromiseに変換します。

User.js

var mongoose = require('mongoose');
var UserSchema = new mongoose.Schema({
    displayName: {
        type: String
    },
    email: {
        type: String
    }
});

UserSchema.static('findOrCreate', function (profile) {
    return Q.Promise(function (resolve, reject) {
        UserDocumentModel.findOne({
            email: profile.email
        })
        .exec()
        .then(function (user) {
            if (user) {
                return resolve(user);
            }
            User.create({
                displayName: profile.displayName,
                email: profile.email
            }).onFulfill(function (user) {
                resolve(user);
            }).onReject(function (err) {
                reject(err);
            });
        });
    });
});

var User = mongoose.model('User', UserSchema);
module.exports = User;

mongooseのModelが持つメソッドexec()とすると(基本的に)Promiseオブジェクトが返されます。

onFulfillのところはthenを使ってもOKですね。

User.create({
    displayName: profile.displayName,
    email: profile.email
}).then(function (user) {
    resolve(user);
}).onReject(function (err) {
    reject(err);
});

thenの第二引数にonRejectの処理を書くと、第一引数のonFullfillでエラーが発生した場合にonRejectに入らないので、onRejectの処理はメソッドチェーンで書くようにしています。

上は例は、新規ユーザを作成する場合にModelのcreateメソッドを使っています。createはPromiseメソッドを返します。

しかし、mongooseのDocument.save()はPromiseを返しません。exec()も使えません。 なのでその場合はコールバック関数を使って以下のように書きます。

UserSchema.static('findOrCreate', function (profile) {
    return Q.Promise(function (resolve, reject) {
        UserDocumentModel.findOne({
            email: profile.email
        })
        .exec()
        .then(function (user) {
            if (user) {
                return resolve(user);
            }
            user = new UserDocumentModel({
                displayName: profile.displayName,
                email: profile.email
            });
            user.save(function(err, user) {
                err ? reject(err) : resolve(user);
            });
        });
    });
});

Userを使う側は以下のようになります。 User.findOrCreateはqのPromiseを返すので、それに対して処理を連結すればOKですね。

app.js

var User = require('./model/User');
passport.use(new TwitterStrategy({
    consumerKey: config.auth.twitter.TWITTER_CONSUMER_KEY,
    consumerSecret: config.auth.twitter.TWITTER_CONSUMER_SECRET,
    callbackURL: config.auth.twitter.callbackURL
  },
  function(token, tokenSecret, profile, done) {
    User.findOrCreate(profile)
      .then(function(user) {
        done(null, user);
      })
      .catch(function(err) {
        done(err);
      });
  }
));

最後にtypescriptで書くと、以下のようになります。

User.ts

/// <reference path="mongoose/mongoose.d.ts" />
/// <reference path="q/Q.d.ts" />

import mongoose = require('mongoose');
import IContact = require('IContact');

var UserSchema: mongoose.Schema = new mongoose.Schema({
  displayName: {
    type: String
  },
  email: {
    type: String
  }
});

UserSchema.static('findOrCreate', (profile: IContact): Q.Promise < User > => {

  return Q.Promise < User > ((resolve, reject) => {
    UserDocumentModel.findOne({
          email: profile.email
      })
      .exec()
      .then(user => {
        if (user) {
          return resolve(user);
        }
        UserDocumentModel.create({
            displayName: profile.displayName,
            email: profile.email
          })
          .onFulfill(user => {
            resolve(user);
          })
          .onReject(err => {
            reject(err)
          });
      });
  });
});

var User = mongoose.model('User', UserSchema);
export = User;