Sukhpinder Singh

每隔几周我就需要写一个二十行左右的小程序:找出构建代理磁盘上哪些文件占用空间、去重 CSV 文件、校验文件夹哈希。十五年来,“选哪门语言?”的诚实答案一直是不是 C#——每次都要先 mkdirdotnet new console,再给这个一次性项目起名,时机早已溜走。所以这些小任务都丢给了 bash 或 Python,我每次都暗暗抱怨。

.NET 10 省去了这些仪式。你只需写一个 .cs 文件就能运行。我一直想验证它在真实脚本场景下的表现,于是这周做了测试——只用一个 Linux 容器和一个秒表,没搞什么花哨的。

单文件,无项目

下面是 biggest.cs,一个列出目录下最大文件的简单工具。整个程序就这一个文件——没有任何 csproj:

#!/usr/bin/env dotnet
#:package Humanizer@3.0.10

using Humanizer;

var root = args.Length > 0 ? args[0] : ".";
var top = args.Length > 1 && int.TryParse(args[1], out var n) ? n : 10;

var files = new DirectoryInfo(root)
    .EnumerateFiles("*", new EnumerationOptions
    {
        RecurseSubdirectories = true,
        IgnoreInaccessible = true,
        AttributesToSkip = FileAttributes.ReparsePoint
    })
    .OrderByDescending(f => f.Length)
    .Take(top)
    .ToList();

foreach (var f in files)
{
    var size = f.Length.Bytes().Humanize("#.#");
    var age = (DateTime.UtcNow - f.LastWriteTimeUtc).Humanize();
    Console.WriteLine($"{size,10}  {f.FullName}  (modified {age} ago)");
}

Enter fullscreen mode Exit fullscreen mode

新增的两行中,#:package [email protected] 是一个写在源码里的 NuGet 引用指令。shebang 稍后再说。其余代码就是你平时写的 C#,包含顶级语句等。

$ dotnet run biggest.cs -- ~/.dotnet 5

Top 5 files under /root/.dotnet:

   37.6 MB  .../FSharp.Compiler.Service.dll  (modified 46 seconds ago)
   18.7 MB  .../Microsoft.CodeAnalysis.CSharp.dll  (modified 46 seconds ago)
   18.7 MB  .../Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll  (modified 45 seconds ago)
   14.9 MB  .../System.Private.CoreLib.dll  (modified 45 seconds ago)
   11.4 MB  .../native/singlefilehost  (modified 47 seconds ago)

Total: 101.1 MB

Enter fullscreen mode Exit fullscreen mode

没错,.NET SDK 里最大的文件是 F# 编译器。我对此乐在其中。

秒表部分

最明显的担忧是:它的迭代速度能像 Python 一样快吗?还是每次运行都要交“编译税”?我在 Linux 容器中使用 SDK 10.0.302 做了测试——不是实验室环境,我只关心比例关系,热启动数据取三次中的最佳值。

首次运行耗时 10.5 秒。这是设计上的最坏情况:它还原了 Humanizer、完成了编译,还经历了 CLI 的首次运行欢迎横幅。之后,未修改的脚本运行时间约为 0.3 秒;修改后立即重跑则略低于 0.5 秒。从保存到输出 0.5 秒,完全处于脚本语言的范畴。我已经注意不到编译器的存在,而这正是重点。

另一个测试结果:我的文件夹里仍然只存在一个文件。没有 bin/,没有 obj/。构建产物被藏在 ~/.local/share/dotnet/runfile/ 下,每个脚本对应一个按内容哈希的文件夹,因此脚本所在目录和 bash 脚本目录一样干净。如果你觉得输入 run 麻烦,也可以直接使用 dotnet biggest.cs

chmod +x,为什么不呢

那行 shebang 并非装饰:

$ chmod +x biggest.cs
$ ./biggest.cs /tmp 3

   22.3 MB  /tmp/phantomjs/phantomjs-2.1.1-linux-x86_64.tar.bz2  (modified 12 weeks ago)
   ...

Enter fullscreen mode Exit fullscreen mode

一个 C# 文件像真正的 shell 脚本一样,可用于 cron 和 CI。我的观点是:对于任何超出单条管道加 grep 的任务,我现在更愿意用它,而不是之前维护的那堆 bash。bash 的字符串处理让我周末的时间都搭进去了,而现在每个脚本只要加一行 #:package 就能拥有真正的 JSON 解析器或 HTTP 客户端。

派对结束之处

至少在我测试的 SDK 上,它确实是单文件的。我在脚本旁边的 util.cs 中添加了一个 Helper 类,结果得到 error CS0103: The name 'Helper' does not exist in the current context。运行单个文件只会编译该文件,仅此而已。

我决定接受这种直白,因为需要第二个文件恰恰说明你的脚本不再是脚本了。升级路径只需一条命令:

$ dotnet project convert biggest.cs

Enter fullscreen mode Exit fullscreen mode

它会生成一个 biggest/ 文件夹,包含源码和一个正式的 csproj,shebang 与指令被移除,#:package 行被转换为真正的 PackageReference。有趣的细节是,生成的项目默认启用了 PublishAotPackAsTool——SDK 对你的脚本未来走向有很强的意见。

不适用场景:需要与队友共同维护、需要测试、想法超过一个文件的情况。另外请记住冷启动——首次运行需要 10 秒的还原时间,这意味着除非缓存 NuGet 文件夹,否则临时 CI 容器每次都要支付这笔开销。

但对于二十行左右的任务?我已经不再伸手去用 Python。一次 10 秒,之后永远只需三分之一秒,而且用的是我真正熟悉的语言。

完整可运行示例:https://github.com/ssukhpinder/dev-to-code-samples/tree/main/016-file-based-csharp

你创建过的最小的“整个解决方案”是什么任务?我的只涉及三行代码和一个至今让我羞愧的 .sln 文件。欢迎在评论区告诉我你的故事。

——仍在计时没人让我计时的事情