必要に迫られ、Go言語まったくの未経験者である私が、1週間でTCPクライアントを書くことになりました。
結果、最終的にはバッチリ動くものができあがりました。が、いろいろと罠にもハマってしまいました。
ので、同じことでハマる人が少しでも減るようにと、主観でしかない上にコードそのものを例示することができないため、ポエムとしてまとめてみました。
【追記】 サーバー側の事情もポエムにまとめました
クライアント要求仕様
- 認証サーバーとhttps REST通信して認証を受け、その後、通信先のIPアドレスとポートを2組受け取る
- その2組のTCPサーバー(lobby serverとchat server)に接続し、独自プロトコルで通信する
- それぞれのTCPサーバーには、冒頭で認証シーケンスがある
- 認証シーケンスをパスした後はだらだらとデータを送受信すればよい
- 1プロセス内で複数のクライアントが動作できるようにする
…わかる人には、どんな用途なのかがわかってしまうクライアント仕様であります。
いちおう、1つのクライアント(←これが1実行プロセスあたり複数個走る)と複数のサーバーとの関係を、図にまとめてみました。
明らかに図のほうが日本語よりわかりやすいですね。
設計
全体構造を考えてみる
というわけで、以下の動作をするクライアント1つを、1つのgoroutineで動作させます。
- http REST通信して、失敗したらクライアント終了、成功したらTCP通信へ
- TCPコネクションを2本張り、それぞれに独立してデータを送り、受信し、を繰り返しつづける
- 2本それぞれで受信したデータや、定期的に変化するステータスに応じて、次の動作を決める
明らかに1.は非同期化する必要がないので、だらだらとロジックを書けば問題なさそうです。
いっぽう、2.については、GoのTCP通信の読み出しはブロッキングしかないため、そこを別のgoroutineにしなければなりません。
さらに、3.はイベントドリブンのビジネスロジックであり、2本のコネクションと独立して動作できなければなりません。
…早くも話が複雑化してしまいました。こりゃだめだ。
というわけでボトムアップで考えます。
lobby serverとの通信部分
- lobby serverと通信する部分(以下、ロビー通信部)は、クライアントロジックから自分自身の座標を受け取り、その座標を記憶する
- ロビー通信部は、1.にかかわらず、定期的に、現在の自分自身の座標をサーバーに送信する
- サーバーは2.への応答として、自分自身の近傍に居る他者の座標をロビー通信部に返す
- 他者の座標が変更された場合、ロビー通信部はクライアントロジックにその旨を通知する
…もはや何のクライアントなのかはバレバレですが、4.が必要なのは、「近傍の他者」を描画するためです。
そして、その「他者」を「どう」描画するかは、また別途、chat serverに照会しなければなりません。これが、クライアントロジックがそれぞれのサーバーとの通信部分とは独立して動作しなければならない理由です。
これらを図にすると、こうなります。
…というわけで、 図からただちに、以下のことがわかります。
- ロビー通信部は2つのgoroutineとして実装すればよい
- ロビー通信部は、サーバーに定期的に座標を送信するためのtime.Tickerをselectする
- goroutine間通信はそれぞれ以下のようなチャンネルで行えばよい(done以外で)
- クライアントロジック→通信部: 座標を渡す
- 通信部→クライアントロジック: 座標の配列を渡す
- TCP受信部→通信部: バイト列を渡す
receiverが「別部分」として書かれているのは、先述のとおり、GoのTCP受信はブロッキングであり、受信データがない間はブロックしてしまうからです。受信だけしてそのデータを通信部にチャンネルで渡し、ふたたび受信に入ってブロックする、それだけのgoroutineを書けばことたります。
chat serverとの通信
こちらのサーバーの機能は、チャットがメインですが、それ以外にも徒党を組むコマンド、描画情報を知るコマンド、などなどがいろいろと実装されています。
こちらの通信(以下、chat serverとの通信を行う部分を、チャット通信部とします)のシーケンスは基本的にはロビー通信部と同じですが、ロビー通信にはない機能が2つあります。
1. keepaliveのための、定期的なNOOPの送信
2. サーバーからプッシュで通知(他クライアントからのチャットの受信、等)が送信されてくることがある
1.は、ロビー通信部で「定期的に座標を送信する」代わりに「定期的にNOOPを送信する」ということですので、通信部がtime.Tickerをひとつ持てばよいということは変わりません。
そして2.についても、そもそも通信部はビジネスロジックをほぼ持たず、クライアントロジックがそれを一括処理していることからして、単にその通知を、他のコマンドリザルトと同様にしてクライアントロジックに渡せばことたります。
- チャット通信部は2つのgoroutineとして実装すればよい
- チャット通信部は、サーバーに定期的にNOOPを送信するためのtime.Tickerをselectする
- goroutine間通信はそれぞれ以下のようなチャンネルで行えばよい(done以外で)
- クライアントロジック→通信部: コマンドを渡す
- 通信部→クライアントロジック: コマンドリザルトまたはサーバー通知を渡す
- TCP受信部→通信部: バイト列を渡す
まぁ、ほぼロビー通信部と同じことですが、動作の細部というか位相が異なるため、チャンネルの型は異なることになります。
通信部を介したクライアントロジックとサーバーのシーケンス
あぁ、モジュールごとに分けて考えるだけで話が単純化できますね(何年選手なんだおまえ>じぶん(20年選手でこのていたらく))。
で、このクライアントは、そもそもこれでエンドユーザーがプレイするわけではなく(Go製のCLIコマンドで遊ぶ現代のゲーム! …でマネタイズできるわけないorz)、あくまでもサーバー動作の確認とか負荷試験とか負荷テストとか負荷検証とかそういうさまざまな用途で使うものですので、「それっぽく」動きさえすればよいものです。
ですので、座標の移動は定期的に乱数値で、チャットは適当な間隔で、さらに延々と所定の動作の繰り返し(今回は、チームを組む→解散→…)を行えばよいわけで、上記の図になった次第です。
ステート管理も単純です。それこそ20年前の技法で、ステートを文字列で表現することにしても問題はないでしょう。
goroutine一覧
これでおおよそのgoroutineの立て方はわかりました。
が、実際に組もうとしてみると、まだ足りてなかったというね。
- 今後、今回の実装を応用して、さまざまなパターンで動作するようなクライアントを量産したいということもあり(さまざまな機能に負荷をかけるため、ということです)、単なる「接続するサーバーに対するクライアント」の動作を汎用的に実装した部分を、個別のロジックから呼び出す、という実装が、分離が効いていてよさげ
- それらを束ねた「サーバー群に対しての論理的なクライアント」を同一プロセスから複数動かすために、終了の待受が必要
これらを満たすためには、もうひとひねりが必要でしたが、結果としては、以下のgoroutineを実装することになりました。
goroutineの名 | 親 | 動作 |
---|---|---|
クライアントコントローラー | プロセス | クライアントロジックを開始し、その動作を制御する |
クライアントロジック | クライアントコントローラー | https REST認証を実施し、2つのサーバーとの通信を開始し、goroutineから抜ける |
ロビー通信部 | クライアントロジック | lobby serverに接続し通信する |
ロビー受信 | ロビー通信部 | lobby serverから受信するだけ |
チャット通信部 | クライアントロジック | chat serverに接続し通信する |
チャット受信 | チャット通信部 | chat serverから受信するだけ |
クライアント終了 | クライアントロジック | 2つの通信部の終了を待ち、自身の終了処理を行う |
「クライアントコントローラーからクライアントロジックを生成し、イベントループに入る」という処理を実行するためには、クライアントロジックのコンテキストは、その子である2つの通信部の終了を待たずに終了し、コントローラーに処理を戻さなければなりません。そのため、通信部の終了を待ち受けるgoroutineが別途必要、となります。
ようやく本題:Goのここがハマる
=
と :=
の混在でハマる
これは超初心者あるあるのような気がしますが、
var hoge []byte
if cond {
hoge := funi(piyo)
} else {
hoge = piyo
}
こう書いて、cond
の際に hoge
が変更されなくて壮絶に事故りました…。
こういう、「ブロック外と同じ変数名を使ってる」場合には、警告欲しいなぁ…
常時、名前の大文字小文字の意識が必要
binary.Read
で受信したパケットをパースしようとして、エラーりました。
type CmdResult struct {
ID int64
internalState int64
}
func ParseResult(data []byte) CmdResult {
buf := bytes.NewReader(data)
var result CmdResult
binary.Read(buf, binary.LittleEndian, &result)
return result
}
CmdResult
の中の internalState
はこのパッケージ内でしか使わないので、i を小文字にしたんですが、思いっきりコンパイル時エラーになります。
いくらこちらの実装上、特定の変数が内部スコープだったとしても、binaryパッケージにとってはそれはまったく関係なく、外部スコープとして参照できなくちゃだめですものね…涙
チャンネルのサイズを指定しないでデッドロック
これまたあるあるでしょう。今回もっとも手こずったところです。
チャンネルと副作用(タイマー、他のチャンネルからの受信など)を組み合わせると、こういう状況はかんたんに再現できます。
- AとBで2つのチャンネルを使って双方向通信を行う
- Bが外部からデータを受信して、Aに送信する
- 同時に、Aがタイマーで起きて、Bに送信する
- おめでとうございます
もちろん対策は、チャンネルのサイズを非ゼロにする、ということになります。
ではその値は、となりますが、起こり得る事態を想定した上で、それなりに大きめの値を指定してもよいような気はしています。
チャンネルのクローズをうまく書けずpanic
片方向キューとしてのチャンネルの使い方の鉄則として、クローズしてよいのは送信側だけ、というのは踏まえた上での話です。
今回の場合、2つのサーバーとの通信部のうち、片方で致命的エラーになった場合に、もう片方をきちんと終了させたい、ということで、 例えば
- error occurred in chat client
- Controller<-chat client close
- Controller->lobby client close
という流れになればよいだけですが、ここでpanicが頻発してしまいました。
なんのことはなく、chat serverやlobby serverのどちらかでエラーはけっこう起こり得るし、またそもそもクライアントコントローラーを「わざとエラーを発生させる」動作にすることもありまして。そういう際にタイミングによりpanicってしまったのです。
理由は、調べてみればなんということもなく、漫然と、クローズされたチャンネルを抱えており、結果2度closeしてしまうことがあるだけだった、というね…
type ChannelController struct {
LChans *LobbyChannels
CChans *ChatChannels
LIsEnabled bool
CIsEnabled bool
}
func (controller *ChannelController) Close() {
if controller.LIsEnabled {
controller.LIsEnabled = false
close(controller.LChans.Req)
}
if controller.CIsEnabled {
controller.CIsEnabled = false
close(controller.CChans.Req)
}
}
closeは送信側からなのでこのClose
の呼び出し内で排他をかける必要もなく、「closeしてポインターをnilにする」か「closeしたらフラグを寝かす」かのどちらかをすればよいだけです。
ニワカが感じた良い子悪い子普通の子
良い: 超軽量
結局1週間で完成したクライアントですが、MacBook Airから1プロセス・600クライアントコントローラーを同時起動しましたが、ふつうにサーバーと通信できてまして、でメモリーも100MBも使ってなく、寝ている時間も多いのかCPUも100%に達していない。嗚呼なんて軽いんだ!!\(^o^)/
これまでの、DirectXがないと動かせず、メモリーも1コントローラーで2GB近く使うクライアントはなんだったんだ…
(…ってそりゃ、開発中の実物をそのままWindows用にビルドしただけだからですね。(^^ゞ)
また、ビルドが軽いのもノーストレスですね。
良い: 環境に(ほぼ)依存しない
私個人の問題ですが、開発環境がWindowsになったりmacOSになったりしています。というか、要はモバイル開発(という名の、旅行する口実)だとmacOSで、オフィスでは強制的にWindows、しかも7、という状態です。
また、このクライアントは負荷試験などではクラウド借りてそこでいっせいに動かしたりする必要がありますが、その場合のOSは当然にLinux系になるわけです。
このような状況で、どこでも動くしどこでもビルドできるしどれ用のもビルドできる、というのはとってもありがたいところです。
悪い: プロプライエタリーかつ複数人での開発がしづらいのでは?
名前空間がないというのがこんなにストレスフルだったとは…ってCとか8bit時代のBASICとかで命名規則で耐えていた感覚をひさびさに味わうことになりました。
もちろん、パッケージ使えばよいじゃん、となりますけど、ローカルのパッケージを利用するにはいろいろなお約束を踏襲せねばならないようです。業務上の都合でリポジトリーサービスを変更することもあり、かつプロプライエタリーものとして作っていてそのパッケージを広く共有することもない、という状況では、やっぱり名前空間なし&命名規則で乗り切るということになりそうで、複数人での開発がめんどくさそうな気はしました。
悪い: ジェネリックスがない
さんざん言われていることですが、やっぱり同じロジックを型ごとにだらだら書くのはすこしめんどくさい、と思いました。
C++のテンプレート的なものだけでも入れられなかったのかしら。ビルドが重くなる、コンパイラーの実装がめんどくさくなる、とかなのでしょうか?
普通: お約束が変態(ただし慣れの問題)
「名前を大文字で始めるとエクスポートされる」というのは、私がこれまで実利用してきたどの言語にもなくて、慣れるまでは大変でしたがな…。まぁ、慣れればよいんですが。
普通: エクスポートされるモノにいちいちコメントを強制される
いや、それが本来なんですよ。だから「普通」にしましたが。
でも、1人で小物を書きなぐりたいような場合にそれはちょっとなぁ、とも感じたりはしました。