Dagger Go SDKからgo-gitとgo-githubへ ~ モノレポのCIの書き換えと実装比較 ~

サムネイル

はじめに

こんにちは、DMM.com の松井建登です。
現在は、プラットフォーム開発本部 Developer Productivity Group 横断チームに所属しています。
所属している横断チームでは、社内の多様なサービスの開発生産性を上げるために、他チームにいくつかのプラットフォームを提供しています。
そのうちの 1 つに、複数のサービスのコードを、同一の GitHub Repository で管理するモノレポがあります。
モノレポは、複数のサービスのコードを同一の GitHub Repository で管理することで、CI を共通化できたり、各種便利ツールを効率よく導入できたりします。
今回の記事では、モノレポの CI を Dagger Go SDK から go-git と go-github に書き換えたことについてお話しします。

Dagger Go SDK / go-git / go-github とは

Dagger Go SDK

Dagger とは CI/CD エンジンで、特徴は大きく 2 つあります。

プログラマブルであること

Dagger では、Go や Python といったプログラミング言語を使って CI/CD を記述できます。
一般的に CI/CD は YAML ファイルで記述することが多いですが、YAML には複雑なロジックを書く際に次のようなデメリットがあります。

  • 可読性が低下し、職人的なコードになりがち
  • リファクタリングや再利用が困難

Dagger を利用することで、Go や Python のようなプログラミング言語を活用して職人的なコードから脱却し、より柔軟かつ可読性の高い CI/CD を実現できます。

ポータブルであること

Dagger は、GitHub Actions や CircleCI といった特定の CI ツールに依存しません。
一度 Go や Python で記述すると、コンテナ環境さえあればどの環境でも動作可能です。
例えば、CI/CD をローカル環境で動作確認できるため、GitHub に何度も push して検証する手間を省けます。

参考資料

go-git

go-git とは、純粋な Go で書かれた git 操作ができる Go ライブラリです。
例えば、一般的な開発で利用されるgit addgit commitなどの操作を Go で書くことができます。
そのため、go-git を利用することで、git コマンドを利用せずに、CI で必要な git 操作を行えます。

参考資料

go-github

go-github は、GitHub API を操作するための Go ライブラリです。
例えば、以下のような GitHub での操作をプログラムで実行できます。

  • Pull Request へラベルを付与する
  • GitHub Release を作成する

これにより、通常手動で行っている操作を CI/CD 内で自動化できるため、開発フローの効率化を期待できます。

参考資料

Dagger Go SDK 以前のお話

YAML ファイルの shell から Dagger Go SDK への移行が気になる方は、以下の記事を読んでみてください。

Dagger Go SDK vs Shell in GitHub Actions ~ モノレポの CI の実装を Go で実装するまでの道のり ~

Dagger Go SDK から go-git と go-github へ書き換えた背景

前提

横断チームは Go に書き換えるときに、cli(interface) / domain / usecase / infrastructure の 4 つのディレクトリのアーキテクチャを採用しました。

ディレクトリ名 説明
cli コマンドラインツールとして動かすためのコードを実装します。
実際には GitHub Actions からコマンドを叩くことで、CI を実行しています。
domain ドメインモデルやメソッドを定義します。
infrastructure dagger コンテナ上でコマンドを叩くためのコードを実装します。
現在は、go-git と go-github を利用するコードを実装しています。
usecase infrastructure や domain にある構造体やメソッドを利用し、CI のユースケースを実装します。

Go に置き換えることのメリットを活かしきれていない

Dagger Go SDK を利用すると、確かに YAML ファイルに書かれている shell からは脱却できました。
しかし、Dagger Go SDK には以下のような課題が残っています。

  • infrastructure 層では、YAML ファイルの時と同じ shall のコマンドを実行している
  • エラーハンドリングを標準出力や標準エラーに頼る必要があるため、エラーハンドリングを行いにくい
  • エラーハンドリングを行いにくいことで、単体テストが書きにくい

Dagger はコンテナ環境が前提のため、コンテナ起動の時間が必要

Dagger はコンテナ環境を前提としているため、ポータブル性や依存するライブラリやツールがホスト環境に影響しないなどのメリットがあります。
反対に GitHub Actions やローカルで CI を実行するたびに、コンテナを起動させる必要があります。
そのコンテナの起動には数秒程度かかります。
ローカルで開発していると、動作確認のたびに数秒かかることが、煩わしく感じるかもしれません。
実際に CI として実行するようになれば、気にならない程度ではあります。

Dagger Go SDK vs go-git と go-github

コードの視認性

Dagger Go SDK

以下は、Dagger Go SDK を用いた GitTag の作成日時を取得するメソッドです。
コードを詳しくみてみると、以下の処理を行っています。

  1. git fetch origin ${GitTag}のコマンドを実行する
  2. git show -s ${GitTag} --date=format:%Y-%m-%dT%H:%M:%SZ --format=%adのコマンドを実行する
  3. 上記の実行結果をパースして、期待通りの日時のフォーマットにする

確かに、YAML ファイルで shell スクリプトを直接記述していた場合に比べると、まだ視認性が高いかもしれません。
しかし、これは Dagger Go SDK が特別に提供する機能の恩恵というよりも、Go の構文やアーキテクチャによる効果だと言えます。

func (d *dagger) FetchGitTagCreatedAt(ctx context.Context, gitTag string) (time.Time, error) {
    container, err := d.factory.DaggerContainerByMachineAccountToken(ctx)
    if err != nil {
        return time.Time{}, err
    }

    // GitTagを取得する
    container = container.WithExec([]string{"git", "fetch", "origin", gitTag})

    // GitTagの作成日時を取得する
    currentTagCreatedAtStr, err := container.
        WithExec([]string{"git", "show", "-s", gitTag, "--date=format:%Y-%m-%dT%H:%M:%SZ", "--format=%ad"}).
        Stdout(ctx)
    if err != nil {
        return time.Time{}, err
    }

    // 作成日時を期待するフォーマットにパースする
    currentTagCreatedAt, err := time.Parse(time.RFC3339, strings.ReplaceAll(currentTagCreatedAtStr, "\n", ""))
    if err != nil {
        return time.Time{}, err
    }

    return currentTagCreatedAt, nil
}

go-git と go-github

以下は、go-git を用いた GitTag の作成日時を取得するメソッドです。
go-git では外部コマンドを実行することなく、ライブラリが提供する専用のメソッドを利用して Git を操作できます。
これにより、処理の流れが直感的に理解しやすく、よりプログラマブルで可読性が高いコードを書くことができます。

func (g *git) FetchGitTagCreatedAt(ctx context.Context, gitTag string) (time.Time, error) {
    // go-gitのクライアントを初期化する
    repo, err := g.factory.GitRepository()
    if err != nil {
        return time.Time{}, err
    }

    // GitTag名からGitTagを取得する
    r, err := repo.Tag(gitTag)
    if err != nil {
        return time.Time{}, err
    }

    // アノテーションタグとライトウェイトタグで作成日時を取得する方法が変わる
    tagObject, err := repo.TagObject(r.Hash())

    // アノテーション付きタグの場合
    if err == nil {
        commit, err := repo.CommitObject(tagObject.Target)
        if err != nil {
            return time.Time{}, fmt.Errorf("annotatedTagのコミットオブジェクトの取得に失敗しました: %w", err)
        }

        return commit.Author.When, nil
    }

    // ライトウェイトタグの場合の処理
    commit, err := repo.CommitObject(r.Hash())
    if err != nil {
        return time.Time{}, fmt.Errorf("lightweightTagのコミットオブジェクトの取得に失敗しました: %w", err)
    }

    return commit.Author.When, nil
}

エラーハンドリング

Dagger Go SDK

Dagger Go SDK では、Dagger コンテナ上でコマンドを実行する仕組みのため、エラーハンドリングは標準出力(Stdout)や標準エラー出力(Stderr)を解析する形になります。
もしエラー内容に応じた処理を分岐する場合、strings.Containsを利用してエラーメッセージを文字列として解析する必要があり、実装が煩雑になりがちです。
具体的に Dagger Go SDK の Stdout の内部実装は、以下のようになっており、responseの文字列を解析することで、エラー内容を判別できます。

// The error stream of the last executed command.
// Will execute default command if none is set, or error if there's no default.
func (r *Container) Stderr(ctx context.Context) (string, error) {
    if r.stderr != nil {
        return *r.stderr, nil
    }
    q := r.q.Select("stderr")

    var response string

    q = q.Bind(&response)
    return response, q.Execute(ctx, r.c)
}

// The output stream of the last executed command.
// Will execute default command if none is set, or error if there's no default.
func (r *Container) Stdout(ctx context.Context) (string, error) {
    if r.stdout != nil {
        return *r.stdout, nil
    }
    q := r.q.Select("stdout")

    var response string

    q = q.Bind(&response)
    return response, q.Execute(ctx, r.c)
}

go-git と go-github

go-github では、AcceptedErrorRateLimitErrorのように一部のカスタムエラー型が用意されており、エラーの種類を型で直接判定できます。
これにより、エラーによる処理の分岐が Dagger Go SDK に比べて簡単に行えます。
例えば、GitHub API のリクエストがレートリミットを超過した場合、RateLimitError型のエラーが発生します。
この型を利用すれば、エラー内容を詳細に把握できるだけでなく、適切なエラー処理を直感的に実装できます。

_, err := ghClient.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, labelName)
if err != nil {
    // レートリミットエラーの処理
    if _, ok := err.(*githubclient.RateLimitError); ok {
        return fmt.Errorf("rate limit exceeded while removing label '%s': %w", labelName, err)
    }
}

実装量と実装速度(移行コスト)

Dagger Go SDK

Dagger Go SDK に移行する場合、基本的には既存の shell コマンドをそのまま実行できるため、移行作業はシンプルで速くできることが特徴です。
例えば、gh pr edit 123 --remove-label bugという shell コマンドを Dagger Go SDK で実装する場合、以下のように書くだけです。

container.WithExec([]string{"gh", "pr", "edit", "123", "--remove-label", "bug"}).Stdout(ctx)

Dagger Go SDK は既存の shell コマンドをそのまま実行できるため、移行コストと言えます。

go-git と go-github

go-git と go-github を利用する場合、実行していた shell コマンドと対応する Go ライブラリのメソッドを調査する必要があります。
初めのうちは調査に時間がかかることもありますが、ライブラリの構造に慣れてくると、対応するメソッドを直感的に見つけられるようになります。
例えば、gh pr edit 123 --remove-label bugというコマンドを go-github で実装する場合、以下のようにライブラリが提供するメソッドを利用します。

ghClient.Issues.RemoveLabelForIssue(ctx, "owner", "repo", 123, "bug")

このように、go-github はコマンドを直接実行するわけではなく、ライブラリが提供するメソッドを利用する形になるため、最初にライブラリの仕様を理解する必要があります。
これが Dagger Go SDK に比べて学習コストを高める要因になります。
また、go-git と go-github で実装するデメリットとして、稀にgh コマンド内部で行っていた処理を自身で行わなければいけないことがあります。
具体例を挙げると、Environment Secrets を登録する際の暗号化があります。
gh コマンドを利用する場合、gh secret set ${secretName} -e ${environmentName} -b ${secretValue}のコマンドを実行するだけで、内部で暗号化からシークレットの登録まで行われていました。
しかし、go-github の場合は以下のように PublicKey を取得し、その PublicKey を利用してシークレットを暗号化するメソッドを自前で書く必要があります。
暗号化するメソッドは、go-github の公式のサンプルコードを参考にしています。
go-github の公式のサンプルコード

// go-githubを利用し、Environment Secretを暗号化するためのPublic Keyを取得する
func (g *gh) GetEnvPublicKey(ctx context.Context, repositoryID int, environment string) (*model.PublicKey, error) {
    ghClient := g.factory.GitHubClientByMachineAccountToken(ctx)

    ghClientPublicKey, _, err := ghClient.Actions.GetEnvPublicKey(ctx, repositoryID, environment)
    if err != nil {
        return nil, errors.Wrap(err, "PublicKeyの取得に失敗しました。")
    }

    publicKey, err := model.NewPublicKey(ghClientPublicKey.GetKeyID(), ghClientPublicKey.GetKey())
    if err != nil {
        return nil, errors.Wrap(err, "PublicKey構造体の初期化に失敗しました。")
    }

    return publicKey, nil
}

// 上記で取得したPublic Keyを利用し、シークレットの値を暗号化する
func (g *gh) EncryptSecret(publicKey *model.PublicKey, secretValue []byte) (string, error) {
    decodedPublicKey, err := base64.StdEncoding.DecodeString(publicKey.Key)
    if err != nil {
        return "", errors.Wrap(err, "publicKeyのデコードに失敗しました。")
    }

    var publicKeyBytes [32]byte
    copy(publicKeyBytes[:], decodedPublicKey)

    out := make([]byte, 0, len(secretValue)+box.AnonymousOverhead+len(publicKeyBytes))

    encryptedSecretBytes, err := box.SealAnonymous(
        out, secretValue, &publicKeyBytes, rand.Reader,
    )
    if err != nil {
        return "", errors.Wrap(err, "シークレットの暗号化に失敗しました。")
    }

    encryptedSecretString := base64.StdEncoding.EncodeToString(encryptedSecretBytes)

    return encryptedSecretString, nil
}

保守・運用のしやすさ

Dagger Go SDK

Dagger Go SDK では、shell コマンドを実行するため、コードを読んだ際に「何をしているのか」を正確に把握するには、実行しているコマンドの内容を確認する必要があります。
1 つのメソッド内で実行するコマンドが 1 つだけであれば、メソッド名からコマンドの内容を理解できるでしょう。
しかし、複数のコマンドを 1 つのメソッドで実行している場合、実際にコマンドの内容を読まなければ、処理の全体像を把握することが難しくなります。
以下は、Dagger Go SDK を使って 2 つのブランチ間で変更されたファイルを取得するメソッドの例です。
このメソッドは主に、mainブランチfeatureブランチを指定して、Pull Request 内で変更されたファイルを取得するために利用されていました。

func (d *dagger) GetChanges(ctx context.Context, baseRef, headRef string) (model.Changes, error) {
    container, err := d.factory.DaggerContainerByMachineAccountToken(ctx)
    if err != nil {
        return nil, err
    }

    // ブランチを取得する
    container = container.
        WithExec([]string{"git", "fetch", "origin", baseRef}).
        WithExec([]string{"git", "fetch", "origin", headRef})

    // 差分を取得する
    diff, err := container.WithExec([]string{"git", "diff",
        "--name-only", fmt.Sprintf("origin/%s", baseRef), fmt.Sprintf("origin/%s", headRef)}).
        Stdout(ctx)
    if err != nil {
        return nil, err
    }

    // 差分をスライスを分割する
    diffSlice := strings.Split(diff, "\n")

    return model.NewChanges(diffSlice), nil
}

このコードを見ると、処理の目的を理解するには、git fetchgit diffコマンドを理解する必要があります。
また、複数のコマンドを順番に実行しているため、それぞれのコマンドがどのように関係しているのかを追う手間が生じます。
そのため、保守・運用のコストが高いと言えます。

go-git と go-github

go-github を利用した場合は、外部コマンドを使わずに GitHub の Pull Request API を通じて変更されたファイルを取得します。
専用のメソッドが用意されているため、コードの内容から何をしているのかを直感的に理解しやすくなっています。
以下は、上記のメソッドと同じ用途で利用される、go-github を使ったメソッドの例です。

func (g *gh) GetChangesInPR(ctx context.Context, prNumber int) (model.Changes, error) {
    ghClient := g.factory.GitHubClientByMachineAccountToken(ctx)

    // オプションを定義する
    // 100ファイル以上変更されることがないため、ページング処理はしない
    opts := &githubclient.ListOptions{
        PerPage: 100,
    }

    // go-githubのライブラリを利用し、Pull Requestで変更されたファイルを取得する
    files, _, err := ghClient.PullRequests.ListFiles(ctx, g.owner, g.repo, prNumber, opts)
    if err != nil {
        return nil, err
    }

    // ファイル名をスライスにする
    var filePaths []string
    for _, file := range files {
        filePath := file.GetFilename()
        filePaths = append(filePaths, filePath)
    }

    return model.NewChanges(filePaths), nil
}

このコードは、GitHub の Pull Request API を利用して Pull Request で変更されたファイルを一覧取得する処理を示しています。
ghClient.PullRequests.ListFilesというメソッド名から、その目的が「Pull Request 内の変更されたファイルを取得する」ということが明確に分かるため、コードの可読性が高くなっています。

単体テスト

Dagger Go SDK

Dagger Go SDK では、shell コマンドを実行する仕組み上、エラーハンドリングが標準出力(Stdout)や標準エラー出力(Stderr)の解析に依存しています。
そのため、エラー内容を確認する際には文字列解析(例: strings.Contains)が必要になります。
上記のように、エラーハンドリングを行いにくいことで、異常系のテストが設計しにくくなっています。

go-git と go-github

go-github や go-git では、エラーの種類ごとにカスタムエラー型が用意されており、この仕組みにより異常系テストを簡単に行えます。
例えば、GitHub API のリクエストがレートリミットを超過した場合には、RateLimitErrorというカスタムエラー型が発生します。
このカスタムエラー型を利用することで、エラー内容を直感的に把握できるだけでなく、特定のエラーを再現したテストも簡単に実装できます。

Dagger Go SDK vs go-git と go-github のまとめ

Dagger Go SDK go-git と go-github
コードの視認性
Go でコマンドを実行する形になる。
YAML よりは視認性が上がるが、それは Go の構文やアーキテクチャによる効果である。

ライブラリのメソッドを利用できるため、直感的に何をしているのか把握しやすい。
エラーハンドリング
標準出力や標準エラーのメッセージを独自で解析する必要がある。

カスタムエラー型が提供されているため、エラーの解析が簡単である。
実装量・実装速度
YAML で書いていたコマンドを再利用できる。

ライブラリの学習コストが少しかかる。
コマンドが内部で行っていた処理を自身で書かなければならない場合がある。
保守・運用のしやすさ
コマンドを読む必要があり、コード変更やトラブルシューティングが行いにくい。

コマンドを読む必要がなく、ライブラリが提供しているメソッドから直感的に処理内容を理解できる。
単体テスト(テスタビリティ)
エラーハンドリングを行いにくいため、異常系のテストが設計しにくくなっている。

カスタムエラー型が提供されているため、異常系のテストを設計しやすい。

Dagger Go SDK から go-git と go-github へ移行してみて

Go に置き換えることのメリットを最大限活かせるようになった

Dagger Go SDK を go-git と go-github に置き換えたことで、Git や GitHub に関する操作を shell コマンドで記述する必要がなくなりました。
これにより、特に infrastructure 層のコードの視認性が向上し、コードベース全体の保守性が高まりました。
さらに、go-git と go-github が提供するライブラリやカスタムエラー型を活用することで、エラーハンドリングが簡単になり、異常系のテスト設計も行いやすくなりました。
この移行により、YAML で書かれた shell から Go に置き換えることのメリットを最大限活かせるようになったと言えます。

モノレポの CI から完全に Dagger Go SDK が剥がれたわけではない

ただし、モノレポの CI は Git や GitHub の操作だけで完結するわけではありません。
例えば、横断チームでは静的解析ツールとして SonarCloud を導入しており、Pull Request 単位でコードを SonarCloud に送信して解析しています。
この送信処理では、sonar-scannerコマンドを利用しています。
現在の CI 環境は Go で統一されているため、このsonar-scannerコマンドを Go のコード上で実行する必要があります。
そのため、Dagger Go SDK を利用して Dagger コンテナにsonar-scannerをインストールし、コマンドを実行する形を取っています。
このように、Go ライブラリが存在しないツールを扱う場合には、Dagger Go SDK が依然として有効な選択肢になります。

終わりに

今回の記事では、モノレポの CI を Dagger Go SDK から go-git と go-github に移行した経緯と、その結果について紹介しました。
最終的に、モノレポの CI は Dagger Go SDK と go-git、go-github が共存する形で運用されています。
CI を Go で統一する場合、以下のアプローチが現実的だと感じました。

  • Go ライブラリが存在する場合: そのライブラリを積極的に活用する
  • Go ライブラリがない場合: Dagger Go SDK を利用して、shell コマンドの実行環境を提供する

アプリケーションと CI を Go で統一することで、開発プロセス全体の効率化と保守性の向上が期待できます。
そのため、アプリケーションと CI を Go で統一するアプローチを検討してみてはいかがでしょうか。

一緒に働く仲間の募集

DMM.com プラットフォーム開発本部では、一緒に働く仲間を募集しています。 興味がある方は、以下のリンクから興味がある求人に応募してみてください。 dmm-corp.com