🦉

Web APIの書き心地のCLI フレームワーク Cocona

に公開

最近見つけたCLIフレームワークCoconaの紹介をします。

CoconaはC#のフレームワークですが、C#の前提知識は問いません。本記事を見ていただければC#のプログラマー以外でも簡単にアプリケーションを構築可能であると理解いただけるかと思います。

モチベーション: Web APIのようにCLIを書きたい

APIやCLIアプリケーションを記述したことはありますか? 特に最近のAPIフレームワークでは次のようなアプリケーションオブジェクトを構築してメソッド追加するスタイルが人気です。

例えば、TypeScriptのフレームワーク Hono では次のように記述できます。

import { Hono } from 'hono'
const app = new Hono()

app.get('/', (c) => c.text('Hono!'))

export default app

また、Rustのフレームワーク Axum でも同様に記述可能です。

use axum::{
    routing::{get, post},
    Router,
};

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let app = Router::new()
        .route("/", get(root))

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn root() -> &'static str {
    "Hello, World!"
}

一方で、CLI アプリケーションで利用されるフレームワークでは、CLI Parserのようなオブジェクトが利用されます。例えば、Pythonの argparse の場合次のように記述します。

parser = argparse.ArgumentParser(
                    prog='ProgramName',
                    description='What the program does',
                    epilog='Text at the bottom of help')
parser.add_argument('filename')           # positional argument
parser.add_argument('-c', '--count')      # option that takes a value
parser.add_argument('-v', '--verbose',
                    action='store_true')  # on/off flag
args = parser.parse_args()
print(args.filename, args.count, args.verbose)

Pythonの argparse のようなCLI Parserは便利です。一方で、parse_args() メソッドを呼び出した後に argparse.NameSpace 型のオブジェクトから機能のディスパッチャーにRouteする文を自分で書く必要があります。

Web APIの書き心地のCLI FW Cocona

そんなことを考えていたときにCoconaというCLIフレームワークを見つけました。

https://github.com/mayuki/Cocona

Coconaの最大の特徴として、APIのようにCLIを書ける点があります。次のコードは見た目はAPIのように見えますが、実際にはCLIアプリケーションです。(人によってはC#だと思わないかもしれませんね)

var app = CoconaApp.Create();
app.AddCommand("hello", ([Argument]string name) => Console.WriteLine($"Hello {name}!"))
    .WithDescription("Say hello");
app.AddCommand("bye", ([Argument]string name) => Console.WriteLine($"Goodbye {name}!"))
    .WithDescription("Say goodbye");
app.Run();

また、組み込みでHelp Messageが使えます。例えば、次のアプリケーションを記述したとします。

using Cocona;
CoconaApp.Run((string name) =>
{
    Console.WriteLine($"Hello {name}");
})

このときのHelp Messageは以下のような内容です。

$ dotnet run
Usage: ConsoleAppSample [--name <String>]

Options:
  --name <String>    (Required)
  -h, --help         Show help message
  --version          Show version

さらに、任意引数も型で表現できます。

// `--name` is non-mandatory option.
// If the user runs the application without this option, the parameter will be `null`.
app.AddCommand((string? name) => { ... });

サブコマンドもRustのClapのように可読性を保てます。

var app = CoconaApp.Create();
// ./myapp info
app.AddCommand("info", () => Console.WriteLine("Show information"));

// ./myapp server [command]
app.AddSubCommand("server", x =>
{
    x.AddCommand("start", () => Console.WriteLine("Start"));
    x.AddCommand("stop", () => Console.WriteLine("Stop"));
})
.WithDescription("Server commands");

// ./myapp client [command]
app.AddSubCommand("client", x =>
{
    x.AddCommand("connect", () => Console.WriteLine("Connect"));
    x.AddCommand("disconnect", () => Console.WriteLine("Disconnect"));
})
.WithDescription("Client commands");

app.Run();

ここまで見て読みやすいAPIに惹かれたのではないでしょうか? 分量の都合で紹介しきれませんが、APIなどでよく利用される、DIやLoggingもサポートされています。

これが静的型付け言語のC#で動くのです。次の章ではCoconaでCLIアプリケーションを作った体験とソースコードを共有します。

簡単なCLIアプリを書いてみる

試しに、半角と全角を変換するCLIアプリケーションを作成しました。実際に作ってみることで、メリットやトレードオフが見えたので知見を共有します。

環境構築

Nix、ShellScript、Taskfileを利用して開発環境を構築しています。本記事の趣旨ではないので利用した自作テンプレートを添付します。

https://github.com/shunsock/playground/tree/main/template/csharp

アプリケーションコードの作成

機能を作ります。シンプルな全角半角変換関数を作成しました。

using System.Text;

namespace hisui.TextWidth
{
    /// <summary>
    /// 全角・半角の相互変換ユーティリティ。
    /// 対象:
    /// - 英数字/基本記号: FF01–FF5E ⇔ 21–7E(差分FEE0)
    /// - スペース: U+3000 ⇔ U+0020
    /// - 半角カナ: FF61–FF9F → 全角カナ(NFKC)
    /// 逆方向のカナ(全角→半角)は対象外。
    /// </summary>
    public static class TextWidthConverter
    {
        /// <summary>
        /// 全角→半角(英数字/基本記号/スペース)。カナは変更しません。
        /// </summary>
        public static string ToHalfWidth(string input)
        {
            var sb = new StringBuilder(input.Length);
            foreach (var ch in input)
            {
                int code = ch;

                switch (code)
                {
                    // 全角英数字・基本記号(FF01〜FF5E)→ 半角(差分 FEE0)
                    case >= 0xFF01 and <= 0xFF5E:
                        sb.Append((char)(code - 0xFEE0));
                        break;
                    // 全角スペース U+3000 → 半角スペース U+0020
                    case 0x3000:
                        sb.Append(' ');
                        break;
                    default:
                        // それ以外はそのまま(※カナはここでは変換しない)
                        sb.Append(ch);
                        break;
                }
            }
            return sb.ToString();
        }

        /// <summary>
        /// 半角→全角(英数字/基本記号/スペース/半角カナ→全角カナ)。
        /// 半角カナはNFKCで合成して全角カナ化します。
        /// </summary>
        public static string ToFullWidth(string input)
        {
            var stringBuilder = new StringBuilder(input.Length);
            foreach (var ch in input)
            {
                int code = ch;

                switch (code)
                {
                    // 半角英数字・基本記号(21〜7E)→ 全角(差分 FEE0)
                    case >= 0x21 and <= 0x7E:
                        stringBuilder.Append((char)(code + 0xFEE0));
                        break;
                    // 半角スペース U+0020 → 全角スペース U+3000
                    case 0x20:
                        stringBuilder.Append('\u3000');
                        break;
                    // 半角カナ FF61〜FF9F → NFKCで全角カナに合成
                    case >= 0xFF61 and <= 0xFF9F:
                        // 単独文字でNFKC正規化(濁点/半濁点の合成も行われる)
                        var s = new string(ch, 1);
                        stringBuilder.Append(s.Normalize(NormalizationForm.FormKC));
                        break;
                    default:
                        stringBuilder.Append(ch);
                        break;
                }
            }
            return stringBuilder.ToString();
        }
    }
}

エントリーポイントではこの機能を呼び出します。Coconaのインターフェースのおかげでシンプルにまとまっています。

using Cocona;
using hisui.TextWidth;

var app = CoconaApp.Create();
app.AddCommand(
    "f2h",
    (string src) =>
    {
        Console.WriteLine(TextWidthConverter.ToHalfWidth(src));
    }
);
app.AddCommand(
    "h2f",
    (string src) =>
    {
        Console.WriteLine(TextWidthConverter.ToFullWidth(src));
    }
);
app.Run();

CLIの配布

CLIアプリケーションにおいてインストールの容易さは重要です。そこで、単一のバイナリで配布したいと考える人は多いのではないでしょうか。そこで重要な技術がAoTコンパイルです。

AoT (Ahead-of-Time Compilation) とは、アプリケーションを実行する前に、ソースコードや中間コードをネイティブな機械語にコンパイルする技術のことです。

.NETには1st PartyでAoTコンパイルがサポートされています。

dotnet publish ./src/hisui/hisui.csproj \
    -c Release \
    -r ${{ matrix.runtime }} \
    -p:PublishAot=true \
    --self-contained true \
    -o artifacts/${{ matrix.runtime }}

私の場合はLinuxとmacOSを利用しているので、GitHub Actionsのランナーを複数用意しています。

name: release

on:
  push:
    tags:
      - "*.*.*"

permissions:
  contents: write

jobs:
  build:
    name: Build ${{ matrix.os }} / ${{ matrix.runtime }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: macos-14
            runtime: osx-arm64
            ext: zip
          - os: ubuntu-22.04
            runtime: linux-x64
            ext: tar.gz

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Install native deps (Linux)
        if: startsWith(matrix.os, 'ubuntu')
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends \
            clang lld zlib1g-dev libicu-dev libkrb5-dev

      - name: Cache NuGet
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj','**/*.props','**/*.targets') }}
          restore-keys: |
            nuget-${{ runner.os }}-

      - name: Test
        run: dotnet test -c Release --nologo

      - name: Publish (NativeAOT)
        run: |
          dotnet publish ./src/hisui/hisui.csproj \
            -c Release \
            -r ${{ matrix.runtime }} \
            -p:PublishAot=true \
            --self-contained true \
            -o artifacts/${{ matrix.runtime }}

      - name: Package
        shell: bash
        run: |
          cd artifacts/${{ matrix.runtime }}
          APP=hisui
          if [ "${{ matrix.ext }}" = "zip" ]; then
            zip -r "../${APP}-${{ matrix.runtime }}.zip" .
          else
            tar czf "../${APP}-${{ matrix.runtime }}.tar.gz" .
          fi

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: hisui-${{ matrix.runtime }}
          path: artifacts/hisui-${{ matrix.runtime }}.${{ matrix.ext }}

  release:
    name: Create GitHub Release
    needs: build
    if: startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: dist
      - name: Checksums
        run: |
          cd dist
          find . -maxdepth 2 -type f \( -name "hisui-*.zip" -o -name "hisui-*.tar.gz" \) | xargs shasum -a 256 > SHA256SUMS.txt 
      - name: Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            dist/**/hisui-*.zip
            dist/**/hisui-*.tar.gz
            dist/SHA256SUMS.txt
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

リリースに成功すると user_name/repository_name/releases/tag/... で次のようなリリース情報が表示されます。

作成したリリース情報

パッケージを用意すると個別のマシンでの作業は次の2つだけです。

  • gh などでダウンロード
  • usr/local/bin などにmv (インストール)

良い点

何よりも可読性が高い点が魅力でしょう。特に個人開発や趣味プロジェクトでは、限られた時間で開発・保守することが多く、コードの理解しやすさは非常に重要です。その点でCoconaは直感的なインターフェースを備えており、実装意図がすぐに読み取れる構造になっています。

実装言語がC#なのでこれらの機能が型安全であることも嬉しい点です。特に気を使わなくても一定の品質がコンパイル時に保証されるという特性はメンテナンスを楽にしてくれます。

※ 動的型付け言語で同様の保証を得る場合は、ほとんどのケースで3rd Party製の静的型解析器を導入が必要で、依存が増えます。さらに型安全性のレベルを設定できるようにしているため、設定ファイルが増えてしまいます。

辛い点

一方で、トレードオフもあります。まず、C#がVMベースの言語であるため、単一バイナリとして配布した際のファイルサイズが大きくなりがちです。今回作成したアプリケーションも約72MBとなり、軽量な配布を重視する場合には不利です。Coconaには軽量版のCocona.Liteがありますが、その場合はDIのサポートを失います。

また、.NETのクロスコンパイルはGoやRustと比較するとやや複雑です。GitHub ActionsやDocker Composeなどのビルドワークフローのテンプレートをあらかじめ作成しておくなど工夫が必要でしょう。

まとめ

本記事では、CLIフレームワーク Cocona の特徴と、実際にCLIアプリケーションを構築した体験を紹介しました。Coconaを利用すれば、読みやすく保守しやすいCLIを簡単に実装できます。

一方で、バイナリサイズの大きさやクロスコンパイルの煩雑さといったトレードオフがあることも提示しました。それでも「可読性と開発体験を重視したい」という開発者にとって、十分に採用する価値のあるフレームワークだと思います!!

Discussion