blog.syfm

徒然なるままに考えていることなどを書いていくブログ

自作 CLI ツールのワークフローとそれを支える技術

去年の秋頃から作っていた gRPC クライアントツールである Evans が今年に入り、徐々にいろんな方々に使われ始めました。

github.com

Issue や Pull Request を頂けてとてもありがたい反面、それらを反映したバグフィックスや機能追加がツールに取り込まれても、ユーザは能動的に GitHub Releases をチェックしなければそのアップデートを知ることができません。
これは開発者的にもユーザ的にも損失です。そもそもユーザがアップデートを利用できるかどうか確認する作業や、アップデート方法に関する知識 (どういった方法でインストールしたかどうかなど) を知る必要も本来は必要ないはずです。 この問題をどうにかしたいと思い、最近は CLI ツールの配布、自動アップデート機構に関するワークフローを整備していました。
そのために使用したツールや開発したライブラリ、その経緯を書いていきたいと思います。

5月29日 追記

この記事の内容で LT を行いました。以下はそのスライドです。

speakerdeck.com

バージョニング

Go は、以下のように標準でリモートリポジトリからバイナリを取得 / 更新することができます。

$ go get -u github.com/ktr0731/evans

しかし、go get ではデフォルトブランチの HEAD、もしくは go1.x.x ブランチ (ローカルの Go のバージョンに依存) のソースが利用されるため、実質バージョンを元にした取得ができないといった問題があります。
ツールの継続的なリリース、自動アップデート機構のためには、これは大きな問題であるため、まずはセマンティックバージョニングによるリリースを前提としました。

最近 Russ Cox が提案した vgo ではライブラリのバージョニングとして、セマンティックバージョニング (Semantic Import Versioning)を前提としているため、今後はセマンティックバージョニングが Go プロジェクトにおけるバージョニングのデファクトスタンダードになるのではないかと思っています。

ktr0731/go-semver

ツールの配布を自動化する上で、バージョニングも自動化したいと思うのが普通です。
そのため、セマンティックバージョニング用のライブラリ・ツールをつくりました。

github.com

go-semver を使い、ツールのソース内に以下のような変数を用意します。

var currentVersion = semver.MustParse("0.1.0")

currentVersion は MajorMinorPatch といった各値を表すフィールドや、Equal といったバージョンの比較のためのメソッドなどが定義されています。

fmt.Printf("current: %s\n", currentVersion) // current: 0.1.0
fmt.Println(currentVersion.LessThan(semver.MustParse("0.1.1"))) // true

詳しくは GoDoc をご覧ください。

go-semver が用意しているツール、cmd/bump により、対象のソースを AST へ変換して解析することで安全にバージョニングを行うことができます。

# main.go から semver.Parse or semver.MustParse を探し、そのバージョンのパッチを上げる
$ bump -w patch main.go

AST を用いたバージョニングは motemen/gobump を参考にしています。

github.com

各プラットフォームへのビルド、GitHub Releases へのリリース

せっかく Go で書いているので、色々なプラットフォーム向けにバイナリを配布したいものです。
Go は簡単に元々、各プラットフォーム向けにビルドすることができますが、mitchellh/gox を使うとより簡単に、並列にビルドすることができます。

github.com

作成したバイナリ群は、GitHub Releases へ並行にアップロードしています。
これには tcnksm/ghr を利用しています。

github.com

Evans では、これら二つの作業は、CI 上で実行しています。
ブランチが master へマージされたときに、ツールのバージョンが更新されているかどうかチェックし、更新されていればそのバージョンで Git のタグを切り、ビルド・リリースを行います。

自動アップデート

自動アップデートは今回、最も重要な機構です。

github.com

ライブラリを設計するにあたって、以下のことを重視しました。

  • アップデートに関する最低限の手段のみを提供し、どう使うかはライブラリの使用者に任せる
  • 複数の手段でアップデートできるようにする

go-updater には Means という概念があります。
これは、例えば Homebrew でのインストール、GitHub Releases からバイナリを取得してインストールといったそれぞれのインストール手段のことです。

go-updater を利用する際に使用する Means を決定します。もっともシンプルな例は以下のようになります。
標準で Homebrew、GitHub Releases の Means パッケージを用意しています。

means, _ := updater.NewMeans(github.GitHubReleaseMeans("ktr0731", "evans", github.TarGZIPDecompresser))

これを利用し、アップデートを行うことができます。

u := updater.New(currentVersion, means)
updatable, newVersion, _ := u.Updatable(context.Background())
if updatable {
    _ = u.Update(context.Background())
    fmt.Printf("update completed: %s → %s\n", currentVersion, newVersion)
}

updater の UpdateIf の値を変更することでアップデートが利用可能かどうかのレベルを変更することができます。

// minor 以上のアップデートが見つかった場合、Updatable は true を返す
u.UpdateIf = updater.FoundMinor

updater.SelectAvailableMeansFrom を使うことで、複数の Means を元に、どの Means によりインストールされたかを判別することができます。
Evans であれば GitHub Releases、Homebrew でのインストールをサポートしているので以下のように記述します。

means, err = updater.SelectAvailableMeansFrom(
    context.Background(),
    brew.HomebrewMeans("ktr0731/evans", "evans"),
    github.GitHubReleaseMeans("ktr0731", "evans"),
)

対象の Means によってインストールされているかどうかのロジックは、各 Means の実装に依存します。
例えば、GitHub Releases であれば ldflags により isGitHubReleasedBinarytrue となっているかどうか、Homebrew であれば brew list <name> コマンドを実行してパスが見つかるかどうかでチェックしています。

注意すべきなのは、Means によっては、インストールされているかどうかのチェックに時間がかかる可能性があることです。
実際、 Homebrew コマンドの実行に 0.3 ~ 0.5 秒ほどかかるので、それが許容できない場合はツール側で工夫する必要があります。
Evans では起動時に非同期でアップデートがあるかをチェックし、見つかった場合はその情報をキャッシュし、次回起動時にプロンプトでアップデートをするかをチェックしています。

まとめ

ktr0731/evans では、ktr0731/go-semver によるセマンティックバージョニングの導入、michellh/gox と tcnksm/ghr によるツールの高速なビルド・リリース、ktr0731/go-updater によるツールの自動アップデート機構の導入を行いました。
これらのツールやライブラリを利用することで、配布から CLI ツールのアップデートまでをほぼ自動的に行えるようになりました。
特に、自動アップデートはユーザの手間がほとんどかからないため、非常に便利です。

自分には何が向いているのか?

最近はよく、「結局自分には何が向いているのか?」を自問していることが多い。
別に就活で鬱になっているとかそういうものではなく、この問い自体は高校生くらいの時からずっと考えていて、それがいろんな環境・人と関わることが多くなってからより強く考えるようになったというだけ。(そもそも就活はもう終えるつもり)

「何が」にはあらゆるものが当てはまって、自分の学んでいるコンピュータサイエンスなどの大きな枠から、ソフトウェアエンジニア・サーバサイドエンジニア、はては使っている技術スタックの一つ一つまで色々ある。
それらは学んでいること、仕事としていることであるけど、それ以外にも例えば趣味の絵を描くことだったり友人らと遊ぶことすら当てはめていることが多い。

そもそも自分は何が好きなのか・楽しいのかをよくわかっていないから、何が向いているのかもわからないのかもしれない。

もうずっと続けているソフトウェアをつくることですら、具体的になにが楽しいのかと聞かれると答えられない (か、適当に言葉を返している)。

新しい技術に触ること?わくわくする Web サービスをつくること?知識を増やすことができるところ?新しい価値をつくること?親しい人々と議論しながら目標へ向かっていくところ?誰かに喜んでもらいたいから?単純に自己顕示欲を満たすため?他にもいろいろあると思う。

上で挙げたことは間違いなくどれも楽しいことであると言えるし、実際そう感じる。でも、どれも一定のラインを超えるものではなくて、いつも自分の心の隅で冷ややかに見つめている自分がいる。
たまにあらゆることに無関心になることがあって、それもこういったところから来ているのかもしれない。

自分の中でもっとも得意なことは間違いなくソフトウェアエンジニアリングで、そのあたりの人よりかは断然できると自負しているし、それ相応の努力もしている。 でも、歳が同じくらいの人が自分より「できる」ところを見せつけられるとなにも言えなくなる。「できる」の定義は人それぞれ相対的なものなので、「自分よりできる」と思ったらそれは「できる」人。
なにも言えなくなると、大きな欠片を失った気分になる。自分からそれを取ったらきっと何も残らない。

絵を描くことも同じくらい好きだけど、こちらは自分の感情を表したいときにしか描かない。感情や心の中の絵を具現化できるのは楽しいことだと感じるけど、描くこと自体はそこまで好きではないのかもしれない。

もっとも関心がないのはお金なので、お金を前提にしていることはすべて興味を惹かれない。自分を評価してくれるスコアとしては必要だと思っているけど、基本的には今の生活を不自由なく送れるだけのお金があれば事足りる。

「本質的にあらゆることに無関心」、それが今の自分のような気がしていて、それが一番の悩みでもある。
なのでこれから何が楽しいのか、しっかり見て、考えていきたい。

もしかしたら考えること自体向いていないのかもしれないけれど。

ある構造体型の変数の値をもう一方の変数にコピーするライブラリを書いた

書いたもの 👇

github.com

使用例

type Foo struct {
  Hoge string
  Fuga string
  piyo string
}

v1 := Foo{Hoge: "dummy", Fuga: "FUGA", piyo: "dummy"}
v2 := Foo{Hoge: "HOGE"}
ires, _ := mapstruct.Map(v1, v2)
res := ires.(Foo)

fmt.Println("%#v", res) // main.Foo{Hoge:"HOGE", Fuga:"FUGA", piyo:""}

こんな感じで、第 2 引数の構造体の各値を第 1 引数の構造体の対応するフィールドへ適用したものが interface{} 型として返ってくる。
以下のような条件の場合、そのフィールドは第 1 引数へ適用されない

  • 非公開なフィールド
  • 値がゼロ値

なので、上記の例のように、第 1 引数の Fuga フィールドに非ゼロ値が入っていて、第 2 引数の Fuga フィールドにはゼロ値が入っている場合、第 2 引数の値は適用されずに、そのまま第 1 引数の値が使われる。
どちらのフィールドも非ゼロ値の場合、第 2 引数の値が使われる。

何に使うか?

このライブラリの使用例としては、なにか設定値を表す構造体があり、アプリケーションが Git のようにローカルとグローバルの設定値を持てる場合などに使える。
例えば、以下のような設定値を表す構造体があるとする。

type Core struct {
  Editor string
}

type User struct {
  Name string
  EMail string
}

type Config struct {
  Core *Core
  User *User
}

ここで、ローカル値を優先しつつ、ローカルで何も指定していない項目はグローバルで設定している値を使いたい。
この時に mapstruct を使う。

localConfig := &Config{
  User: &User{
    Name: "ktr",
    EMail: "ktr@example.com",
  },
}

globalConfig := &Config{
  User: &User{
    Name:  "dummy",
    EMail: "dummy@example.com",
  },
  Core: &Core{
    Editor: "/usr/bin/vim",
  },
}

ic, _ := mapstruct.Map(globalConfig, localConfig)
config := ic.(*Config)
pp.Println(config)

すると以下のような構造体が返ってくることがわかる。

&main.Config{
  Core: &main.Core{
    Editor: "/usr/bin/vim",
  },
  User: &main.User{
    Name:  "ktr",
    EMail: "ktr@example.com",
  },
}

ちゃんと両方の値が適用されてて便利。
実際にこのライブラリは Evans でも使っている。(というかそのためにライブラリを書いた)

github.com

このライブラリがやっていることを手動でやろうとすると、どうしても設定値が増えたときなどにバグを生みやすいのでこれを使うことでそういったことを防ぐことができると思う。