Mackerel REST APIの公式Goクライアントをジェネリクスを使ってリファクタリングしました

この記事はMackerel Advent Calendar 202320日目です。 昨日はsfujiwaraさんでした。

先日15日目の記事で、私が作ったRustクライアントmackerel-client-rsの設計についてご紹介しました。 itchyny.hatenablog.com クライアントの設計について色々とご紹介しましたが、肝となる実装は以下のメソッドです (はてなブログのRustのシンタックスハイライトでasyncに色つかないな〜チラッチラッ)。

impl Client {
    pub(crate) async fn request<R, S>(
        &self,
        method: http::Method,
        path: impl AsRef<str>,
        query_params: &[(&str, impl AsRef<str>)],
        request_body_opt: Option<impl serde::ser::Serialize>,
        converter: impl FnOnce(R) -> S,
    ) -> Result<S>
    where
        for<'de> R: serde::de::Deserialize<'de>,
    { ... }
}

Clientの全てのメソッドは、このrequestメソッドを使って実装しています。 methodpathquery_paramsはそのままの意味なので説明不要でしょう。 request_body_optは、SerializeすなわちJSONに変換できるあらゆる構造体を渡すことができます。 OptionなのでNoneを指定した場合は、リクエストボディはありません。 レスポンスはR: Deserialize<'de>、つまりJSONから変換できるものなのですが、converterというclosureを引数でもらって返り値を変更できるようにしています。 例えば|res| resを渡せばレスポンスボディからデシリアライズした構造体をそのまま返しますが、|res: ListMonitorsResponse| res.monitorsを渡すとレスポンスボディのmonitorsフィールドを抜き出すという感じです (このListMonitorsResponseという構造体すら手で定義するのが面倒なのでマクロで生成しています)。

さて、Mackerel APIのオフィシャルクライアントmackerel-client-goは、もちろんGo言語で書かれています。 このパッケージの典型的な実装は次のような感じでした。

func (c *Client) GetOrg() (*Org, error) {
    req, err := http.NewRequest("GET", c.urlFor("/api/v0/org").String(), nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.Request(req)
    defer closeResponse(resp)
    if err != nil {
        return nil, err
    }
    var data Org
    err = json.NewDecoder(resp.Body).Decode(&data)
    if err != nil {
        return nil, err
    }
    return &data, nil
}

func (c *Client) CreateDowntime(param *Downtime) (*Downtime, error) {
    resp, err := c.PostJSON("/api/v0/downtimes", param)
    defer closeResponse(resp)
    if err != nil {
        return nil, err
    }
    var data Downtime
    err = json.NewDecoder(resp.Body).Decode(&data)
    if err != nil {
        return nil, err
    }
    return &data, nil
}

代表例としてGETするものとPOSTするものを挙げてみました。 closeResponseClient#PostJSONなど多少の便利関数はあるものの、素朴にHTTPリクエストを作ってJSONデコードしていることがわかりますね。 特定のフィールドを一覧で出すものやクエリパラメータを指定している実装も見てみましょう。

func (c *Client) FindServices() ([]*Service, error) {
    req, err := http.NewRequest("GET", c.urlFor("/api/v0/services").String(), nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.Request(req)
    defer closeResponse(resp)
    if err != nil {
        return nil, err
    }

    var data struct {
        Services []*Service `json:"services"`
    }
    err = json.NewDecoder(resp.Body).Decode(&data)
    if err != nil {
        return nil, err
    }
    return data.Services, err
}

func (c *Client) FetchLatestMetricValues(hostIDs []string, metricNames []string) (LatestMetricValues, error) {
    v := url.Values{}
    for _, hostID := range hostIDs {
        v.Add("hostId", hostID)
    }
    for _, metricName := range metricNames {
        v.Add("name", metricName)
    }

    req, err := http.NewRequest("GET", fmt.Sprintf("%s?%s", c.urlFor("/api/v0/tsdb/latest").String(), v.Encode()), nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.Request(req)
    defer closeResponse(resp)
    if err != nil {
        return nil, err
    }

    var data struct {
        LatestMetricValues LatestMetricValues `json:"tsdbLatest"`
    }
    err = json.NewDecoder(resp.Body).Decode(&data)
    if err != nil {
        return nil, err
    }

    return data.LatestMetricValues, err
}

似たようなコードを何度も書いていてこれは大変だなと共感していただけるかと思います。 レスポンスをデコードするのに毎回json.NewDecoderと書くのは面倒ですし、うっかりレスポンスを閉じるのも忘れそうになります。

このような実装をリファクタリングするには、皆さんはどのようなアプローチをとりますか? 私はRustクライアントの経験を元にmackerel-client-goのこの実装をなんとかしてやろうと思い試行錯誤した結果、次のような設計に落ち着きました。

まず、最も内側の関数を以下のようなシグネチャにしてみました (実際には諸事情でレスポンスヘッダーも返すのですが、この記事では省略します)。 レスポンスボディをどの型にデコードするかを型パラメータで指定します。 リクエストを送ってレスポンスをJSONデコードしたり閉じたりするのはこの関数でのみ行います。

func requestInternal[T any](
    client *Client, method, path string,
    params url.Values, body io.Reader) (*T, error) { ... }

次に、リクエストボディのない関数とJSONエンコードする関数を実装します。

func requestNoBody[T any](client *Client, method, path string, params url.Values) (*T, error) {
    return requestInternal[T](client, method, path, params, nil)
}

func requestJSON[T any](client *Client, method, path string, payload any) (*T, error) {
    var body bytes.Buffer
    err := json.NewEncoder(&body).Encode(payload)
    if err != nil {
        return nil, err
    }
    return requestInternal[T](client, method, path, nil, &body)
}

これらを各クライアントメソッドの実装で使っても良いのですが、実装の簡潔さのためにHTTPメソッドごとのユーティリティー関数を用意しました。 Mackerel REST APIのGETメソッドはクエリパラメータのないものが多いのでこれにrequestGetという名前をつけて、パラメータを指定するものはrequestGetWithParamsと名付けました。 GET以外のAPIでクエリパラメータを指定するものはないとか、DELETEメソッドのAPIでリクエストボディを指定するものはないなど、Mackerel REST APIの特徴に合わせて実装しています。

func requestGet[T any](client *Client, path string) (*T, error) {
    return requestNoBody[T](client, http.MethodGet, path, nil)
}

func requestGetWithParams[T any](client *Client, path string, params url.Values) (*T, error) {
    return requestNoBody[T](client, http.MethodGet, path, params)
}

func requestPost[T any](client *Client, path string, payload any) (*T, error) {
    return requestJSON[T](client, http.MethodPost, path, payload)
}

func requestPut[T any](client *Client, path string, payload any) (*T, error) {
    return requestJSON[T](client, http.MethodPut, path, payload)
}

func requestDelete[T any](client *Client, path string) (*T, error) {
    return requestNoBody[T](client, http.MethodDelete, path, nil)
}

以上のユーティリティー関数を使うと、クライアントの実装が圧倒的に楽になります。 単純なGETやPOSTならば一行で書けてしまいます。 JSONデコーダをそれぞれで作っていたコードからすると、とてもスッキリして見えますね。

func (c *Client) GetOrg() (*Org, error) {
    return requestGet[Org](c, "/api/v0/org")
}

func (c *Client) CreateDowntime(param *Downtime) (*Downtime, error) {
    return requestPost[Downtime](c, "/api/v0/downtimes", param)
}

特定のフィールドを抜き出したりクエリパラメータを指定する実装も、これまでと比較すると簡潔に書けるようになったと思います。 匿名の構造体を型パラメータに指定するというのがなかなかおしゃれではないでしょうか。

func (c *Client) FindServices() ([]*Service, error) {
    data, err := requestGet[struct {
        Services []*Service `json:"services"`
    }](c, "/api/v0/services")
    if err != nil {
        return nil, err
    }
    return data.Services, nil
}

func (c *Client) FetchLatestMetricValues(hostIDs []string, metricNames []string) (LatestMetricValues, error) {
    params := url.Values{}
    for _, hostID := range hostIDs {
        params.Add("hostId", hostID)
    }
    for _, metricName := range metricNames {
        params.Add("name", metricName)
    }

    data, err := requestGetWithParams[struct {
        LatestMetricValues LatestMetricValues `json:"tsdbLatest"`
    }](c, "/api/v0/tsdb/latest", params)
    if err != nil {
        return nil, err
    }
    return data.LatestMetricValues, nil
}

改めてリクエストを実際に送る共通処理の型を見てみましょう。

func requestInternal[T any](
    client *Client, method, path string,
    params url.Values, body io.Reader) (*T, error) { ... }

Go 1.18で導入されたジェネリクスを使っていますね。 当初は賛否両論が盛り上がりましたが、あれから二年弱経って、ジェネリクスに関する標準パッケージも徐々に使われるようになってきました。 まだGo言語にジェネリクスのない世界線であれば次のように実装したでしょう。

func (c *Client) requestInternal(
    method, path string, params url.Values,
    body io.Reader, resp any) error { ... }

func (c *Client) GetOrg() (*Org, error) {
    var org Org
    if err := c.requestGet("/api/v0/org", &org); err != nil {
        return nil, err
    }
    return &org, nil
}

ジェネリクスはメソッドに使えないという難点はあるものの、返したい値を引数で渡すといった古臭いやり方をしなくて良くなったのはいいことだと思います。

mackerel-client-goの全てのクライアントメソッドを以上のようにリファクタリングしたPRを出したところ、すぐにレビューして取り込んでいただきました。 github.com しかし、やや乱暴なやり方だったかもしれないなと反省しています。 もう少し実装方針を共有してから進めた方が良かったかもしれません。 既存の実装と併存させたくないという思いが強くて一気に直してしまいました。

また、動作確認が甘くて一部の実装をバグらせてしまいました。 これについては本当に申し訳なかったです。 ボディーがない場合はContent-Type: application/jsonをリクエストで指定しなくても良いかと思ったのですが、GET以外なら常に必要なようです。 おそらくリクエストボディーの有無に関わらず、共通レイヤーでバリデーションしているのでしょう。

mackerel-client-goをジェネリクスを使ってリファクタリングしたよという話でした。 mackerel-client-rsの設計の知見を生かしつつ、Goのジェネリクスをmackerel-client-goに取り入れて大幅にコードを削減しました。 個人的には実装がかなり読みやすくなったのではないかと思います。 mackerel-client-goで次に改善できそうなことと言えば…やはり日時の扱いですかねぇ… (チラッチラッ

以上、Mackerel Advent Calendar 2023の20日目でした。明日はkmutoさんです。

qiita.com