前言
近两年随着 AI 的铺开,整个 IT 行业发生了翻天覆地的变化,在发展的浪潮中,我对 AI 的态度也随波逐流从最开始的抵触到逐渐接受。
项目里使用 AI 生成代码的比例也逐渐升高,AI 抹平了对编程语言、技术栈熟练度的差距,那么在选型的时候就有了更多的方向。
比如半年前,我还在坚持 Django ,此事在《当人人都能用 AI 写代码时,我为什么选择重回 Django?》中亦有记载。
后面看了 Flask 作者 Armin Ronacher 的博客文章 《 90%》,我自己也做了一些实践,发现 go 语言确实与 AI 很搭,之前不想用 go 做项目后端就是因为代码太臭太长了,和 Java 有得一拼,但不需要自己亲自写的情况下,go 反而眉清目秀起来了,除了代码丑之外,怎么看都是优点啊。
所以我今年也用 go 开发了不少项目,后面有时间写文章分享一下。
本文主要想说的是另一个技术栈,老读者应该知道我很喜欢 C# 以及 .NET 生态,在前 AI 时代,我就做了一系列项目,以及之前带了不少人入坑的 StarBlog 系列 Web 开发教程😂
近两年我也尝试过用 AI 开发 C# 的项目,不过思维还停留在传统的 MVC, DDD 等设计模式,还没转过来,搞了几个项目后面都因为复杂度爆炸最后黄了。
连续受挫让我对 C# 有点心里发怵,之后项目的技术选型也是以 Python, TypeScript 为主,这俩脚本语言在 AI 时代确实如鱼得水,不过项目规模上来之后实在是丑。
不过作为资深 C# 爱好者,肯定不能轻易放弃,在不断的尝试之后,直到我把 monorepo, modular, monolith 结合到一起,我才恍然大悟,这就是我要的啊!
不知不觉展开得有点多了,先收一收,我最近把上面说的新的思路整理成了一个脚手架,初步实践下来非常滴好用,后续我再来详细分享,接下来回到正题。
AI + Go 为什么好,就因为 go 的设计哲学非常符合 AI 的口味,在之前你可以说它简陋、死板、不够灵活,而现在简陋成了优点,代码写出来都是一个样子,在编码习惯、工具链各方面做了统一,心智负担很低。
相比之下,语法糖拉满的 C# 反而吃了亏,就像茴字有九种写法一样,C# 要实现相同的功能有太多种写法了,不同水平的人来写,上下限相差很大,到了 AI 这边也是一样的问题,如果不做限制,你真不知道 AI 会把代码写成什么样,一个功能可以分成多少层😂
就单一个代码风格就有很多流派,C# 默认是走 C++ 的大括号换行流派,我比较喜欢 Java 的 K&R 风格,AI 生成的代码一般来说不太符合我的个人习惯。尤其是 ASP.NET Core Minimal API,大量 Endpoint 注册代码、Lambda 表达式和方法调用嵌套在一起,经常出现一行几百个字符的情况。
比如这样的代码:
wallets.MapPost("/{orgId:guid}/top-ups", async (Guid orgId, ConfirmTopUp body, HttpContext http, WalletLedger ledger, CancellationToken token) => Results.Ok(await ledger.TopUp(Actor(http), orgId, body, ApiProtocol.ReadIdempotencyKey(http.Request.Headers), token, administrator: true)));
代码本身倒没有什么问题,但阅读的时候,这都什么乱七八糟的啊。

我一直有一套比较固定的 C# 代码风格,早年开发 StarBlog 时就基本形成了自己的习惯。
其中最重要的两点是:
- 喜欢 K&R 风格的花括号,也就是
{跟在类型、方法或者控制语句的同一行。 - 希望长表达式、参数列表能够合理地自动折行,而不是无限延伸到屏幕右侧。

其实这两个要求都很普通。
我不希望每次让 AI 生成代码时,都额外提醒它遵循什么格式规范,更不想手动调整代码排版。
理想情况下,应该像 Go 语言的 gofmt 一样,执行一个命令,就能把整个项目的代码整理成统一的风格。
于是,我开始研究 C# 的代码格式化工具。
C# 有没有类似 gofmt 的工具?
当然有,而且还不止一个。
在此之前,我都是用 Rider 写代码,并且用 IDE 的格式化功能,并且把代码风格导出为 .editorconfig 放在项目里,这样不同的电脑打开后,IDE 都会认这个代码风格。
在现在的 AI 时代,用 Rider 的情况已经很少了,我一般用 VSCode。
经过 AI 的调研:.NET 自带的 dotnet format 就支持代码格式化,可以结合 .editorconfig 控制缩进、空格和花括号等代码风格。
另外,还有一个很有意思的开源工具:CSharpier。
GitHub:https://github.com/belav/csharpier
CSharpier 的定位和 Prettier 非常相似,主打 Opinionated Code Formatter,也就是尽量减少配置选项,用一套统一的规则决定代码应该怎么排版。
这恰好符合我的需求。
我希望 Formatter 可以自动决定:
- 一行代码什么时候应该折行;
- 方法参数太多时应该怎样排列;
- 链式调用和复杂表达式怎样布局;
- 缩进和空白如何保持一致。
CSharpier 在这些方面做得不错。
CSharpier 的花括号风格不是我想要的
CSharpier 使用的是类似下面这样的 Allman 风格:
public class MyService
{
public void Execute()
{
if (true)
{
Console.WriteLine("Hello!");
}
}
}
但我更喜欢 K&R 风格:
public class MyService {
public void Execute() {
if (true) {
Console.WriteLine("Hello!");
}
}
}
这纯粹是代码审美和个人习惯,并没有哪种风格更高级。
对我来说,K&R 风格更加紧凑,在同样大小的屏幕上能看到更多代码,而且我自己写 C# 一直采用这种风格。
CSharpier 并不支持通过 .editorconfig 的 csharp_* 配置项改变花括号的位置。
它的设计理念就是尽量统一代码风格,而不是让用户自由配置每一种排版细节。
这就有点尴尬了。
CSharpier 能解决折行问题,却不能满足我的花括号偏好;dotnet format 能控制花括号,却不能完全替代 CSharpier 的自动折行能力。
难道只能二选一?
换个思路:为什么不把两个工具组合起来?
后来我突然想到:
既然两个工具各有优势,那为什么一定要让其中一个工具完成全部工作呢?
能不能先让 CSharpier 处理代码布局,再使用 dotnet format 把花括号调整回来?
思路其实非常简单:
第一步:CSharpier 负责折行和整体排版。
把过长的方法签名、参数列表、Lambda 表达式和调用链重新组织为可读性更好的结构。
这一阶段不考虑花括号风格,即使 CSharpier 把花括号移动到下一行也没关系。
第二步:Roslyn 负责花括号排版。
使用 dotnet format whitespace,根据 .editorconfig 将花括号调整成我喜欢的 K&R 风格。
最终得到的代码,同时具备 CSharpier 的自动折行能力和 Roslyn 的花括号配置能力。
整个流程可以概括为:
原始 C# 代码
|
v
CSharpier
自动折行、整理代码布局
|
v
dotnet format whitespace
恢复 K&R 花括号
|
v
最终格式化代码
这样就不需要修改 CSharpier,也不需要自己开发一个新的 C# Formatter。
两个成熟的工具,各自负责自己擅长的事情。
经过实际测试,这个思路确实可行。
开干,CSharpier + Roslyn
为了验证方案,我创建了一个独立的 Demo 项目,并使用 Taskfile 管理整个格式化过程。
这里使用的是 CSharpier 1.3。不同版本的格式化行为和配置支持可能有所区别,正式使用时建议锁定工具版本。
1. 安装 CSharpier
首先,在项目目录中创建 .NET 本地工具清单,并安装 CSharpier:
dotnet new tool-manifest
dotnet tool install csharpier
这样 CSharpier 会记录在项目的 .config/dotnet-tools.json 中,方便其他开发者恢复相同的工具版本。
之后就可以使用:
dotnet csharpier format .
对当前目录的 C# 代码进行格式化。
在我的 Demo 中,使用 CSharpier 的默认排版配置:100 字符行宽、4 空格缩进。
需要注意,CSharpier 不会因为 .editorconfig 中设置了 K&R 花括号规则就改变自己的输出风格。
所以还需要第二步。
2. 配置 .editorconfig
在 Demo 项目目录中创建 .editorconfig:
root = true
[*.cs]
csharp_new_line_before_open_brace = none
csharp_new_line_before_else = false
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
这里的配置意图是:
- 花括号与对应的声明或语句保持在同一行;
else不单独换行;catch和finally使用换行风格。
当然,这只是 Demo 的最小配置,正式项目可以根据自己的代码风格继续调整。
我还特意设置了 root = true,防止 Demo 继承仓库上层的 .editorconfig 配置。
这是因为我的仓库根目录中原本存在 max_line_length = 120 等设置,而 Demo 希望独立验证 CSharpier 默认的 100 字符行宽行为。
3. 使用 dotnet format 修正花括号
第二阶段执行:
dotnet format whitespace FormatDemo.csproj
这里最重要的是 whitespace 子命令。
dotnet format 除了空白格式化,还支持代码风格和分析器相关的修复操作。
而我的需求只是调整格式,不希望顺带修改代码逻辑或执行其他代码修复。
因此,明确使用 dotnet format whitespace,比直接执行不带子命令的 dotnet format 更合适。
不过需要说明,whitespace 仍会处理适用的空白格式规则,而不一定只执行前面列出的四条设置。所以实际项目仍应检查格式化产生的 diff。
到这里,两阶段的核心思路就实现了。
通过 Taskfile 封装成一个命令
既然想要获得类似 gofmt 的体验,自然不能让开发者每次手动执行两个命令。
我使用 Task 来组织整个流程,我在之前的 monorepo 项目里,使用 Taskfile 来作为项目编排工具。
它是一个跨平台任务运行工具,和 Makefile 有点类似,但使用 YAML 定义任务。
下面是简化后的 Taskfile:
version: '3'
vars:
PROJECT: FormatDemo.csproj
tasks:
format:
desc: CSharpier defaults, then K&R braces
cmds:
- task: csharpier
- task: braces
csharpier:
desc: Reprint C# with CSharpier defaults
cmds:
- dotnet csharpier format .
braces:
desc: Apply K&R braces
cmds:
- dotnet format whitespace {{.PROJECT}} --verbosity quiet
这样一来,在项目根目录执行:
task format
就能完成整个格式化过程。
是不是有点 gofmt 的感觉了?
一个有意思的问题:怎么检查代码是否已经格式化?
做到这里,格式化功能已经能用了。
但如果想把它引入实际项目,还需要解决另一个问题:
如何检查代码是否符合这套格式规范?
CSharpier 本身提供了检查模式。
可是,对于我的方案来说,直接使用 CSharpier 的 check 并不可行。
原因很简单:最终格式化结果是经过 Roslyn 二次处理的,其中的花括号已经不符合 CSharpier 自身的默认格式。
如果让 CSharpier 单独检查,它就会认为代码没有正确格式化。
这就出现了一个很有意思的现象:
某个格式化工具认为不规范的代码,可能恰好是整个格式化流水线的规范结果。
所以真正需要检查的,不是 CSharpier 的单独输出,而是整个 Pipeline 的最终输出。
设:
- C 代表 CSharpier;
- R 代表 Roslyn whitespace formatter;
- F 代表最终的格式化流水线。
那么:
F(x) = R(C(x))
我们希望已经格式化的代码再次执行完整流水线后不会产生变化,也就是满足:
F(F(x)) = F(x)
这就是格式化的幂等性(Idempotence)。
对于 Formatter 来说,这是一项非常重要的性质。
如果每次执行格式化都会产生新变化,那格式化流程就无法稳定用于持续集成,也容易制造无意义的 Git diff。
我的 format-check 实现
在 Demo 中,我实现了一个简单的检查任务:
format-check:
desc: Fail when formatting would change C# files
cmds:
- |
set -eu
snapshot="$(mktemp -d)"
cp -a samples "$snapshot/samples"
task format
if ! diff -ru "$snapshot/samples" samples; then
cp -a "$snapshot/samples/." samples/
rm -rf "$snapshot"
exit 1
fi
rm -rf "$snapshot"
它的思路是:
- 先保存一份原始文件快照;
- 执行完整的格式化流水线;
- 对比格式化前后的文件;
- 如果产生差异,就说明代码尚未符合最终格式规范。
这段实现主要用于 Demo 验证,并不是完整的生产级无副作用检查器。比如格式化任务中途失败时,文件恢复并没有得到充分保障。
正式引入 CI 时,可以在干净的 Git 工作区执行完整格式化,然后通过 git diff --exit-code 判断是否存在变更。
另外,我还打算进一步测试:从未格式化的源码开始,连续执行多次 task format,检查第一次执行之后结果是否完全稳定。
只有验证这一点,才能更有把握地把这套组合长期用于实际项目。
实际效果:Olive Cloud 格式化前后对比
说了这么多,最终效果怎么样?
直接看图。

这是 Olive Cloud 中的 Billing 模块,已经应用了前面的格式化流水线。
可以看到,长参数列表和 Lambda 表达式都被合理地拆分到了多行,复杂的 Endpoint 注册代码不再全部挤在一起。
同时,花括号仍然保留了我喜欢的 K&R 风格。
虽然这样的格式并不能解决代码本身的所有问题,但至少在排版方面,已经比较符合我的个人习惯。
【同一段 Endpoint 的 Before / After 对比图】
| Before | After |
|---|---|
![]() |
![]() |
这里还需要区分两个概念:
代码格式化不等于代码重构。
例如,某个 Endpoint 即使经过了自动折行,仍然可能因为包含太多内联业务逻辑而难以维护。
这种问题需要通过提取 Handler、拆分模块、调整职责等方式解决,而不是依靠 Formatter。
但 Formatter 至少解决了一个不应该占用开发者精力的问题:排版。
以后不管是自己写代码,还是使用 AI 生成代码,都可以通过相同的命令恢复统一风格。
这套方案有没有缺点?
当然有。
首先,它并不是真正的 gofmt。
Go 语言的 gofmt 使用单一格式化规则,而我的方案是两个 Formatter 的组合。
工具版本变化、配置变化,以及格式化器之间的相互影响,都可能改变最终输出。
所以我倾向于锁定工具版本,并通过自动化测试验证格式化结果。
其次,执行顺序不能随意调整。
先由 CSharpier 完成整体布局,再由 Roslyn 修正花括号,这才是这套工作流的核心。
如果最后又单独运行了一次 CSharpier,花括号可能再次变成 Allman 风格。
这也意味着,如果在 IDE 中启用了 CSharpier 的 Format on Save,需要特别注意是否会覆盖最终格式。
最后,双阶段格式化显然比运行单一 Formatter 多了一些执行开销。
不过对于我自己的开发流程来说,只要性能在可以接受的范围内,用一点工具执行时间换取统一的代码风格,还是值得的。
小结
这次折腾其实起源于一个很小的需求:我不喜欢 AI 生成的 C# 代码排版。
但研究之后发现,有时候不一定非要寻找一个功能完整、配置齐全的工具。
如果两个成熟工具分别解决了问题的一部分,那么把它们组合起来,也许就是最简单的解决方案。
回过头来看,这件事情和我最近使用 AI 辅助开发的体验也很契合。
以前,开发者的大量时间花在亲自编写代码上,所以顺手调整缩进、空行和花括号,似乎不是什么大问题。
但现在不一样了,AI 可以在很短时间内生成大量代码,如果还需要开发者一行一行调整格式,显然不太合理。
我也不想为了代码风格,给 Agent 写一份长篇大论的提示词,让它反复模仿我的排版习惯。
相比之下,我更喜欢将这些规则交给确定性的工具。
AI 负责实现功能,Formatter 负责统一风格,开发者则把精力放在需求、架构和代码质量上。
当然,这并不意味着格式化之后的代码就一定是好代码。但至少在代码排版这件事情上,我希望不再需要反复操心。
最终,我想要的体验其实非常简单:
task format
执行完毕,整个项目格式化为我喜欢的风格。
当然这和我最终想要的 C# fmt 工作流还有差距,这个工作流我会试用一段时间,后续可能会考虑 fork CSharpier,把 K&R 大括号风格搞定,这样就不需要依赖两步骤的工作流了。
程序设计实验室
微信公众号

