mcpField
D
dotnet-msbuild-tasks

dotnet-msbuild-tasks

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

关于 dotnet-msbuild-tasks

面向 .NET 构建人员的自定义 MSBuild 任务编写指南,涵盖任务实现、外部 CLI 封装、增量执行、调试和 NuGet 分发。内容以 .NET 8 SDK 与 MSBuild 17.8+ 为基准。

主要功能

  1. 01实现 ITask 任务
  2. 02用 ToolTask 封装 CLI
  3. 03借助 IIncrementalTask 增量处理
  4. 04定义内联任务与参数
  5. 05调试并打包任务

适用场景

  1. 01为构建流程编写自定义逻辑
  2. 02将外部命令行工具接入 MSBuild
  3. 03减少大型文件集合的重复处理
  4. 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()自定义处理非零退出代码的方式
StandardOutputLoggingImportancestdout 的日志级别(默认值:Low)
StandardErrorLoggingImportancestderr 的日志级别(默认值: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],了解目标级别的增量处理模式。

工作原理
  1. 目标声明 Inputs 和 Outputs(必需——引擎会使用它们检测更改)。
  2. MSBuild 比较时间戳,确定哪些输入已过期。
  3. 如果任务实现了 IIncrementalTask,MSBuild 会向任务的 ITaskItem[] 参数传递已更改的项,而不是完整集合。
  4. 任务仅处理这些项——无需手动处理时间戳逻辑。

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"
booltrue/falseVerbose="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" />
任务名称解析
属性值效果
TaskNameGenerateFileHash短名称;优先匹配第一个
TaskNameMyCompany.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 环境变量:

值行为
1MSBuild 在启动时调用 Debugger.Launch(),显示 JIT 调试器附加对话框
2MSBuild 等待调试器附加(在控制台中输出 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
调试工作流
  1. 在终端中设置 MSBUILDDEBUGONSTART=2。
  2. 对使用自定义任务的项目运行 dotnet build。
  3. MSBuild 暂停并输出进程 ID。
  4. 附加调试器(Visual Studio:Debug > Attach to Process;VS Code:.NET Attach)。
  5. 在任务的 Execute() 方法中设置断点。
  6. 继续执行,调试器便会命中断点。
以编程方式启动调试器

对于开发版本,可在任务中添加条件式调试器启动代码:

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 注意事项

  1. 返回 false 却不记录错误。 如果 Execute() 返回 false,但从未调用 Log.LogError,MSBuild 会报告一条笼统的“任务失败”消息,且没有可采取行动的信息。返回 false 前务必记录错误。

  2. 使用 Console.WriteLine 而不是 Log.LogMessage。 控制台输出会绕过 MSBuild 的日志基础设施,可能不会出现在生成日志、二进制日志或 IDE 错误列表中。请务必使用 Log.LogMessage、Log.LogWarning 或 Log.LogError。

  3. 引用 IIncrementalTask 却未进行版本限制。 此接口需要 MSBuild 17.8+(.NET 8 SDK)。引用此接口的任务在较旧的 MSBuild 版本上会因 TypeLoadException 而无法加载。如果需要支持较旧的 SDK,请改用目标级别的 Inputs/Outputs。如果任务必须同时支持新旧 MSBuild,请针对不同的 MSBuild 版本范围提供单独的任务程序集,或使用带有版本常量的 #if 条件编译。

  4. 将任务 DLL 放入 NuGet 的 lib/ 文件夹。 这会将程序集作为编译引用添加到使用方项目中,污染其类型命名空间。请设置 IncludeBuildOutput=false,并将文件打包到 tools/ 中。

  5. 忘记在 MSBuild 框架包引用上设置 PrivateAssets="all"。 如果未设置,Microsoft.Build.Framework 和 Microsoft.Build.Utilities.Core 会成为使用方项目的传递依赖,导致版本冲突。

  6. 在 AssemblyFile 中使用相对于项目的路径。 在 NuGet 包中,.targets 文件的位置与使用方项目不同。请使用 $(MSBuildThisFileDirectory),以构建相对于 .targets 文件本身的路径。

  7. 在发布版本中保留 Debugger.Launch()。 发布含有无条件 Debugger.Launch() 的任务会导致 CI/CD 服务器上的生成暂停。请用 #if DEBUG 保护该调用,或在打包前将其删除。

  8. 内联任务依赖复杂。 CodeTaskFactory 会在生成时编译代码,但可用的程序集引用有限。对于需要 NuGet 包或复杂类型层次结构的任务,请改为编译独立的任务程序集。


参考资料

评论

支持 Markdown