んだ日記

ndaDayoの技術日記です

『実用 Go言語』を読んでみた。4章インターフェース編〜5章エラーハンドリング

こんにちわ。んだです。

今回も『実用 Go言語』の読書録です。

インターフェースの設計のポイント

まずは、Goのインターフェース設計の指針についての記述があったので紹介します。

Goでは「何でもインターフェースにして柔軟性を保つ」「いつでも実装を切り替えられるようにする」という設計はされていませんでした。(...中略)さまざまな事情はあると思いますが、過剰に複雑な実装をするくらいならインターフェースを使わずにコードを書くという指針で十分です。あらかじめ、テストを考えてモック化できるようにインターフェースにしておく、という考えをしているGoユーザーは少数派です。

この辺は、Go特有というわけではなく、他の言語でも同じことが言えますね。

Example

では、Exampleを『Writing A Compiler In Go』から見てみましょう。

interfaceの定義

interfaceの定義は、こんな感じです。

type Node interface {
    TokenLiteral() string
    String() string
}

type Statement interface {
    Node
    statementNode()
}

type Expression interface {
    Node
    expressionNode()
}

interfaceの具象

interfaceを満たすには、インターフェースで定義しているメソッドを実装すればOKです。 以下の実装は、Nodeインターフェースを実装しているということになります。

func (p *Program) TokenLiteral() string {
    if len(p.Statements) > 0 {
        return p.Statements[0].TokenLiteral()
    } else {
        return ""
    }
}

func (p *Program) String() string {
    var out bytes.Buffer

    for _, s := range p.Statements {
        out.WriteString(s.String())
    }

    return out.String()
}

エラーハンドリング

続いてはエラーハンドリング

エラーはただの値。

まず、おさえておくことはGoにおいては、エラーはただの値であること。PHPでは、try-catchで捕縛することはできるが、Goでは例外処理はない。

go.dev

よって、

if err != nil {
   // エラー発生時の挙動
}

という書き方をする。

個人的には明示的にエラー発生時の挙動をかけるので、いいなと思う点ではある。 が、冗長な表現になってしまうという点も否めない。

ここらへんを解消するは、いろんな小技があるらしい。

独自のエラー型

で、Goでは、buildinでerrorインターフェースが定義されている。

github.com

よって、こんな感じに独自のエラーを簡単に定義することが可能です。

type HTTPError struct {
    StatusCode int
    URL        string
}

func (he *HTTPError) Error() string {
    return fmt.Sprintf("http status code = %d, url = %s", he.StatusCode, he.URL)
}

func ReadContents(url string) ([]byte, error) {
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, &HTTPError{
            StatusCode: resp.StatusCode,
            URL:        url,
        }
    }

    return io.ReadAll(resp.Body)
}

エラーのラップ

ここからは、以下の書籍をもとに書いていきます。

エラーラッピングとは?

まず、エラーをラップするというのは、どういうことか?なんですが、

イメージ的には、こんな感じです。(本書を参考に作成。

そして、どんなときにラップするか?ですが、

  • エラーに文脈情報を追加する
  • エラーを特定エラーとしてマークする

の2つです。

Go1.13から導入された%w

Go1.13より前のバージョンではエラーをラップしたい場合には、前述のHTTPErrorのように独自のエラーを開発者が定義してラップするしかなかった。

が、Go1.13から導入された%wによって

if err != nil  {
        return fmt.Errorf("bar failed : %w, err)
}

という書き方が可能になり、独自のエラーを定義しなくてもエラーに文脈情報を追加することができるようになった。

エラー型の検査

続いては、errorAs()について書いていく。

まずは、次のコードを見てみましょう。

type transientError struct {
    err error
}

func (t transientError) Error() string {
    return fmt.Sprintf("transient error: %v", t.err)
}

func GetTransactionAmountHandler(w http.ResponseWriter, r *http.Request) {
    transactionID := r.URL.Query().Get("transaction")
    amount, err := getTransactionAmount(transactionID)
    if err != nil {
        switch err := err.(type) {
        case transientError:
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
        default:
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
    }
}

func getTransactionAmount(transactionID string) (float32, error) {
    if len(transactionID) != 5 {
        return 0, fmt.Errorf("id is invalid: %s", transactionID)
    }

    amount, err := getTransactionAmountFromDB(transactionID)
    if err != nil {
        return 0, transientError{err: err}
    }

    return amount, nil
}

func getTransactionAmountFromDB(transactionID string) (float32, error) {
    var amount float32
    err := db.QueryRow("SELECT amount FROM transactions WHERE id = $1", transactionID).Scan(&amount)
    if err != nil {
        return 0, transientError{err: err}
    }

    return amount, nil
}

GetTransactionAmountHandlerで、エラーの型をみて、transientErrorであれば503、それ以外は400を返している。

この状態であれば、特に問題はないが、getTransactionAmountを以下のようにリファクタリングした場合を考えてみましょう。

func getTransactionAmount(transactionID string) (float32, error) {
    if len(transactionID) != 5 {
        return 0, fmt.Errorf("id is invalid: %s", transactionID)
    }

    amount, err := getTransactionAmountFromDB(transactionID)
    if err != nil {
        return 0, fmt.Errorf("failed to get transaction %s: %w", transactionID, err)
    }

    return amount, nil
}

return 0, fmt.Errorf("failed to get transaction %s: %w", transactionID, err)で、transientErrorをラップして返しています。

しかし、このように変更すると、case transientError:を通らなくなってしまいます。

func GetTransactionAmountHandler(w http.ResponseWriter, r *http.Request) {
    transactionID := r.URL.Query().Get("transaction")
    amount, err := getTransactionAmount(transactionID)
    if err != nil {
        switch err := err.(type) {
        case transientError:
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
        default:
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
    }

そこで、登場するのが、errors.As() errors.As()は、ラップされたエラーの型をチェックしてくれます。

errors.As()で書き直したGetTransactionAmountHandlerがこちら。

func GetTransactionAmountHandler(w http.ResponseWriter, r *http.Request) (float32, error) {
    transactionID := r.URL.Query().Get("transaction")
    amount, err := getTransactionAmount(transactionID)
    if err != nil {
        if errors.As(err, &transientError{}) {
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
            return 0, err
        }
        http.Error(w, err.Error(), http.StatusBadRequest)
        return 0, err
    }

    return amount, nil
}

errors.As()を使って、ラップされたエラーの型をチェックしています。エラーの型ではなく、値を確認する場合には、Is()を使ってあげればOK

まとめ

インターフェースとエラーハンドリングについて、見てきました。 エラーハンドリングには、まだいろいろと小技とか気を付けるべきところがありそうなので、これからぼちぼち理解していきたいと思います。

僕から以上。あったかくして寝ろよ。