ローカルRAGってやつにMackerelを教えてやった

準備

Phi-3 のモデルを https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-onnx からダウンロードする。 今回は cpu-int4-rtn-block-32 を使用した。

同様に Embedding のモデルを https://huggingface.co/TaylorAI/bge-micro-v2 から。

これらを利用するには SemanticKernel と仲間たちを使うといいらしい。

> dotnet add package Microsoft.SemanticKernel --version 1.20.0
> dotnet add package Microsoft.SemanticKernel.Connectors.Onnx --version 1.20.0-alpha
> dotnet add package Microsoft.SemanticKernel.Plugins.Memory --version 1.20.0-alpha

Mackerel について

Mackerel のヘルプは https://github.com/mackerelio/documents にマークダウンファイルであるので、Git サブモジュールとかで適当に持ってきておく。

今回のモデルは日本語に対応してないようなので、英語のヘルプページを読み込むことにする。

実装

気ままにぴゃ~っと。

open System
open System.IO
open System.Threading.Tasks
open Microsoft.SemanticKernel
open Microsoft.SemanticKernel.ChatCompletion
open Microsoft.SemanticKernel.Connectors.OpenAI
open Microsoft.SemanticKernel.Embeddings
open Microsoft.SemanticKernel.Memory
open Microsoft.SemanticKernel.Plugins.Memory

module Task =
    let bind f t = task {
        let! t' = t
        return! f t'
    }

    let map f = bind (f >> Task.FromResult)

type MemoryInfo = { Id: string; Text: string }

module MemoryInfo =
    let create id text = { Id = id; Text = text }

type MackerelHelp = { Path: string; Content: string }

module MackerelHelp =
    let create path content = { Path = path; Content = content }

    let rec list dir = seq {
        for file in Directory.EnumerateFiles(dir, "*.md") do
            yield file

        for subdir in Directory.EnumerateDirectories dir do
            yield! list subdir
    }

    let read path = task {
        let! content = File.ReadAllTextAsync path
        return create path content
    }

    let toInfo h = MemoryInfo.create h.Path h.Content

    let load = list >> Seq.map (read >> Task.map toInfo)

module MemoryStore =
    let save (memory: SemanticTextMemory) collection info =
        memory.SaveInformationAsync(collection, info.Text, info.Id)

    let populate (memory: SemanticTextMemory) collection =
        Seq.map (Task.bind (save memory collection))

let phi3modelPath = Path.Join("path", "to", "cpu-int4-rtn-block-32")
let bgeModelPath = Path.Join("path", "to", "bge-micro-v2", "onnx", "model.onnx")
let vocabPath = Path.Join("path", "to", "bge-micro-v2", "vocab.txt")
let builder = Kernel.CreateBuilder()
builder
    .AddOnnxRuntimeGenAIChatCompletion("phi-3", phi3modelPath)
    .AddBertOnnxTextEmbeddingGeneration(bgeModelPath, vocabPath)
|> ignore

let kernel = builder.Build()
let chatCompletionService = kernel.GetRequiredService<IChatCompletionService>()
let embeddingGenerator = kernel.GetRequiredService<ITextEmbeddingGenerationService>()
let memoryStore = VolatileMemoryStore()
let memory = SemanticTextMemory(memoryStore, embeddingGenerator)
kernel.ImportPluginFromObject(TextMemoryPlugin(memory)) |> ignore

let docsPath = Path.Join("path", "to", "documents", "content", "docs")
let collection = "mackerel-help"
MackerelHelp.load docsPath
|> MemoryStore.populate memory collection
|> Task.WhenAll
|> Async.AwaitTask
|> Async.RunSynchronously
|> ignore

let executionSettings =
    OpenAIPromptExecutionSettings(ToolCallBehavior = ToolCallBehavior.EnableKernelFunctions, MaxTokens = 200)

while true do
    Console.ForegroundColor <- ConsoleColor.White
    Console.Write "\n>> "

    let question = Console.ReadLine()
    let arguments = KernelArguments executionSettings
    arguments.Add("input", question)
    arguments.Add("collection", collection)

    let response =
        kernel.InvokePromptStreamingAsync(
            """
            Question: {{$input}}
            Answer the question using the memory content: {{Recall}}
            """,
            arguments
        )

    Console.ForegroundColor <- ConsoleColor.Cyan
    Console.Write "\n> "

    task {
        let enumerator = response.GetAsyncEnumerator()
        while! enumerator.MoveNextAsync() do
            Console.Write enumerator.Current
    }
    |> Async.AwaitTask
    |> Async.RunSynchronously

    Console.WriteLine()

お試し

何はともあれ Mackerel について

>> what is mackerel

>  Mackerel is a service that uses the concept of "roles" to manage and monitor hosts. It provides a web GUI and API for visualizing the statuses of hosts and applications with a high degree of detail and customization. Mackerel-agent is a program installed on hosts to gather and send host information and metrics to Mackerel for display and management. Mackerel supports various environments, including Linux and Windows, and is well-suited for public IaaS cloud-based systems. Metric data is saved in a time series database, with 1-minute intervals and retention periods of 460 days.

エージェントを Windows にインストールするには

>> how to install agent to windows

>  To install the mackerel-agent on a Windows host, you can follow these steps:

1. Download the installer from the provided URL: https://repo-kcps.mackerel.io/file/msi/mackerel-agent-latest-kcps.msi

2. Copy the downloaded msi file to the host that you want to monitor.

3. Install the agent using the GUI method:
   - Double-click on the downloaded msi file.
   - Follow the installation dialog.
   - When prompted for an API key, enter the API key obtained from the [API tab in the Organization page of the Mackerel Management screen](https://mackerel.io/my?tab=apikeys).

Alternatively, you can install the agent using the command line method:

1. Open the administrator's command prompt.
2. Run the following command, replacing the path with the actual path to the downloaded msi file and the API key with your actual API key:

   ```

   msiexec /qn /i "path\to\mackerel-agent-latest-kcps.msi" APIKEY="&quot;&lt;YOUR API KEY&gt;&quot;"

   ```

3. After installation, the mackerel-agent will start automatically. You can check if the agent is running by accessing the Windows Service management screen. The service name should be "mackerel-agent," and its status should be "running."

To check the mackerel-agent's log, refer to the Windows Event Log. The log will provide information about the agent's behavior.

Remember to uninstall the mackerel-agent and delete the ID files if you wish to remove the agent from the host.

やるじゃん。

感想

でも遅い。CUDAを使えるモデルもあったけど、このコードではうまくいかんかったので、追々もっといろいろ試してみたい。

F# でも Opaque Type

Scala 3 でできるアレ、F# だと測定単位で数値はできるけど文字列とかはできないんだよなー。

[<Measure>]
type customerId

[<Measure>]
type orderId

[<Measure>]
type kg

type Order = {
    id: string<orderId>          // compile error
    customer: string<customerId> // compile error
    quantity: int<kg>
}

let order = {
    id = "orderId"<orderId>             // compile error
    customer = "customerId"<customerId> // compile error
    quantity = 42<kg>
}

と思ってたんだけど、FSharp.UMX というものを見つけた。

github.com

これを使うと先のコードが型定義はそのままに、% を使ってこう書けるようになる。

open FSharp.UMX

let order = {
    id = %"orderId"
    customer = %"customerId"
    quantity = %42
}

逆も % でいい感じにしてくれる。

let printOrder (order: Order) =
    printfn "orderId=%s customerId=%s quantity=%d" %order.id %order.customer %order.quantity

printOrder order

期待通りに型が違うことも保証されてうれしい。

let lookupById (orders: Order list) (id: string<orderId>) =
    orders |> List.tryFind (fun o -> o.id = id)

lookupById [] order.id
lookupById [] order.customer // compiler error (正しい!!)

なんか黒魔術的なすごいことしてんのかと思ったらそうでもなかった。 こんなんでできるのかー

https://github.com/fsprojects/FSharp.UMX/blob/4b540852bb5e654382af59d69c88eab4c2d1890a/src/FSharp.UMX.fs

GUIDや日付型・時刻型でも使えて便利~

「関数型ドメインモデリング」にテストを書こう(後編)

前回の続き。 書籍では、依存性の注入は関数の部分適用によってなされるためドメインのコア部分はテストが容易、とのことなので実際そうなのかを確かめてみる。

単純ではあるが長くなったので適当に省略しつつ placeOrder ワークフローのエントリポイントの一部を抜粋する。

open FsToolkit.ErrorHandling
open Expecto
open Expecto.Flip

module PlaceOrder =
    open OrderTaking.Common
    open OrderTaking.PlaceOrder
    open OrderTaking.PlaceOrder.Implementation

    let placeOrder =
        let checkProductExists _ = true
        let checkAddressExists address = asyncResult { return CheckedAddress address }
        let getProductPrice _ = Price.unsafeCreate 10M
        let createOrderAcknowledgmentLetter _ = HtmlString ""
        let sendOrderAcknowledgment _ = Sent

        let unvalidatedOrder = {
            UnvalidatedOrder.OrderId = "1"
            CustomerInfo = {
                FirstName = "foo"
                LastName = "bar"
                EmailAddress = "foobar@example.com"
            }
            ShippingAddress = {
                AddressLine1 = "hoge"
                AddressLine2 = ""
                AddressLine3 = ""
                AddressLine4 = ""
                City = "fuga"
                ZipCode = "12345"
            }
            BillingAddress = {
                AddressLine1 = "piyo"
                AddressLine2 = ""
                AddressLine3 = ""
                AddressLine4 = ""
                City = "poyo"
                ZipCode = "67890"
            }
            Lines = [
                {
                    OrderLineId = "1"
                    ProductCode = "W9999"
                    Quantity = 2M
                }
                {
                    OrderLineId = "2"
                    ProductCode = "G999"
                    Quantity = 3M
                }
            ]
        }

        testList "placeOrder" [
            testAsync "should return events AcknowledgmentSent, OrderPlaced and BillableOrderPlaced with valid order" {
                let! result =
                    placeOrder
                        checkProductExists
                        checkAddressExists
                        getProductPrice
                        createOrderAcknowledgmentLetter
                        sendOrderAcknowledgment
                        unvalidatedOrder

                result
                |> Expect.wantOk "should be OK"
                |> Expect.equal "should equal" [
                    AcknowledgmentSent {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        EmailAddress = EmailAddress.create "" "foobar@example.com" |> Expect.wantOk ""
                    }
                    OrderPlaced {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        CustomerInfo = {
                            Name = {
                                FirstName = String50.create "" "foo" |> Expect.wantOk ""
                                LastName = String50.create "" "bar" |> Expect.wantOk ""
                            }
                            EmailAddress = EmailAddress.create "" "foobar@example.com" |> Expect.wantOk ""
                        }
                        ShippingAddress = {
                            AddressLine1 = String50.create "" "hoge" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "fuga" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "12345" |> Expect.wantOk ""
                        }
                        BillingAddress = {
                            AddressLine1 = String50.create "" "piyo" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "poyo" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "67890" |> Expect.wantOk ""
                        }
                        AmountToBill = BillingAmount.create 50M |> Expect.wantOk ""
                        Lines = [
                            {
                                OrderLineId = OrderLineId.create "" "1" |> Expect.wantOk ""
                                ProductCode = WidgetCode.create "" "W9999" |> Expect.wantOk "" |> Widget
                                Quantity = UnitQuantity.create "" 2 |> Expect.wantOk "" |> Unit
                                LinePrice = Price.unsafeCreate 20M
                            }
                            {
                                OrderLineId = OrderLineId.create "" "2" |> Expect.wantOk ""
                                ProductCode = GizmoCode.create "" "G999" |> Expect.wantOk "" |> Gizmo
                                Quantity = KilogramQuantity.create "" 3M |> Expect.wantOk "" |> Kilogram
                                LinePrice = Price.unsafeCreate 30M
                            }
                        ]
                    }
                    BillableOrderPlaced {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        BillingAddress = {
                            AddressLine1 = String50.create "" "piyo" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "poyo" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "67890" |> Expect.wantOk ""
                        }
                        AmountToBill = BillingAmount.create 50M |> Expect.wantOk ""
                    }
                ]
            }

            testAsync "should return ValidationError when product not found" {
                let checkProductExists _ = false

                let! result =
                    placeOrder
                        checkProductExists
                        checkAddressExists
                        getProductPrice
                        createOrderAcknowledgmentLetter
                        sendOrderAcknowledgment
                        unvalidatedOrder

                result
                |> Expect.wantError "should be Error"
                |> Expect.equal "should equal" (Validation(ValidationError "Invalid: Widget WidgetCode \"W9999\""))
            }
        ]

    let tests = testList "PlaceOrder" [ placeOrder ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args (testList "OrderTaking" [ PlaceOrder.tests ])

まぁ普通にそうなるよねって感じだった。

Expecto 固有の話だが、Expect.wantOk がそこそこ煩く感じる。 とはいえドメイン型の正当性を保証するためなら仕方ないか。 テストでなら Price.unsafeCreate のような裏道を意識して使うのは全然アリだろう。

「関数型ドメインモデリング」にテストを書こう(前編)

熱が冷めないうちにいろいろやっとこうの構え。 本書でもテスト容易性についての記述はあり、サンプルテストはありますが、実はリポジトリにはテストがありません。

https://github.com/swlaschin/DomainModelingMadeFunctional

「テスト書いてないとか(ry」という天の声が聞こえる気がするので書いていきましょう。 今回は導入編ということで、シンプルな汎用型のとこだけ。

今回は書籍でも名前だけ紹介されていた Expecto を使います。

open Expecto

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      let subject = String50.create "name" "foo"
                      let value = Expect.wantOk subject "should be Ok" |> String50.value
                      Expect.equal value "should equal" "foo"
                  } ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args (testList "OrderTaking" [ Common.tests ])

まぁ普通ですね。でもなんかこう F# らしさが足りない? そんなときは Expecto.Flip を使うといかにもそれっぽい。

open Expecto
open Expecto.Flip

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      String50.create "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  } ]

じゃあ続きを書いていこうか。

open FsToolkit.ErrorHandling
open Expecto
open Expecto.Flip

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      String50.create "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  }

                  test "should return message when string is empty" {
                      String50.create "name" ""
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be null or empty"
                  }

                  test "should return message when string is longer than 50 characters" {
                      String50.create "name" (String.replicate 51 "x")
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be more than 50 chars"
                  } ]

        let createOption =
            testList
                "createOption"
                [ test "should return value when string is longer than 50 characters" {
                      String50.createOption "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> Expect.wantSome "should be Some"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  }

                  test "should return None when string is empty" {
                      String50.createOption "name" ""
                      |> Expect.wantOk "should be Ok"
                      |> Expect.isNone "should be None"
                  }

                  test "should return message when string is longer than 50 characters" {
                      String50.createOption "name" (String.replicate 51 "x")
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be more than 50 chars"
                  } ]

        let tests = testList "String50" [ create; createOption ]

    module BillingAmount =
        let create =
            testList
                "create"
                [ test "should return value when amount is between 0 and 10000" {
                      BillingAmount.create 10000M
                      |> Expect.wantOk "should be Ok"
                      |> BillingAmount.value
                      |> Expect.equal "should equal" 10000M
                  }

                  test "should return message when amount isn't between 0 and 10000" {
                      BillingAmount.create -1M
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "BillingAmount: Must not be less than 0.0"
                  } ]

        let sumPrices =
            testList
                "sumPrices"
                [ test "should return total price when sum is betweeen 0 and 10000" {
                      let prices =
                          [ 100M; 200M; 300M ]
                          |> List.traverseResultM Price.create
                          |> Expect.wantOk "should be Ok"

                      BillingAmount.sumPrices prices
                      |> Expect.wantOk "should be Ok"
                      |> BillingAmount.value
                      |> Expect.equal "should equal" 600M
                  }

                  test "should return message when sum isn't betweeen 0 and 10000" {
                      let prices =
                          List.replicate 10001 (Price.create 1M)
                          |> List.sequenceResultM
                          |> Expect.wantOk "should be Ok"

                      BillingAmount.sumPrices prices
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "BillingAmount: Must not be greater than 10000"
                  } ]

        let tests = testList "BillingAmount" [ create; sumPrices ]

    let tests = testList "Common" [ String50.tests; BillingAmount.tests ]

module PlaceOrder =
    open OrderTaking.PlaceOrder

    let tests =
        testList
            "PlaceOrder"
            [
            // 後で
            ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args (testList "OrderTaking" [ Common.tests; PlaceOrder.tests ])

モジュールや testList の構成がまだ手探りだけど、概ねいい感じ。 関数ごとに分割しなくても、全部まとめてネストさせることはできるけど、階層が深くなりすぎて見た目がアレになったので分けてみた。

Common 名前空間については後はだいたい同じになるだろうから省略。

しれっと FsToolKit.ErrorHandling なんてのも使っている。 今回は List.traverseResultMList.sequenceResultM を使って、list |> List.map Price.createResult<Price, string> list になって不便なところを、 list |> List.traverseResultM Price.create とすることによって Result<Price list, string> になって便利とだけ雰囲気を感じてもらえればよい。 書籍の方でも sequncetraverse について解説されてたので見てくれ。

次はドメインのコア部分について本当にテストが容易なのかを確かめてみよう。

ついに来た!「関数型ドメインモデリング」

はじめに

ご縁があって翻訳者の猪股さんよりご恵贈いただきました。 この場をお借りして御礼申し上げます。

本書は F# 界隈では F# for fun and profit でお馴染みの Scott Wlaschin 氏による「Domain Modeling Made Functional」の翻訳です。

僕はもともと F# を書く人なので、数年前にそちら由来で原著を読んでおりました。 とても学びを得ることができていい本だけど翻訳されるのは難しいだろうなと思っていたので、この度の本書の出版はまさに偉業!

本書について

ドメイン駆動設計や関数型プログラミングに、なんか難しそうというイメージを持っている方は少なくないと思います。 自分もその一人で、エリック・エヴァンスのドメイン駆動設計は何回読んでも理解しきれないし、モナドとかファンクタとかよくわからんまま過ごしています。

「本書を読むためには、ドメイン駆動設計や関数型プログラミングの予備知識は必要ありません。本書は入門書であり、すべての重要な概念は必要に応じて説明されます。」という想定読者の記述の通り、 ある程度のソフトウェア開発経験があれば、読み進められるでしょう。

ドメイン駆動設計と関数型プログラミングについては教科書的な内容でなく、重要な部分を中心に実践的な範囲にフォーカスしているので、この分野にありがちな概念の渦に圧倒されるみたいなことはありませんでした。 ページ数がエヴァンス本の半分くらいなのでそれはそうですよね。

感想とか

個人的に印象に残った点をいくつか紹介したいと思います。 書ききれない、というか書きすぎてもアレなのでほどほどに。

衝動との闘い

データベース駆動設計をしたい衝動、クラス駆動設計をしたい衝動。わかる。

しかしそれらを堪えてドメインに向き合って理解することで、実装の制約や先入観にとらわれずにモデリングで表現していくことができるとのこと。肝に銘じたい。

本書は3部構成のうち、第1部が丸々ドメインの分析とそれをどう関数的に捉えるかに充てられて、実際のコードが出てきません。 コードを書く前に、設計を始める前に、ドメインに関する理解を深めることの重要さが伝わりました。

鉄道指向プログラミング

といわれてもなんじゃそれって感じでスッと流れていっちゃうのですが、原著では「Railway Oriented Programming」とのことで、略して「ROP」として言及されている記事をたまに見かけます。

関数合成によってワークフローをパイプライン化することで、成功フローと失敗フローを意識しすぎることなく一本化できるみたいな、ある種のフレームワーク的な考えと捉えました。

そのままでは合成できない関数を、Result.mapResult.bindをアダプターとして用いることで繋げられるのを学び、実はモナドだったんだよ!あーそういうことだったのねと理解できました。

本書では紙面の都合かモナディックなアプローチのみが紹介されていて、バリデーションなどはアプリカティブなアプローチを使うといいよぐらいしか書かれておらず、そっちのが気になるんじゃーという気持ちになるのですが、原著を読んだ後に気になって学んでいたので、忘れてなければ後日ご紹介したいと思います。

ちなみに著者自身による数年越しのアンサーソングが出ています。

Against Railway-Oriented Programming | F# for fun and profit

超要約すると「何でもかんでも Result 使うべきではない」なんですが、本書を読んで実践して、違和感を覚えたころに読むとなるほど感が高そうです。 興味が湧いた人は是非読んでほしい。

不正な状態を私たちのドメインで表現できないようにすること

こちらも原著では「Making Illegal States Unrepresentable」とされ、参照されている記事を見かけることがあります。

ビジネス上で起こりえないならそんな型は存在しないとも言い換えることができ、そのような状態をコンパイルすらできない実装として強制的に排除する考えです。 ドメイン知識がコードと完全に対応して、狭義のモデルのみならずワークフローまで含んだ広義のモデルが、型としてドキュメントにまで昇華するという本書のキモを表現する一文ではないでしょうか。

リポジトリパターン

そんなものはない。とまでは言ってないけど、本書では部分適用や永続化を端に追いやることで、そもそも必要なくなるというスタンスでした。 関数型プログラミングを活用することで、オブジェクト指向において有効だった概念を、よりシンプルな形で実現できるのはよいですね。

リポジトリパターンをやるとしたら、F# 8の新機能でいい塩梅にできるんじゃないかという案があるので後で実験しよう。

おわりに

原著を読んでからコードの書き方が変わった感はありましたが、本書を読んだことで、微妙に理解やニュアンスが曖昧だった部分が確信できた気がします。 ドメイン駆動設計と関数型プログラミングの組み合わせは、これまでオブジェクト指向で考えていた時よりもずっと腑に落ちました。 これまでとは違った観点からドメインモデリングを学んでみたい方には、お勧めの一冊として推したいです。

ちなみに原著がちょっと昔なので F# のバージョンが 4.1 と古く、現在の 8 ではよりよい書き方ができそうな部分もありますが、そこは本筋ではないのでさしたる問題ではないでしょう。 そもそも F# を学ぶ本ではないので、本書で興味を持った方がこっちに来て盛り上がってくれ~って感じです。

ところで僕の所属するはてなでは、5月からエンジニア有志による原著の輪読会が開催されているのですが、第1回をした週に翻訳が発表されて何とも言えない空気になったのが印象深いです。 もうすぐ本書3部からの F# のコードを、Go、TypeScript、Scalaで書き換えてみようという回に入っていくので、その試みもどこかで紹介できたらいいですね。

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 さんです!