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パケットのペイロードの扱いと、テストのカバレッジについてやっていきます。

今回の学び