关于 extension-points
面向维护 MSBuild SDK、NuGet 包和仓库构建逻辑的开发者,说明扩展点、导入顺序与打包布局。重点涵盖钩子链、通配符导入及 NuGet 前向器的审查规则。
主要功能
- 01配置 CustomBefore/CustomAfter 钩子
- 02了解扩展目录与导入门控属性
- 03组织 NuGet build 与 buildTransitive 文件
- 04检查源代码树和打包布局差异
- 05处理 Directory.Build.props 层级发现
适用场景
- 01为 SDK 或仓库注入构建逻辑
- 02排查 NuGet 扩展文件导入失败
- 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>
可用的控制属性
| 属性 | 禁用的内容 |
|---|---|
ImportDirectoryBuildProps | Directory.Build.props 自动发现 |
ImportDirectoryBuildTargets | Directory.Build.targets 自动发现 |
ImportProjectExtensionProps | obj/ 中由 NuGet 生成的 *.props |
ImportProjectExtensionTargets | obj/ 中由 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 中的打包布局可能会存在合理差异。这是“导入指向缺失文件”误报的常见来源。
三种打包机制会在打包时重塑布局:
-
.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会重命名文件。 -
.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\"),将一个源文件分发到多个目标位置,但上面的多项写法更不容易产生歧义。 -
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> 标记为问题之前:
- 在项目目录及其直接父目录中查找
*.nuspec(不要继续向上遍历)。读取所有<file target=…>,检查其target是否与导入路径匹配。 - 检查
.csproj中<None>/<Content>项的<PackagePath>元数据。 - 只有当目标路径在源码树和映射后的包布局中都不存在时,才标记该导入。
另请参阅 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 未导入父级文件会导致仓库根目录的设置丢失。