mcpField
D
dotnet-msbuild-authoring

dotnet-msbuild-authoring

github.com/wshaddix开发8679 次安装35 周前更新
代码仓库

关于 dotnet-msbuild-authoring

面向 SDK 风格 .NET 项目的 MSBuild 项目配置编写技能。内容包括目标执行顺序、增量构建、导入顺序、项与条件,以及进阶 `Directory.Build` 模式。

主要功能

  1. 01自定义目标执行顺序
  2. 02通过输入和输出实现增量构建
  3. 03Props 与 targets 的导入顺序
  4. 04项、元数据与批处理
  5. 05条件和属性函数
  6. 06进阶 Directory.Build 模式

适用场景

  1. 01在构建步骤前后添加目标
  2. 02避免不必要的重复构建
  3. 03定制共享 props 和 targets
  4. 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(&quot;$(InformationalVersion)&quot;)]"
                    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>

增量构建的工作原理:

  1. MSBuild 会比较 Inputs 项与 Outputs 项的时间戳。
  2. 如果所有输出都存在,且都比所有输入新,则完全跳过该目标。
  3. 如果任意输入比任意输出新,则运行整个目标。

增量构建常见故障:

  • 缺少 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 常见问题

  1. 条件比较未加引号。 始终为两边都加上引号:'$(Prop)' == 'value'。如果属性为空或包含空格,不加引号的 $(Prop) == value 会静默失败。

  2. 在共享的 .props/.targets 文件中使用 $(MSBuildProjectDirectory)。 这会解析为导入项目的目录,而不是文件自身所在的目录。使用 $(MSBuildThisFileDirectory) 来引用相对于 .props/.targets 文件自身的路径。

  3. 在 .targets 中设置属性并期望项目覆盖。 .targets 中的属性在项目主体之后评估,会覆盖项目级值。如果某个属性应该允许覆盖,请在 .props 中设置它,并使用 Condition="'$(Prop)' == ''" 进行保护。

  4. 在 SDK 样式项目中添加 <Compile Include="**/*.cs" />。 SDK 样式项目会自动包含所有 *.cs 文件。显式包含会导致 NETSDK1022 重复项错误。在条件场景中使用 Remove,然后再使用 Include。

  5. 在带有 Inputs 的目标上遗漏 Outputs。 带有 Inputs 但没有 Outputs 的目标会在每次构建时运行。务必同时设置二者,以实现增量行为。

  6. 在 .props/.targets 文件中使用 $(SolutionDir)。 只有通过解决方案文件构建时才会设置此属性。命令行 dotnet build MyProject.csproj 会使其保持为空。请使用 $([MSBuild]::GetPathOfFileAbove('*.sln', '$(MSBuildProjectDirectory)')),或传递相对于 $(MSBuildThisFileDirectory) 的路径。

  7. 将项放在 PropertyGroup 中,或将属性放在 ItemGroup 中。 项(使用 Include=)必须放在 <ItemGroup> 中。属性(使用元素值)必须放在 <PropertyGroup> 中。混用会导致评估静默失败。

  8. 忘记在父级导入链上添加 Condition 保护。 如果未找到文件,GetPathOfFileAbove 会返回空字符串。<Import> 必须带有 Condition="Exists('$(ResolvedPath)')",否则构建会因找不到文件而失败。


参考资料

评论

支持 Markdown