※注意:以下の記事は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.dart、start、Rikulo Stream、shelf あたりでしょうか。
いずれも軽量なフレームワーク(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
- filter メソッド
- 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プロパティ
最も大事なメソッドであるserve
は Stream<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
クラスですが、これはserve
やfilter
の第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種類でMap
かBytes
かString
です。これはリクエスト形式によって異なるので注意してください。詳しくはこのへんの実装を見てください。
ちなみにどうしてもライブラリ使いたくないんだよっていう人は、こちらの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以下に配信対象のファイルを設置するようにしましょう。なお、followLinks
がfalse
の場合だと、そもそも上の階層に対するリンクが使えないのでこのプロパティは意味ないです。
- root(コンストラクタの第1引数で指定したパス)以下ではない階層についてのアクセスを禁止するかどうか。trueの場合は禁止されているので、例えば
これら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まとめ
Completer
とFuture.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()
は基本的にはnumber
、boolean
、string
、null
、list
、Stringがキーの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));
の部分で、ソケットが切断されたタイミングをdone
Futureから取得し、コネクションリストから除外しておく事です。
この上で、ブロードキャストする時は、コネクションリストの中からreadyState
がOPEN
のものについてforEach
でsocket.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変数を利用しています。
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変数に定数(のようなモノ)を定義しておくテクニックは広く使い道があると思います。
あとがき
Future
やStream
周りの処理をキメるとかなり気持ちよいですね。何か「俺はDartしてるんだぞおぉぉ」という感じがして。
それはそれとして、Dartのコア系のパッケージ(dart:io
とかdart:async
とか)はDartで実装されてる部分もかなり多く、使い方が分からない部分もソースコード見ればある程度理解できるので楽ちんです。利用方法をググるよりもソース見たほうが早い事もちらほら。実際のDartTeamによる実装を見るという意味においても非常に勉強になります。
というわけで、今回フレームワークを使わずにアプリを実装してみたわけですが、開発効率とか出来上がりの美しさとかGoodな設計思想とかそういうのとは無縁な糞が出来たものの、得られた知見はいろいろと大きいものでした。やはり、自分でガリガリと書いていかないと細かいところで理解できてなかったところもたくさんあり、ハマりつつコケつつとりあえず実装できました。よかった。
Railsのようなフルスタックフレームワークが存在せず、どういったフレームワークだとDartに向いてるのか、みたいなところの理想型のイメージもあまり持ててないのですが、そういうのが登場するまでは、マイクロフレームワークをゴリゴリとカスタマイズしていくしかないと思うので、こういった基礎的な知見というのは何らかの役に立つのではないかと思います。
さて、もう2014年も終わりです。2015年は立派なDartisans(Dartユーザーの事)になれるよう精進したいと思います。
それでは、良いお年を。