mcpField
E
extension-points

extension-points

github.com/dotnet开发5.8k3.2k 次安装1 天前更新
代码仓库

关于 extension-points

面向维护 MSBuild SDK、NuGet 包和仓库构建逻辑的开发者,说明扩展点、导入顺序与打包布局。重点涵盖钩子链、通配符导入及 NuGet 前向器的审查规则。

主要功能

  1. 01配置 CustomBefore/CustomAfter 钩子
  2. 02了解扩展目录与导入门控属性
  3. 03组织 NuGet build 与 buildTransitive 文件
  4. 04检查源代码树和打包布局差异
  5. 05处理 Directory.Build.props 层级发现

适用场景

  1. 01为 SDK 或仓库注入构建逻辑
  2. 02排查 NuGet 扩展文件导入失败
  3. 03审查多目标框架包的转发路径

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill extension-points

安装后在 Claude Code 中输入/extension-points确认已加载。

详细介绍

MSBuild 扩展点

MSBuild 管道如何为 SDK、NuGet 包、代码仓库和用户提供注入自定义逻辑的钩子。

CustomBefore / CustomAfter 钩子

每个主要的 .targets 文件都会定义导入钩子:

<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets Condition="'$(CustomBeforeMicrosoftCommonTargets)' == ''">
    $(MSBuildExtensionsPath)\v$(MSBuildToolsVersion)\Custom.Before.Microsoft.Common.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>

<Import Project="$(CustomBeforeMicrosoftCommonTargets)"
    Condition="'$(CustomBeforeMicrosoftCommonTargets)' != '' and Exists('$(CustomBeforeMicrosoftCommonTargets)')"/>

<Import Project="$(CustomAfterMicrosoftCommonTargets)"
    Condition="'$(CustomAfterMicrosoftCommonTargets)' != '' and Exists('$(CustomAfterMicrosoftCommonTargets)')"/>
规则
  • 默认路径包含版本号(v$(MSBuildToolsVersion)),以支持并行安装。
  • 始终检查 Exists()。并非每台计算机上都一定有该文件。
  • 追加到属性中(不要覆盖),以串联多个钩子:
<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets>
    $(CustomBeforeMicrosoftCommonTargets);$(MSBuildThisFileDirectory)MyExtension.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>

通配符导入目录

MSBuild 会导入扩展目录中的所有文件,并按字母顺序排序:

<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore\*"
    Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == 'true'
               and Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore')" />
主要路径
属性解析为范围
$(MSBuildUserExtensionsPath)%APPDATA%\Microsoft\MSBuild每用户
$(MSBuildExtensionsPath)MSBuild 安装目录整台计算机
$(MSBuildProjectExtensionsPath)obj/ 目录每个项目(NuGet)

使用数字前缀为文件命名,以控制顺序:01-first.props、02-second.props。

导入门控 — 控制属性

每个通配符导入都由一个布尔属性控制:

<PropertyGroup>
  <ImportByWildcardBeforeMicrosoftCommonProps
      Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == ''">true</ImportByWildcardBeforeMicrosoftCommonProps>
  <ImportDirectoryBuildProps
      Condition="'$(ImportDirectoryBuildProps)' == ''">true</ImportDirectoryBuildProps>
</PropertyGroup>
可用的控制属性
属性禁用的内容
ImportDirectoryBuildPropsDirectory.Build.props 自动发现
ImportDirectoryBuildTargetsDirectory.Build.targets 自动发现
ImportProjectExtensionPropsobj/ 中由 NuGet 生成的 *.props
ImportProjectExtensionTargetsobj/ 中由 NuGet 生成的 *.targets
ImportByWildcardBefore*计算机级 ImportBefore 扩展
ImportByWildcardAfter*计算机级 ImportAfter 扩展

NuGet 包构建扩展布局

NuGet 包通过 build/ 或 buildTransitive/ 文件夹注入构建逻辑:

MyPackage/
  build/
    MyPackage.props      ← imported via *.props wildcard
    MyPackage.targets    ← imported via *.targets wildcard
  buildTransitive/
    MyPackage.props      ← imported by transitive consumers
    MyPackage.targets
规则
  • 文件名必须与包 ID 完全一致。
  • build/ 仅影响直接使用者。buildTransitive/ 会影响整个依赖链。
  • Props 在较早阶段导入(项目之前),targets 在较晚阶段导入(项目之后)。
转发链:buildTransitive/ → build/ → shared

通过同级的 build/*.props / build/*.targets 文件转发 buildTransitive/*.props 和 buildTransitive/*.targets(链路为 buildTransitive → build → shared),而不是直接导入 buildMultiTargeting/。这样可让 build/ 成为唯一事实来源,并保持清晰的所有权链,使传递依赖使用者与直接使用者保持同步,避免两种布局逐渐不一致。

当 build/ 按 TFM 打包(build/<tfm>/,通过 TfmSpecificPackageFile、按 TFM 指定的 <PackagePath> 或 SDK 约定),而 buildMultiTargeting/ 未按 TFM 打包时,buildTransitive/<tfm>/ 转发文件必须包含 TFM 路径段——省略它会解析到不存在的包根目录 build/MyPackage.props,并导致传递依赖使用者遇到 MSB4019 错误。应从文件自身的文件夹推导该路径段,切勿使用 $(TargetFramework)(NuGet 最近匹配机制可能会为 net10.0 使用者提供 net9.0 文件夹,因此 $(TargetFramework) 可能指向一个从未还原过的文件夹):


<Import Project="$(MSBuildThisFileDirectory)..\..\build\$([System.IO.Path]::GetFileName($([System.IO.Path]::GetDirectoryName('$(MSBuildThisFileDirectory)'))))\MyPackage.props" />

源码树布局与打包布局

检查 NuGet 构建扩展包时,仓库中的源码布局与生成的 .nupkg 中的打包布局可能会存在合理差异。这是“导入指向缺失文件”误报的常见来源。

三种打包机制会在打包时重塑布局:

  1. .nuspec <file src=… target=…> 映射 — 将单个源文件复制到多个按 TFM 区分的目标位置:

    
    <files>
      <file src="buildTransitive\common\MyAdapter.props" target="buildTransitive\net462\MyAdapter.props" />
      <file src="buildTransitive\common\MyAdapter.props" target="buildTransitive\net8.0\MyAdapter.props" />
      <file src="buildTransitive\common\MyAdapter.props" target="buildTransitive\net9.0\MyAdapter.props" />
    </files>
    

    在 <file> 元素中,以 \ 结尾的 target 会被视为文件夹(保留 src 中的文件名);以文件名结尾的 target 会重命名文件。

  2. .csproj 中 <None Update=…> 或 <Content Include=…> 项的 <PackagePath> 元数据 — 通过 SDK pack 实现相同效果。每个目标位置使用一个项,以确保映射清晰明确:

    <ItemGroup>
      <None Include="buildTransitive\common\MyAdapter.props" Pack="true" PackagePath="buildTransitive\net8.0\MyAdapter.props" />
      <None Include="buildTransitive\common\MyAdapter.props" Pack="true" PackagePath="buildTransitive\net9.0\MyAdapter.props" />
    </ItemGroup>
    

    NuGet/SDK pack 也接受以分号分隔的列表(PackagePath="buildTransitive\net8.0\;buildTransitive\net9.0\"),将一个源文件分发到多个目标位置,但上面的多项写法更不容易产生歧义。

  3. SDK 约定 — IncludeBuildOutput、BuildOutputTargetFolder、IncludeContentInPack 会自动将生成的输出放到 lib/<tfm>/ 或 build/<tfm>/ 下。

对审查者的影响

如果打包后的 build/net462/ 文件夹中存在如下转发文件,即使源码树中没有 buildTransitive/net462/ 目录,也不是“缺失文件”错误:


<Project>
  <Import Project="$(MSBuildThisFileDirectory)..\..\buildTransitive\net462\MyAdapter.props" />
</Project>

将 build/<tfm>/ 或 buildTransitive/<tfm>/ 文件夹中的无条件 <Import> 标记为问题之前:

  1. 在项目目录及其直接父目录中查找 *.nuspec(不要继续向上遍历)。读取所有 <file target=…>,检查其 target 是否与导入路径匹配。
  2. 检查 .csproj 中 <None>/<Content> 项的 <PackagePath> 元数据。
  3. 只有当目标路径在源码树和映射后的包布局中都不存在时,才标记该导入。

另请参阅 msbuild-antipatterns AP-13(“NuGet 包转发文件”例外)。

导入保护模式

.targets 文件通过保护属性确保 .props 已导入:


<PropertyGroup>
  <MicrosoftCommonPropsHasBeenImported>true</MicrosoftCommonPropsHasBeenImported>
</PropertyGroup>


<Import Project="Microsoft.Common.props"
    Condition="'$(MicrosoftCommonPropsHasBeenImported)' != 'true'" />

这适用于仅导入 .targets 的项目。

Directory.Build 查找机制

MSBuild 会沿目录树向上查找最近的 Directory.Build.props:

<_DirectoryBuildPropsBasePath>
  $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'Directory.Build.props'))
</_DirectoryBuildPropsBasePath>

系统只会发现最近的文件。嵌套层级必须显式导入父级文件:


<PropertyGroup>
  <_ParentPropsPath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))</_ParentPropsPath>
</PropertyGroup>
<Import Project="$(_ParentPropsPath)" Condition="'$(_ParentPropsPath)' != ''" />

创建自己的扩展点


<Project>
  <Import Project="MySDK.props" Condition="'$(MySDKPropsImported)' != 'true'" />

  <PropertyGroup>
    <CustomBeforeMySDK Condition="'$(CustomBeforeMySDK)' == ''">$(MSBuildProjectDirectory)\MySDK.Before.targets</CustomBeforeMySDK>
    <CustomAfterMySDK Condition="'$(CustomAfterMySDK)' == ''">$(MSBuildProjectDirectory)\MySDK.After.targets</CustomAfterMySDK>
  </PropertyGroup>

  <Import Project="$(CustomBeforeMySDK)" Condition="Exists('$(CustomBeforeMySDK)')" />

  <PropertyGroup>
    <MySDKBuildDependsOn>BeforeMySDKBuild;CoreMySDKBuild;AfterMySDKBuild</MySDKBuildDependsOn>
  </PropertyGroup>
  <Target Name="MySDKBuild" DependsOnTargets="$(MySDKBuildDependsOn)" />
  <Target Name="BeforeMySDKBuild" />
  <Target Name="AfterMySDKBuild" />
  <Target Name="CoreMySDKBuild">
    
  </Target>

  <Import Project="$(CustomAfterMySDK)" Condition="Exists('$(CustomAfterMySDK)')" />
</Project>

常见陷阱

  • 可选导入缺少 Exists() 会在文件不存在时导致构建失败。例外:NuGet 包中已发布的 build/<tfm>/ 和 buildTransitive/<tfm>/ 文件夹里的导入属于包契约——打包布局保证目标文件存在(见上文“源码树布局与打包布局”)。不要为其添加保护,也不要将其标记为问题。
  • *覆盖 Custom 属性**会丢失已有的挂钩。请使用 ; 分隔并追加。
  • NuGet 包文件名与包 ID 不匹配会导致导入被静默跳过。
  • 嵌套的 Directory.Build.props 未导入父级文件会导致仓库根目录的设置丢失。

评论

支持 Markdown