Coniでのネイティブ並列ダウンロードの構築

独自の言語やツールを構築する際の素晴らしい点の一つは、操作がどのように実行されるかを再考できることです。最新のコミットでは、ビルドプロセスの大きなボトルネックであるMaven依存関係のダウンロードに取り組むことにしました。

プラットフォーム依存スクリプトの問題点

これまで、Coniのdownload-url-to-file関数は、プラットフォーム固有のツールを呼び出す(シェルアウトする)ことに依存していました:

  • Linux/macOSでは、curlプロセスを生成しました。
  • Windowsでは、System.Net.WebClientを使用する巨大なpowershellコマンドを呼び出しました。

これは機能していましたが、いくつかの欠点がありました。第一に、外部プロセスを呼び出すことは遅く、リソースを消費します。第二に、外部ツールへの依存は、curlのバージョンやWindowsのセキュリティプロトコルの微妙な違いが予期せぬ失敗を引き起こす可能性があることを意味しました。最も重要なのは、これらのシェルコマンドを使用してアーティファクトを順次(シリアルに)ダウンロードするため、大規模なMavenプロジェクトの解決に非常に時間がかかっていたことです。

ネイティブな解決策

コミット9ac76d12で、Goの評価器(エバリュエーター)内に直接実装されたネイティブの組み込み関数sys-http-downloadを導入しました。この新しい組み込み関数は、Goの標準のnet/httpクライアントを使用します。

評価器にネイティブに組み込まれているため、プロセス作成のオーバーヘッドを完全に回避できます。さらに、Maven Centralからのレート制限(認識可能なUser-Agentなしで速すぎるアクセスをした場合に頻繁に発生する「429 Too Many Requests」エラー)を処理するために、Goの実装に直接堅牢なリトライロジックを追加しました。

ゴルーチンによるスーパーチャージ

ネイティブの組み込み関数が準備できた後、Coniの並行処理プリミティブを適用したときに真の魔法が起こりました。Coniはgochan<!(チャネルからの読み取り)、>!(チャネルへの書き込み)を使用して、Goスタイルの並行処理をネイティブにサポートしています。

アーティファクトを一つずつダウンロードする代わりに、標準ライブラリの中に直接ワーカープールをセットアップしました:

(let [total-count (count missing)
      result-ch (chan total-count)
      task-ch (chan total-count)]
  ;; すべてのタスクをタスクチャネルにプッシュ
  (loop [rem missing]
    (if (not (empty? rem))
      (do 
        (>! task-ch (first rem))
        (recur (rest rem)))))
  (close! task-ch)
  
  ;; タスクを並行処理するために8つのワーカーゴルーチンを生成
  (loop [i 0]
    (if (< i 8)
      (do
        (go 
          (loop []
            (let [item (<! task-ch)]
              (if (not (nil? item))
                (do
                  (>! result-ch (download-file-single item repos))
                  (recur))))))
        (recur (+ i 1)))))

  ;; 結果を待ち、進捗を表示
  ...)

ネットワークを圧倒したり、攻撃的なレート制限を引き起こしたりするのを防ぐため、ワーカープールの最大値を8ゴルーチンに制限しています。

組み込みサブコマンドによるConi CLIのスーパーチャージ

Coni言語の最大の強みの一つは、そのポータビリティ(移植性)です。私たちは、コア・インタープリタと標準ライブラリが単一の静的バイナリとして配布されるように設計しました。肥大化したインストールプロセスは必要ありません。実行可能ファイルをダウンロードするだけで、すぐに使い始めることができます。

しかし、エコシステムが成長するにつれて(Androidビルドパイプラインの追加など)、ワークフローに少し煩わしさを感じるようになりました。AndroidのAPKビルダーを呼び出すには、次のように絶対パスでスクリプトを実行する必要がありました:

coni libs/android/bin/build-apk.coni ./my-app

機能はしますが、ネイティブのツールのように感じられませんでした。開発者が次のようにシンプルに実行できるような、洗練された開発者体験を提供したいと考えました:

coni android build-apk ./my-app

本日、Goのembedファイルシステムを活用した**動的サブコマンドルーティング(Dynamic Subcommand Routing)**を実装することで、このギャップを埋めたことを発表できることを嬉しく思います!

仕組み

Goコンパイラに3つの重要なアーキテクチャ上の変更を加えました:

1. バイナリへの組み込み(Embedding)

Goコンパイラ内の//go:embedディレクティブを拡張し、すべてのモジュールにわたるすべてのbin/ディレクトリを自動的にパッケージ化するようにしました:

//go:embed libs/*/src libs/*/bin
var embeddedLibs embed.FS

このシンプルな変更により、(build-apk.coniのような)ヘルパースクリプトが実行可能ファイルに永続的に焼き付けられることが保証されます。

2. サブコマンドのインターセプター

CLI引数の解析ロジックに賢いインターセプターを導入しました。これにより、coni android build-apkのような認識できないコマンドを実行した場合、パーサーがそれをインターセプトし、動的にパスクエリ(libs/android/bin/build-apk.coni)を構築します。

組み込まれたファイルシステムを確認し、スクリプトが存在する場合、実行を組み込まれたペイロードに直接動的にルーティングします!

3. 引数のマスキング(Masking)

最後のパズルのピースは、スクリプト自身に対する「錯覚」を維持することでした。build-apk.coniスクリプトは、フラグ(-p cameraなど)を読み取るためにcli/parse-optsを使用します。引数を盲目的に評価器(エバリュエーター)に渡すと、先頭のandroidbuild-apkといったトークンによってスクリプトが混乱してしまいます。

これを解決するために、Goのルーターは舞台裏でグローバルなos.Argsスライスを書き換えます:

os.Args = append([]string{os.Args[0], embeddedPath}, os.Args[3:]...)

スクリプト側から見れば、直接呼び出されたと思い込むわけです!

結果

その結果、超高速でシームレスなCLI体験が実現しました。すべてのツールとヘルパースクリプトはConiバイナリ内に有機的に組み込まれて出荷され、自然でネイティブ感のあるサブコマンドを通じて瞬時にアクセスできます。

追加のダウンロードも、パスの設定も不要です。ただ、純粋で邪魔のない生産性があるのみです!