ラベル dartlang の投稿を表示しています。 すべての投稿を表示
ラベル dartlang の投稿を表示しています。 すべての投稿を表示

2016年11月12日土曜日

Tips for unit testing a Polymer.dart 1.x web app

Polymer.dart 1.xを使ってウェブアプリを書く際、単体テストを作るときの個人的な知見をまとめた。なおこれはテストフレームワークとしてpackage:testpackage:mockitoを使った場合を前提とする。

アプリの初期化はreadyハンドラで行わず別のinit関数を用意する

ユーザーがウェブサイトにアクセスした際、何らかの初期化コードを実行したいときがある。例えばページがロードされたあと背後でサーバーとやりとりするようなコードだ。 色々調べてるとアプリの初期化はroot elementのreadyハンドラでやればいいよ!という印象を受けるが、 ここで初期化すると単体テストで困ることになる。

例えば下記のようなコードがあったとすると、単体テスト時に(本来は呼びたくない)サーバーとの通信が発生してテストがしづらくなってしまう。readyハンドラはelementのlocal DOMが用意できたとき自動で呼ばれてしまうので、単体テストでもそれは回避できないのだ。

@PolymerRegister("main-app")
class MainApp extends PolymerElement {
  void ready() {
    _localizeUI();
    // Set a handler for some events
    window.onBeforeUnload.listen((e) => Logger.root.info("Page was unloaded!"));
    _getSomeData();
  }

  /// Localize the UI
  void _localizeUI() {
    ...
  }

  /// Get some data from the server
  Future _getSomeData() async {
    var info = await HttpRequest.request("myserver.com/api/data", method:"GET");
    ...
  }
}

そこでreadyハンドラではあくまでelementとしての初期化コードのみにして、アプリの初期化コードは別のinit関数でやるようにする。

  void ready() {
    _localizeUI();
  }

  /// Localize the UI
  void _localizeUI() {
    ...
  }

  /// Initialize 
  Future init() async {
    // Set a handler for some events
    window.onBeforeUnload.listen((e) => Logger.root.info("Page was unloaded!"));
    var info = await HttpRequest.request("myserver.com/api/data", method:"GET");
    ...
  }

こうすると単体テスト時にアプリ初期化コードは自動的に呼ばれなくなるのでテストがやりやすくなる。 では実際のアプリではどのようにinit関数を呼ぶかというと、ページのエントリポイントから直接呼ぶことができる。

Future main() async {
  await initPolymer();
  MainApp ma = querySelector('main-app');
  ma.init();
}

これでアプリの初期化コードはウェブ上では必ず呼ばれるし、単体テストでは呼びたい場合とそうでない場合でわけることができる。

サービス相当の機能はラッパークラスを作って外から渡す

先程の例ではinit関数内でwindow変数にハンドラを登録したり外部へHttpRequestを送ったりしていた。ウェブ上ではこのやり方で問題ないのだが、やはり単体テスト時には都合が悪いためラッパークラスを作成してクラスごと外部から渡すように変えた方がいい。 こうしておくと単体テストのときにmockしたクラスを代わりに差し込んで想定した処理が呼ばれているかチェックできるからだ。

実際に他のアプリフレームワーク、例えばAngular JSではこういったwindowとやり取りする処理やHTTP Request処理はすべてサービスというラッパーを経由して実行するようになっている。

ラッパークラスを経由するように変えるとinit関数で言えば下記の形になる。

/// Window wrapper class
class MyWindow {
  void subscribeOnBeforeUnload(void onData(Event e))
  ...
}

/// Class that communicates with the server
class ServerChannel {
  Future<Info> getInfo() async {
    var res = await HttpRequest.request("myserver.com/api/data", method:"GET");
    return new Info(res);
  }
}
...

  /// Initialize 
  Future init(MyWindow window, ServerChannel channel) async {
    // Set a handler for some events
    window.subscribeOnBeforeUnload((e) => Logger.root.info("Page was unloaded!"));
    var info = await channel.getInfo();
    ...
  }

こうしておくと単体テスト時はmockしたクラスをinit関数に渡して適切にAPIが呼ばれたかを確認することができる

class MockMyWindow extends Mock implements MyWindow {
}

class MockServerChannel extends Mock implements ServerChannel {
}

Future main() async {
  await initPolymer();
  test("check init", () async {
    MainApp ma = fixture("init");
    var mmw = new MockMyWindow();
    var msc = new MockServerChannel();
    await ma.init(mmw, msc);
    verify(msc.getInfo()).called(1);
  });
}

まとめ

こうして書くとわりと普通のことのように感じるのだけど、Polymer.dartの場合どうすればいいんだ…?と少し悩んだのでまとめておいた。

2016年3月1日火曜日

Using webfont.js from Dart (via package:js)

webfont.jsをDartから呼ぶ必要があって、今まではdart:jsを使っていたのだけどdart:jsが非推奨になってしまったので新しいpackage:jsへ書き換えることにした。ドキュメントがあまり整備されてなくて少し手間取ったので参考として残しておく。なお使用したバージョンは下記の通り。

  • Dart : 1.14.2
  • package:js : 0.6.0
  • webfont.js : 1.6.16

JavaScript版

var param = {
  google: {
    families: ['Droid Sans', 'Droid Serif']
  }
};
WebFont.load(param);

Dart(package:js)版

@JS("WebFont")
library web_font;

import "package:js/js.dart";

@JS()
external load(Config config);

@JS()
@anonymous
class GoogleGroup {
  external List<String> get families;
  external factory GoogleGroup({List<String> families});
}

@JS()
@anonymous
class Config {
  external GoogleGroup get google;
  external Function get active;
  external void set active(Function f);
  external factory Config({GoogleGroup google});
}

/// Initialize web font 
void init(List<String> fonts, {Function onActive: null}) {
  var gg = new GoogleGroup(families:fonts);
  var c = new Config(google: gg);
  c.active = allowInterop(onActive);
  load(c);
}

使い方はたとえばこんな感じ。

import 'package:web_font/web_font.dart' as WebFont;
...
var fontNames = ["Krona One", "Atomic Age"];
var f = () => Logger.root.info("Loaded font!");
WebFont.init(fontNames, onActive: f);
...

基本的にはJavaScript側へ渡すオブジェクトに対してクラス定義して修飾子つけてアノテーションつけて…という風に機械的にやってくことになる。自動的にラッパーを生成することもできるだろうし、実際Polymer.dartではPolymer.jsからcustom_element_apigenというツールを使ってAPIを自動的に生成している。

感想としては、こうして並べてみるとDartからJavaScript使うのエラい面倒だなという印象を受ける。自動的にAPIを生成してくれるものがあれば(SWIGみたいな?)それを使うのが楽かもしれない。

2016/3/13追記

Dart SDK 1.15リリースではさっきのコードで次のエラーが表示されるようになった。

Uncaught Unhandled exception:
Unhandled exception:
Unhandled exception:
Invalid argument (Expandos are not allowed on strings, numbers, booleans or null): null
#0      Expando._checkType (dart:core-patch/expando_patch.dart:134)
#1      Expando.[] (dart:core-patch/expando_patch.dart:14)
#2      allowInterop (dart:js:1532)
#3      init (package:xclamm/web_font/web_font.dart:29:14)
...

これを直すにはコードを下記のようにする必要がある

...
/// Initialize web font 
void init(List<String> fonts, {Function onActive: null}) {
  var gg = new GoogleGroup(families:fonts);
  var c = new Config(google: gg);
  if (onActive != null) {
    c.active = allowInterop(onActive);
  }
  load(c);
}

2014年7月1日火曜日

Internationalization with Polymer.dart and intl

2014/7/6追記

もともとintl 0.9.10向けにかかれていたのを0.11.0向けに更新

GUIアプリを作る上で国際化をどうするか、というのは意外と面倒な問題で、後からやろうとすると大変な目に遭うことが多い。Polymer.dartを使ってウェブアプリを作る場合でも同様で、本格的に作りこむ前にうまい方法を確立しておきたい。

幸いDartには公式な国際化ライブラリ(intl)が存在していて基本的にはそれを使えば良さそうな状況になっている。ただこの国際化ライブラリ使ってみると手順が複雑で、案外ハマりどころがあったのでここでまとめておく。

サンプルプロジェクト

今回も動作するサンプルプロジェクトをGithubに置いといた。Github Pagesで動作するウェブサイトも作ろうとしたら何かが原因でコンパイラが落ちるため、今回はそこまでやらない(ちなみにIssueは上げてある)。とりあえずローカルで動かした時の画像だけは貼っておく。上が英語、下が日本語で、ロケールのselectorを変えることで言語を動的に変えられる。

国際化の流れ

簡単に国際化の流れを説明しておくと下記のようになる。intlのドキュメントにも色々書いてあるのでそちらも参照するとわかりやすい。

  1. pubspec.yamlでpolymer、intl、petitparserの使用を宣言する
  2. 各Componentの中で国際化したい文字列のidとデフォルトの表記を記述する。このときIntl.message関数を使う
  3. intlパッケージに含まれているextract_to_arb.dartを使い、Intl.message関数で指定した内容をarbファイルに吸い出す
  4. 各文字列idに対応する言語ごとの表記を言語ごとのarbファイルに記載する
  5. intlパッケージに含まれているgenerate_from_arb.dartに対して下記の項目を渡し、国際化用のクラスを自動生成する
    • 元のComponentのソースコード
    • 吸いだされたarbファイル
    • 作成した各言語ごとのarbファイル
  6. 生成された国際化用のクラスを使い、ロケールに応じた文字列が表示されるようpolymerのバインディングを書く

pubspec.yaml

pubspec.yamlは今回はこんな感じ。重要なのはpolymer、intl、とあとpetitparserが記載されていることで、最後のtransformersはdart2js用のものだからあまり本質的ではない。petitparserはあとで使うgenerate_from_arb.dartが依存しているためここで追加しておく。

name: polymer_intl_sample
dependencies:
  logging: any
  polymer: any
  intl: any
  serialization: any
  petitparser: any
transformers:
- polymer:
   entry_points:
   - web/index.html

Intl.message関数

Intl.message関数は文字列の翻訳処理を行うとともに、あとで使う各種スクリプトにとっての目印にもなる重要な関数だ。ドキュメントを読むと指定できる項目が色々あるようなんだけど、今回は最小限で行く。

下記のコードは今回のサンプルアプリの本体であるmain-appコンポーネントの一部で、下の方にIntl.message関数が使われている。ここでは最低限指定する必要があるデフォルトの表記"Hello"と文字列自体のID"hello"だけを指定している。

CustomTag("main-app")
class MainApp extends PolymerElement {
  ...
  /// Locale of language
  @observable String currentLocale;
  @observable String helloStr;
  @observable String userStr;
  @observable String localeStr;
  @observable String japaneseStr;
  @observable String englishStr;
  @observable int selectedLocale;

  ...

  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }

  ...
  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

  hello() => Intl.message("Hello",name:"hello",args:[]);

  MainMenu _menu;
}

公式のドキュメントを読む限りIntl.message関数は上記の"hello() => Intl.Message(...)"のように関数にラップして使うんだけど、どうもその関数名(今回で言えばhello)と文字列のID("hello")は一致していないとあとのスクリプトが上手く動かないため注意が必要。

extract_to_arb.dartによる吸い出し

Intl.message関数で翻訳したい文字列をコンポーネント内に記述したあとは、そのソースコードをextract_to_arb.dartというスクリプトに渡すことで翻訳用のarbファイルを抽出することができる。

このarb(App Resource Bundle)ファイルは文字列リソースを格納するための独自フォーマットらしく、公式サイトの説明を読むと使用するプログラミング言語に非依存なようだ。

extract_to_arb.dartはintlパッケージに含まれているので、pubのキャッシュディレクトリから持ってくることができる。Windowsの場合pubのキャッシュは"C:\Users\ユーザー名\AppData\Roaming\Pub\Cache\hosted\pub.dartlang.org"の下にあるので、そこの"intl-*.*.*\bin"ディレクトリ内から持ってくることができる(ちなみに他のOSはどこにキャッシュが置いてあるか知らない)。

extract_to_arb.dartは下記のように実行する。

dart --package-root=packages tool/extract_to_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart

これでlib/components/messages_allの下にintl_messages.arbというファイルが生成される。intl_messages.arbの中身は下記の通り。Intl.message関数で指定したことをそのまま持ってきたような内容だ。

{
  "hello": "Hello",
  "@hello": {
    "type": "text",
    "placeholders": {
      
    }
  },
  "setting": "Setting",
  "@setting": {
    "type": "text",
    "placeholders": {
      
    }
  }
  ...

翻訳作業

ここに来てようやく翻訳作業に入れる。先ほどのintl_messages.arbが出力されているlib/components/messages_allの中にtranslation_jp.arbというファイルを作り、そのファイルに翻訳された単語を書いていく。書き方は下記の通り。

{
  "_locale":"ja",
  "user":"ユーザー",
  "hello":"こんにちは",
  "setting":"設定",
  "locale":"ロケール",
  "japanese":"日本語",
  "english":"英語"
}

最初にロケールを指定して、あとは単語のIDとロケールに応じた翻訳を記載していく形だ。このarbファイルは翻訳したい言語に応じて増やすことができるみたいなんだけど、今回は日本語のみにしてる。

generate_from_arb.dart

翻訳作業が終わったら、次にgenerate_from_arb.dartを使って翻訳用のクラスを生成する。generate_from_arb.dartはextract_to_arb.dartと同じようにintlパッケージに含まれているのでそこから持ってくることができる。

generate_from_arb.dartは下記のように実行する。

dart --package-root=packages tool/generate_from_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart lib/components/messages_all/translation_jp.arb

これを実行すると今度はmessages_all.dartとmessages_ja.dartというファイルが生成される。このクラスの中身に関しては何も知らなくても使えるのでここでは特に紹介しない。ちなみに今回のサンプルでは先ほどのextract_to_arb.dartとgenerate_from_arb.dartを一発で実行するPythonスクリプトtranslate.pyを用意してるので、それを実行すれば比較的楽に翻訳できる。

polymerによるバインディング

翻訳用のクラスが生成されたので、ついにUIに翻訳した文字列が表示できる。最初のmain-appに対応するhtmlを用意し、生成したクラスをimportして初期化処理を書いた後、翻訳した文字列とバインドすればおしまいだ。今回はhtmlを下記のようにした。

<link rel="import" href="../../../packages/polymer/polymer.html">
<link rel="import" href="main_menu.html">
<polymer-element name="main-app">
  <template>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" >
    <main-menu id="mainMenu" ></main-menu>
    <div class="container">
      <h2>{{helloStr}} {{userStr}}</h2>
      <label>{{localeStr}}</label>
      <select selectedIndex={{selectedLocale}}>
        <option value="jp">{{japaneseStr}}</option>
        <option value="en_US">{{englishStr}}</option>
      </select>
    </div>
  </template>
  <script type="application/dart" src="main_app.dart"></script>
</polymer-element>

初期化はintlライブラリと生成されたmessages_allライブラリをインポートしてからinitializeMessages関数を呼ぶだけ。initializeMessages関数を呼ぶときは有効なロケール(今回だと"ja"かデフォルトの"en_US")を指定する必要がある点に注意。

import 'package:intl/intl.dart';
import 'package:polymer_intl_sample/components/messages_all/messages_all.dart';
  ...
  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }
  ...  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

あとは上記のupdateLocale関数のようにIntl.defaultLocaleを変更してからIntl.messageで対応する文字列を取り出して、UIとバインドした文字列を更新してやれば動的に言語を変えることができる。

複数コンポーネントで翻訳を共有したい場合

複数のコンポーネントで文字列を共有したい場合というのは良くあって、今回のサンプルではそれの例も示している。やり方は単純で、Intl.message関数のみを含むlibraryを作ってそれを各コンポーネントで共有する、というものだ。今回でいえばcommon_messages.dartがその共有されるライブラリとなる。

library common_messages;

import 'package:intl/intl.dart';

user() => Intl.message("User",name:"user",args:[]);
locale() => Intl.message("Locale",name:"locale",args:[]);
japanese() => Intl.message("Japanese",name:"japanese",args:[]);
english() => Intl.message("English",name:"english",args:[]);

これを他のコンポーネントと同じようにextract_to_arb.dartとgenerate_from_arb.dartに食わせて、あとはさきほどのupdateLocale関数のようにライブラリの関数を呼び出してやればいい。

import 'package:polymer_intl_sample/components/common_messages.dart' as msg;
  ...
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

なおこれは公式に用意されている方法ではなく、なんとなくやってみたら出来たものなのでそのうち上手くいかなくなるかも。

感想

長々と説明してきたようにDartでの国際化は手順が多くて少し複雑だ。実際、Issueとしてintlパッケージが使いづらいから何とかしてくれ、というのが提出されていたこともある。

まだそんなにヘビーに使ってるわけではないのでメリット・デメリットを示せるわけではないけど、ある程度慣れてしまえばそんなに大変では無い気がする。それより混沌としがちな国際化方法をきっちりライブラリとして用意してくれている点はありがたいと思う。

2014年5月26日月曜日

Using Bootstrap dropdown from Dart

自分で何かウェブサイトをでっち上げる場合、最近はもっぱらBootstrapを使うことが増えてる。普段はCSSで出来る範囲しかやらないんだけど、今回dropdownも含めてDartからやろうとして少しひっかかったのでここにメモを残しておく。

Bootstrap

Bootstrapは使うだけでなんとなくそれっぽいウェブサイトがデザインできる素敵CSSとJavascriptのライブラリで最近はなにかと良く使ってる。

ただBootstrapはDartと組み合わせようとすると少し面倒なことになってしまう。CSSだけを使ってる分にはいいけど、dropdownみたいに何らかのJavascriptが走るようなのは途端にうまく動かなくなるからだ。というか実際色々試してみたところ動かなかった。

たとえばBootstrapのdropdownに関するドキュメントではdata-toggleにdropdownって書けばいいよーと書いてあるけど、polymer.dart使って定義したWeb Component内でそれをやっても動かない。jQueryとdart:jsを駆使してむりくりJavascript側のdropdown関数を呼んでみても何か動かない。

しょうがないからBootjackとか使ってみるか…? いやでも最新のBootstrap使えないんじゃちょっとイケてないしな…という感じで困って色々探したところ、公式のDart API ReferenceってDartで書かれてるけどdropdown使ってる(Optionsのところクリックするとdropdownする)じゃん、と気づいた。

それでソースをちらっと見たところDart API ReferenceもまさにBootstrap使ってて完全に渡りに船だった。

Javascriptは不要

結論から言うと、dropdownでもjavascriptを使う必要はなかった。Dart API Referenceのソースはここで見られて、dropdownに関する部分を転載すると以下のようになる。

  <li class="dropdown">
    <a class="dropdown-toggle" on-click="{{toggleOptionsMenu}}">
      Options <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li class="visible-lg"><a on-click="{{togglePanel}}">
        {{showOrHideLibraries}} Libraries
      </a></li>
      <li class="visible-lg"><a on-click="{{toggleMinimap}}">
        {{showOrHideMinimap}} Minimap
      </a></li>
      <li><a on-click="{{toggleInherited}}">
        {{showOrHideInherited}} Inherited
      </a></li>
      <li><a on-click="{{toggleObjectMembers}}">
        {{showOrHideObjectMembers}} Object Members
      </a></li>
      <li><a on-click="{{togglePkg}}">
        {{showOrHidePackages}} Packages
      </a></li>
    </ul>
  </li>

上記に対応するコードが次の部分。

  void enteredView() {
    super.enteredView();
    ...
    registerObserver('onclick',
        onClick.listen(hideOptionsMenuWhenClickedOutside));

    ...
  }

  void toggleOptionsMenu(MouseEvent event, detail, target) {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    if (list.classes.contains("open")) {
      list.classes.remove("open");
    } else {
      _openedAt = event.timeStamp;
      list.classes.add("open");
    }
  }

  void hideOptionsMenuWhenClickedOutside(MouseEvent e) {
    if (_openedAt != null && _openedAt == e.timeStamp) {
      _openedAt == null;
      return;
    }
    hideOptionsMenu();
  }

  void hideOptionsMenu() {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    list.classes.remove("open");
  }
  

内容としてはdropdownクラスなliに対してopenっていうクラスを付加してやればdropdownするし、openを取り除けば閉じる、っていう極めて単純なものだった。

よりPolymerらしく

上のコードをそのまま流用しても使えるんだけど、せっかくなのでよりPolymerらしくしたのが次のバージョン。自分用に書き換えてるのでさっきのとは少し内容が違うけど、雰囲気は大体一緒。

  <li class="{{ {'dropdown':true, 'open':userDropdownOpen} }}">
    <a class="dropdown-toggle" on-click="{{toggleUserDropdown}}" >
    <span class="glyphicon glyphicon-user"></span> {{authState.user.email}} <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li><a href="setting/"><span class="glyphicon glyphicon-wrench"></span> {{setting}}</a></li>
      <li class="divider"></li>
      <li><a href="{{authState.url}}"><span class="glyphicon glyphicon-log-out"></span> {{logout}}</a></li>
    </ul>
  </li>

どの辺がPolymerっぽいかというとclassのenable/disableをコードからいじるんでなくバインディングで解決してるところ。このhtmlで言えばopenクラスの有効・無効がuserDropdownOpenフラグにバインドされているので、あとはフラグの値を書き換えるだけで自動的にopenが付与される。

なお、上のhtmlに対応するコードがこちら。

  @observable bool userDropdownOpen = false;

  @override
  void enteredView() {
    super.enteredView();

    // Apply style from parent in order to use bootstrap
    shadowRoot.applyAuthorStyles = true;

    // Register to the root window so we can receive the onClick
    // even if it was clicked outside of this component
    window.onClick.listen((e) => hideOptionsMenu(e.timeStamp));
  }

  void toggleUserDropdown(MouseEvent e, var detail, Node target) {
    if (userDropdownOpen) {
      userDropdownOpen = false;
    } else {
      _dropdownOpenTime = e.timeStamp;
      userDropdownOpen = true;
    }
  }

  void hideOptionsMenu(int timestamp) {
    if (_dropdownOpenTime != null && _dropdownOpenTime == timestamp) {
      _dropdownOpenTime == null;
      return;
    }
    userDropdownOpen = false;
  }

元のコードではregisterObserverでonclickイベントに接続していたんだけど、こっちではwindowのonClickに直接つないでる。

こういう風にした理由はregisterObserverだと同一Component内のonclickしか拾えなかったから。たとえば全く別のcomponentの上でマウスクリックした場合、onclickが検知できなくてdropdownが閉じない現象が発生していた。そのため、全componentの親に相当するwindowのonClickに直接つないでどこをクリックしても適切にdropdownが閉じられるようにした。

2014年4月6日日曜日

Applying <dialog> polyfill from Dart

<dialog>要素をDartから使おうとしたときにちょっとハマったのでここでまとめておく。

<dialog>

dialog要素はhtml要素の一つで、名前の通り何らかのダイアログを示すものだ。今はclass定義やらで擬似的にダイアログを表示するのが主流(例:Bootstrap)ななか、汎用的に使われてるしいっそ専用のタグを用意すればいいんじゃない(ついでにブラウザ側でダイアログを特別扱いできればいろんな問題も解決するし)、という流れででてきたのがdialog。実際に動作する様子はここのデモサイトで見られる。

現時点でdialogを実装しているのはChromeのみで、そのChromeも明示的にフラグ(enable-experimental-web-platform-features)を有効にしないと使うことができない。ちなみにcaniuse.comで調べてみたらマイナーすぎて登録すらされてなかった

<dialog> polyfill from Dart

そんな状況のdialog要素をまともに使おうとすると当然他のブラウザ対応が問題になってくる。Googleの人もそのへんはちゃんとわかっていて、他ブラウザにも擬似的にdialogを表示できるようにするpolyfillを用意してある

このpolyfillをDartから適用しようとしたときに少しハマって、色々試した末なんとか上手く行ったのでここにサンプルを置いておく。

dialog_sample

Github上に置いたサンプルプロジェクトはここで、実際にDartiumから動作してる様子が下のキャプチャ。dart2jsでJavascriptに変換してFirefoxからも動くことは確認している。

中身は単にダイアログを出してるだけのなんてこと無いサンプルで、主要なコンポーネントのうちコード的に重要なのは次の部分。

  @override
  void enteredView() {
    super.enteredView();

    // Apply style from parent in order to use bootstrap
    shadowRoot.applyAuthorStyles = true;

    _dialog = shadowRoot.querySelector("#sampleDialog");
    if (_dialog is UnknownElement) {
      print("<dialog> not supported on this browser. Applying polyfill");
      var pf = context['dialogPolyfill'];
      pf.callMethod('registerDialog',[_dialog]);
    }
    _dialog.on["close"].listen((data) {
      print("Dialog closed");
    });
    _dialog.on["cancel"].listen((data) {
      print("Dialog cancelled");
    });
  }

  void openDialog(Event e, var detail, Node target) {
    if (_dialog is DialogElement) {
      var dlg = _dialog as DialogElement;
      dlg.showModal();
    } else {
      // Native dialog not available.
      // Call polyfilled function
      var obj = new JsObject.fromBrowserObject(_dialog);
      obj.callMethod("showModal",[]);
    }
  }

Dart自体にはDialogElementが既に定義されていて直接DialogのAPIが叩けるんだけど、ブラウザ側がDialogに対応していない場合はUnknownElementとして返ってくるようになっている(少なくとも今は)。なので最初にDialogElementが取得できるかを試すことでDialog対応しているかを確認している。

もしDialogに対応してる場合はそのまま使えばいいし、対応してない場合はdialogPolyfill.registerDialog関数を適用してdialogのpolyfillをすればいい(必要なスクリプトやスタイルは事前にindex.htmlで読み込み済みという前提)。polyfill自体はdialogPolyfill.registerDialogをdart:jsライブラリ経由で呼べばできる。あとdialog専用のcancel/closeイベントハンドラもいまあるAPIからそのままハンドラを登録できる。

最後にちょっとハマったのがpolyfillされた版のshowModal関数を呼ぶところ。UnknownElementとして見えている要素に対してshowModal関数を呼ぼうとすると「そんな関数知らない」と蹴られてしまう。これはpolyfillによってJavascript側へ追加された関数やらはDartからは見えないことに起因する。なので、やはりこちらもdart:jsライブラリのJsObject.fromBrowserObjectでJavascript側のオブジェクトへ取得し、直接showModal関数を呼び出すことで解決している。

2014年3月11日火曜日

"listChanges" Event Stream of ObservableList

DartのobserveパッケージにあるObservableListクラスにはlistChangesというEvent Streamがあって、このStreamはObservableListに加えられた変化を外部に通知する役割を担ってる。このlistChangesは仕組みがちょっとわかりづらいのでここにまとめておく。

listChanges

listChangesのドキュメントに記載されているコードを下記に転載する。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) => records.forEach(print));
model.removeAt(1);
model.insertAll(0, ['c', 'd', 'e']);
model.removeRange(1, 3);
model.insert(1, 'f');

で、これを実行すると次のように表示される。

#<ListChangeRecord index: 0, removed: [], addedCount: 2>
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>

この出力、見るとちょっと良くわからない点が多い。例えばコード上ではもっとremoveしてるはずなのにbしかremoveされたことになってないところとか。

最初と最後の差分

実はさっきの出力はリストの最初の状態(['a','b'])と最後の状態(['c','f','a'])の差分を一回のイベント通知で知らせていて、ListChangeRecord間の状態を書き足すとと次のようになる。

['a','b']
#<ListChangeRecord index: 0, removed: [], addedCount: 2>
['??','??','a','b']
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>
['??','??','a']

この'??'に当てはまるのが何かは、イベント通知で送られてくる情報と現在のリストの状態から逆算して実はcとfだった、ということがわかるようになっている。そのほかに追加してすぐ消したdとeかはどうなるかというと、このデフォルトの動作だとそもそも追加されたことすら(listChangesからでは)検知できない。

非同期なバースト型イベント

更に注意しなきゃいけないのは、このイベントが非同期に、しかもバーストでまとめて送られてくるという点にある。例えば元のコードを下記のように書き換えてみる。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) {
  print("got ${records.length} changes");
  for (var r in records) {
    print(r);
    print(r.object);
  }
});
model.removeAt(1);
model.insertAll(0, ['c', 'd', 'e']);
model.removeRange(1, 3);
model.insert(1, 'f');
print("end state: $model");

これの出力は次のようになる。

end state: [c, f, a]
got 2 changes
#<ListChangeRecord index: 0, removed: [], addedCount: 2>
[c, f, a]
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>
[c, f, a]

最初にend state...って表示されているところを見ると、リストに対する操作の都度ではなく全部操作が終わってからlistChangesへイベントが一回にまとめて送られてきてるのがわかる。

scheduleMicrotask()

この辺の仕組みがどうなってるかを調べるためにobservable_list.dartのソースを直接見たところ、内部ではscheduleMicrotask()という関数を使っていた。このscheduleMicrotaskの説明は長くなるので公式の立派な文書に任せるとして、ObservableList的には次のような動作となっている。

  1. addやremoveのようにObservableListの状態が変わる関数を呼ぶと、その変更を通知する非同期タスク(microtask)がエンキューされる
  2. microtaskが実行されるまで以降のaddやremoveで追加・削除された項目は全て「変更された項目リスト」に追加されていく
  3. microtaskが実行される段階で「変更された項目リスト」は最小化される。例えばaddで追加された項目が直後にremoveされた場合は相殺されてそもそも最初から追加されなかったことにされる
  4. 最小化された「変更された項目リスト」はlistChangesへ通知されて、ObservableList内では消える

同期的な通知

ちなみに同期的に通知する方法ももちろん用意されていて、それをやっているのが次のコード。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) {
  print("got ${records.length} changes");
  for (var r in records) {
    print(r);
    print(r.object);
  }
});
model.removeAt(1);
model.deliverListChanges();
model.insertAll(0, ['c', 'd', 'e']);
model.deliverListChanges();
model.removeRange(1, 3);
model.deliverListChanges();
model.insert(1, 'f');
model.deliverListChanges();
print("end state: $model");

やっているのはリストへの操作の度にdeliverListChangesという通知用の関数を呼んでいること。これを実行すると次のように表示される。これは本当に手順通りの結果なのでわかりやすいと思う。

got 1 changes
#<ListChangeRecord index: 1, removed: [b], addedCount: 0>
[a]
got 1 changes
#<ListChangeRecord index: 0, removed: [], addedCount: 3>
[c, d, e, a]
got 1 changes
#<ListChangeRecord index: 1, removed: [d, e], addedCount: 0>
[c, a]
got 1 changes
#<ListChangeRecord index: 1, removed: [], addedCount: 1>
[c, f, a]
end state: [c, f, a]

感想

最初このlistChangesのドキュメントを読んだときは全然理解できなくて、サンプルを書き換えていてやっと理解できた。

ただこのようにObservableListが非同期かつバーストなイベント通知をしている理由は推測できて、おそらく何らかのリスト操作の度にイベントが飛ぶのはパフォーマンス的に避けたかった、ということなのだと思う。 例えば数万の要素をObservableListに追加するようなループを書いたとして、要素が追加されるたびにイベントが飛んでたらさすがにパフォーマンスへの影響は大きいはず。なので、可能な限り少ないイベントで大量の差分を通知できる今の方式になっているのだと考えられる。

改めて考えると多分因果が逆で、Streamていう"非同期で発行されることが前提な通知インフラ"でObservableListの変化を通知するには前記のような仕組みにするのが最適だった、というのが本当のところかもしれない。それが結果的に"変化の度に通知されてしまうよパフォーマンス問題"も解消してる、と。

ちなみにDartでのObservableListに相当するWPFのObservableCollectionはまさに操作の度にイベントを通知するようになっている。これはこれでわかりやすくていいと思うんだけど、ただ今度は大量に追加するときのパフォーマンス問題が出てくるので、一度に大量の要素を追加する可能性の高いチャート用のライブラリとかは一時的にイベント通知を切る仕組みが用意されていたりする。知る限りではDynamicDataDisplayのObservableDataSourceとか。

2014年2月1日土曜日

Unordered list with a custom layout algorithm

Polymer.dartを使って作ってるウェブアプリで次のようなカスタム<ul>が必要になった。

  • 通常の<ul>と同じように<li>を子要素として追加できる
  • <li>のレンダリングされたときのサイズに応じてそれぞれの表示位置を決定する

例えば1280x720ピクセルの矩形内にそれぞれのli要素がオーバーラップしないよう自動的に配置してくれるようなul、てところ。それで色々と調べたり実験した結果なんとかできたので、ここでサンプルを公開しておく。

何をつくったか

今回作ったのは、追加されたli要素が階段上に表示されるよう配置するweb component。各要素のレンダリングサイズに応じて位置を決めているのでキレイな階段になってるはず(でもAndroid版Chromeで試したら表示崩れてた…)。

要素を追加したり折り返しの幅を変えたりといったこともできる。

コードそのものはGithub上のリポジトリに置いてある。今回は更にdart2jsを試す実験も兼ねてjavascriptに変換してGithub Pagesにおいてみたので、実際に動かしてみることもできる。現状FirefoxとChromeで動作確認した。

MutationObsever

コードに関してはGithubを直接みてもらうとして、このサンプルで重要な役割を果たしたMutationObserverのあたりだけ少し解説しておく。

MutationObserverを使うと指定されたElementにDOM的な変更が加えられたときにイベントとして受け取ることができる。今回は子要素が追加・削除されたかのみを監視してるけど、設定次第では別のイベントも受け取れるらしい。

今回なぜMutationObserverを使ったかというと、li要素をレンダリングサイズに応じて配置する必要があったから。調べたかぎりレンダリングサイズが決定するのは実際にElementがDOMに挿入されたあとなので、サイズが決定した段階で適切な位置へ移動するために使ってる。

今回はulをcustom-listというweb componentでラップしてて、そのcustom-listが表示された(enteredViewが呼ばれた)タイミングで直下にいるulをMutationObserverへ登録している。

    @override
    void enteredView() {
        super.enteredView();
        _root = shadowRoot.querySelector("#root");

        // observe change of child elements for a 2 pass layout approach
        var mo = new MutationObserver(onChildMutation);
        mo.observe(_root, childList:true);
        _observer = mo;
    }

上のコードでMutationObserverのコールバックとしてonChildMutationという関数を登録してて、その中身は以下の通り。

    /// Called when a mutation occurs to the target element
    void onChildMutation(List<MutationRecord> changes, MutationObserver observer) {
        // layout children depending on its rendering size

        int itemCount = 0;
        int bounceBackDelta = bouncebacknum - 1;
        int nextBounceBack = bounceBackDelta;
        int xOffset = 0;
        int yOffset = 0;
        bool offsetRight = true;
        for (var record in changes) {
            var added = record.addedNodes;
            var removed = record.removedNodes;
            for (var a in added) {
                if (!(a.nodeType == Node.ELEMENT_NODE && a is CustomListItem)) {
                    // ignore nodes expect for the one we want
                    continue;
                }
                var elem = a as CustomListItem;
                var width = elem.clientWidth;
                var height = elem.clientHeight;
                // print("${width}x${height}");

                var unit = "px";
                if (elem.item.entered) {
                    // This item is already displayed
                    // so place the view at the same position

                    var l = "${elem.item.rect.left}${unit}";
                    var t = "${elem.item.rect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                } else {
                    // This item is a new one.
                    //  Layout the remark at a sufficient position
                    Rectangle<int> posRect;
                    if (offsetRight) {
                        posRect = new Rectangle<int>(xOffset,yOffset,width, height);
                    } else {
                        posRect = new Rectangle<int>(xOffset-width, yOffset, width, height);
                    }
                    var l = "${posRect.left}${unit}";
                    var t = "${posRect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                    elem.item.rect = posRect;
                    applyAnimation(elem, "fadein", 250);
                    elem.item.entered = true;
                }

                // update offset depending on direction
                if (offsetRight) {
                    xOffset += width;
                } else {
                    xOffset -= width;
                }

                // update bounceback state
                if (offsetRight && itemCount == nextBounceBack) {
                    offsetRight = false;
                    xOffset -= width;
                    nextBounceBack += bounceBackDelta;
                } else if (!offsetRight && itemCount == nextBounceBack) {
                    offsetRight = true;
                    xOffset += width;
                    nextBounceBack += bounceBackDelta;
                }

                yOffset += height;
                itemCount++;
            }
        }
    }

    /// Apply specified animation to the given element
    static void applyAnimation(Element element, String name, int msecDuration) {
        element.style.animationName = name;
        element.style.animationDuration = "${msecDuration}ms";
        element.style.animationTimingFunction = "linear";
        element.style.animationFillMode = "forwards";
    }

onChildMutationでやってることは大体次のこと。

  1. 追加された子要素のうち、新規に追加されたCustomListItemのサイズを取得して設定に応じて階段状に配置する。ついでにアニメーションも適用する。
  2. 既に表示されているCustomListItemが来た場合はそのまま現在の位置に表示する

注意しなきゃいけないのは既に表示されている項目と新規に追加された項目の処理をわけるところ。今回試した限りonChildMutationは新しい子要素を一つ追加したときでも全子要素がaddされた要素としてよばれてくるため、既に表示されてる項目の位置が変わらないようenteredというフラグで処理を切り替えている。

加えてなぜか空のTextElementも追加された子要素として送られてくるため、対象のElement以外は弾くようにしないといけない。空のTextElementが来る理由はよくわからない。バグ? Polymer.dartの仕様?

ちなみにcustom-listのhtmlは下の通りで、中身はほぼulをラップしているだけ。

<link rel="import" href="custom-list-item.html">
<polymer-element name="custom-list">
    <template>
        <style>
        ul#root {
            list-style-type: none;
            position: relative;
        }
        li {
            position: absolute;
        }
        @-moz-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }
        @-webkit-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }

        </style>
        <ul id="root">
            <template repeat="{{item in itemlist}}">
                <li is="custom-list-item" item="{{item}}"></li>
            </template>
        </ul>
    </template>
    <script type="application/dart" src="custom-list.dart"></script>
</polymer-element>

さきほどちらっと出てきたCustomListItem(custom-list-item)は下記のように定義されてる。これもほぼテキストを表示しているだけ。

<polymer-element name="custom-list-item" extends="li">
    <template>
        <style>
        .container {
            padding: 15px;
            background-color: ghostwhite;
            border:solid 1px #dedede;
        }
        .content {
            overflow:hidden;
            padding:5px;
            display:block;
        }
        .text {
            font-family:'Georgia', Times New Roman, Times, serif
        }

        </style>
        <div id="root" class="container">
            <div class="content text">{{item.text}}</div>
        </div>
    </template>
    <script type="application/dart" src="custom-list-item.dart"></script>
</polymer-element>

感想

当初はMutationObserverの存在を知らずにやろうとしてたため、なんか無駄に苦労してた。具体的には下のような流れで詰まってた。

  1. 新しいliを追加するにあたって、その配置を決めるためにレンダリング時のサイズが知りたい!
  2. でもサイズを知るためには一旦liを追加してみるしかない…
  3. 本番環境にliを追加するまえに、仮の環境(visibilityをhiddenにして隠してあるcustom-list)で追加してみてサイズを計ろう!
  4. 仮の環境に追加してもすぐにはDOMに反映されない(おそらくPolymer.dartがそういう動きになってる)から、本番環境に突っ込むタイミングではやっぱりサイズがわからない…

それでどうすればいいかよくわからなくなったので、Stackoverflowで質問してみたところMutationObserverなるものを教えてもらった。結果的にこのMutationObserverのおかげで色々と解決したので、似たようなことをやりたい人のためにサンプルとしてまとめておいた。

2013年12月7日土曜日

Skeleton Project for Google App Engine/Go + Dart

2016/4/30更新

この記事の内容はすでに古く、またGithub上にリポジトリにおいてあるコードも最新のDartに合わせて変わっているため参考にならない点に注意。

Dartも1.0になったことだしそろそろ試す人が増えてきてもいいはず。ということで、Google App Engine上でDartなウェブアプリを作りたい人のためにスケルトンのプロジェクトを作った。バックエンドの言語はGoでフロントエンドはDart。動作確認はDartiumでしかしてないので、他のブラウザで動かしたい人はどっかでdart2js通せばいける…はず? 正直dart2jsほとんど試したことないからよくわからない。

スケルトンプロジェクトについて

Github上にリポジトリを作ったのでそれをCloneするなりForkするなり好きにやってもらえればいいはず。

ちなみに今回は実際にApp Engine上にデプロイしてあるので、そっちでどんなもんか確認することもできる。URLはgae-go-dart-skeleton.appspot.comで、Dartiumでアクセスすればちゃんと見れるはず。なおローカルからDartium上で動かすとこんな感じ(Windows 8.1 + GAE/Go 1.8.8 + Dart SDK 1.0.0.3_r30188)。

スケルトン

スケルトンプロジェクトの動かし方

gae-go-dart-skeletonを動かすには以下の手順を踏む必要がある。

  1. GAE/GoのSDKとDartのSDKをダウンロードしてきてパスを通す
  2. git clone git@github.com:rillomas/gae-go-dart-skeleton.gitする。あるいは自分のとこにForkしてそっちをcloneする
  3. pub getして必要なパッケージを落としてくる
  4. dev_appserver.pyを使ってローカルで動かす
  5. (デプロイする場合は)appcfg.pyでアプリをアップする

pub get

pubはDartに標準でついてくるパッケージマネージャで、pubspec.yamlファイルに必要なものを書くと落としてきてくれる。今回のpubspec.yamlはこんな感じ。

dependencies:
  logging: any
  polymer: any

Polymerとログ用のパッケージしか書いてないけど、それ以外に必要なパッケージはpub側で判断してもってきてくれるからこれで十分。でpubspec.yamlが置いてあるディレクトリでpub getするとこんな感じに実行される。

$ pub get
Resolving dependencies............................
Downloading polymer 0.9.1+1 from hosted...
Downloading polymer_expressions 0.9.1 from hosted...
Downloading shadow_dom 0.9.1 from hosted...
Downloading observe 0.9.1+1 from hosted...
Downloading html5lib 0.9.1 from hosted...
Got dependencies!

ルートディレクトリにpackagesってディレクトリが作られて、そこにパッケージがすべてダウンロードされてくるはず。あとは必要なところにpackagesへのリンクが自動的に張られる。それ以外にもpubspec.lockていうファイルが作られるけど、それはpubがバージョンの管理用に自動生成するファイルなのであまり気にしなくて大丈夫。

App Engine関係

App Engine関係は公式ドキュメントがあるからそっちでよろしく。dev_appserver.pyまわりはここ、appcfg.pyはこことか。

コードについて

今回のはPolymerを使った極めて単純なウェブアプリなんだけど、クライアントサイドで完結するようなアプリだと面白くないのでサーバー側のREST APIを叩いてdatastoreからデータを持ってきてそれを表示している。

サーバーサイド

サーバー側は来訪者の情報を提供するためのvisitorInfo APIを用意している。パスはapi/1/visitorInfoで、そこにGETをかければjsonが返ってくるし、POSTすればサーバー側の数値を書き換えることができる。実際アップしてあるアプリのAPIにアクセスすると現在の来訪者数累計が取得できるはず。やっている処理の内容はserver/main.goの以下のあたり。

func init() {
    http.HandleFunc("/api/1/visitorInfo", handleVisitorInfoRequest)
}

func handleVisitorInfoRequest(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    c.Debugf("Method: %s Url:%s ContentLength: %d\n", r.Method, r.URL, r.ContentLength)
    if (r.Method == "GET") {
        getVisitorInfo(c,w,r)
    } else if (r.Method == "POST") {
        setVisitorInfo(c,w,r)
    }
}

getVisitorInfo()の中でdatastoreからデータの取得とGETへの応答、setVisitorInfo()のなかでPOSTで送られてきたデータのdatastoreへの格納をやっている。datastoreへのget/setは固定の文字列キーでやってるので基本的に間違いはないはずなんだけど、初回にまだ何もsetされる前にgetが走る可能性があるのでNoSuchEntityエラーだけは現状握りつぶしてる。

const (
    visitorInfoKind string = "visitorInfo"
    visitorInfoKey string = "theVisitorInfoKey"
)

type VisitorInfo struct {
    Count int // total number of visitors
}

func getVisitorInfo(c appengine.Context, w http.ResponseWriter, r *http.Request) {
    k := datastore.NewKey(c,visitorInfoKind, visitorInfoKey, 0, nil)
    info := new(VisitorInfo)
    err := datastore.Get(c,k, info)
    // ignore the NoSuchEntityError and return the default value if we don't have the entity stored yet
    if err != nil && err != datastore.ErrNoSuchEntity {
        c.Errorf("%s", err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    outBuf, err := json.Marshal(info)
    if err != nil {
        c.Errorf("%s", err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    size, err := w.Write(outBuf)
    if err != nil {
        c.Errorf("%s", err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    c.Debugf("Wrote %d bytes as response\n", size)

}

func setVisitorInfo(c appengine.Context, w http.ResponseWriter, r *http.Request) {
    buf, err := ioutil.ReadAll(r.Body)
    if err != nil {
        c.Errorf("%s", err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    var info VisitorInfo
    json.Unmarshal(buf, &info)
    k := datastore.NewKey(c,visitorInfoKind, visitorInfoKey, 0, nil)

    _,err = datastore.Put(c,k,&info)
    if err != nil {
        c.Errorf("%s", err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

クライアントサイド

クライアント側ではMainApp(index.htmlから参照されてるWeb Component)が表示された段階でサーバーに対してGETを発行して、結果をもとに自身の内容を更新している。コード的にはclient/lib/components/main_app.dartの以下のあたり。

    @override
    void enteredView() {
        super.enteredView();

        var domain = ServerChannel.generateRootDomain(window.location);
        ServerChannel.getVisitorInfo(domain).then((info) {
            info.count++;
            visitorInfo = info;

            // update the visitor info on the server side
            ServerChannel.sendVisitorInfo(info, domain);
        });
    }

enteredView()はWeb Component側がdocumentに挿入されたときに呼ばれてくる関数で、今回はそこでvisitorInfo APIへGETを投げている。サーバーから今までの来訪者数を取得して、自分の分を加算して表示、更にサーバー側の来訪者数をPOSTで更新するところまで通してやっている。

ちなみにこのやり方だと来訪者数が一瞬だけゼロ表示されてしまうので、情報がもらえるまでは表示そのものを隠すとかした方が美しいのかも。めんどうだからやってない。

実際にHttpリクエストを投げる部分はすべてServerChannelというクラスで隠蔽している。コード的にはclient/lib/services/server_channel.dart

class ServerChannel {

    static Future<VisitorInfo> getVisitorInfo(String domain) {
        var url = "${domain}${visitorInfoApiPath}";
        Logger.root.info("getting from ${url}");
        var task = new AsyncGet<int>();
        var f = task.request(url, (res) {
            Logger.root.info("response: $res");
            int count = -1;
            if (res.isEmpty) {
                Logger.root.warning("received empty string");
                return count;
            }
            Map map = JSON.decode(res);
            var info = new VisitorInfo.fromJson(map);
            return info;
        });
        return f;
    }

    static void sendVisitorInfo(VisitorInfo info, String domain) {
        var request = new HttpRequest();
        var url = "${domain}${visitorInfoApiPath}";
        Logger.root.info("Sending info to ${url}");
        request.open("POST", url);
        var str = JSON.encode(info);
        request.send(str);
    }

    // Generate the root domain from location information
    static String generateRootDomain(Location location) {
        return "${location.protocol}//${location.host}/";
    }

    static const string visitorInfoApiPath = "api/1/visitorInfo";
}

内部で使っているAsynGetていう独自クラスがFutureやらCompleterやらを駆使して非同期なコードをDartっぽくなるようにラップしてる。

class AsyncGet<Output> {
    Future<Output> request(String url, Output process(String response)) {
        HttpRequest.request(url).then((r) {
            if (r.readyState == HttpRequest.DONE &&
                (r.status == 200)) {
                Output out = process(r.responseText); // convert the raw response text
                _completer.complete(out);
            }
        });
        return _completer.future;
    }

    Completer<Output> _completer = new Completer<Output>();
}

ServerChannelを経由してサーバーから取得できた来訪者情報は、visitorInfo変数に代入された段階で自動的にバインド先に反映される。これはPolymerのおかげ。その辺のバインドが書いてあるのはMainAppのビューに相当するclient/lib/components/main_app.html。

<polymer-element name="main-app">
    <template>
        <h2>Welcome to my Dart Skeleton</h2>
        <p>You are the #{{visitorInfo.count}} visitor!</p>
    </template>
    <script type="application/dart" src="main-app.dart"></script>
</polymer-element>

自分でDOMいじる必要がないから楽。

雑感

App Engine + Dartでもわりと普通に動いてる印象。ただこっちで試したところ、Channel APIをjs-interopでラップするとデプロイしたときに"Uncaught Closure call with mismatched arguments: function 'call'"とかいう謎のエラーでうまく動かないことがわかってる。でもなぜかローカルの環境だとちゃんと動く。

DartにPolymerが無くてWeb UIだった頃から追ってるけど、Polymerは素直に書けてちゃんと動くしいいと思う。Web UIの頃はところどころ未完成でいまいちだった。個人的には@observableとかのアノテーションを書くのはちょっと違和感があるんだけど、たぶん慣れの問題。あとは早めにWeb AnimationsとPolymer UI Elementsを移植してもらえると助かるかな。

そんな感じ。Dart VMはいずれChrome stableにマージされる予定らしいし、dart2jsの性能もメリメリ向上してるのでみんなどんどんDart書くといいよ。

Dart Advent Calendar 2013 12月7日担当

2013年10月20日日曜日

Creating a custom class with an event stream

Dartでウェブアプリを作る際、ある自作のクラスから何らかのイベントを飛ばしたいときがある。そのクラスが自作のWeb Componentの場合、Polymer.dartの@observableやら**Changedイベント関数を定義すればだいたいそれでどうにかなる。というか実際いまのところそれでなんとかなってる。

問題になってくるのはUIの直接絡まないクラスでイベントを定義したい場合で、そのような場合はどうすればいいのかいまいちよくわからなかった。C#で言うところのeventを定義してそのリスナーを複数登録したいだけなんだけどどうすりゃいいの、ていう状態。

その辺を改めて調べて、だいたいのやり方が把握できた気がするのでここに記しておく。なお、試したバージョンはDart SDKの0.8.1.2_r28355とGoogle App Engine(Go) の1.8.6.1。

Channel APIをラップする

今回実際に必要になったので作ったのがGoogle App Engine Channel APIのクライアントサイドJavascriptをラップするクラス。Google App EngineのChannel APIは今のところJavascript用のクライアントサイドスクリプトしか用意してないため、Dartから使おうとするとjs-interopあたりを駆使して自分でラップする必要がある。たしか非公式なパッケージもどっかにあったけど、どうせそんなアップデートされないだろうし公式にDartがサポートされるまでは自分でラップした方が確実だろう、ということで自分でやった。

Server Channel

下のServerChannelがChannel API経由のメッセージ受信、及びサーバーへのリクエスト送信を担当するクラス。Web Socketと違ってChannel APIは片方向なので、サーバーへのリクエストは都度HTTPで送る必要がある。サーバーへのインタフェースは一箇所に集めたかったからそのリクエストを出す部分もこいつに担当させてる。クラス内で使われてるClientとUserは単なるjsonをクラスにマッピングしたものと考えてもらえればOK(つまりそのクラス自体には現状何も機能が無い)。

import 'dart:async';
import 'dart:html';
import 'dart:json' as json;
import 'package:js/js.dart' as js;

/**
 * Interface class to the server
 */
class ServerChannel {

    ServerChannel(this.socket, this.client, this.user);

    Client client;
    Object socket;
    User user;

    static Future<ServerChannel> connect(String domain) {
        var request = new HttpRequest();
        var url = "${domain}${clientApiPath}";
        print("sending to ${url}");
        request.open("POST", url);
        var str = json.stringify(new Client(-1,"",""));
        print(str);

        ServerChannel chan = new ServerChannel(null, null, null);
        chan._controller = new StreamController<Object>.broadcast();
        // handler for the server response
        request.onReadyStateChange.listen((_) {
            if (request.readyState == HttpRequest.DONE &&
                (request.status == 200 || request.status == 0)) {
                print(request.responseText);
                Map map = json.parse(request.responseText);
                var client = new Client.fromJson(map["Client"]);
                var user = new User.fromJson(map["User"]);

                // connect to the server via channel api
                var appengine = js.context.goog.appengine;
                var channel = new js.Proxy(appengine.Channel, client.channelToken);
                var socket = channel.open();

                // setup handlers for the channel
                socket.onopen = new js.Callback.once(() {
                    print("channel opened");
                });

                socket.onmessage = new js.Callback.many(chan._onMessage);

                socket.onerror = new js.Callback.many(chan._onError);

                socket.onclose = new js.Callback.once(() {
                    print("channel closed");

                    // disopose Callback.many explicitly
                    socket.onmessage.dispose();
                    socket.onerror.dispose();
                });

                chan.socket = socket;
                chan.client = client;
                chan.user = user;
                chan._completer.complete(chan);
            }
        });
        request.send(str);
        return chan._completer.future;
    }

    void listen(Function onMessage) {
        _controller.stream.listen(onMessage);
    }

    void _onMessage(Object message) {
        print(message);
        // send a meesage to all the listeners
        // We need to pass the actual data in order to avoid
        // "Proxy has been invalidated" error
        _controller.add(message.data);

    }

    void _onError(Object message) {
        print(message);
        // send a message to all the listeners
        // _controller.addError(message);
    }

    void sendRemark(Remark remark, String domain) {
        var request = new HttpRequest();
        var url = "${domain}${remarkApiPath}";
        print("sending to ${url}");
        request.open("POST", url);
        var str = json.stringify(remark);
        print(str);
        request.send(str);
    }


    static const string remarkApiPath = "api/1/remark";
    static const string clientApiPath = "api/1/client";
    Completer<ServerChannel> _completer = new Completer<ServerChannel>();
    StreamController<Object> _controller;
}

ServerChannelだけだとわかりづらいので、それを実際に使ってる部分のコードも載せる。 中でやってることは以下の通り。

  1. 指定されたドメインに対してChannel APIの接続リクエストを送る
  2. 接続が成功したらServerChannelのインスタンスに対してメッセージ受信時のハンドラ(_onMessage)を登録する
class SampleClient {
    void init() {
        // connect to server
        print("Sending connect request");
        var domain = window.location.href;
        ServerChannel.connect(domain).then((chan) {
            _channel = chan;
            _channel.listen(_onMessage);
        });
    }

    void _onMessage(Object message) {
        print("onmessage at channel: ${message}");
    }

    ServerChannel _channel;
}

Stream Controller

先ほどのServerChannelでイベントを扱っているキモの部分はStreamControllerで、それ以外はあまり関係無い。StreamControllerの詳細はドキュメントの方を読んでもらうとして、簡単に言うとStreamControllerはDartのイベントまわりのインフラであるStreamを叩くための補助クラス。イベントの下回りはStream側で担当してくれるので、Streamを簡単に任意のものと組み合わせられるようにしてくれるのがStreamController。

ServerChannel.connect関数

実際にStreamController経由でイベントを飛ばすにはまずサーバーからChannel API経由でメッセージを受け取れる必要があって、その辺の準備をやっているのがconnect関数。connect関数はだいたい以下の流れになってる。

  1. ServerChannelクラスを初期化する。中のStreamControllerは複数のリスナーを想定してるのでbroadcast関数で複数リスナー用のモードにする。
  2. 指定されたドメインに対してクライアント情報を送る。上のコードではCompleterを使ってFutureを返しているので、connect関数はすぐに抜けて実際にリクエストを送って返答を待つ部分は非同期に実行される。
  3. サーバーのレスポンスを待って、レスポンスからChannel接続用のクライアントIDとその他ユーザー情報を取得する
  4. js-interopを使ってChannel APIのJavascriptコードを呼び出す。ここではChannel APIのソケットを初期化して内部のハンドラを登録してる。
  5. 完成したServerChannelインスタンスをFutureの先で待ってるコードに渡す

js-interopの落とし穴

上の手順を踏むとメッセージが来るたびにServerChannel内部の_onMessageが呼ばれるようになって、あとはそれを外部のリスナーへ通知してやればおしまい。それをやってるのがStreamControllerのadd関数なんだけどここで一点注意が必要。

どうもjs-interopから戻ってきたオブジェクトそのものへの参照は保持してはダメらしく、保持しようとすると"Exception: Proxy js-ref-8 has been invalidated. "みたいなエラーが飛ぶ。そこで上のコードではオブジェクト本体ではなく、オブジェクトが持つデータをリスナーに渡すことでエラーを回避してる。

まとめというか感想

  • UIの絡まない汎用的なイベントはStreamController経由で飛ばしましょう。UIが絡む場合は素直にPolymerの機構を使いましょう。
  • js-interop使うときはオブジェクトの参照に注意。それさえ気をつければJavascriptのライブラリもDartから割と普通に使えるかも。

2013年6月14日金曜日

Playing with Dart Web UI again

最後にWeb UIを触ってからしばらく経って、Dart自体のライブラリやらも色々と大きな変更があったようなのでまた久しぶりにWeb UIまわりを触ってみた。作ったものは前回とおなじというか、それを修正・拡張したもの。使ったのはDart Editor version 0.5.16_r23799とWeb UI version 0.4.11+1で、できあがったサンプルはここ

できたもの

動物の名前と色をリストで表示するだけのもの。


表示した直後

スライダー動かすと色も変わる

前と比べてどう変わったか

前やったときにはできなかった(もしくはめんどくさそうでやらなかった)ことがいくつかできるようになってた。具体的には以下の2つ。

  • 子のコンポーネントと親のコンポーネントをイベントでつなげられるようになった

    前試したときはちょうどStreams APIまわりができたばっかりくらいのタイミングで、それをどうカスタムなイベントに転用すればいいかよくわからなかった。その後情報がいくらか整備されてEventStreamProviderとCustomEvent使えばできるようになることがわかった、ていう感じ。個人的にこれは結構大きい。

  • input type="range"とのバインドができるようになった

    前ためしたときは確かinput type="range"とのバインドはうまく動かなかったけど、今回試したら普通に動いてた。

大きな変更があったのはanimal-summaryとcolor-editorの中だけなので、そこだけ解説する。

animal-summary

<!doctype html>
<html>
    <head>
        <link rel="import" href="color-editor.html">
    </head>
    <body>
        <element name="animal-summary" constructor="AnimalSummaryViewModel" extends="div">
            <template>
                <div>{{header}}</div>
                <color-editor id="pallet" model="{{model.color}}"></color-editor>
                <canvas id="summary" width="300" height="200"></canvas>
            </template>
            <script type="application/dart">
            import 'dart:json' as json;
            import 'package:web_ui/web_ui.dart';
            import 'package:dart_web_ui_sample/src/AnimalSummary.dart';
            import 'package:dart_web_ui_sample/src/Color.dart';

            class AnimalSummaryViewModel extends WebComponent {

                AnimalSummaryViewModel() {
                }

                String get header => "${model.name}:${model.count}";

                void increment(e) {
                    model.count++;
                }

                void created() {
                }

                void inserted() {
                    _initializeCanvas(model.color);

                    var p = query("#pallet");
                    p.xtag.onColorChanged.listen((event) {
                        // sync canvas color with the editor color
                        String str = event.detail;
                        var obj = json.parse(str);
                        Color c = new Color(obj["r"], obj["g"], obj["b"]);
                        _initializeCanvas(c);
                    });
                }

                void _initializeCanvas(Color color) {
                    var c = query("#summary");
                    var ctx = c.getContext('2d');
                    ctx.fillStyle = color.cssString;
                    ctx.fillRect(0,0,300,200);
                }

                AnimalSummary model;
            }
            </script>
        </element>
    </body>
</html>

前と変わったところで重要なのはinserted()の中。inserted()はanimal-summaryがDOMに追加された段階で呼ばれてくるんだけど、上のコードではその段階でcolor-editorが独自に定義したonColorChangedイベントにリスナーを登録してる。これの意図はcolor-editorの中で色に変更が加わったら、それをanimal-summary側で察知して表示されてる色を更新したい、というもの。

途中であらわれる謎のxtagというプロパティはこの辺に記述がある。queryから返ってきたElementはただのdivなので、自分で独自に定義したonColorChangedにアクセスするにはxtagを経由して実際のWeb Componentインスタンスにアクセスしないといけない、らしい。

ちなみに、そのうちinsertedとかはなくなるかも

color-editor

<!doctype html>
<html>
    <body>
        <element name="color-editor" constructor="ColorViewModel" extends="div">
            <template>
                <ul>
                    <li>R:<input type="range" bind-value="red" min="0" max="255" ><input type="text" readonly bind-value="red"></li>
                    <li>G:<input type="range" bind-value="green" min="0" max="255" ><input type="text" readonly bind-value="green"></li>
                    <li>B:<input type="range" bind-value="blue" min="0" max="255" ><input type="text" readonly bind-value="blue"></li>
                </ul>
            </template>
            <script type="application/dart">
            import 'dart:html';
            import 'dart:async';
            import 'dart:json' as json;
            import 'package:web_ui/web_ui.dart';
            import 'package:dart_web_ui_sample/src/Color.dart';

            class ColorViewModel extends WebComponent {
                ColorViewModel() {
                }

                static const EventStreamProvider<CustomEvent> colorChangedEvent = 
                    const EventStreamProvider<CustomEvent>('colorChanged');

                Stream<CustomEvent> get onColorChanged => colorChangedEvent.forTarget(this);

                String get red => "${model.red}";
                set red(String r) {
                    int nr = int.parse(r);
                    if (model.red == nr) {
                        return;
                    }
                    model.red = nr;
                    _notifyColorChange(model);
                }

                String get green => "${model.green}";
                set green(String g) {
                    int ng = int.parse(g);
                    if (model.green == ng) {
                        return;
                    }
                    model.green = ng; 
                    _notifyColorChange(model);
                }

                String get blue => "${model.blue}";
                set blue(String b) {
                    int nb = int.parse(b);
                    if (model.blue == nb) {
                        return;
                    }
                    model.blue = nb;
                    _notifyColorChange(model);
                }

                void created() {
                }

                void inserted() {
                }

                void _notifyColorChange(Color newColor) {
                    var str = json.stringify(newColor);
                    CustomEvent ev = new CustomEvent('colorChanged', canBubble: true, cancelable: true, detail: str);
                    dispatchEvent(ev);
                }

                Color model;
            }
            </script>
        </element>
    </body>
</html>

こっちで重要なのはEventStreamProviderとCustomEventでカスタムなイベントを定義してるところと、notifyColorChangeで実際にイベントを発行してるところ。

定義するところはもう作法が決まってるので、作法にのっとって書けば大丈夫。個人的にこの辺はWPFでDependencyPropertyを定義するときと少し似てるなーと思った。イベントを発行するところも大体作法通りなんだけど、1つ注意が必要なのはdetailの引数を渡すところ。これはドキュメントを見るとObject型を受け付けてるんだけど、実際は勝手に文字列に変換されるっぽい。最初インスタンスをそのまま渡したらリスナー側で例外が飛んで困った。そのため、今は先にこちらでjson文字列に変えてから渡してる。もちろんリスナー側でもjson文字列が来ることを前提でparseしてる。

なお、Colorをjson化するためColor側ではtoJson()ていう専用の関数を定義している。stringifyの中でそのtoJsonが呼ばれてるっぽい。

感想

しばらく放置してた間に色々変化があったようで、当初やりたかったことができるようになってきた印象。もう少し遊んでみるかも。

2012年12月12日水曜日

Playing with Dart Web UI

以前DartでのWeb Componentsの記事を書いてからまだ一月ちょっとくらいしか経ってないけど、Web Componentsパッケージ側で色々と変化があったせいですでに前の情報が役立たずになってしまっている。ということで最新情報で改めて書く。

何が変わったか

以下の通り。詳しくはDartの開発者が書いた記事へ。

  • そもそもパッケージの名前がweb_componentsからweb_uiに変わった。
  • バインディングやらの記法が変わった
  • Web Componentsとは直接関係ないけど、パッケージの構成が少し変わった。

逆にdwc.dartでのコンパイル方法やらDartiumでの閲覧方法は変わってないのでそこは前回と同じでいける。

コードの修正版

パッケージにしたものをGithubのリポジトリにアップした。ここではそれぞれをもう少し詳しく見ていく。

index.html

このindex.htmlはこのサンプルのエントリーポイントで、Dartのmainが記述されてる。以前は一つのindex.htmlに全部まとめてたんだけど、コンポーネント間の依存関係がうまく解決できなかったので今回は細かくファイルを分けた。

<!doctype html>
<html>
    <head>
        <title>Dart Web UI Sample</title>
        <link rel="stylesheet" type="text/css" href="stylesheets/bootstrap.min.css">
        <link rel="shortcut icon" type="image/png" href="images/favicon.png">
        <link rel="components" href="components/animal-summary.html">
        <script type="text/javascript" src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script> 
    </head>
    <body>
    <div id="navigationArea" class="navbar">
        <div class="navbar-inner">
            <div class="container">
                <a class="brand" href="#">Dart Web UI Sample</a>
            </div>
        </div>
    </div>
    <div id="animalListArea">
        <ul>
            <template iterate='a in animalList'>
                <animal-summary model="{{a}}" ></animal-summary>
            </template>
        </ul>
    </div>
    <script type="application/dart">
    import 'package:dart_web_ui_sample/src/AnimalSummary.dart';
    import 'package:dart_web_ui_sample/src/Color.dart';

    List<AnimalSummary> animalList = <AnimalSummary>[
        new AnimalSummary("aardvark",0, new Color(140,70,20)),
        new AnimalSummary("giraffe",1, new Color(255,255,0))
        ];
  
    void main() {}
    </script>  
    </body>
</html>
dwc.dartでコンパイルしてからDartiumで開いたindex.html

記法やらを新しくしたのと、色を示すColorクラスを追加したのが前と違う点。

animal-summary.html

AnimalSummaryクラスのViewModelとViewに相当するのがこのhtmlファイル。templateタグ内のマークアップがViewで、その下にDartで書かれているクラスがViewModel。

<!doctype html>
<html>
 <head>
  <link rel="components" href="color-editor.html">
 </head>
 <body>
     <element name="animal-summary" constructor="AnimalSummaryViewModel" extends="div">
         <template>
             <div>{{header}}</div>
             <color-editor model="{{model.color}}"></color-editor>
             <canvas id="summary" width="300" height="200"></canvas>
         </template>
         <script type="application/dart">
         import 'package:web_ui/web_ui.dart';
         import 'package:dart_web_ui_sample/src/AnimalSummary.dart';

         class AnimalSummaryViewModel extends WebComponent {

             AnimalSummaryViewModel() {
             }

             String get header => "${model.name}:${model.count}";

             void increment(e) {
                 model.count++;
             }

             void created() {
                print("created.");
             }

             void inserted() {
                 _initializeCanvas("#summary");
                 print("inserted.");
             }

             void _initializeCanvas(String id) {
                 var c = query(id);
                 var ctx = c.getContext('2d');
                 ctx.fillStyle = model.color.cssString;
                 ctx.fillRect(0,0,300,200);
             }

             AnimalSummary model;
         }
         </script>
     </element>
 </body>
</html>

Viewとしてはdiv、canvas、それと別に定義したcolor-editorというComponentを使用している。Canvasに関してはViewModel側でinserted関数を実装することで挿入されたタイミングに合わせて初期化を行なっている点が重要。

試したかんじtemplate内の子要素についてはinsertedのタイミングならqueryで取得できる。ただcreatedでqueryしようとしても現状はnullしか返ってこない。

color-editor.html

color-editorはanimal-summaryのtemplateで使っている色のエディタ。本当はスライダー(input type="range")を使いたいんだけど、現状はWeb UI側がスライダーとのデータバインディングをサポートしてないみたいで今はテキストボックスを使ってる。

<!doctype html>
<html>
 <body>
     <element name="color-editor" constructor="ColorViewModel" extends="div">
         <template>
          <ul>
              <li>Red:<input type="text" bind-value="red" /></li>
              <li>Green:<input type="text" bind-value="green" /></li>
              <li>Blue:<input type="text" bind-value="blue" /></li>
          </ul>
         </template>
         <script type="application/dart">
         import 'package:web_ui/web_ui.dart';
         import 'package:dart_web_ui_sample/src/Color.dart';

         class ColorViewModel extends WebComponent {
             ColorViewModel() {
             }

             String get red => "${model.red}";
             String set red(String r) => model.red = int.parse(r);

             String get green => "${model.green}";
             String set green(String g) => model.green = int.parse(g);

             String get blue => "${model.blue}";
             String set blue(String b) => model.blue = int.parse(b);

             void created() {

             }

             void inserted() {
              print("r: ${model.red} g: ${model.green} b: ${model.blue}");
             }

             Color model;
         }
        </script>
     </element>
 </body>
</html>

現状は値をViewModel側に渡すタイミング(WPFで言うところのUpdateSourceTrigger)を制御できないので、一文字消すとその段階でViewModelへの値の引渡しが行われる。そのせいで、テキストボックス内の数字を全部消すとnullか何かが渡ってきてエラーが起きる。

Redのテキストボックスを空にした瞬間にエラーが飛ぶ

本当ならテキストボックスからフォーカスが外れた段階で値の引渡しをやりたい。WPFみたいにタイミングいじれるようになるのか?

Color.dart

ColorはColorViewModelのModelに相当するクラス。見たまんまなので特にいうことはない。あえていえばcssStringというプロパティでCSS用の文字列を返すようにした、という点。

library dart_web_ui_sample;

class Color {
    Color(this.red, this.green, this.blue);
    int red;
    int green;
    int blue;
    String get cssString => "rgb(${red},${green},${blue})";
}

AnimalSummary.dart

本サンプルの主役を張ってるクラス。これも見たまんま。

library dart_web_ui_sample;
import 'package:dart_web_ui_sample/src/Color.dart';

class AnimalSummary {
    AnimalSummary(this.name, this.count, this.color);
    String name = "No Name";
    int count = 0;
    Color color;
}

感想

着実に良くなってる感じ。記法はシンプルになったし、子要素へのqueryも前までできなかったけどできるようになった。書いてて気になったのは下の点くらい。

  • WPFでいうところのUpdateSourceTriggerみたいに値を引渡すタイミングをいじりたい。
  • WPFでいうところのConverter的な仕組みで文字列 to 数値の変換のような働きをViewModelの外に出したい。今回でいえば色の数値をViewModelの中でint.parse()してるのをConverter的なクラスにやらせて、ColorViewModelはあくまでintしかインタフェースを持たないようにしたい。
  • ColorViewModelでの色の変更をAnimalSummaryViewModelに伝えてCanvasの色も更新したいんだけどどうすりゃいいんだ。

でもやっぱhtmlでも部品ごとにhtmlファイル分けて、それぞれで完結するViewとViewModelを書けるのは気持ちいい。

2012年11月4日日曜日

Playing with Dart Web Components

2012/12/27更新:この記事は一部情報が古いので、こちらと併せて読むといいかも。

Dart Web Componentsの記事が10月にアップされて、それからしばらくはバグってたりでまともに使えなかったんだけど、今日試したらなんとか使えた。で、Web Componentsマジさいこーってなったのでちょっとここで参考用に情報を残しておこうと思う。試した環境はUbuntu Linux 12.10(64 bit)。

手順

注:最近はDartで互換性を捨てる変更がいろいろと入ってるため、タイミングによっては同じやり方でうまく行かないかもしれない

Web Componentsを試すにはいろいろと手順が必要で、順番的には以下の通りになる。

  1. Dart SDK、Dartiumの準備
  2. テスト用のパッケージの生成
  3. htmlの記述、およびdwc.dartによるコンパイル
  4. Dartiumでの閲覧

それぞれ順を追って説明する。

Dart SDK、Dartiumの準備

まずすべきは最新のDartiumとSDKを落としてくること。Editorごと落とすと全部含まれているのでそれが楽かも。場所はここ(64bit版)。なぜ最新版でないといけないかというと、安定版では最新のDartの変更が取り込まれていないため、あとの手順で失敗してしまうから。(2012/11/4現在)

Editorを落としたら解凍してでてきたdartディレクトリにパスを通すんだけど、自分は~/binの下にdartディレクトリをおいて、~/.bashrcに以下のように書いてる。

export PATH=$PATH:~/bin/dart/dart-sdk/bin

これで必要な実行ファイル、特にdartとpubにパスが通る。

テスト用パッケージの生成

Dartはコードのパッケージ化を推進していて、今回使うWeb Componentsももちろんパッケージ化されている。パッケージ化されたコードを使う側もパッケージ化したほうが何かと都合がいいので、その作法に従うことにする。パッケージ化の詳細な手順は公式なドキュメントに任せるとして、ここでは最低限必要な部分だけを説明する。

  1. パッケージのルートディレクトリの生成

    パッケージ関連のファイルを格納する適当なディレクトリを作る。今回はdart-web-components-testとする。

  2. pubspec.yamlの作成

    pubspec.yamlというファイルはパッケージの情報、例えば名前やら依存する他のパッケージやらの情報を記述するファイルで、Web Componentsのパッケージを持ってくるためにこれを記述しなければならない。といっても内容は単純で、以下のとおり。

    name: dart-web-components-test 
    dependencies:
      web_components: any 
    

    パッケージの名前と、あとweb_componentsというパッケージに依存してることを示している。内容はこれだけ。

  3. pubによる依存パッケージのダウンロード

    今度は先ほど作ったpubspec.yamlを使って依存するパッケージをダウンロードする。dart-web-components-testのしたにpubspec.yamlを置いて、以下のコマンドをうつ。

    $ pub install
    Resolving dependencies...
    Downloading web_components 0.2.5+2 from hosted...
    Downloading js 0.0.7 from hosted...
    Downloading html5lib 0.0.19 from hosted...
    Dependencies installed!
    $ 
    

    これでWeb Componentsを使うのに必要なパッケージがダウンロードされて、自動的に作られたpackagesディレクトリの下に置かれる。ラクチン。ちなみにpackagesの中身は下のようになってるはず。

    $ ls packages/
    args  html5lib  js  logging  unittest  web_components
    

htmlの記述、およびdwc.dartによるコンパイル

必要なパッケージがそろったので、今度は実際にコードを書いていく。Web Componentsなコードの書き方はこの記事に詳しく書いてあるのでそっちを見るのをおすすめする。MVVM(Model-View-View Model)なコードに経験がある人ならあっさり理解できると思うけど、そうでない人にはもしかしたらちょっとわかりづらいかも。幸い俺は経験があるので問題なかった。

index.html

以下にサンプルのコードを示す。内容は動物のリストを表示するという単純なもの。中ではWeb Componentsの要素(element, template, iterate等)を色々と使ってる。

<!doctype html>
<html>
    <head>
        <title>Dart Web Components</title>
        <link rel="stylesheet" type="text/css" href="dart-web-components-test/bootstrap.min.css">
        <script src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script> 
    </head>
    <body>
    <!-- Summary of Animal -->
    <element name="animal-summary" constructor="AnimalSummaryViewModel" extends="div">
        <template>
            <div>{{header}}</div>
            <button data-action="click:increment">Click to increment</button>
            <input type="text" data-bind="value:name" placeholder="type name here">
        </template>
        <script type="application/dart">
        import 'package:web_components/web_component.dart';

        /// Model class of animal
        class AnimalSummary {
            AnimalSummary(this.name, this.count);
            String name = "No Name";
            int count = 0;
        }

        /// View Model class of animal
        class AnimalSummaryViewModel extends WebComponent {

            /// read-only header
            String get header => "${_animal.name}:${_animal.count}";

            void increment(e) {
                _animal.count++;
            }

            String get name => _animal.name;
            String set name(String s) => _animal.name = s;

            /// accessor for the underlying model
            AnimalSummary get model => _animal;
            AnimalSummary set model(AnimalSummary ts) => _animal = ts;

            AnimalSummary _animal;
        }
        </script>
    </element>

    <div id="navigationArea" class="navbar">
        <div class="navbar-inner">
            <div class="container">
                <a class="brand" href="#">Dart Web Components</a>
            </div>
        </div>
    </div>
    <div id="animalListArea">
        <ul>
            <template iterate='a in animalList'>
                <animal-summary data-value="model: a"/>
            </template>
        </ul>
    </div>
    <script type="application/dart">

    // create a list of animals to show
    List<AnimalSummary> animalList = <AnimalSummary>[
        new AnimalSummary("aardvark",0),
        new AnimalSummary("giraffe",1)
        ];
  
    void main() {}
    </script>  
    </body>
</html>

コードが書けたら今度はそれを一旦コンパイルしないといけない。これは最終的には不要になる(と期待している)けど、現状は必要みたい。ということで以下のコマンドをうつ。

$ dart --package-root=packages/ packages/web_components/dwc.dart index.html 
Total time spent on index.html                               -- 270 ms
$ ls
_index.html.animal_summary.dart  _index.html_bootstrap.dart  packages
_index.html.dart                 bootstrap.min.css           pubspec.lock
_index.html.html                 index.html                  pubspec.yaml

これでindex.htmlの解析がおこなわれて、無数のよくわからないファイル(_index.html*)が生成される。

Dartiumでの閲覧

これで必要なファイルが揃ったのでいよいよDartiumを起動して結果を確認する。Dartiumを起動するときは下のコマンドを使用する。

$ ~/bin/dart/chromium/chrome --user-data-dir --enable-experimental-webkit-features --allow-file-access-from-files &

これでWeb Componentsが有効化されたDartiumが起動する。ついでにローカルファイルアクセスを有効化するフラグもつけた。この状態で先ほど生成された_index.html.htmlを開くと下のようになる。

テキストボックス上で名前をいじれば表示されている名前も変わるし、ボタンを押せば数字も増える。

AnimalSummaryViewModelのプロパティと各種のView、例えば名前とテキストボックスがバインド(Web Component的には別の用語かも)されていて、名前が変更された場合は自動的にその変更が反映されるようになっている。

またulの中のli要素も"iterate='a in animalList'"という書き方でリストから自動的に生成させることができる。その生成される要素もelementとして定義したanimal-summary要素で、すごいすっきりと宣言的に書くことができている。

まとめ

ということでDart Web Componentsを試してみた。JavaScriptではBackbone.jsとかで似たようなことはできるっぽいけど、やっぱり公式にサポートしてもらえると大変助かる。あと最近はC# + WPF + MVVMを書くことが多かったので、Webでも似たような書き方ができるというのは非常にありがたい(というかそうでないとめんどくさすぎてヤル気が起きないくらい)。今後もWeb Componentsには頑張ってほしい。

2012年8月18日土曜日

Playing with Play!

ここ何回か紹介してたWebSocketのサンプル、今度はScala (Playframework) + Dartに移植したので公開する。Playframeworkを使ってる関係でファイルが多いのでGithubのリポジトリにアップしてある。

わざわざGoで書いたのになんで今度はPlayframeworkに移植したかというと、ローカルで遊ぶんじゃなくてどこかのサーバーでプログラムを動かしたいと思ったから。で色々調べたらHerokuが良さそうだ!ってんでHerokuにGo版をアップしたら、実はHerokuがWebSocketにまだ対応してないことが発覚。しょうがないから他の候補を探したらdotCloudというサービスを発見して、でもそこはGoには対応してないからじゃあ今度はScala(というかPlayframework)で書きましょう、という流れになった。

Scalaとの戦い

今回はじめてScalaに触ったけど、これが想像以上に苦戦した。苦戦した点としては以下。

  • 型推論のおかげで(せいで?)色んなところが省略できてしまう。戻り値があるのにreturnは書いても書かなくてもいいとか、引数の無い関数はカッコが不要とか、そういった諸々のルールのおかげで何が型で何が関数かすら最初はわかりづらかった。
  • 記号の羅列、たとえば">>>"とか"?"とかも関数として定義できるから、importしたライブラリのリファレンスをちゃんと探さないとプログラムの意味がわからない。今回のPlayframeworkで言えば"a ? b"という記述の意味するところがわからなくて(結局はAkkaのaskという関数コールを意味してた)、その上検索もしづらいからかなり解読に苦労した。
  • いきなりPlayframeworkのコードはハードル高かった。フレームワーク自体が色々高度なことをやってるから、Scalaはじめたばっかりの人間が見ても厳しいものがある。最初はobject定義とclass定義の違いすらわからなくて、本当はobject定義の方のリファレンス見なきゃいけないのにずっとclassの方の定義をみてて、関数の定義のってねーなーどういうこと? とかやってた。

Application.scala

サーバー側で行う各種の処理を記述してあるのがこのApplication.scalaで、ここで定義された関数をroutesファイルでURLと結びつけることでウェブサイトを構築していく。

indexはルートのURLにアクセスしてきたクライアントに対してUUIDを生成して、そのUUIDを埋めこんだhtml+scriptを返すということをやっている。また、connectはWebSocketでアクセスしてきたクライアントに対してIterateeとEnumeratorの組を生成して返す、ということをやっている。

package controllers

import play.api._
import play.api.mvc._
import play.api.data._
import play.api.libs.json._
import play.api.data.Forms._
import models.Room
import models.Remark

object Application extends Controller {

  private val initialString = """
      _,,,,._                  、-r
   ,.','" ̄`,ゝ _,,,_   _,,,_   _,,,,__,. | |  _,,,,,_
  { {   ,___ ,'r⌒!゙! ,'r⌒!゙! ,.'r⌒!.!"| l ,.'r_,,.>〉
  ゝヽ、 ~]| ゞ_,.'ノ ゞ_,.'ノ ゞ__,.'ノ | l {,ヽ、__,.
   `ー-‐'"   ~    ~  〃 `゙,ヽ ̄` `゙'''"
                 ゙=、_,.〃
  """
 
  def index = Action { implicit request =>
    val uuid = java.util.UUID.randomUUID.toString
    Logger.info("Generated uuid: "+uuid)
    Ok(views.html.index("Remark Presenter", uuid, initialString))
  }

  def connect(userID: String) = WebSocket.async[JsValue] { request  =>
    Logger.info("userID from client: "+userID)
    Room.join(userID) 
  }
}

Room.scala

クライアントとやりとりを行うアクターを定義している。基本的にやっていることは単純で、クライアントをリストに追加して誰かが発言するたびにその内容を全員に伝播する、というもの。

package models

import akka.actor._
import akka.util.duration._

import play.api._
import play.api.libs.json._
import play.api.libs.iteratee._
import play.api.libs.concurrent._

import akka.util.Timeout
import akka.pattern.ask

import play.api.Play.current

object Room {

  lazy val default = Akka.system.actorOf(Props[Room])

  implicit val timeout = Timeout(1 second)

  def join(userID:String):Promise[(Iteratee[JsValue,_],Enumerator[JsValue])] = {
    (default ? Join(userID)).asPromise.map {
      
      case Connected(enumerator) => {
      
        // Create an Iteratee to consume the feed
        val iteratee = Iteratee.foreach[JsValue] { event =>
          val content = (event \ "Remark").as[String]
          val duration = (event \ "Duration").as[String]
          val path = (event \ "Path").as[String]
          val doRotate = (event \ "Rotate").as[Boolean]
          val remark = Remark(content, duration, path, doRotate)
          default ! Talk(userID, remark)
        }.mapDone { _ =>
          default ! Quit(userID)
        }

        (iteratee,enumerator)
      }
        
      case CannotConnect(error) => {
      
        // Connection error

        // A finished Iteratee sending EOF
        val iteratee = Done[JsValue,Unit]((),Input.EOF)

        // Send an error and close the socket
        val enumerator =  Enumerator[JsValue](JsObject(Seq("error" -> JsString(error)))).andThen(Enumerator.enumInput(Input.EOF))
        
        (iteratee,enumerator)
      }
    }
  }
}

case class Remark(content: String, duration: String, path: String, rotate: Boolean)
case class Join(userID: String)
case class Quit(userID: String)
case class Talk(userID: String, remark: Remark)

case class NotifyJoin(userID: String)
case class Connected(enumerator:Enumerator[JsValue])
case class CannotConnect(msg: String)

class Room extends Actor {
 private var clients = Map.empty[String, PushEnumerator[JsValue]]

 def receive = {
  case Join(userID) => {
      // Create an Enumerator to write to this socket
      val channel =  Enumerator.imperative[JsValue]( onStart = self ! NotifyJoin(userID))
      if(clients.contains(userID)) {
        sender ! CannotConnect("This userID is already used")
      } else {
        clients = clients + (userID -> channel)
        
        sender ! Connected(channel)
      }
  }

    case Quit(userID) => {
      clients = clients - userID
      Logger.info(userID + " leaved")
      //notifyAll("quit", userID, "has leaved the room")
    }

  case Talk(userID, remark) => {
      Logger.info(userID + ":" +remark.toString)
   // send to all clients
      val msg = Json.toJson(
          Map(
          "Remark" -> Json.toJson(remark.content),
          "Duration" -> Json.toJson(remark.duration),
          "Path" -> Json.toJson(remark.path),
          "Rotate" -> Json.toJson(remark.rotate)
        )
      ) 
      //Logger.info(msg.toString);
      clients.foreach { 
        case (_, channel) => channel.push(msg)
      }
   }
    
    case NotifyJoin(userID) => {
      Logger.info(userID + " joined")
      // notifyAll("join", userID, "has entered the room")
    }
  }


  // def notifyAll(kind: String, user: String, text: String) {
  //   val msg = JsObject(
  //     Seq(
  //       "kind" -> JsString(kind),
  //       "user" -> JsString(user),
  //       "message" -> JsString(text),
  //       "clients" -> JsArray(
  //         clients.keySet.toList.map(JsString)
  //       )
  //     )
  //   )
    
  //   clients.foreach { 
  //     case (_, channel) => channel.push(msg)
  //   }
  // }
}

routes

Application.scalaで記述されている関数と実際のURLを紐づけているのがこのroutesファイル。一番下の行はpublicディレクトリの下の各種CSS・スクリプトとURLを対応付けしている。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index
GET     /:userID                    controllers.Application.connect(userID: String)
GET     /assets/*file               controllers.Assets.at(path="/public", file)

main.scala.html

クライアント側で実行されるスクリプトのmain部分が含まれたテンプレート。Application.scalaで生成されたuserIDをもとにWebSocketへ接続を行うといった処理がDart側で行なわている。

@(title: String, userID: String)(content: Html)(implicit request: RequestHeader)

<!doctype html>
    <head>
        <title>@title</title>
        <link rel="stylesheet" type="text/css" href="@routes.Assets.at("stylesheets/bootstrap.min.css")">
        <link rel="stylesheet" type="text/css" href="@routes.Assets.at("stylesheets/client.css")">
        <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
        <script src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script> 
    </head>
    <body>
    <script type="application/dart">
    #import("@routes.Assets.at("scripts/App.dart")");
    #import("@routes.Assets.at("scripts/SVGRemarkDisplayer.dart")");

    void main() {
        // initialize displayers
        final int MAX_NUMBER_OF_REMARKS = 15;
        var displayer = new SVGRemarkDisplayer();
        displayer.initialize("#stage", MAX_NUMBER_OF_REMARKS);

        PanelLayoutParameter param = new PanelLayoutParameter("#inputPanel", "#displayArea", "#remarkPanel", "#hideInputPanel", "#hideRemarkPanel");

        var url = "@routes.Application.connect(userID).webSocketURL()";
        print(url);

        App app = new App();
        app.initialize(url, displayer, param);
    }
    </script>
        @content
    </body>
</html>

サーバー動作

上記のキモとなるロジックの部分とその他フレームワーク的に必要なものを用意してPlayframeworkに実行させ、Dartiumでlocalhost:9000にアクセスすると実行されているのを確認することができる。下は実際にPlayframworkを実行させたときのログ。

$ play
[info] Loading project definition from /home/masato/desk/programs/xclamm-j/project
[info] Set current project to xclamm-j (in build file:/home/masato/desk/programs/xclamm-j/)
       _            _ 
 _ __ | | __ _ _  _| |
| '_ \| |/ _' | || |_|
|  __/|_|\____|\__ (_)
|_|            |__/ 
             
play! 2.0.3, http://www.playframework.org

> Type "help play" or "license" for more information.
> Type "exit" or use Ctrl+D to leave this console.

[xclamm-j] $ run

--- (Running the application from SBT, auto-reloading is enabled) ---

[info] play - Listening for HTTP on port 9000...

(Server started, use Ctrl+D to stop and go back to the console...)

[info] play - database [default] connected at jdbc:h2:mem:play
[info] play - Application started (Dev)
[info] application - Generated uuid: a447101a-df11-45c5-8c64-97f0d1a96eb0
[info] application - userID from client: a447101a-df11-45c5-8c64-97f0d1a96eb0
[info] play - Starting application default Akka system.
[info] application - a447101a-df11-45c5-8c64-97f0d1a96eb0 joined

2012年5月28日月曜日

WebSocket client with Dart, WebSocket server with Go

ちょっと前の話になるけど、5/12に開催されたDartのハッカソンに参加して、そこでWebSocketを用いてウェブアプリをつくるチームに参加した。自分はここで公開してるSVGアニメーション用のコードがあったので、それを用いたアプリを作ってきた。

その場ではサーバーもクライアントもDartで書いたんだけど、個人的にはサーバーサイドはGoで書きたかったので改めてGoで書きなおしてみた。ちなみにハッカソンではGithubを使って開発したので、それを自分のところへForkして拡張してる。場所はここね。

server.go

基本的にはmanageClientという関数がクライアントに関するリソースを管理するアクターで、serveClient内での各クライアントとのやりとりはすべてこのmanageClientに対して転送される。例えば新規にクライアントがつながってきた場合はAddClientRequestがserveClientからmanageClientに対して送られ、クライアント一覧に相当するmapにクライアントとの接続が格納される。

ちなみにちょっと苦労したのがscriptやらCSSやらをGoのhttpサーバー経由でどうやってホストするのか、という点。それは色々調べたところhttp.ServeFile()という関数を使うとファイルに対するリクエストは大体いい感じに処理してくれることがわかったので、下のコードはそれを採用している

package main

import (
 "code.google.com/p/go.net/websocket"
 "net/http"
 "fmt"
)

type RequestType int
type ResponseType int

var (
 requestQueue = make(chan Request)
 responseQueue = make(chan Response)
)

const (
 // Request
 GENERATE_CLIENT_ID RequestType = 0
 ADD_CLIENT RequestType = 1
 REMOVE_CLIENT RequestType = 2
 BROADCAST RequestType = 3

 // Response
 CLIENT_ID ResponseType = 0
)

type RequestParameter struct {
 clientID int
 Connection *websocket.Conn
}

type DisplayParameter struct {
 Remark string
 Duration string
 Path string
 Rotate string
}

type Request interface {
 Type() RequestType
 ClientID() int
 Process(clientList map[int]*websocket.Conn, queue chan Response, currentClientID *int) map[int]*websocket.Conn
}

type GenerateClientIDRequest struct {
 RequestParameter
}

type BroadcastRequest struct {
 RequestParameter
 DisplayParameter
}

type AddClientRequest struct {
 RequestParameter
}

type RemoveClientRequest struct {
 RequestParameter
}

type Response interface {
 Type() ResponseType
}

type GenerateClientIDResponse struct {
 ClientID int
}

func (gcr GenerateClientIDResponse) Type() ResponseType {
 return CLIENT_ID
}

func (gcm GenerateClientIDRequest) ClientID() int {
 return gcm.clientID
}

func (gcm GenerateClientIDRequest) Type() RequestType {
 return GENERATE_CLIENT_ID
}

func (gcm GenerateClientIDRequest) Process(clientList map[int]*websocket.Conn, queue chan Response, currentClientID *int) map[int]*websocket.Conn {
 res := GenerateClientIDResponse {
  *currentClientID,
 }
 queue <- res
 // proceed to next id
 *currentClientID++
 return clientList
}



func (acm AddClientRequest) ClientID() int {
 return acm.clientID
}

func (acm AddClientRequest) Type() RequestType {
 return ADD_CLIENT
}

func (acm AddClientRequest) Process(clientList map[int]*websocket.Conn, queue chan Response, currentClientID *int) map[int]*websocket.Conn {
 clientList[acm.clientID] = acm.Connection
 fmt.Printf("Added client. %d clients existing.\n", len(clientList))
 return clientList
}

func (bm BroadcastRequest) ClientID() int {
 return bm.clientID
}

func (bm BroadcastRequest) Type() RequestType {
 return BROADCAST
}

func (bm BroadcastRequest) Process(clientList map[int]*websocket.Conn, queue chan Response, currentClientID *int) map[int]*websocket.Conn  {
 // send a text message serialized as JSON.
 for id, client  := range clientList {
  fmt.Printf("Sending to %d\n", id)
  err := websocket.JSON.Send(client, bm.DisplayParameter)
  if err != nil {
   fmt.Printf("error: %s\n",err)
   // remove client
   delete(clientList, id)
  }
 }
 fmt.Println("Broadcasted")
 return clientList
}

func (rcm RemoveClientRequest) ClientID() int {
 return rcm.clientID
}

func (rcm RemoveClientRequest) Type() RequestType {
 return REMOVE_CLIENT
}

func (rcm RemoveClientRequest) Process(clientList map[int]*websocket.Conn, queue chan Response, currentClientID *int) map[int]*websocket.Conn  {
 // remove client
 delete(clientList, rcm.clientID)
 fmt.Printf("Removed client ID:%d. %d left\n", rcm.clientID, len(clientList))
 return clientList
}

/**
  * Message loop for the client manager
  */
func manageClient(reqQueue chan Request, resQueue chan Response) {
 var currentClientID int = 0
 clientList := make(map[int]*websocket.Conn, 0)
 fmt.Println("Entering client manage loop")
 for {
  select {
  case msg := <- reqQueue:
   fmt.Printf("client:%d type:%d\n", msg.ClientID(), msg.Type())
   clientList = msg.Process(clientList, resQueue, &currentClientID)
  }
 }
}

/**
  * Get a new client ID from the client manager
  */
func generateClientID(reqQueue chan Request, resQueue chan Response)  int {
 msg := GenerateClientIDRequest {}
 reqQueue <- msg
 res := <- resQueue
 v, _ := res.(GenerateClientIDResponse)
 return v.ClientID
}

/**
  * Function that serves each client
  */
func serveClient(ws *websocket.Conn, reqQueue chan Request, resQueue chan Response) {
 id := generateClientID(reqQueue, resQueue)
 defer func() {
  // remove client when exiting
  msg := RemoveClientRequest {
   RequestParameter {
    id,
    ws,
   },
  }
  reqQueue <- msg
 }()

 // add client first
 msg := AddClientRequest {
  RequestParameter {
   id,
   ws,
  },
 }
 reqQueue <- msg
 fmt.Println("Entering receive loop")
 for {
  var param DisplayParameter
  err := websocket.JSON.Receive(ws, &param)
  if err != nil {
   fmt.Printf("receive error: %s\n",err)
   break
  }
  fmt.Printf("recv:%#v\n", param)

  // broadcast received message
  msg := BroadcastRequest {
   RequestParameter {
    id,
    ws,
   },
   param,
  }
  reqQueue <- msg
 }
}

/**
  * Handler for the websocket json server
  */
func echoJsonServer(ws *websocket.Conn) {
 fmt.Printf("jsonServer %#v\n", ws.Config())
 serveClient(ws, requestQueue, responseQueue)
}

/**
  * Serve all files (html, dart, css)
  */
func mainServer(w http.ResponseWriter, req *http.Request) {
 path := req.URL.Path[1:]
 fmt.Printf("path: %s\n",path)
 http.ServeFile(w, req, path)
}

func main() {
 // start an actor that processes requests from every client
 go manageClient(requestQueue, responseQueue)

 // setup the handlers
 http.Handle("/echo", websocket.Handler(echoJsonServer))
 http.HandleFunc("/", mainServer)
 fmt.Println("serving...")
 port := 8080
 err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
 if err != nil {
  panic("ListenAndServer: " + err.Error())
 }
}

client.dart

クライアント側で実行されるDartのコードが以下。いままでのサンプルと違うのは、他のクライアントとポスト内容を共有するためポストボタンをクリックしたら即websocketで内容を送っているところ。実際の表示処理はサーバー側からブロードキャストされてきたメッセージに対してon.messageのハンドラで実行することになる。

#import("dart:html");
#import("dart:json");
#import("RemarkDisplayer.dart");
#import("SVGRemarkDisplayer.dart");
#import("Unit.dart");

void main() {
    // initialize displayers
    final int MAX_NUMBER_OF_REMARKS = 15;
    final int MAX_SPEED = 100;
    var displayer = new SVGRemarkDisplayer();
    displayer.initialize("#stage", MAX_NUMBER_OF_REMARKS);

    // create websocket and add handlers
    WebSocket webSocket = new WebSocket("ws://localhost:8080/echo");
    Element status = document.query("#statusArea");
    webSocket.on.message.add((event) {
            // display remark with given parameters
            var message = event.data;
            Map map = JSON.parse(message);
            DisplayParameter param = new DisplayParameter.map(map);
            displayer.display(param);
        });

    // connect post button with websocket
    TextAreaElement textNode = document.query("#remarkText");
    TextAreaElement pathNode = document.query("#pathString");
    Element postButton = document.query("#postRemark");
    InputElement speedNode = document.query("#displaySpeed");
    InputElement rotateNode = document.query("#rotateRemark");
    postButton.on.click.add((Event e) {
        String message = textNode.value;
        String path = pathNode.value;

        bool rotate = rotateNode.checked;
        var num = speedNode.valueAsNumber;
        var duration = (MAX_SPEED - num.toInt()) * 100;
        var param = new DisplayParameter(message, duration, Unit.Millisecond, path, rotate);
        String paramJson = JSON.stringify(param.toMap());
        webSocket.send(paramJson);
    });
}

感想

Dartで書いたときと比べてGo版はかなりコード量が増えた気がする。大半はmanageClientとどういったメッセージをやりとりするかの定義なので、アクターモデルをやるからにはしょうがないのかもしれない。ただやっぱり厳密に書こうとするとアクターモデルはめんどう。もっと規模が大きくなればアクターモデルの恩恵がわかる、かも?

サーバー側の出力例

サーバー側をビルド・実行して、上記のDartクライアントからGoogleのアスキーアートをPostした場合の出力が下のログ。参考に掲載しておく。

$ go build server.go 
$ ./server 
serving...
Entering client manage loop
path: 
jsonServer &websocket.Config{Location:(*url.URL)(0x188abd80), Origin:(*url.URL)(0x188abdc0), Protocol:[]string(nil), Version:13, TlsConfig:(*tls.Config)(nil), handshakeData:map[string]string(nil)}
client:0 type:0
Entering receive loop
client:0 type:1
Added client. 1 clients existing.
path: favicon.ico
recv:main.DisplayParameter{Remark:"\u3000\u3000\u3000\u3000_,,,,._\u3000\u3000\u3000\u3000\u3000 \u3000 \u3000 \u3000 \u3000 \u3000 \u3000 、-r\n\u3000 \u3000,.','\" ̄`,ゝ\u3000_,,,_\u3000\u3000 _,,,_\u3000\u3000 _,,,,__,. | |\u3000 _,,,,,_\n\u3000\u3000{ {\u3000\u3000 ,___\u3000,'r⌒!゙! ,'r⌒!゙! ,.'r⌒!.!\"| l ,.'r_,,.>〉\n\u3000\u3000ゝヽ、\u3000~]|\u3000ゞ_,.'ノ\u3000ゞ_,.'ノ\u3000ゞ__,.'ノ\u3000| l {,ヽ、__,.\n\u3000\u3000\u3000`ー-‐'\"\u3000\u3000 ~\u3000\u3000\u3000 ~\u3000\u3000〃 `゙,ヽ ̄` `゙'''\"\n\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000\u3000 ゙=、_,.〃 ", Duration:"5000&ms", Path:"M10,150 A15 15 180 0 1 70 140 A15 25 180 0 0 130 130 A15 55 180 0 1 190 120 A15 10 170 0 1 10 150", Rotate:"false"}
client:0 type:3
Sending to 0
Broadcasted