tech.guitarrapc.cóm

Technical updates

.NET 10のファイルベースプログラムをパイプ入力やGitHub Actionsで使う

以前にも軽く紹介する記事を書いているのですが、.NET 10から、C#のファイルベースプログラム実行機能が追加されました。C#にファイルベースプログラムが来ても.csファイルを用意して実行することを考えがちでしたが、そういえばパイプ入力でコードを渡して実行できます。例えば、次のようなコマンドが実行できます。

# BashやPowerShellで実行できる
$ echo 'Console.WriteLine("FooBar");' | dotnet run -
FooBar

いい感じに見えますね。これまでC#はコンパイルが必要でスクリプト的な使い心地を得にくかったのですが、これを使うといい感じの使い勝手になっています。 今回はこの機能の活用例を紹介します。

コンパイルを意識せず実行できる

C#はコンパイルが必要なため、スクリプト言語のような気軽さがありませんでした。.csxという選択肢もありますが、標準C#との記法の違いや、デバッグしにくさ、dotnet SDKだけでは実行できない点が課題でした。しかし、.NET 10のファイルベースプログラムならdotnet SDKだけで実行可能で、パイプライン入力を使ってファイルがなくても実行できます。実行時に明示的なビルドも不要で、ファイルサイズが大きくなりにくいためコンパイルを意識せず、それでいてコンパイルによる安全性も担保されます。

$ echo 'int x = "string";' | dotnet run -
C:\Users\guitarrapc\AppData\Local\Temp\dotnet\runfile\1ohbfytn.dex\app.cs(1,9): error CS0029: Cannot implicitly convert type 'string' to 'int'

The build failed. Fix the build errors and run again.

型エラーや構文エラーがあれば実行前に検出できるというのは、かなり安心感があります。スクリプト的な使い心地とコンパイル言語の安全性を両立できるのが強みです。

パイプ入力を使ってちょっとした処理を実行する

ちょっと1-10でランダムな順番の数列を生成したいときは、Enumerable.Range(1,10).Shuffle()で簡単に実行できます。

$ echo 'Console.WriteLine(string.Join(",", Enumerable.Range(1,10).Shuffle()));' | dotnet run -
9,3,1,8,10,4,5,2,7,6

Webページの最初の10行を取得したいときは、HttpClientを使って簡単に実行できます。複数行コードもパイプで渡せるので、いわゆるPythonやBashスクリプトのように使えます。かき捨てですばやく実行したいときに便利です。

$ cat <<EOF | dotnet run -
using System.Net.Http;
var client = new HttpClient();
var res = await client.GetStringAsync("https://tech.guitarrapc.com");
var first10 = res.Split("\n").Take(10);
Console.WriteLine(string.Join("\n", first10));
EOF
<!DOCTYPE html>
<html
  lang="ja"

data-admin-domain="//blog.hatena.ne.jp"
data-admin-origin="https://blog.hatena.ne.jp"
data-author="guitarrapc_tech"
data-avail-langs="ja en"
data-blog="guitarrapc-tech.hatenablog.com"
data-blog-host="guitarrapc-tech.hatenablog.com"

GitHub Actionsでスクリプト代わりに実行する

スクリプト的な書き捨ては、GitHub ActionsのワークフローYAMLで実行するときに便利です。賛否あるものの、GitHub Actionsでちょっとした処理を実行したいときにBash/Python/Rubyスクリプトをインラインで書くことがあります。ただ、少し規模が大きくなるなら、ファイルに保存すると管理しやすくなります。

RubyスクリプトとC#のファイルベースプログラムで同じことを実現する例を紹介します。hadashiA/VContainer | GitHubは、GitHub ActionsワークフローでRubyスクリプトを使ってファイルのバージョンを更新しています。

jobs:
  update-version-number:
    steps:
      - name: Update version number ${{ steps.configure.outputs.git-tag }}
        run: |
          ruby .github/update_version_number.rb ${{ steps.configure.outputs.git-tag }}

Rubyの中身は次のようになっています。シンプルでいいです。

V = ARGV[0]
working_dir = File.expand_path(File.dirname(File.dirname(__FILE__)))

def replace_install_url(src)
  src.gsub(
    %r{(https://github.com/hadashiA/VContainer.git\?path=VContainer/Assets/VContainer#)[\d\.]+},
    %Q{\\1#{V}}
  )
end

def replace_package_json(src)
  src.gsub(
    /"version"\s*:\s*"([\d\.]+)"/,
    %Q{"version": "#{V}"})
end

def replace_docusaurus_config(src)
  src.gsub(
    /label\s*:\s*['"]v?[\d\.]+['"]/,
    %Q{'label': 'v#{V}'})
end

{
  replace_package_json: ["VContainer/Assets/VContainer/package.json"],
  replace_install_url: ["README.md", "website/docs/getting-started/installation.mdx", "website/i18n/ja/docusaurus-plugin-content-docs/current/getting-started/installation.mdx"],
  replace_docusaurus_config: ["website/docusaurus.config.ts"]
}.each do |method, relative_paths|
  relative_paths.each do |relative_path|
    path = File.join(working_dir, relative_path)
    src = File.read path
    dst = send(method, src)
    File.write path, dst
  end
end

C#のファイルベースプログラムも、これと近い雰囲気で書くことができます。C#版を見るとRuby版と良く似ているのが分かるでしょう。ここまで書き心地が近いなら、好みの言語を選びやすくなります。1

using System.Text.RegularExpressions;
var v = args[0];
var workingDir = Path.GetDirectoryName(Directory.GetCurrentDirectory())!;

string ReplaceInstallUrl(string src) => Regex.Replace(
    src,
    @"(https://github.com/hadashiA/VContainer.git\?path=VContainer/Assets/VContainer#)[\d\.]+",
    m => $"{m.Groups[1].Value}{v}"
);

string ReplacePackageJson(string src) => Regex.Replace(
    src,
    "\"version\"\\s*:\\s*\"[\\d\\.]+\"",
    $"\"version\": \"{v}\""
);

string ReplaceDocusaurusConfig(string src) => Regex.Replace(
    src,
    @"label\s*:\s*['""]v?[\d\.]+['""]",
    $"'label': 'v{v}'"
);

var jobs = new (Func<string, string> Replacer, string[] RelativePaths)[]
{
    (ReplacePackageJson,["VContainer/Assets/VContainer/package.json"]),
    (ReplaceInstallUrl,["README.md", "website/docs/getting-started/installation.mdx", "website/i18n/ja/docusaurus-plugin-content-docs/current/getting-started/installation.mdx"]),
    (ReplaceDocusaurusConfig,["website/docusaurus.config.ts"]),
};

foreach (var (replacer, relativePaths) in jobs)
{
    foreach (var relativePath in relativePaths)
    {
        var path = Path.Combine(workingDir, relativePath);
        if (!File.Exists(path))
        {
            Console.Error.WriteLine($"Skip (not found): {path}");
            continue;
        }
        var src = File.ReadAllText(path);
        var dst = replacer(src);
        if (src.Equals(dst, StringComparison.Ordinal))
        {
            Console.WriteLine($"No changes: {path}");
            continue;
        }
        File.WriteAllText(path, dst);
        Console.WriteLine($"Updated: {path}");
    }
}

GitHub Actionsワークフローも、Rubyとほぼ同様の呼び出し方でC#コードを実行できます。

jobs:
  update-version-number:
    steps:
      - name: Update version number ${{ steps.configure.outputs.git-tag }}
        run: |
          dotnet run .github/update_version_number.cs -- ${{ steps.configure.outputs.git-tag }}

実行すると次のようなログが出ます。

$ dotnet run .github/update_version_number.cs -- 1.1.1
Updated: VContainer-master/VContainer-master/VContainer/Assets/VContainer/package.json
Updated: VContainer-master/VContainer-master/README.md
Updated: VContainer-master/VContainer-master/website/docs/getting-started/installation.mdx
Updated: VContainer-master/VContainer-master/website/i18n/ja/docusaurus-plugin-content-docs/current/getting-started/installation.mdx
No changes: VContainer-master/VContainer-master/website/docusaurus.config.ts

色々な使い方

ちょっと使いたくなった時に、どうしようってなることの使い方例をメモです。

プロセス実行を簡単にする

C#はプロセス実行が煩雑なコードになりやすいのですが、Cysharp/ProcessXのZxを使うと簡単に書けます。GitHub Actionsでちょっとした処理を実行したいときに便利です。

#:package Cysharp.ProcessX@1.5.6
using Zx;
await "cat package.json | grep name";

JSONやYAMLの取り扱い

JSONの取り扱いも、System.Text.Jsonとレコード型を使うと簡単でしょう。ファイルベースプログラムはデフォルトNativeAOTでビルドされるため、JSONシリアライズのコンテキストを用意しておく必要があります。2

using System.Text.Json;
using System.Text.Json.Serialization;

var user = JsonSerializer.Deserialize<User>(File.ReadAllText("user.json"), AppContext.Default.User);
Console.WriteLine(user);

// 読み込み専用
record User(string Name, int Age);

[JsonSerializable(typeof(User))]
internal partial class AppContext : JsonSerializerContext { }

レコードクラスは書き換え不可なinit Onlyプロパティを持つため、プロパティを書き換えるなら自前でプロパティを用意する必要があります。

using System.Text.Json;
using System.Text.Json.Serialization;

var user = JsonSerializer.Deserialize<User>(File.ReadAllText("user.json"), AppContext.Default.User);
Console.WriteLine(user);

user.Name = "NewName";
File.WriteAllText("user2.json", JsonSerializer.Serialize(user, AppContext.Default.User));

Console.WriteLine(user);

// 書き込み可能
record User
{
    public string Name { get; set; } = "";
    public int Age { get; set; }
}

[JsonSerializable(typeof(User))]
internal partial class AppContext : JsonSerializerContext { }

Pythonと違って標準ライブラリにYAMLがないのですが、hadashiA/VYamlaaubry/YamlDotNetを使うとYAMLも簡単に扱えます。

まとめ

あまり頻繁には使わないでしょうが、ちょっとしたスクリプト的な処理を実行したいときに便利です。特に以下のようなケースで便利に使えます。

  • GitHub Actionsでちょっとした処理を実行したい
  • ワンライナーで簡単な処理を試したい

.NET 10 SDKがあればすぐに使えるので、ぜひ試してみてください。


  1. これはClaudeに指示をして、RubyをベースにC#版を書かせてから軽く調整していますが、ほぼ手間がかかっていません。
  2. ファイルベースプログラムはデフォルトでNativeAOTビルドされるため、起動が高速です。ただし、JSONシリアライズではソースジェネレーターを使ったコンテキストの用意が必要になります。NativeAOTを無効にするのも手ですが、起動速度が犠牲になるため注意してください。