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」がサーバーでこれを実装していきます。

https://s3.amazonaws.com/www.appcelerator.com.images/MQTT_1.png

引用元:API Builder and MQTT for IoT - Part 1

目次。

MQTTの仕様を確認

実装してみるのは、MQTTのv3.1.1。

MQTT Version 3.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で確認。以下のように通信が行われていることが分かる。

  1. クライアント → Connect Command → サーバー
  2. クライアント ← Connect Ack ← サーバー
  3. クライアント → Publish Message → サーバー
  4. クライアント → Disconnect Req → サーバー

f:id:bati11:20181011195454p:plain

自分で実装した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 型を使うことにする( byteuint8 の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 というサフィックスをつけたパッケージにしておく
      • 別パッケージにすることで、テスト対象のプライベートな変数や関数に依存したテストを書くのを防げる
// 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の演算子について。

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固定ヘッダーの続きから。

今回の学び

Visual Studio Codeのコマンドをvscodevimでキーバインドする

VS CodeにGoのプラグインを追加するとテストの実行を支援してくれる機能が色々入ります。カーソルがあたってるテストケースを実行したり、テストファイルとテスト対象ファイルを切り替えたり。例えば、コマンドパレットで Go Test Function At Cursor というコマンドを実行すると、カーソルがあたってるテストケースを実行できます。

code.visualstudio.com

でも、コマンドパレット開いて入力するの面倒くさい!もっと気軽に実行したい!!

というわけで、できるようにしてみます。


VS Codeのショートカットの設定をしてもいいのだけど、vscodevimを使ってるのでそちらでキーバインディングの設定をしたい。コマンドパレットを開いて Go Test Function At Cursor を実行する代わりに、 \gt と入力するだけで実行できるようにする。

以下のメニューを開く。

f:id:bati11:20180929100404p:plain

vscodevimのキーバインドで必要なコマンドID?を以下のように「Copy Command」で取得する。

f:id:bati11:20180929101119p:plain

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 を使って対応
    • 次にうまくいかなかったところ
      • 存在しないイベントに対する応答がベンチマーカーが期待してたステータスコードと違う
      • 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後の動作確認して終了ー