前回で 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のエラーハンドリング