Mackerel DSLを作ろう

できました、こちらです(一部抜粋)。

mackerel {
    apiKey ".........."

    host {
        name "foo"

        agent "mackerel-agent/0.78.3 (Revision f41be78)" "f41be78" "0.78.3"
        memory [ "total", "33554432kB"; "something", "useful" ]
    }

    host {
        name "bar"
        memo "hoge"
    }
}

おっと、こちらは はてなエンジニア Advent Calendar 2023 の42日目です。 昨日は id:lufiabb さんによる Mackerelのラベル付きメトリック実装でentを使ってスキーマを書いてみた話 - Mackerel お知らせ #mackerelio でした。

developer.hatenastaff.com

さて先ほどのコードは、ぱっと見ではRubyに見えなくもないですが、構文的に怪しそうなところがありますね。 そんなわけでお察しの通りいつものF#です。 内部DSLなのでこのままdotnet runで実行できます。

F#ではコンピュテーション式のカスタムオペレータってやつを使うとこんなことが実現できます。 しかも静的型付けなので定義も利用も間違ってるとコンパイルエラーが出るので安心です。

以前 にも利用した FsHttp などもこれを上手く使っていて、非常に参考になります。

では実装を見ていきましょう。 こちらも全部網羅すると大変なので、というかまだそこまで作り込んではいないので、一部のみ掲載します。

まずは基本の型から。 ここは普通ですね。

type HostMetadata =
    | Agent of name: string * revision: string * version: string
    | Memory of (string * string) list

type Host =
    { Name: string
      Memo: string option
      Metadata: HostMetadata list }

type Mackerel = { ApiKey: string; Hosts: Host list }

で、コンピュテーション式のキモとなるビルダーを定義。 まだ全容を把握しているわけではないけど、特定のメソッドを実装することでコンピュテーション式の構文に変換されるようです。 ちなみにXxxProperty型はコンピュテーション式とは直接の関係は無く、実装上で便利になるので定義しています。

[<RequireQualifiedAccess>]
type HostProperty =
    | Name of name: string
    | Memo of memo: string
    | Metadata of metadata: HostMetadata

type HostBuilder() =
    member inline _.Yield(()) = ()

    member inline _.Run(props: HostProperty list) =
        props
        |> List.fold
            (fun host prop ->
                match prop with
                | HostProperty.Name name -> { host with Name = name }
                | HostProperty.Memo memo -> { host with Memo = Some(memo) }
                | HostProperty.Metadata metadata -> { host with Metadata = metadata :: host.Metadata })
            { Name = ""; Memo = None; Metadata = [] }

    [<CustomOperation("name")>]
    member inline _.Name((), name: string) = [ HostProperty.Name name ]

    [<CustomOperation("memo")>]
    member inline _.Memo(prev: HostProperty list, memo: string) = (HostProperty.Memo memo) :: prev

    [<CustomOperation("agent")>]
    member inline _.Agent(prev: HostProperty list, name: string, revision: string, version: string) = (Agent(name, revision, version) |> HostProperty.Metadata) :: prev

    [<CustomOperation("memory")>]
    member inline _.Memory(prev: HostProperty list, memory: (string * string) list) = (Memory memory |> HostProperty.Metadata) :: prev

[<RequireQualifiedAccess>]
type MackerelProperty =
    | ApiKey of string
    | Host of Host

type MackerelBuilder() =
    member inline _.Yield(()) = ()

    member inline _.Yield(host: Host) = MackerelProperty.Host host

    member inline _.Run(props: MackerelProperty list) =
        props
        |> List.fold
            (fun mackerel prop ->
                match prop with
                | MackerelProperty.ApiKey apiKey -> { mackerel with ApiKey = apiKey }
                | MackerelProperty.Host host -> { mackerel with Hosts = host :: mackerel.Hosts })
            { ApiKey = ""; Hosts = [] }

    member inline this.Run(prop: MackerelProperty) = this.Run([ prop ])

    member inline _.Delay(f: unit -> MackerelProperty list) = f ()

    member inline _.Delay(f: unit -> MackerelProperty) = [ f () ]

    member inline _.Combine(prop: MackerelProperty, props: MackerelProperty list) = prop :: props

    member inline this.For(prop: MackerelProperty, f: unit -> MackerelProperty list) = this.Combine(prop, f ())

    [<CustomOperation("apiKey")>]
    member inline _.ApiKey((), apiKey: string) = MackerelProperty.ApiKey apiKey

ここまでできたら冒頭の通りで、使うのは簡単です。

let host = HostBuilder()
let mackerel = MackerelBuilder()

mackerel {
    apiKey ".........."

    host {
        name "foo"

        agent "mackerel-agent/0.78.3 (Revision f41be78)" "f41be78" "0.78.3"
        memory [ "total", "33554432kB"; "something", "useful" ]
    }

    host {
        name "bar"
        memo "hoge"
    }
}

さてmackerel { ... }の返り値はMackerel型になります。 今回は使い道は本題ではないので、適当に考えるだけしてみましょうか。

let deploy mackerel =
    async {
        printfn "Deplying: %A" mackerel

        use client = new MackerelClient(mackerel.ApiKey)
        return! client.AsyncCreateOrUpdate(mackerel.Hosts) // 適当
    }

mackerel { ... }
|> deploy
|> Async.RunSynchronously
|> printfn "%A"

Terraformっぽいものが作れそうな気分になってきませんか? まぁ当然そういうのは既にあるわけで、AzureのIaCとしては Farmer なんかがありますね。

今回のコードも実装を進めて一通り使えるようにしたいので、気長にやっていこうと思います。

はてなエンジニア Advent Calendar 2023 、明日は id:mangano-ito さんです!