リリース時にのみ行う処理はできるかぎり減らす

GitHub Actionsなどでテストやリリースを自動化していると、様々なトリガーによって異なる処理を行うことはよくあることです。 例えばpushのトリガーではテストやlintを行い、tagをpushしたときはクロスビルドしてリリースする、などです。 これらの処理は内容も頻度も異なるので、ワークフローのファイルを分けることはよくあることだと思います。 私もかつてはこのようにトリガーや実行したい頻度が異なるのだから分けるのは自然なことだと考えていました。 一つのワークフローの中でトリガーによって処理を分岐させるとワークフローが複雑になるし、面倒なことが多いからです。

しかし、最近はリリース時の処理をテストと同じワークフローにまとめる方が良いと考えるようになりました。 リリースする時になって初めて動く処理が多いほど、リリースのワークフローが壊れるリスクが高まるからです。 GitHub Actionsになって、依存するactionの更新や実行するrunnerの更新など、ジョブが外的要因によって壊れることが増えたように感じます。 クロスビルドやDockerイメージのビルドなどリリースする時しか使っていないツールやactionがあると、それらの更新を取り込んだ後の最初のリリースで動かないことに初めて気が付くというリスクがあります。 ワークフローが分かれていると、リリースのコミットやその時の外的要因 (CIの不調とか依存の更新とか) によってテストが落ちるようになったのにリリースされてしまうという懸念もあります。

テストもリリースも一つのワークフローにまとめた上で、リリース時にのみ行う処理はできるかぎり減らすというのが良いと思います。 リリースかどうかの分岐をあちこちに書く羽目になったとしても、です。 クロスビルドは普段からビルドすれば良いですし、Dockerイメージのビルドも常に行っておくべきです。 リリース時には、そういうビルド済みのアーティファクトをダウンロードしてアップロードするだけとか、Dockerイメージをpushするだけとか (docker/build-push-actionならpushオプションでオンオフできる)、そういう処理に限ることが望ましいです。 要するに、リリースのためのビルド処理は、テストと同じ頻度で行いましょうということだと思います。 また、テストが通らなかったらリリースを止めたいという素朴な要件が、ワークフローがまとまっていれば簡単に達成できるのです (GitHub Actionsがワークフロー間の依存を定義しにくいという事情もあるでしょう。workflow_runってみなさん使ってます?)。

私はjqのメンテナをやっているのですが、jqのCIもテストとリリースを一つのワークフローにまとめています。 それでもなお、dependabotによるactions/upload-artifactactions/download-artifactの更新PRが別々に来た時に、他のメンテナが前者のみをマージしてしまい一時的にリリースジョブが壊れてしまったことがあります。 ビルドした実行ファイルをダウンロードしてDockerイメージを作るためにdownload-artifactを使っていたのですが、そのジョブはリリースの時にしか実行していませんでした。 今は、PRの作成時もDockerイメージのビルドを行うようにしています。 github.com

CIリソースを心配されるかもしれませんが、大体のケースではキャッシュを活用すれば節約できますし、リリースジョブが壊れにくくするための必要なコストかなと思っています。 もちろんEnvironmentsの都合などで常には実行できない処理は諦めざるを得ない場面もあるでしょう。 できるだけリリースする時のリスクを減らすための考え方の一つとしてご紹介しました。 他の手としては、リリース用のワークフローは手動でdry runできるようにしておく方法もありそうです (経験上、どんなワークフローでもworkflow_dispatchをつけておいて損はないと思います)。

2023年を振り返って

サイボウズに入社して二年が経ちました。 プロダクトのインフラ基盤の移行に携わりながら、チームの生産性を向上させる様々な取り組みを行っていました。 特にCircleCIからGitHub Actionsへの移行はかなりコミットしました。 色々な知見が溜まったのですがアウトプットし損ねています。 来年は失ったオープンネスを取り戻してチームの取り組みを外に出していきたいです。

OSS活動としてはjqのメンテナになったのが大きい変化です。 jqのリポジトリをjqlang orgに移譲して、新しいメンテナの体制の元で、新しい1.7というバージョンをリリースできました。 itchyny.hatenablog.com itchyny.hatenablog.com 様々な機能を実装してリリースできたことはもちろん、持続的な開発体制に移行できたのはなによりも大きな変化でした。 これからもjqの開発に貢献していきたいです。

jqのリリースが落ち着いた秋頃、Rustへの興味が再燃しました。 長らく放置していたMackerelのREST APIのRustクライアントのメンテナンスを再開し、Mackerelの機能追加に追従しつつ、APIの使い勝手を大幅に改善しました。 この改善の内容は、アドベントカレンダーの記事として公開しました。 itchyny.hatenablog.com さらに、この改善が落ち着いた頃に公式のGoクライアントの実装のリファクタリングも行いました。 この内容も、アドベントカレンダーの空き枠があったところにシュッと入って公開しました。 itchyny.hatenablog.com

今年の前半は、とにかく結婚式の準備に奔走していていました。 テーブルクロスの色について永遠に議論したり、印刷所に行って席次表を印刷したり、音声のトラブルがあり焼き直したDVDを持って休日の郵便局に駆け込んだりしたのが、はるか昔の出来事のようです。 式の当日はあっという間に過ぎてしまいました。 雰囲気の良い式場で挙式できて本当に一生の思い出になりました。 五月に旅行で行った北海道もとてもよかったです。

生成系AIがあっという間に広まった一年でした。 個人的にはChatGPTよりもGitHub Copilotの方が衝撃が大きかったです。 ChatGPTは確かに便利ではあるものの、たまに手紙やスピーチの原稿を作ってもらう程度で、そこまで生活を変えた感覚はありません。 しかし、GitHub Copilotは違いました。 コーディングの速度が圧倒的に上がり、もはや手放せないツールになっています。 特に、類似するコードが同じファイルにあるときにCopilotが提案するコードは驚くほど精度が良く、数秒で狙った実装やテストコードが完成することも少なくありません。 今まで画面を分割して似たようなコードを参考にしながら頑張って書いていたのはなんだったんだろうと思います。

今年はよくドラマを見ていました。 TBS系日曜劇場の『VIVANT』は圧倒的に良かったですね。続編が見たいです。 『星降る夜に』は雰囲気が好みで毎話二回は見ていました。 『いちばんすきな花』も大好きで何度も見返していました。 藤井風の主題歌がドラマの雰囲気とよく合ってました。 他にも『ハヤブサ消防団』『罠の戦争』『トリリオンゲーム』『ブラッシュアップライフ』が記憶に残っています。 アニメは『葬送のフリーレン』を見ていました。

今年は結婚式や住環境の変化もあり慌ただしい一年でした。 来年は落ち着いて新しいことに挑戦する年にしたいです。

志木美鳥「他人の価値観なんて理解できないけど、理解したいと思える他人と出会えることはある」

いちばんすきな花 第11話

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

Mackerel REST APIのRustクライアントで取り入れた設計

この記事はMackerel Advent Calendar 2023の15日目です。 昨日はkmutoさんでした。

MackerelのREST APIクライアントをRustで書き始めたのは2017年の春のことでした。もう六年半も前のことになります。 2017年ごろの日記を見返してみるとRustにかなりハマっていた時期で、色々なツールを作っていたのを思い出しました。 mackerel-client-rsはそれ以来放置してしまっていたのですが、最近また急にRustのやる気スイッチが入ったので、色々と実装し直しています。

github.com

久しぶりにMackerelのAPI一覧のドキュメントを見ると、ダウンタイムやアラートグループ設定といった個人的に思い入れのある機能のAPIや複数のホストを一括で操作するAPIなどが追加されていて、進化を感じました。 死活監視のステータスや外形監視のリダイレクトなど、監視設定の項目が増えているのも嬉しいですね。 ダッシュボードの設定項目もだいぶ増えていて、最近力を入れているんだなというのが伝わってきます。

今年の十月にmackerel-client-rsのメンテナンスを再開したのですが、設計力が上がったのとRustの進化やライブラリの充実もあって、かなり良い設計ができているなと実感しています。 最近リライトしている中で取り入れた設計について、本記事でいくつかご紹介いたします。

  • Entity<T>型とId<T>型を導入する
  • サービス名・ロール名を文字列型と区別する
  • バリュー型にBuilderを実装する
  • 絞り込む一覧APIのパラメータの設計
  • マクロを使って実装コードを減らす
  • ローカルサーバーでテストを行う

まず紹介したいのは、Entity<T>型とId<T>型の導入です (これを実装したのは二年も前のことですが、設計に自信がなくてリリースせず放置していた)。 これまではエンティティーとバリューの型の区別がなく、idOption<String>で表現していました。 しかし、これではIDを必ず持っている作成後のエンティティーのIDを取得するのにunwrapする必要があり、安全ではありません。 また、作成時に渡す引数の構造体がすでにIDフィールドを持っているというおかしな設計になってしまいます。 また、IDがただの文字列型だと、監視設定を引くメソッドの引数にホストIDを渡すといったこともできてしまいます。

この問題を解決するために、バリューとエンティティーを別の型にして区別することにしました。 また、例えば監視のIDとホストのIDは全く別のものなので、互いに代入できてはいけません。 それぞれの型のIDを区別するために、幽霊型(phantom type)を使ってId<T>という型を作りました。

#[derive(Clone, Serialize, Deserialize)]
 pub struct Entity<T> {
    pub id: Id<T>,

    #[serde(flatten)]
    pub value: T,
}

pub struct Id<T>(str16, PhantomData<T>);

ドキュメントには記載されていないのですが、MackerelのIDは今のところせいぜい11文字なので、取り回しがしやすいよう (Copyできるよう・ヒープアロケーションが発生しないよう) に固定長文字列を使っています (もしIDが16文字を超えるようになったらどうするかはその時に考えます)。 そして、バリューの構造体を普通に作り、エンティティーとIDはバリューの型を引数とします。

pub type AlertGroupSetting = Entity<AlertGroupSettingValue>;

pub type AlertGroupSettingId = Id<AlertGroupSettingValue>;

pub struct AlertGroupSettingValue {
    pub name: String,
    pub memo: String,
    pub service_scopes: Vec<ServiceName>,
    pub role_scopes: Vec<RoleFullname>,
    pub monitor_scopes: Vec<MonitorId>,
    pub notification_interval: Option<u64>,
}

バリューとエンティティーを型で区別することで、IDがOptionになったり、作成APIのメソッドの引数の構造体がなぜかIDを持っていたりといったおかしなことが避けられるのです。

エンティティーvalueというフィールドで持つようにすると、各フィールドにアクセスするために毎回v.value.nameのように書かなくてはいけませんが、これは面倒ですね。 この問題は、Derefトレイトを実装すれば解決できます。

impl<T> std::ops::Deref for Entity<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.value
    }
}

// => v.value.name を v.name と書ける

しかし、色々と調べているとどうやらこれはアンチパターンらしいです (参考)。 Derefはスマートポインタのdereferenceにのみ使えとのことです。 便利だと思ったのですが困ります (ただ、実装はこのままにしています)。


IDと同様に、サービス名とロール名もStringとは別の型で実装しました。 サービスとロールはMackerelにおいて重要な概念で、これらの名前はURLにも含まれています。 また、Mackerelのサービスとロールをまとめたservice:roleという形式 (APIドキュメントにはどこにも書かれていませんが、APIのレスポンスでは service: role というスペースが入った形で返却されます) をロールのフル名と言い、これは監視ルールやダウンタイム、ホストの更新APIなど色々なところに出てきます。 こういったフォーマットを持った値を単純に文字列型にしてしまうと、サービス名やロール名を簡単に取り出せなくて不便です。

そこで、ロールのフル名は文字列から簡単に変換してサービス名とロール名に分離できるように実装しました。

pub struct RoleFullname {
    pub service_name: ServiceName,
    pub role_name: RoleName,
}

impl std::str::FromStr for RoleFullname {
    fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
        // 頑張って実装
    }
}

// unwrapするが便利なように…
impl From<&str> for RoleFullname { /* ... */ }

let role_fullname = RoleFullname::from("ExampleService:ExampleRole");
println!("{:?}", role_fullname);
println!(
    "service={}, role={}",
    role_fullname.service_name, role_fullname.role_name
);

// "ExampleService:ExampleRole"
// service=ExampleService, role=ExampleRole

ロールのフル名を抽象化することで、これを使っている他のところも綺麗に書けるようになります。 例えば、監視スコープはサービス名またはロールのフル名という形式なので次のように実装できます。

pub enum MonitorScope {
    Service(ServiceName),
    Role(RoleFullname),
}

こういったものをきちんと型に落とし込むことで、例えば監視設定のスコープからサービスやロールのメタデータを引くみたいなことも簡単に実装できるのです。

型といえば、すべての日時の型をchrono::DateTime<Utc>に変更しました。 MackerelのAPIは日時をUnix epochからの経過秒数で表現しています。 このAPIデザインの良し悪しはさておき、APIのレスポンスに引きずられてクライアントのモデルまでepoch秒 (unsigned long)の値だと使いにくくてつらいです。 メトリックの日時、ホストの退役日時やアラートの発報日時はもちろん、ダウンタイムやグラフアノテーションなどの日時で表現されるすべてのフィールドをDateTime型で表現することにしました。 chrono crateがchrono::serde::ts_secondsというモジュールを提供してくれており、JSONとの変換にはアトリビュートを一行書くだけでした。

#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlertValue {
    pub status: AlertStatus,
    #[serde(rename = "type")]
    pub monitor_type: MonitorType,
    // ...
    #[serde(with = "chrono::serde::ts_seconds")]
    pub opened_at: DateTime<Utc>,
    #[serde(default, with = "chrono::serde::ts_seconds_option")]
    pub closed_at: Option<DateTime<Utc>>,
}

このように簡単にJSONでの表現方法を指定できるのはserdeの素晴らしいところですね。 日時は日時型でモデリングするというのは当たり前のことなのですが、APIのクライアントだと変換の実装が面倒でレスポンスの型に寄せてしまうことがあります。 ちゃんと日時は日時型にしましょう。


様々なバリュー型にBuilderを実装してクライアントの使い勝手を向上しました。 Goと比べた時にRustの困ることとして、フィールドの多い構造体の初期化があります。 RustではDefaultトレイトを実装して、構造体の更新記法を使うのが一般的なようです。

let value = AlertGroupSettingValue {
    name: "Example alert group setting".to_string(),
    service_scopes: vec!["ExampleService".into()],
    ..AlertGroupSettingValue::Default()
};

これはよく使われる方法なのですが、必須にしたいフィールドも省略できてしまうのでイマイチだなと思っています。 色々と調べていると、型パラメータを使ったBuilderパターンの実装というのがあるのを知りました。 keens.github.io 詳細は上の記事に任せますが、初期化されていない必須フィールドがある場合に.build()を呼ぼうとしたり同じフィールドの初期化を二回やろうとしてもコンパイルできないという方法です。 そして、このBuilderパターンの実装を自動で導出してくれるcrateがtyped-builderです。

#[derive(TypedBuilder)]
#[builder(field_defaults(setter(into)))]
pub struct AlertGroupSettingValue {
    pub name: String,
    #[builder(default)]
    pub memo: String,
    #[builder( // setter(into) だけでは [&str] から変換できないので…
        default,
        setter(transform = |service_names: impl IntoIterator<Item = impl Into<ServiceName>>| service_names
            .into_iter().map(Into::into).collect::<Vec<_>>()),
    )]
    #[builder(default)]
    pub service_scopes: Vec<ServiceName>,
    #[builder(
        default,
        setter(transform = |role_fullnames: impl IntoIterator<Item = impl Into<RoleFullname>>| role_fullnames
            .into_iter().map(Into::into).collect::<Vec<_>>()),
    )]
    #[builder(default)]
    pub role_scopes: Vec<RoleFullname>,
    #[builder(
        default,
        setter(transform = |monitor_ids: impl IntoIterator<Item = impl Into<MonitorId>>| monitor_ids
            .into_iter().map(Into::into).collect::<Vec<_>>()),
    )]
    #[builder(default)]
    pub monitor_scopes: Vec<MonitorId>,
    #[builder(default, setter(strip_option))]
    pub notification_interval: Option<u64>,
}

// 色々と省略して初期化できる (がnameは必須)
let value = AlertGroupSettingValue::builder()
    .name("Example alert group setting")
    .service_scopes(["ExampleService"])
    .build();

// 全て指定するとこんな感じ
let value = AlertGroupSettingValue::builder()
    .name("Example alert group setting")
    .memo("This is an alert group setting memo.")
    .service_scopes(["ExampleService"])
    .role_scopes(["ExampleService:ExampleRole"])
    .monitor_scopes(["monitor0", "monitor1"])
    .notification_interval(60)
    .build();

build()がその型自身を返すので.unwrap()を呼ぶ必要はありません (derive_builderResultを返すため、初期化が成功しているかどうかをハンドリングしなくてはいけません。これはとても面倒です)。 また、typed-builderには.into()を自動でつける機能やデフォルト値を指定できる機能もあって、めちゃくちゃ便利です。 Builderパターンのよくないところを型検査でコンパイル時にチェックできるのは素晴らしいですね。

このtyped-builderは列挙型 (enum type) には対応していません。 しかし、特に監視ルールや通知チャンネルなど列挙型で表現しているものもBuilderを提供したいと考えています。 rust-typed-builder のコードを読んでいたら自分にも実装できそうだったので、列挙型対応のPRを作成しました。 設計へのレビュー指摘もあり難航しそうですが、なんとか修正して取り込んでもらおうと思っています。

github.com


Mackerelにはホスト一覧のAPIがあります。 このAPIはクエリパラメータで色々な条件で絞り込めるのですが、この設計にはずいぶん頭を悩まされました。 このAPIは全てのパラメータがオプショナルで、単数指定するものと複数指定可能なパラメータがあります。 さらに、serviceroleは一緒に指定するとか、statusは他のパラメータと組み合わせられるといったパラメータ間の関係があります。 このようなパラメータの制約をコードで表現するにはどうするのが良いでしょうか?

まずは、このパラメータを引数でもらうパターンを考えてみましょう。

pub async fn list_hosts(
    &self,
    service_name: Option<ServiceName>,
    role_names: Vec<RoleName>,
    host_name: Option<String>,
    statuses: Vec<HostStatus>,
) -> Result<Vec<Host>> { ... }

// Noneとは何? vec![] とは何?
// 将来的にクエリパラメータが追加されるとコードがコンパイルできなくなる…
let hosts = client.list_hosts(Some(ServiceName::from("service0")), vec![], None, vec![]).await?;
let hosts = client.list_hosts(Some(ServiceName::from("service0")), vec![RoleName::from("role0")], None, vec![]).await?;

// ロールだけ指定しても無視されるが大丈夫?
let hosts = client.list_hosts(None, vec![RoleName::from("role0")], None, vec![]).await?;

これはだいぶ厳しいですね。 特にパラメータの追加に弱いです。 次にBuilderパターンを考えてみましょう。

#[derive(TypedBuilder)]
#[builder(field_defaults(setter(into)))]
pub struct ListHostsParams {
    #[builder(default, setter(strip_option))]
    service_name: Option<ServiceName>,
    role_names: Vec<RoleName>,
    #[builder(default, setter(strip_option))]
    host_name: Option<String>,
    statuses: Vec<HostStatus>,
}

pub async fn list_hosts(
    &self,
    list_hosts_params: ListHostsParams,
) -> Result<Vec<Host>> { ... }

// Builderパターンで組み立て渡す
let hosts = client.list_hosts(ListHostsParams::builder().service_name("service0").build()).await?;
let hosts = client.list_hosts(ListHostsParams::builder().service_name("service0").role_names(["role0"]).build()).await?;
let hosts = client.list_hosts(ListHostsParams::builder().service_name("service0").statuses([HostStatus::Working]).build()).await?;

// ロールだけ指定しても無視されるが大丈夫?
let hosts = client.list_hosts(ListHostsParams::builder().role_names(["role0"]).build()).await?;

// Serviceがすでにある場合でもBuilderを使う必要があるが…
let service: Service = ...;
let hosts = client.list_hosts(ListHostsParams::builder().service_name(service.name).build()).await?;
// 簡潔にこう書けないだろうか?
// let hosts = client.list_hosts(service.name).await?;

Builderは悪くはないのですが、roleとserviceをセットで指定するというのが表現できないことと、やはり記述がやや冗長に感じます。 サービスやロールに所属するホスト一覧を引くというのはよくあるユースケースなので、簡潔に書きたいですよね。 そこで、Builderをやめつつ、構造体へのInto変換を実装することで簡単にホスト一覧を引けるようにしてみました。

impl ListHostsParams {
    // 色々なものから作れるように
    pub fn service_name(service_name: impl Into<ServiceName>) -> Self { ... }
    pub fn role_fullname(role_fullname: impl Into<RoleFullname>) -> Self { ... }
    pub fn service_role_name(service_name: impl Into<ServiceName>, role_name: impl Into<RoleName>) -> Self { ... }
    pub fn host_name(host_name: impl AsRef<str>) -> Self { ... }

    // Host statusの絞り込み指定
    pub fn status(self, status: HostStatus) -> Self { ... }
    pub fn statuses(self, statuses: impl IntoIterator<Item = HostStatus>) -> Self { ... }
}

// 便利なように使用頻度の高い絞り込みをimpl Fromで変換
impl From<ServiceName> for ListHostsParams { ... }
impl From<RoleFullname> for ListHostsParams { ... }
impl From<(ServiceName, RoleName)> for ListHostsParams { ... }
// Host statusはtupleからの変換ではなくメソッドで指定する。

pub async fn list_hosts(
    &self,
    list_hosts_params: impl Into<ListHostsParams>,
) -> Result<Vec<Host>> { ... }

// ServiceやRoleからホスト一覧を引ける
let hosts = client.list_hosts(ServiceName::from("service0")).await?;
let hosts = client.list_hosts(RoleFullname::from("service0:role0")).await?;
let hosts = client.list_hosts((ServiceName::from("service0"), RoleName::from("role0"))).await?;
let hosts = client.list_hosts(ListHostsParams::host_name("example-host")).await?;

// Host statusの絞り込み
let hosts = client.list_hosts(
    ListHostsParams::service_name("service0").status(HostStatus::Working),
).await?;
let hosts = client.list_hosts(
    ListHostsParams::service_name("service0")
        .statuses([HostStatus::Working, HostStatus::Standby, HostStatus::Maintenance]),
).await?;
let hosts = client.list_hosts(
    ListHostsParams::service_role_name("service0", "role0").status(HostStatus::Working),
).await?;

この方法は、利便性を提供しつつ拡張性・汎用性もあるなかなか良い方法だなと思っています。 リクエストパラメータに関する制約を表現できるのも良いですね。


APIクライアントを実装してみると、リクエストボディとレスポンスボディのバリエーションによって実装が冗長になることがあります。 リクエストボディのバリエーションとしては、クライアントのメソッド引数をそのままJSONにして送るパターンと、引数を元にJSONを組み立てるパターンがあります。 レスポンスボディは、APIのレスポンスをそのまま欲しい構造体にデコードするパターンと、APIクライアントの簡潔さのために特定のフィールドのみを取り出したいパターンがあります。

これらのバリエーションを吸収するために、リクエストボディとレスポンスボディ用のマクロを実装しました。 マクロを使うことで、クライアントの実装は直感的に書けるようになりました。

// メソッドの引数をそのままリクエストボディに送って、レスポンスボディをそのまま返したいケース
pub async fn create_monitor(
    &self,
    monitor_value: impl Borrow<MonitorValue>,
) -> Result<Monitor> {
    self.request(
        Method::POST,
        "/api/v0/monitors",
        query_params![],
        request_body!(monitor_value.borrow()),
        response_body!(..),
    )
    .await
}

// メソッドの引数からリクエストボディを組み立てたいケース
pub async fn update_host_status(
    &self,
    host_id: impl Into<HostId>,
    host_status: HostStatus,
) -> Result<()> {
    self.request(
        Method::POST,
        format_url!("/api/v0/hosts/{}/status", host_id),
        query_params![],
        request_body! { status: HostStatus = host_status },
        response_body!(),
    )
    .await
}

// レスポンスボディの特定のフィールドを取り出したいケース
pub async fn list_monitors(&self) -> Result<Vec<Monitor>> {
    self.request(
        Method::GET,
        "/api/v0/monitors",
        query_params![],
        request_body![],
        response_body! { monitors: Vec<Monitor> },
    )
    .await
}

なかなかfancyに書けているのではないかと思います。 レスポンスボディのマクロは次のように定義しています。 必要なフィールドを指定した場合は構造体を作って、そこから取り出しています。 複数フィールドを指定したときはtupleになります。

macro_rules! response_body {
    () => {
        |_: ::serde_json::Value| ()
    };
    (..) => {
        |response| response
    };
    { $( $field:ident: $type:ty ),+ $(,)? } => {{
        #[allow(non_snake_case)]
        #[derive(::serde_derive::Deserialize)]
        struct Response { $( $field: $type ),+ }
        |response: Response| ( $( response.$field ),+ )
    }};
}

APIクライアントのテストとしては、httptest crateを利用しています。 実は以前は個人のorgでMackerelにリクエストして動作の確認をしていたのですが、今回リライトする時にそれはあまりにも厳しいということでローカルでテストするようになりました。 テストを実装するにあたってできるだけ簡潔にかけるように、サーバーとクライアントを作るマクロを作りました。

#[async_std::test]
async fn create_host() {
    let server = test_server! {
        method = POST,
        path = "/api/v0/hosts",
        request = json!({
            "name": "example-host",
            "displayName": "Example host",
            "customIdentifier": "custom-identifier",
            "meta": { "agent-name": "mackerel-agent" },
            "memo": "This is a host memo.",
        }),
        response = json!({ "id": "host0" }),
    };
    assert_eq!(
        test_client!(server).create_host(
            HostValue::builder()
                .name("example-host")
                .display_name("Example host")
                .custom_identifier("custom-identifier")
                .meta([("agent-name".to_string(), json!("mackerel-agent"))])
                .memo("This is a host memo.")
                .build()).await,
        Ok(HostId::from("host0")),
    );
}

期待するメソッドとパス (とクエリパラメータ)、リクエストボディとレスポンスボディを指定してローカルサーバーを立てて、そこにクライアントからリクエストしてメソッドの返り値をテストしています。 本当にテストしたいことのみを記述しており、Rustに慣れていなくても読みやすいのではないかと思います。

httptestにはServer Poolingという機能があり、ポートを使いまわす機能があるため、テストが並列で動いてもポートを枯渇させるようなことはありません。 Server Poolingを使うとテストが落ちた時に別の成功するはずのテストも巻き添えになるというバグがあったのですが、報告したら次の日には直っていました。

github.com


この記事では、MackerelのRustクライアントの設計についてご紹介しました。 お楽しみいただけましたか? 私も最近のRustの機能や流行をキャッチアップしながら試行錯誤しているところです。 これからも細々とメンテナンスを続けていこうと思います。

github.com

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

qiita.com

actions/setup-javaにcache-dependency-pathオプションを実装しました

GitHub Actionsで公式のアクションを利用するとき、依存パッケージを適切にキャッシュすることが大切です。 setup-node・setup-python・setup-javaにはcacheオプションがあり、指定したパッケージマネージャーに応じてキャッシュしてくれます。 setup-goの場合はcacheオプションはブール値ですが、デフォルトが有効になっているのでこれを指定しなくてもキャッシュしてくれます。 これらのアクションは、リポジトリの依存管理ファイルの内容をキャッシュのキーとして保存します。

例えば、setup-nodeでcache: npmを指定するとリポジトリルートのpackage-lock.jsonファイルの内容を元にキャッシュのキーを計算します。 しかし、モノレポ構成のリポジトリでは依存管理ファイルが各パッケージのディレクトリに配置されているため、ルートのファイルしか見ないのは不十分です。 このようなケースで役に立つのがcache-dependency-pathオプションです。

- uses: actions/setup-node@v4
  with:
    node-version-file: .node-version
    cache: npm
    cache-dependency-path: subdir/package-lock.json

setup-go, setup-pythonにも同じオプションがあり、モノレポ構成でのキャッシュの挙動を制御できるようになっています。

さて、タイトルのsetup-javaの話です。 リポジトリルートの依存管理ファイルしか見ないsetup-nodeやsetup-goとは異なり、setup-javaリポジトリ全体から検索してキャッシュキーを計算します。 これではキャッシュが複数のプロジェクト間で共有されてしまう上に、一つのプロジェクトの依存の変更が全てのプロジェクトのキャッシュに影響してしまいます。 しかも、cache-dependency-pathオプションがsetup-javaにはありませんでした。

仕事で使う上でだいぶ困っていたのと、他の公式アクションには揃っているオプションがsetup-javaにだけない理由はないと感じたので、サクッと実装してみました。 今年の六月のことです。 github.com しばらく放置されてダメかと思っていましたが、二週間前にようやくレビューしていただき、先日ようやくマージされました。 v4としてリリースされています。

github.com

使い方は、他のactionのcache-dependency-pathと同じです。 サブプロジェクトのGradleファイルを指定するとその内容を元にキャッシュキーが計算されます。

- uses: actions/checkout@v4
- uses: actions/setup-java@v4
  with:
    distribution: corretto
    java-version: '17'
    cache: gradle
    cache-dependency-path: |
      sub-project/*.gradle*
      sub-project/**/gradle-wrapper.properties

今回、公式のactionに初めてコントリビュートできました。 TypeScriptで書かれていてコントリビュートしやすいですし、actionのE2Eのやり方なども参考になりました。 個人的にはまだ大きなactionを実装したことがないので、何か作ってみたいと思いました。