extension-points

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

MSBuild Extension Points

MSBuild扩展点

How the MSBuild pipeline provides hooks for SDKs, NuGet packages, repos, and users to inject custom logic.
MSBuild流水线如何为SDK、NuGet包、代码库和用户提供注入自定义逻辑的钩子。

CustomBefore / CustomAfter Hooks

CustomBefore/CustomAfter钩子

Every major
.targets
file defines import hooks:
xml
<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets Condition="'$(CustomBeforeMicrosoftCommonTargets)' == ''">
    $(MSBuildExtensionsPath)\v$(MSBuildToolsVersion)\Custom.Before.Microsoft.Common.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>

<Import Project="$(CustomBeforeMicrosoftCommonTargets)"
    Condition="'$(CustomBeforeMicrosoftCommonTargets)' != '' and Exists('$(CustomBeforeMicrosoftCommonTargets)')"/>
<!-- ... core targets ... -->
<Import Project="$(CustomAfterMicrosoftCommonTargets)"
    Condition="'$(CustomAfterMicrosoftCommonTargets)' != '' and Exists('$(CustomAfterMicrosoftCommonTargets)')"/>
每个主要的
.targets
文件都定义了导入钩子:
xml
<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets Condition="'$(CustomBeforeMicrosoftCommonTargets)' == ''">
    $(MSBuildExtensionsPath)\v$(MSBuildToolsVersion)\Custom.Before.Microsoft.Common.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>

<Import Project="$(CustomBeforeMicrosoftCommonTargets)"
    Condition="'$(CustomBeforeMicrosoftCommonTargets)' != '' and Exists('$(CustomBeforeMicrosoftCommonTargets)')"/>
<!-- ... core targets ... -->
<Import Project="$(CustomAfterMicrosoftCommonTargets)"
    Condition="'$(CustomAfterMicrosoftCommonTargets)' != '' and Exists('$(CustomAfterMicrosoftCommonTargets)')"/>

Rules

规则

  • Default path includes version (
    v$(MSBuildToolsVersion)
    ) for side-by-side installations.
  • Always check
    Exists()
    . The file may not be present on every machine.
  • Append to the property (don't overwrite) to chain multiple hooks:
xml
<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets>
    $(CustomBeforeMicrosoftCommonTargets);$(MSBuildThisFileDirectory)MyExtension.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>
  • 默认路径包含版本号(
    v$(MSBuildToolsVersion)
    ),用于并行安装。
  • 始终检查
    Exists()
    。并非每台机器上都存在该文件。
  • 追加到属性中(不要覆盖)以链式调用多个钩子:
xml
<PropertyGroup>
  <CustomBeforeMicrosoftCommonTargets>
    $(CustomBeforeMicrosoftCommonTargets);$(MSBuildThisFileDirectory)MyExtension.targets
  </CustomBeforeMicrosoftCommonTargets>
</PropertyGroup>

Wildcard Import Directories

通配符导入目录

MSBuild imports all files in extension directories, sorted alphabetically:
xml
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore\*"
    Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == 'true'
               and Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore')" />
MSBuild会导入扩展目录中的所有文件,并按字母顺序排序:
xml
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore\*"
    Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == 'true'
               and Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Imports\Microsoft.Common.props\ImportBefore')" />

Key paths

关键路径

PropertyResolves toScope
$(MSBuildUserExtensionsPath)
%APPDATA%\Microsoft\MSBuild
Per-user
$(MSBuildExtensionsPath)
MSBuild install directoryMachine-wide
$(MSBuildProjectExtensionsPath)
obj/
directory
Per-project (NuGet)
Name files with numeric prefixes for ordering:
01-first.props
,
02-second.props
.
属性解析路径作用范围
$(MSBuildUserExtensionsPath)
%APPDATA%\Microsoft\MSBuild
按用户划分
$(MSBuildExtensionsPath)
MSBuild安装目录机器全局
$(MSBuildProjectExtensionsPath)
obj/
目录
按项目划分(NuGet)
为文件添加数字前缀来控制顺序:
01-first.props
02-second.props

Import Gating — Control Properties

导入门控——控制属性

Every wildcard import is gated by a boolean property:
xml
<PropertyGroup>
  <ImportByWildcardBeforeMicrosoftCommonProps
      Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == ''">true</ImportByWildcardBeforeMicrosoftCommonProps>
  <ImportDirectoryBuildProps
      Condition="'$(ImportDirectoryBuildProps)' == ''">true</ImportDirectoryBuildProps>
</PropertyGroup>
每个通配符导入都由一个布尔属性控制:
xml
<PropertyGroup>
  <ImportByWildcardBeforeMicrosoftCommonProps
      Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == ''">true</ImportByWildcardBeforeMicrosoftCommonProps>
  <ImportDirectoryBuildProps
      Condition="'$(ImportDirectoryBuildProps)' == ''">true</ImportDirectoryBuildProps>
</PropertyGroup>

Available control properties

可用控制属性

PropertyWhat it disables
ImportDirectoryBuildProps
Directory.Build.props auto-discovery
ImportDirectoryBuildTargets
Directory.Build.targets auto-discovery
ImportProjectExtensionProps
NuGet-generated
*.props
in obj/
ImportProjectExtensionTargets
NuGet-generated
*.targets
in obj/
ImportByWildcardBefore*
Machine-level ImportBefore extensions
ImportByWildcardAfter*
Machine-level ImportAfter extensions
属性禁用内容
ImportDirectoryBuildProps
Directory.Build.props自动发现
ImportDirectoryBuildTargets
Directory.Build.targets自动发现
ImportProjectExtensionProps
obj/目录中NuGet生成的
*.props
ImportProjectExtensionTargets
obj/目录中NuGet生成的
*.targets
ImportByWildcardBefore*
机器级ImportBefore扩展
ImportByWildcardAfter*
机器级ImportAfter扩展

NuGet Package Build Extension Layout

NuGet包构建扩展布局

NuGet packages inject build logic via
build/
or
buildTransitive/
folders:
text
MyPackage/
  build/
    MyPackage.props      ← imported via *.props wildcard
    MyPackage.targets    ← imported via *.targets wildcard
  buildTransitive/
    MyPackage.props      ← imported by transitive consumers
    MyPackage.targets
NuGet包通过
build/
buildTransitive/
文件夹注入构建逻辑:
text
MyPackage/
  build/
    MyPackage.props      ← 通过*.props通配符导入
    MyPackage.targets    ← 通过*.targets通配符导入
  buildTransitive/
    MyPackage.props      ← 被传递性消费者导入
    MyPackage.targets

Rules

规则

  • File names must match the package ID exactly.
  • build/
    affects direct consumers only.
    buildTransitive/
    affects the entire dependency chain.
  • Props are imported early (before the project), targets are imported late (after the project).
  • 文件名必须完全匹配包ID
  • build/
    仅影响直接消费者。
    buildTransitive/
    影响整个依赖链。
  • Props会提前导入(在项目之前),Targets会延后导入(在项目之后)。

Forwarding chain:
buildTransitive/
build/
→ shared

转发链:
buildTransitive/
build/
→ 共享

Forward
buildTransitive/*.props
and
buildTransitive/*.targets
through their sibling
build/*.props
/
build/*.targets
files (chain
buildTransitive → build → shared
) instead of importing
buildMultiTargeting/
directly. This keeps
build/
as the single source of truth with a clear ownership chain, so transitive consumers stay in sync with direct consumers instead of the two layouts drifting apart.
When
build/
is packed per-TFM (
build/<tfm>/
, via
TfmSpecificPackageFile
, a per-TFM
<PackagePath>
, or SDK conventions) while
buildMultiTargeting/
is not, a
buildTransitive/<tfm>/
forwarder must include the TFM segment — dropping it resolves to a non-existent package-root
build/MyPackage.props
and fails transitive consumers with
MSB4019
. Derive the segment from the file's own folder, never
$(TargetFramework)
(NuGet nearest-match can serve a
net10.0
consumer the
net9.0
folder, so
$(TargetFramework)
may name a folder that was never restored):
xml
<!-- buildTransitive/<tfm>/MyPackage.props -->
<Import Project="$(MSBuildThisFileDirectory)..\..\build\$([System.IO.Path]::GetFileName($([System.IO.Path]::GetDirectoryName('$(MSBuildThisFileDirectory)'))))\MyPackage.props" />
通过同级的
build/*.props
/
build/*.targets
文件转发
buildTransitive/*.props
buildTransitive/*.targets
(形成
buildTransitive → build → 共享
的链条),而非直接导入
buildMultiTargeting/
。这样可以让
build/
作为单一可信源,拥有清晰的归属链,确保传递性消费者与直接消费者保持同步,避免两种布局出现差异。
build/
按TFM打包(
build/<tfm>/
,通过
TfmSpecificPackageFile
、按TFM设置的
<PackagePath>
或SDK约定)而
buildMultiTargeting/
不按TFM打包时,
buildTransitive/<tfm>/
转发器必须包含TFM段——如果省略,会解析到不存在的包根目录
build/MyPackage.props
,导致传递性消费者出现**
MSB4019
**错误。TFM段应从文件自身的文件夹中获取,绝不能使用
$(TargetFramework)
(NuGet就近匹配可能会为
net10.0
消费者提供
net9.0
文件夹,因此
$(TargetFramework)
可能指向从未还原的文件夹):
xml
<!-- buildTransitive/<tfm>/MyPackage.props -->
<Import Project="$(MSBuildThisFileDirectory)..\..\build\$([System.IO.Path]::GetFileName($([System.IO.Path]::GetDirectoryName('$(MSBuildThisFileDirectory)'))))\MyPackage.props" />

Source Tree vs Packed Layout

源码树与打包后布局

When reviewing a NuGet build-extension package, the source layout in the repository can legitimately differ from the packed layout inside the produced
.nupkg
. This is a common source of false-positive "import points at a missing file" findings.
Three packaging mechanisms reshape the layout at pack time:
  1. .nuspec
    <file src=… target=…>
    mappings
    — copy a single source file into multiple per-TFM targets:
    xml
    <!-- Source tree has ONE shared file:
           buildTransitive\common\MyAdapter.props
         Pack rewrites it to per-TFM targets inside the .nupkg:
           buildTransitive\net462\MyAdapter.props
           buildTransitive\net8.0\MyAdapter.props
           buildTransitive\net9.0\MyAdapter.props -->
    <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>
    In the
    <file>
    element, a
    target
    ending in
    \
    is treated as a folder (filename preserved from
    src
    ); a
    target
    ending in a filename renames the file.
  2. .csproj
    <PackagePath>
    metadata
    on
    <None Update=…>
    or
    <Content Include=…>
    items — same effect via SDK pack. Use one item per destination to keep the mapping unambiguous:
    xml
    <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 also accepts a semicolon-separated list (
    PackagePath="buildTransitive\net8.0\;buildTransitive\net9.0\"
    ) to fan one source out to multiple destinations, but the multi-item form above is harder to misread.
  3. SDK conventions
    IncludeBuildOutput
    ,
    BuildOutputTargetFolder
    ,
    IncludeContentInPack
    automatically place built outputs under
    lib/<tfm>/
    or
    build/<tfm>/
    .
审查NuGet构建扩展包时,代码库中的源码布局可能与生成的
.nupkg
中的打包后布局合法存在差异。这是导致“导入指向不存在文件”误判的常见原因。
三种打包机制会在打包时调整布局:
  1. .nuspec
    <file src=… target=…>
    映射
    ——将单个源码文件复制到多个按TFM划分的目标位置:
    xml
    <!-- 源码树中有一个共享文件:
           buildTransitive\common\MyAdapter.props
         打包后会将其重写到.nupkg中的按TFM划分的目标位置:
           buildTransitive\net462\MyAdapter.props
           buildTransitive\net8.0\MyAdapter.props
           buildTransitive\net9.0\MyAdapter.props -->
    <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打包实现相同效果。为每个目标位置使用一个项,保持映射清晰:
    xml
    <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打包也接受分号分隔的列表(
    PackagePath="buildTransitive\net8.0\;buildTransitive\net9.0\"
    ),将一个源码文件分发到多个目标位置,但上面的多项形式更易读,不易出错。
  3. SDK约定——
    IncludeBuildOutput
    BuildOutputTargetFolder
    IncludeContentInPack
    会自动将构建输出放置在
    lib/<tfm>/
    build/<tfm>/
    下。

Implication for reviewers

对审查者的提示

A forwarder like the following inside a packed
build/net462/
folder is not a "missing-file" bug, even if the source tree has no
buildTransitive/net462/
directory:
xml
<!-- In packed build/net462/MyAdapter.props -->
<Project>
  <Import Project="$(MSBuildThisFileDirectory)..\..\buildTransitive\net462\MyAdapter.props" />
</Project>
Before flagging an unguarded
<Import>
inside a
build/<tfm>/
or
buildTransitive/<tfm>/
folder:
  1. Look for
    *.nuspec
    in the project directory and its immediate parent directory (do not walk further up). Read every
    <file target=…>
    whose
    target
    matches the imported path.
  2. Read the
    .csproj
    for
    <PackagePath>
    metadata on
    <None>
    /
    <Content>
    items.
  3. Only flag the import if the target path is missing from both the source tree and the projected package layout.
See also
msbuild-antipatterns
AP-13 ("NuGet package forwarders" exception).
打包后的
build/net462/
文件夹中的如下转发器不是“文件缺失”漏洞,即使源码树中没有
buildTransitive/net462/
目录:
xml
<!-- 在打包后的build/net462/MyAdapter.props中 -->
<Project>
  <Import Project="$(MSBuildThisFileDirectory)..\..\buildTransitive\net462\MyAdapter.props" />
</Project>
在标记
build/<tfm>/
buildTransitive/<tfm>/
文件夹中未加防护的
<Import>
之前:
  1. 在项目目录及其直接父目录中查找
    *.nuspec
    (不要向上遍历更多层级)。读取每个
    target
    与导入路径匹配的
    <file target=…>
  2. 读取
    .csproj
    <None>
    /
    <Content>
    项的
    <PackagePath>
    元数据。
  3. 只有当目标路径在源码树和预期包布局中都不存在时,才标记该导入。
另请参阅
msbuild-antipatterns
中的AP-13(“NuGet包转发器”例外)。

Import Guard Pattern

导入防护模式

The
.targets
file ensures
.props
was imported using a guard property:
xml
<!-- End of Microsoft.Common.props -->
<PropertyGroup>
  <MicrosoftCommonPropsHasBeenImported>true</MicrosoftCommonPropsHasBeenImported>
</PropertyGroup>

<!-- Top of Microsoft.Common.CurrentVersion.targets -->
<Import Project="Microsoft.Common.props"
    Condition="'$(MicrosoftCommonPropsHasBeenImported)' != 'true'" />
This handles projects that only import
.targets
.
.targets
文件使用防护属性确保
.props
已被导入:
xml
<!-- Microsoft.Common.props的结尾 -->
<PropertyGroup>
  <MicrosoftCommonPropsHasBeenImported>true</MicrosoftCommonPropsHasBeenImported>
</PropertyGroup>

<!-- Microsoft.Common.CurrentVersion.targets的开头 -->
<Import Project="Microsoft.Common.props"
    Condition="'$(MicrosoftCommonPropsHasBeenImported)' != 'true'" />
这可以处理仅导入
.targets
的项目。

Directory.Build Discovery

Directory.Build自动发现

MSBuild walks up the directory tree to find the nearest
Directory.Build.props
:
xml
<_DirectoryBuildPropsBasePath>
  $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'Directory.Build.props'))
</_DirectoryBuildPropsBasePath>
Only the nearest file is discovered. Nested hierarchies must explicitly import parents:
xml
<!-- src/Directory.Build.props -->
<PropertyGroup>
  <_ParentPropsPath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))</_ParentPropsPath>
</PropertyGroup>
<Import Project="$(_ParentPropsPath)" Condition="'$(_ParentPropsPath)' != ''" />
MSBuild会向上遍历目录树,查找最近的
Directory.Build.props
xml
<_DirectoryBuildPropsBasePath>
  $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'Directory.Build.props'))
</_DirectoryBuildPropsBasePath>
只会发现最近的文件。嵌套层次结构必须显式导入父级文件:
xml
<!-- src/Directory.Build.props -->
<PropertyGroup>
  <_ParentPropsPath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))</_ParentPropsPath>
</PropertyGroup>
<Import Project="$(_ParentPropsPath)" Condition="'$(_ParentPropsPath)' != ''" />

Creating Your Own Extension Point

创建自定义扩展点

xml
<!-- MySDK.targets -->
<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">
    <!-- implementation -->
  </Target>

  <Import Project="$(CustomAfterMySDK)" Condition="Exists('$(CustomAfterMySDK)')" />
</Project>
xml
<!-- MySDK.targets -->
<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">
    <!-- implementation -->
  </Target>

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

Common Pitfalls

常见陷阱

  • Missing
    Exists()
    on optional imports
    causes build failures when files are absent. Exception: imports inside published
    build/<tfm>/
    and
    buildTransitive/<tfm>/
    folders of a NuGet package are a package contract — the target is guaranteed by the packed layout (see "Source Tree vs Packed Layout" above). Don't guard them and don't flag them.
  • Overwriting Custom properties* drops prior hooks. Append with
    ;
    separator.
  • NuGet package file names not matching package ID silently skips the import.
  • Nested Directory.Build.props without parent import loses repo-root settings.
  • 可选导入缺少
    Exists()
    检查
    会在文件不存在时导致构建失败。例外:NuGet包已发布的
    build/<tfm>/
    buildTransitive/<tfm>/
    文件夹中的导入属于包契约——目标文件由打包后布局保证存在(参见上文“源码树与打包后布局”)。不要为这些导入添加防护,也不要标记它们。
  • 覆盖Custom*属性会丢弃之前的钩子。使用
    ;
    分隔符追加内容。
  • NuGet包文件名与包ID不匹配会导致导入被静默跳过。
  • 嵌套的Directory.Build.props未导入父级文件会丢失代码库根目录的设置。