65.9K
CodeProject 正在变化。 阅读更多。
Home

无服务器 - DevOps 小帮手

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2019 年 4 月 29 日

CPOL

18分钟阅读

viewsIcon

21581

downloadIcon

85

为什么不使用无服务器计算来执行 Azure DevOps 中的维护任务?

Serverless

目录

引言

近年来,serverless 这个词越来越流行。迟早会有一个 serverless 挑战出现在 Code Project 上。幸运的是,这一次,我有一个可行的想法想要呈现。

我目前的项目很多都是通过 Azure DevOps 完成的。我个人认为这个平台很棒,因为它提供了一个完整统一的包来处理代码项目。它有仪表板、仓库、完整的 CI/CD 解决方案,甚至还有一个集成的包管理器(例如,NPM、NuGet 等)。通常,我们使用包处理很多事情——构建小型库,然后将它们聚合到服务中。

一个非常常见的问题是:我们有一组常用库,这些库尚未稳定,因此会发生很多变化(通常是无损的,但仍然很重要,应尽快发布)。在库的 PR 被接受并构建了包之后,我们需要更新所有使用的服务以使用此库的最新版本。这非常耗时且效率不高。

简而言之,对于每个服务,我们需要

  • 拉取(最新状态的)开发分支
  • 创建一个新分支用于引用更新(特性分支)
  • 更新引用到已更改的库
  • 暂存/提交更改
  • 推送特性分支
  • 创建从特性分支到开发分支的拉取请求

在本文中,我们将使用 Azure Function 自动化整个过程。Serverless 计算万岁!

背景

2014 年,亚马逊推出了一项名为 Lambda 的新服务,该服务允许开发人员将简单函数作为完整的计算资源提供。无需管理服务器、添加或维护运行时,或选择计划。成本仅基于已发布函数的调用次数。

很快,许多竞争对手也推出了类似的服务。此外,开源项目也开始支持面向函数的开发方法。诞生了两个新术语:FaaS(Function-as-a-Service,函数即服务)和 serverless。前者描述了单个函数的部署,无需发布 Docker 镜像或完整运行时即可完成所有工作。后者描述了 BaaS(Backend-as-a-Service,后端即服务)系统的使用,其中计算资源对客户完全隐藏。

虽然 FaaS 可能是 serverless 中用于在线提供功能的模型之一,但 FaaS 并非必须是 serverless。事实上,大多数 FaaS 用户仍然会维护他们的服务器,或者负责许多决策以保证托管服务的可靠性。大多数时候,FaaS 关注的是统一性以及部署的简便性/速度,而不是 serverless。

Serverless - 稍等片刻?

在云中提供功能的 serverless 宣传,就像将新款智能手机宣传为“无手”一样。显然,一天结束时,某些实际硬件需要工作来运行某些计算。这一点无法避免。

Serverless Comic

© commitstrip - 版权所有。

如果我们询问 AWS Lambda 的产品经理,当他听到“serverless”时会想到什么,我们会得到以下回答

对我来说,serverless 意味着与服务器相关的活动/职责不再是你的关注点。一个 serverless 解决方案,用于完成你以前需要服务器才能完成的事情,至少会符合以下四个标准——简单但重要的原始功能、自动扩展、从不为闲置付费、以及内置的可靠性/可用性。

此陈述中有一些非常好的优点。可靠性?每个人都想要!没有不必要的付费?听起来好得令人难以置信!可扩展性?为什么不呢!不用说,每个优点都有其缺点。

根据维基百科,serverless 还带来了一些其他优势。

Serverless 计算可以简化将代码部署到生产环境的过程。扩展、容量规划和维护操作可能对开发人员或操作员隐藏。Serverless 代码可以与以传统样式部署的代码(如微服务)结合使用。或者,应用程序可以被编写成纯粹的 serverless,根本不使用预配的服务器。

哇!为什么我们不只使用 serverless 计算呢?结果发现 serverless 有其首选的用例,但许多“经典”问题领域并不适合这种计算。

事实上,对于大部分处于空闲状态的应用程序,serverless 可以真正节省时间,除非应用程序有非常特殊的需求。对于永久调用的应用程序,serverless 在经济上可能是一种倒退,但由于可靠性和可扩展性,仍然可能可行。重要的是不要将可扩展性因素与性能混淆,后者的性能通常比等效的服务器端运行时差。

总而言之,在大多数情况下,这不是非黑即白的问题,而是取决于多个变量来确定 serverless 是否是解决特定问题的正确方法。目前相当强的供应商锁定、隐私和安全方面的缺点不利于决策。

我们确切知道的是,serverless 非常适合作为连接两个服务(尤其是同一云或平台内运行的服务)的“粘合剂”。在我们的示例项目中,我们将粘合我们 Azure DevOps 设置中的两部分。

实际上,由于 Webhook 应该只会有周期性(不可预测)的需求,让它们在 serverless 架构中运行是很棒的。此外,Webhook 可以被视为经典软件中扩展/插件的 Web 版本。大多数时候,插件也只是将一个软件连接到另一个软件——非常轻量级且任务集中。如果我们以类似的方式开发 Webhook,我们就会立即看到在 serverless 环境中使用 FaaS 是有意义的。

作战计划

我们的目标是编写一个 Webhook,当某个构建完成时(例如,创建一个 NuGet 包)触发代码更改(以拉取请求的形式)。

让我们快速看一下我们的目标图

The usage diagram of DevOps Little Helper

为了实现这一点,应该遵循以下作战计划,该计划允许我们分步完成此任务(或项目)。

  1. 本地测试(模拟)
  2. 本地测试(固定数据)
  3. 本地测试(使用环境变量)
  4. 发布(首次部署)
  5. 在线测试(固定数据,环境变量)
  6. 在 Azure DevOps 中设置触发器
  7. 本地测试(可变数据)
  8. 更新(后续部署)
  9. 在线测试(可变数据)
  10. Azure DevOps 的完整集成测试

因此,我们将从一个模拟的 Azure Function 开始,该函数将在本地进行测试,以了解我们在这里处理什么。然后,我们将提供一些代码说明我们的 Azure Function 应该如何工作(只是不太灵活/硬编码)。之后,我们将通过使用环境变量使其更灵活一些。

此时,我们就可以发布第一个草稿了。之后,我们可以执行在线本地测试,看看部署需要做什么。一旦此步骤正常工作,我们将通过在已发布的 Webhook 上创建订阅来集成 Azure DevOps。

最后,我们只需要执行真实世界的测试,使用完全灵活的实现。听起来很简单,对吧?那么让我们从基础开始!

Azure Function with C#

我们从 Visual Studio 开始。实际上,如今,使用 Visual Studio Code 在生产力方面我们可以非常相似,但使用 Visual Studio 可以获得(几乎)开箱即用的最佳体验。我将使用 Visual Studio 2017,但步骤应该与 Visual Studio 2019 相当。确保安装了 Azure Development Tools。

第一步

幸运的是,已经有一个模板可以使用 C# 作为编程语言创建新的 Azure Function。我们可以将样板设置为“HTTP 触发器”(这会给我们一个可以从任何地方调用的终结点),并将访问权限设置为函数。这样,就需要代码来触发函数。此代码应仅由调用 Webhook 的服务(以及出于调试目的——我们)知道。

Create new Azure Function Project

这几乎就完成了。到目前为止,Visual Studio 只为我们提供了一个不错的样板代码可以开始——但更重要的是,它已经将其连接到了正确的调试工具。让我们按 F5 运行应用程序...

Start of Azure Function Debug CLI

也许令我们惊讶的是,一个命令提示符会打开,显示一个漂亮的 Azure Function 标志 ASCII 艺术。显然,有人度过了一个美好的周末,喝了很多啤酒,有很多时间!

一段时间后,本地实例已完全启动并准备好接收请求。我们现在可以设置断点或暂停应用程序进行修改。

在 CLI 中,看起来如下

Azure Function Debug CLI Ready to Receive

注意端口是 7071。我们将需要它来触发请求。

现在,由于我们没有更改样板代码中的任何一行,因此该函数设置为允许 `GET` 和 `POST` 请求。我们可以使用 Postman 创建一个简单的请求,Postman 是一个非常适合测试(或手动使用)API 的小型应用程序。

Azure Function Trigger Endpoint

这是我们需要喝杯啤酒开始编码的时刻。你可以用你喜欢的(冰镇)饮料代替啤酒。天生就是巴伐利亚人——对我来说,选择非常简单。

DevOps 助手类

Azure Function 中的 Function 仅表示一个简单函数用作单个请求的处理程序——它并不限制我们必须在同一个函数内。实际上,我们可以使用任何我们想要的库、类和其他资产。我们应该使用与往常一样的编码模式和技术来编写可维护的实用代码。

我们现在开始使用一个简单的 `Helper` 类(是的,可能不是最好的名字——如果你愿意,可以将其重命名为更适合你的名称)。此类应处理与 Azure DevOps 的所有交互。

我们不想从零开始处理——坦率地说——Azure DevOps 背后的巨大 API。因此,我们将使用一个现有的库,它在现有的 RESTful API 之上提供了一个很好的抽象。官方包名为 `Microsoft.TeamFoundationServer.Client`,仍然使用旧名称。

上面的代码已经几乎完成了所有工作——不用担心,我们将回顾最重要的几行。

using Microsoft.Azure.WebJobs.Host;
using Microsoft.TeamFoundation.SourceControl.WebApi;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DevOpsLittleHelper
{
    internal class Helper
    {
        private readonly GitHttpClient _gitClient;

        public Helper(String pat)
        {
            var creds = new VssBasicCredential(String.Empty, pat);

            // Connect to Azure DevOps Services
            var connection = new VssConnection(new Uri(collectionUri), creds);

            // Get a GitHttpClient to talk to the Git endpoints
            _gitClient = connection.GetClient<GitHttpClient>();
        }

        public async Task<Int32> UpdateReferenceAndCreatePullRequest()
        {
            var repo = await _gitClient.GetRepositoryAsync
                         (projectName, repoName).ConfigureAwait(false);
            var commits = await _gitClient.GetCommitsAsync
                              (repo.Id, new GitQueryCommitsCriteria
            {
                ItemVersion = new GitVersionDescriptor
                {
                    Version = baseBranchName,
                    VersionType = GitVersionType.Branch,
                },
            }, top: 1).ConfigureAwait(false);
            var lastCommit = commits.FirstOrDefault()?.CommitId;
            var path = "SmartHotel360.PublicWeb/SmartHotel360.PublicWeb.csproj";
            var item = await _gitClient.GetItemContentAsync
                       (repo.Id, path, includeContent: true).ConfigureAwait(false);
            var oldContent = await GetContent(item).ConfigureAwait(false);
            var newContent = oldContent.Replace(
                $"<PackageReference 
                   Include=\"Microsoft.AspNetCore.All\" Version=\"2.0.0\" />",
                $"<PackageReference 
                   Include=\"Microsoft.AspNetCore.All\" Version=\"2.0.1\" />");
            var push = CreatePush(lastCommit, path, newContent);
            await _gitClient.CreatePushAsync(push, repo.Id).ConfigureAwait(false);
            var pr = CreatePullRequest();
            var result = await _gitClient.CreatePullRequestAsync
                         (pr, repo.Id).ConfigureAwait(false);
            return result.PullRequestId;
        }
    }
}

在上面的代码中,我们创建了一个小型类,它有一个字段,可以访问“**Git 客户端**”,Azure DevOps API 库可以在此之上进行工作。

客户端是使用 Azure DevOps 的个人访问令牌(PAT)创建的。安全令牌相当敏感,但对于这种简单的触发器非常有用。切勿将此令牌分享/展示给任何人!

此类真正核心的部分是 `UpdateReferenceAndCreatePullRequest` 方法。在这里,我们以前描述的所有步骤都以代码形式体现

  • 获取一个可能需要更改的仓库
  • 获取最后一个提交的 ID 作为引用
  • 获取 csproj 文件的内容
  • 更新 csproj 文件中的引用
  • 创建带有更新后的 csproj 文件的新提交/分支
  • 创建 PR 以将创建的分支/更改合并回

如作战计划所述,提供的方法是“`static`”,即当前我们不处理可变数量的引用、引用名称或其版本。此外,我们假设必须始终发生更改。

然而,稍后显示的这部分将是核心算法——只是稍微多一些情况和灵活性。仅此而已,即我们所需的一切?

如果我们将此代码复制粘贴,目前将无法工作。该类还应该包含一些常量(或字段——如果我们想使其更具可变性)

const String collectionUri = "https://your-name.visualstudio.com/";
const String projectName = "your-project";
const String repoName = "your-repo";
const String baseBranchName = "master";
const String newBranchName = "feature/auto-ref-update";

这些值决定了源文件应该从哪里读取,以及用于创建 PR 的新分支的名称应该是什么。我们也可以例如使用 guid 或类似的东西来随机化新分支的名称。

上面代码中缺少的另一件事是用于创建 Git Push 详细信息的函数。以下简单函数处理此问题

private static GitPush CreatePush(String commitId, String path, String content) => new GitPush
{
    RefUpdates = new List<GitRefUpdate>
    {
        new GitRefUpdate
        {
            Name = GetRefName(newBranchName),
            OldObjectId = commitId,
        },
    },
    Commits = new List<GitCommitRef>
    {
        new GitCommitRef
        {
            Comment = "Automatic reference update",
            Changes = new List<GitChange>
            {
                new GitChange
                {
                    ChangeType = VersionControlChangeType.Edit,
                    Item = new GitItem
                    {
                        Path = path,
                    },
                    NewContent = new ItemContent
                    {
                        Content = content,
                        ContentType = ItemContentType.RawText,
                    },
                }
            },
        }
    },
};

最后,还需要创建 Git Pull Request 的详细信息。另一个简单的函数如下所示

private GitPullRequest CreatePullRequest() => new GitPullRequest
{
    Title = "Automatic Reference Update",
    Description = "Updated the reference / automatic job.",
    TargetRefName = GetRefName(baseBranchName),
    SourceRefName = GetRefName(newBranchName),
};

太棒了!现在只剩下两个小的辅助函数——一个用于将标准分支转换为 ref 名称,另一个用于从流中获取内容。

private static String GetRefName(String branchName) => $"refs/heads/{branchName}";

private static async Task<String> GetContent(Stream item)
{
    using (var ms = new MemoryStream())
    {
        await item.CopyToAsync(ms).ConfigureAwait(false);
        var raw = ms.ToArray();
        return Encoding.UTF8.GetString(raw);
    }
}

此时,我们的 Azure Function 本身看起来与以下代码类似

[FunctionName("UpdateRepositories")]
public static async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, 
       "post", Route = null)] HttpRequest req, TraceWriter log)
{
    log.Info("Processing request ...");

    var helper = new Helper("***********");
    var prId = await helper.UpdateReferenceAndCreatePullRequest(log).ConfigureAwait(false);

    return new OkObjectResult(new
    {
        id = prId,
        message = $"Pull Request #{prId} created.",
    });
}

让我们尝试运行它以查看一些结果!再次,我们启动调试模式并通过 Postman 触发函数。

Azure Function Successfully Created

硬编码 PAT 是很糟糕的。理想情况下,我们应该通过环境变量或其他机制(例如,直接从 Azure Key Vault 获取)来存储它。

让我们在 Azure Function 中插入以下行

var pat = Environment.GetEnvironmentVariable("DEVOPS_PAT") ?? 
      throw new ArgumentException("Missing environment variable DEVOPS_PAT");

这允许我们构建类似 `new Helper(pat)` 的辅助函数。但是如何使用环境变量进行测试呢?Visual Studio 为我们解决了这个问题。

Azure Function Set Environment

此环境变量也需要在我们稍后的第一次部署(初始发布)期间(手动)设置。Visual Studio 将环境变量视为仅用于开发。我们也应该避免提交存储环境变量的文件,因为 PAT(抱歉在此重复)是非常敏感的信息。切勿将其发布到任何地方!

如果我们做的一切都正确,Azure DevOps 已经在 Web 应用程序中为我们显示了一个漂亮的拉取请求。

Azure DevOps New Pull Request

发布 Azure Function

可以直接从 Visual Studio 发布 Azure Function 项目。我个人认为通过 Visual Studio 的发布过程比在 Azure Portal 中更直接/更快/更简单。

第一步是右键单击项目并调用“**发布**”。然后我们选择“**Azure Function**”作为目标。显然,我们也可以覆盖现有的——但在这种情况下(首次),我们想从一个新的开始。

Publish New Azure Function

我们需要填写所有详细信息。与“serverless”的含义形成鲜明对比的是,我们需要选择一些“计划”(与机器大小相关)和其他详细信息,这些细节除了隐藏基础设施细节之外,什么都做。依我看,这使得 Azure Functions 仅仅是 FaaS 而不是 serverless,而 AWS Lambda 才是真正的 serverless 和 FaaS。但我怎么能评判呢……我们继续吧?

由于我们的 Azure Function 只是作为两个服务之间的粘合剂,我们拒绝任何提供的数据库选择——现在,我们生活在纯逻辑中,而不是数据。

Azure Function Create Details

按下“**创建**”将为我们配置所有必需的服务。因此,这一步需要相当长的时间(大约 5-10 分钟,取决于各种因素——包括我们的 Azure Function 应用的大小和我们的互联网连接速度)。最后,我们将看到一个特殊的屏幕,它为我们提供了新 Azure Function 的摘要。

Azure Function Publish Completed

现在我们可以再次使用 Postman 测试我们的在线(活动)Azure Function。再次收到相同的结果(别忘了设置正确的 PAT 环境变量——否则,我们将收到内部服务器错误,代码 500!)。

使用 Azure Portal,我们可以查看(和更改)可用的环境变量。此对话框应该从标准的 App Service 中熟悉。

Environment Setting for Azure Function

是时候通过创建订阅来连接 Azure DevOps 了。

Azure DevOps 订阅设置

我们开始在 Azure DevOps 中为我们的项目添加一个新订阅。只需单击服务挂钩/“创建订阅”即可打开一个新对话框,用于添加我们刚刚部署的 Webhook。

Service Hooks Setting in Azure DevOps

有多种选择(为许多流行服务预制配置)。在我们的例子中,我们想要最灵活和最强大的解决方案。我们想要一个标准的 Webhook。

Service Hook Selection in Azure DevOps

我们将触发器设置为要监视的构建管道。请记住:构建管道应完成特定 NuGet 包的发布。包引用是我们想要在选定仓库中更新的内容。

在下面的示例屏幕截图中,我们将管道设置为单个值——我们也可以监视所有构建管道。由于可以设置多个服务挂钩,因此无需不必要地打扰我们的 Azure Function。

Azure DevOps New Service Hook Trigger Setup

在操作中,我们必须将 URL 设置为我们的 Azure Function。此 URL 应包含 `code` 查询参数,用于 Azure DevOps 对我们 Azure Function 的身份验证。我们可以通过例如我们要求的标头提供额外的安全性。在我们的例子中,我们仅使用 `code` 参数就感觉相当安全。

其余详细信息可以保留原样。我们想要完整的响应以获取最大信息量。

Azure DevOps New Service Hook Action Setup

最后,在设置好服务挂钩后,我们应该对其进行测试。这将向我们的 Azure Function 发送一个模拟请求。

此测试的输出如下所示。重要的是,内容以 JSON 的形式传递,其中包含所有构建后相关信息。

我们应该使用内容来继续使我们的解决方案更加灵活。

Method: POST
URI: https://devopslittlehelper.azurewebsites.net/api/UpdateRepositories?code=****
HTTP Version: 1.1
Headers:
{
    Content-Type: application/json; charset=utf-8
}
Content:
{
    "subscriptionId": "00000000-0000-0000-0000-000000000000",
    "notificationId": 1,
    "id": "4a5d99d6-1c75-4e53-91b9-ee80057d4ce3",
    "eventType": "build.complete",
    "publisherId": "tfs",
    "message": {
        "text": "Build ConsumerAddressModule_20150407.2 succeeded",
        "html": "Build ... succeeded",
        "markdown": "Build [ConsumerAddressModule_20150407.2]
         (https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?
          pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&builduri=
          vstfs%3a%2f%2f%2fBuild%2fBuild%2f3) succeeded"
    },
    "detailedMessage": {
        "text": "Build ConsumerAddressModule_20150407.2 succeeded",
        "html": "Build ... succeeded",
        "markdown": "Build [ConsumerAddressModule_20150407.2]
         (https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?
          pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&builduri=vstfs%3a%2f%2f
          %2fBuild%2fBuild%2f3) succeeded"
    },
    "resource": {
        "uri": "vstfs:///Build/Build/2",
        "id": 2,
        "buildNumber": "ConsumerAddressModule_20150407.1",
        "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/
                71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/Builds/2",
        "startTime": "2015-04-07T18:04:06.83Z",
        "finishTime": "2015-04-07T18:06:10.69Z",
        "reason": "manual",
        "status": "succeeded",
        "dropLocation": "#/3/drop",
        "drop": {
            "location": "#/3/drop",
            "type": "container",
            "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/
                    _apis/resources/Containers/3/drop",
            "downloadUrl": "https://fabrikam-fiber-inc.visualstudio.com/
             DefaultCollection/_apis/resources/Containers/3/drop?/
             api-version=1.0&$format=zip&downloadFileName=ConsumerAddressModule_20150407.1_drop"
        },
        "log": {
            "type": "container",
            "url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/
                   _apis/resources/Containers/3/logs",
            "downloadUrl": "https://fabrikam-fiber-inc.visualstudio.com/
                           _apis/resources/Containers/3/logs?api-version=1.0&
                        $format=zip&downloadFileName=ConsumerAddressModule_20150407.1_logs"
        },
        "sourceGetVersion": "LG:refs/heads/master:600c52d2d5b655caa111abfd863e5a9bd304bb0e",
        "lastChangedBy": {
            "displayName": "Normal Paulk",
            "url": "https://fabrikam-fiber-inc.visualstudio.com/_apis/
                    Identities/d6245f20-2af8-44f4-9451-8107cb2767db",
            "id": "d6245f20-2af8-44f4-9451-8107cb2767db",
            "uniqueName": "fabrikamfiber16@hotmail.com",
            "imageUrl": "https://fabrikam-fiber-inc.visualstudio.com/
                         DefaultCollection/_api/_common/identityImage?
                         id=d6245f20-2af8-44f4-9451-8107cb2767db"
        },
        "retainIndefinitely": false,
        "hasDiagnostics": true,
        "definition": {
            "batchSize": 1,
            "triggerType": "none",
            "definitionType": "xaml",
            "id": 2,
            "name": "ConsumerAddressModule",
            "url": 
            "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/
            71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/Definitions/2"
        },
        "queue": {
            "queueType": "buildController",
            "id": 4,
            "name": "Hosted Build Controller",
            "url": 
            "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/build/Queues/4"
        },
        "requests": [
            {
                "id": 1,
                "url": "https://fabrikam-fiber-inc.visualstudio.com/
                DefaultCollection/71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/Requests/1",
                "requestedFor": {
                    "displayName": "Normal Paulk",
                    "url": "https://fabrikam-fiber-inc.visualstudio.com/
                    _apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db",
                    "id": "d6245f20-2af8-44f4-9451-8107cb2767db",
                    "uniqueName": "fabrikamfiber16@hotmail.com",
                    "imageUrl": "https://fabrikam-fiber-inc.visualstudio.com/
                    DefaultCollection/_api/_common/
                          identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db"
                }
            }
        ]
    },
    "resourceVersion": "1.0",
    "resourceContainers": {
        "collection": {
            "id": "..."
        },
        "account": {
            "id": "..."
        },
        "project": {
            "id": "..."
        }
    },
    "createdDate": "2019-04-28T22:47:44.6491834Z"
}

灵活的 DevOps 助手

让我们回顾一下前面提到的作战计划中剩下的内容

  • 本地测试(可变数据)
  • 更新(后续部署)
  • 在线测试(可变数据)
  • Azure DevOps 的完整集成测试

确实,我们只需要使我们的解决方案更灵活一些,并避免使用硬编码的引用等。

第一个动作是,我们不仅要针对单个仓库,还要针对所有可能的仓库。此外,我们应该使用动态包名和版本。毕竟,我们不仅想更新一个固定的包,还想在未来更新任何类型的包。此外,我们希望支持最新的包版本,而不仅仅是我们刚刚选择的某个包版本。

同样适用于基础分支。我们现在从仓库的默认分支中选择基础分支。最终代码如下

public async Task<List<Int32>> UpdateReferencesAndCreatePullRequests
      (String packageName, String packageVersion)
{
    var results = new List<Int32>();
    var allRepositories = await _gitClient.GetRepositoriesAsync
                           (_projectId).ConfigureAwait(false);
    Log($"Received repository list: ${String.Join
                      (", ", allRepositories.Select(m => m.Name))}.");

    foreach (var repo in allRepositories)
    {
        var pr = await UpdateReferencesAndCreatePullRequest
        (repo.Name, repo.DefaultBranch, packageName, packageVersion).ConfigureAwait(false);

        if (pr.HasValue)
        {
            results.Add(pr.Value);
        }
    }

    return results;
}

我们的更新方法已经有了很大的变化。我们现在有条件地创建拉取请求——只有当我们找到一个合适的并且需要更新的文件时。才会创建。

public async Task<Int32?> UpdateReferencesAndCreatePullRequest
(String repoName, String baseBranchName, String packageName, String packageVersion)
{
    var repo = await _gitClient.GetRepositoryAsync(_projectId, repoName).ConfigureAwait(false);
    Log($"Received info about repo ${repoName}.");

    var versionRef = GetVersionRef(baseBranchName);
    var baseCommitInfo = GetBaseCommits(versionRef);
    var commits = await _gitClient.GetCommitsAsync
                 (repo.Id, baseCommitInfo, top: 1).ConfigureAwait(false);
    var lastCommit = commits.FirstOrDefault()?.CommitId;
    Log($"Received info about last commits (expected 1, got {commits.Count}).");

    var items = await _gitClient.GetItemsAsync(_projectId, repo.Id, 
    versionDescriptor: versionRef, recursionLevel: 
                      VersionControlRecursionType.Full).ConfigureAwait(false);
    var changes = await GetChanges(repo.Id, packageName, packageVersion, 
                        versionRef, items).ConfigureAwait(false);
    return await CreatePullRequestIfChanged(repo.Id, changes, 
                        lastCommit, baseBranchName).ConfigureAwait(false);
}

整个方法的核心是 `GetChanges` 方法。

private async Task<List<GitChange>> GetChanges
(Guid repoId, String packageName, String packageVersion, 
GitVersionDescriptor versionRef, IEnumerable<GitItem> items)
{
    var changes = new List<GitChange>();

    foreach (var item in items)
    {
        if (item.Path.EndsWith(".csproj"))
        {
            var itemRef = await _gitClient.GetItemContentAsync
            (repoId, item.Path, includeContent: true, versionDescriptor: 
                               versionRef).ConfigureAwait(false);
            var oldContent = await itemRef.GetContent().ConfigureAwait(false);
            var newContent = ReplaceInContent(oldContent, packageName, packageVersion);

            if (!String.Equals(oldContent, newContent))
            {
                changes.Add(CreateChange(item.Path, newContent));
                Log($"Item content of {item.Path} received and changed.");
            }
        }
    }

    return changes;
}

在这里,我们选择所有 `csproj` 文件进行更仔细的检查和可能的更改。

其余的与之前基本相同。我们创建拉取请求(这次仅在我们有更改且可能有多个更改文件的情况下)并返回 ID。新方法收集来自不同仓库的所有 PR ID 并返回它们以供完整性。

Production Update

完整的示例源代码可在 GitHub 上找到。

使用代码

你可以直接 fork 代码并进行自己的调整。该解决方案在以下假设下工作

  • Azure DevOps 中的触发器是完成的构建作业的“构建成功”触发器
  • 引用的 URL 包含一个 `name` 参数,用于提供要更新的包引用(目前每个已安装的 Webhook 只能更新一个包)
  • 仅支持 NuGet 包(和 C# .NET SDK 项目文件 * .csproj*)
  • 构建成功后,最新包已可通过(Azure DevOps)NuGet Feed 获取

所有调整都可以通过 `Constants.cs` 文件完成。有两个环境变量

变量 必需? 描述
DEVOPS_ORGA 否,有备选方案 Azure DevOps 帐户的组织/名称
DEVOPS_PAT 访问 NuGet Feed 和仓库的个人访问令牌

结论

使用 Azure Functions 为我们提供了一种将两个系统粘合在一起的好方法。在这种情况下,我们通过一种方式扩展了 Azure DevOps 的基本功能,该方式可以自动更新其使用常见库的消耗服务仓库中的引用。仅此一项就是巨大的帮助,使我们能够专注于开发解决方案,而不是一直更新引用。

serverless 有其局限性。它肯定不是万能的,但在遇到正确的问题时是一个很好的补充。设置 Webhook 来扩展现有系统的功能肯定是一个不错的选择。

尽管 Azure Functions 被宣传为 serverless,但我们可能会在这个产品中看到一两个粗糙的边缘。有多种交互直接向我们展示了真相:Azure Functions 只是 App Services(位于虚拟机之上)之上的另一个抽象层。初始设置和操作方面非常熟悉——只有确切的运行时是为我们决定的。

兴趣点

有多种方法可以解决这个问题。在 NPM 中,我们也可以避免锁定版本并将常用库设置为“latest”。然后,Azure DevOps 上的简单多重触发器足以重新构建使用最新版本库的服务,而无需任何拉取请求或代码更改。尽管如此,所示的显式方法也有一些优点,并且可以用来解决其他问题。

告诉我你认为这项技术在哪里大放异彩(或者你为什么认为这是完全过度杀伤且毫无意义的……)!

参考文献

历史

  • v1.0.0 | 初始发布 | 2019 年 4 月 30 日
  • v1.1.0 | 添加了图表和目录 | 2019 年 4 月 30 日
  • v1.2.0 | 添加了 Azure Function 环境设置和下载 | 2019 年 4 月 30 日
  • v1.3.0 | 添加了“使用代码”部分 | 2019 年 4 月 30 日
© . All rights reserved.