MQTTサーバーを実装しながらGoを学ぶ - その1 Table Driven Test
2018年はGoとMQTTデビューをしたので、学んだことの振り返りも兼ねて、GoでMQTTサーバーを実装してみます、という日記です。
使用するGoのバージョンは1.10.7です。
MQTTとは以下のようばPubSubを実現するプロトコルです。詳細は実装しながら確認していきます。以下の図だと「Temperature Sensor」がPublisherとなるクライアント、「Computer」と「Mobile Device」がSubscriberとなるクライアントです。真ん中の「MQTT Broker」がサーバーでこれを実装していきます。
引用元:API Builder and MQTT for IoT - Part 1
目次。
MQTTの仕様を確認
実装してみるのは、MQTTのv3.1.1。
MQTTの通信でやりとりされるパケットを、MQTT Control Packetと呼ぶ。MQTT Control Packetにはいくつか種類があり、後述する固定ヘッダーの MQTT Control Packet type
で判別できる。
MQTT Control Packetは以下の3つの部分で構成される。
- 固定ヘッダー
- 2バイト
- 必須
- 可変ヘッダー
- MQTT Control Packet typeによって異なる
- ペイロード
- MQTT Control Packet typeによって異なる
MQTTのパケットを確認
MosquittoというMQTTサーバーを使ってどういうパケットがやりとりされてるか確認してみる。
インストール。
$ brew install mosquitto
サーバーを起動。
$ mosquitto -v 1535406563: mosquitto version 1.5.1 starting 1535406563: Using default config. 1535406563: Opening ipv6 listen socket on port 1883. 1535406563: Opening ipv4 listen socket on port 1883.
Wiresharkでパケットキャプチャする。
Mosquittoをインストールすると、MQTTクライアントとして使えるコマンドもついて来るので、Publishしてみる。
$ mosquitto_pub -t hoge -m "Hello"
Wiresharkで確認。以下のように通信が行われていることが分かる。
クライアント → Connect Command → サーバー
クライアント ← Connect Ack ← サーバー
クライアント → Publish Message → サーバー
クライアント → Disconnect Req → サーバー
自分で実装したMQTTサーバーとMosquittoクライアントで上の通信ができるようにしよう!
固定ヘッダーの実装
まずは固定ヘッダーから。
下調べ
MQTT Control Packetの固定ヘッダーを実装する。
固定ヘッダーの仕様を調べる。
- 2byte
- 全ての種類のControl Packetにある
- 以下のようなフォーマットになってる
Bit 7 6 5 4 3 2 1 0 byte1 MQTT Control Packet type Flags specific to each MQTT Control Packet type byte2... Remaining Length
WiresharkでConnect Commandを詳しく見てみる。
16進数とASCIIで表現されたバイト列は以下。最初の2バイト 10
23
が固定ヘッダー。
0000 10 23 00 04 4d 51 54 54 04 02 00 3c 00 17 6d 6f .#..MQTT...<..mo 0010 73 71 70 75 62 7c 37 31 33 33 35 2d 6f 2d 31 30 sqpub|11111-a-22 0020 38 39 33 2d 6d 222-b
Wiresharkが上のバイト列をMQTTとして解釈して構造化してくれたのが以下。最初の1バイト目が Header Flagsで、2バイト目がMsg Len。
MQ Telemetry Transport Protocol, Connect Command Header Flags: 0x10, Message Type: Connect Command 0001 .... = Message Type: Connect Command (1) .... 0000 = Reserved: 0 Msg Len: 35 Protocol Name Length: 4 Protocol Name: MQTT Version: MQTT v3.1.1 (4) Connect Flags: 0x02, QoS Level: At most once delivery (Fire and Forget), Clean Session Flag 0... .... = User Name Flag: Not set .0.. .... = Password Flag: Not set ..0. .... = Will Retain: Not set ...0 0... = QoS Level: At most once delivery (Fire and Forget) (0) .... .0.. = Will Flag: Not set .... ..1. = Clean Session Flag: Set .... ...0 = (Reserved): Not set Keep Alive: 60 Client ID Length: 23 Client ID: mosqpub|11111-a-22222-b
FixedHeaderの実装
固定ヘッダーを表す FixedHeader
というstructを実装する。MQTT Control Packetの種類を表す PacketType
フィールドを持たせる。
Goで数値を表す型はいくつかある。PacketTypeは0から15のいずれかなので、1番小さいサイズで良いので byte
型を使うことにする( byte
は uint8
のalias)。
// fixed_header.go package packet type FixedHeader struct { PacketType byte }
固定ヘッダーは2バイト。 []byte
を引数で受け取って FixedHeader
を返す ToFixedHeader
関数を作る。
// fixed_header.go package packet type FixedHeader { PacketType byte } func ToFixedHeader(bs []byte) FixedHeader { return FixedHeader{} }
Goでテスト - Table Driven Test
テストを書いてみる。この記事がすごく参考になります。
- Goのtestを理解する in 2018 #go - My External Storage
- Goのテストコードは同じディレクトリに
_test
というサフィックスをつけた.go
ファイルに書く$ go test
の時だけビルド対象となる
- テストは
Test
というプレフィックスをつけた関数名で書く。引数は*testing.T
- パッケージはテスト対象と同じパッケージでもいいけど、
_test
というサフィックスをつけたパッケージにしておく- 別パッケージにすることで、テスト対象のプライベートな変数や関数に依存したテストを書くのを防げる
- Goのテストコードは同じディレクトリに
// fixed_header_test.go package packet_test func TestPacketType(t *testing.T) {}
GoだとTable Driven Testという書き方で書くといい。Table Driven Testで FixedHeader
のテストを書く。
ちなみに、JetBrains製品使ってる場合はテスト対象の関数内にカーソルがある状態で ⌘+Shift+T
、VSCodeなら Go: Generate Unit Tests For Function
するとTable Driven Testな雛形のテストを生成してくれて便利。
package packet_test import ( "github.com/bati11/oreno-mqtt/study/packet" "reflect" "testing" ) func TestToFixedHeader(t *testing.T) { type args struct { bs []byte } tests := []struct { name string args args want packet.FixedHeader }{ { "Reserved", args{[]byte{0x00, 0x00}}, packet.FixedHeader{0}, }, { "CONNECT", args{[]byte{0x10, 0x00}}, packet.FixedHeader{1}, }, { "CONNACK", args{[]byte{0x20, 0x00}}, packet.FixedHeader{2}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := packet.ToFixedHeader(tt.args.bs); !reflect.DeepEqual(got, tt.want) { t.Errorf("ToFixedHeader() = %v, want %v", got, tt.want) } }) } }
テストを実行すると失敗する。実行の仕方は色々ある。
$ go test ./packet/fixed_header_test.go --- FAIL: TestToFixedHeader (0.00s) --- FAIL: TestToFixedHeader/CONNECT (0.00s) fixed_header_test.go:37: ToFixedHeader() = {0}, want {1} --- FAIL: TestToFixedHeader/CONNACK (0.00s) fixed_header_test.go:37: ToFixedHeader() = {0}, want {2} FAIL FAIL command-line-arguments 0.018s
ビット演算
Goの演算子について。
- The Go Programming Language Specification - The Go Programming Language
- [Go] 算術シフトと論理シフト|tabatak|note
MQTTの仕様を見ると、固定ヘッダーの1バイト目の上位4ビットがMQTT Control Packet type。 ToFixedHeader
を書き換える。
func ToFixedHeader(bs []byte) FixedHeader { b := bs[0] packetType := b >> 4 return FixedHeader{packetType} }
$ go test ./packet/fixed_header_test.go ok command-line-arguments 1.241s
テストが通った!
ちなみに、 -v
をオプションでつけると成功した場合もログが出力される。
$ go test -v ./packet/fixed_header_test.go === RUN TestToFixedHeader === RUN TestToFixedHeader/CONNECT === RUN TestToFixedHeader/CONNACK --- PASS: TestToFixedHeader (0.00s) --- PASS: TestToFixedHeader/CONNECT (0.00s) --- PASS: TestToFixedHeader/CONNACK (0.00s) PASS ok command-line-arguments 0.018s
おしまい
次はMQTT固定ヘッダーの続きから。
今回の学び
- mosquitto_pubした時の通信
- MQTTの固定ヘッダーの構造
- Goのテスト
- Goのビット演算
Visual Studio Codeのコマンドをvscodevimでキーバインドする
VS CodeにGoのプラグインを追加するとテストの実行を支援してくれる機能が色々入ります。カーソルがあたってるテストケースを実行したり、テストファイルとテスト対象ファイルを切り替えたり。例えば、コマンドパレットで Go Test Function At Cursor
というコマンドを実行すると、カーソルがあたってるテストケースを実行できます。
でも、コマンドパレット開いて入力するの面倒くさい!もっと気軽に実行したい!!
というわけで、できるようにしてみます。
VS Codeのショートカットの設定をしてもいいのだけど、vscodevimを使ってるのでそちらでキーバインディングの設定をしたい。コマンドパレットを開いて Go Test Function At Cursor
を実行する代わりに、 \gt
と入力するだけで実行できるようにする。
以下のメニューを開く。
vscodevimのキーバインドで必要なコマンドID?を以下のように「Copy Command」で取得する。
setting.jsonに以下を記述。
"vim.normalModeKeyBindingsNonRecursive": [ { "before": ["<leader>", "g", "t"], "after": [], "commands": [ "go.test.cursor" // 「Copy Command」で取得したコマンドID ] }, ]
これでコマンドパレットを使わずに、 \gt
と入力するだけでカーソルがあたってるテストケースを実行することができた!直前に実行テストを実行するコマンドやテストファイルとテスト対象ファイルを切り替えるコマンドも簡単に実行できると捗りそうですね!
ISUCON8予選で敗退しました...
ISUCON8の予選に @hanhan1978, @trtraki とyokohama-northというチームで参加しましたが、最終スコア30824で敗退しました。無念。。
以前と同様のメンバーでしたが、今回は役回りを変更。過去に参加した時はいつもDB等ミドルウェア・OS周りをやってたけど、今回はアプリケーション改修班に。Goでやりました。担当を変えたことで別の体験ができてすごく良かったです。
今回のお題はイベントの座席の予約サイトという感じ。アプリケーション触ったりコード見たりしてて排他制御があるなー、それ以外は普通かな?と思ってたのですが、実際はやたらと呼ばれてるgetEventという関数の改善で右往左往してるだけでほとんど終わってしまいました。もっとプログラミングの質とスピードを上げたいのと、複数台サーバー使った時に排他制御のところどうやっていこうかなというのをじっくり考えたいなぁという気持ちです。
とにかくすごく楽しかったし、ベンチマーカーやポータルサイトも非常に快適でした。運営の方々、チームメンバーの方々、こんなに楽しい機会をいただき本当にありがとうございます!
以下、雑な思い出タイムライン。
- 10:00に滞りなく開始!
- とりあえずGoに切り替える
- pprofとecho-pprofを導入
- 使ったことがないgbとかいうパッケージ管理ツールが使われてて一瞬焦る
- まずはコード読んだりアプリケーション使ってみたり
- h2oであることに気がつく
- h2o使ったことある人がいないので、Nginxに置き換えることをチームで決定
- pprofがうまくいかない
- 502 BadGatewayだったので、h2oのタイムアウトかなぁ、という感じ
- Nginxに置き換えられるの待ち
- 11:00くらい
- チームで話し合い
- Nginxへの置き換えをとりあえず終わらせて、ログをalpで分析しよう、ということに
- alpの結果から
GET /
とGET /api/users/:id
とで分担。GET /
を担当することに - pprofしたけど、handlerが無名関数で書かれていてプロファイルできない
- handlerを関数化する。最初の1時間でやっておくべきだった
- 関数化完了。pprof見ると、ほとんど
getEvent
- 各エンドポイントで N+1 にならないようにしましょう、とメンバーと話す
GET /
のフロントのコードを読むと、reservationsをそもそも使っていないので、DBから取得する必要すらないだろう、ということで修正する- 修正したけど、ベンチが通らない。あー、ベンチマーカーには画面を返してるわけじゃなくJSON返してるんだから、レスポンスの構造は変えちゃダメじゃないかということに気がつく
- getEventのインタフェースは維持して、中身のロジックを修正する方針に切り替える
- eventとreservationsのN+1を解消する
- 最初うまくいかなかったところ
- OUTER JOINを使ったSQLの結果をstructにマッピングするときにNULLを
int64
に代入しようとして実行時エラー sql.NullInt64
を使って対応
- OUTER JOINを使ったSQLの結果をstructにマッピングするときにNULLを
- 次にうまくいかなかったところ
- 存在しないイベントに対する応答がベンチマーカーが期待してたステータスコードと違う
db.QueryRow
を使ってレコードがない場合はScan
時にsql.ErrNoRows
が返ってくるが、db.Query
を使うように変更したのでレコードが存在しない場合の挙動が変わってしまった。明示的にsql.ErrNoRows
を返すようにして対応
- getEvent修正終わった
- 点数が10000点超えた。たしかここで16:00くらいになってた気がする
- alpすると、
GET /
とPOST /api/events/:id/actions/reserve
が遅い - pprofすると
POST /api/events/:id/actions/reserve
は結局getEvent
- 引き続き
GET /
を対応する- 公開済みのeventのデータだけをN+1を解消して取得するようにする
- 修正できたけど
GET /
速くならず・・・! - 最初のgetEvent改修時のSQLをチューニングしてなかったことに気がついてインデックス作成する
- 20000-30000くらいをいったり来たり
- 17:00くらい
- なんかfailになる時がちょいちょいある
- しかもサーバー上のコミットがなんか意図したものと違うことに気がつく
- gitで調整しつつ、バイナリ作る
- うまくいくコミットが見つかったので、ビルド。バイナリを保存
- reboot後の動作確認して終了ー