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から割と普通に使えるかも。

0 件のコメント:

コメントを投稿