基于 C# 编写构建脚本

基于 C# 编写构建脚本

Intro

最近我们在迁移 CI,从原来的 Jenkin 迁移到了 Bitbucket Pipeline,在这一过程中,对于 bash 脚本不太熟悉的我也可以先借助我最熟悉的 C# 来达到目标,我也将我的开源项目中的借助 cake build 来写的构建脚本换成了原生的 C# 代码,并通过 dotnet-exec 来在 CI 的时候执行我的脚本,在之前每次需要修改 cake 脚本的时候都要去查 cake 支持的 API,使用原生的 C# 代码之后,就可以使用自己最熟悉的语言,使用自己最喜欢的 nuget 包来实现自己的目标

Sample

之前使用 cake build 的主要场景执行 dotnet build, dotnet test 以及 dotnet pack 和 dotnet nuget push,之前的 cake 脚本会使用 cake 提供的一些 API

Task("build")    
    .Description("Build")
    .IsDependentOn("clean")
    .IsDependentOn("restore")
    .Does(() =>
    {
      var buildSetting = new DotNetBuildSettings
      {
         NoRestore = true,
         Configuration = configuration
      };
      foreach (var project in srcProjects)
      {
         DotNetBuild(project.FullPath, buildSetting);
      }
    });

Task("test")    
    .Description("Test")
    .IsDependentOn("build")
    .Does(() =>
    {
      var testSettings = new DotNetTestSettings
      {
        NoRestore = false
      };
      foreach (var project in testProjects)
      {
        DotNetTest(project.FullPath, testSettings);
      }
    });

为了迁移的方便,同时也是借鉴 cake 的 API,改造出来下面的使用 API:

await BuildProcess.CreateBuilder()
    .WithSetup(() =>
    {
        // cleanup artifacts
        if (Directory.Exists("./artifacts/packages"))
            Directory.Delete("./artifacts/packages", true);
    })
    .WithTask("hello", b => b.WithExecution(() => Console.WriteLine("Hello dotnet-exec build")))
    .WithTask("build", b =>
    {
        b.WithDescription("dotnet build")
            .WithExecution(cancellationToken => ExecuteCommandAsync($"dotnet build {solutionPath}", cancellationToken))
            ;
    })
    .WithTask("test", b =>
    {
        b.WithDescription("dotnet test")
            .WithDependency("build")
            .WithExecution(async cancellationToken =>
            {
                foreach (var project in testProjects)
                {
                    await ExecuteCommandAsync($"dotnet test --collect:\"XPlat Code Coverage;Format=cobertura,opencover;ExcludeByAttribute=ExcludeFromCodeCoverage,Obsolete,GeneratedCode,CompilerGeneratedAttribute\" {project}", cancellationToken);
                }
            })
            ;
    })
    .WithTask("pack", b => b.WithDescription("dotnet pack")
        .WithDependency("test")
        .WithExecution(async cancellationToken =>
        {
            if (stable)
            {
                foreach (var project in srcProjects)
                {
                    await ExecuteCommandAsync($"dotnet pack {project} -o ./artifacts/packages", cancellationToken);
                }
            }
            else
            {
                var suffix = $"preview-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
                foreach (var project in srcProjects)
                {
                    await ExecuteCommandAsync(
                        $"dotnet pack {project} -o ./artifacts/packages --version-suffix {suffix}", cancellationToken);
                }
            }

            if (noPush)
            {
                Console.WriteLine("Skip push there's noPush specified");
                return;
            }

            if (string.IsNullOrEmpty(apiKey))
            {
                // try to get apiKey from environment variable
                apiKey = Environment.GetEnvironmentVariable("NuGet__ApiKey");

                if (string.IsNullOrEmpty(apiKey))
                {
                    Console.WriteLine("Skip push since there's no apiKey found");
                    return;
                }
            }

            // push nuget packages
            foreach (var file in Directory.GetFiles("./artifacts/packages/", "*.nupkg"))
            {
                await ExecuteCommandAsync($"dotnet nuget push {file} -k {apiKey} --skip-duplicate", cancellationToken);
            }
        }))
    .WithTask("Default", b => b.WithDependency("hello").WithDependency("pack"))
    .Build()
    .ExecuteAsync(target, InvokeHelper.GetExitToken());

构建的过程定义为了 BuildProcess, 一个 BuildProcess 可以拥有 Setup/Cleanup 对应的 BuildTask, 一个 BuildTask 可以依赖于若干个其他 BuildTask

BuildProcess/BuildTask/BuildProcessBuilder/BuildTaskBuilder 定义如下,仅供参考:

file sealed class BuildProcess
{
    public IReadOnlyCollection<BuildTask> Tasks { get; init; } = [];
    public Func<Task>? Setup { private get; init; }
    public Func<Task>? Cleanup { private get; init; }

    public async Task ExecuteAsync(string target, CancellationToken cancellationToken = default)
    {
        var task = Tasks.FirstOrDefault(x => x.Name == target);
        if (task is null)
            throw new InvalidOperationException("Invalid target to execute");

        try
        {
            if (Setup != null)
                await Setup.Invoke();

            cancellationToken.ThrowIfCancellationRequested();
            await ExecuteTask(task, cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            Console.WriteLine("Operation canceled");
        }
        finally
        {
            if (Cleanup != null)
                await Cleanup.Invoke();
        }
    }

    private static async Task ExecuteTask(BuildTask task, CancellationToken cancellationToken)
    {
        Console.WriteLine($"===== Task {task.Name} {task.Description} executing ======");
        // execute dependency tasks
        foreach (var dependencyTask in task.Dependencies)
        {
            await ExecuteTask(dependencyTask, cancellationToken);
        }
        // execute task
        await task.ExecuteAsync(cancellationToken);
        Console.WriteLine($"===== Task {task.Name} {task.Description} executed ======");
    }

    public static BuildProcessBuilder CreateBuilder()
    {
        return new BuildProcessBuilder();
    }
}

file sealed class BuildProcessBuilder
{
    private readonly List<BuildTask> _tasks = [];
    private Func<Task>? _setup, _cleanup;

    public BuildProcessBuilder WithTask(string name, Action<BuildTaskBuilder> buildTaskConfigure)
    {
        var buildTaskBuilder = new BuildTaskBuilder(name);
        buildTaskBuilder.WithTaskFinder(s =>
            _tasks.Find(t => t.Name == s) ?? throw new InvalidOperationException($"No task found with name {s}"));
        buildTaskConfigure.Invoke(buildTaskBuilder);
        var task = buildTaskBuilder.Build();
        _tasks.Add(task);
        return this;
    }

    public BuildProcessBuilder WithSetup(Action setupFunc)
    {
        _setup = setupFunc.WrapTask();
        return this;
    }

    public BuildProcessBuilder WithSetup(Func<Task> setupFunc)
    {
        _setup = setupFunc;
        return this;
    }

    public BuildProcessBuilder WithCleanup(Action cleanupFunc)
    {
        _cleanup = cleanupFunc.WrapTask();
        return this;
    }

    public BuildProcessBuilder WithCleanup(Func<Task> cleanupFunc)
    {
        _cleanup = cleanupFunc;
        return this;
    }

    internal BuildProcess Build()
    {
        return new BuildProcess() { Tasks = _tasks, Setup = _setup, Cleanup = _cleanup };
    }
}

file sealed class BuildTask(string name, string? description, Func<CancellationToken, Task>? execution = null)
{
    public string Name => name;
    public string Description => description ?? name;

    public IReadOnlyCollection<BuildTask> Dependencies { get; init; } = [];

    public Task ExecuteAsync(CancellationToken cancellationToken = default) =>
        execution?.Invoke(cancellationToken) ?? Task.CompletedTask;
}

file sealed class BuildTaskBuilder(string name)
{
    private readonly string _name = name;

    private string? _description;
    private Func<CancellationToken, Task>? _execution;
    private readonly List<BuildTask> _dependencies = [];

    public BuildTaskBuilder WithDescription(string description)
    {
        _description = description;
        return this;
    }

    public BuildTaskBuilder WithExecution(Action execution)
    {
        _execution = execution.WrapTask().WrapCancellation();
        return this;
    }

    public BuildTaskBuilder WithExecution(Func<Task> execution)
    {
        _execution = execution.WrapCancellation();
        return this;
    }

    public BuildTaskBuilder WithExecution(Func<CancellationToken, Task> execution)
    {
        _execution = execution;
        return this;
    }

    public BuildTaskBuilder WithDependency(string dependencyTaskName)
    {
        if (_taskFinder is null) throw new InvalidOperationException("Dependency task name is not supported");

        _dependencies.Add(_taskFinder.Invoke(dependencyTaskName));
        return this;
    }

    private Func<string, BuildTask>? _taskFinder;

    internal BuildTaskBuilder WithTaskFinder(Func<string, BuildTask> taskFinder)
    {
        _taskFinder = taskFinder;
        return this;
    }

    public BuildTask Build()
    {
        var buildTask = new BuildTask(_name, _description, _execution) { Dependencies = _dependencies };
        return buildTask;
    }
}

ExecuteCommandAsync 封装了执行命令行操作,实现基于自己封装的一个 CommandExecutor,有需要可以参考 https://github.com/WeihanLi/WeihanLi.Common/blob/dev/src/WeihanLi.Common/Helpers/CommandExecutor.cs:

async Task ExecuteCommandAsync(string commandText, CancellationToken cancellationToken = default)
{
    Console.WriteLine($"Executing command: \n    {commandText}");
    Console.WriteLine();

    var result = await CommandExecutor.ExecuteCommandAndOutputAsync(commandText, cancellationToken: cancellationToken);
    result.EnsureSuccessExitCode();
    Console.WriteLine();
}

你也可以自己直接基于 Process 类型进行封装或者基于 CliWrap 来实现

async Task ExecuteCommandAsync(string commandText, KeyValuePair<string, string>[]? replacements = null)
{
    var commandTextWithReplacements = commandText;
    if (replacements is { Length: > 0})
    {
        foreach (var item in replacements)
        {
            commandTextWithReplacements = commandTextWithReplacements.Replace(item.Value, item.Key);
        }
    }
    Console.WriteLine($"Executing command: \n    {commandTextWithReplacements}");
    Console.WriteLine();
    var splits = commandText.Split([' '], 2);
    var result = await Cli.Wrap(splits[0])
        .WithArguments(splits.Length > 1 ? splits[1] : string.Empty)
        .WithStandardErrorPipe(PipeTarget.ToStream(Console.OpenStandardError()))
        .WithStandardOutputPipe(PipeTarget.ToStream(Console.OpenStandardOutput()))
        .ExecuteAsync();
    Console.WriteLine();
    Console.WriteLine($"ExitCode: {result.ExitCode} ElapsedTime: {result.RunTime}");
}

注意的话会发现这个方法多了一个 replacements 方法,主要是用来替换打印出来命令行中的敏感信息,比如 api key 等信息,可以根据自己的需要进行改造

最后我们为了比较方便的自定义,提供了一些参数可以从命令行中直接获取

var target = Argument("target", "Default");
var apiKey = Argument("apiKey", "");
var stable = ArgumentBool("stable");
var noPush = ArgumentBool("noPush");

Console.WriteLine($$"""
Arguments:

target: {{target}}
stable: {{stable}}
noPush: {{noPush}}
args:   
{{args.StringJoin("\n")}}

""");

bool ArgumentBool(string argumentName, bool defaultValue = default)
{
    var value = ArgumentInternal(argumentName);
    if (value is null) return defaultValue;
    if (value == string.Empty) return true;
    return bool.Parse(value);
}

string? Argument(string argumentName, string? defaultValue = default)
{
    return ArgumentInternal(argumentName) ?? defaultValue;
}

string? ArgumentInternal(string argumentName)
{
    for (var i = 0; i < args.Length; i++)
    {
        if (args[i] == $"--{argumentName}" || args[i] == $"-{argumentName}")
        {
            if (i + 1 == args.Length || args[i + 1].StartsWith('-'))
                return string.Empty;

            return args[i + 1];
        }

        if (args[i].StartsWith($"-{argumentName}=", StringComparison.Ordinal) 
            || args[i].StartsWith($"-{argumentName}:", StringComparison.Ordinal))
            return args[i][$"-{argumentName}=".Length..];

        if (args[i].StartsWith($"--{argumentName}=", StringComparison.Ordinal)
            || args[i].StartsWith($"--{argumentName}:", StringComparison.Ordinal))
            return args[i][$"--{argumentName}=".Length..];
    }

    return null;
}

最后原来 cake 的 build.ps1build.sh 我们也来更新一下,之前是安装 cake dotnet tool 并执行命令,现在变成了安装 dotnet-exec tool

build.ps1:

# Install the lastest dotnet-execute tool
dotnet tool update --global dotnet-execute --prerelease

# Execute CSharp script
Write-Host "dotnet-exec ./build/build.cs --args $ARGS" -ForegroundColor GREEN
dotnet-exec ./build/build.cs --args "$ARGS"

buil.sh

#!/bin/sh

# Install dotnet-exec tool
dotnet tool update --global dotnet-execute --prerelease

# configure dotnet tool path
export PATH="$PATH:$HOME/.dotnet/tools"

echo "dotnet-exec ./build/build.cs --args $@"
dotnet-exec ./build/build.cs --args "$@"

来看一下执行效果

c017d148ad858fca1afc5bc4f0696c25.png

dotnet-exec hello

a96d827c7cbf60a8020c5c6ffa5f6a1a.png

dotnet-exec build

More

如果担心代码写起来有点麻烦可以在脚本目录下脚本一个项目文件来借助 IDE 来在有代码提示的情况下完成代码,第一个项目里我写的时候是加了一个项目文件,项目文件就是默认的项目文件,然后添加了我自己写的一个包引用,如下:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="WeihanLi.Common" />
  </ItemGroup>
</Project>

后面的项目就是直接拷贝过去改下项目文件路径基本就好了

改造之后,以后的维护成本就会方便很多,比如说之前 dotnet test 只是跑一下 test case,我们可以比较方便地添加命令行参数来生成代码覆盖率报告等

完整的代码可以在 Github 上找到,有兴趣的朋友可以自己研究一下哈,最后的链接是已经改造好的项目可以作为参考

最后除了 dotnet-exec 之外,也可以考虑 dotnet-script 来执行 C# 脚本

dotnet-script 的语法稍微不同,比如引用 nuget 包,而 dotnet-exec 可以相对来说好一些可以在源码基础上执行,不影响打开项目文件在 IDE 里编译

References

  • https://github.com/WeihanLi/dotnet-exec/blob/0.15.0/build.cake

  • https://github.com/WeihanLi/dotnet-exec/blob/main/build/build.cs

  • https://github.com/WeihanLi/dotnet-httpie/blob/dev/build/build.cs

  • https://github.com/WeihanLi/WeihanLi.Common/blob/dev/build/build.cs

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值