はじまり
汎用リアルタイム通信サーバーを設計するにあたり, 既存のプロトコルの採用ではなく独自プロトコルを作成したのでその時の考えなどをつらつらとまとめます.
前日譚
もともとフィーチャーフォン(ガラケー)向けブラウザゲームを作っていたので, スマホ時代になっても主要な通信は全て HTTP ベースで行われていました. そこそこのオーバーヘッドはありつつも, 社内外にノウハウは豊富ですしステートレスの恩恵を受けて信頼性の高いシステムを構築可能でした.
しかしゲーム業界の方向性がより高頻度の双方向通信を求めるようになってきたことで, ステートフルな通信環境をどうすれば安定して運用できるかを考える必要が出てきました.
通信モデルを決める
独自開発を選択した理由については他の記事に譲るとして, ひとまず目的としては以下の一点です.
- 多クライアント間での双方向通信を可能にする
ただ
- NAT 環境下にいるモバイル端末同士を接続しないといけない
- 最大何台での通信が行われるかが不明
ということもありP2Pは諦め, クライアント/サーバーのスター型ネットワークトポロジーを選択しました.
Pub/Sub
通信単位は扱いやすいのでメッセージ型を.
次にメッセージ配送の管理モデルとして Publish/Subscribe モデルを選択しました.
Pub/Sub モデルではトピックという単位でメッセージを配送します. トピックは文字列です.
通信したいクライアントは事前に打ち合わせた共通の /topic/A というトピックを "購読" します.
その後, メッセージを配信したいクライアントが /topic/A へメッセージを "出版" すると "購読" しているクライアントにメッセージが届きます. それだけです.
ゲームやチャットなどでなじみの深いルーム型でもよかったのですが, 中央サーバーはそれでなくてもクライアントとの通信状態というステートを抱えるため, さらにルームというステートを追加で管理する事でコードを複雑にしたくないというのが不採用の主な理由です.
ただ, このプロトコルの上にルーム的なものを構築可能なようには意識しました.
機能
まずクライアントは
- 接続の確立(&認証)
- メッセージの購読を登録
- メッセージを配信
- 切断
ができないといけません.
さらに
- 購読トピックを他ユーザーが購読を開始/停止した場合の通知
- サーバータイムの取得
- 対象トピックの購読者一覧取得
- UDP 接続の確認・確立
などを実装しましたが今回の記事では触れません.
オプション機能
ちなみに基本機能以外に TCP コネクションの切断があっても, 短時間以内に再接続を行った場合にはメッセージの到達・順序保証を維持する必要がありました.
また到達・順序保証が要らないメッセージに限り UDP が使用可能な場合は UDP 経由で送受信されるが, UDP が使用できないクライアントでは TCP にフォールバックされるという機能も必要でした.
プロトコル設計
まずは TCP か UDP かですが, NAT越えや接続性・整合性を考えて TCP を軸に作っていきます.
UDP が必要な用途は限られるのもありました.
というわけで基本となるメッセージのフォーマットを考えていきます.
大前提として高頻度通信かつゲーム用途なので何が送信されるかわからないのと効率のためバイナリベースです.ただし, MQTT のように数ビットや数バイトを省力化するよりフォーマットのシンプルさと拡張性を優先しました.
基本となる考え方としては TypeLengthValue https://ja.wikipedia.org/wiki/Type-length-value を全面的に採用しています.
単純に構造として Type(種類) Length(Valueの長さ) Value(値) という3つセットでデータを作っていくだけですがシンプルで扱いやすいのがよかったです.
Header
まず固定ヘッダとして最初にメッセージの種類(MessageType)とペイロードの長さ(Length)それにクライアント・サーバー間の通信遅延を計測するための Timesamp (という名のミリ秒のタイマー) を用意しました.
Payload
ペイロードも連続した TLV で構成されます.
MessageType によって必要な情報は違うので情報ごとに 1つの TLV のセットで記述していく事にします. この 1 セット を Section と呼んでいます. パースを楽にするため 1 Section は必ず 4bytes の倍数にするというルールにしました.
メッセージ設計
というわけで基本的な構造は決めたので機能の実装に必要な情報を洗い出して個別のメッセージを設計していきます.
接続要求 - CONNECT TOKEN
TCPで接続時を確立したら最初にやる事は独自プロトコルとしての接続の確立方法です.
MessageType の番号を払い出して割り当てていきます.
ちなみにパースの容易さのため MessageType/SectionType 双方とも 2bytes も割り当てたので空間があまりまくったため MessageType/SectionType の番号は被らないように発番しました.
認証には文字列のトークンを使う事にしていたので接続用のメッセージにはトークン用の Section が必要です.
コネクションのタイムアウト時間もコネクションごとに設定したかったので KeepAlive Section も作ります.
接続応答(成功) - CONNACK SUCCESS
接続したら当然サーバーから応答を返す必要があります.
成功の場合は他ユーザーから識別するための ClientID とこの接続の一時的なID(SessionID)を返します.
ClientID はメッセージを受け取った他のユーザーが誰からのメッセージなのか識別するために使用します.
SessionID は接続ごとにクライアントとサーバー間で識別するためのIDです.
接続応答(失敗) - CONNACK FAILED
成功があれば当然失敗もあります.
理由ごとに MessageType を分けています.
接続応答(認証失敗) - CONNACK FAILED TOKEN ERROR
こちらはトークンの認証に失敗した場合のメッセージ. 単に MessageType の番号が違うだけです.
購読 - SUBSCRIBE
接続出来たらメッセージを受け取るためにトピックを購読しなければいけないのでそのためのメッセージです.
到達・順序保証のために MessageID という Section を持っています. TCP と同じように送信側のカウンターで増える値でこのIDをもとに再送を行う事で保証を行っています.
当然購読対象のトピックを指定するための Section を持っています.
購読解除 - UNSUBSCRIBE
購読したらそれを解除したい事もあるので当然購読解除のメッセージも作ります.
Section は購読と変わりません.
出版 - PUBLISH
メッセージ配信です.
配信先トピックと配信内容用の Section があります.
ちょっと変わった事として配信内容の Section の SectionType は 0x8000 - 0xFFFF の範囲でクライアントが自由に設定できるようにしました.
使用者にとってのメッセージ種別をつけれると使用者にとってはパース前にメッセージ種別がわかって何かと便利なので.
切断
最後は切断するだけです.
TCPコネクションが切れても一定時間セッションを維持する機構を持っているため, 明示的な切断を区別するため専用のメッセージを用意しました.
まとめ
とまぁ, こんな感じでひたすら必要なメッセージを組み立てていくという事をしました.
プロトコル設計といってもプログラミングと同じく一つ一つ問題を解決していくのみです.
(ちなみに大規模な設計変更は3回ほどありましたがTLVベースの構造は変わらずSecitonの増減で済んでいます)
自分で作ってみるとRFCなどを読んでいてプロトコルの意図みたいなものが理解しやすくなったのも個人的に良い成果でした.