ぽよメモ

レガシーシステム考古学専攻

GitHub Actionsのキャッシュをより細かく制御するactions/cache/restoreとactions/cache/save

はじめに

これはGitHub Actions Advent Calendar 2022 22日目の記事です。諸事情によりフライング投稿です。

GitHub Actionsのキャッシュにおいて、そのリストアと保存を別々に制御する機能が actions/cache@v3.2.0-beta.1 で実装されたので使ってみました。トピックブランチではキャッシュを保存しない、ビルドが失敗した際にもキャッシュを保存する、などこれまでは出来なかった細かい制御が可能になっています。

背景

GitHub Actionsにおいて、ダウンロード済みの依存関係などをキャッシュすることでワークフローの実行を高速化することは、一般的によく知られたテクニックです。
一方でGitHub Actionsのキャッシュはいくつかの制限があることが知られています。

  • 1リポジトリあたり合計10GBまで*1
  • デフォルトブランチおよびカレントブランチのキャッシュしかリストアできない*2
    • Pull Requestではベースブランチのキャッシュも利用可能
  • 同じキーに対するキャッシュを上書きできない*3
  • 暗黙的に定義された事後処理ステップにおいてキャッシュの保存が行われるため、無関係な箇所でジョブが失敗した場合にでもキャッシュの保存がスキップされてしまう

これにより、特にキャッシュサイズが大きい場合トピックブランチで複数回キャッシュの保存が行われるとデフォルトブランチのキャッシュが消えてしまったり、依存関係のフェッチとテストを別ジョブに分けて確実に依存関係がキャッシュされるようなワークアラウンドが必要なケースがありました。

actions/cache/restoreとactions/cache/save

以前からより詳細なキャッシュの制御がしたいという要望はあり、2019年頃から以下のようなissueがありましたが、あまり進展は見られていませんでした。

github.com

しかし、2022年12月になって急にDiscussionにてrestoreとsaveに対応するactionが実装されることが発表されました。

github.com

実際に v3.2.0-beta.1 からrestoreとsaveが実装されています。

github.com

それぞれについて書くことはそんなにありません。単にキャッシュのリストア・保存が別アクションに分かれただけです。

■ 追記(2022/12/26)

actions/cache/saveとactions/cache/restoreはv3.2.0でGAになりました。
以降のサンプルコードの@v3.2.0-beta.1@v3 で読み替えても動作します。

github.com

追記終わり

ユースケースの紹介

実際に背景で説明したいくつかの課題をこれで解決することが出来るので一例を紹介します。

トピックブランチではキャッシュを保存しない

2GBのランダムなダミーデータをキャッシュに保存してみることにします。ただし、トピックブランチではキャッシュの保存をスキップします。

name: Save cache only on main

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache/restore@v3.2.0-beta.1
        with:
          path: |
            ./large-object
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate random file if needed
        run: |
          if [ ! -f ./large-object ]; then
            base64 /dev/urandom | head -c 2048M > large-object
          fi
      - uses: actions/cache/save@v3.2.0-beta.1
        if: github.ref == 'refs/heads/main'
        with:
          path: |
            ./large-object
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}

トピックブランチではsaveがスキップされる

mainにマージすると保存される

キャッシュがevictされる問題に対応出来るだけでなく、そのトピックブランチでしか有効でないキャッシュの保存にかかる時間をスキップできることも大きいです。サイズの大きなキャッシュのアップロードを無効化するだけで場合によっては数十秒〜数分の短縮に繋がることもあります。

ただし、これはトピックブランチで長い期間開発する場合にはキャッシュがないことによりむしろ実行時間が延びる可能性があります。

常にキャッシュを保存する

例えばテストが失敗するケースでも、キャッシュを保存したいというようなユースケースです。特にflakyなテストが存在する場合には有用かもしれません。

name: Save cache always

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - name: Step to fail
        run: |
          echo hello > ./file-to-cache
          false
      - uses: actions/cache/save@v3.2.0-beta.1
        if: always()
        with:
          path: |
            ./file-to-cache
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}

途中のステップで失敗してもキャッシュが保存されている

途中のステップで失敗しているので、ジョブ全体のステータスとしては失敗になります。

restoreとsaveで異なるkeyを使う

これがどれくらい需要のあるユースケースなのかはわかりませんが、これまでは地味にできなかったことです。
hashFiles などを使ってハッシュを計算する際、これまでは最初のリストア時に計算されたkeyが保存時にもそのまま利用されていました。つまりそのビルド中に hashFiles による計算結果が変わる場合に対応出来ていませんでした。例として以前の挙動を確認してみます。

途中のステップで hashFiles の対象としているファイルを作成しています。最初の評価時点ではファイルが存在しないため、 hashFiles('**/hello.txt') は空文字列になります。

name: Old behavior

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache@v3
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate hashFiles targets
        run: |
          echo "hello" > hello.txt

actions/cacheでは最初にkeyを評価する時の値で保存する

actions/cache/saveを使って同じ事をすると、保存時に hashFiles の結果が再度評価されていることがわかります。

name: hashFiles get different result

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache/restore@v3.2.0-beta.1
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate hashFiles targets
        run: |
          echo "hello" > hello.txt
      - uses: actions/cache/save@v3.2.0-beta.1
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}

save時に評価されている

hashFiles を使う場合に限らず、restoreとsaveで異なるkeyを指定することが可能になっているので刺さる人には刺さるかも知れません。

まとめ

ライトなユースケースでは従来通り actions/cache をそのまま利用するのがわかりやすく、記述も容易であるため完全に置き換わることはない印象です。
一方、より詳細なキャッシュの制御を求める人にとっては待望の新機能になりそうです。キャッシュサイズの大きさが気になっている人は、とりあえずトピックブランチでのキャッシュ保存を辞めてみると良いかも知れません。


go.workはmonorepoの夢を見るか

TL; DR

  • multi moduleなmonorepoだからgo.workを置こう、は間違い
    • 依存関係の管理を一緒にしてしまって問題ない物だけが一つのWorkspaceに共存できる
  • monorepoでも普通のリポジトリでも、go.workは一時的に外部依存をローカルにあるモジュールで上書きするときに使う
    • 一時的な上書きは開発者のローカル環境の事情が混入するため go.work はコミットすべきでない
  • goplsを動かしたいだけならVSCodeMulti-root Workspaceを使え

はじめに

これはあくあたん工房アドベントカレンダー 2022 およびGoアドベントカレンダーその3 5日目の記事です。他の日の記事も面白いので、是非読んでみてください。

Go 1.18では Workspace モードが導入され、 go.work というファイルで制御するということは比較的広く知られていると思います。最近monorepoで go.work が使えないか( go.workリポジトリにコミットして良いか)という相談を受けることが何度かあったためこの記事を書くことにしました。現時点での自分の理解と、調べた限りの情報を元にしています。間違っていたら指摘を下さい。

Workspace modeが解決したい課題

まず、Workspace modeの元となるプロポーザルが以下にあります。

go.googlesource.com

このプロポーザルには解決したい課題が書かれています。Backgroudのところです。

Users often want to make changes across multiple modules: for instance, to introduce a new interface in a package in one module along with a usage of that interface in another module. Normally, the go command recognizes a single “main” module the user can edit. Other modules are read-only and are loaded from the module cache. The replace directive is the exception: it allows users to replace the resolved version of a module with a working version on disk. But working with the replace directive can often be awkward: each module developer might have working versions at different locations on disk, so having the directive in a file that needs to be distributed with the module isn't a good fit for all use cases.

DeepL翻訳にかけた物も置いておきます。

例えば、あるモジュールのパッケージに新しいインターフェイスを導入し、別のモジュールでそのインターフェイスの使い方を変更したいなど、複数のモジュールにまたがって変更を加えたい場合がよくあります。通常、go コマンドは、ユーザが編集可能な単一の「メイン」モジュールを認識します。他のモジュールは読み込み専用で、モジュールキャッシュから読み込まれます。replace指示は例外で、解決されたモジュールのバージョンをディスク上の作業バージョンと置き換えることができます。しかし、replace ディレクティブの扱いはしばしば厄介です。各モジュールの開発者はディスク上の異なる場所に作業バージョンを持っているかもしれません。

workspace modeが導入されるまで、あるモジュールに変更を加えリリースするまでの間、それに依存する他のモジュールでは変更内容を試せないという問題の回避方法がreplaceするかリリースしてしまうかの二択しかありませんでした。
実験段階の物などはできればリリースしたくありませんし、そもそもローカルで気軽に試したいと考えるのは当然です。replaceはそのための有効な回避方法でしたが、プロポーザルに書かれているとおり各開発者のローカル環境の事情がgo.modに混入してしまうことが大きな課題でした。replaceで置き換えたのを忘れてcommitしてしまった経験のある方も多いのではないでしょうか。

Backgroudにはまだ続きがあり、こちらはgoplsに関する話題です。

gopls offers users a convenient way to make changes across modules without needing to manipulate replacements. When multiple modules are opened in a gopls workspace, it synthesizes a single go.mod file, called a supermodule that pulls in each of the modules being worked on. The supermodule results in a single build list allowing the tooling to surface changes made in a dependency module to a dependent module. But this means that gopls is building with a different set of versions than an invocation of the go command from the command line, potentially producing different results. Users would have a better experience if they could create a configuration that could be used by gopls as well as their direct invocations of cmd/go and other tools. See the Multi-project gopls workspaces document and proposal issues #37720 and #32394.

DeepL翻訳にかけたものが以下です。

gopls は、モジュール間の置換を操作することなく変更を行う便利な方法をユーザーに提供します。複数のモジュールが gopls のワークスペースで開かれると、作業中の各モジュールを取り込んだ supermodule と呼ばれる単一の go.mod ファイルが合成されます。スーパーモジュールの結果、単一のビルドリストができ、依存モジュールで行われた変更を依存モジュールに反映させるツールができます。しかし、これはgoplsがコマンドラインからのgoコマンドの呼び出しとは異なるバージョンのセットでビルドしていることを意味し、潜在的に異なる結果を生成します。cmd/go や他のツールを直接呼び出すのと同様に、gopls でも使用できるような設定を作成できれば、ユーザーはより良い経験を得ることができるでしょう。Multi-project gopls workspaces ドキュメントおよび提案課題 #37720 と #32394 を参照してください。

goplsではGo 1.18のWorkspace modeよりも前の時点から、multi moduleなprojectにおいても動作するように experimentalWorkpaceModule というフラグがあり、実験的にサポートしていました*1
しかしプロポーザルに書かれているとおり、これは本来のgoコマンドの挙動と異なる可能性がありました。具体的にはモジュールAがあるモジュールBのv1.0に依存し、別のモジュールCがモジュールBのv1.1に依存しているとします。このモジュールA、Cが存在するworkspaceにおいてgoplsはMinimal Version Selection(以下MVS)に従いモジュールBについてはv1.1を選択するという挙動になるはずです。しかしモジュールAはgo buildする際にはv1.0を使うので、v1.0では存在しなかった機能をgoplsが補完し、ユーザが取り込んでいるとビルドが壊れてしまいます。Go本体がworkspaceの機能を入れることで、これらの挙動を統一したいというわけです。

まとめると

  • replaceを使った依存の上書きは開発者のローカル環境の事情が混入してしまうおそれがある
  • 現状複数go.modがあるケースにおいてgoplsとgoコマンドの挙動に差異があり、ユーザを混乱させる恐れがある

これらの課題を解決するためにWorkspace modeが考案されています。

Workspace modeのユースケース

複数リポジトリにまたがる開発をする

具体的なストーリーをあげると↓のようなケースが考えられます。

  • 自分が開発しているモジュールAに入れた変更をリリースする前に、それを使う予定の他のモジュールBから試したい
  • フォークして修正したモジュールAを、PRを出す前に自身のモジュールBと組み合わせて試験したい
  • ……etc

以下の様なディレクトリ構成で開発をすることが考えられます。go.workはリポジトリの外側にあり、当然コミットすることはできません。最終的にmodule-a、module-bの完成版がリリースされ、それらのgo.modで正しく解決される必要があります。

.
├── go.work
├── module-a
│   ├── .git
│   └── go.mod
└── module-b
    ├── .git
    └── go.mod

go.workをリポジトリ内に置くこともできます。go.work から other-module を参照することができますが、このother-moduleの存在はローカル環境依存のものであるため go.work はコミットすべきではありません。

.
├── your-module
│   ├── .git
│   ├── go.mod
│   └── go.work
└── other-module
    ├── .git
    └── go.mod

experimentalWorkspaceModuleを置き換える

複数のgo.modを伴う開発において、goplsを experimentalWorkspaceModule: true で使っていたユーザはこの機能で置き換えができます。ただし、注意点が二つありgoplsが動くからという理由でサブディレクトリのgo.modを全て列挙した go.work を置くべきはなく、go.workリポジトリにコミットするべきでもありません。

  1. go buildの挙動が変わる
    • これまで各go.modのみで評価していた依存関係がworkspace全体を考慮するようになるため、MVSの仕様により選択される依存関係のバージョンが変わる可能性がある
  2. go.workをコミットしてしまうと外部依存を開発者のローカル環境のモジュールで置き換えるという本来の使い方がやりにくくなる
    • go.modにreplaceを書きたくないからgo.workというコミットしないファイルにその情報を逃がしたのに、go.workをコミットしてしまうと同じ問題が再び起き得る

1はこれまでgoplsの中では既にそうなっていましたが、Workspace modeにより実際に生成されるバイナリにまで影響が及ぶためです。そしてこの仕様が許容できるなら、全体で一つのgo.modを共有するやり方で問題ないことになります。
2は先述した「go.workリポジトリ内に置き、リポジトリ外のother-moduleを参照する」という場合と実質的に同じ事です。そのため go.work をコミットするということは開発者のローカル環境の事情を混入させるおそれがあります。

monorepoとWorkspace mode

single moduleの場合

これはそのリポジトリにただ一つのgo.modをおいて全体でそれを共有する方式です。この場合、プロジェクトのルート直下にgo.modがないとgoplsが動作しない問題があります。ただしこれはmonorepoでなくとも起き得るため、特にmonorepoに特異な問題ではありません。

そして自明ですが、これはWorkspace modeで解消したかった課題ではありません。副次的に go.workリポジトリルートに置くことでgoplsが動作する可能性がありますが、本来の意図とは異なることに注意が必要です。各開発者が外部依存の上書きを可能にするため、go.workはコミットせずignoreしておくのが無難です。

multi moduleな場合

一つのリポジトリ内に複数のgo.modが存在する方式です。この場合、各go.modごとに依存関係が管理されています。

まずreplaceの問題について考えてみます。元々のプロポーザルでは、replaceを使うことで開発者のローカル環境に依存した変更が入ってしまうことが問題としてあげられていました。しかし、multi moduleなmonorepoの中だけで言えば各モジュールの位置は開発者間で差異はなく、replaceを使っても問題ないと考えられます*2。monorepoの中から外部の依存関係を参照しており、それを一時的にreplaceするという使い方においてWorkspaceは有用です。これはmonorepoでないリポジトリでの開発と考え方は同じで、go.work には開発者のローカル環境の事情が紛れ込んでしまうためコミットするべきではありません。

次にgoplsの問題について考えてみます。multi moduleなmonorepoを選択したことにはおそらく様々な事情があるはずです。

  • 元々複数のリポジトリだったものをmonorepoに集約する過程で生じた
  • モジュールごとに個別に依存関係のバージョンを管理する必要があった
  • 複数の開発が並行することでgo.modやgo.sumが頻繁にコンフリクトするのを避けたかった
  • ……etc

特に個別で依存関係の管理ができなければならない、という要件は見過ごされがちです。この仕様がある場合、何も考えず全てのモジュールを列挙した go.work ファイルを置くと意図せず依存関係を変更してしまう可能性があります。よって、そもそも現在既に依存関係にあるもの同士しか go.work に書かれるべきではありません。

goplsのためのワークアラウンド

「個別に依存関係を管理しなければならない」という複数go.modを持つプロジェクトでは、これまで書いた通り go.work を置いて解決する方法がとれません。それでもgoplsを動かしたい場合に採用できる方法を2つ紹介します*3

go.modのあるディレクトリにcdしてそこでエディタを開く

最も単純なワークアラウンドです。ただし開きたいモジュールの数だけエディタを開く必要がある他、それより上位のディレクトリにユーティリティ(Makefileなど)がある場合、それを使うのが面倒になるというデメリットが存在します。

エディタのMulti-root Workspaceを利用する

これは以前からサブディレクトリに go.mod がある場合のワークアラウンドとして知られています。
VSCodeにはWorkspace機能があり、一つのウィンドウ内で複数のプロジェクトを開けるようになっています((この設定は *.code-workspace というファイルに保存することができます。開発者のローカル環境の事情を汲んだファイルであることから共有する類いのファイルではありません。))。これを使うとあるプロジェクトの複数のサブディレクトリを、あたかも別プロジェクトのようにworkspaceに追加してそこをルートとして扱うことができます。これによりgoplsが動作するようになります。この方法はgoplsが公式に紹介しています。

https://github.com/golang/tools/blob/master/gopls/doc/workspace.md#multiple-workspace-folders

その他のエディタではプラグインがサポートしている場合があるので、以下のドキュメントを参照してください。

まとめ

  • go.workに書かれたモジュールは依存関係を共有してしまうため、既に依存関係にあるモジュール以外を書くべきではない
  • monorepoであるかないかに関わらず、go.work は一時的な外部依存の上書きに使用する際に有用である
    • 一時的な上書きは開発者のローカル環境の事情が混入するため go.work はコミットすべきでない
  • プロジェクトのルートにgo.modがなく、goplsを動かしたいケースではエディタの機能が現状では有用である

go.work の導入に悩んでいる方の参考になれば幸いです。


*1:このフラグは先日のgopls v0.10.0からdeprecatedになり、Go本体のWorkspace modeに統一される流れになりました。

github.com

*2:厳密に言えばモジュールの位置を変更したり削除するなどによってreplaceの指すパスが無効になってしまうことはありますが、それはworkspace modeでも解消できません。

*3:なお筆者はGoLandをもう2年以上使用していないため、JetBrainsにおける事情は分かりません

clapの使い方まとめ

はじめに

clapとはRustのCLI引数をパースするためのクレートです。Pythonで言うところのargparse、Goで言うところのcobra/spf13のような立ち位置のクレートで、CLIツールの実装をする場合お世話になることが多いでしょう。

docs.rs

自分が実装時にclapでこれはどういうやり方をするのか、などを調べても日本語では網羅的な情報があまり出てきませんでした。自分のためのまとめを書いていたらそれなりの文量になったので公開することにしました。

実際の実装は以下のリポジトリにあります。

github.com

インストール

cargo を使っているという前提で、Cargo.tomlに以下の行を足します。

[dependencies]
clap = { version = "4.0.11", features = ["derive"] }

使い方

基本形は以下の通りです。ここでは基本的にderive API*1を使ったやり方のみを紹介し、ビルダーパターンの方法は紹介しません。

use clap::Parser;

#[derive(Debug, Parse)]
struct Args {
    #[arg(help = "位置引数の説明")]
    pos_arg: String,
    #[arg(short, long, help = "オプション引数の説明")]
    opt_arg: String,
}

fn main() {
    let args = Args::parse();
    println!("opt_arg: {}", args.pos_arg);
    println!("opt_arg: {}", args.opt_arg);
}

structの各フィールドに書いたマクロでその引数の挙動を調整します。 arg の引数は key = value の形で渡すのですが、この key にはclapの Arg strutに実装されているものを指定できるので、詳しくは以下を見ると良いでしょう。

docs.rs

位置引数

arg マクロを使わなくとも、何も指定しない場合はデフォルトで位置引数になります。とはいえ、最低限ヘルプメッセージを実装しておくのが親切なので以下の様にします。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(help = "位置引数の説明")]
    pos_arg: String,
}

この場合以下の様なヘルプメッセージが出力されます。

❯ cargo run -q -- --help
Usage: positional-arg <POS_ARG>

Arguments:
  <POS_ARG>  位置引数の説明

Options:
  -h, --help  Print help information

<POS_ARG> という表記が気に入らない場合、以下の様に任意の名前を指定できます。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(value_name = "FILE", help = "位置引数の説明")]
    pos_arg: String,
}
❯ cargo run -q -- --help
Usage: positional-arg <FILE>

Arguments:
  <FILE>  位置引数の説明

Options:
  -h, --help  Print help information

エスケープされた位置引数

例えばcargoでは cargo run するとき、runした先に対して引数を渡せるように -- で区切って指定することができます。clapを使えば、これと同じ機能を自分のCLIでも実装できます。 使われ方は色々考えられますが、例えば cargo run のように他のコマンドのラッパーを書いた時に指定された引数をそのままラップしているコマンドに渡す、などを実装できます。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(value_name = "FILE", help = "位置引数の説明")]
    pos_arg: String,
    #[arg(last = true, help = "ここはパースされない")]
    last_args: Vec<String>,
}
Usage: positional-arg <FILE> [-- <LAST_ARGS>...]

Arguments:
  <FILE>          位置引数の説明
  [LAST_ARGS]...  ここはパースされない

Options:
  -h, --help  Print help information

last = true を設定すると、 -- 以降の引数のみを消費するため、 -- を指定しない場合に該当する位置引数が無ければエラーになります。単に Vec<T> を指定した普通の位置引数は -- があってもなくても引数を消費します。

# これはいずれのケースでもエラーになる。実装されていないオプション引数として解釈されるため
positional-arg FILE --hoge --fuga

# last = true がある場合これはエラー。
positional-arg FILE Hoge Fuga
# -- の指定を以下の様に強制される。
positional-arg FILE -- Hoge Fuga
# -- 付きならオプション引数を指定してもエラーにならない
positional-arg FILE -- --hoge --fuga

# last = falseの場合、以下のいずれもエラーにならない。
# -- は引数として消費されないため last = trueの場合と得られる値は同じ。
positional-arg FILE Hoge Fuga
positional-arg FILE -- Hoge Fuga
positional-arg FILE -- --hoge --fuga

引数を取るオプション

例えばファイルパスやユーザ名のような文字列、リトライ回数のような整数などを取るオプションを作る場合は以下の様にします。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(short = 'n', long = "name", help = "your name")]
    name: String,

    // shortやlongは値を省略するとフィールドの値から勝手に埋めてくれる
    // でもドキュメントが見つからない……
    #[arg(short, long, help = "a 32bit integer")]
    count: i32,
}

これらのパラメータが必須かどうか、複数取れるかどうかなどはフィールドの型で決まります。↑の例ではいずれも必須かつそれぞれ一つしか取れません。以下のリンクに対応表があります。

https://docs.rs/clap/latest/clap/_derive/index.html#arg-types

よくあるオプショナルな引数を作るには Option<T> 、複数の値を取れるものは Vec<T> を使えば良いです。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, help = "optional value")]
    opt: Option<String>,

    #[arg(short, long, help = "multiple inputs")]
    inputs: Vec<String>,
}

複数取る場合、これは必須では無くなる(0以上の要素が許容される)ので注意が必要です。必須にしたい場合は明示的に required = true が必要になります。

引数を取らないオプション

例えば verbose フラグのような、ある機能の有効無効を決定するオプションを実装する場合に使います。単に型を bool にすればフラグになります。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long)]
    verbose: bool,
}

bool なオプションはデフォルトで required = false になり、デフォルト値が false になります。

デフォルトを true 、指定したら false になるフラグを作りたい場合は明示的に action を指定する必要があります。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, action = clap::ArgAction::SetFalse)]
    non_verbose: bool,
}

環境変数と紐付ける

env featureを有効にすると使えるようになります。

https://docs.rs/clap/latest/clap/builder/struct.Arg.html#method.env

特にコンテナなどでコマンドラインフラグではなく環境変数で挙動を変えたいケースは多々あります。引数に対して環境変数を紐付け、自動で取得させることが出来ます。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, env, help = "Value from env")]
    from_env: String,
}

デフォルトではフィールドのアッパースネークケースがそのまま環境変数として使われますが、自分で指定することもでき、その場合は env = "ENV_VAR_NAME" とします。

ヘルプメッセージにどういう環境変数が使えるかが表示されるのが結構良いですね。

❯ cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV>

Options:
  -f, --from-env <FROM_ENV>  Value from env [env: FROM_ENV=]
  -h, --help                 Print help information

値を入れるとhelpメッセージにもそれが表示されます。

FROM_ENV=value cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV>

Options:
  -f, --from-env <FROM_ENV>  Value from env [env: FROM_ENV=value]
  -h, --help                 Print help information

ただしなんらかのクレデンシャルなどを環境変数経由で渡したいときに、そのまま出てくると困るケースがあります。こういう場合は以下の様に値が隠れるようにオプションを指定すると良いでしょう。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, env, hide_env_values = true, help = "Any credential")]
    credential: String,
}
FROM_ENV=value CREDENTIAL=password cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV> --credential <CREDENTIAL>

Options:
  -f, --from-env <FROM_ENV>      Value from env [env: FROM_ENV=value]
  -c, --credential <CREDENTIAL>  Any credential [env: CREDENTIAL]
  -h, --help                     Print help information

そもそも環境変数で指定できることを隠したいケース(そういうケースあるの……?)では hide_env = true とすれば [env: XXX] のような表示も消すことができます。

一つのフラグで複数の値を取る

どういう事かというと、カンマ区切りの値を受け取ってそれをカンマでsplitして Vec<T> として扱います。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(
        short,
        long,
        env,
        value_delimiter = ',',
        help = "comma separated values are allowed"
    )]
    multi: Vec<String>,
}

fn main() {
    let args = Args::parse();
    println!("{:?}", args.multi);
}
❯ cargo run -q -- --multi hoge,fuga
["hoge", "fuga"]

MULTI=hoge,fuga のように環境変数経由でも Vec<T> にできます。

ただしこれはナイーブに , でsplitしているだけなので、クォートなどを解釈してくれるわけではありません。例えば hoge, fugapiyo を渡したくなって以下の様にしても期待する動作にはなりません。

❯ cargo run -q -- --multi '"hoge, fuga",piyo'
["\"hoge", " fuga\"", "piyo"]

一応自前で引数のパース処理を書くことが出来るので、TypedValueParserを実装することでこうしたケースをカバーすることも可能なはずです(未検証)。

https://docs.rs/clap/latest/clap/builder/struct.Arg.html#method.value_parser

https://docs.rs/clap/latest/clap/builder/trait.TypedValueParser.html

排他オプション

オプションが排他であるとは、option Aが指定されたとき、別のoption Bは指定できなくなる(指定するとエラー)というような関係を指します。これは例えばオプション引数で入力を受け付ける --input INPUT と、指定されると標準入力から入力を受け付ける --input-from-stdin フラグのような同じリソースを指定する2つの異なるオプションや、同時に指定すると矛盾が発生するオプションなどに適用されます。

clapでは2つの方法を使ってこれを実装できます。

  • 各argに対して confricts_withconfricts_with_allexclusive を設定して他のコマンドとの排他性を宣言する
  • ArgGroup を設定してそのうちの一つのみを許可するようにする

前者のやり方はどちらのオプションにこれを設定するかなどが問題になることから、基本的には後者でこれを行うこととします。 ArgGroup のようなtraitはないためやり方がよくわかっていませんでしたが、tutorialに書かれていました。

docs.rs

use clap::{ArgGroup, Parser};

#[derive(Parser)]
#[command(group(ArgGroup::new("how_to_input").required(true).args(["input", "input_from_stdin"])))]
struct Args {
    #[arg(long, help = "input from arg")]
    input: Option<String>,

    #[arg(long, help = "input from stdin")]
    input_from_stdin: bool,
}

fn main() {
    let args = Args::parse();
    println!(
        "input: {}, input_from_stdin: {}",
        args.input.unwrap_or("not specified".to_string()),
        args.input_from_stdin
    );
}
❯ cargo run -q -- --help 
Usage: exclusive-option <--input <INPUT>|--input-from-stdin>

Options:
      --input <INPUT>     input from arg
      --input-from-stdin  input from stdin
  -h, --help              Print help information

❯ cargo run -q -- --input-from-stdin          
input: not specified, input_from_stdin: true

❯ cargo run -q -- --input hoge
input: hoge, input_from_stdin: false

❯ cargo run -q -- --input hoge --input-from-stdin
error: The argument '--input <INPUT>' cannot be used with '--input-from-stdin'

Usage: exclusive-option <--input <INPUT>|--input-from-stdin>

For more information try '--help'

ここで勘違いしてはいけないのは、ArgGroupへ設定した required と各 arg の required は両立してしまうということです。 bool なフラグはデフォルトで false が指定されるため気付きづらいのですが、それ以外の型でデフォルト値なしもしくは Option なしでargを設定すると、それは rquired = true になってしまいます。今回の例では --inputOption<String> ではなく String にすると、 —-input-from-stdin のみを指定したときに error: The following required argument was not provided: input というエラーを見ることになるでしょう。

もう一つ気をつけなくてはいけないのは、デフォルトでは各 arg がそれぞれ ArgGroup を作ってしまう点です。通常同じstruct内では同じ名前のfieldを宣言できないのでこれらが重複することはありませんが、自分で ArgGroup を作ると同じ名前で作ることができてしまいます。これはエラーになるので、異なる名前になるようにしなければいけません。

候補から選択するオプション

予め取り得る値がいくつか決まっており、そのうち一つまたは一つ以上を選択して指定するオプション。Pythonのargparseだと choices とかで指定できたものを指します。

よくある例としてはログレベルです。いくつか定義されたレベルがあり、そのうち一つを選びます。以下はデフォルト値を設定していますが、 default_value_t を消せば指定が必須のパラメータになります。もちろん Option<LogLevel> にすればオプショナルにもできます(が、ログレベルではデフォルト値の方が普通の挙動でしょう)。

use clap::{Parser, ValueEnum};
use std::fmt::{Display, Formatter, Result};

#[derive(Debug, Parser)]
struct Args {
    #[arg(long, value_enum, default_value_t = LogLevel::Info, help = "Log level")]
    log_level: LogLevel,
}

#[derive(Debug, Clone, ValueEnum)]
enum LogLevel {
    Debug,
    Info,
    Warn,
    Error,
    Critical,
}

impl Display for LogLevel {
    fn fmt(&self, f: &mut Formatter) -> Result {
        let raw = format!("{:?}", self);
        write!(f, "{}", raw.to_uppercase())
    }
}

fn main() {
    let args = Args::parse();
    println!("log_level: {}", args.log_level);
}

N個の選択肢からM個を選ぶ場合は以下の様に Vec<T> を使えば良いです。

use clap::{Parser, ValueEnum};
use std::fmt::{Display, Formatter, Result};

#[derive(Debug, Parser)]
struct Args {
    #[arg(long, value_enum, value_delimiter = ',', help = "Fruits")]
    fruits: Vec<Fruit>,
}

#[derive(Debug, Clone, ValueEnum)]
enum Fruit {
    Apple,
    Orange,
    Banana,
}

impl Display for Fruit {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "{:?}", self)
    }
}

fn main() {
    let args = Args::parse();
    println!("fruits: {:?}", args.fruits);
}

ヘルプメッセージにはちゃんとデフォルト値や取り得る値が表示されます。

❯ cargo run -q -- --help
Usage: choices [OPTIONS]

Options:
      --fruits <FRUITS>   Fruits [possible values: apple, orange, banana]
  -h, --help              Print help information

サブコマンド

CLIツールを作る場合には基本的にサブコマンドも作ることが多いでしょう。clapではサブコマンドもderiveで実装出来ます。基本的に以下の様に実装するようです。

  1. ルートコマンドの引数のパーサーを作る( #[derive(Parser)]
  2. サブコマンドのenumを作る( #[derive(Subcommand)]
  3. ルートコマンドに2のenumを埋め込む( #[command(subcommand)]

実際のサブコマンドの実行は、2で作ったenumでmatchさせることで処理を分岐するようです。

use clap::{Args, Parser, Subcommand};

#[derive(Debug, Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    #[command(about = "help for hoge")]
    Hoge {
        #[arg(short, long)]
        opt: String,
    },
    #[command(about = "help for fuga")]
    Fuga(FugaArgs),
}

#[derive(Debug, Args)]
struct FugaArgs {
    #[arg(short, long)]
    opt: String,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Hoge { opt } => {
            println!("hoge {}", opt);
        }
        Commands::Fuga(fuga) => {
            println!("fuga {}", fuga.opt);
        }
    }
}
❯ cargo run -q -- --help
Usage: subcommand <COMMAND>

Commands:
  hoge  help for hoge
  fuga  help for fuga
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help information

❯ cargo run -q -- hoge --opt 1
hoge 1

グローバルなオプション

verbose フラグだったりログレベルのようなオプションはどのコマンドにも付けたいケースがあります。全てのサブコマンドにこれを毎回実装するのは手間ですが、ルートコマンドの直後でしか verbose を許容できないのも使い勝手が悪く、全コマンドに --verbose を付けられるようにしたいです。

そこで global というフラグを使うと、ルートコマンドのオプションをそのサブコマンドでも使えるようになります。

use clap::{Args, Parser, Subcommand};

#[derive(Debug, Parser)]
struct Cli {
    #[arg(short, long, global = true, help = "Global flag")]
    verbose: bool,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    #[command(about = "help for hoge")]
    Hoge {
        #[arg(short, long)]
        opt: String,
    },
    #[command(about = "help for fuga")]
    Fuga(FugaArgs),
}

#[derive(Debug, Args)]
struct FugaArgs {
    #[arg(short, long)]
    opt: String,

    #[arg(short, long, global = true, help = "global option for fuga")]
    global: bool,

    #[command(subcommand)]
    command: Option<SubCommands>,
}

#[derive(Debug, Subcommand)]
enum SubCommands {
    #[command(about = "help for nested")]
    Nested {
        #[arg(short, long, help="opt for nested")]
        opt: String,
    }
}

fn main() {
    let cli = Cli::parse();
    println!("verbose: {}", cli.verbose);
    match cli.command {
        Commands::Hoge { opt } => {
            println!("hoge {}", opt);
        }
        Commands::Fuga(fuga) => {
            println!("fuga {}", fuga.opt);
            println!("global {}", fuga.global);
            match fuga.command {
                Some(c) => {
                    match c {
                        SubCommands::Nested { opt } => {
                            println!("nested {}", opt);
                        }
                    }
                }
                None => {
                    println!("sub command is not specified");
                }
            }
        }
    }
}

単に arg(global = true) にするだけで、そのオプションはそのコマンド以下全てのサブコマンドで有効になります。ただし、サブコマンドの Args から急にそのパラメータが生えてくるわけではなく、あくまで global = true を設定した struct に値は入るため、ログレベルの変更などはそこで行わなければなりません。

❯ cargo run -q -- --verbose hoge --opt 1
verbose: true
hoge 1

❯ cargo run -q -- hoge --opt 1 --verbose
verbose: true
hoge 1

❯ cargo run -q -- fuga --opt 1 --verbose       
verbose: true
fuga 1
global false
sub command is not specified

「そのサブコマンド以下全て」であり、全体ではないのでこの例のように fuga サブコマンドで global なオプションを実装しても、それは hoge サブコマンドでは使えません。

❯ cargo run -q -- fuga --opt 1 --global 
verbose: false
fuga 1
global true
sub command is not specified

❯ cargo run -q -- hoge --opt 1 --global
error: Found argument '--global' which wasn't expected, or isn't valid in this context

  If you tried to supply '--global' as a value rather than a flag, use '-- --global'

Usage: subcommand hoge <--opt <OPT>>

For more information try '--help'

テスト

ここでは、単に引数のパースに関するテストのみを主眼とし、ツール全体の試験については考えないこととします。今回の実装では雑に src/main.rsユニットテストを記述していますが、 src/lib.rs ないしその他ライブラリクレートに突っ込む方が普通のやり方だと思います。

Argsに実装されている try_parse_from を使うことで任意の引数を処理させることができます。そのパース結果はResultで返ってくるので、エラーになる場合のテストなども書くことが出来ます。

例えば一つの位置引数を取る場合のテストを書いてみます。

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn one_arg() {
        // 0番目の値は実行するプログラム自身を指す。何でも良いのでここでは空文字を入れておく
        let args = Args::try_parse_from(["", "hoge"]);
        assert!(args.is_ok());
        assert_eq!(args.unwrap().pos_arg, "hoge".to_string());
    }
}

こんな感じで、引数を入力して出力をチェックする普通のユニットテストとして記述できます。

おわりに

一応一通り自分で実装して動かした物ですが、非効率な書き方をしているかもしれません。改善点などあれば指摘ください。

公式のtutorialやcoolbookにはこれ以外の例や、より詳細な実装もあるのでそちらも参照してみてください。

docs.rs

docs.rs