关于 dotnet-msbuild-tasks
面向 .NET 构建人员的自定义 MSBuild 任务编写指南,涵盖任务实现、外部 CLI 封装、增量执行、调试和 NuGet 分发。内容以 .NET 8 SDK 与 MSBuild 17.8+ 为基准。
主要功能
- 01实现 ITask 任务
- 02用 ToolTask 封装 CLI
- 03借助 IIncrementalTask 增量处理
- 04定义内联任务与参数
- 05调试并打包任务
适用场景
- 01为构建流程编写自定义逻辑
- 02将外部命令行工具接入 MSBuild
- 03减少大型文件集合的重复处理
- 04通过 NuGet 分发构建任务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-msbuild-tasks安装后在 Claude Code 中输入/dotnet-msbuild-tasks确认已加载。
详细介绍
dotnet-msbuild-tasks
自定义 MSBuild 任务的编写指南:实现 ITask 接口、扩展 ToolTask 以封装命令行工具、使用 IIncrementalTask(MSBuild 17.8+)实现增量执行、使用 CodeTaskFactory 定义内联任务、通过 UsingTask 注册任务、声明任务参数、调试任务,以及将任务打包为 NuGet 包。
版本假设:.NET 8.0+ SDK(MSBuild 17.8+)。IIncrementalTask 需要 MSBuild 17.8+(VS 2022 17.8+、.NET 8 SDK)。所有示例都使用 SDK 样式项目。除非另有明确说明,所有 C# 示例都假设 using Microsoft.Build.Framework; 和 using Microsoft.Build.Utilities; 已在作用域内。
**范围边界:**本技能负责自定义 MSBuild 任务的编写,包括 ITask、ToolTask、IIncrementalTask、内联任务、UsingTask、参数、调试和 NuGet 打包。MSBuild 项目系统的编写(targets、props、items、conditions)由 [skill:dotnet-msbuild-authoring] 负责。
交叉引用:[skill:dotnet-msbuild-authoring],了解自定义 targets、导入顺序、items、conditions 和属性函数。
ITask 接口
所有 MSBuild 任务都实现 Microsoft.Build.Framework.ITask。最简单的做法是继承 Microsoft.Build.Utilities.Task,它为 BuildEngine 和 HostObject 提供默认实现。
最小自定义任务
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
public class GenerateFileHash : Task
{
[Required]
public string InputFile { get; set; } = string.Empty;
[Output]
public string Hash { get; set; } = string.Empty;
public override bool Execute()
{
if (!File.Exists(InputFile))
{
Log.LogError("Input file not found: {0}", InputFile);
return false;
}
using var stream = File.OpenRead(InputFile);
var bytes = System.Security.Cryptography.SHA256.HashData(stream);
Hash = Convert.ToHexString(bytes).ToLowerInvariant();
Log.LogMessage(MessageImportance.Normal,
"SHA-256 hash for {0}: {1}", InputFile, Hash);
return true;
}
}
ITask 合约
| 成员 | 用途 |
|---|---|
BuildEngine | 提供日志记录、错误报告和生成上下文 |
HostObject | 特定于宿主的数据(很少使用) |
Execute() | 运行任务。成功时返回 true,失败时返回 false |
基类 Task 提供了 Log 属性(TaskLoggingHelper),其中包含便捷方法:
| 方法 | 使用场景 |
|---|---|
Log.LogMessage(importance, msg) | 信息输出(Normal、High、Low) |
Log.LogWarning(msg) | 非致命问题 |
Log.LogError(msg) | 致命错误(导致生成失败) |
Log.LogWarningFromException(ex) | 根据捕获的异常记录警告 |
Log.LogErrorFromException(ex) | 根据捕获的异常记录错误 |
ToolTask 基类
ToolTask 扩展了 Task,用于封装外部命令行工具。它负责调用进程、捕获输出并解释退出代码。
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
public class RunLintTool : ToolTask
{
[Required]
public string SourceDirectory { get; set; } = string.Empty;
public string Severity { get; set; } = "warning";
// Required: name of the executable
protected override string ToolName => "dotnet-lint";
// Required: full path or tool name (OS resolves via PATH)
protected override string GenerateFullPathToTool()
{
// Return tool name; the OS resolves it via PATH at process start
return ToolName;
}
// Required: build the command-line arguments
protected override string GenerateCommandLineCommands()
{
var builder = new CommandLineBuilder();
builder.AppendSwitch("--check");
builder.AppendSwitchIfNotNull("--severity ", Severity);
builder.AppendFileNameIfNotNull(SourceDirectory);
return builder.ToString();
}
// Optional: interpret non-zero exit codes
protected override bool HandleTaskExecutionErrors()
{
Log.LogError("{0} found lint violations in {1}",
ToolName, SourceDirectory);
return false;
}
}
ToolTask 的关键重写项
| 重写项 | 用途 |
|---|---|
ToolName | 可执行文件名(例如,dotnet-lint) |
GenerateFullPathToTool() | 可执行文件的完整路径;也可返回 ToolName,让操作系统通过 PATH 解析 |
GenerateCommandLineCommands() | 为工具构造参数字符串 |
GenerateResponseFileCommands() | 写入响应文件的参数(用于较长的命令行) |
HandleTaskExecutionErrors() | 自定义处理非零退出代码的方式 |
StandardOutputLoggingImportance | stdout 的日志级别(默认值:Low) |
StandardErrorLoggingImportance | stderr 的日志级别(默认值:Normal) |
长命令行的响应文件
当参数列表过长、无法放入操作系统命令行时(源文件较多时很常见),可使用 GenerateResponseFileCommands() 将参数写入临时响应文件:
protected override string GenerateResponseFileCommands()
{
var builder = new CommandLineBuilder();
// These arguments go into a @response.rsp file
foreach (var source in SourceFiles)
{
builder.AppendFileNameIfNotNull(source.ItemSpec);
}
return builder.ToString();
}
protected override string GenerateCommandLineCommands()
{
// These arguments stay on the command line (before the @file ref)
var builder = new CommandLineBuilder();
builder.AppendSwitchIfNotNull("--config ", ConfigFile);
return builder.ToString();
}
MSBuild 会创建响应文件,将 @responsefile.rsp 传递给工具,并在之后清理文件。工具必须支持 @file 语法(大多数 .NET 工具都支持)。
**何时使用 ToolTask 与 Task:**封装外部 CLI 工具时使用 ToolTask。逻辑完全由 .NET 代码实现且不涉及外部进程时,使用 Task(ITask)。
IIncrementalTask
Microsoft.Build.Framework.IIncrementalTask(MSBuild 17.8+、VS 2022 17.8+、.NET 8 SDK)向 MSBuild 引擎表明,任务支持接收预先筛选的输入。当 target 声明 Inputs/Outputs 且引擎确定了哪些输入发生变化时,它只会将发生变化的项传递给实现 IIncrementalTask 的任务,而不是传递完整的项列表。
版本要求
IIncrementalTask 要求:
- MSBuild 17.8+(随 VS 2022 17.8+ 一起提供)
- .NET 8.0 SDK 或更高版本
面向较旧 MSBuild 版本的任务不得引用此接口。在较旧版本上使用目标级别的 Inputs/Outputs 实现增量处理。请参阅 [skill:dotnet-msbuild-authoring],了解目标级别的增量处理模式。
工作原理
- 目标声明
Inputs和Outputs(必需——引擎会使用它们检测更改)。 - MSBuild 比较时间戳,确定哪些输入已过期。
- 如果任务实现了
IIncrementalTask,MSBuild 会向任务的ITaskItem[]参数传递已更改的项,而不是完整集合。 - 任务仅处理这些项——无需手动处理时间戳逻辑。
FailIfIncrementalBuildIsNotPossible 属性控制回退行为:
false(默认值):如果引擎无法确定已更改的输入(例如,缺少Outputs),则回退为传递所有输入。任务以完整重建模式运行。true:如果引擎无法提供增量输入,任务会记录错误并失败。在完整重建速度慢到无法接受时使用此选项。
实现
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
public class TransformTemplates : Task, IIncrementalTask
{
[Required]
public ITaskItem[] Templates { get; set; } = [];
[Output]
public ITaskItem[] GeneratedFiles { get; set; } = [];
// IIncrementalTask: if true, the task errors when the engine
// cannot provide filtered inputs (falls back to full set if false)
public bool FailIfIncrementalBuildIsNotPossible { get; set; }
public override bool Execute()
{
// Templates contains ONLY changed items (filtered by engine)
// when the target has Inputs/Outputs and incremental build is possible
var outputs = new List<ITaskItem>();
foreach (var template in Templates)
{
var inputPath = template.GetMetadata("FullPath");
var outputPath = Path.ChangeExtension(inputPath, ".g.cs");
var content = ProcessTemplate(File.ReadAllText(inputPath));
File.WriteAllText(outputPath, content);
Log.LogMessage(MessageImportance.Normal,
"Transformed: {0} -> {1}", inputPath, outputPath);
outputs.Add(new TaskItem(outputPath));
}
GeneratedFiles = outputs.ToArray();
return true;
}
private static string ProcessTemplate(string input)
{
// Template transformation logic
return $"// Auto-generated\n{input}";
}
}
<Target Name="TransformAllTemplates"
BeforeTargets="CoreCompile"
Inputs="@(Template)"
Outputs="@(Template->'%(RootDir)%(Directory)%(Filename).g.cs')">
<TransformTemplates Templates="@(Template)">
<Output TaskParameter="GeneratedFiles" ItemName="Compile" />
</TransformTemplates>
</Target>
IIncrementalTask 与仅使用目标级别 Inputs/Outputs 的区别: 如果不使用 IIncrementalTask,目标级别的增量处理是全有或全无:只要有任何输入发生更改,整个目标就会携带所有项重新运行。使用 IIncrementalTask 后,引擎会预先筛选项列表,使任务只接收已更改的项,因此对于处理大量文件的目标来说速度更快。
任务参数
任务参数是任务类上的公共属性。MSBuild 会自动将 XML 属性映射到这些属性。
参数属性
| 属性 | 效果 |
|---|---|
[Required] | 未提供参数时,构建失败 |
[Output] | 后续任务/目标可通过 %(TaskName.PropertyName) 使用该值 |
| 无属性 | 可选参数,具有默认值 |
参数类型
| .NET 类型 | MSBuild XML | 示例 |
|---|---|---|
string | 标量值 | InputFile="src/app.cs" |
bool | true/false | Verbose="true" |
int | 数值 | MaxRetries="3" |
string[] | 以分号分隔 | Assemblies="a.dll;b.dll" |
ITaskItem | 单个项 | SourceFile="@(MainSource)" |
ITaskItem[] | 项集合 | SourceFiles="@(Compile)" |
ITaskItem 元数据访问
ITaskItem 携带丰富的元数据,而不仅仅是文件路径:
public class ProcessAssets : Task
{
[Required]
public ITaskItem[] Assets { get; set; } = [];
[Output]
public ITaskItem[] ProcessedAssets { get; set; } = [];
public override bool Execute()
{
var results = new List<ITaskItem>();
foreach (var asset in Assets)
{
// ItemSpec = the Include value (relative path)
var relativePath = asset.ItemSpec;
// Built-in metadata
var fullPath = asset.GetMetadata("FullPath");
var filename = asset.GetMetadata("Filename");
var extension = asset.GetMetadata("Extension");
// Custom metadata set in MSBuild XML
var category = asset.GetMetadata("Category");
Log.LogMessage(MessageImportance.Normal,
"Processing {0} (category: {1})", filename, category);
var output = new TaskItem(
Path.ChangeExtension(fullPath, ".processed" + extension));
// Copy all metadata from input to output
asset.CopyMetadataTo(output);
// Add new metadata
output.SetMetadata("ProcessedAt",
DateTime.UtcNow.ToString("o"));
results.Add(output);
}
ProcessedAssets = results.ToArray();
return true;
}
}
<ItemGroup>
<GameAsset Include="textures/*.png">
<Category>texture</Category>
</GameAsset>
<GameAsset Include="models/*.fbx">
<Category>model</Category>
</GameAsset>
</ItemGroup>
<Target Name="ProcessGameAssets" BeforeTargets="Build">
<ProcessAssets Assets="@(GameAsset)">
<Output TaskParameter="ProcessedAssets" ItemName="ProcessedGameAsset" />
</ProcessAssets>
</Target>
内联任务(CodeTaskFactory)
对于不值得单独创建程序集的简单任务,可以使用 CodeTaskFactory 在 MSBuild XML 中内联定义任务逻辑。代码会在构建时编译。
<UsingTask TaskName="GetTimestamp"
TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Format ParameterType="System.String" Required="false" />
<Timestamp ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Code Type="Fragment" Language="cs">
<![CDATA[
var format = string.IsNullOrEmpty(Format) ? "yyyyMMdd-HHmmss" : Format;
Timestamp = DateTime.UtcNow.ToString(format);
]]>
</Code>
</Task>
</UsingTask>
<Target Name="StampBuild" BeforeTargets="CoreCompile">
<GetTimestamp Format="yyyy.MMdd.HHmm">
<Output TaskParameter="Timestamp" PropertyName="BuildTimestamp" />
</GetTimestamp>
<Message Importance="high" Text="Build timestamp: $(BuildTimestamp)" />
</Target>
CodeTaskFactory 代码类型
Type | 描述 |
|---|---|
Fragment | 代码在 Execute() 方法体内运行。将参数作为局部变量访问。 |
Method | 代码是一个完整的方法体。必须包含 return true; 或 return false;。 |
Class | 代码是一个完整的类。必须实现 ITask 或继承 Task。 |
添加程序集引用
<UsingTask TaskName="ValidateJson"
TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<JsonFile ParameterType="System.String" Required="true" />
<IsValid ParameterType="System.Boolean" Output="true" />
</ParameterGroup>
<Task>
<Reference Include="System.Text.Json" />
<Code Type="Fragment" Language="cs">
<![CDATA[
try
{
var content = System.IO.File.ReadAllText(JsonFile);
System.Text.Json.JsonDocument.Parse(content);
IsValid = true;
}
catch (System.Text.Json.JsonException)
{
IsValid = false;
Log.LogWarning("Invalid JSON: {0}", JsonFile);
}
]]>
</Code>
</Task>
</UsingTask>
何时使用内联任务与编译任务: 内联任务最适合简单、独立的逻辑(时间戳、文件检查、字符串处理)。对于复杂逻辑、多个依赖项,或需要在多个项目间复用的情况,应编译任务程序集并通过 NuGet 分发。
UsingTask 注册
UsingTask 告知 MSBuild 在哪里查找自定义任务实现。它必须出现在任何使用该任务的目标之前。
从已编译的程序集注册
<UsingTask TaskName="MyCompany.Build.GenerateFileHash"
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\MyCompany.Build.Tasks.dll" />
<UsingTask TaskName="MyCompany.Build.GenerateFileHash"
AssemblyName="MyCompany.Build.Tasks, Version=1.0.0.0, Culture=neutral" />
任务名称解析
| 属性 | 值 | 效果 |
|---|---|---|
TaskName | GenerateFileHash | 短名称;优先匹配第一个 |
TaskName | MyCompany.Build.GenerateFileHash | 完全限定名称;精确匹配 |
AssemblyFile | 相对或绝对路径 | 从文件路径加载 |
AssemblyName | 强名称或简单名称 | 按程序集标识加载 |
对于通过 NuGet 包分发的任务,请将 AssemblyFile 与 $(MSBuildThisFileDirectory) 搭配使用。该路径相对于 .targets 文件解析,而非相对于使用该包的项目。
条件注册
<UsingTask TaskName="MyCompany.Build.CodeGen"
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\MyCompany.Build.Tasks.dll"
Condition="Exists('$(MSBuildThisFileDirectory)..\tools\MyCompany.Build.Tasks.dll')" />
任务调试
调试自定义 MSBuild 任务时,需要将调试器附加到 MSBuild 进程。
MSBUILDDEBUGONSTART
在运行生成之前设置 MSBUILDDEBUGONSTART 环境变量:
| 值 | 行为 |
|---|---|
1 | MSBuild 在启动时调用 Debugger.Launch(),显示 JIT 调试器附加对话框 |
2 | MSBuild 等待调试器附加(在控制台中输出 PID),然后继续 |
# Option 1: Launch debugger dialog (Windows)
set MSBUILDDEBUGONSTART=1
dotnet build
# Option 2: Wait for debugger attach (cross-platform)
export MSBUILDDEBUGONSTART=2
dotnet build
# MSBuild prints: "Waiting for debugger to attach (PID: 12345)..."
# Attach from VS or VS Code, then execution continues
调试工作流
- 在终端中设置
MSBUILDDEBUGONSTART=2。 - 对使用自定义任务的项目运行
dotnet build。 - MSBuild 暂停并输出进程 ID。
- 附加调试器(Visual Studio:Debug > Attach to Process;VS Code:.NET Attach)。
- 在任务的
Execute()方法中设置断点。 - 继续执行,调试器便会命中断点。
以编程方式启动调试器
对于开发版本,可在任务中添加条件式调试器启动代码:
public override bool Execute()
{
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
System.Diagnostics.Debugger.Launch();
}
#endif
// Task logic ...
return true;
}
发布前请移除或禁用调试器启动代码。 仅发布任务程序集的 Release 版本。#if DEBUG 保护可确保生产环境中不会弹出调试器提示。
任务的 NuGet 打包
自定义 MSBuild 任务通常通过 NuGet 包分发。包必须将 .props/.targets 文件和任务程序集放置在正确的文件夹中。
包布局
MyCompany.Build.Tasks.nupkg
build/
MyCompany.Build.Tasks.props (optional: set defaults)
MyCompany.Build.Tasks.targets (UsingTask + target definitions)
buildTransitive/
MyCompany.Build.Tasks.props (optional: set defaults)
MyCompany.Build.Tasks.targets (UsingTask + target definitions)
tools/
net8.0/ (matches csproj TargetFramework)
MyCompany.Build.Tasks.dll (task assembly)
(other dependencies)
build 与 buildTransitive
| 文件夹 | 范围 |
|---|---|
build/ | Targets/props 仅应用于直接使用者 |
buildTransitive/ | Targets/props 应用于直接使用者及所有传递引用该包的项目 |
对于必须在依赖关系图中的每个项目中运行的任务(例如代码分析器、源生成器),请使用 buildTransitive/。对于仅适用于使用该包的项目的任务,请使用 build/。
NuGet 包的 .targets 文件
<Project>
<UsingTask TaskName="MyCompany.Build.GenerateFileHash"
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\net8.0\MyCompany.Build.Tasks.dll" />
<Target Name="_MyCompanyHashOutputs"
AfterTargets="Build"
Condition="'$(GenerateOutputHashes)' == 'true'">
<GenerateFileHash InputFile="$(TargetPath)">
<Output TaskParameter="Hash" PropertyName="_OutputHash" />
</GenerateFileHash>
<Message Importance="high"
Text="Output hash: $(_OutputHash)" />
</Target>
</Project>
NuGet 包的 .props 文件
<Project>
<PropertyGroup>
<GenerateOutputHashes Condition="'$(GenerateOutputHashes)' == ''">false</GenerateOutputHashes>
</PropertyGroup>
</Project>
任务包的项目文件
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>true</IsPackable>
<PackageId>MyCompany.Build.Tasks</PackageId>
<PackageVersion>1.0.0</PackageVersion>
<Description>Custom MSBuild tasks for MyCompany build pipeline</Description>
<IncludeBuildOutput>false</IncludeBuildOutput>
<NoWarn>$(NoWarn);NU5100</NoWarn>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Framework" Version="17.8.3"
PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.8.3"
PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)/**/*.dll" Pack="true"
PackagePath="tools/$(TargetFramework)/" />
</ItemGroup>
<ItemGroup>
<None Include="build/**" Pack="true" PackagePath="build/" />
<None Include="buildTransitive/**" Pack="true" PackagePath="buildTransitive/" />
</ItemGroup>
</Project>
关键 csproj 设置:
IncludeBuildOutput=false可防止任务 DLL 出现在lib/文件夹中(否则会将其作为编译引用添加到使用者项目中)。DevelopmentDependency=true将该包标记为仅在生成时使用,因此不会将其传递到使用者的运行时依赖项中。- 在 MSBuild 框架引用上设置
PrivateAssets="all",可防止它们成为传递依赖项。
Agent 注意事项
-
返回
false却不记录错误。 如果Execute()返回false,但从未调用Log.LogError,MSBuild 会报告一条笼统的“任务失败”消息,且没有可采取行动的信息。返回false前务必记录错误。 -
使用
Console.WriteLine而不是Log.LogMessage。 控制台输出会绕过 MSBuild 的日志基础设施,可能不会出现在生成日志、二进制日志或 IDE 错误列表中。请务必使用Log.LogMessage、Log.LogWarning或Log.LogError。 -
引用
IIncrementalTask却未进行版本限制。 此接口需要 MSBuild 17.8+(.NET 8 SDK)。引用此接口的任务在较旧的 MSBuild 版本上会因TypeLoadException而无法加载。如果需要支持较旧的 SDK,请改用目标级别的Inputs/Outputs。如果任务必须同时支持新旧 MSBuild,请针对不同的 MSBuild 版本范围提供单独的任务程序集,或使用带有版本常量的#if条件编译。 -
将任务 DLL 放入 NuGet 的
lib/文件夹。 这会将程序集作为编译引用添加到使用方项目中,污染其类型命名空间。请设置IncludeBuildOutput=false,并将文件打包到tools/中。 -
忘记在 MSBuild 框架包引用上设置
PrivateAssets="all"。 如果未设置,Microsoft.Build.Framework和Microsoft.Build.Utilities.Core会成为使用方项目的传递依赖,导致版本冲突。 -
在
AssemblyFile中使用相对于项目的路径。 在 NuGet 包中,.targets文件的位置与使用方项目不同。请使用$(MSBuildThisFileDirectory),以构建相对于.targets文件本身的路径。 -
在发布版本中保留
Debugger.Launch()。 发布含有无条件Debugger.Launch()的任务会导致 CI/CD 服务器上的生成暂停。请用#if DEBUG保护该调用,或在打包前将其删除。 -
内联任务依赖复杂。
CodeTaskFactory会在生成时编译代码,但可用的程序集引用有限。对于需要 NuGet 包或复杂类型层次结构的任务,请改为编译独立的任务程序集。