MQTTサーバーを実装しながらGoを学ぶ - その2 go vet, gofmt, go doc

前回、固定ヘッダーを表すstructの実装に着手しました。

// fixed_header.go
package packet

type FixedHeader {
    PacketType byte
}

func ToFixedHeader(bs []byte) FixedHeader {
    b := bs[0]
    packetType := b >> 4
    return FixedHeader{packetType}
}

ここからの続きです。Goでの開発で便利そうなgo vet, gofmtといったコマンドも試してみます。

目次。

MQTT固定ヘッダー

MQTTの固定ヘッダーは以下のようなフォーマットになってた。

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

MQTT Control Packet type を実装したので、次は Flags specific to each MQTT Control Packet type に着手する。その後、 Remaining Length

Flags

ここはサクッと。

fixed_header_test.go

                want packet.FixedHeader
        }{
                {
-                       "Reserved",
-                       args{[]byte{0x00, 0x00}},
-                       packet.FixedHeader{0},
+                       "Reserved Dup:0 QoS:00 Retain:0",
+                       args{[]byte{0x00, 0x00}}, // 0000 0 00 0
+                       packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0},
                },
                {
-                       "CONNECT",
-                       args{[]byte{0x10, 0x00}},
-                       packet.FixedHeader{1},
+                       "CONNECT Dup:1 QoS:01 Retain:1",
+                       args{[]byte{0x1B, 0x00}}, // 0001 1 01 1
+                       packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1},
                },
                {
-                       "CONNACK",
-                       args{[]byte{0x20, 0x00}},
-                       packet.FixedHeader{2},
+                       "CONNACK Dup:0 QoS:10 Retain:1",
+                       args{[]byte{0x24, 0x00}}, // 0002 0 10 0
+                       packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0},
                },
        }
        for _, tt := range tests {

fixed_header.go

 type FixedHeader struct {
        PacketType byte
+       Dup        byte
+       QoS1       byte
+       QoS2       byte
+       Retain     byte
 }
 
 func ToFixedHeader(bs []byte) FixedHeader {
        b := bs[0]
        packetType := b >> 4
-       return FixedHeader{packetType}
+       dup := refbit(bs[0], 3)
+       qos1 := refbit(bs[0], 2)
+       qos2 := refbit(bs[0], 1)
+       retain := refbit(bs[0], 0)
+       return FixedHeader{
+               PacketType: packetType,
+               Dup:        dup,
+               QoS1:       qos1,
+               QoS2:       qos2,
+               Retain:     retain,
+       }
+}
+
+func refbit(b byte, n uint) byte {
+       return (b >> n) & 1
 }

Remining Length

次は、固定ヘッダーの2バイト目が表しているRemining Length。Remining Lengthは、固定ヘッダーに続く「可変ヘッダー」「ペイロード」のサイズが合計で何バイトなのかを示す。

ドキュメントにencodeとdecodeのアルゴリズムが書いてある。

ドキュメントを参考にテストコードを修正。

fixed_header_test.go

                want packet.FixedHeader
        }{
                {
-                       "Reserved Dup:0 QoS:00 Retain:0",
-                       args{[]byte{0x00, 0x00}}, // 0000 0 00 0
-                       packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0},
+                       "Reserved Dup:0 QoS:00 Retain:0 RemainingLength:0",
+                       args{[]byte{
+                               0x00, // 0000 0 00 0
+                               0x00, // 0
+                       }},
+                       packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0, RemainingLength: 0},
                },
                {
-                       "CONNECT Dup:1 QoS:01 Retain:1",
-                       args{[]byte{0x1B, 0x00}}, // 0001 1 01 1
-                       packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1},
+                       "CONNECT Dup:1 QoS:01 Retain:1 RemainingLength:127",
+                       args{[]byte{
+                               0x1B, // 0001 1 01 1
+                               0x7F, // 127
+                       }},
+                       packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1, RemainingLength: 127},
                },
                {
-                       "CONNACK Dup:0 QoS:10 Retain:1",
-                       args{[]byte{0x24, 0x00}}, // 0002 0 10 0
-                       packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0},
+                       "CONNACK Dup:0 QoS:10 Retain:1 RemainingLength:128",
+                       args{[]byte{
+                               0x24,       // 0002 0 10 0
+                               0x80, 0x01, //128
+                       }},
+                       packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0, RemainingLength: 128},
                },
        }
        for _, tt := range tests {

コンパイルが通るように修正。

fixed_header.go

 package packet
 
 type FixedHeader struct {
-       PacketType byte
-       Dup        byte
-       QoS1       byte
-       QoS2       byte
-       Retain     byte
+       PacketType      byte
+       Dup             byte
+       QoS1            byte
+       QoS2            byte
+       Retain          byte
+       RemainingLength uint
 }
 
 func ToFixedHeader(bs []byte) FixedHeader {

テスト実行。

$ go test ./packet/fixed_header_test.go 
--- FAIL: TestToFixedHeader (0.00s)
    --- FAIL: TestToFixedHeader/CONNECT_Dup:1_QoS:01_Retain:1_RemainingLength:127 (0.00s)
        fixed_header_test.go:47: ToFixedHeader() = {1 1 0 1 1 0}, want {1 1 0 1 1 127}
    --- FAIL: TestToFixedHeader/CONNACK_Dup:0_QoS:10_Retain:1_RemainingLength:128 (0.00s)
        fixed_header_test.go:47: ToFixedHeader() = {2 0 1 0 0 0}, want {2 0 1 0 0 128}
FAIL
FAIL    command-line-arguments  0.019s

まだ実装してないので失敗。ドキュメントを参考にdecodeする処理を追加。

fixed_header.go

        qos1 := refbit(bs[0], 2)
        qos2 := refbit(bs[0], 1)
        retain := refbit(bs[0], 0)
+       remainingLength := decodeRemainingLength(bs[1:])
        return FixedHeader{
-               PacketType: packetType,
-               Dup:        dup,
-               QoS1:       qos1,
-               QoS2:       qos2,
-               Retain:     retain,
+               PacketType:      packetType,
+               Dup:             dup,
+               QoS1:            qos1,
+               QoS2:            qos2,
+               Retain:          retain,
+               RemainingLength: remainingLength,
        }
 }
 
 func refbit(b byte, n uint) byte {
        return (b >> n) & 1
 }
+
+func decodeRemainingLength(bs []byte) uint {
+       multiplier := uint(1)
+       var value uint
+       i := uint(0)
+       for ; i < 8; i++ {
+               b := bs[i]
+               digit := b
+               value = value + uint(digit&127)*multiplier
+               multiplier = multiplier * 128
+               if (digit & 128) == 0 {
+                       break
+               }
+       }
+       return value
+}

テスト実行。

$ go test ./packet/fixed_header_test.go 
ok      command-line-arguments  2.739s

OKOK。

テストケースの name を書くのが面倒なのでリファクタリング

fmt.Sprintf を使って、 want で指定してるstructをテストケースの name の代わりに使う。

diff --git a/study/packet/fixed_header_test.go b/study/packet/fixed_header_test.go
index 2670823..ed086a2 100644
--- a/study/packet/fixed_header_test.go
+++ b/study/packet/fixed_header_test.go
@@ -1,6 +1,7 @@
 package packet_test
 
 import (
+       "fmt"
        "reflect"
        "testing"
 
@@ -12,12 +13,10 @@ func TestToFixedHeader(t *testing.T) {
                bs []byte
        }
        tests := []struct {
-               name string
                args args
                want packet.FixedHeader
        }{
                {
-                       "Reserved Dup:0 QoS:00 Retain:0 RemainingLength:0",
                        args{[]byte{
                                0x00, // 0000 0 00 0
                                0x00, // 0
@@ -25,7 +24,6 @@ func TestToFixedHeader(t *testing.T) {
                        packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0, RemainingLength: 0},
                },
                {
-                       "CONNECT Dup:1 QoS:01 Retain:1 RemainingLength:127",
                        args{[]byte{
                                0x1B, // 0001 1 01 1
                                0x7F, // 127
@@ -33,7 +31,6 @@ func TestToFixedHeader(t *testing.T) {
                        packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1, RemainingLength: 127},
                },
                {
-                       "CONNACK Dup:0 QoS:10 Retain:1 RemainingLength:128",
                        args{[]byte{
                                0x24,       // 0002 0 10 0
                                0x80, 0x01, //128
@@ -42,7 +39,7 @@ func TestToFixedHeader(t *testing.T) {
                },
        }
        for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
+               t.Run(fmt.Sprintf("%#v", tt.args.bs), 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)
                        }

以下のようにテスト名に []byte{0x23,_0x80,_0x1} というような感じで出力される。

$ go test -v ./packet/fixed_header_test.go
=== RUN   TestToFixedHeader
=== RUN   TestToFixedHeader/[]byte{0x0,_0x0}
=== RUN   TestToFixedHeader/[]byte{0x1b,_0x7f}
=== RUN   TestToFixedHeader/[]byte{0x24,_0x80,_0x1}
--- PASS: TestToFixedHeader (0.00s)
    --- PASS: TestToFixedHeader/[]byte{0x0,_0x0} (0.00s)
    --- PASS: TestToFixedHeader/[]byte{0x1b,_0x7f} (0.00s)
    --- PASS: TestToFixedHeader/[]byte{0x24,_0x80,_0x1} (0.00s)
PASS
ok      command-line-arguments  1.299s

go vet

fmt.Sprintf はすごく便利なのだけど、例えば fmt.Sprinf("x is %v") というように第2引数を指定し忘れていたとしてもコンパイルエラーにならず、ミスに気がつきにくい。

go vet というコマンドを使うと、よくあるミスを指摘してくれる。

さっきのテストコードでわざと間違えてみる。

                },
        }
        for _, tt := range tests {
-               t.Run(fmt.Sprintf("%#v", tt.args.bs), func(t *testing.T) {
+               t.Run(fmt.Sprintf("%#v"), 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 vet ./packet/
# github.com/bati11/oreno-mqtt/packet_test
packet/fixed_header_test.go:42: Sprintf format %#v reads arg #1, but call has 0 args

これは助かる!他にどんなチェックをしてくれるのかは以下のページを参照。

実はGo1.10からは go test 実行時に、vetの項目のうちのいくつかをチェックしてくれるようになったらしい。

試しに fmt.Sprintf の第2引数を渡してない状態で go test してみる。

$ go test ./packet/
# github.com/bati11/oreno-mqtt/packet_test
packet/fixed_header_test.go:42: Sprintf format %#v reads arg #1, but call has 0 args
FAIL    github.com/bati11/oreno-mqtt/study/packet [build failed]

良い。

gofmt

お次はフォーマッター。

gofmt コマンドとは別に go fmt というのもある。何か歴史的な経緯があるのだろうか。ちなみに gofmt -l -wgo fmt の結果が同じになった。

$ gofmt --help
...
  -l    list files whose formatting differs from gofmt's
...
  -w    write result to (source) file instead of stdout

gofmt-d オプションで実際にファイルをフォーマットせずに差分の出力だけすることができるのでCIでも使い勝手良さそう。

go doc

お次はドキュメンテーション。2通りある。

  • godoc コマンド
  • go doc というようにgoコマンドにdocを指定する方法の

まずは godoc から。以下のように実行する。

$ godoc -http=:6060

ブラウザで http://localhost:6060 にアクセス。するといつものGoの画面が。

いつものと違って「Packages」自分が作ったパッケージが紛れてます。自分が作ったパッケージの他に、ローカルPCのGOPATHにあるパッケージも載ってます。

ドキュメントの書き方は以下の記事が参考になりそう。

起動時に $ godoc -http=:6060 -analysis=pointer -analysis=type というように analysis オプションをつけてるとコードの解析もしてくれる。ただし、起動に時間がかかる...。

もう一方の go doc (goコマンド+docオプション)ですが、こちらはコマンドラインでGo Docが確認できる。

結構色々あるみたい。こちらの記事が参考になる。

おしまい

FixedHeaderReminingLength フィールドを追加しました。しかし、 ToFixedHeader に渡す []byte のチェックをしてないので1バイトの配列やnilを渡すとpanicしてしまいます。次回はここのエラーハンドリングから考えることにします。

今回の学び

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 と入力するだけでカーソルがあたってるテストケースを実行することができた!直前に実行テストを実行するコマンドやテストファイルとテスト対象ファイルを切り替えるコマンドも簡単に実行できると捗りそうですね!