Edited at

サーバからクライアントに送信する技術 - WebSocketを中心に

More than 1 year has passed since last update.


Webでのプッシュ技術

HTTPはクライアント(ブラウザ)からリクエストしてサーバからレスポンスが返る一問一答型のプロトコルなので、基本的にはサーバ側からブラウザに新着情報をリアルタイムで通知(プッシュ)できるようにはできていません。

しかしそれでもプッシュをしたいという場合にどうするかという話が出てきます。やり方には以下のようなものがあります。



  • ポーリング

    クライアントからサーバに定期的に新着を問い合わせるようにします。
    最も原始的かつ確実なやり方。欠点は、最大でポーリング間隔の分だけ通知が遅延しうることです。


  • ロングポーリング(“COMET”)

    ポーリングなのですが、問い合わせを受けたサーバは新着情報がなければレスポンスを返すのをしばらく保留します。

    そのあいだに新着情報が発生すれば即座にレスポンスを返しますし、一定時間経過したら何もなかったとレスポンスを返しましょう。

    飛び交う通信内容はポーリングと変わらないのですが、サーバ側の実装は一気に複雑になります。
    サーバ側でスレッド資源を浪費しないような工夫(非同期プログラミング)も必要になってきます。

    反面、クライアント側のプログラミングは容易で、ポーリングのときとほぼ同じ内容になります。


  • SSE(Server-Sent Events)

    ロングポーリングと同じく下り情報が何か発生するまでレスポンスを保留するところまでは同じなのですが、情報が発生するたびにレスポンスが完結するのではなく、長さ不定のレスポンスをだらだら返し続ける(情報が発生するたびにレスポンスの続きが来る)形です。レスポンスボディの長さを不定にできるというHTTPのルール(chunked)を活かしています。

    下り情報が発生するたびにリクエスト・レスポンスが必要というロングポーリングのオーバーヘッド問題を解消するものです。

    クライアント側のプログラミングはポーリングと同じというわけにはいかなくなります。ブラウザ上のJavaScript環境の場合はXHRでなくEventSourceというAPIを利用するのが普通です。


  • WebSocket

    SSEのところでも触れたとおり、ロングポーリングにはパフォーマンス上の欠点があります。毎回の下り情報取得ごとにHTTPのヘッダが飛び交うという通信上のオーバーヘッドです。

    高頻度・小サイズのデータがどんどん飛んでくるような状況だとオーバーヘッドが実データの数倍になりかねません。

    そこでオーバーヘッドなしに双方向通信できるようにしようという規格がWebSocket。

    HTTP(HTTPS)の体裁でリクエストするのですが、Upgradeというヘッダを付けることですぐにHTTPの枠外に飛び出し、一問一答というルールにとらわれずに小さな(最短で2バイトの)ヘッダだけ付けて電文をやりとりします。Socketという名前にやや反し、バイト単位のストリームではなく電文単位のストリーム。

MQTTは? WebRTCは? これらはここでは注目しないことにします。あくまでHTTP/HTTPS上のプッシュ技術ということで。だって、企業内環境ってHTTPプロキシ通さないと外出られないようになってるもんじゃないですか。HTTP/HTTPSじゃない時点で環境が限られてしまい、使いにくいんですよね。


WebSocketを阻むものたち

WebSocketは上記4方式の中でもリアルタイム性が高く、特に上り方向もリアルタイムで到着順保証があるという他にないメリットがあるほか、プログラミングモデルがシンプルになる、標準ライブラリか安定したOSSライブラリがだいたいのプラットフォームに存在する、と利点の多い方式です。

ところが実際使ってみると、意外に接続性でトラブって泣かされることがあるのです。


HTTPプロキシ

HTTPプロキシの存在を意識してMQTTやWebRTCは最初から考えないことにした、と上で書きました。しかし、squidに代表されるHTTPプロキシが意外にWebSocketを通してくれません。これは、squidに 【HTTPのCONNECTメソッドを443ポート以外に使うのを禁止】 というポリシー設定項目が存在し、しかもデフォルトでONであることによります。

CONNECTメソッドとは何か。

まず、HTTPプロキシへのアクセスの仕方は大きく分けて二通りあります。



  • GET/POSTメソッドをそのまま与える方法
    プロキシに対して "GET http://qiita.com/ HTTP/1.1" などと話しかける方法です。するとプロキシは qiita.com に接続して "GET / HTTP/1.1" と問い合わせ、そのレスポンスをこっちに返してくれます。


  • リモートサーバとのTCP接続を要求する方法
    プロキシに対して "CONNECT qiita.com:443 HTTP/1.1" と話しかけます。するとプロキシは qiita.com の443ポートに接続して、以降は自由にTCPストリームを中継してくれます。
    なぜこのような方法があるか? それはHTTPSのためです。GET/POSTを与える方法でHTTPSサイトにアクセスすると、レスポンスの内容を中継することはできますがプロキシがSSLを復号してしまうためブラウザまで暗号化した状態で届けることができません。HTTPSを中継するためには、プロキシは何も手を出さない必要があり、そのためにCONNECTメソッドがあります。

この後者のCONNECTメソッド、WebSocketクライアントもこれを使います。WebSocketは一問一答のアクセスではないのでプロキシには手を出さないでもらう必要がありますから。

ところがここでセキュリティ対策。CONNECTメソッドはやっぱり危ないわけです。自由なTCP接続を許してしまうので、ある意味ファイアーウォール築いてプロキシサーバを置いている意味がまったくなくなるものです。だから、443ポートを対象にするCONNECTメソッド以外は拒否する、これがデフォルトになっているわけ。

SSLでないWebSocketは80番ポート宛なので、このセキュリティ設定に引っかかりプロキシサーバで止められてしまいます。対策は、SSL化すること。SSL化したWebSocketをSecure WebSocket(wss)と呼びます。


i-FILTER

国内で圧倒的なシェアを誇るWebフィルタリングソフトウェアがi-FILTERです。

Webフィルタリングとはどういう仕組みでネットワークに導入されるか? 簡単な話、これもプロキシサーバの一種だと思ってください。

i-FILTERは上記のプロキシサーバの事情とは別の理由でWebSocketを止めてしまいます。フィルタリングポリシーに違反するからかと思いきやそうではなく、WebSocketのプロトコルを理解できずに手を出してこけているというもの。しかも2重にこけています。


  • サーバからのレスポンスヘッダのうち、Connection ヘッダを書き換えてしまいます。これでWebSocketの規格違反になり、大部分のWebSocketクライアントライブラリは接続失敗と判定します。

  • JavaScript以外のクライアントなら、クライアント側をごにょごにょして Connection ヘッダの規格違反を無視するようにできるでしょう。しかしそうしても、上りのメッセージがサーバに届きません。

    ここの仕組みはまだ完全には解析できていませんが、おそらくi-FILTERが一問一答型の通信を想定しているため、上りメッセージもHTTPリクエストだと解釈し、ヘッダセクションだとして解析しようとバッファしてそのまま止まってしまっている、そんな動きに見えます。

ソフトウェア側での対策はできず、i-FILTERの設定をいじってホスト名だかIPアドレスだかを基準に除外設定してもらうしかありません。


SSL MITM

WebSocketをSSL化してしまえばそのi-FILTERだって手を出せないのではないか?

いえ、出してきます。それを可能にするのが、SSL MITM(i-FILTERにおいては「SSLアダプタ」)という仕組み。

こうです。


  • プロキシ側でローカルSSL認証局を立てる

  • このローカルSSL認証局の証明書は社内すべてのPCにグループポリシーでルート証明機関として強制インストール

  • プロキシサーバはWebサイトからのアクセスを復号してフィルタリングし、レスポンスをオレオレ証明書で再暗号化してブラウザに返す

  • ブラウザは、ルート証明機関の署名があるオレオレ証明書を正当だと判定する

という仕組み。こういうのはsquidでもできます。i-FILTERではインストールした時点でこのSSLアダプタが「あとはルート証明書をグループポリシーで配れば完了」くらいのところまでお膳立てされているので、結構な確率で有効化されています。そういう環境だと前項の理由で止められてしまいます。

MITMとは「Man in the middle」、中間者という意味です。一般的には盗聴とか改竄とかの攻撃的な文脈で使われる用語。

この問題も、i-FILTERの設定で除外するしか方法はありません。


WebSocketだと困ることたち

数々の困難を乗り越えて接続できたとしても、WebSocketでの通信プログラミングは意外にもバラ色ではありません。


到達が保証されない

到達順は保証されます。が、「どこまで届いたか」が送信側でわかりません。


  • まず、素の(非SSLの)WebSocketだと、切断したとき、直前に送信成功したメッセージが届いている保証がありません。再接続するとして、どのメッセージから再送信開始するかを決定するために一工夫必要になります。

  • SSL化するためにApacheやnginxなどでリバースプロキシを立てた場合が問題。帯域が細いせいでメッセージが届くのに時間がかかっていてもリバースプロキシまでは一瞬で届いてしまうので、アプリ側からは絶好調で送信成功しているように見えます。実はリバースプロキシのバッファに列をなしている状態だっていう。これ高頻度データだとサーバのメモリ負荷にもなりますし怖いです。また、回線状況によって送信を制御しようなんてアプリ上の工夫がまったくできなくなります。

これらの問題を解決しようとすると、送ったメッセージに対して受信側からACKを戻してもらうというプログラミングがどうしても必要になります。

TCPの上に何層も重ねた高レベルプロトコルなのに、ここでACKかよ! 気持ちはわかります。でも仕方ありません。


(C++での問題)Casablancaが意外に小回りが利かない

C++だとCasablancaというOSSライブラリを採用することになると思います(Objective-Cでも結局これになるのでは)。

これ自体は、Futureパターンを徹底してるとかいろいろ先進的なライブラリで悪くないのですが、ことWebSocketの扱いに関して言うと正常系はスムーズに書けるのに例外を処理しようとするとこう、壁が多くて泣かされるのです。

例えば接続を開けなかったとき、HTTPステータスを取得する方法がない。例えばステータスが407だったら「あ、プロキシ認証が必要なのね」と判断してID/PW入力を求めるとかできるはずなのに、検知できなくてそういかない。

また、接続失敗時に独自のエラーコードを返してくれるのですが、このエラーコードが公式にドキュメント化されていないとか。異常系厳しいですね。

CasablancaはOSSなんだから必要なら必要な情報取り回すようにパッチを自分で書け⋯ ってのが対策になります。ふぇぇ


(JavaEEでの問題)非同期送信が到達順を保証しないっぽい

JavaEEのWebSocketサポートだと、メッセージの送信に同期と非同期を選べます。

ところが非同期送信のメソッドが、ドキュメントのどこを読んでも「このメソッドを呼んだ順番でメッセージが届きます」の一言を書いてくれてないんですよ…

(ドキュメントになくてもソースを読めば、といかないのが辛いところ。JavaEEはライブラリでなくインターフェースなので、ドキュメントこそすべてです)

送信順の保証が必要になる場合、同期送信しないといけなくなります。ってことは送信で全体処理がブロックしないようにキュー作って送信スレッドをクライアントごとに立てて… あれあれ、そういうプログラミングなくすためにJavaEEは進化してきたんじゃなかったの? ってなります。


最終的な選択は

プログラミングの大変さはやるしかないと腹を括るとして、特に企業環境での接続性に難あり、というのを考えると厳しくなってくるわけです。企業環境ってことは、お金払ってるんだ繋がりませんねじゃ済まされねーんだってことですから。確実性考えるとポーリング系の方式が結局いいのでは⋯となってきます。


  • ゲームアプリ、個人向けアプリなど「ありゃ繋がりませんね」で済まされ、もしくは企業環境がターゲットになっていないなら、WebSocket悪くないです。ただプログラミングは苦労してください。私の苦労を思い知れ。

  • 上りの高頻度データ転送があるなら、これもWebSocketやるしかありません。下りの高頻度情報だけならSSEでいけるんですけどねえ。接続性でつまずいたらごめんなさいを連呼しつつ地道にネットワーク設定をいじってもらうしか。

  • タイムラグが許される話だったら、単なるポーリングでいきましょう。結局それが、アプリサーバの多重化とかそういうののとき苦しまないで済むので。

  • タイムラグが嫌だとしたら、ロングポーリングです。

    ロングポーリングのいいところは、とりあえず最初はポーリングとして作りはじめられることです。サーバ側だけあとからロングポーリング動作するようにしてもクライアント側は結構無変更でいけます。

  • 高頻度の下りデータが発生するとしたらSSEですかね。

  • サーバもクライアントもJavaScriptならSocket.io使うのが良いんじゃないですかね。WebSocketのライブラリなんですが繋がらなかったらポーリングで代替接続するところまで裏でやってくれるんですって。

    このライブラリのことは詳しくないのでパス。でもフォールバックするところまで含んだこのライブラリみたいな手順をRFCで標準化してくれれば全方面幸せになりそうじゃありませんか。


改版履歴

初版では、レスポンスを終わらせない形式のことをXMPP型と表現していました。これ微妙に間違っていて、XMPPはもっと抽象度の高いプロトコルであり、その中でHTTPをトランスポート層として利用するXMPP over BOSHという方式がこのSSEと同じ形式をとっている、というのが厳密でした。

Webでと言っている以上JavaScriptのAPIにもあるSSEを挙げる方が一般的でしたので書き換えました。