关于 dotnet-msbuild-authoring
面向 SDK 风格 .NET 项目的 MSBuild 项目配置编写技能。内容包括目标执行顺序、增量构建、导入顺序、项与条件,以及进阶 `Directory.Build` 模式。
主要功能
- 01自定义目标执行顺序
- 02通过输入和输出实现增量构建
- 03Props 与 targets 的导入顺序
- 04项、元数据与批处理
- 05条件和属性函数
- 06进阶 Directory.Build 模式
适用场景
- 01在构建步骤前后添加目标
- 02避免不必要的重复构建
- 03定制共享 props 和 targets
- 04按项应用构建行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-msbuild-authoring安装后在 Claude Code 中输入/dotnet-msbuild-authoring确认已加载。
详细介绍
dotnet-msbuild-authoring
MSBuild 项目系统元素的编写指南:自定义目标(使用 BeforeTargets/AfterTargets/DependsOnTargets)、使用 Inputs/Outputs 实现增量构建、props 与 targets 的导入顺序、项和项元数据(Include/Exclude/Update/Remove)、条件、属性函数、常用元数据,以及高级 Directory.Build.props/Directory.Build.targets 模式。
版本假设: .NET 8.0+ SDK(MSBuild 17.8+)。所有示例均使用 SDK 风格项目。
范围边界: 此技能涵盖 MSBuild 编写基础——自定义目标、导入顺序、项、条件、属性函数和高级 Directory.Build 模式。基本解决方案布局和共享配置(Directory.Build.props 结构、CPM、.editorconfig)由 [skill:dotnet-project-structure] 负责。MSBuild 错误解读和 CI 偏差诊断由 [skill:dotnet-build-analysis] 负责。
交叉引用:[skill:dotnet-project-structure] 介绍解决方案布局和基本 Directory.Build.props 结构;[skill:dotnet-build-analysis] 介绍如何解读 MSBuild 错误和 CI 偏差。
自定义目标
目标是 MSBuild 中的执行单元。每个目标都会运行一系列任务,并可以声明与其他目标的执行顺序关系。
定义自定义目标
<Target Name="PrintBuildInfo"
BeforeTargets="Build">
<Message Importance="high"
Text="Building $(MSBuildProjectName) v$(Version) for $(TargetFramework)" />
</Target>
执行顺序:BeforeTargets、AfterTargets、DependsOnTargets
有三种机制可以控制目标的执行顺序:
| 机制 | 效果 | 使用场景 |
|---|---|---|
BeforeTargets="X" | 在 X 之前运行此目标 | 插入现有管线(例如,在 Build 之前运行) |
AfterTargets="X" | 在 X 之后运行此目标 | 后处理(例如,在 Publish 之后复制输出) |
DependsOnTargets="A;B" | 确保在此目标之前运行 A 和 B | 在自定义目标图中声明前置目标 |
<Target Name="CheckLicenseHeaders"
BeforeTargets="CoreCompile">
<Exec Command="dotnet tool run license-check -- --verify" />
</Target>
<Target Name="CopyNativeLibs"
AfterTargets="Publish">
<Copy SourceFiles="@(NativeLibrary)"
DestinationFolder="$(PublishDir)runtimes/%(NativeLibrary.RuntimeIdentifier)/native/" />
</Target>
<Target Name="FullValidation"
DependsOnTargets="CheckLicenseHeaders;RunApiCompat">
<Message Importance="high" Text="All validations passed." />
</Target>
向标准构建管线中插入逻辑时,优先使用 BeforeTargets/AfterTargets,而非 DependsOnTargets。DependsOnTargets 最适合编排自定义目标图。
扩展现有 DependsOn 列表
SDK 目标会公开 *DependsOn 属性供扩展。应追加目标名称,而不是替换列表:
<PropertyGroup>
<BuildDependsOn>$(BuildDependsOn);GenerateVersionInfo</BuildDependsOn>
</PropertyGroup>
<Target Name="GenerateVersionInfo">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(InformationalVersion)")]"
Overwrite="true" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)Version.g.cs" />
</ItemGroup>
</Target>
使用 Inputs/Outputs 实现增量构建
只有在输出缺失或比输入旧时,带有 Inputs 和 Outputs 的目标才会运行。这对提升构建性能至关重要。
<Target Name="GenerateEmbeddedResources"
BeforeTargets="CoreCompile"
Inputs="@(EmbeddedTemplate)"
Outputs="@(EmbeddedTemplate->'$(IntermediateOutputPath)%(Filename).g.cs')">
<Exec Command="dotnet tool run template-gen -- %(EmbeddedTemplate.Identity) -o $(IntermediateOutputPath)%(EmbeddedTemplate.Filename).g.cs" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)%(EmbeddedTemplate.Filename).g.cs" />
</ItemGroup>
</Target>
增量构建的工作原理:
- MSBuild 会比较
Inputs项与Outputs项的时间戳。 - 如果所有输出都存在,且都比所有输入新,则完全跳过该目标。
- 如果任意输入比任意输出新,则运行整个目标。
增量构建常见故障:
- 缺少
Outputs: 目标会在每次构建时运行。务必将Inputs与Outputs配对使用。 - 易变输出: 如果另一个目标在构建过程中写入输出路径,时间戳会重置,从而触发不必要的重新构建。
- 生成器副作用: 即使内容未变也无条件写入的代码生成器会破坏增量构建。应先写入临时文件,仅在内容不同时再复制。
- 文件复制时间戳:
Copy任务使用SkipUnchangedFiles="true"时会保留时间戳;不使用时,每次复制都会更新时间戳。
Props 与 Targets:导入顺序
MSBuild 会按特定顺序评估项目文件。理解此顺序对于正确自定义项目至关重要。
评估顺序
1. Directory.Build.props (imported by SDK early)
2. <Project Sdk="..."> (SDK props imported)
3. Explicit <Import> in project (your .props imports)
4. Project body <PropertyGroup>, (project-level properties)
<ItemGroup>
5. SDK targets imported (SDK targets)
6. Directory.Build.targets (imported by SDK late)
7. Explicit .targets imports (your .targets imports)
规则
.props文件设置属性默认值并定义项。它们在项目正文之前运行,因此项目级属性可以覆盖它们。.targets文件定义目标并最终确定项列表。它们在项目正文之后运行,因此可以获取所有项目级设置。
<Project>
<PropertyGroup>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
</PropertyGroup>
</Project>
<Project>
<Target Name="ValidatePackageMetadata"
BeforeTargets="Pack"
Condition="'$(IsPackable)' == 'true'">
<Error Condition="'$(Description)' == ''"
Text="Description is required for packable projects." />
</Target>
</Project>
关键规则: .props 文件中的属性应使用 Condition="'$(Prop)' == ''",以允许项目级设置覆盖它们。.targets 文件中的属性最后求值,因此无法被项目覆盖。
项和项元数据
项是文件或值的命名集合。每个项都可以携带元数据(键值对)。
项操作
<ItemGroup>
<Content Include="assets/**/*.png" />
<Compile Include="**/*.cs" Exclude="**/*.generated.cs" />
<Content Update="assets/logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>contentFiles/any/any/</PackagePath>
</Content>
<Compile Remove="legacy/**/*.cs" />
</ItemGroup>
SDK 风格的项目会自动包含 *.cs 文件。 不要添加 <Compile Include="**/*.cs" />——这会导致 NETSDK1022 重复项。对于条件编译场景,请先使用 Remove,再使用 Include:
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<Compile Remove="Polyfills/**/*.cs" />
</ItemGroup>
常用元数据
每个项都有可通过 %(ItemName.MetadataName) 访问的内置元数据:
| 元数据 | 值 | src/Models/Order.cs 示例 |
|---|---|---|
%(FullPath) | 绝对路径 | /repo/src/Models/Order.cs |
%(RootDir) | 根目录 | / |
%(Directory) | 相对于根目录的目录 | repo/src/Models/ |
%(Filename) | 不含扩展名的文件名 | Order |
%(Extension) | 文件扩展名 | .cs |
%(RecursiveDir) | glob 中由 ** 匹配的部分 | Models/(如果 glob 为 src/**/*.cs) |
%(Identity) | 声明时的项规范 | src/Models/Order.cs |
项元数据与批处理
自定义元数据可通过 MSBuild 批处理实现逐项行为:
<ItemGroup>
<DbMigration Include="migrations/*.sql">
<TargetDb>main</TargetDb>
</DbMigration>
<DbMigration Include="migrations/audit/*.sql">
<TargetDb>audit</TargetDb>
</DbMigration>
</ItemGroup>
<Target Name="RunMigrations">
<Exec Command="sqlcmd -d %(DbMigration.TargetDb) -i %(DbMigration.Identity)"
Condition="'@(DbMigration)' != ''" />
</Target>
任务属性中的 %(Metadata) 会触发批处理。MSBuild 会按元数据值对项分组,并为每个组调用一次任务。
条件
条件用于控制是否评估属性、项、目标和任务。
属性条件
<PropertyGroup>
<LangVersion Condition="'$(LangVersion)' == ''">latest</LangVersion>
<DefineConstants Condition="$(TargetFramework.StartsWith('net8'))">$(DefineConstants);NET8_OR_GREATER</DefineConstants>
<Optimize Condition="'$(Configuration)' == 'Release'">true</Optimize>
<RuntimeIdentifier Condition="$([MSBuild]::IsOSPlatform('Windows'))">win-x64</RuntimeIdentifier>
</PropertyGroup>
项和目标条件
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
<Target Name="SignAssembly"
AfterTargets="Build"
Condition="'$(Configuration)' == 'Release' AND '$(SignAssembly)' == 'true'">
<Exec Command="signtool sign /fd SHA256 $(TargetPath)" />
</Target>
条件运算符
| 运算符 | 示例 |
|---|---|
== / != | '$(Config)' == 'Release' |
AND / OR | '$(A)' == '1' AND '$(B)' != '' |
!(取反) | !Exists('$(OutDir)') |
Exists() | Exists('$(SolutionDir)global.json') |
HasTrailingSlash() | HasTrailingSlash('$(OutputPath)') |
比较两侧始终使用单引号。 '$(Prop)' == 'value' 才是正确写法。如果属性为空,不带引号的比较会失败。
属性函数
MSBuild 属性可以内联调用 .NET 静态方法和 MSBuild 内置函数。
.NET 静态方法调用
<PropertyGroup>
<NormalizedName>$([System.String]::Copy('$(PackageId)').ToLowerInvariant())</NormalizedName>
<ToolPath>$([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', 'tools', 'analyzer.dll'))</ToolPath>
<BuildId>$([System.Guid]::NewGuid().ToString('N'))</BuildId>
<CleanVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '-.*$', ''))</CleanVersion>
<CiServer>$([System.Environment]::GetEnvironmentVariable('CI'))</CiServer>
</PropertyGroup>
MSBuild 内置函数
<PropertyGroup>
<IsWindows>$([MSBuild]::IsOSPlatform('Windows'))</IsWindows>
<IsLinux>$([MSBuild]::IsOSPlatform('Linux'))</IsLinux>
<IsMacOS>$([MSBuild]::IsOSPlatform('OSX'))</IsMacOS>
<NextVersion>$([MSBuild]::Add($(PatchVersion), 1))</NextVersion>
<HasModernSdk>$([MSBuild]::VersionGreaterThanOrEquals('$(NETCoreSdkVersion)', '8.0.100'))</HasModernSdk>
<InputHash>$([MSBuild]::StableStringHash('$(InputFile)'))</InputHash>
<SharedPropsPath>$([MSBuild]::GetPathOfFileAbove('SharedConfig.props', '$(MSBuildProjectDirectory)'))</SharedPropsPath>
<SafePath>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '..', 'shared'))</SafePath>
</PropertyGroup>
常用 MSBuild 属性
| 属性 | 值 |
|---|---|
$(MSBuildProjectDirectory) | 当前 .csproj 所在目录 |
$(MSBuildThisFileDirectory) | 当前 .props/.targets 文件所在目录 |
$(MSBuildProjectName) | 不含扩展名的项目名称 |
$(IntermediateOutputPath) | obj/ 输出路径 |
$(OutputPath) | bin/ 输出路径 |
$(TargetFramework) | 当前 TFM(例如 net10.0) |
$(TargetFrameworks) | 多 TFM 列表(例如 net8.0;net10.0) |
$(Configuration) | Debug 或 Release |
$(SolutionDir) | 解决方案目录(仅在从解决方案生成时设置) |
在 .props/.targets 文件中使用 $(MSBuildThisFileDirectory),而不是 $(MSBuildProjectDirectory)。前者解析为文件自身所在位置;当文件从 NuGet 包或其他目录导入时,这才是正确位置。
Directory.Build.props/targets 高级模式
[skill:dotnet-project-structure] 介绍了基本的 Directory.Build 布局。本节介绍适用于多仓库和单体仓库场景的高级模式。
使用 GetPathOfFileAbove 链接导入链
在包含嵌套目录的单体仓库中,每一层都可以定义自己的 Directory.Build.props,并链接到父级:
repo/
Directory.Build.props (repo-wide defaults)
src/
Directory.Build.props (src-specific overrides)
MyApp/
MyApp.csproj
tests/
Directory.Build.props (test-specific overrides)
MyApp.Tests/
MyApp.Tests.csproj
<Project>
<PropertyGroup>
<_ParentBuildProps>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))</_ParentBuildProps>
</PropertyGroup>
<Import Project="$(_ParentBuildProps)" Condition="'$(_ParentBuildProps)' != ''" />
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
<Project>
<PropertyGroup>
<_ParentBuildProps>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))</_ParentBuildProps>
</PropertyGroup>
<Import Project="$(_ParentBuildProps)" Condition="'$(_ParentBuildProps)' != ''" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>
条件保护
防止属性值被子级导入覆盖:
<Project>
<PropertyGroup>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
</PropertyGroup>
</Project>
规则: .props 文件中的属性应使用 Condition="'$(Prop)' == ''" 保护条件,以便内部 .props 文件和项目级属性可以覆盖它们。需要无条件强制执行的属性应放在 Directory.Build.targets 中(该文件最后评估)。
防止重复导入
当多个 Directory.Build.props 文件逐级向上链接时,共享导入可能会被评估两次。使用哨兵属性来防止这种情况:
<Project>
<PropertyGroup Condition="'$(_CommonPropsImported)' != 'true'">
<_CommonPropsImported>true</_CommonPropsImported>
<Authors>My Company</Authors>
<Company>My Company</Company>
<Copyright>Copyright (c) My Company. All rights reserved.</Copyright>
</PropertyGroup>
</Project>
哨兵属性和内容属性必须位于带有 != 'true' 条件的同一个 PropertyGroup 中。将内容放在带有 == 'true' 条件的单独块中并不能防止重复评估——每次导入时它都会运行,因为哨兵属性已经设置。
更简洁的做法是在 <Import> 元素上使用 Condition:
<Import Project="$(SharedPropsPath)"
Condition="'$(_CommonPropsImported)' != 'true' AND Exists('$(SharedPropsPath)')" />
在 Directory.Build.targets 中强制应用设置
.targets 文件中设置的属性无法被项目级 PropertyGroup 元素覆盖,因为它们在项目主体之后评估:
<Project>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
Agent 常见问题
-
条件比较未加引号。 始终为两边都加上引号:
'$(Prop)' == 'value'。如果属性为空或包含空格,不加引号的$(Prop) == value会静默失败。 -
在共享的
.props/.targets文件中使用$(MSBuildProjectDirectory)。 这会解析为导入项目的目录,而不是文件自身所在的目录。使用$(MSBuildThisFileDirectory)来引用相对于.props/.targets文件自身的路径。 -
在
.targets中设置属性并期望项目覆盖。.targets中的属性在项目主体之后评估,会覆盖项目级值。如果某个属性应该允许覆盖,请在.props中设置它,并使用Condition="'$(Prop)' == ''"进行保护。 -
在 SDK 样式项目中添加
<Compile Include="**/*.cs" />。 SDK 样式项目会自动包含所有*.cs文件。显式包含会导致NETSDK1022重复项错误。在条件场景中使用Remove,然后再使用Include。 -
在带有
Inputs的目标上遗漏Outputs。 带有Inputs但没有Outputs的目标会在每次构建时运行。务必同时设置二者,以实现增量行为。 -
在
.props/.targets文件中使用$(SolutionDir)。 只有通过解决方案文件构建时才会设置此属性。命令行dotnet build MyProject.csproj会使其保持为空。请使用$([MSBuild]::GetPathOfFileAbove('*.sln', '$(MSBuildProjectDirectory)')),或传递相对于$(MSBuildThisFileDirectory)的路径。 -
将项放在
PropertyGroup中,或将属性放在ItemGroup中。 项(使用Include=)必须放在<ItemGroup>中。属性(使用元素值)必须放在<PropertyGroup>中。混用会导致评估静默失败。 -
忘记在父级导入链上添加
Condition保护。 如果未找到文件,GetPathOfFileAbove会返回空字符串。<Import>必须带有Condition="Exists('$(ResolvedPath)')",否则构建会因找不到文件而失败。