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