系列写到第三篇,前面的铺垫就不重复了,直接进入正题。今天聊的不是某个具体命令,也不是某个新参数怎么调优,而是我一直觉得最容易被忽视的一层:那条把构建、发布各个环节真正“推着往前走”的引擎。我习惯叫它“泵”。
泵这个词,听上去有点像玄学,其实不是。你在命令行敲下dotnet publish之后,从还原 NuGet 包、编译程序集,到生成 deps.json、runtimeconfig.json,再到把文件复制到 publish 目录,每一步都由同一个机制来调度。它会判断哪些步骤这次可以跳过,哪些必须重跑;哪些任务可以并行,哪些必须等着前面的依赖完成。这套机制平时藏在 SDK 和你的 .csproj 背后,大多数开发者甚至从未主动查看过它。
这篇内容适合两类人。一类是正在搭 CI/CD 流水线、对构建发布速度不满意的开发者——读完你会明确知道问题出在哪个环节;另一类是已经开始用自定义 Target 改打包流程、但遇到“改了没生效”“发布结果不稳定”的人——读完你会找到排查思路。
1. “泵”到底管什么:一条流水线里最难看见的引擎
1.1 构建和发布不是一回事——但共用同一台泵
先纠正一个常见误解。dotnet build和dotnet publish从使用者视角看,像是两个独立命令;实际上它们共享同一个 MSBuild 引擎。Build 内部负责还原、解析引用、编译、把输出文件复制到 bin 目录;Publish 则在 Build 之上叠加另一组目标,比如生成 deps.json、runtimeconfig.json、计算需要发布到目标目录的文件集合等。它们更像是同一台泵在不同档位下的出口,而不是两台完全分开的机器。
为什么我坚持用“泵”这个比喻?因为构建发布本质上是个流体过程:依赖流过还原阶段,源码流过编译阶段,产物涌向输出目录。凡是流体,就有压力、有阀门、有堵点。MSBuild 作为这台泵,不仅提供动力,还负责调节节奏——它决定哪个目标要执行、哪个目标可以跳过、哪些任务能并行、哪些任务必须排队。
理解了这一层,很多 CLI 参数的反直觉表现就有了解释。比如你给dotnet publish加了--no-restore,不是因为它“不需要还原”,而是因为你明确告诉泵:还原这部分的压力我已经自己处理过了。又比如你只改了.csproj里一个和发布流程无关的 Property,发布结果纹丝不动,这不是没生效,而是该 Property 根本不在这个泵的驱动链上。
1.2 泵要处理的活儿,其实就三条主线
第一条是还原线。Restore 把项目引用、PackageReference、框架引用解析成一组确定的资产,写入obj/project.assets.json。这条线决定了你编译时到底看到的是哪个版本的包,也是“软件供应链”在构建期落地的关键点。很多 CI 第一次构建慢,就慢在这条线没有合理利用缓存。
第二条是编译线。ResolveReferences、Compile、CopyFilesToOutputDirectory 这些目标依次运转,把源代码变成程序集和中间输出。这条线大家最熟悉,因为它的耗时通常最长。但很多人不知道,它也在被增量判断控制着——输入没变时,编译目标会直接跳过,而不是把所有文件重新编译一遍。
第三条是发布线。ComputeFilesToPublish、GeneratePublishDependencyFile、CopyFilesToPublishDirectory 这些目标会决定最终发布目录里有什么、以什么结构存在。这条线离开发者最远,却直接决定交付物的长相。我见过不少团队产物乱、缺文件、多文件,根因都在这条线上。
三条线由同一个依赖图驱动。SDK 每个小版本的更新,很大一部分工作就是微调这三条线之间的连接方式——让某些步骤提前、让某些检查后移、让某些文件不再被重复拷贝。这也是为什么同一套代码在不同 SDK 版本下发布产物会有细微差异。
2. 泵的齿轮组:目标依赖图与发布 Target 的真实串联方式
2.1 dotnet publish 的展开逻辑比想象中复杂
很多人以为dotnet publish就是“编译完之后复制文件”,实际远不是这样。MSBuild 并不是从上到下顺序执行一个“publish 脚本”,而是展开一个目标依赖图。关键设计在于,目标之间通过DependsOnTargets声明依赖关系,每个目标还可以通过Inputs和Outputs做增量判断。
大致展开顺序是这样:还原 → 编译 → 发布前准备 → 生成发布配置和依赖清单 → 计算要复制的文件集合 → 复制文件 → 后处理。如果你开了 AOT 或单文件发布,还会插入本机编译、链接器、打包等多个阶段。看到这里你应该明白了:Publish本身不是一个动作,而是一系列目标和它们之间依赖关系的集合名称。
实际修改.csproj时,你会看到PublishTrimmed、PublishReadyToRun、PublishSingleFile这些 Property。它们不是简单开关,而是会在目标依赖图的某个节点改变分支流向。比如开了PublishSingleFile后,原本“复制所有文件”的目标会让位于“把内容打包进单文件”的目标;开了PublishAot后,管线会额外引入本机编译链路。
2.2 想定制这台泵,先找对挂点
很多团队会在Directory.Build.targets里加自定义目标。最常见的需求:发布后自动把一组额外文件放进发布目录;发布前生成版本号文件;发布前清理某个目录。基本挂法就是利用BeforeTargets和AfterTargets:
<Target Name="AppendLicenseIntoPublish" AfterTargets="Publish"> <ItemGroup> <_LicenseFile Include="$(MSBuildProjectDirectory)/LICENSE.txt" /> </ItemGroup> <Copy SourceFiles="@(_LicenseFile)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" /> </Target>但这里有个很多人踩过的坑:不是所有输出文件都会在$(PublishDir)下出现,因为发布可能执行“单文件打包”这类转换。真正稳当的做法是把自己要追加的文件放进一个 ItemGroup,并通过CopyToPublishDirectory声明,让泵在合适的阶段自动处理它:
<ItemGroup> <Content Include="config/runtime.json" CopyToPublishDirectory="PreserveNewest" /> </ItemGroup>我自己在维护一个小型内部组件库时,曾经用AfterTargets="Publish"手动复制运行时文件,本地一切正常。后来改成在同一个机器上跑多个发布任务,结果出现重复复制和顺序冲突。换回CopyToPublishDirectory机制后,问题消失。结论很简单:优先使用 SDK 暴露的公开挂点,别在管道末端自己硬蹬一脚。
如果你想自定义更复杂的逻辑,可以挂BeforeTargets="PrepareForPublish"或AfterTargets="ComputeFilesToPublish"。前者适合在发布文件集合计算之前做准备工作,后者适合在你需要修改“将要被发布的文件列表”的时候介入。记住一个原则:改动文件集合,别等到“复制完成”之后才动手。
3. 泵的润滑系统:增量判断、缓存和并行调度
3.1 增量构建不靠感觉,靠输入输出
MSBuild 每个目标都可以声明Inputs和Outputs,泵据此做增量判断:如果所有输出都比最新输入新,这个目标就可以跳过。这套机制在 .NET SDK 里被用得非常深。比如还原阶段会写project.assets.json,下次执行时如果引用没变、相关属性没变,它就直接走“无操作”,不产生网络请求。
但增量判断只在目标“允许”它发生的时候才生效。你显式执行-t:Rebuild、或者改了会改变输出路径的属性,等于主动跳过增量判断。所以遇到“改了代码没生效”的诡异问题时,先瞄一眼目标和输入输出,比直接删 obj 目录强得多。
我之前在某内部模拟项目中遇到过一个典型场景:同事每次改完代码都要手动清理 bin 和 obj 才能正常发布,因为他在一个跨平台 CI 步骤里用了大小写敏感的路径拼接,导致输出路径在 Windows 和 Linux 上产生不同结果,增量判断彻底失效。修正路径处理方式后,他再也不用带着“清理一下再跑”的习惯做事了。
3.2 中间产物和缓存,应该认到文件级
obj 目录里那堆文件不是垃圾,它们是泵的记忆体。project.assets.json是还原结果;FileListAbsolute.txt记录了文件清单;CoreCompileInputs.cache保存了编译输入缓存。理解这些文件的作用,能在排查很多问题时省下大量时间。
继续说缓存。CI 还原慢很多时候不是因为网速,而是因为缓存没打中。NuGet 缓存是全局的,但如果 CI 每次用一个全新的容器跑,没有挂载缓存目录,就等于每次都做冷启动还原。解决办法是在 CI 里把 NuGet 的 packages 目录或全局缓存挂载为持久卷,同时用锁文件锁定版本。这样既不牺牲版本确定性,又能让第二次开始的构建明显变快。
3.3 并行组装与节点复用,是一把双刃剑
MSBuild 默认会启动多个节点并行构建,还会复用长期存活的节点进程来加速后续构建。对小项目这没什么感知,但对大型解决方案,并行和节点复用能节省不少时间。
但并行也有代价。最常见的坑是多个项目往同一个输出目录写文件,产生真实冲突。如果项目之间存在“复制本地”的依赖链,这种情况更明显。另一个隐患是节点进程长时间驻留,内存和句柄占用逐渐升高。CI 上我会根据环境限制并行度,比如用/m:1或设置合适的节点数量,避免为了争几分钟把整个构建稳定性搭进去。
4. 泵的压力阀:发布模式和产物形态的控制点
4.1 框架依赖、自包含、AOT 之间怎么选
发布形态的选择本质上是在给泵设定输出路线。我整理了一张常用对比表,方便按团队实际情况判断:
| 模式 | 产物特点 | 部署要求 | 典型适用场景 |
|---|---|---|---|
| 框架依赖 | 体积小,不含运行时 | 目标机器已安装对应 .NET 运行时 | 部署环境可控、已有运行时基础 |
| 自包含 | 体积大,含完整运行时 | 目标机器无需额外运行时 | 环境不可控、长期稳定部署 |
| AOT 发布 | 启动快,可裁剪,体积差异大 | 需要本机工具链,局部功能受限 | 冷启动敏感、嵌入式边缘场景 |
选型没有绝对标准。如果团队对目标机器环境有强控制力,框架依赖最省事;如果不想操心运行时版本匹配,自包含更省心;如果对启动延迟和资源占用有硬指标,那才值得研究 AOT。
AOT 不是简单的“换个发布方式”。它会强制启用裁剪,合入本机编译链路,还可能影响反射、动态加载等功能。我把 AOT 测试套件单独放在一个模拟项目里做兼容性验证,项目跑通后再决定是否全面铺开,这样才能把泵的压力阀调到合适的档位。
4.2 发布目录的清理策略与“脏文件”管理
发布目录里出现上一次的残留,是我见过频率最高的问题之一。假设发布目录不是干净目录,旧文件和新文件混在一起,可能到运行阶段才暴露“版本冲突”或“类找不到”这类诡异问题。
默认dotnet publish会清理发布目标目录中的“来自上一次发布的内容”,但不会清理你手动放进去的第三方文件,也不会处理隐藏文件。更需要注意的是,如果你在 CI 里用$(PublishDir)$(RuntimeIdentifier)这种方式切换输出路径,每次切换后旧目录是真实存在的,不会自动消失。某个模拟项目中,我让每次提交发布到带 commit hash 的独立目录,从根上规避了共享中转目录的残留问题。
工具链会把“发布输出目录”当成临时目录,别拿它当长期缓存。如果一定要长期保留,就在流水线里显式管理清理。将发布目录的“干净状态”视为一个需要维护的工程条件,而不是默认属性。
5. 泵的异响:发布管道常见故障的定位方法
5.1 用二进制日志做第一次解剖
无论遇到构建还是发布问题,第一步永远是生成二进制日志:
dotnet publish -bl:build.binlog然后用开源 binlog 查看工具打开。这种日志把目标依赖图完整记录下来,你可以逐个目标看耗时,看它到底执行了还是跳过了。里面还能展开属性、环境变量、项目集合,几乎能回答所有“到底发生了什么”的疑问。
我最常用的方法是看目标节点的状态:目标显示“已跳过”,说明编译输入没有变化;目标显示“已执行”,就继续往下追触发条件。很多人困惑“我明明改了文件为什么没生效”,在 binlog 里通常几十秒就能找到答案——因为某个中间步骤已经被增量判断吃掉了。配套的详细日志还有-v:d、-v:diag,但日常诊断我优先用 binlog,因为它能把结构化信息留下来,方便反复分析。
5.2 三组高频问题的快速判断
第一类:发布比构建慢得多。直接看 Publish 阶段的复制目标,是否把大量文件做逐文件校验复制。如果可用,通过文件集合的筛选条件减少不必要的复制范围。第二类:改了.csproj属性,发布结果没变。检查该属性是否在目标依赖图的驱动链上,如果它只影响编译阶段的某个输入,而编译阶段又因为增量判断被跳过,那结果不变很正常。第三类:CI 首次还原特别慢。优先检查包缓存是否命中,而不是单纯怪网络。把缓存挂载起来,再用锁文件保证版本固定。
AOT 发布失败集中在“链接器”步骤时,不要急着关裁剪,先确认是否需要通过TrimmerRootAssembly保留某些程序集。单文件发布后提示找不到某个 dll,则检查单文件打包包含与排除规则是否合理。这些切入点比“逐个搜索带红叉的报错”要系统得多。
6. 把泵调稳之后,构建发布这件事发生了什么变化
6.1 我看到的变化不是“变快”这么简单
说实话,把这套机制理清楚之后,最大的变化不是构建时间从 X 分钟变成 Y 秒,而是不稳定的步骤变少了。以前出了问题,大家习惯性地清 obj、清 bin、换个目录重新发布,反复折腾几轮也未必找到根因。现在遇到异常,直接生成 binlog,顺着目标依赖图查一遍,哪个环节没走对、哪个文件被增量判断误跳过,基本能在半小时内定位。
在一个内部模拟项目中,我们曾经被“清空 obj 后稳定,不清就随机挂”的问题困扰了很久。排查后发现问题出在一个自定义目标上:它对中间输出路径做了大小写敏感拼接,在 Windows 容器和 Linux 容器上表现不一致,导致增量判断的输入输出集合时好时坏。修正之后,团队终于不用再靠“清理一下再跑”这种玄学流程兜底了。
6.2 落地时我会盯的三个检查点
第一,所有自定义 Target 尽可能补齐Inputs和Outputs,否则它会变成不可增量的常驻成本,每次构建都白白多跑一段。第二,优先用 SDK 公开的挂点机制,比如CopyToPublishDirectory、PrepareForPublish、ComputeFilesToPublish,而不是在AfterTargets里做所有补充逻辑。第三,发布目录必须当作临时目录来管理和清理,别让它成为隐性的状态缓存。
最后再分享一个小技巧:如果你在排查过程中发现某个 Property 改了但发布产物没变化,先用dotnet msbuild project.csproj -getProperty:属性名确认它在当时的构建上下文里到底是多少。很多时候不是“没生效”,而是某个前置目标重新覆盖了它。理解了这台泵是怎么运转的,你就能一眼看出问题是出在齿轮上、阀门上,还是润滑系统上。