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
す、すごい...。
他にも -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 }
ToFixedHeader
で bs
が nil
かどうかをチェックする。ちなみに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 theerror
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にそんなに不満ないけど、色々あるんですかね。
- Defer, Panic, and Recover - The Go Programming Language
- 「例外」がないからGo言語はイケてないとかって言ってるヤツが本当にイケてない件 #Go - Qiita
おしまい
MQTTのCONNECTパケットの可変ヘッダーを実装しながら、Goのエラーハンドリングについて学びました。次はCONNECTパケットのペイロードの扱いと、テストのカバレッジについてやっていきます。
今回の学び
- errors
- CONNECTパケットの可変ヘッダー
- Goのエラーハンドリング
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 -w
と go 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
オプションをつけてるとコードの解析もしてくれる。ただし、起動に時間がかかる...。
- https://qiita.com/lufia/items/97acb391c26f967048f1#%E9%9D%99%E7%9A%84%E8%A7%A3%E6%9E%90
- Static analysis features of godoc - The Go Programming Language
もう一方の go doc
(goコマンド+docオプション)ですが、こちらはコマンドラインでGo Docが確認できる。
結構色々あるみたい。こちらの記事が参考になる。
おしまい
FixedHeader
に ReminingLength
フィールドを追加しました。しかし、 ToFixedHeader
に渡す []byte
のチェックをしてないので1バイトの配列やnilを渡すとpanicしてしまいます。次回はここのエラーハンドリングから考えることにします。
今回の学び
- MQTTの固定ヘッダーのRemining Length
- go vet
- gofmt
- go doc