MSBuild 与 dotnet 工程手册:从项目求值到可解释构建
一个 .NET 项目在开发机上能编译,到了 CI 却提示找不到目标框架;同一条 dotnet build,一位同事只重建了一个项目,另一位却触发了整条项目链;把 bin、obj 删除后问题暂时消失,第二天又回来。这类问题表面上像缓存不稳定,根因通常是团队没有把 SDK 选择、项目求值和目标执行分开理解。
MSBuild 不是“编译器命令的别名”。它先读取项目及全部导入文件,把属性、项和目标合成为本次构建模型,再按依赖关系执行目标与任务。dotnet、Visual Studio 和 MSBuild.exe 都可能进入这台引擎,但入口、工具链、支持平台和默认参数并不完全相同。
先判断构建矛盾发生在哪一层
同一仓库出现“开发机成功、CI 失败”时,先把现场拆成三层:global.json 和工作负载决定选中了哪套 SDK;项目及导入文件经过 evaluation 形成属性、项与目标;MSBuild 再按依赖关系执行 target、task 与 project graph。SDK 选错、求值结果漂移和执行阶段共享输出是三类不同故障,不能都用删除 bin/、obj/ 处理。
SDK-style 跨平台工程优先统一到 dotnet 入口;依赖 Visual Studio 工作负载、传统项目系统或 Windows 专属工具链的工程,则必须把 Visual Studio/MSBuild 实例和构建镜像一起纳入契约。NuGet 在这条链路中负责依赖恢复并生成导入文件;包源治理、Central Package Management、锁文件和凭证提供器由 NuGet 工程规范承接。C# 语法、ASP.NET Core 业务逻辑和 Visual Studio 扩展不会改变上述构建分层,应在各自工程域处理。
动手取证前,先固定 SDK 与运行现场
准备一台非生产开发机、一个可删除的练习目录和访问批准软件源的网络。企业环境还应确认:
当前操作系统与 CPU 是否属于目标 .NET 版本支持的组合。HTTPS proxy、根 CA 和软件分发策略是否允许下载 SDK 与 NuGet 包。本机能否创建文件、监听测试端口并写入用户级 NuGet 缓存。
项目要求的是跨平台 .NET,还是必须由 Windows 与 Visual Studio 承载的 .NET Framework、桌面或 C++ 工程。
不要先从网页抄一个版本号。先查看仓库中的 global.json、CI 镜像和工程 TFM,再决定安装哪个受支持 SDK。版本与支持期以 .NET 支持策略和项目基线为准。
先分清四个入口
.NET SDK
.NET SDK 是开发工具包,包含 dotnet 主机、编译器、MSBuild、模板、目标包解析逻辑等。只安装 Runtime 可以运行已有应用,但不能完成常规 restore、build 和 test。
dotnet CLI
dotnet 是跨平台入口。dotnet build、dotnet test、dotnet publish 最终都会调用 SDK 随附的 MSBuild,并注入对应目标和属性。SDK-style 的跨平台项目优先使用它作为本机与 CI 的共同入口。
MSBuild
MSBuild 是项目求值与目标执行引擎。dotnet msbuild 使用当前选中的 .NET SDK;Visual Studio Developer Command Prompt 中的 MSBuild.exe 来自 Visual Studio 实例。二者版本与可用工作负载可能不同。
Visual Studio
Visual Studio 是 IDE 与工作负载宿主。它会调用自己的 MSBuild 实例,并提供设计时构建、调试器和 Windows 专属工具链。IDE 中成功不等于命令行成功,因为设计时环境可能额外提供工作负载、环境变量或用户配置。
对于 SDK-style 跨平台项目,优先让权威构建入口保持为 dotnet build。只有工程明确依赖 Visual Studio 工作负载、传统项目系统或 Windows 专属工具时,才把 MSBuild.exe 和对应构建镜像纳入契约。
安装 SDK 并确认命令来自哪里
Windows、macOS 和 Linux 的安装入口见 .NET 下载页与安装文档。开发机可以使用官方安装包或系统包管理器;CI 应使用固定镜像、官方 setup action 或受控安装脚本,并保留来源和摘要。
安装后不要只看 dotnet --version,应同时确认命令路径、全部 SDK 和 Runtime:
Get-Command dotnet | Format-List Source
dotnet --info
dotnet --list-sdks
dotnet --list-runtimesPOSIX 环境使用:
command -v dotnet
dotnet --info
dotnet --list-sdks
dotnet --list-runtimesdotnet --version 显示当前目录最终选中的 SDK,不一定是机器上最高版本。若输出提示找不到 SDK,说明可能只安装了 Runtime,或 PATH 指向了没有 SDK 的 dotnet 主机。
用 global.json 固定 SDK 选择
global.json 控制 dotnet 如何从已安装 SDK 中选版本。它从当前工作目录向上搜索,而不是固定从解决方案目录读取。一个常见基线是:
{
"sdk": {
"version": "10.0.300",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}示例版本应替换为团队批准版本。latestPatch 表示在同一 feature band 内接受补丁;如果 CI 只安装了其他 feature band,选择会失败。需要更宽松升级时可以使用 latestFeature,但必须把兼容测试与回滚条件写清楚。
生成文件时先查看已安装列表:
dotnet --list-sdks
dotnet new globaljson --sdk-version <approved-sdk> --roll-forward latestPatch
dotnet --version故障现场首先在仓库根目录和子目录分别运行 dotnet --version。结果不同,通常不是 SDK 随机变化,而是搜索到了不同层级的 global.json。
建立一个最小 SDK-style 项目
在空目录中执行:
mkdir msbuild-lab
cd msbuild-lab
dotnet new console --name BuildLab
dotnet new xunit --name BuildLab.Tests
dotnet new sln --name BuildLab
dotnet sln BuildLab.sln add BuildLab/BuildLab.csproj
dotnet sln BuildLab.sln add BuildLab.Tests/BuildLab.Tests.csproj
dotnet add BuildLab.Tests/BuildLab.Tests.csproj reference BuildLab/BuildLab.csproj然后验证:
dotnet restore BuildLab.sln
dotnet build BuildLab.sln --no-restore --configuration Release
dotnet test BuildLab.sln --no-build --configuration Release预期 restore 生成 obj/project.assets.json,build 在各项目的 bin/Release/<TFM>/ 生成产物,test 返回测试汇总。--no-restore 与 --no-build 不是提速魔法,它们声明前一步已成功且输入未变;缺少前置证据时不要使用。
SDK-style project 隐藏了哪些导入
最小项目可能只有几行:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Sdk="Microsoft.NET.Sdk" 会隐式导入 SDK 的 props 与 targets。短文件不代表构建模型简单;最终模型还包括 SDK 默认值、NuGet 生成的 .nuget.g.props/.targets、Directory.Build.* 和项目自定义导入。
要查看展开后的项目:
dotnet msbuild BuildLab/BuildLab.csproj -preprocess:expanded.xmlexpanded.xml 可能包含本机路径和内部包名,只用于诊断,不应直接上传公共工单。它能回答“这个属性最后从哪里来”和“目标到底何时被导入”。
求值与执行是两段不同的过程
MSBuild 先完成 evaluation,再进入 execution。
求值阶段读取 XML、导入文件、条件、属性与项,形成项目实例。执行阶段根据请求目标及其依赖图运行任务。很多“我在 target 中改了属性,为什么前面的 ItemGroup 没变化”的问题,正是把两个阶段混为一谈。
MSBuild 17.8 及以上可以直接读取求值结果:
dotnet msbuild BuildLab/BuildLab.csproj -getProperty:TargetFramework,OutputPath
dotnet msbuild BuildLab/BuildLab.csproj -getItem:Compile需要兼容旧工具链时,用预处理文件和详细日志建立同样证据,不要为了使用诊断参数临时升级发布构建机。
四个核心对象:Property、Item、Target、Task
Property 是标量配置:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>Item 是带元数据的文件或对象集合:
<ItemGroup>
<None Include="schema/*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>Target 是有名称、依赖和输入输出的工作单元;Task 是 target 内真正执行的动作:
<ItemGroup>
<ContractFile Include="schema/*.json" />
</ItemGroup>
<Target Name="CopyContracts"
BeforeTargets="BeforeCompile"
Inputs="@(ContractFile)"
Outputs="$(IntermediateOutputPath)contracts.stamp">
<MakeDir Directories="$(IntermediateOutputPath)contracts" />
<Copy SourceFiles="@(ContractFile)"
DestinationFiles="@(ContractFile->'$(IntermediateOutputPath)contracts/%(Filename)%(Extension)')" />
<Touch Files="$(IntermediateOutputPath)contracts.stamp" AlwaysCreate="true" />
</Target>自定义逻辑优先挂接 BeforeTargets、AfterTargets 或 DependsOnTargets,不要覆盖 SDK 内置 target。覆盖同名 target 会把实现绑死在导入顺序上,升级后极难解释。
Configuration、TFM 与 RID 是三条不同维度
Configuration 常见为 Debug/Release,控制优化、符号和条件属性。TFM 是 Target Framework Moniker,例如 net10.0,决定可用 API 与引用程序集。RID 是 Runtime Identifier,例如 linux-x64、win-x64,用于选择平台特定资产和发布目标。
dotnet build -c Release -f net10.0
dotnet publish -c Release -f net10.0 -r linux-x64 --self-contained false指定 RID 不等于目标机器必然可运行。原生库、系统依赖、容器基础镜像、glibc/musl 和 CPU 都要进入跨平台测试组合。不要在开发机只验证默认 RID 后,就把跨平台产物当作已验收。
多目标框架项目会先执行 outer build,再为每个 TFM 执行 inner build:
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>自定义 target 若没有考虑 $(TargetFramework) 为空的 outer build,可能重复执行或覆盖产物。
Restore、Build 与 Test 的真实链路
dotnet build 默认会先 restore。restore 解析 PackageReference,并在 obj/ 生成资产文件和 NuGet 的 props/targets;随后 MSBuild 才能用这些输入完成编译。
CI 推荐显式拆开,是为了让失败边界和网络权限清楚:
dotnet restore BuildLab.sln --locked-mode
dotnet build BuildLab.sln --no-restore -c Release
dotnet test BuildLab.sln --no-build -c Release --logger trx只有项目已经启用 NuGet 锁文件时才能使用 --locked-mode。恢复失败先排查 NuGet 源、凭证、CA 和资产图;编译失败再看 MSBuild 模型。不要用反复删除 obj 混淆两类问题。
Project Graph 与 solution 边界
ProjectReference 形成项目依赖图。MSBuild 按依赖关系调度,不按解决方案文件中的视觉顺序编译。
<ItemGroup>
<ProjectReference Include="../BuildLab/BuildLab.csproj" />
</ItemGroup>解决方案适合开发入口和项目集合,但不是依赖真值。缺失 ProjectReference、用文件路径偷取兄弟项目产物,会导致增量和并行构建失效。
诊断项目图可以生成结构化日志,或使用静态图构建参数:
dotnet msbuild BuildLab.sln -graphBuild -m启用前要确认项目引用能被静态求值。动态生成引用、在 target 执行阶段才修改项目图,会破坏图构建假设。
增量构建依赖 Inputs 与 Outputs
MSBuild 判断 target 是否需要执行,核心是输入与输出时间戳,而不是“上次运行成功”。输入没有声明完整,变更后可能错误跳过;输出不稳定,则每次都重建。
检查增量行为时连续运行两次构建:
dotnet clean BuildLab.sln -c Release
dotnet build BuildLab.sln -c Release -v:minimal
dotnet build BuildLab.sln -c Release -v:diag > second-build.log第二次日志应能解释哪些 target 被跳过。修改一个源文件后,只有受影响项目和下游需要重建。若整个仓库重建,检查全局时间戳文件、永远变化的生成代码、未声明输出和自定义 target。
Clean 不是构建正确性的必要前置。只有 clean build 才正确,说明增量契约已经损坏,应修 inputs/outputs,而不是让 CI 永久用全量清理掩盖问题。
用 Directory.Build.props 和 targets 管理仓库基线
放在目录中的 Directory.Build.props 会从项目目录向上查找并早期导入,适合默认属性:
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>Directory.Build.targets 导入更晚,适合 target 和晚期覆盖。Linux 文件系统区分大小写,文件名大小写错误会让本机 Windows 成功、Linux CI 静默忽略。
搜索遇到第一份文件后通常停止。大型仓库需要多层规则时,内层文件应显式导入外层文件,并用预处理结果验证导入顺序。不要在多个目录复制同一套规则,它们很快会漂移。
二进制日志是首选诊断证据
遇到模型、并行、增量或任务失败,先生成 binlog:
dotnet build BuildLab.sln -c Release -bl:artifacts/build.binlog二进制日志可以用 MSBuild Structured Log Viewer 分析求值、target 时间线、属性来源和任务参数。它比截取最后几十行控制台更适合复盘。
binlog 也可能包含绝对路径、内部项目名、包源、环境变量和任务参数。上传前要按敏感信息处理;凭证绝不能通过 /p:Password=... 进入命令行和日志。必要时在隔离仓库复现并替换内部名称。
其他常用证据:
dotnet msbuild BuildLab/BuildLab.csproj -t:Build -v:diag
dotnet msbuild BuildLab/BuildLab.csproj -getProperty:TargetFramework,RuntimeIdentifier
dotnet msbuild BuildLab/BuildLab.csproj -preprocess:expanded.xml并行构建与 node reuse
MSBuild 可用多个节点并行项目和 target。-m/-maxCpuCount 控制节点数;Visual Studio 场景还可能复用 worker 节点。并行能暴露写同一输出目录、固定端口测试和非线程安全自定义任务。
排查并行问题时建立串行对照:
dotnet msbuild BuildLab.sln -m:1 -nodeReuse:false -bl:serial.binlog
dotnet msbuild BuildLab.sln -m -nodeReuse:false -bl:parallel.binlog如果串行稳定、并行失败,修复共享状态和输出隔离。不要把 -m:1 永久当作架构方案。CI 短生命周期节点通常关闭 node reuse 更容易隔离;开发机是否复用则以启动成本和稳定性实测决定。
网络出口、CA、权限与凭证
SDK 下载、NuGet restore 和自定义任务可能走不同网络栈。企业环境至少验证三条链路:SDK 分发、包恢复、构建任务访问的外部服务。
dotnet --info
dotnet nuget list source
Get-ChildItem Env:HTTP_PROXY,Env:HTTPS_PROXY,Env:NO_PROXY -ErrorAction SilentlyContinue根 CA 应进入操作系统或运行环境的受控信任库。不要关闭 TLS 校验,也不要把网络出口账号写入项目文件。NuGet 凭证使用凭证提供器、受控用户配置或 CI secret 注入;普通验证 job 只给读取权限。
MSBuild 属性会进入日志,命令行也可能被进程列表和流水线记录。把 token 作为 /p: 参数传入通常是不安全的。构建任务需要身份时,优先使用短期工作负载身份,并让任务只接收临时文件路径或受保护环境变量名称。
CI:把构建事实固定下来
本机与 CI 应调用同一入口:
steps:
- name: Toolchain
run: |
dotnet --info
dotnet --version
- name: Restore
run: dotnet restore BuildLab.sln --locked-mode
- name: Build
run: dotnet build BuildLab.sln --no-restore -c Release -bl:artifacts/build.binlog
- name: Test
run: dotnet test BuildLab.sln --no-build -c Release --logger trx缓存键至少包含 OS、架构、SDK feature band 与锁文件摘要。不要缓存 bin/ 和 obj/ 作为跨提交真值;它们包含求值和平台状态。NuGet 缓存只加速恢复,删除后仍须从批准源重建。
交付证据至少包括 SDK 与 MSBuild 版本、global.json、restore 模式、测试报告、产物摘要和脱敏 binlog。发布与普通验证分离,签名、推送和生产凭证只在审批后的发布 job 中出现。
常见失败:按四段式定位
找不到兼容 SDK
A compatible .NET SDK was not found,开发机正常而 CI 失败。
运行 dotnet --list-sdks 和 dotnet --version,从当前目录向上查找 global.json,比较 rollForward 与 CI 已安装 feature band。
安装批准 SDK,或在兼容验证后调整 global.json。不要删除文件让机器任意选择最高 SDK。
开发机和 CI 在仓库根目录输出相同 SDK 基线,restore/build/test 全链通过。
属性看起来设置了却没有生效
项目文件中有属性,构建仍使用另一值。
用 -getProperty、预处理文件和 binlog 查最后赋值位置,检查 Condition、Configuration、TFM 与导入顺序。
把默认值放在合适的 props 层,把晚期覆盖放在 targets 或最终项目导入之后;删除重复来源。
不同 Configuration/TFM 下读取到预期值,binlog 能解释来源。
本地增量成功,干净构建失败
保留 obj 时成功,清理后缺文件或生成顺序错误。
查看自定义 target 的 Inputs、Outputs、BeforeTargets/AfterTargets 和项目引用,比较 clean 与 incremental binlog。
声明真实输入输出和 target 依赖,消除从旧目录偷取文件的逻辑。
干净构建、第二次增量构建和单文件变更构建都符合预期。
Linux CI 找不到规则或文件
Windows 成功,Linux 缺失属性、源码或资源。
检查 Directory.Build.props 大小写、路径分隔、文件名大小写、RID 和原生依赖。
统一仓库路径大小写,使用 MSBuild 路径函数,显式声明平台条件。
在获准支持的每个 OS/RID 组合上完成 restore/build/test,不只检查编译退出码。
binlog 显示 target 反复执行
第二次无变更构建仍耗时很长。
定位未跳过 target,比较输入输出时间戳,检查生成文件是否每次改写、输出是否被其他 target 删除。
让生成器在内容未变时不写文件,补齐 Inputs/Outputs,隔离不同 TFM/RID 输出。
第二次构建跳过预期 target,修改单个输入只触发必要下游。
升级、迁移与回滚
升级前保存基线:
dotnet --info > artifacts/dotnet-before.txt
dotnet restore BuildLab.sln --locked-mode
dotnet build BuildLab.sln -c Release -bl:artifacts/before.binlog
dotnet test BuildLab.sln -c Release --no-build升级通常涉及 SDK、TFM、NuGet 包、工作负载和 CI 镜像,不应一次混改。先升级 SDK 并保持 TFM,再升级目标框架;比较警告、求值结果、依赖资产、测试、产物大小、启动行为和跨平台结果。
回滚必须恢复 global.json、项目文件、Directory.Build.*、锁文件与 CI 镜像的同一提交。只在开发机卸载新 SDK 不构成回滚,因为其他节点仍可能选择不同版本。
传统项目迁移到 SDK-style 时,先记录原有 imports、生成步骤、签名和发布输出,再逐项替换。不要把旧 target 全部复制到新项目;很多职责已由 SDK 默认目标承担,重复导入会制造双重执行。
SDK 漂移会改变构建,即使 TFM 没变
同一 TFM 可以由不同 SDK 构建,编译器、默认 target、分析器和发布逻辑仍可能变化。判断标准是 dotnet --info、MSBuild 版本和产物差异,不是只看 <TargetFramework>。团队要锁 SDK 选择并定期更新补丁,不能长期冻结到失去安全支持。
隐式导入是效率来源,也是变更传播面
SDK、NuGet 包和 Directory.Build.targets 都能注入 target。一个基础包升级可能把构建逻辑传递给所有消费者。包与仓库级 targets 都应有 owner、变更审查和可回滚版本;发现未知 target 先查导入链,不要在项目尾部用同名覆盖压住。
binlog 是高价值证据,也是敏感资产
它能解释几乎整个构建,但也可能暴露路径、项目拓扑和环境变量。内部工单可按最小范围存储并设置保留期;跨边界共享应在隔离样例复现。凭证一旦进入日志,处理方式是撤销和轮换,不是只删附件。
并行与多目标框架会放大输出冲突
若多个 TFM、RID 或项目写同一文件,串行时可能偶然成功,并行后出现损坏。输出目录必须包含足够维度,生成器必须声明输入输出并支持并发。判断标准是并行与串行产物一致,而不是关闭并行后不报错。
Visual Studio 成功不能替代命令行契约
IDE 可能自动 restore、使用用户级凭证、加载本机工作负载并执行设计时构建。权威入口必须在干净 shell 和 CI 可运行。只有 IDE 才能成功时,先把隐式工作负载和配置找出来,再决定是否正式纳入构建镜像。
清缓存只能建立对照,不能成为常规修复
删除 bin/obj 或全局 NuGet 缓存会让故障暂时消失,也会抹掉定位证据。先保留 binlog、资产文件和版本事实,在隔离目录建立干净对照。最终修复必须落在依赖锁定、输入输出、源配置或工具链上。
跨平台不是“能编译”就完成
TFM 与 RID 只表达构建目标的一部分。原生依赖、证书库、文件大小写、时区、区域设置和容器基础镜像都可能改变行为。团队的覆盖组合要从实际部署平台倒推,并设置停止条件:没有目标运行环境验证的 RID 产物,不进入发布。
团队落地清单
仓库有 global.json 或等价的 SDK 固定策略,开发机与 CI 选择结果可解释。已区分 .NET SDK、dotnet CLI、MSBuild.exe 与 Visual Studio 工作负载边界。SDK-style 项目的隐式 imports、NuGet 生成 imports 和仓库级规则有 owner。
restore、build、test 在本机与 CI 使用同一入口,跳步参数有明确前置证据。Configuration、TFM、RID 和多目标框架输出目录互不污染。自定义 target 声明了依赖与增量 Inputs/Outputs,没有覆盖 SDK 内置 target。
ProjectReference 表达真实项目依赖,solution 文件不被当作依赖图。网络出口、CA、NuGet 凭证和构建任务身份使用最小权限,不进入命令行参数。binlog 有脱敏、访问控制和保留策略,失败时先保留证据再清理。
CI 缓存只用于加速,空缓存能从批准源完成 restore/build/test。SDK、TFM、包和 CI 镜像分步升级,并保留成组回滚提交。支持范围覆盖实际 OS、CPU、RID 与工作负载组合,不把未运行产物直接发布。
延伸阅读
.NET 下载与安装。.NET 与 .NET Core 支持策略。global.json 概览
.NET SDK、MSBuild 与 Visual Studio 版本关系。MSBuild 概念与 XML 架构
