ネイティブ CLI クイックスタート#

このクイックスタートでは、シンプルで実用的な MoonBit CLI の構成を示します。

  • 引数解析とビジネスロジックはライブラリパッケージに置く

  • cmd/main は小さく保つ

  • ネイティブ I/O には moonbitlang/async を使う

  • ネットワークに触れずに純粋な部分をテストする

この例では moonbitlang/async を使用します。現時点では native バックエンドのサポートが最も充実しています。

プロジェクトを作成する#

通常の MoonBit モジュールから始めます。

moon new download_cli
cd download_cli
moon add moonbitlang/async@0.19.2

argparse はすでに標準ライブラリに含まれているため、このクイックスタートでは moonbitlang/async のみ追加します。

moon.mod.json で preferred target を native に設定すると、moon runmoon buildmoonbitlang/async のサポートが最も良いバックエンドを既定で使います。

{
  "name": "username/download_cli",
  "version": "0.1.0",
  "deps": {
    "moonbitlang/async": "0.19.2"
  },
  "preferred-target": "native"
}

最終的な構成は次のようになります。

download_cli
├── cmd
│   └── main
│       ├── main.mbt
│       └── moon.pkg
├── cli_test.mbt
├── config.mbt
├── download.mbt
├── moon.mod.json
└── moon.pkg

パース処理はライブラリパッケージに置く#

ルートパッケージで CLI の契約を定義します。コマンドの形を定義し、argv を型付きの設定値に変換します。

pub struct Config {
  url : String
  output : String?
} derive(Eq)

///|
pub fn command() -> @argparse.Command {
  @argparse.Command(
    "moon-fetch",
    about="Download a URL to stdout or a file",
    options=[
      @argparse.OptionArg(
        "output",
        short='o',
        about="Write the response body to this file",
      ),
    ],
    positionals=[
      @argparse.PositionArg(
        "url",
        about="HTTP or HTTPS URL to download",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
  )
}

///|
pub fn parse_config(argv : ArrayView[String]) -> Config raise {
  let matches = @argparse.parse(command(), argv~)
  let values : Map[String, Array[String]] = matches.values
  guard values is { "url": [url], "output"? : output_paths, .. } else {
    fail("missing url")
  }
  let output = match output_paths {
    Some([output, ..]) => Some(output)
    _ => None
  }
  { url, output }
}

パッケージ記述子では、moonbitlang/coreargparse と実装で使う async ライブラリをインポートします。

import {
  "moonbitlang/core/argparse",
  "moonbitlang/core/test",
  "moonbitlang/async/fs",
  "moonbitlang/async/http",
  "moonbitlang/async/stdio",
}

async I/O は 1 つの関数に集約する#

run が実際のダウンロード処理を行います。-o が渡された場合は本文をファイルへストリームし、それ以外は標準出力へ直接書き出します。

pub async fn run(config : Config) -> Unit {
  let (response, body) = @http.get_stream(config.url)
  defer body.close()

  guard response.code is (200..<300) else {
    fail("download failed: \{response.code} \{response.reason}")
  }

  match config.output {
    Some(path) => {
      let file = @fs.create(path, permission=0o644)
      defer file.close()
      file.write_reader(body)
      @stdio.stderr.write("saved \{config.url} to \{path}\n")
    }
    None => @stdio.stdout.write_reader(body)
  }
}

main は薄く保つ#

cmd/main は基本的に配線だけを担当します。argv を読み、設定を組み立て、ライブラリのエントリポイントを呼び出します。

import {
  "moonbit-community/cli-quickstart-doc" @app,
  "moonbitlang/core/env",
  "moonbitlang/async",
}

options(
  "is-main": true,
)
async fn main {
  let argv = @env.args()
  let config = @app.parse_config(argv[1:])
  @app.run(config)
}

コマンドを実行する#

レスポンス本文を標準出力に書き出す場合:

moon run cmd/main https://example.com/feed.xml

ファイルに書き出す場合:

moon run cmd/main https://example.com/feed.xml -o feed.xml

ネイティブバイナリをビルドする場合:

moon build --target native

純粋な部分をテストする#

パーサーと設定組み立てロジックは I/O を行わないため、テストしやすいまま保てます。

///|
test "parse config for stdout" {
  let config = parse_config(["https://example.com/feed.xml"])
  assert_eq(config.url, "https://example.com/feed.xml")
  @test.assert_eq(config.output, None)
}

///|
test "parse config for file output" {
  let config = parse_config(["https://example.com/feed.xml", "-o", "feed.xml"])
  assert_eq(config.url, "https://example.com/feed.xml")
  guard config.output is Some(path) else { fail("expected an output path") }
  assert_eq(path, "feed.xml")
}

テストは次で実行します。

moon test

CLI が大きくなっても、同じ分離方針を維持してください。

  • 入力のパースと検証はライブラリパッケージで行う

  • 副作用は少数の async 関数に閉じ込める

  • cmd/main は配線に専念させる