OIC ITCreate Club Advent Calendar 2018 22日目担当のかずきちです.
数日前から必死こいてまとめたWebSocket + その他Web周辺技術のまとめです.
きちんとした情報が得たい方はこの記事をいますぐ閉じてRFCでも読んでいてください.
Ajaxが生まれるまで
1980年代後半に欧州原子核研究機構(CERN)に所属していた物理学者のティム・バーナーズ・リー氏がWWW(World Wide Web: 以下Web)を考案してからWebページを取り巻く通信技術は様々な変遷を辿ってきた.
はじめはWebブラウザの画面を書き換えるには, リロードやリンク,フォームによる画面遷移が必要な静的なWebページだった.
1990年代半ばにJavaScriptが登場し, それを用いてDHTML(Dynamic HTML)と呼ばれる, より動きのあるWebページが作られるようになった.
これにより, 動的なWebページが作成できないといった問題が解決されたが, 新しい情報を取得するためには相変わらず画面遷移が必要であった.
それからしばらくしてmetaタグやJavaScriptによる定期的な画面のリロードによって新しい情報を取得するWebページや, 隠しiframeを使用してサーバーと通信し, 画面遷移なしで新しい情報を取得するWebページが作成された.
しかし, 複雑で簡単に扱えないため, Ajaxが誕生した.
Ajaxの誕生
Ajaxは「Asynchronous JavaScript + XML」の略.
Asynchronousには「非同時性の」という意味があり, その名の通りJavaScriptとXMLを用いて非同期にサーバと通信を行う技術である.
同期通信の場合, Webブラウザからサーバーにリクエストをするとリロードがかかり, 画面が一瞬白くなり, レスポンスが返ってくるまで他の作業は行えなくなる.
しかし, 非同期通信の場合, Webブラウザからサーバーにリクエストをしてもリロードがかからず, レスポンスが返ってくるまでの間も他の作業を行える.
これにより, ユーザビリティの向上やサーバ負荷の軽減といったメリットが生まれる.
Ajaxという仕組みを実現するための技術は,
- XMLHttpRequest
- JavaScript
- DOM
- XML
- JSON
の主に5つである.
XMLHttpRequest
JavaScriptの組み込みオブジェクトであり, クライアント - サーバー間でHTTP通信を行うための機能をクライアント側で提供するAPIで, ページ全体を再読込することなく, URLからデータを取得する方法を提供する.
XMLHttpRequestはあくまでHTTP通信を行うためのオブジェクトであり, Ajaxはその応用先の1つである.
ちなみに, 古いIEではXMLHttpRequestの実装が微妙に異なることがあるため注意が必要である.
XMLHttpRequestの例
以下のコードはJavaScriptのXMLHttpRequestを使用して, GitHubのユーザ情報を取得するサンプルである.
const userId = 'tyokinuhata'
const xhr = new XMLHttpRequest()
xhr.open('GET', `https://api.github.com/users/${userId}`)
xhr.send()
xhr.addEventListener('load', (event) => {
console.log(event.target.status)
console.log(event.target.responseText)
})
200
test.js:9 {
"login": "tyokinuhata",
"id": 16177316,
"node_id": "MDQ6VXNlcjE2MTc3MzE2",
"avatar_url": "https://avatars2.githubusercontent.com/u/16177316?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/tyokinuhata",
"html_url": "https://github.com/tyokinuhata",
"followers_url": "https://api.github.com/users/tyokinuhata/followers",
"following_url": "https://api.github.com/users/tyokinuhata/following{/other_user}",
"gists_url": "https://api.github.com/users/tyokinuhata/gists{/gist_id}",
"starred_url": "https://api.github.com/users/tyokinuhata/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/tyokinuhata/subscriptions",
"organizations_url": "https://api.github.com/users/tyokinuhata/orgs",
"repos_url": "https://api.github.com/users/tyokinuhata/repos",
"events_url": "https://api.github.com/users/tyokinuhata/events{/privacy}",
"received_events_url": "https://api.github.com/users/tyokinuhata/received_events",
"type": "User",
"site_admin": false,
"name": "Kazukichi",
"company": null,
"blog": "https://tyokinuhata.github.io/portfolio/",
"location": null,
"email": null,
"hireable": null,
"bio": "元気しとぉや!",
"public_repos": 79,
"public_gists": 0,
"followers": 36,
"following": 40,
"created_at": "2015-12-06T14:53:25Z",
"updated_at": "2018-12-13T15:48:05Z"
}
デベロッパーツールのネットワークタブからXHRを選択すると, レスポンスの様々な情報を確認できる.
DOM
DOM(Document Object Model)はHTMLやXMLのドキュメントをプログラムから利用するためのAPIである.
DOMではHTMLドキュメントやXMLドキュメントを「オブジェクトのツリー状の集合」として扱い, このツリーをDOMツリーと呼ぶ.
DOMツリーの1つ1つのオブジェクトはノードと呼ばれ, あるノードと他のノードの関係には,
- 親ノード
- 子ノード
- 兄弟ノード
- 先祖ノード
- 子孫ノード
がある.
DOMの仕様は以前はW3Cにより策定されていたが, 現在はWHATWGにより策定されており, Level1 ~ Level4が定義されている.
Documentオブジェクト
DOMツリーのルートノードのオブジェクトで, そのHTMLドキュメント全体を表現するオブジェクトである.
DocumentオブジェクトはJavaScriptにおいて, documentという名前のグローバル変数でアクセスできる.
DOM操作の例
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Example of DOM</title>
</head>
<body>
<ul id="foods">
<li>ラーメン</li>
<li>焼き肉</li>
<li>寿司</li>
</ul>
</body>
</html>
const foods = document.getElementById('foods')
console.log(foods)
"<ul id='foods'>
<li>ラーメン</li>
<li>焼き肉</li>
<li>寿司</li>
</ul>"
document.getElementById('ID名')
で対象となる要素を取得できる.
このとき, IDはDOMツリーの中で一意に定まっている必要がある.
他にもタグ名/クラス名による要素の取得, 要素の追加/削除など様々な操作ができる.
これがなぜAjaxに必要かというと, 動的なWebページを作成する際に, HTML/XMLのどの要素を変更するか指定する必要があるからである.
コラム: W3CとWHATWG
W3C(World Wide Web Consortium)とは, Web上で使用される各種技術の標準化を推進するために設立された標準化団体, 非営利団体である.
ティム・バーナーズ・リー氏が創設した.WHATWG(Web Hypertext Application Technology Working Group)とは, HTMLとその関連技術を開発するためのコミュニティで, W3Cに不満を持った開発者によって結成された.
対抗組織として始まった組織だが, 現在はW3Cと共に提唱・策定を推めている.
XML
XML(Extensible Markup Language)はHTMLなどと同じく, マークアップ言語の1つ.
文書やデータの意味/構造を記述するためのマークアップ言語で, HTMLと違い自由にタグを設定できる.
<foods>
<food>
<name>ラーメン</name>
<description>すする</description>
</food>
<food>
<name>焼き肉</name>
<description>焼く</description>
</food>
<food>
<name>寿司</name>
<description>にぎる</description>
</food>
</foods>
Ajaxにおいては主にクライアント - サーバー間のデータのやり取りに用いられる.
ただしXMLはパースや生成が複雑なため, 現在はJSONの方が使われる傾向にある.
JSON
JSON(JavaScript Object Notation)は軽量なデータ交換フォーマットで, 人間にとって読み書きが容易であり, かつマシンにとってもパースや生成が楽に行える形式である.
[
{
"name": "ラーメン",
"description": "すする"
},
{
"name": "焼き肉",
"description": "焼く"
},
{
"name": "寿司",
"description": "にぎる"
}
]
JSONの仕様はこれまでRFC7159が参照されていたが, 2017年に新たにRFC8259が策定されたことによって廃止(Obsolete)された.
https://www.rfc-editor.org/rfc/rfc7159.txt
https://www.rfc-editor.org/rfc/rfc8259.txt
JSONの仕様に関してはIETFが策定するRFCとは別にEcma Internationalが策定するECMA-404がある.
https://www.ecma-international.org/publications/standards/Ecma-404.htm
しかし, RFC8259の公開に合わせて両者はJSONの仕様を共通化することに合意し, ECMA-404 2nd Editionが承認され, RFC8259とECMA-404 2nd Editionは共通の仕様となった.
ちなみにRFC8259ではUTF-8によるエンコーディングが必須化された.
コラム: IETFとEcma International
IETF(Internet Engineering Task Force)はインターネットで利用される技術の標準化を行う組織である.
最終的に策定された仕様はRFC(Request for Comments)としてインターネット上に公開される.Ecma Internationalは情報通信システム分野における国際的な標準化団体で, JavaScriptの仕様であるECMAScriptの策定などを行っている.
Ajaxによる非同期通信の流れ
- ページ上で任意のイベントが発生(例: ボタンのクリック)
- JavaScriptのXMLHttpRequestからサーバーに対してリクエストを送信
このとき, リクエストの送信先, リクエストの形式, サーバーに対して送るデータなどを指定する. - サーバーで受け取ったデータを処理(例: DBに受け取ったデータを格納)
この間もクライアントでは作業を継続できる - サーバーからのレスポンスを受け取り, DOMを書き換え
このとき, ページ内の該当箇所のみを書き換えるためWebブラウザにリロードがかかることはない.
Ajaxの通信の起点はクライアントであり, サーバーを起点とした通信ができないため, 次章で示すようなプッシュ技術が続々と誕生した.
Webにおけるプッシュ技術
サーバーを起点とした通信をするための技術をプッシュ技術と呼び, プッシュ技術には以下のようなものがある.
ポーリング
ポーリングはクライアントから一定間隔でサーバーにリクエストを送り, サーバー側に新着情報があればそれをレスポンスとして返してもらう方法.
欠点は最大でリクエストを送る間隔分だけ遅延が発生することである.
ロングポーリング
ロングポーリングはCometとも呼ばれ, クライアントから一定間隔でサーバーにリクエストを送るのはポーリングと同様だが, リクエストを受け取ったサーバーは新着情報があれば即座にレスポンスを返し, なければ一定時間経過後にレスポンスを返すというものである.
通信内容はポーリングとほぼ変わらないが, サーバー側の実装が複雑になる.
SSE
SSE(Server-Sent Events)はロングポーリングと同様, リクエストを受け取ったサーバーは新着情報があれば即座にレスポンスを返し, なければ一定時間経過後にレスポンスを返すが, 新着情報が発生する度にレスポンスが完結するわけではなく, 新着情報が発生する度に長さ不定のレスポンスを流し続ける形式となっている.
これはレスポンスボディの長さを不定にできるというHTTPの規約(チャンク)を活かしており, 新着情報が発生する度にリクエスト・レスポンスが必要なロングポーリングのオーバーヘッド問題を解決するものである.
JavaScriptで実装する際は, 通常XMLHttpRequestではなくEventSourceというAPIを使用する.
チャネルを複数持つこともできる(ただしSSEではeventと呼ばれている).
SSEは通常のHTTPのGETリクエストと粗方同じセキュリティ対策で対応でき, SOPも適用される.
SSEの欠点として,
- サーバーからクライアントへの単一方向通信しかできない
- テキストデータのみしか送信できず, バイナリを送りたい場合はBase64でエンコーディングする必要がある(Base64を使うことによってデータ量は約33%増加するが, HTTP上で実現されている技術のためgzipで圧縮可能)
- 文字エンコーディングはUTF-8しか使用できず, それ以外の文字エンコーディングを使う場合にはBase64でエンコーディングする必要がある.
- ブラウザはIE, Edgeは対応しておらず, Polyfillを使う必要がある(https://caniuse.com/#feat=eventsource を参照).
- HTTP対応のリバースプロキシで動作するが, ある程度の設定が必要になる.
- 定期的に何かメッセージを送らないとブラウザやリバースプロキシが通信を切断することがある.
- 同時接続数は6 ~ 13と少なく, SSE専用のサブドメインを用意して回避する必要がある.
コラム: リバースプロキシ
リバースプロキシとは, クライアントとサーバーの中間に入り, サーバーの応答を代理するサーバーである.
セキュリティ対策, 性能向上, 負荷分散, システムの自由度向上などを目的に利用される.
コラム: Base64
Base64とは64進数を意味する言葉で, 全てのデータをアルファベット(a
~z
,A
~Z
), 数字(0
~9
), 記号(+
,/
)に加え, データ長を揃えるためのパディング用の記号=
の64 + 1文字で表現するエンコーディング方式である.
詳しい実装については拙著(http://kazukichi.hatenablog.jp/entry/2017/11/28/205606) を参照すると良い.
コラム: gzip
gzipとは, 「GNU Zip」の略で, データ圧縮形式及びデータ圧縮プログラムの一つ.
RFC1952で標準化されている.
https://www.ietf.org/rfc/rfc1952.txt
HTTPにおいてもサポートされており, gzip圧縮を上手く利用することで転送速度の向上に繋がる.
コラム: Polyfill
Polyfill(読み: ポリフィル)とは一種のライブラリである.
仕様で策定されている機能だが, 古いブラウザでは実装されていないという場合に, APIが全く同じ互換実装を提供する.
前述した通り, ポーリングやロングポーリングにはオーバーヘッドが大きいという欠点があり, 高頻度に小サイズのデータが飛び交う状況の場合, オーバーヘッドが実データの数倍になりかねない.
SSEもサーバーからの単一方向通信しかできないなど, 通信を阻む様々な問題がある.
そこで生まれたのがWebSocketである.
WebSocketの誕生
WebSocketとは, Web上で低コストに双方向通信を実現するためのプロトコルである.
前章で解説したプッシュ技術の欠点を補うべくして誕生した.
WebSocketは元々HTML5の仕様の一部として策定されていたが, 現在は独立したプロトコルとして策定されている.
WebSocketの利点
WebSocketはサーバーとクライアントが一度コネクションを確立すると, そのコネクション上で通信を行う(つまりHTTPのようにリクエストの度にコネクションを張るようなことはない)ため, 通信量の削減になる.
また, 張ったコネクション上で, サーバーとクライアントのどちらからでも通信を行うことができる(双方向通信).
ヘッダのサイズも最小2byte ~ 最大14byteとかなり小さいため, 通信量の削減になる.
これは, ポーリングやロングポーリングにおける欠点を克服しているといえる.
文字エンコーディングについても制限がなく, バイナリも送信できる.
大抵のリバースプロキシは対応しており, nginxでは数行の設定を書くだけで対応できる.
チャネルも複数持つことができる.
これは, SSEにおける欠点を克服しているといえる.
基本的にIE10以降のモダンブラウザでは対応されているおり, 詳細については https://caniuse.com/#feat=websockets を参照すると良い.
ちなみに, http://www.websocket.org/echo.html からWebSocketのテスト接続ができるのでこちらを試すのも良い.
WebSocketの欠点
先発のプッシュ技術の様々な欠点を克服しているとはいえ, WebSocketも銀の弾丸ではなく, 欠点は存在する.
リバースプロキシ
Squidを代表とするリバースプロキシがWebSocketを通してくれないことがある.
これは, HTTPのCONNECTメソッドを443ポート以外に使用することを禁止するというポリシー設定項目が存在し, デフォルトでONになっているためである.
HTTPプロキシへアクセスする方法は大きく分けて2通りあり, 1つ目はGET/POSTメソッドをそのまま与える方法である.
これはHTTPプロキシに対してGET http://example.com/ HTTP/1.1
というリクエストを送ると, HTTPプロキシはexample.comに対して, そのままGET http://example.com/ HTTP/1.1
というリクエストを送る方式である.
2つ目はHTTPプロキシに対してCONNECT example.com:443 HTTP/1.1
というリクエストを送ると, HTTPプロキシはexample.comの443ポートに接続し, 以降はTCPストリームを中継する.
なぜこのような方式があるのかというと, それはHTTPSのためで, クライアントがGET/POSTをHTTPプロキシに対して与える方式の場合, レスポンスの内容を中継することはできるが, HTTPプロキシがSSLを復号してしまうため, ブラウザまで暗号化された状態で届けることができない.
HTTPSを中継するためには, HTTPプロキシは何も手を出さない必要があり, そのためにCONNECTメソッドが存在する.
WebSocketもHTTPプロキシに手を出さないでもらう必要があるため, 後者のCONNECTメソッドを使用する.
しかしCONNECTメソッドは自由なTCP接続を許してしまうことにより, セキュリティ的に危険な状態になるため, 443ポート以外を対象としたCONNECTメソッドはデフォルトで禁止されているのである.
SSLでないWebSocketは80番ポート宛なので, このセキュリティ設定に引っかかりHTTPプロキシで止められてしまう.そのため, SSL化する必要がある.
SSL化されたWebSocketをSecure WebSocket(wss)と呼ぶ.
i-FILTER
i-FILTERは国内で圧倒的なシェアを誇るWebフィルタリングソフトウェアである.
i-FILTERはWebSocketのプロトコルを理解できず, WebSocketを止めてしまう.
アプリケーション側で対策することはできないため, i-FILTERの設定で当該ホスト名またはIPアドレスを許可してもらう必要がある.
Cloudflare
CloudflareはCDN(Content Delivery Network)サービスの一つで, ウェブコンテンツをインターネット経由で配信するのに最適化されたネットワークを提供する.
現状, WebSocketをCloudFlareに通す場合は有料のEnterpriseプランが必要になる.
到達が保証されない
到達順は保証されるが, 「どこまで届いたか」が送信側に分からない.
WebSocketが切断された場合, 直前に送信したメッセージが届いている保証がないため, 再接続した際にどのメッセージから再送信するべきなのかを決定するのに一工夫必要になる.
また, Apacheやnginx等でリバースプロキシを立てた場合, 帯域が細いせいで送信先のサーバーまでメッセージが届くのに時間がかかっていても, リバースプロキシまでは一瞬で届くため一見成功しているように見える.
しかし実際はリバースプロキシのバッファで詰まっている状態ということがある.
高頻度にデータが送信されている場合, サーバーのメモリを圧迫するため注意が必要である.
これらの問題を解決しようとすると, 送信したメッセージに対して受信側からACKを返してもらうといった工夫が必要になる.
SOPが無い
WebSocketにはSOPという仕組みが存在しないため, Cross-Site WebSocket Hijacking(CSWSH)の対策, Cross-Site WebSockets Scripting(XSWS)の対策が必要となり, Origin
ヘッダをチェックする, Content-Security-Policy
ヘッダを活用してXSSしにくくするなどの対策をする必要がある.
URIスキームはws
またはwss
である.
コラム: オリジン
オリジンとは, URIスキームとホスト名とポート番号の組み合わせのことである.
コラム: URIスキーム
URIスキームとは, URI(URL, URN)で用いられる名前の一つで処理方法を表す識別子である.
代表的なものにはhttp
やhttps
,data
,file
,ftp
などがある.
URIスキームの衝突を避けるために登録が必要となっており, 手順はRFC4395で定義されている.
https://www.ietf.org/rfc/rfc4395.txt
一覧についてはIANAにて公開されている.
http://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
コラム: SOP
SOP(Same-Origin Policy)は日本語で同一オリジンポリシーと呼ばれる.
SOPは端的に言うと, オリジンが同じものは同じ保護範囲のリソースとして取り扱うというものである.
XSSなどの攻撃から守るために重要な役割を果たしている.
RFC6454で策定されている.
https://www.ietf.org/rfc/rfc6454.txt
例えば,http://example.com/foo
とhttp://example.com:80
はそれぞれ同一のURIスキーム(http
), ホスト名(example.com
), ポート番号(80
)を持つため, 同一のオリジンであると判断される.
一方で,https://example.com/foo
とhttp://example.com/foo
はどちらも同じホスト名, ポート番号を持つが, URIスキームがそれぞれhttps
,http
であるため, 異なるオリジンであると判断される.
二つのオリジンが同一でない場合, それを「クロスオリジン」と呼ぶ.オリジンに依存して動作が制約されるものとしては以下のようなものがある.
・XMLHttpRequest
同一オリジンでは無条件にリソースにアクセスできるが, クロスオリジンでは許可された場合にしかリソースにアクセスできない.
・Canvas
Canvasに表示された画像は, 同一オリジンであれば画像データにJavaScriptでアクセスできるが, クロスオリジンの場合は許可された場合にしかアクセスできない.
・Web Storage
データの保存される単位はオリジンに基づくため, クロスオリジンにあるデータの読み書きはできない.一方で, オリジン以外をベースに動作の制約を定めているものには以下のようなものがある.
・Cookie
path
やdomain
の指定が可能であり, デフォルトでhttp
とhttps
のCookieは共有される.
・<script src="..."></script>
によって読み込まれるJavaScriptファイル
これを利用してJSONP(JSON with Padding)という仕組みが実現されている.
・<link rel="stylesheet" href="...">
によって読み込まれるCSSファイル
<img>
,<audio>
,<video>
などのメディアファイル
@font-face
で指定されたフォント
<frame>
,<iframe>
コラム: CORS
CORS(Cross-Origin Resource Sharing)は日本語でオリジン間リソース共有と呼ばれる.
HTTPヘッダを追加することにより, あるオリジンで動作しているアプリケーションに対し, 異なるオリジンにあるリソースへのアクセスを許可することができる仕組みである.
具体的には, レスポンスのAccess-Control-Allow-Origin
ヘッダにリクエストのOrigin
ヘッダと同じオリジンを入れてレスポンスを返すことで, Webブラウザはそのレスポンスを受け入れることとなる.
この値に*
を指定すると, どのオリジンからでもアクセスできるということを明示できる.
コラム: CSP
CSP(Contents Security Policy)とは, XSSや各種インジェクション攻撃など, よく知られた攻撃を検出して軽減するために追加されたセキュリティレイヤーである.
サーバーからWebブラウザに対してコンテンツの使用ポリシーを伝えることで各種攻撃から保護する.
CSPを記述することでコンテンツの提供元や取得方法を制限することができ, コンテンツの提供者が意図しないようなコンテンツが読み込まれるのを阻止することができる.
WebSoketの仕様
以下ではWebSocketの仕様であるRFC6455の主要な部分を抜粋した.
https://www.rfc-editor.org/rfc/rfc6455.txt
コネクションの確立
WebSocketでの通信に先立って, HTTPでコネクションの確立を行う.
クライアントから以下のようなHTTPリクエストを送る.
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
1行目にリクエスト行と呼ばれるメソッド名(GET
), リクエストURI(/chat
), プロトコルバージョン(HTTP/1.1
)がの並びがあり, HTTPとして正しいリクエストとなっている.
HTTPとして正しいリクエストを満たす条件は, リクエスト行が存在し, リクエストURIが相対URIであればヘッダーにホストを記載するのみである.
上記はそれを満たしているため, 正しいHTTPリクエストと言える.
また, 2行目以降のヘッダではUpgrade
ヘッダやConnection
ヘッダが存在する.
これら2つのヘッダが存在することでHTTPからWebSocketへプロトコルを切り替えることを示している.
Sec-WebSocket-Key
ヘッダは特定のクライアントとコネクションの確立を立証するために使用される.
Sec-WebSocket-Version
ヘッダはプロトコルのバージョンを指定するヘッダである.
WebSocketの最新バージョンは13であるため, 13が指定される.
Sec-WebSocket-Protocol
ヘッダはサブプロトコルのリストである.
サーバーからは以下のようなレスポンスを返される.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
こちらもHTTPとして正しい形式のレスポンスである.
1行目がステータス行と呼ばれるもので, プロトコルバージョン(HTTP/1.1
), ステータスコード(101
), テキストフレーズ(Switching Protocols
)の並びがあり, ステータス行がある時点で正しいHTTPレスポンスである条件を満たしている.
Sec-WebSocket-Accept
ヘッダはSec-WebSocket-Key
ヘッダを元に値を算出される.
クライアント側でもSec-WebSocket-AcceptP
ヘッダの値がSec-WebSocket-Key
ヘッダの値を元に算出された値かどうかを確認できるようになっており, 自分のリクエストに対するレスポンスかどうかを検証できるようになっている.
これによりコネクションが確立し, これ以降はHTTPではなくWebSocketにより通信が行われる.
ちなみに, このコネクションの確立までの一連の流れをopeningハンドシェイクと呼ぶ.
通信の開始
ハンドシェイクによってコネクションが確立すると, TCP上でWebSocketによる双方向通信を行えるようになる.
送信データはフレームという単位で扱われ, 下図のような構成になっている.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
Payload Data
という部分にWebアプリケーションが送りたいメッセージを格納するのだが, Payload Data
以外の情報は最小2byte ~ 最大14byteに収まるようにできており, HTTPに比べるとかなり低コストである(HTTPの場合数100byteのリクエストヘッダになることもある).
opcodeと呼ばれる4bitの値(%x0
~ %xF
)はフレームの種類を定義している.
フレームには現状6種類あり,
opcode | フレームの種類 |
---|---|
%x0 | 継続フレーム |
%x1 | テキストフレーム |
%x2 | バイナリフレーム |
%x8 | クローズフレーム |
%x9 | ping |
%xA | pong |
を表している.
%x1
/%x2
はデータフレーム(非制御フレーム), %x8
/%x9
/%xA
は制御フレームと呼ばれている.
データフレームがWebアプリケーションのデータを送信するために使うフレームで, テキストデータかバイナリデータかで使い分けることができる.
制御フレームはWebSocketの通信制御に用いられ, 通信の切断(closingハンドシェイク)にクローズフレームが使われたり, 通信できる状態にあるのかを確認するping/pongがある.
また, opcodeには現状は使用されていないが将来のために予約されている値があり, %x3
~ %x7
は非制御フレーム用に, %xB
~ %xF
は制御フレーム用に予約されている.
WebSocketのライブラリ
一番代表的なのはSocket.IOで, WebSocketでの通信を簡単に扱えるようにしたNode.jsとJavaScriptのライブラリのセットである.
Socket.IOはWebSocketで繋がらなかった場合に, 裏で自動的にポーリングに切り替えてくれるなどかなり優秀である.
JavaScriptだけでなく, その他の主要な言語でもそれぞれWebSocketのライブラリが提供されている.
WebSocketに適したサーバー
WebSocketはクライアントとサーバーの間でTCPコネクションを張りっぱなしにして通信を行うため, ブロッキングI/Oを持つWebサーバーとは相性が悪い.
ブロッキングI/Oを持つサーバープロセスは一度に一つのクライアントとしかコネクションを張れないため, WebSocketを使いたければクライアントの数だけサーバープロセスが必要になる.
そういった状況になることを避けるため, 通常WebSocketを使用する場合は, ノンブロッキングI/Oを持つサーバーが推奨される.
一番有名なのはNode.jsだが, RubyのThinやRainbows!など様々である.
WebSocketの実装例
WebSocketの簡単なサンプルとして, ボタンを押したらWebSocketでメッセージが送信され, コンソール上に送ったメッセージが表示される簡易的なチャットアプリを作成する.
開発環境として, WebブラウザはGoogle Chromeを使用する.
また, サーバー側はNode.jsで実装(WebSocketのライブラリとしてwsを使用)し, クライアント側はJavaScript(WebSocketのライブラリとしてWebSocket APIを使用)で実装した.
Node製のWebSocketライブラリといえばSocket.IOが有名だが, wsはWebSocket.IOほど多機能でないがシンプルな作りで非常に高速に動作する.
ちなみにSocket.IOの内部でもwsが使われている.
プロジェクトの作成
サンプルの実装に先立ってプロジェクトを作成する.
$ mkdir ws-example
$ cd ws-example
$ touch server.js client.html client.js
$ npm init -y
$ npm i ws --save
サーバー側
サーバーはws://127.0.0.1:5001
で待ち受ける.
コネクション確立時の処理, メッセージを受信する処理, 受信したメッセージを他のクライアントにプッシュする処理, コネクション切断時の処理を書く.
const server = require('ws').Server;
const s = new server({port: 5001});
// コネクション確立時
s.on('connection', ws => {
console.log('Connection opened.')
// メッセージを受信
ws.on('message', message => {
console.log('Received: ' + message)
// 受信したメッセージを他のクライアントにプッシュ
s.clients.forEach(client => {
client.send(message)
})
})
// コネクション切断時
ws.on('close', () => {
console.log('Connection closed.')
})
})
クライアント側
HTMLはボタンの表示とJavaScriptの読み込みのみ記述する.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Example of WebSocket</title>
</head>
<body>
<button type="submit" id="send">Send</button>
<script src="client.js"></script>
</body>
</html>
// メッセージの送信先を指定
const ws = new WebSocket('ws://127.0.0.1:5001')
// コネクション確立時
ws.addEventListener('open', event => {
console.log('Connection opened.')
})
// メッセージを受信
ws.addEventListener('message', event => {
console.log('Received: ' + event.data)
})
// メッセージを送信
document.getElementById('send').addEventListener('click', event => {
ws.send('hello!')
})
// コネクション切断時
ws.addEventListener('close', event => {
console.log('Connection closed.')
})
起動する
node server.js
でサーバーを起動する.
$ node server.js
$ open client.html
$ open client.html
デベロッパーツールのコンソールを開いてSend
を押すと, デベロッパーツールのコンソールタブ, サーバー側のコンソールのそれぞれでメッセージが表示されるのが分かる.
また, 送信したタブとは別で開いてあるタブでもメッセージが受信されるため, 確認すると良い.
デベロッパーツールのネットワークタブからWSを選択して確認すると, 確かにWebSocketで通信されていることが分かる.
ちなみに, プロトコルの仕様はIETFのRFCにて策定されているが, APIについてはW3Cにて策定されている.
https://www.w3.org/TR/websockets/
さいごに
人類にWebSocketは早かった.