LoginSignup
12
10

More than 5 years have passed since last update.

フレームワークを使わないでDartのWebアプリを作ってみて得られた知見集

Posted at

※注意:以下の記事は2014/12/28時点のDartや各パッケージ内容を元に書いています。

TL;DR

  • フレームワーク使え
  • Dartレベルが1あがった。ぱらぱらっぱっぱー

まえがき

Dartは主にクライアントサイドの利用が期待されてる言語だと思いますが、個人的にはBetter NodeJSとしてかなり期待しています。
NodeJSはJS...というかV8がベースなので、V8が対応してないES6のシンタックスは(そのままだと)使えないですし、ES6もJSが抱えているフラストレーションを解決しきってるわけでは無いですし、JSがブラウザで使われる事を前提とした言語であるが故に、言語仕様の策定が大変(調整が大変)だったりして、進化が早いかと言われると決してそうではないわけです。
Promiseやyieldなどすぐにでも使いたいなぁと思う機能も、実際に使えるようになるまでは結構時間がかかるわけです。
その点Dartは、Googleさんが好きに作ってる(ECMA標準にも登録されてますが)ので、仕様決めて、実装して、すぐに開発者が新しい機能を使えるようになります。
NodeJSと同じシングルスレッドをベースにしたイベントループモデルであり、Nodeで出来る事は既に大体できる(はず)ので、pubがnpmばりのエコシステムとして成長したらかなり来るなぁと思ってるわけです。

クライアントサイドにばかり注目されがちなDartさんですが、個人的にはサーバーサイドにフューチャーしていきたいと思っています。

で、今回、フレームワークを使わずにDartのサーバーサイドのWebアプリケーションを作ってみました。

dart-bbs-vanilla-study
https://github.com/takyam-git/dart-bbs-vanilla-study

Dartのサーバーサイドのフレームワークで有名なのはRedstone.dartstartRikulo Streamshelf あたりでしょうか。
いずれも軽量なフレームワーク(shelfにいたってはWeb Server Middleware for Dartだし)なので、大した機能は無いのですが、それにしろある程度の有り難みを感じるためにも、内部動作を推測するためにも、とりあえずフレームワークを使わずに自分でアプリケーションを書いてみたほうが良いなと思い、今回はフレームワークを使わずに書いてみました。

出来上がったものは紛うことなき糞ですが、いくつか知見が得られたので備忘録がてらメモしておきます。
特にサーバーサイドにかぎらず、がっつりDart書いたのが初めてなので、Dartの基礎的な部分についても書いてます。

先に結論だけ書いておくと、勉強にはなったけど普通にフレームワーク使ったほうがいいですね。

おしながき

  • routeパッケージのRouterの使い方
    • 静的ファイルとそうじゃないやつの処理分けの方法。
  • http_serverパッケージ
    • jailRootによってroot階層より上は参照できない、とか。
  • HttpResponseのContentTypeの設定方法
    • request.response.headers.add("Content-Type", "text/html")は間違い。
  • Completer超便利
    • Future.wait() と合わせてよく使う。
  • MapMixin超便利
    • JSON.encode() とかにも対応できちゃう。素敵。
  • StreamController超便利
    • 外部にStreamを公開したい時に超楽ちん。
  • WebSocketのbroadcastをする方法
    • WebSocketのコネクションの管理を実装する。
  • constインスタンスをstatic final定数で持つ
    • ContentTypeクラスとかはこれやってる。

routeパッケージのRouterの使い方

HTTPリクエストを捌くのに、dart:ioパッケージのHTTPServerクラスを使うわけですが、そのままだと原始的な事しかできないので、routeパッケージを使ってルーティングを行います。
Routeクラスはクライアントサイド・サーバーサイドのどちらもサポートしています。
サーバーサイドの使い方は非常にシンプルで、Route単体で何か複雑な事ができる訳ではありません。

機能は 「設定したURLのパターンに一致するリクエストに対応するStreamを生成する」 だけです。

登場するクラスや関数も少なく、以下を抑えておけばほぼ問題なく利用する事ができるでしょう。

  • Router クラス
    • filter メソッド
      • void filter(Pattern url, Filter filter)
      • Filter は Future<bool> Filter(HttpRequest request)
    • serve メソッド
      • Stream<HttpRequest> serve(Pattern url, {String method})
    • defaultStream プロパティ
      • Stream<HttpRequest> get defaultStream
  • UrlPattern クラス
  • matchAny関数

Router の基本的な使い方

var router = new Router(server); //server は HttpServer のインスタンス
router
  ..filter(new UrlPattern(r"/mypage"), authFilter)
  ..serve(new UrlPattern(r"/"), method: "GET").listen(topPageHandler)
  ..serve(new UrlPattern(r"/mypage").listen(myPageHandler)
  ..defaultStream.listen(notFoundHandler);
  //topPageHandler/myPageHandler/notFoundHandler は別途自分で定義する必要があります

serveメソッドとdefaultStreamプロパティ

最も大事なメソッドであるserveStream<HttpRequest> を返しますので、それを .listen() する事で、serve()に一致したパターンの処理を.listen()に登録したハンドラが行うようになります。
上記の例でいえば、r"/"(トップページ)の処理はtopPageHandlerが行い、r"/mypage"(マイページ)の処理をmyPageHandlerが行うように設定しています。

Tips: Stream<HttpRequest> の、 <HttpRequest> は、そのストリームをlistenしてるハンドラに渡される引数が HttpRequest クラスのインスタンスである事を表しています。なので、ハンドラは (HttpRequest request)=> do something... のようなコールバックを設定する事になります。

さらに、どのUrlPatternにもマッチするserveが無かった場合のStream<HttpRequest>としてdefaultStreamプロパティが定義してあり、例えば上記の例の場合はそこに404 Not Foundページを表示するためのnotFoundHandlerを定義するような使い方をしています。

filterメソッド

上記の例の3行目にあるfilter()メソッドは、第1引数にserveと同じようにUrlPatternを設定し、第2引数にはFuture<bool>を返すハンドラ設定します。Filterの実装例としてパッケージのREADMEに書いてある例を見てみます。

Future<bool> authFilter(req) {
  return getUser(getUserIdCookie(req)).then((user) {
    if (user != null) return true;
    redirectToLoginPage(req);
    return false;
  });
}

非同期でユーザーのセッションを確認して、有効なユーザーであればtrueを、そうでなければfalseを返しています。
trueが返った時のリクエストの処理については、通常どおり、一致したserveのlistenのハンドラが実行されるのですが、ここでfalseを返した場合は、たとえ一致したserveがあったとしても処理されません。
じゃぁfalseの時はdefaultStreamに行くのかというと、そうでもなくて、本当に何も処理されなくなるので、上記例の場合redirectToLoginPage(req)の部分で恐らくリダイレクト処理をしているのですが、このように何かしらrequest.response.close()を行うようにしないと、 いつまでだってもレスポンスが返りません

UrlPatternクラスと matchAny 関数

既に何回か登場しているUrlPatternクラスですが、これはservefilterの第1引数に渡すPatternの実装です。基本的にはコンストラクタの引数に渡された 正規表現風 のURLにマッチするかどうか、を判定するクラスになっています。
が、通常のRegExpとは違う点がいくつかありますので、注意してください。以下こちらからの引用です。

通常のRegExpとの違い

  • 全ての正規表現の特殊文字はグルーピング(訳注:正規表現の()で囲うアレ)する必要があります。グルーピングされてないものについては、エスケープ済みの正規表現特殊文字か通常の文字列として処理されます。
  • 文字列全体との一致させる必要があります。パターンの先頭と末尾にそれぞれ^$が自動で付与されます。
  • パターンは曖昧ではいけず、たとえば、(.*)(.*)はトップレベルでは許可されていません。
  • ハッシュ文字列である#'#''/'の両方にマッチし、ひとつのパターンで一度しか使う事はできません。また、ハッシュはグルーピングする事を許可していません。

特に、r"/path/to/hoge/.*" は恐らく期待通りに動作せず r"/path/to/hoge/(.*)" としなければならない点は、最初に誰もがハマるポイントかなと思います。

で、基本的にUrlPatternクラスはひとつのパターンしか定義できないわけですが、複数のパターンにおいて一つのハンドラを登録したい場合があり、そういった場合にはmatchAny()関数を使う事になります。

matchAny関数
Pattern matchAny(Iterable<Pattern> include, {Iterable<Pattern> exclude})

大した話じゃなくて、serve(matchAny([pattern1, pattern2]))のように、複数のパターンを合わせて一つのPatternにしてくれる感じで、第1引数のList内のUrlPatternのいずれかに一致するかどうか、といった判定に変わります。

routesパッケージのまとめ

Routerクラスができる事は基本的にコレだけで、例えば「◯◯ページのリクエストは◯◯Controllerの◯◯メソッドでハンドリングする」といったフレームワークライクな処理をフランクに書く事はできません。いわゆるroutesの定義を設定ファイルベースで行うといった事は、これだけだとできないわけです。

しかしながら、必要最低限のルーティング処理は提供されますし、これをベースに、フレームワークライクな処理を書く事も難しくはありません。非常にシンプルで強力なパッケージといえるでしょう。

なお、ルーティング処理の詳細はこちらのソースコードを見るとわかります。Routerクラス自体非常にシンプルで、コード量も少ないので、全部を読むのも特に苦にはならないでしょう。

http_serverパッケージ

http_serverパッケージも、先のroutesパッケージと併せてHTTPサーバーを作成する際によく利用されるパッケージのひとつだと思います。このパッケージは主に以下の機能を備えています。

  • POSTリクエスト等のリクエストボディの取得
  • VirtualDirectoryクラスによる静的ファイルの配信
  • VirtualHostクラスによるリクエストドメイン毎の処理わけ

VirtualHostは使った事がないので、今回は上の2つについて記載します。

なお、http_serverパッケージのソースは以下にあります。

現在DartTeam製のパッケージは徐々にGitHubに移動していってますので、これも近いうちにGitHubに移動するかもしれません。

リクエストボディの取得

POSTされた値を取るにあたり、PHPだと$_POSTで簡単に取得できたりしますが、Dartの場合は自分でいろいろ処理しなければ取得する事はできません。が、リクエストボディのパース処理は意外と面倒なので、ここはHttpBodyHandlerクラスを利用して、簡単にリクエストボディを取得するようにしましょう。

先ほどのRouterクラスの利用例を少し改修してみます。

var router = new Router(server); //server は HttpServer のインスタンス
router
  ..serve(new UrlPattern(r"/"), method: "GET").listen(topPageHandler)
  ..serve(new UrlPattern(r"/"), method: "POST").transform(new HttpBodyHandler()).listen(topPagePostHandler)
  ..defaultStream.listen(notFoundHandler);

.transform(new HttpBodyHandler())が増えています。
.transform()はStreamの標準メソッドで、詳細はこちらのドキュメントに書いてあります。簡単にいうと、Streamの値を変換するやつです。
今回の例でいうと、元々はStream<HttpRequest>だったわけですが、このHttpBodyHandler(StreamTransformerの実装)を間に挟む事で、Stream<HttpRequestBody>として扱えるようになるわけです。

つまり、GETリクエストのtopPageHandler(HttpRequest request) => do something...で、
POSTリクエストのtopPagePostHandler(HttpRequestBody request) => do something...になるわけです。

HttpRequestBodyのインスタンスは.bodyプロパティを持っていて、この中にリクエストボディが含まれ、プロパティの形式は3種類でMapBytesStringです。これはリクエスト形式によって異なるので注意してください。詳しくはこのへんの実装を見てください。

ちなみにどうしてもライブラリ使いたくないんだよっていう人は、こちらのStackOverflowの質問を参考に、UTF8.decodeStream()を使う事で頑張れなくもないですが、Formのパースとか辛いので使えるものは使ったほうがいいと思うのです。

VirtualDirectoryクラスによる静的ファイルの配信

静的ファイル(JS/CSS/IMG)の配布はNginx等のWEBサーバーレイヤで行う場合は必要ないのですが、いちいちそんなの設定すんの面倒くさくて、Dart側で配信する場合にはVirtualDirectoryクラスを使うと簡単に配信する事ができるようになります。

var staticFiles = new VirtualDirectory(r"web/assets", pathPrefix: r"/assets")
  ..followLinks = true
  ..allowDirectoryListing = false
  ..jailRoot = true;
HttpServer.bind('0.0.0.0', 3000).then((HttpServer server) {
  var router = new Router(server)
    ..serve(new UrlPattern(r"/assets/(.*)"), method: "GET").listen(staticFiles.serveRequest)
    ..serve(new UrlPattern(r"/"), method: "GET").listen(topPageHandler)
    ..defaultStream.listen(notFoundHandler);
});

この例では、web/assets ディレクトリ以下に対して /assets から始まるURLでアクセスがあった場合に、web/assets以下においてあるファイルを配布しますよ、といった設定になっています。
例えばweb/assets/css/hoge.cssがあるのであれば、/assets/css/hoge.cssでアクセスすると、そのCSSファイルがレスポンスされるわけです。

このVirtualDirectoryに設定すべきプロパティが3つあります。

  • followLinks (デフォルト:true)
    • symlinkに対応するかどうか
  • allowDirectoryListing (デフォルト:false)
    • ディレクトリのURLにアクセスした場合にの中身を公開するかどうか
  • jailRoot (デフォルト:true)
    • root(コンストラクタの第1引数で指定したパス)以下ではない階層についてのアクセスを禁止するかどうか。trueの場合は禁止されているので、例えばweb/assets/packagesがwebと同階層のpackagesディレクトリへのリンクになっている場合、実体がroot以下ではないので、/assets/packages/*にアクセスしてもNot Foundを返すようになります。基本的にはrootより上の階層にアクセスできるというのは往々にして脆弱性を生みやすいので、基本的にはデフォルト値のtrueのままにし、root以下に配信対象のファイルを設置するようにしましょう。なお、followLinksfalseの場合だと、そもそも上の階層に対するリンクが使えないのでこのプロパティは意味ないです。

これら3つのプロパティを用途に合わせて正しく設定するようにしましょう。

http_serverパッケージのまとめ

フレームワーク使わない場合は、http_serverパッケージが無いといろいろつらいと思うので、ここに書いた2つくらいは使い方を覚えておくと良いと思います。

HttpResponseのContentTypeの設定方法

レスポンスのContentTypeの設定には以下の2つの方法があります。

  • response.headers.contentType = ContentType.HTML
  • response.headers.add(HttpHeaders.CONTENT_TYPE, "text/html")

どちらもContentTypeにtext/htmlを設定できるのですが、基本的には前者の方法を利用してください。
なぜなら、後者の方法の場合文字コードがLatin 1に固定されてしまいます。

ContentTypeクラスを使う事で文字コードを指定する事ができ、ContentType.HTMLなどのデフォルトの定数を利用する事でUTF-8を指定できます。

半角英数しか扱わないのであれば後者の方法でも問題ありませんが、そんな事は無いと思うので、ContentTypeを利用するようにしてください。

Completer超便利

非同期の処理を扱うにあたり、Completerを良く使います。
例えばあるメソッドが複数の非同期処理を含み、それら全てが完了した場合に結果を返すような事を行いたい時に便利です。

import "dart:async";
import "dart:io";

class FileReader {
  static Future<String> read(String filePath) {
    //処理完了をハンドリングするCompleter
    var completer = new Completer();

    var file = new File(filePath);
    file
      //ファイルが存在するかどうかの確認を開始
      .exists()
      //ファイルの存在確認の結果を受け取り、存在するならステータス取得を開始
      .then((bool exists) => exists ? file.stat() : new Future.error("File not exists"))
      //ファイルがファイルなのかどうかをチェックする
      .then((FileStat stat) => stat.type == FileSystemEntityType.FILE ? file : new Future.error("File is not file"))
      //ファイルがファイルだったならファイルの読み込みを開始
      .then((File file) => file.readAsString())
      //ここまできたって事はファイルが読み込めたのでcompleteする
      .then((String text) => completer.complete(text))
      //エラーが発生した場合はcompleteErrorでエラーを返す
      .catchError((e) => completer.completeError("ReadFailed: ${e}"));

    //読み込みが終わるかエラーが発生するまで待つfutureプロパティを返す
    return completer.future;
  }
}

void main() {
  FileReader.read("/path/to/target.txt").then((String text) => print(text));
  //ファイルがあればその中身の文字列が表示される
}

これは超適当実装なファイルリーダーの実装例です。

read()メソッドに渡されたファイルパスのファイルを読み込み、それを表示しています。read()メソッドは返り値としてFuture<String>、つまり非同期に文字列を返すFutureを返しており、 その実装はcompleter.futureになっています

このread()では、「ファイルが存在するかどうか」「ファイルがディレクトリとかじゃなくてファイルかどうか」「ファイルの読み込み」という3つの非同期処理が行われていて、それらが完了したタイミングでようやく結果の値を返せる状態になります。

このように、複数の非同期処理の結果として、非同期処理を返したい場合にCompleterが非常に有用です。

Completerで使う機能は

  • future プロパティ
  • complete() メソッド
  • completeError() meso土

この3つだけです。全然難しくない。
futureプロパティは、今回の例のようにメソッドや関数の返り値として返してあげるような使い道が多いです。メソッドや関数を利用する側からすると単にFutureが返ってくるわけなので、.then()してあげればいいわけです。

Completerを使った関数/メソッドを実装する側は、いくつかの非同期処理が終わって、結果を返せるようになった段階でcomplete()メソッドを呼ぶか、エラーが発生した場合はcompleteError()メソッドを呼べばよく、結果の値はcomplete()の第1引数に渡してあげれば、ちゃんと利用者側の.then()にその結果の値が渡ります。

併せて使いたいFuture.wait

class FileReader {
  static Future<String> read(String filePath) {
    //さっきと一緒なので省略
  }
  static Future<String> readFiles(List<String> filePaths){
    //completerを用意
    var completer = new Completer();

    //複数のFutureを格納するためのListを用意
    List<Future<String>> waitList = [];

    //ListにFileReader.readの返り値のFutureを突っ込んでく
    filePaths.forEach((String filePath){
      waitList.add(FileReader.read(filePath));
    });

    //Future.waitで全部読み込み終わるまで待って、結果がList<String>で渡ってくるので、
    //渡ってきたList<String>を結合してcompleterで完了させる
    Future.wait(waitList).then((List<String> texts) => completer.complete(texts.join("\n")));

    //completer.futureを返しておく
    return completer.future;
  }
}

void main() {
  //file1.txtとfile2.txtの中身が結合された文字列が返ってくるのでprintする
  FileReader.readFiles(["/path/to/file1.txt", "/path/to/file2.txt"]).then((String text) => print(text));
}

このように、複数のFutureをListに突っ込んで、Future.wait()に渡す事で、List内の全てのFutureが完了した場合に、Future.wait().then()が実行されるこれまた便利なstaticメソッド。さらに、結果の値は自動的にListに格納(変換)されて渡ってくるので、一時キャッシュに突っ込む処理をしたりする必要もない素晴らしいやつです。

Completerまとめ

CompleterFuture.wait()を使うだけで、非常に簡潔に、簡単に、非同期処理の「完了したらこれを実行する」を実装する事ができます。

MapMixin超便利

dart:collectionパッケージには、便利なMixinがいくつもありますが、今回はMapMixinをご紹介。
MapMixinは、自作のクラスをMapとして扱えるようにするMixinです。

import "dart:collection";

class UserModel extends Object with MapMixin {
  Map<String, Object> _properties = new Map<String, Object>();

  UserModel([Map<String, Object> properties]) {
    if (properties != null) {
      this._properties.addAll(properties);
    }
  }
  //以下の2つのoperator, 1つのgetter, 2つのメソッドがMapMixinにおいて必要な実装
  operator [](String key) => this._properties[key];
  operator []=(String key, Object value) => this._properties[key] = value;
  Iterable<String> get keys => this._properties.keys;
  Object remove(String key) => this._properties.remove(key);
  void clear() => this._properties.clear();
}

void main() {
  var user = new UserModel({"name": "takyam", "mail": "takyam@example.com"});
  user["age"] = 30;
  user.forEach((String key, Object value)=> print("${key}: ${value}"));
  // name: takyam
  // mail: takyam@example.com
  // age: 30
}

上記のように、データの実体はプライベートなメンバ変数(_properties)に持ちつつ、個別にgetter/setterを定義しなくてもuser["name"]user["age"]といったかたちで、まるでMapのようにアクセスできるし、user.forEach()のようなMapが備えているメソッドも使えるようになる。素晴らしい。

このMapのように扱える、というのはデータを保持してるElementにとって非常に重要で、何が重要かというとJSONに変換できるというメリットがあります。

import "dart:convert";
//(略)
var user1 = new UserModel({"name": "user1", "age":15});
var user2 = new UserModel({"name": "user2", "age":20});
var user3 = new UserModel({"name": "user3", "age":25});
print(JSON.encode([user1, user2, user3]));
// [{"name":"user1","age":15},{"name":"user2","age":20},{"name":"user3","age":25}]

JSON.encode()は基本的にはnumberbooleanstringnulllistStringがキーのMapにしか対応していないので、たとえばMaMixinをMixinしてない自作クラスをJSON.encode()にかけようとすると第2引数のtoEncodableに変換用のハンドラを定義したりと、中々に面倒くさいわけです。それがMapMixinで(キーがStringである必要はありますが)Mapライクに扱えるようになっている事で、JSONエンコード時も特に細かい事を気にせずに済むわけです。これは便利です。

発展形:.プロパティ名でアクセスできるようにしたい

前述のUserModelクラスをちょっと拡張して.プロパティ名でのアクセスを可能にする事もできます。

import "dart:mirrors";
//(略)
class UserModel extends Object with MapMixin {
  //前述の実装と全く一緒なので省略
  Object noSuchMethod(Invocation invocation) {
    if (invocation.isAccessor) { // getter or setter の場合
      // プロパティ名をStringで取得
      String accessKey = MirrorSystem.getName(invocation.memberName);
      if (invocation.isGetter && this.containsKey(accessKey)) {
        // Getterの場合はキーが存在すればそれを返す
        return this[accessKey];
      } else if (invocation.isSetter && invocation.positionalArguments.length == 1) {
        // Setterの場合は値をセット(Setterの場合はキーの末尾に「=」がついてるので注意)
        return this[accessKey.replaceAll("=", "")] = invocation.positionalArguments[0];
      }
    }
    //superに投げれば大体 NoSuchMethodError が出るはず
    return super.noSuchMethod(invocation);
  }
}

noSuchMethodを定義する事で、例えばこのUserModelの場合print(user.name);user.age = 30;といった形でアクセスする事ができます。これはコレでかっこいいですし便利なのですが、SymbolをStringに変換する必要がある事から、どうしてもMirrosが必要になってしまうので、個人的にはあまりここまでする必要は無いと思ってます。流石にメタメタしぃですし。

MapMixinまとめ

超便利なので、MapMixinに必要な実装[],[]=,remove,clear,keysの5つはソラで言えるようになっておきましょう。そして.プロパティアクセスはやりすぎだと思う。かっこいいけど。

StreamController超便利

Streamを扱うクラスを作る場合、Streamのインターフェースを外部に公開したい事があります。

注意: 以下Messageクラスが登場しますが、これはオレオレメッセージフォーマットで、サーバーサイドとクライアントサイドとのやりとりにおいて共通のフォーマットでJSONをencode/decodeするためのクラスです。特に話の本流とは関係ないのでそういうもんだと思っておいてもらえれば。

class WebSocketStream {
  final String _url = "ws://${Uri.base.host}:${Uri.base.port}/ws";
  WebSocket _ws;
  bool _encounteredError = false;
  int _retryWaitSeconds = 1;

  //onMessage用のStreamController
  StreamController<Message> _onMessageStream = new StreamController<Message>.broadcast();

  WebSocketStream() {
    this._initialize();
  }

  //WebSocket接続の初期化
  void _initialize() {
    //再接続用のプロパティをリセット
    this._encounteredError = false;
    this._retryWaitSeconds *= 2;
    //WebSocket接続して各Streamにイベントハンドラを登録
    this._ws = new WebSocket(this._url)
      ..onOpen.listen(this._onOpen)
      ..onClose.listen(this._onClose)
      ..onError.listen(this._onError)
      ..onMessage.listen(this._onMessage);
  }

  //各StreamのGetter
  Stream<Event> get onOpen => this._ws.onOpen;
  Stream<CloseEvent> get onClose => this._ws.onClose;
  Stream<Event> get onError => this._ws.onError;
  Stream<Message> get onMessage => this._onMessageStream.stream;

  //メッセージをサーバーにPushするメソッド
  void send(Message message) => this._ws.send(message.toJson());

  //各イベント発生時のイベントハンドラ
  void _onOpen(Event event) => print("connected!");
  void _onClose(CloseEvent event) => this._reconnect();
  void _onError(Event event) => this._reconnect();

  //再接続処理
  void _reconnect() {
    if (!this._encounteredError) {
      new Timer(new Duration(seconds: this._retryWaitSeconds), () => this._initialize());
    }
    this._encounteredError = true;
  }

  //サーバーからメッセージ受信時のイベントハンドラ
  void _onMessage(MessageEvent event) {
    try {
      //サーバーから受け取ったJSONをMessageに変換して外部公開用Streamに流す
      var message = new Message.fromJson(event.data);
      this._onMessageStream.add(message);
    } catch (e) {
      //do nothing
    }
  }
}

これは実際に今回作成した、クライアントサイドのWebSocketの接続管理用のラッパークラスです。
実装はこちらの記事を参考にしています。

機能は以下の2つです。

  • コネクション切断時の再接続処理
  • サーバーからPushされるメッセージをMessageクラス(今回自作したクラス)に変換する

今回重要なのは、

StreamController<Message> _onMessageStream = new StreamController<Message>.broadcast();
Stream<Message> get onMessage => this._onMessageStream.stream;
this._onMessageStream.add(message);

この3行で、StreamControllerを作成しておき、getterで外部にStreamを公開し、内部で.add()する事で外部に伝搬させる事ができます。
このクラスを扱う側は、

new WebSocketSteram()
  ..onMessage.listen((Message message)=> print(message));

のように、本来.listen((Event event) => do something...)のところを変換したMessageクラスのインスタンスで受け取れるようになっています。このように、Streamを外部に公開したい場合に非常に便利です。

あと、今書いてて思いましたが、今回のケースではStreamControllerを作らずに、StreamTransformerで実装したほうが良さそうだとは思います。

Stream<Message> get onMessage => this._ws.onMessage.transform(new StreamTransformer<MessageEvent, Message>.fromHandlers(
  handleData: (MessageEvent event, EventSink<Message> sink) {
    try {
      var message = new Message.fromJson(event.data);
      sink.add(message);
    } catch (e) {
      //do nothing
    }
  }
));

試してないけどたぶんこんな感じで動くはず。

StreamControllerまとめ

外部にStreamを公開したい場合に、非常に簡単に実装できるので便利。
ポイントは作成したStreamControllerのインスタンスの.streamプロパティを公開する事。
それだけで外部では細かい事きにせずに.listen()すりゃいいだけなので非常に便利です。

そして今回の実装では特に使う必要が無いことに気づいた。悲しい。

WebSocketのbroadcastをする方法

Websocketで、サーバーサイドからクライアントサイドにPush通知を送る場合、個人個人のリクエスト毎にPushする場合はws.send()すれば良いだけなので簡単ですが、接続中の全ユーザーに対してbroadcastしたい場合、標準でそういった機能は提供されていないようなので、接続管理とブロードキャスト部分を自分で実装する必要があります。

import "dart:io";
import "message.dart"; //オレオレメッセージフォーマットのMessageクラス

class WebSocketConnectionsHandler {
  static Set<WebSocket> _connections = new Set<WebSocket>();
  static add(WebSocket socket) {
    _connections.add(socket);
    //切断時にコネクションリストから削除する
    socket.done.then((_) => remove(socket));
  }
  static remove(WebSocket socket) {
    _connections.remove(socket);
  }
  static broadcast(Message message) {
    _connections
    .where((WebSocket socket) => socket.readyState == WebSocket.OPEN)
    .forEach((WebSocket socket) => socket.add(message.toJson()));
  }
}

適当にこんな感じのコネクションを管理するクラスを用意しておきます。
んでもって、

..serve(new UrlPattern(r"/ws")).transform(new WebSocketTransformer()).listen((WebSocket socket) {
  WebSocketConnectionsHandler.add(socket);
  //do something
});

みたいに接続時点でコネクションリストに追加しておいてあげます。
ポイントは、socket.done.then((_) => remove(socket)); の部分で、ソケットが切断されたタイミングをdoneFutureから取得し、コネクションリストから除外しておく事です。
この上で、ブロードキャストする時は、コネクションリストの中からreadyStateOPENのものについてforEachsocket.addする事で、ブロードキャストを行う事ができます。

broadcastまとめ

ライブラリを使わない場合、自分でコネクションを管理する必要があります。たぶん。
実際にはWebsocket非対応ブラウザ用にプロトコルのアップグレードないしダウングレード的な対応が必要になると思うので、NodeJSで言うところのSocket.IO的なライブラリを探して使ったほうが良いと思います。

constインスタンスをstatic final定数で持つ

前述のContentTypeクラスでやってるテクニックなんですが、複数のプロパティからなる定数はstatic final変数で定義しておくというテクニックです。
ContentTypeはContent-type: text/html; charset=UTF-8のように$primaryType/$subType; charset=$charsetという3つの要素から成り立っています。とはいえ良く使う定義については定数として保持しておきたいので、そのためにstatic final変数を利用しています。

ContetTypeクラスの実装の抜粋
abstract class ContentType implements HeaderValue {
  static final TEXT = new ContentType("text", "plain", charset: "utf-8");
  static final HTML = new ContentType("text", "html", charset: "utf-8");
  static final JSON = new ContentType("application", "json", charset: "utf-8");
  static final BINARY = new ContentType("application", "octet-stream");
  factory ContentType(String primaryType, String subType, {String charset, Map<String, String> parameters}) {
    return new _ContentType(primaryType, subType, charset, parameters);
  }
}

実際のソースコードはこちらを参照ください。

こういう風に実装してあるので、利用する場合はContentType.HTMLとするだけで良いわけです。
このように◯◯Typeクラスを実装して、static final変数に定数(のようなモノ)を定義しておくテクニックは広く使い道があると思います。

あとがき

FutureStream周りの処理をキメるとかなり気持ちよいですね。何か「俺はDartしてるんだぞおぉぉ」という感じがして。

それはそれとして、Dartのコア系のパッケージ(dart:ioとかdart:asyncとか)はDartで実装されてる部分もかなり多く、使い方が分からない部分もソースコード見ればある程度理解できるので楽ちんです。利用方法をググるよりもソース見たほうが早い事もちらほら。実際のDartTeamによる実装を見るという意味においても非常に勉強になります。

というわけで、今回フレームワークを使わずにアプリを実装してみたわけですが、開発効率とか出来上がりの美しさとかGoodな設計思想とかそういうのとは無縁な糞が出来たものの、得られた知見はいろいろと大きいものでした。やはり、自分でガリガリと書いていかないと細かいところで理解できてなかったところもたくさんあり、ハマりつつコケつつとりあえず実装できました。よかった。

Railsのようなフルスタックフレームワークが存在せず、どういったフレームワークだとDartに向いてるのか、みたいなところの理想型のイメージもあまり持ててないのですが、そういうのが登場するまでは、マイクロフレームワークをゴリゴリとカスタマイズしていくしかないと思うので、こういった基礎的な知見というのは何らかの役に立つのではないかと思います。

さて、もう2014年も終わりです。2015年は立派なDartisans(Dartユーザーの事)になれるよう精進したいと思います。

それでは、良いお年を。

12
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
10