ネイティブ 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 run と moon build は moonbitlang/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/core の argparse と実装で使う 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は配線に専念させる