MQTTサーバーを実装しながらGoを学ぶ - その4 テストカバレッジ

前回の続きです。

今回は、MQTTのCONNECTパケットのペイロードから。ペイロードをbinaryパッケージを使って実装します。その後、regexパッケージを使って入力チェック処理を書いてテストします。Goではテストカバレッジが簡単に取得できるようだったので、試しにやってみます。

目次。

CONNECTパケットのペイロード

ペイロードには5つのデータが含まれる。

  • Client Identifier
  • Will Topic
  • Will Message
  • UserName
  • Password

この中のClient Identifierは必須。残りの4つは可変ヘッダーのConnect Flagsの値次第で必要かどうかが決まる。Connect Flagsについては後回しにしているので、ペイロードでもClient Identifierだけをとりあえずは実装する。

http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html#_Toc398718031

Client Identifier

仕様を読むとClient Identifierはこんな感じ。

  • クライアント毎にユニーク
  • クライアントとサーバー間のセッションを維持するために使う
  • ペイロードの先頭
  • Section 1.5.3で定義されたUTF-8エンコーディングされた文字列
  • さらに条件がある
    • 1〜23byte
    • 使える文字は 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
    • 以下のように書いてあるけど、許可しないでおく
      • The Server MAY allow ClientId’s that contain more than 23 encoded bytes. The Server MAY allow ClientId’s that contain characters not included in the list given above.
      • A Server MAY allow a Client to supply a ClientId that has a length of zero bytes
  • 不正なClient Identifierだった場合、サーバーはクライアントにCONNACKパケット(return codeは0x02)を返して、コネクションを切断する

上記の仕様を実装していく。

binaryパッケージ

Client Identifierはペイロードの先頭で、Section 1.5.3で定義されたUTF-8エンコーディングされた文字列なので、ペイロードの先頭の2バイト分がClient Identifierの長さになる。バイト列と数値の変換は binary パッケージが使える。

binary package - encoding/binary - Go Packages

ビッグエンディアンなのでlength := binary.BigEndian.Uint16(payload[0:2]) とすればClient Identifierの長さが取得できる。

package packet

import (
    "encoding/binary"

    "github.com/pkg/errors"
)

type ConnectPayload struct {
    ClientID string
}

func ToConnectPayload(bs []byte) (ConnectPayload, error) {
    if len(bs) < 3 {
        return ConnectPayload{}, errors.New("payload length is invalid")
    }
    length := binary.BigEndian.Uint16(bs[0:2])
    var clientID string
    if len(bs) < 2+int(length) {
        clientID = string(bs[2:])
    } else {
        clientID = string(bs[2 : 2+length])
    }
    if len(clientID) < 1 || len(clientID) > 23 {
        return ConnectPayload{}, errors.New("ClientID length is invalid")
    }
    return ConnectPayload{ClientID: clientID}, nil
}
package packet

import (
    "reflect"
    "testing"
)

func TestToConnectPayload(t *testing.T) {
    type args struct {
        bs []byte
    }
    tests := []struct {
        name    string
        args    args
        want    ConnectPayload
        wantErr bool
    }{
        {
            name:    "ClientIDが1文字",
            args:    args{[]byte{0x00, 0x01, 'a'}},
            want:    ConnectPayload{ClientID: "a"},
            wantErr: false,
        },
        {
            name:    "ペイロードが0byte",
            args:    args{[]byte{}},
            want:    ConnectPayload{},
            wantErr: true,
        },
        {
            name:    "ClientIDが23文字を超える",
            args:    args{[]byte{0x00, 0x18, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd'}},
            want:    ConnectPayload{},
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ToConnectPayload(tt.args.bs)
            if (err != nil) != tt.wantErr {
                t.Errorf("ToConnectPayload() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("ToConnectPayload() = %v, want %v", got, tt.want)
            }
        })
    }
}

regexpパッケージ

次は文字の種類をチェック。正規表現を使う。

regexp package - regexp - Go Packages

                        want:    ConnectPayload{},
                        wantErr: true,
                },
+               {
+                       name:    "使えない文字がある",
+                       args:    args{[]byte{0x00, 0x02, '1', '%'}},
+                       want:    ConnectPayload{},
+                       wantErr: true,
+               },
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
--- a/study/packet/connect_payload.go
+++ b/study/packet/connect_payload.go
@@ -2,6 +2,7 @@ package packet
 
 import (
        "encoding/binary"
+       "regexp"
 
        "github.com/pkg/errors"
 )
@@ -10,6 +11,8 @@ type ConnectPayload struct {
        ClientID string
 }
 
+var clientIDRegex = regexp.MustCompile("^[a-zA-Z0-9-|]*$")
+
 func ToConnectPayload(bs []byte) (ConnectPayload, error) {
        if len(bs) < 3 {
                return ConnectPayload{}, errors.New("payload length is invalid")
@@ -24,5 +27,8 @@ func ToConnectPayload(bs []byte) (ConnectPayload, error) {
        if len(clientID) < 1 || len(clientID) > 23 {
                return ConnectPayload{}, errors.New("ClientID length is invalid")
        }
+       if !clientIDRegex.MatchString(clientID) {
+               return ConnectPayload{}, errors.New("clientId format shoud be \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"")
+       }
        return ConnectPayload{ClientID: clientID}, nil
 }

これでOK!

$ go test ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       1.283s

テストのカバレッジ

go test でテストを実行してきたが、オプションをつけるとカバレッジを取得できるらしい。しかも -cover オプションをつけるだけ。

The cover story - The Go Programming Language

試してみる。

$ go test -cover ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       1.270s  coverage: 97.6% of statements

97.6%。さっきの文字種類のテストコードを削ってみる。

                        want:    ConnectPayload{},
                        wantErr: true,
                },
-               {
-                       name:    "使えない文字がある",
-                       args:    args{[]byte{0x00, 0x02, '1', '%'}},
-                       want:    ConnectPayload{},
-                       wantErr: true,
-               },
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {

実行。

$ go test -cover ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       0.655s  coverage: 95.2% of statements

95.2%に下がった。

もっと詳細に調べてみる。 -coverprofile=cover.out という指定をすると、cover.outというファイルができて、そのファイルを go tool cover コマンドでプロファイリングできる。

$ go test -coverprofile=cover.out ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       0.019s  coverage: 95.2% of statements
$ go tool cover -func=cover.out
github.com/bati11/oreno-mqtt/study/packet/connect_payload.go:16:                ToConnectPayload        83.3%
github.com/bati11/oreno-mqtt/study/packet/connect_variable_header.go:23:        ToConnectVariableHeader 100.0%
github.com/bati11/oreno-mqtt/study/packet/connect_variable_header.go:41:        isValidProtocolName     100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:14:                   ToFixedHeader           100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:35:                   refbit                  100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:40:                   decodeRemainingLength   100.0%
total:                                                                          (statements)            95.2%

ふむふむ、connect_payload.goの ToConnectPayloadカバレッジが低めだということが分かる。

さらにさらに、 -html=cover.out と指定するとブラウザで確認することもできる。

$ go tool cover -html=cover.out

https://i.gyazo.com/5e2b68026888cf2537364dc5f0c2a41d.png

す、すごい...。

他にも -covermode というオプションがあり、実行された回数まで含めてプロファイルもできそう。

カバレッジ見てたら、指定された長さに対して実際に取得できるClient Identifierの長さが足りない場合のテストが足りないことに気がついたので追加。さっき消したテストも戻す。

--- a/study/packet/connect_payload.go
+++ b/study/packet/connect_payload.go
@@ -20,7 +20,7 @@ func ToConnectPayload(bs []byte) (ConnectPayload, error) {
        length := binary.BigEndian.Uint16(bs[0:2])
        var clientID string
        if len(bs) < 2+int(length) {
-               clientID = string(bs[2:])
+               return ConnectPayload{}, errors.New("specified length is not equals ClientID length")
        } else {
                clientID = string(bs[2 : 2+length])
        }
--- a/study/packet/connect_payload_test.go
+++ b/study/packet/connect_payload_test.go
@@ -33,6 +33,18 @@ func TestToConnectPayload(t *testing.T) {
                        want:    ConnectPayload{},
                        wantErr: true,
                },
+               {
+                       name:    "使えない文字がある",
+                       args:    args{[]byte{0x00, 0x02, '1', '%'}},
+                       want:    ConnectPayload{},
+                       wantErr: true,
+               },
+               {
+                       name:    "指定された長さよりも実際に取得できたClientIDが短い",
+                       args:    args{[]byte{0x00, 0x03, '1', '2'}},
+                       want:    ConnectPayload{},
+                       wantErr: true,
+               },
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
go test -coverprofile=cover.out ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       0.022s  coverage: 100.0% of statements
 go tool cover -func=cover.out
github.com/bati11/oreno-mqtt/study/packet/connect_payload.go:16:                ToConnectPayload        100.0%
github.com/bati11/oreno-mqtt/study/packet/connect_variable_header.go:23:        ToConnectVariableHeader 100.0%
github.com/bati11/oreno-mqtt/study/packet/connect_variable_header.go:41:        isValidProtocolName     100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:14:                   ToFixedHeader           100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:35:                   refbit                  100.0%
github.com/bati11/oreno-mqtt/study/packet/fixed_header.go:40:                   decodeRemainingLength   100.0%
total:                                                                          (statements)            100.0%

$ go test --help を読むと以下のように書いてある。

-cover
    Enable coverage analysis.
    Note that because coverage works by annotating the source
    code before compilation, compilation and test failures with
    coverage enabled may report line numbers that don't correspond
    to the original sources.

-cover をつけない状態でテストして、PASSしてからカバレッジを取得する方が良さそうだ。

おしまい

MQTTのCONNECTパケットのペイロードを実装して、テストのカバレッジを取得しました。次回はサーバーとして起動するところまでいきたい。

今回の学び

MQTTサーバーを実装しながらGoを学ぶ - その3 errorとエラーハンドリング

前回func ToFixedHeader(bs []byte) FixedHeader という関数を実装しました。この関数の引数のチェックとエラーハンドリングからやります。Goにおけるエラーハンドリングを学んでいきたいと思います。

目次。

error

今のところ、 ToFixedHeader() 関数は以下のようになってる。

func ToFixedHeader(bs []byte) FixedHeader {
    b := bs[0]
    packetType := b >> 4
    dup := refbit(bs[0], 3)
    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,
        RemainingLength: remainingLength,
    }
}

以下のようなテストを追加して実行。

                        }},
                        packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0, RemainingLength: 128},
                },
+               {
+                       args: args{nil},
+                       // FIXME パラメータがnilのとき、wantはどうなるべき?
+                       want: packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0, RemainingLength: 0},
+               },
        }
        for _, tt := range tests {
                t.Run(fmt.Sprintf("%#v", tt.args.bs), func(t *testing.T) {
$ go test ./packet/
panic: runtime error: index out of range [recovered]
        panic: runtime error: index out of range

panicが起きた。ちゃんと引数をチェックして、問題がある場合は error を返すようにする。 error というのはただのinterfaceで以下のように定義されてる。

type error interface {
        Error() string
}

ToFixedHeaderbsnil かどうかをチェックする。ちなみにGoでは bs == nil でも len(bs) == 0 でもチェックできる。MQTT固定ヘッダーは最低でも2バイトなのでチェック処理は len(bs) <= 1 の場合は error を返すようにする。

        RemainingLength uint
 }
 
-func ToFixedHeader(bs []byte) FixedHeader {
+func ToFixedHeader(bs []byte) (FixedHeader, error) {
+       if len(bs) <= 1 {
+               return FixedHeader{}, errors.New("len(bs) should be greater than 1")
+       }
        b := bs[0]
        packetType := b >> 4
        dup := refbit(bs[0], 3)
@@ -24,7 +24,7 @@ func ToFixedHeader(bs []byte) FixedHeader {
                QoS2:            qos2,
                Retain:          retain,
                RemainingLength: remainingLength,
-       }
+       }, nil
 }
 
 func refbit(b byte, n uint) byte {

Goでは、エラーが起き得る関数の返り値は、正常な場合の返り値とエラーだった場合の返り値との2値を返すようにする。その場合、テストコードをTableDrivenTestで書いてる時は、 wantErr というパラメータを増やして、以下のように書き換える。

--- a/study/packet/fixed_header_test.go
+++ b/study/packet/fixed_header_test.go
@@ -13,8 +13,9 @@ func TestToFixedHeader(t *testing.T) {
                bs []byte
        }
        tests := []struct {
-               args args
-               want packet.FixedHeader
+               args    args
+               want    packet.FixedHeader
+               wantErr bool
        }{
                {
                        args{[]byte{
@@ -22,6 +23,7 @@ func TestToFixedHeader(t *testing.T) {
                                0x00, // 0
                        }},
                        packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0, RemainingLength: 0},
+                       false,
                },
                {
                        args{[]byte{
@@ -29,6 +31,7 @@ func TestToFixedHeader(t *testing.T) {
                                0x7F, // 127
                        }},
                        packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1, RemainingLength: 127},
+                       false,
                },
                {
                        args{[]byte{
@@ -36,11 +39,22 @@ func TestToFixedHeader(t *testing.T) {
                                0x80, 0x01, //128
                        }},
                        packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0, RemainingLength: 128},
+                       false,
+               },
+               {
+                       args{nil},
+                       packet.FixedHeader{},
+                       true,
                },
        }
        for _, tt := range tests {
                t.Run(fmt.Sprintf("%#v", tt.args.bs), func(t *testing.T) {
-                       if got := packet.ToFixedHeader(tt.args.bs); !reflect.DeepEqual(got, tt.want) {
+                       got, err := packet.ToFixedHeader(tt.args.bs)
+                       if (err != nil) != tt.wantErr {
+                               t.Errorf("ToFixedHeader() error = %v, wantErr %v", err, tt.wantErr)
+                               return
+                       }
+                       if !reflect.DeepEqual(got, tt.want) {
                                t.Errorf("ToFixedHeader() = %v, want %v", got, tt.want)
                        }
                })

テストケース毎のパラメータが増えて読みづらくなってきたので、テストコードをリファクタリング。フィールド名を明示的に指定する形に書き換え、あと bs が1byteのテストも追加。結果、テストコードは以下ようになった。

func TestToFixedHeader(t *testing.T) {
    type args struct {
        bs []byte
    }
    tests := []struct {
        args    args
        want    packet.FixedHeader
        wantErr bool
    }{
        {
            args: args{[]byte{
                0x00, // 0000 0 00 0
                0x00, // 0
            }},
            want:    packet.FixedHeader{PacketType: 0, Dup: 0, QoS1: 0, QoS2: 0, Retain: 0, RemainingLength: 0},
            wantErr: false,
        },
        {
            args: args{[]byte{
                0x1B, // 0001 1 01 1
                0x7F, // 127
            }},
            want:    packet.FixedHeader{PacketType: 1, Dup: 1, QoS1: 0, QoS2: 1, Retain: 1, RemainingLength: 127},
            wantErr: false,
        },
        {
            args: args{[]byte{
                0x24,       // 0002 0 10 0
                0x80, 0x01, //128
            }},
            want:    packet.FixedHeader{PacketType: 2, Dup: 0, QoS1: 1, QoS2: 0, Retain: 0, RemainingLength: 128},
            wantErr: false,
        },
        {
            args:    args{nil},
            want:    packet.FixedHeader{},
            wantErr: true,
        },
        {
            args:    args{[]byte{0x24}},
            want:    packet.FixedHeader{},
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(fmt.Sprintf("%#v", tt.args.bs), func(t *testing.T) {
            got, err := packet.ToFixedHeader(tt.args.bs)
            if (err != nil) != tt.wantErr {
                t.Errorf("ToFixedHeader() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("ToFixedHeader() = %v, want %v", got, tt.want)
            }
        })
    }
}

CONNECTパケットの可変ヘッダー

MQTTのパケットは 固定ヘッダー + Controlパケット別の可変ヘッダー + ペイロード という構造。固定ヘッダーはひと段落したということにして、CONNECTパケットの可変ヘッダーに取り掛かる。

http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html#_Toc398718030

FixedHeader の実装と同じように、 ConnectVariableHeader を実装する。CONNECT可変ヘッダーは大きく以下の構造を持ってる。

  • Protocol Name
  • Protocol Level
  • Connect Flags
  • Keep Alive

ざっと仕様を読んだ感じ、Connect FlagsとKeep Aliveは後でちゃんと考えることに仮実装にする。

Protocol Nameの説明に以下のようにあるので入力チェックをする。

If the protocol name is incorrect the Server MAY disconnect the Client, or it MAY continue processing the CONNECT packet in accordance with some other specification. In the latter case, the Server MUST NOT continue to process the CONNECT packet in line with this specification

Protocol Levelの説明に以下のようにあるので、こちらも入力チェックする。

The Server MUST respond to the CONNECT Packet with a CONNACK return code 0x01 (unacceptable protocol level) and then disconnect the Client if the Protocol Level is not supported by the Server

仕様に書いてあるexampleを仮実装する。

package packet

type ConnectFlags struct {
    CleanSession bool
    WillFlag     bool
    WillQoS      uint8
    WillRetain   bool
    PasswordFlag bool
    UserNameFlag bool
}

type ConnectVariableHeader struct {
    ProtocolName  string
    ProtocolLevel uint8
    ConnectFlags  ConnectFlags
    KeepAlive     uint16
}

func ToConnectVariableHeader(fixedHeader FixedHeader, bs []byte) (ConnectVariableHeader, error) {
    return ConnectVariableHeader{
        ProtocolName:  "MQTT",
        ProtocolLevel: 4,
        ConnectFlags:  ConnectFlags{UserNameFlag: true, PasswordFlag: true, WillRetain: false, WillQoS: 1, WillFlag: true, CleanSession: true},
        KeepAlive:     10,
    }, nil
}
func TestToConnectVariableHeader(t *testing.T) {
    type args struct {
        fixedHeader packet.FixedHeader
        bs          []byte
    }
    tests := []struct {
        name    string
        args    args
        want    packet.ConnectVariableHeader
        wantErr bool
    }{
        {
            name: "仕様書のexample",
            args: args{
                fixedHeader: packet.FixedHeader{PacketType: 1},
                bs: []byte{
                    0x00, 0x04, 'M', 'Q', 'T', 'T', // Protocol Name
                    0x04,       // Protocol Level
                    0xCE,       // Connect Flags
                    0x00, 0x0A, // Keep Alive
                },
            },
            want: packet.ConnectVariableHeader{
                ProtocolName:  "MQTT",
                ProtocolLevel: 4,
                ConnectFlags:  packet.ConnectFlags{UserNameFlag: true, PasswordFlag: true, WillRetain: false, WillQoS: 1, WillFlag: true, CleanSession: true},
                KeepAlive:     10,
            },
            wantErr: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := packet.ToConnectVariableHeader(tt.args.fixedHeader, tt.args.bs)
            if (err != nil) != tt.wantErr {
                t.Errorf("ToConnectVariableHeader() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("ToConnectVariableHeader() = %v, want %v", got, tt.want)
            }
        })
    }
}

テスト実行。

$ go test ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       0.714s

OK!

入力チェックでerrorになるようなテストケースを追加。

                        },
                        wantErr: false,
                },
+               {
+                       name: "固定ヘッダーのPacketTypeが1ではない",
+                       args: args{
+                               fixedHeader: packet.FixedHeader{PacketType: 2},
+                               bs: []byte{
+                                       0x00, 0x04, 'M', 'Q', 'T', 'T', // Protocol Name
+                                       0x04,       // Protocol Level
+                                       0xCE,       // Connect Flags
+                                       0x00, 0x0A, // Keep Alive
+                               },
+                       },
+                       want:    packet.ConnectVariableHeader{},
+                       wantErr: true,
+               },
+               {
+                       name: "Protocol Nameが不正",
+                       args: args{
+                               fixedHeader: packet.FixedHeader{PacketType: 1},
+                               bs: []byte{
+                                       0x00, 0x04, 'M', 'Q', 'T', 't', // Protocol Name
+                                       0x04,       // Protocol Level
+                                       0xCE,       // Connect Flags
+                                       0x00, 0x0A, // Keep Alive
+                               },
+                       },
+                       want:    packet.ConnectVariableHeader{},
+                       wantErr: true,
+               },
+               {
+                       name: "Protocol Levelが不正",
+                       args: args{
+                               fixedHeader: packet.FixedHeader{PacketType: 1},
+                               bs: []byte{
+                                       0x00, 0x04, 'M', 'Q', 'T', 'T', // Protocol Name
+                                       0x03,       // Protocol Level
+                                       0xCE,       // Connect Flags
+                                       0x00, 0x0A, // Keep Alive
+                               },
+                       },
+                       want:    packet.ConnectVariableHeader{},
+                       wantErr: true,
+               },
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {

テストが失敗する。

$ go test ./packet/
--- FAIL: TestToConnectVariableHeader (0.00s)
    --- FAIL: TestToConnectVariableHeader/固定ヘッダーのPacketTypeが1ではない (0.00s)
        connect_variable_header_test.go:87: ToConnectVariableHeader() error = <nil>, wantErr true
    --- FAIL: TestToConnectVariableHeader/Protocol_Nameが不正 (0.00s)
        connect_variable_header_test.go:87: ToConnectVariableHeader() error = <nil>, wantErr true
    --- FAIL: TestToConnectVariableHeader/Protocol_Levelが不正 (0.00s)
        connect_variable_header_test.go:87: ToConnectVariableHeader() error = <nil>, wantErr true
FAIL
FAIL    github.com/bati11/oreno-mqtt/study/packet       5.758s

チェック処理を追加。

--- a/study/packet/connect_variable_header.go
+++ b/study/packet/connect_variable_header.go
@@ -1,5 +1,7 @@
 package packet
 
+import "errors"
+
 type ConnectFlags struct {
        CleanSession bool
        WillFlag     bool
@@ -17,6 +19,15 @@ type ConnectVariableHeader struct {
 }
 
 func ToConnectVariableHeader(fixedHeader FixedHeader, bs []byte) (ConnectVariableHeader, error) {
+       if fixedHeader.PacketType != 1 {
+               return ConnectVariableHeader{}, errors.New("fixedHeader.PacketType must be 1")
+       }
+       if !isValidProtocolName(bs[:6]) {
+               return ConnectVariableHeader{}, errors.New("protocol name is invalid")
+       }
+       if bs[6] != 4 {
+               return ConnectVariableHeader{}, errors.New("protocol level must be 4")
+       }
        return ConnectVariableHeader{
                ProtocolName:  "MQTT",
                ProtocolLevel: 4,
@@ -24,3 +35,9 @@ func ToConnectVariableHeader(fixedHeader FixedHeader, bs []byte) (ConnectVariabl
                KeepAlive:     10,
        }, nil
 }
+
+func isValidProtocolName(protocolName []byte) bool {
+       return len(protocolName) == 6 &&
+               protocolName[0] == 0 && protocolName[1] == 4 &&
+               protocolName[2] == 'M' && protocolName[3] == 'Q' && protocolName[4] == 'T' && protocolName[5] == 'T'
+}

テスト成功!

$ go test ./packet/
ok      github.com/bati11/oreno-mqtt/study/packet       1.386s

Goのエラーハンドリング

テストが通ったものの error が返ってきたかどうかの判定しかしてない。返ってきた error に応じて呼び出し元の挙動を変えたい場合はどうやって書くべきなのだろう。

調べてみるといくつかパターンがあるようだ。

  • error.Error()して中身をみてstringとして使う
  • Sentinel error(io.EOFなど)を使う
  • 自分でError typeを定義してType assertionする

これらのパターンはオススメではないらしい。

上の記事から参照されてるPDFのスライドが非常に勉強になる。

error.Error()して中身を見るパターン

関数の呼び出し側で error.Error() を読み出し、得られたstringで処理を分岐する。現状の ToConnectVariableHeader() はこのパターンになる。

package packet

func ToConnectVariableHeader(fixedHeader FixedHeader, bs []byte) (ConnectVariableHeader, error) {
    if fixedHeader.PacketType != 1 {
        return ConnectVariableHeader{}, errors.New("fixedHeader.PacketType must be 1")
    }
    ...
_, err := packet.ToConnectVariableHeader(header, bs)
if err.Error() == "fixedHeader.PacketType must be 1" {
    ...
}

しかし、呼び出し側が error.Error() で得られる文字列に依存してしまい、文字列が変更されると簡単にバグる。

また、errorを返す関数側では errors.New の引数に静的な文字列しか渡せない。例えば、 ToConnectVariableHeader() の内部で、引数で渡された値を使って fmt.Errorf("protocol level must be 4. but get %v", bs[6]) のように文字列を動的に作ると、呼び出し元が error.Error() で文字列を取得して分岐してる場合は困ることになる。

スライドには以下のようにも書いてある。

The Error method on the error interface exists for humans, not code.

error.Error() で取得できる文字列は人のため、つまりプログラマーデバッグするときに使うための文字列であると。この文字列を使って処理を分岐するのは良くなさそう。

Sentinel error パターン

Sentinel errorパターンは errors.New() で生成する error をパッケージの公開変数として定義する。

package packet

var PacketTypeError = errors.New("fixedHeader.PacketType must be 1")

func ToConnectVariableHeader(fixedHeader FixedHeader, bs []byte) (ConnectVariableHeader, error) {
    if fixedHeader.PacketType != 1 {
        return ConnectVariableHeader{}, PacketTypeError
    }
    ...
import packet

_, err := packet.ToConnectVariableHeader(header, bs)
if err == packet.PacketTypeError {
    ...
}

Sentinel error パターンを使うと error.Error() で取得する文字列に依存することはなくなる。しかし、呼び出し側が処理を分岐をするために、 error を定義してるパッケージをimportする必要が出てくる(例えば、if err == io.EOF という分岐を書くとioパッケージをimportしなくてはならない)。つまり、このパターンでも依存関係ができてしまうので良くない、と。

Error types パターン

3つめのパターン「 自分でError typeを定義してType assertionする 」というのはスライドでは Error Types と呼んでいる。この方法であれば呼び出し元に文字列以外の色々な追加で渡すことができる。呼び出し元は errors.Error() で文字列を取り出すことなく、以下のように分岐できる。

switch e := err.(type) {
case net.Error:
        // ...
default:
        // ...
}

参考 Effective Go: Interface conversions and type assertions

ただし、この方法でも依存関係の問題は解決しない。結局、自分で定義したError typeはPublicなAPIの一部であり、Sentinel errorパターンと同様に呼び出し元が分岐で利用する際にimportする必要がある。

Opaque errors パターン

そこで、オススメされてるのが、スライド内で Opaque errors と呼んでいるパターン。例えば、以下のような呼び出し元のコードがある。呼び出し元にinterfaceをterfaceを定義しておいて、呼び出される側はそのinterfaceを満たしているような error を返すようにする。

package a

type temporary interface {
    Temporary() bool

}

func IsTemporary(err error) bool {

   te, ok := err.(temporary)

   return ok && te.Temporary()

}

先ほどの Error Types パターンと大きく違うのは、Type Assersionするinterface(上の例だと temporary )が、呼び出し元パッケージで定義されている点。この方法が最も柔軟なエラーハンドリングの戦略になる、とスライドには書いてある。

自分なりにまとめると

スライドで心に残った言葉たち。

  • Errors are just values
  • Errors are part of your package's public API
  • However, I have concluded that there is no single way to handle errors.

Errors are just values 」については以下の記事も参考になるなぁと思った。

Errors are part of your package's public API 」はまぁそうだよね。Sentinel ErrorやError Typeを使うとpublic APIを増やすことになる。

AパッケージからBパッケージの関数を呼びだす場合、Bパッケージに定義されたSentinel ErrorやError Typeを関数が返し、呼び出し元がそれらの error に応じて処理を分岐させると、AパッケージがBパッケージに依存する。つまり A→B という依存関係。

そこでOpaque Patternを使う。Aパッケージにインタフェースを定義しておいて、Bパッケージからはそのインタフェースを満たした error を返すようにする。これで A→B という依存は消える。GoはJavaのように明示的にインタフェースをimplementsと書いたりする必要はなく、BパッケージがAパッケージをimpoertすることなくインタフェースを満たす実装を書けるので、コード上において明示的に B→A という依存はなくなる。しかし、実装する時はAパッケージを 知っている 必要があるので、暗黙的に依存することになってしまうのではないか。

つまり、Opaque Patternを使っても依存関係がなくなるわけではなく、 A→B という依存が B→A のように依存関係が逆方向になってるだけなのではないか。これは何もGoの errors に限った話ではなく、DIPで依存関係の方向を逆にしてるのと同じにように感じる。

However, I have concluded that there is no single way to handle errors. 」と書いてある通り常に1つの方法で解決できるわけではなさそう。

使いわけとしては、まず呼び出し元で分岐が必要なければ値としての error を返せばいいだけ。呼び出し元で error に応じて分岐させたい場合はError TypeかOpaque Patternを使う。Senitinel Errorは、後からError TypeやOpaque Patternに書き換えることが大変そうなので避ける。

Opaque Patternを使うかどうかは、 error だけを特別に考えるのではなく関数呼び出しの方向と依存関係の方向に着目する。クリーンアーキテクチャなどでDIPを使って関数呼び出しの方向と依存関係の方向を逆方向にしてたら、 error も同じようにOpaque Patternで依存関係の方向を逆にする。そうでなければError Typeでいい。のではないか。

他には、HTTPでエラー時のレスポンスを作るために error を使いたい場合は、そのためのインタフェースをどっかのパッケージにinterfaceを定義しておいてOpaque Patternを使うといいかも。という風に考えてたら確かにほとんどの場合Opaque Patternで良さそうだ。

package app

type ErrorDetail struct {
    Code    uint
    Message string
}

type Error interface {
    ErrorDetail() ErrorDetail
}
result, err := bpackage.exec(params)
if err != nil {
  if appErr, ok := err.(app.Error); ok {
    errorDetail := appErr.ErrorDetail()
    // errorDetailを使う処理
  }
  // appErrじゃないerrorの処理
}

// resultを使う処理

panic

panicについて。個人的にはpanicとdeferにそんなに不満ないけど、色々あるんですかね。

おしまい

MQTTのCONNECTパケットの可変ヘッダーを実装しながら、Goのエラーハンドリングについて学びました。次はCONNECTパケットのペイロードの扱いと、テストのカバレッジについてやっていきます。

今回の学び

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してしまいます。次回はここのエラーハンドリングから考えることにします。

今回の学び