写这个系列的时候,我一直在琢磨一件事:为什么很多 .NET 技术人的心智模型还停留在五年前?一提到构建和发布,条件反射还是 bin、obj、右击项目等一个绿色勾。但这几年 .NET 在构建发布侧的改动,跨度比前面十年加起来都大,而且基本都是能直接落地的干货,不是停留在提案里的愿景。
第一篇聊的是 SDK 风格项目文件如何把工程结构从"GUID 地狱"里解放出来,第二篇聊了跨平台编译这条主线怎么从早期一路走到今天。这一篇我定位成"释放篇":前两篇把工程骨架搭好了,这一篇全部围绕实操——怎么把产物做得更干净、怎么把发布做得更快、怎么让多项目的依赖不再互相打架。
我挑了四个最值得聊的革新点展开:Artifacts 统一输出布局、Native AOT 原生化发布、集中包管理,以及构建性能的诊断与缓存。这四个点正好覆盖构建发布链路里最常见的四个痛点:产物乱、运行时重、版本冲突、构建慢。每个点我都会讲清楚"为什么这么设计"和"实际操作时会踩什么坑",尤其是后面这类,文档里常常只带一句,但真踩上去非常疼。
这篇适合谁看?适合手里管着多项目解决方案、要维护 CI 流水线的人,也适合单项目但被 bin/obj 或发布体积折磨的开发者。前两个点只需要会敲dotnet命令就够,后两个点需要一点 MSBuild 属性的概念,我也会尽量把背景交代清楚。
先说一个总体判断:革新不意味着把旧东西全扔了。这四个点里,Artifacts 和集中包管理是增量改造型的,做完了还能随时回退;AOT 是选项型的,要用才开;构建诊断则是方法论型的,它不改变产物,只改变你看问题的方式。你完全可以按自己项目的容忍度,挑一两样先上。这也是我在多个项目里反复验证过的节奏——一次改太多,回滚成本高到让人想辞职。
2. Artifacts 统一输出布局:bin/obj 终于不用在源码里四处流浪
2.1 旧方案为什么让人头大
传统 SDK 风格项目默认会把编译结果输出到项目目录下的bin/Release,把中间文件输出到obj/Release。单项目时还好,一个解决方案里挂十几个项目,每个项目自带一对 bin 和 obj,整个代码仓看起来就像被轰炸过。
更麻烦的是仓库卫生问题:要么.gitignore写得又臭又长,要么一不留神把 obj 里的缓存文件提交上去,然后开始漫长的"为什么他那边编译不过、我这边就好好的"的玄学排查。就算没提交上去,每次切换分支时 bin/obj 里的增量缓存经常失效,一编译就是全量,时间全耗在等上。
除了视觉和卫生问题,更实际的是工程问题:增量缓存散落在每个子目录的 obj 里,CI 想缓存中间产物得一层层把路径找齐;发布脚本想收集所有项目的输出,得写一个循环去每个子目录里翻。其实官方很早就注意到了这个生态痛点,最终在 .NET 8 前后把"统一输出到解决方案级 artifacts 目录"的能力做成了稳定特性,我身边不少人都直接叫它 Artifacts Output Layout。
2.2 开启方式与目录结构
开启方式极其简单,二选一。
在解决方案根目录的Directory.Build.props(或者某个公共属性文件)里加:
<Project> <PropertyGroup> <UseArtifactsOutput>true</UseArtifactsOutput> </PropertyGroup> </Project>也可以在构建时直接传参,不落盘任何全局配置:
dotnet build --artifacts-path .artifacts开启之后的目录结构大概是这样的:
artifacts/ ├── bin/ │ ├── My.WebApp/ │ │ ├── Debug/ │ │ └── Release/ │ │ └── net8.0/ │ │ ├── win-x64/ │ │ └── linux-x64/ │ └── My.Library/ │ └── Release/ │ └── net8.0/ └── obj/ └── My.WebApp/ └── Release/ └── net8.0/注意这里的层级是"配置 → 项目 → 目标框架 → 平台运行时"。所有项目的 bin 和 obj 都被收纳到最外层统一的artifacts/下,源码目录干净得像刚整理完的书架。.gitignore里从此只需要一行:
.artifacts/这一行就能代替过去几十行的 bin/ 和 obj/ 清单。我第一次迁移完,站在根目录扫了一眼,真的有那种"原来仓库可以这么干净"的错觉。
2.3 迁移之后的三个实际收益
第一个收益是路径可预测。CI 里收集产物时,直接按artifacts/bin/<项目名>取就行,每个项目的输出都在自己的独立目录下,Debug 和 Release 不再混在一起,也不会因为某个项目没编译而让收集脚本莫名其妙地失败。第二个收益是缓存集中。obj 全部收进artifacts/obj,CI 想保留中间产物实现跨构建加速,只需要缓存这一个目录,不用再配置一堆通配符。
第三个收益容易被忽略:多目标框架项目的产物不会互相覆盖。以前同一个项目同时编译 net6.0 和 net8.0,bin 目录里经常出现输出互相串位的诡异问题,现在每个目标框架(以及每个 RID)在 artifacts 下都有清晰独立的子树,互相完全不干扰。
不过这里必须提醒一个迁移坑:自定义 MSBuild 任务里的绝对路径。很多老项目的构建脚本里写着从$(MSBuildProjectDirectory)\bin\...复制文件的逻辑,开启 Artifacts 之后这些路径全部失效。正确做法是改用$(ArtifactsPath)这个由 SDK 注入的新路径变量。这一点官方文档写得很隐晦,我最初迁移时在这里卡了半个下午,后来才发现是路径语义变了,而不是任务本身写错了。
3. Native AOT:发布效率的极端路线
3.1 它革掉的到底是什么
传统 .NET 发布的本质,是把你的 IL 程序集和运行时一起送出门:要么目标机器预装运行库(框架依赖发布),要么我们把整个运行时打包进去(自包含发布),程序启动后再由 JIT 在本地把 IL 逐段编译成机器码。
这套机制很成熟,但它天然有两个代价:体积大、启动慢。体积大是因为运行时本身就有几十上百 MB;启动慢是因为进程要先初始化运行时、加载一大堆元数据,JIT 还要边跑边编译。
Native AOT 的思路是彻底绕开 JIT。发布时直接用原生编译器把 IL 编译成目标平台的原生机器码,输出的就是单个可执行二进制文件。它不依赖任何 .NET 运行时安装,没有 JIT 预热阶段,进程一启动就直接执行机器码。
打个比方:以前发布是"给你一个脚本和一个解释器,到目标机器上现解释现跑",AOT 发布是"直接给你一个编译好的可执行程序,双击就跑"。对这种差异的感受,只有真正在一个几块钱一天的边缘小设备上部署过的人才会懂——自包含版本把磁盘占掉一大半,而 AOT 版本小到你甚至怀疑自己是不是漏发了文件。
3.2 控制台项目发布成原生二进制的实测过程
我拿一个简单的控制台工具做实验,目标平台是 Windows x64,步骤并不复杂。
前置条件先确认好:Windows 上做 AOT 发布需要装 C++ 生成工具里的链接器,Linux 上需要 clang 和相关开发包。这一步特别容易漏,漏了之后报的链接错误会让人误以为是代码问题,其实只是环境缺组件。
然后执行发布:
dotnet publish -c Release -r win-x64 -p:PublishAot=true如果用的是 .NET 8 及以上 SDK,且项目文件里已经声明了<PublishAot>true</PublishAot>,那么直接:
dotnet publish -c Release -r win-x64就会走进 AOT 流程。发布完成后,输出目录里不再是几百个 DLL 加一个启动器,而是孤零零一个 .exe,直接双击就能跑。
我顺手对比了几个常见发布方式的体积和启动形态:
| 发布方式 | 典型体积 | 启动方式 | 目标机器要求 |
|---|---|---|---|
| 框架依赖发布 | 几 MB(不含运行时) | JIT | 必须预装对应 .NET 运行时 |
| 自包含发布 | 60-150 MB | JIT | 无要求,体积大 |
| 自包含 + 裁剪 | 15-40 MB | JIT | 无要求,裁掉未使用代码 |
| Native AOT | 1-10 MB | 原生 | 无要求,体积最小 |
以上是单文件控制台应用的量级,真实项目加上依赖库后会大一些,但相对自包含版本的优势依然非常明显。启动速度的差异更夸张,实测一个小工具从原来的三四百毫秒降到了二三十毫秒,对命令行场景来说几乎是秒开的体验。
3.3 哪些场景该上、哪些场景先冷静
AOT 有明显的使用边界,不适合当成默认发布方式。适合的场景很清晰:命令行工具、对启动速度极度敏感的服务(网关、边缘节点)、需要做成单一可执行文件分发的工具。在这些场景里,AOT 带来的部署简化是实打实的,用户再也不用担心"目标机器装没装 .NET"。
不适合的场景同样有代表性。需要大量反射操作的程序集——比如某些 ORM、依赖注入容器如果在运行期依赖反射发现类型——在 AOT 下要么需要额外适配,要么直接不可用。需要动态加载插件的项目基本告别 AOT。另一个限制是平台绑定:AOT 发布必须指定目标运行时,Linux 的产物不能在 Windows 上跑,这等于放弃了"一份产物到处跑"的便利,多平台就得分别发布。还有构建资源:AOT 需要全程序分析,小项目还好,大项目一次发布可能要好几分钟,CI 机器的内存也要给足。
我的做法是把 AOT 当成"场景型革新":它放进工具箱,需要的时候拿出来,而不是整个团队的默认发布参数。后面章节我会具体讲怎么在一个解决方案里同时保留 JIT 和 AOT 两条发布路线,互相不干扰。
4. 集中包管理:多项目仓库的版本地狱终结者
4.1 版本冲突是怎么慢慢变成灾难的
NuGet 的 PackageReference 已经比旧时代的 packages.config 强太多,但多项目仓库里依然有一个高频痛点:同一个包在不同项目里被引用了不同版本。
举个例子。解决方案里一个公共类库引用序列化包 13.0.1,另一个业务项目因为新需求升到 13.0.3,还有一个老项目锁死在 12.x 不敢动。三方汇到同一个解决方案后,NuGet 只能按"就近原则"做冲突降级,最终哪个版本生效完全依赖引用层级,行为极为难猜。
更痛的是升级流程:想升级一个包的版本,你得先搞清楚这个仓库里到底哪些项目引用了它,然后逐一改 csproj。改到最后总有人开始偷懒,直接写通配符版本13.*,结果某天 CI 上冒出一堆编译错误,因为通配符把破坏性更新也拉下来了。
集中包管理(业内一般叫 Central Package Management,我在项目里简称 CPM)就是官方针对这个场景给出的解法:把版本信息全部集中到仓库根目录的一个文件里,所有项目只声明"我引用了哪个包",至于版本,由中央统一决定。
4.2 Directory.Packages.props 的正确配置方式
使用方式分两步。第一步,在仓库根目录创建一个Directory.Packages.props:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> </ItemGroup> </Project>第二步,各项目引用包时不再写 Version:
<ItemGroup> <PackageReference Include="Microsoft.Extensions.Logging" /> <PackageReference Include="Newtonsoft.Json" /> </ItemGroup>这里有个很严格的约束:如果某个包版本在中央文件里声明了,项目里又写了 Version,构建时直接报错(NU1008,意思是版本不能两处同时指定)。刚上手时这道禁令会觉得烦,但它恰好保证了版本来源唯一,杜绝了"某个项目悄悄固定旧版本"的隐患。
真有项目必须用不同版本时,官方留了后门,给 PackageReference 加一个VersionOverride属性即可覆盖中央版本。我几乎不用这个功能,但它在兼容老接口的场景里能救命,知道总比不知道强。
4.3 迁移时踩到的两个拦路问题
第一个问题是传递依赖不受中央管。CPM 默认只管"直接引用"的包版本,传递依赖的版本还是由 NuGet 按图解析,这就可能出现两个项目引入同一个传递依赖却解析成不同版本的结果。解决办法是在中央文件里开启:
<PropertyGroup> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup>开启之后,所有传递依赖也会被钉到中央声明的版本,整个解决方案的依赖图才真正一致。
第二个问题来自历史包袱。仓库里如果还有项目用 packages.config 或旧式引用方式,迁移 CPM 之前必须先统一成 PackageReference,否则两套体系并存时 restore 行为会变得非常诡异。我当时把迁移拆成两步走:一个提交先把所有项目统一为 SDK 风格和 PackageReference,另一个提交再引入中央版本文件。两步走比一步到位安全得多,出问题也能精准定位到是哪个阶段引入的。
5. 构建速度优化:先诊断再动手,别急着升级硬件
5.1 binlog 是最诚实的"构建体检报告"
每次听到有人抱怨"构建太慢,我要换电脑、我要上分布式构建",我都会先问一句:你知道时间到底耗在哪个环节了吗?大多数人的回答是"反正就是慢"。
遇到这种情况,我建议先取构建日志,再谈优化。生成日志很简单:
dotnet build -bl:build.binlog这个命令会输出一个二进制日志文件,里面记录了整个构建过程中每个任务的执行时间、依赖关系、输入输出哈希,甚至能展开到每次编译任务本身的耗时。用对应的日志查看器打开,你会看到一张直观的耗时占比图。最常见的几个罪魁祸首通常是这几类:
- 某个项目的编译任务本身耗时长,往往是因为目标框架太多或单个项目源码体积过大;
- ResolvePackageAssets 这类包解析任务反复执行,通常和 restore 的时机、包缓存的命中率有关;
- Copy 任务在大量复制文件,说明输出路径设计不合理,或者有"每次都全量复制"的公共目标;
- 自定义 MSBuild 任务没有做增量判断,每个项目都要完整跑一遍。
我以前管过一个项目,日志一展开就发现 Copy 任务占了总时长的三分之一,原因是一个公共目标文件把发布产物复制到共享目录,每次构建全量执行。改成带 Condition 的增量复制之后,构建时间直接砍半。
5.2 增量编译、确定性构建与 CI 缓存的三层配合
诊断完,优化手段可以分三层叠加。
第一层,确保增量编译真的在生效。不要频繁用dotnet clean或者手动删 obj 目录来"解决疑难杂症",那等于每次把缓存丢掉再全量构建一次。遇到诡异问题更合理的做法,是检查具体项目的输出产物和 obj 里的缓存标记,而不是无脑清仓。真正的踩坑经验是:先看项目文件改没改、再看输入哈希变没变,最后才轮到怀疑缓存坏了。
第二层,开启确定性构建和 CI 标识。在公共属性文件里加上:
<PropertyGroup> <Deterministic>true</Deterministic> <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> </PropertyGroup>前者保证同一份源码在相同参数下每次构建产出二进制完全一致,后者让编译器在 CI 环境下不再把本地路径、时间戳写进产物。这一步本身不直接加速,但它让产物比较、缓存复用成为可能,属于"为快先慢"的投入。
第三层,CI 缓存。缓存对象只需要两个:一个是 NuGet 全局包目录(通常在~/.nuget/packages),另一个是统一的 obj 输出目录。如果已经用了前面说的 Artifacts 布局,CI 里只要缓存artifacts/obj一个目录,整个解决方案的中间产物都被覆盖了。很多 CI 卡在 restore 阶段,就是因为每次构建都在重新下载包;全局包缓存一旦命中,restore 时间从几分钟降到几秒很常见。
这里要特别强调一个反直觉的点:不是缓存越多越好。有些团队把整个artifacts/bin也缓存起来,结果项目代码一改,缓存里的旧产物会触发复制任务的隐性问题,反而引入脏状态。我的习惯是只缓存"可恢复的中间产物"——NuGet 包和 obj——不缓存"不可恢复的输出产物"bin。这一条帮我避开了好几次"本地好好的,CI 突然挂掉"的深夜排查。
6. 实操:把一个传统解决方案改造成新一代构建方式
6.1 改造前的典型形态
为了把前面几个点串起来,我拿一个虚构的解决方案 X 做演示。X 是典型的三层结构:一个 Web API 项目、一个业务逻辑类库、一个模型库,外加一个控制台工具项目。
改造之前它是这种状态:各项目各自用 SDK 风格,但没有统一属性;根目录没有Directory.Build.props;bin/obj 散落在源码树各个角落;包版本散落在各 csproj 里且不一致;发布靠手工执行 publish 命令,产物收集靠人肉遍历目录,CI 里每次构建都要全量 restore 加全量编译。
我相信读者手里多多少少都有类似的项目。它不破不烂,但每次发布都要陪它耗时间,依赖升级更是不敢轻举妄动。这种项目恰恰是整套革新收益最大的改造对象。
6.2 改造分三步进行
第一步,在根目录建Directory.Build.props,统一公共属性:
<Project> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <UseArtifactsOutput>true</UseArtifactsOutput> <Deterministic>true</Deterministic> </PropertyGroup> </Project>这样下面四个子项目都不用再各自写 TargetFramework 和 Nullable,将来整体升目标框架只需改这一处。
第二步,建Directory.Packages.props,统一包版本:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup> <ItemGroup> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="8.0.0" /> </ItemGroup> </Project>同时把各 csproj 里 PackageReference 的 Version 属性全部删掉,只保留 Include。删的过程会触发一次全量 restore,正好借这个时机检查依赖图是否有冲突,有冲突就当场处理,不要留到 CI 里去炸。
第三步,按场景分层配置发布方式。Web API 保持 JIT 自包含发布,因为它的启动速度没那么敏感,而且引用的框架组件多,AOT 适配成本高;控制台工具改成 AOT 发布,因为它是高频内部工具,追求体积小、秒开。这样一来,同一个解决方案里就可以同时存在两种发布策略。Web 项目的 csproj 关键片段:
<PropertyGroup> <SelfContained>true</SelfContained> <PublishSingleFile>true</PublishSingleFile> <PublishTrimmed>true</PublishTrimmed> </PropertyGroup>工具项目的 csproj 里则加<PublishAot>true</PublishAot>。注意 AOT 和 PublishTrimmed 不要同时写——AOT 内部已经包含裁剪逻辑,再开一次只会增加构建时长,还可能触发双重分析的误报。
6.3 改造后的发布命令与验证方法
改造之后的日常发布命令被简化成两条:
# Web API:输出到 artifacts/bin,自包含单文件 dotnet publish src/My.WebApi -c Release -r linux-x64 # 控制台工具:原生二进制 dotnet publish src/My.Tools -c Release -r linux-x64由于 Artifacts 布局存在,所有产物都在artifacts/bin下,CI 脚本可以直接写死这个相对路径,不用再按项目名去翻各层目录。
验证发布结果时,我习惯做三个检查:一看artifacts/bin下各项目产物是否完整;二在 Linux 上用file命令确认 Web 产物是 ELF、工具产物是原生可执行文件;三直接启动二进制跑一次健康检查接口,确认没有缺运行库。整个过程现在大概五分钟搞定,而改造前同样的流程要半小时以上,这还没算上依赖升级时那些提心吊胆的对时间的消耗。
7. 最后沉淀下来的几条经验
这个系列写到第三篇,我在不同项目里反复试用这四个方向,感触最深的一点是:构建发布的"革新"从来不是一个单独特性,而是一整套互相支撑的实践。Artifacts 布局让产物路径统一,统一路径让 CI 缓存简单,CI 缓存让构建变快,构建变快之后才有余力去尝试 AOT 这种重量级发布方式。它们之间是环环相扣的,单拎出来收益减半,组合起来才能看到质变。
再说几条根据我个人经验整理的落地建议。第一,如果不是多项目解决方案,单独一个项目先别急着迁移 CPM,收益没那么大,反而为它引入两个额外文件会增加理解成本。第二,AOT 在正式项目里推广前,一定要留一个验证窗口,拿真实依赖库跑一遍完整功能测试,因为很多反射行为在常规发布下一切正常,到了 AOT 才暴露出来。第三,Artifacts 布局最好在创建新项目时就开启,事后迁移虽然也能做,但要额外处理自定义脚本里的路径引用,成本高不少。
最后分享一个小技巧。如果你的 CI 是按解决方案整体构建的,可以把 NuGet 缓存目录和artifacts/obj一起放进缓存键,但缓存键里一定要拼上所有 csproj 文件的哈希。这样只有当项目文件真的变化时才重新解析 restore,代码改了而项目文件没变时可以直接享受完整缓存命中。这一条是我在把一套构建从二十多分钟压到十分钟以内时收益最大的一项改动。
工具型的革新还会继续来,但底层逻辑不会变:构建发布是为了让代码可靠地变成产物,而不是为了在技术上求新。把这套流程理顺,比追每一个新特性都值钱。