ラベル Go の投稿を表示しています。 すべての投稿を表示
ラベル Go の投稿を表示しています。 すべての投稿を表示

2025年12月13日土曜日

Yet another なガントチャート生成ツールを作った

CSV からガントチャートを生成する CLI ツール「ganttgen」を作りました。 この記事では、細かい仕様説明は省いて、実際に動かすところまでを紹介します。

始め方

実行バイナリのダウンロード

以下、 ganttgen の最新リリースページから、ご利用の OS に合わせた実行バイナリをダウンロードしてください。

https://github.com/mikoto2000/gantt-generator/releases/latest

CSV ファイルの準備

以下のような CSV ファイルを用意してください。ファイル名は sample.csv とします。

タスク名,開始,終了,期間,依存,実績開始,実績終了,実績期間,備考
#要件定義,,,,,,,,
タスク1,2025/12/1,,2d,,2025/12/11,2025/12/15,,"12/22 mikoto2000
まいった、終わらん
12/2 mikoto2000
病欠のため着手不能"
#設計,,,,,,,,要件定義は終わっていないが、決まったところから設計していく
タスク2,,,3d,タスク1,,,,"12/22 mikoto2000
テスト
12/2 mikoto2000
テテスト"
#製造,,,,,,,,着手未定
タスク3,,,4d,タスク2,,,,
#テスト,,,,,,,,着手未定
タスク4,,,5d,タスク3,,,,
タスク5,,,,,,,,着手未定

Excel 等の表計算ソフトで編集するのを推奨します。

See: https://github.com/mikoto2000/gantt-generator/blob/main/sample/sample.csv

祝日定義ファイルの準備

以下のような YAML ファイルを用意してください。ファイル名は sample_holiday.yaml とします。

# YAML で祝日を指定するサンプル。
# YYYY-MM-DD の文字列を配列で記述してください。
holidays:
  - 2024-06-10
  - 2024-09-16
  - 2024-09-23

See: https://github.com/mikoto2000/gantt-generator/blob/main/sample/sample_holiday.yaml

ガントチャートの生成・監視・再描画

前述の工程で用意した CSV ファイルと祝日定義ファイルを指定して、 ganttgen コマンドを実行してください。

ganttgen --livereload --holidays ./sample_holiday.yaml ./sample.csv -o sample.html

生成されたガントチャートは sample.html という名前で出力されます。ブラウザで開いて確認してください。

--livereload オプションを指定すると、CSV ファイルや祝日定義ファイルに変更があった場合に自動的にガントチャートが再生成されます。ブラウザのリロードは不要です。

参考資料

2025年4月6日日曜日

Go 言語で MCP サーバーを作り、 Claude Desktop で利用する

前提

  • OS: Windows 11 Pro 24H2
  • Claude Desktop インストール済み
  • Go 言語の開発環境構築済み

MCP サーバーの実装

mark3labs/mcp-go を使用して、 Hello, World! を返却するツールを実装した。

プロジェクト初期化とライブラリのインストール

以下コマンドで環境構築完了。

go mod init mikoto2000.dev/study/mcp
go get github.com/mark3labs/mcp-go
go get github.com/mark3labs/mcp-go/server
go get github.com/mark3labs/mcp-go/mcp

ソースコード

main.go:

package main

import (
    "context"
    "fmt"

    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)

func main() {
    // サーバーの生成
    s := server.NewMCPServer("hello-mcp", "1.0.0")

    // ツールの仕様生成
    tool := mcp.NewTool("hello_world",
        mcp.WithDescription("Say `Hello, World!`"),
    )

    // サーバーにツールを追加
    s.AddTool(tool, helloHandler)

    // サーバーの開始
    if err := server.ServeStdio(s); err != nil {
        fmt.Printf("Server error: %v\n", err)
    }
}

// `Hello, World!` を返却するツールのリクエスト受信ハンドラ
func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    return mcp.NewToolResultText("Hello, World!"), nil
}

ビルド

go build

Claude Desktop の設定

サーバーの配置

先ほどビルドした mcp.exe を、任意の場所に配置する。

今回は C:\Users\mikoto\tmp\mcp.exe に配置した。

MCP サーバーの設定

  1. Claude Desktop を開く

  2. 左上のハンバーガーアイコンをクリック -> ファイル -> 設定 -> 開発者 -> 構成を編集

  3. エクスプローラーが開くので、その中にある claude_desktop_config.json を編集

    {
      "mcpServers": {
        "hello-mcp": {
          "command": "C:\\Users\\mikoto\\tmp\\mcp.exe",
          "args": []
        }
      }
    }
  4. Claude Desktop を再起動する

呼び出してみる

新しいチャットを開始し、

「hello-mcp を呼び出して結果を教えて」と入力する。

hello-world(ローカル)からのツールを許可しますか?と聞かれるので このチャットで許可する を選択。

そうすると、しばらくぐるぐるした後以下のような返答が返ってきた。 OK.

関数の実行結果は「Hello, World!」でした。ご要望通りに関数を実行し、その結果をお知らせしました。何か他にお手伝いできることがありましたら、お気軽にお尋ねください。

参考資料

2024年6月10日月曜日

Go 言語の text/template を使ってみる

開発環境起動

docker run -it --rm -v "$(pwd):/work" --workdir /work golang

プロジェクト作成

go mod init github.com/mikoto2000/TIL/golang/template/text/template/firststep

実装

ステップとしては、

  1. テンプレート記法で書いたテキストを準備
  2. Go プログラム上でテンプレートを定義し、「1.」のテキストを読み込む
  3. 「2.」のテンプレートに、構造体か map で置換したい情報を流し込む

という感じ。

package main

import (
    _ "embed"
    "fmt"
    "strings"
    "text/template"
)

//go:embed mail.template.txt
var mailTemplate string

type MailInfo struct {
    RecipientName        string
    RecipientCompanyName string
    CompanyName          string
    SenderName           string
    YourPhoneNumber      string
    DiscussionTopic      string
    ProposedDates        string
    YourEmail            string
}

func main() {
    // テンプレート準備
    tmpl, err := template.New("MailTemplate").Parse(mailTemplate)
    if err != nil {
        panic(err)
    }

    fmt.Println("構造体で情報を流し込む")
    構造体で情報を流し込む(tmpl)
    fmt.Println()

    fmt.Print("==============================\n\n")

    fmt.Println("mapで情報を流し込む")
    mapで情報を流し込む(tmpl)
}

func 構造体で情報を流し込む(tmpl *template.Template) {
    // テンプレートに流し込む情報を組み立て
    proposedDates := `1) 2024/6/10 12:00-13:00
2) 2024/6/17 12:00-13:00
3) 2024/6/24 12:00-13:00`

    mailInfo := MailInfo{
        RecipientName:        "山田 太郎",
        RecipientCompanyName: "三光飼料 株式会社",
        CompanyName:          "有限会社 リファレンス",
        SenderName:           "大雪 命",
        YourPhoneNumber:      "000-0000-0000",
        DiscussionTopic:      "あのあれのあれ",
        ProposedDates:        proposedDates,
        YourEmail:            "mikoto2000@example.com",
    }

    // テンプレートに情報を流し込む
    var mailContent strings.Builder
    err := tmpl.Execute(&mailContent, mailInfo)
    if err != nil {
        panic(err)
    }

    // 完成したテキストを表示
    fmt.Print(mailContent.String())
}

func mapで情報を流し込む(tmpl *template.Template) {
    // テンプレートに流し込む情報を組み立て
    proposedDates := `Ⅰ) 1024/6/10 12:00-13:00
Ⅱ) 1024/6/17 12:00-13:00
Ⅲ) 1024/6/24 12:00-13:00`

    mailInfo := map[string]string{
        "RecipientName":        "宮本 むさし",
        "RecipientCompanyName": "てすと 株式会社",
        "CompanyName":          "ますと 株式会社",
        "SenderName":           "大雪 命",
        "YourPhoneNumber":      "000-0000-0000",
        "DiscussionTopic":      "そちらのそれのこれ",
        "ProposedDates":        proposedDates,
        "YourEmail":            "mikoto2000@example.com",
    }

    // テンプレートに情報を流し込む
    var mailContent strings.Builder
    err := tmpl.Execute(&mailContent, mailInfo)
    if err != nil {
        panic(err)
    }

    // 完成したテキストを表示
    fmt.Print(mailContent.String())
}

動作確認

実行するだけ。

root@8e6914348863:/work# ./firststep 
構造体で情報を流し込む
件名: 面談のお願い

山田 太郎様

はじめまして。有限会社 リファレンスの大雪 命と申します。

突然のご連絡失礼いたします。貴社のご活躍にいつも感銘を受けております。
この度、ぜひ三光飼料 株式会社の山田 太郎様とお話をさせていただきたく、面談の機会をお願い申し上げます。

具体的には、以下の日程でご都合の良いお時間をお知らせいただけますでしょうか。

1) 2024/6/10 12:00-13:00
2) 2024/6/17 12:00-13:00
3) 2024/6/24 12:00-13:00

面談の内容としましては、あのあれのあれについてお伺いできればと考えております。
ご多忙のところ恐縮ですが、少しのお時間をいただけますと幸いです。

お手数をおかけいたしますが、どうぞよろしくお願い申し上げます。

敬具

大雪
有限会社 リファレンス
mikoto2000@example.com
000-0000-0000

==============================

mapで情報を流し込む
件名: 面談のお願い

宮本 むさし様

はじめまして。ますと 株式会社の大雪 命と申します。

突然のご連絡失礼いたします。貴社のご活躍にいつも感銘を受けております。
この度、ぜひてすと 株式会社の宮本 むさし様とお話をさせていただきたく、面談の機会をお願い申し上げます。

具体的には、以下の日程でご都合の良いお時間をお知らせいただけますでしょうか。

) 1024/6/10 12:00-13:00
) 1024/6/17 12:00-13:00
) 1024/6/24 12:00-13:00

面談の内容としましては、そちらのそれのこれについてお伺いできればと考えております。
ご多忙のところ恐縮ですが、少しのお時間をいただけますと幸いです。

お手数をおかけいたしますが、どうぞよろしくお願い申し上げます。

敬具

大雪
ますと 株式会社
mikoto2000@example.com
000-0000-0000

正しく流し込めていそう。

以上。

Go 言語で利用可能な空きポートを自動で探してもらう

TL;DR

ポートを 0 にすると、空いているポートを適当に選んで開いてくれるよ。

開発環境起動

devcontainer.vim run -v "$(pwd):/work" --workdir /work golang

プロジェクト作成

go mod init github.com/mikoto2000/TIL/golang/net/tcp/findavailableport

実装

TL;DR の通り。ポートが 0 なことを除けば TCP の入門コード。

package main

import (
    "fmt"
    "net"
)

func main() {
    // Listen 開始
    // port を 0 としているので、適当な空きポートを使って Listener を作成してくれる
    listener, err := net.Listen("tcp", "127.0.0.1:0")
    if err != nil {
        panic(err)
    }

    // 待ち受けアドレス・ポート表示
    fmt.Printf("Listen address %s.\n", listener.Addr())

    // 待ち受け開始
    for {
        conn, err := listener.Accept()
        if err != nil {
            panic(err)
        }

        // 接続時処理を開始
        go echo(conn)
    }
}

const RECEIVE_BUFFER_SIZE = 1024

// 接続元から受け取ったメッセージを表示し、
// 接続元に受け取ったメッセージをそのまま返却する。
// サーバー側切断時には、「Server down, bye!」のメッセージを接続元へ送る。
func echo(conn net.Conn) {
    defer func() {
        conn.Close()
    }()

    // データ受信ループ
    buf := make([]byte, RECEIVE_BUFFER_SIZE)
    for {
        // データ受信
        // 受信データが buf に入り、
        // 受信したサイズが readSize に入る。
        readSize, err := conn.Read(buf)
        if readSize == 0 {
            break
        }
        if err != nil {
            panic(err)
        }

        // 受信データを表示
        // バッファすべてを表示すると1024バイト全部表示してしまうので、
        // readSize までを切り取る
        fmt.Print(string(buf[:readSize]))

        // 受信データを返却
        // バッファすべてを表示すると1024バイト全部送信してしまうので、
        // readSize までを切り取る
        conn.Write(buf[:readSize])
    }
}

動作確認

TCP リクエストを送信できるツール socat で動作確認を行う。

socat インストール

apt update
apt install -y socat

TCP 接続

まずは接続先(サーバー)を起動。

root@9ce488c3fe16:/work# ./findavailableport
Listen address 127.0.0.1:44367.

次に、 socat で TCP 接続し、メッセージを送信する。

socat stdin tcp-connect:<サーバー起動時に表示されたアドレス>

socat は、改行ごとにデータを送信するので、 そのタイミングでサーバ側のメッセージ表示がされるのと、 サーバからのエコーバックが返ってくるのがわかる。

サーバ側出力:

root@9ce488c3fe16:/work# ./findavailableport 
Listen address 127.0.0.1:44367.
abc
def
ghijklmnopq
^C

クライアント側出力:

root@9ce488c3fe16:/work# socat stdin tcp-connect:127.0.0.1:44367
abc
abc
def
def
ghijklmnopq
ghijklmnopq

以上。

参考資料

2024年5月30日木曜日

Go 言語でプロセスの存在確認を行う(gopsutil 編)

shirou/gopsutil: psutil for golang を使用して、プロセスの存在確認を行う。

プロジェクト作成

go mod init で作成。

go mod init github.com/mikoto2000/TIL/golang/process/isExists/gopsutil

ライブラリ追加

go get で追加。

go get github.com/shirou/gopsutil/v3/process

実装

gopsutil には func PidExists(pid int32) (bool, error) が存在するので、それを利用する。

main.go

package main

import (
    "github.com/shirou/gopsutil/v3/process"
    "log"
    "os"
    "strconv"
)

/**
 * コマンドライン引数のひとつ目に PID をうけとり、
 * その PID のプロセスが実行中かを表示する。
 */
func main() {
    pidString := os.Args[1]
    log.Printf("PID String: %s", pidString)

    pid, err := strconv.Atoi(pidString)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("PID Decimal: %d", pid)

    // gopsutil を使って PID のプロセスの実行確認
    isRunning, err := process.PidExists(int32(pid))
    if err != nil {
        log.Fatal(err)
    }

    // 表示
    if isRunning {
        log.Printf("PID %d は実行中です。\n", pid)
    } else {
        log.Printf("PID %d は実行していません。\n", pid)
    }
}

動作確認

Windows

試しに gvim を起動して、それが実行中かを確認するとの、適当な PID を入れて実行されていないのを確認する。

> Get-Process -Name gvim

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
     16    20.49      39.34       4.72   30084   1 gvim

> .\gopsutil.exe 30084
2024/05/30 20:11:09 PID String: 30084
2024/05/30 20:11:09 PID Decimal: 30084
2024/05/30 20:11:09 PID 30084 は実行中です。
> .\gopsutil.exe 99999999
2024/05/30 20:11:18 PID String: 99999999
2024/05/30 20:11:18 PID Decimal: 99999999
2024/05/30 20:11:18 PID 99999999 は実行していません。

OK そう。

Linux

試しに自分の bash が実行中かを確認するとの、適当な PID を入れて実行されていないのを確認する。

$ ps
    PID TTY          TIME CMD
    234 pts/1    00:00:00 bash
  31817 pts/1    00:00:00 ps
$ ./gopsutil 234
2024/05/30 11:12:30 PID String: 234
2024/05/30 11:12:30 PID Decimal: 234
2024/05/30 11:12:30 PID 234 は実行中です。
$ ./gopsutil 9999999
2024/05/30 11:12:34 PID String: 9999999
2024/05/30 11:12:34 PID Decimal: 9999999
2024/05/30 11:12:34 PID 9999999 は実行していません。

こちらも OK そう。

以上。

前回(syscall を使う)よりも大分楽ができましたね!

参考資料

Go 言語でプロセスの存在確認を行う(syscall 編)

Windows と Linux それぞれで、どうやってプロセスの存在確認を行うかというのをやっていく。

今回使用するのは syscall パッケージ。

プロジェクト作成

go mod init で作成。

go mod init github.com/mikoto2000/TIL/golang/process/isExists/syscall

実装

サードパーティーのライブラリは使っていないので、そのまま実装に進む。

Go 言語はビルドタグを使うことで、環境変数応じてビルドするかしないかを、ファイルごとに定義することができる。(See: Goのビルドタグの書き方が// +buildから//go:buildに変わった理由)

この仕組みを利用して、 Windows 版の処理と Linux 版の処理を書き分けていく。

説明を記載する体力がなくなったので、各 API の詳細は参考資料のリンク先を参照してくださいで済ませます。

Windows 版の処理(process_windows.go)

//go:build windows

package main

import (
    "os"
    "syscall"
)

func IsRunningProcess(process *os.Process) (bool, error) {
    const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000

    handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(process.Pid))
    if err != nil {
        // そもそもチェック処理で失敗
        return false, err
    }

    defer syscall.CloseHandle(handle)

    var exitCode uint32
    err = syscall.GetExitCodeProcess(handle, &exitCode)
    if err != nil {
        // そもそもチェック処理で失敗
        return false, err
    }

    if exitCode == 259 { // STILL_ACTIVE
        // プロセスが実行中
        return true, nil
    } else {
        // プロセスが実行中でない
        return false, nil
    }
}

Linux 版の処理(process_nowindows.go)

//go:build !windows

package main

import (
    "os"
    "syscall"
)

func IsRunningProcess(process *os.Process) (bool, error) {
    err := process.Signal(syscall.Signal(0))
    if err != nil {
        if err == os.ErrProcessDone {
        // プロセスが存在していない
            return false, nil
        } else {
        // そもそもチェック処理で失敗
            return false, err
        }
    } else {
        // プロセスが既に存在している
        return true, nil
    }
}

メイン関数(main.go)

package main

import (
    "log"
    "os"
    "strconv"
)

/**
 * コマンドライン引数のひとつ目に PID をうけとり、
 * その PID のプロセスが実行中かを表示する。
 */
func main() {
    pidString := os.Args[1]
    log.Printf("PID String: %s", pidString)

    pid, err := strconv.Atoi(pidString)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("PID Decimal: %d", pid)

    // プロセスを取得
    process, err := os.FindProcess(pid)
    if err != nil {
        // そもそも PID が割り当てられたことが無い(Windows)
        // Linux では必ず成功するので後段のチェックが必須
        log.Printf("PID %d は実行していません。\n", pid)
        os.Exit(0)
    }
    log.Printf("PID from Process type: %d", process.Pid)

    // プロセスを渡してプロセスが実行中かを確認
    // (ビルドフラグによって windows とそれ以外で処理を分けている)
    isRunning, err := IsRunningProcess(process)
    if err != nil {
        log.Fatal(err)
    }

    // 表示
    if isRunning {
        log.Printf("PID %d は実行中です。\n", pid)
    } else {
        log.Printf("PID %d は実行していません。\n", pid)
    }
}

動作確認

Windows

試しに gvim を起動して、それが実行中かを確認するとの、適当な PID を入れて実行されていないのを確認する。

> $(Get-Process -Name gvim).Id
30084
> .\syscall.exe 30084
2024/05/30 17:49:29 PID String: 30084
2024/05/30 17:49:29 PID Decimal: 30084
2024/05/30 17:49:29 PID from Process type: 30084
2024/05/30 17:49:29 PID 30084 は実行中です。
> .\syscall.exe 99999999
2024/05/30 17:49:34 PID String: 99999999
2024/05/30 17:49:34 PID Decimal: 99999999
2024/05/30 17:49:34 PID 99999999 は実行していません。

OK そう。

Linux

試しに自分の bash が実行中かを確認するとの、適当な PID を入れて実行されていないのを確認する。

$ ps
    PID TTY          TIME CMD
 208144 pts/12   00:00:00 bash
 221983 pts/12   00:00:00 ps
$ ./syscall 208144
2024/05/30 17:53:14 PID String: 208144
2024/05/30 17:53:14 PID Decimal: 208144
2024/05/30 17:53:14 PID from Process type: 208144
2024/05/30 17:53:14 PID 208144 は実行中です。
$ ./syscall 999999
2024/05/30 17:53:20 PID String: 999999
2024/05/30 17:53:20 PID Decimal: 999999
2024/05/30 17:53:20 PID from Process type: 999999
2024/05/30 17:53:20 PID 999999 は実行していません。

こちらも OK そう。

以上。

補足

ツイッターにて以下コメントをもらったので、この後そちらも試す予定。

参考資料

2024年5月24日金曜日

go-github で GitHub の最新リリースタグを取得する

よくあるやつ。

Google が Go 用の GitHub クライアントを公開しているのでそれを利用する

プロジェクト作成

go mod init で作成。

go mod init github.com/mikoto2000/TIL/golang/github/go-github/firststep

ライブラリ追加

go get で追加。

go get github.com/google/go-github/v62

実装

コマンドライン引数にユーザー名とリポジトリを受け取り、そのリポジトリの latest タグを返却する。

■ main.go

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/google/go-github/v62/github"
)

/**
 * コマンドライン引数のひとつ目にユーザー名、ふたつ目にリポジトリ名を渡す。
 *
 * latest タグの取得に成功した場合、タグ名を標準出力へ出力する。
 * latest タグの取得に失敗した場合、エラーを出力し、終了コード 1 で終了する。
 */
func main() {
    ctx := context.Background()
    client := github.NewClient(nil)

    owner := os.Args[1]
    repo := os.Args[2]

    release, _, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
    if err != nil {
        log.Fatalf("Error getting latest release: %v", err)
    }

    fmt.Printf("%s\n", release.GetTagName())
}

動作確認

ぱっと見取得できているように見える。

$ ./firststep mikoto2000 devcontainer.vim
v0.6.1
$ ./firststep mikoto2000 devcontainer.vima
2024/05/24 03:13:10 Error getting latest release: GET https://api.github.com/repos/mikoto2000/devcontainer.vima/releases/latest: 404 Not Found []

以上。

参考資料

2024年4月7日日曜日

Go で JSON をマージする(darccio/mergo 編)

mikoto2000/devcontainer.vim: コンテナ上で Vim を使った開発をするためのツール。 VSCode Dev Container の Vim 版を目指しています。 の中で、 JSON をマージしたいという要求が出てきたので darccio/mergo: Mergo: merging Go structs and maps since 2013 を試す。

正確に言うと、「構造体・マップをマージするライブラリ」だが、 Marshal 後の JSON でも使えるので試す。

開発環境起動

docker run -it --rm -v "$(pwd):/work" --workdir /work -v "$HOME/.vim:/root/.vim" golang:1.22.1-bookworm

環境構築

go mod init github.com/mikoto2000/golang/json/mergo/firststep
go get dario.cat/mergo

JSON ファイル作成

json/base.json:

{
  "name":"Go",
  "image":"mcr.microsoft.com/devcontainers/go:1-1.22-bookworm",
  "mounts": [
    {
      "type": "bind",
      "source": "${localEnv:HOME}/.gitconfig",
      "target": "/home/vscode/.gitconfig"
    }
  ],
  "features":{},
  "remoteUser":"vscode"
}

json/additional.json:

{
  "mounts": [
    {
      "type": "bind",
      "source": "${localEnv:HOME}/.vim",
      "target": "/home/vscode/.vim"
    }
  ]
}

プログラム実装

JSON のスキーマファイル定義

devcontainer/schema.go:

package devcontainer

import (
        "encoding/json"
)

type Devcontainer struct {
        Name       string
        Image      string
        Mounts     Mounts
        Features   interface{}
        RemoteUser string
}

type Mounts []Mount

type Mount struct {
        Type   string
        Source string
        Target string
}

func UnmarshalDevcontainer(data []byte) (Devcontainer, error) {
        var d Devcontainer
        err := json.Unmarshal(data, &d)
        return d, err
}

主処理実装

main.go:

package main

import (
        "fmt"
        "os"

        "dario.cat/mergo"

        "github.com/mikoto2000/golang/json/mergo/firststep/devcontainer"
)

const baseJsonPath = "json/base.json"
const additionalJsonPath = "json/additional.json"

func main() {
        baseJson, err := parseJsonFile("json/base.json")
        if err != nil {
                panic(err)
        }
        fmt.Printf("=== %s ===\n", baseJsonPath)
        fmt.Printf("%+v\n", baseJson)
        fmt.Println()

        additionalJson, err := parseJsonFile("json/additional.json")
        if err != nil {
                panic(err)
        }
        fmt.Printf("=== %s ===\n", additionalJsonPath)
        fmt.Printf("%+v\n", additionalJson)
        fmt.Println()

        // マージオプションは以下を参照
        // https://github.com/darccio/mergo/blob/cde9f0ea26cccb1168ee3900cf8ca457bb928c3c/merge.go#L329-L372
        mergo.Merge(&baseJson, additionalJson, mergo.WithAppendSlice)
        fmt.Printf("=== Merged JSON %s and %s ===\n", baseJsonPath, additionalJsonPath)
        fmt.Printf("%+v\n", baseJson)
        fmt.Println()

}

func parseJsonFile(jsonFilePath string) (devcontainer.Devcontainer, error) {
        jsonContent, err := os.ReadFile(jsonFilePath)
        if err != nil {
                return devcontainer.Devcontainer{}, err
        }

        d, err := devcontainer.UnmarshalDevcontainer(jsonContent)
        if err != nil {
                return devcontainer.Devcontainer{}, err
        }

        return d, nil
}

実行

root@a67c959ef4b8:/work# go run main.go
=== json/base.json ===
{Name:Go Image:mcr.microsoft.com/devcontainers/go:1-1.22-bookworm Mounts:[{Type:bind Source:${localEnv:HOME}/.gitconfig Target:/home/vscode/.gitconfig}] Features:map[] RemoteUser:vscode}

=== json/additional.json ===
{Name: Image: Mounts:[{Type:bind Source:${localEnv:HOME}/.vim Target:/home/vscode/.vim}] Features:<nil> RemoteUser:}

=== Merged JSON json/base.json and json/additional.json ===
{Name:Go Image:mcr.microsoft.com/devcontainers/go:1-1.22-bookworm Mounts:[{Type:bind Source:${localEnv:HOME}/.gitconfig Target:/home/vscode/.gitconfig} {Type:bind Source:${localEnv:HOME}/.vim Target:/home/vscode/.vim}] Features:map[] RemoteUser:vscode}

参考資料