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

从头开始构建服务虚拟化功能(第一部分)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (4投票s)

2023年10月24日

CPOL

43分钟阅读

viewsIcon

5847

一个关于使用 LocalStack (AWS)、Minimal APIs 和 Terraform 构建云工具以执行服务虚拟化的案例研究

引言

这是我系列文章的一个不寻常的开端。这有点像一份爱的劳动成果,是近三年思考的结晶,我观察到团队在管理所谓的“不 happy path”(非正常路径)测试时遇到的困难,以及创造一个可以轻松集成到你的应用程序空间中的服务的愿望。

多年来,我一直在开发 API,但我对那些只真正测试调用结果的 2xx 系列的团队感到沮丧。想象一下,如果你的应用程序依赖于 Google Maps API,例如。如果 Maps API 离线,你的应用程序会优雅地运行吗?当您身份验证的用户不再具有访问权限时,会发生什么?你能应对吗?

为了回答这些问题,我使用服务虚拟化应用程序,这些应用程序允许我模拟结果并根据我输入的数据返回它们。

本系列将介绍创建此功能所需的各个阶段。我将遵循某些假设和实践,稍后将详细介绍核心概念。随着系列的进行,我们将进一步完善应用程序,我会补充这些实践。

源代码

  • 本文的源代码可以在 GitHub 上找到

非常高层架构

当我们创建和部署我们的服务时,我假设以下功能

  • 我们的服务将利用 AWS(Amazon Web Services)功能。我们可以使用 Azure,但我选择 AWS。
  • 我们将部署一项服务来管理服务虚拟化(我们将其称为 SV)。
  • 我们只会虚拟化 HTTP/HTTPS。
  • 我们的 SV 功能将通过 API 控制。
  • 我们将能够同时托管多个用户/组织在我们的 SV 中。
  • 我们将使用 DynamoDB 控制请求/响应项的存储。
  • 为了降低开发成本,所有开发都将使用 LocalStack(免费版本功能齐全)。
  • 我们的云基础设施将使用一种称为基础设施即代码 (IaC) 的技术来定义。换句话说,我们将尽可能脚本化我们的 AWS 部署的各个区域。
  • 我们将使用 Blazor 作为我们的前端应用程序。我们可以使用任何客户端技术,但为了本次开发的目的,我们将使用 Web Assembly (WASM) 框架。
  • 我们将使用 Docker 来安装 LocalStack。
  • 我们将逐步构建我们的 SV 功能,因此我们会丢弃一些代码。这没关系,这是非常正常的事情。
  • 我们不会在代码中到处都是单元测试。我想重点介绍构建和部署 SV 到云环境的过程,而不是让人们迷失在各种测试的细节中。
  • 我们最初不会过多担心性能。如果我们觉得需要加快速度,我们会对其进行分析并在稍后进行。
  • 我们不会花太多时间纠结 AWS 的工作方式。在本系列中,我们将逐步积累知识。
  • 所有对我们服务的 API 调用都将遵循一个约定,即创建在 /api 下。
  • 我将创建尽可能少的接口。我将注册具体的类型。为了使类型可测试,我们可以将方法标记为 virtual

SV 解决了什么问题?

服务虚拟化是一种用于模拟软件应用程序所依赖的组件或服务的行为的技术,但这些组件或服务在开发或测试期间可能不可用或无法访问。它是在某些依赖项不可用或难以复制时创建受控且逼真的测试环境的一种方法。作为一项技术,它使大型团队能够以简单、可控的方式分解交付;其好处是,一个团队可以定义一个 RESTful 合约并基于它进行开发,而实际开发实现的团队此时不必提供它。

在软件系统(尤其是大型系统)中,不同的组件或服务相互交互以执行各种功能。在开发和测试期间,这些组件可能并未全部准备好或易于访问。这就是 SV 的用武之地,它允许开发人员和测试人员模拟这些组件的行为。这些虚拟表示形式模仿了真实组件的实际行为,包括所谓的“不 happy path”(非正常路径),即使真实组件尚未完全实现或运行。

SV 通常是这样工作的:

  1. 模拟:SV 工具会创建我们应用程序与之交互的服务或组件的虚拟实例。这些实例模仿真实服务的行为和响应。

  2. 模拟:我们可以配置这些虚拟实例以响应各种输入和场景,模拟正常和异常条件。这有助于在不依赖实际服务的情况下测试不同场景。

  3. 测试:作为开发人员和测试人员,我们使用这些虚拟实例对软件进行全面的测试。我们能够测试不同的集成点、功能交互和错误处理。

  4. 隔离:虚拟化服务有助于我们将测试过程与可能不可用或不稳定的依赖项隔离开来,这意味着即使实际服务正在进行更改或维护,开发和测试过程也可以顺利进行。

  5. 效率:SV 减少了在测试开始前需要所有组件完全开发并可用的需求。这加快了测试过程,并允许进行更全面的测试,即使某些组件仍处于开发中。这是创建可重复、可控地执行的回归套件的好方法。

SV 在组件或服务分布式、来自第三方,或者我最喜欢的——它们仍处于开发阶段的情况下尤其有用。

安装 LocalStack

正如我在开头提到的,我们想使用 LocalStack 作为 AWS 的代理(而且它是一个非常好的代理!)。要安装 LocalStack,我们将使用以下 docker compose 文件(将文件保存为 docker-compose.yml)。

version: "3.8"

services:
  localstack:
    container_name: "goldlight-localstack"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
    environment:
      - DEBUG=${DEBUG-}
      - DOCKER_HOST=unix:///var/run/docker.sock
      - PERSISTENCE=1                    # Persist data after docker restart
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

创建此文件后(假设您已经安装了 docker - 如果没有,请下载 Docker Desktop 并安装);创建文件后,运行以下命令:

docker compose up

这将在本地下载 localstack 镜像并启动它。

附加工具

下载并安装 AWS Command Line Interface (AWS CLI)。由于我是在 Windows 笔记本电脑上开发此软件,我使用了 chocolatey 通过命令安装它:

choco install awscli

安装完成后,我使用一个名为 awslocal 的 AWS CLI 包装器。我使用它的原因是让我的生活更简单,因为它消除了在我想使用 CLI 时输入本地部署的 AWS 实例的端点的需要。请按照 GITHUB 指示在本地安装包,并使用以下命令测试一切是否正常:

awslocal dynamodb list-tables

此命令将返回我们已创建的任何表的列表。由于我们实际上还没有在 AWS 中执行任何操作会导致我们拥有表,因此我们应该会收到以下响应:

基础设施即代码

就在我准备撰写本文时,发生了一些事情,改变了我原本的写作计划。我最初使用一个名为 Terraform 的工具编写了我的 IaC 脚本,它使我能够脚本化环境的样貌。就在我坐下来开始撰写本系列时,HashiCorp(Terraform 背后的公司)改变了其 OSS 许可模式。

我反复考虑是否要考虑不同的功能,因为 Terraform 的 OSS 部分的许可模型更改引起了很多噪音,但我决定坚持我最初的实现;Terraform 仍然可以免费用于个人工作,因此请在此处 下载并解压二进制文件

相关的许可详细信息可以在这里 找到 [https://developer.hashicorp.com/terraform/cloud-docs/overview]。相关部分是:

小型团队可以免费使用 Terraform Cloud 的大部分功能,包括远程 Terraform 执行、VCS 集成、私有模块注册表等。

免费组织最多有五名活跃成员。

注意:对于非 Windows 开发人员,请参考不同产品下的相关指南,了解如何在您的系统上安装。

配置 AWS CLI

现在已经安装了 AWS CLI,我们真的需要配置它。我将添加三个环境变量来帮助我记住我将要设置的内容。

AWS_ACCESS_KEY_ID='goldlight'
AWS_SECRET_ACCESS_KEY='goldlight'
AWS_DEFAULT_REGION='eu-west-2'

设置好这些变量后,我就可以配置 AWS 了。运行以下命令,并在相应的提示下输入上述值。

aws configure

添加第一个表

到目前为止,我们已经做了以下工作:

  1. 将 LocalStack 作为 Docker 服务安装
  2. 安装了适当的工具(awslocal、terraform)
  3. 学习了如何列出表
  4. 配置了 AWS CLI

一切就绪后,我们现在可以创建一个 DynamoDB 表了。如果您以前从未听说过 DynamoDB,它是 Amazon 的 NoSQL 产品,我们将使用它来管理我们的数据。

我们将用于创建表的方法遵循 IaC 控制我们构建内容的理念。核心概念是我们描述我们想要创建的内容,然后使用 Terraform 来实际创建它。这是一个很有吸引力的提议,因为它意味着如果我们想将代码从 LocalStack 迁移到 AWS,我们将有一条更容易的路线。

就我们而言,Terraform 有两个主要部分:

  1. Terraform 应用程序(我们之前已安装)
  2. 我们的 Terraform 文件(它们具有 .tf 扩展名)

目前,我们暂时不会过多担心 Terraform 的最佳实践。当我们将架构分层时,我们会回到这个问题。

让我们开始创建一个名为 main.tf 的 Terraform 文件。这将包含构建表结构所需的一切。重要的是要注意,Terraform 在构建基础设施时会创建许多文件,因此最好将 .tf 文件保存在应用程序其余部分单独的文件夹中。长远来看,这将使您的工作轻松得多。

我们的 Terraform 文件分为两部分。第一部分告诉我们我们将要使用的提供程序(AWS)。第二部分告诉我们如何构建 DynamoDB 表。

首先,我们添加提供程序部分。

provider "aws" {
  region                      = "eu-west-2"
  access_key                  = "goldlight"
  secret_key                  = "goldlight"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    dynamodb = "https://:4566"
  }
}

AWS 服务可以托管在多个区域(基本上,可以将其视为靠近数据中心所在的国家/地区)。由于我住在英国,我使用 EU-WEST-2 作为我的区域。区域非常重要,原因有很多;例如,您可能有数据主权问题,只能在某些国家/地区托管某些信息。您需要仔细选择区域的其他原因包括并非所有功能在每个区域都可用。请查阅 AWS 文档以获取功能信息,以更好地了解在哪里可以托管它(对于 DynamoDB,您可以在此处找到列表:here)。

secret_keyaccess_key 应该包含熟悉的值。目前,我们只是复制了这里的值。稍后,我们将删除硬编码的值,并用我们上面设置的环境值替换它们。

skip_... 条目指示 Terraform 绕过身份和访问管理 (IAM) 检查、AWS 元数据 API 检查和用户凭据。同样,随着我们构建 AWS 功能,这些选项将开始变得有意义。我现在不必太担心它们;只需等待我们构建功能集,届时我们将开始添加用户管理等内容。

最后,我们设置 DynamoDB 的端点,以便 Terraform 知道要使用哪个地址来创建表。显然,这里的值与我们的 LocalStack 条目相关。

现在我们已经设置了 AWS 提供程序,我们需要描述我们的 DynamoDB 表。由于这是一个 NoSQL 数据库,我们不必详细说明将出现的所有字段;我们只需要详细说明主键(哈希键)以及我们的读写容量。

resource "aws_dynamodb_table" "organizations" {
  name           = "organizations"
  read_capacity  = 20
  write_capacity = 20
  hash_key       = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

第一行告诉我们,我们将创建一个 DynamoDB 表,并为其提供一个名为 organizations 的键。这不是表名,Terraform 允许我们在其他资源中使用此键,因此我们在此处添加它。

我们的表名是 organizations,这将在我们运行 Terraform 构建时创建。

当我们创建 DynamoDB 表时,read_capacity 参数指定要分配给表的初始读取容量单位 (RCU) 数量。DynamoDB 使用一种称为预置容量模式的机制,我们在其中指定应用程序所需的读取和写入容量单位的数量。

DynamoDB 中,一个 RCU 代表每秒一次强一致性读取,或每秒两次最终一致性读取,对于高达 4 KB 的项目。如果我们还需要读取更大的项目,我们将消耗更多的读取容量单位。

强一致性读取和最终一致性读取指的是我们在 DynamoDB 中管理数据存储的方式。如果我们进行强一致性读取,我们表示我们将始终返回最新数据,但这会带来等待数据写入数据存储的权衡。最终一致性读取提供了更好的性能,但这里的权衡是我们不保证我们读取的是最新记录,因为它们可能尚未复制到其他区域。从根本上说,这些都与正在复制的数据有关;如果我们从单个节点数据库读取和写入,那么我们应该能够获得一致的视图。如果我们复制到许多数据实例,每次写入后都会有一段时间数据正在传播到各种数据源。这意味着我们有可能不会从尚未更新的源读取。

在此之后,我们可以看到我们容量的写入版本通过我们的 write_capacity 进行管理,它指定了我们表的写入容量单位 (WCU)。

同样,在这个阶段不要过分担心这些值。我们在这个阶段还没有决定任何吞吐量或数量。我们可以稍后回来调整这个值。

hash_key 可以认为是主键的等价物。我们将此字段命名为 id,并在我们的 attribute 部分进行描述。我们哈希的数据类型是 string;我们将使用 UUID 作为我们的值,但 DynamoDB 不支持此作为原生类型,因此我们将存储其 string 表示形式。

此时,我们的 Terraform 文件如下所示:

provider "aws" {
  region                      = "eu-west-2"
  access_key                  = "goldlight"
  secret_key                  = "goldlight"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    dynamodb = "https://:4566"
  }
}

resource "aws_dynamodb_table" "organizations" {
  name           = "organizations"
  read_capacity  = 20
  write_capacity = 20
  hash_key       = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

现在我们已经创建了文件,我们需要运行它。为此,我们将使用我们之前安装的 terraform 命令。由于 Terraform 只是提取必要的文件,我将 terraform.exe 的位置添加到我的 PATH 中。如果我忘记这样做,我将不得不使用可执行文件的完全限定路径。

应用我们的脚本基本上有三个阶段(在控制台或 shell 窗口中运行命令,并将当前工作目录更改为包含 main.tf 文件的文件夹):

  1. 运行 terraform init。这会准备当前工作目录以接受 Terraform 命令。
  2. 运行 terraform plan --out terraform.tfplan。这显示了 Terraform 执行将需要进行哪些更改,并将这些更改保存到名为 terraform.tfplan 的文件中。
  3. 运行 terraform apply terraform.tfplan。这会创建或更新我们的基础设施。可以跳过步骤 2,只运行步骤 1 和 3,但这需要您删除命令中的 terraform.tfplan 部分。

就是这样,运行这些命令,我们的 DynamoDB 表就会被创建。命令完成后,我们可以使用 awslocal 命令验证我们的表是否已创建。

awslocal dynamodb list-tables

输出现在应该如下所示:

{
    "TableNames": [
        "organizations"
    ]
}

让我们写一些代码

好吧,我们现在有一个表了。虽然它里面什么都没有,但它已经准备好让我们开始编写代码了。我们现在将稍微偏离 AWS,我们将构建一个服务,无论我们发送什么,它都会返回单个响应。这将是我们构建 SV 服务器的起点,也是我们的前端会与之交互以更新 DynamoDB 的点。

目前,我们假设任何请求都将获得 HTTP 200 OK 结果。让我们开始创建一个 ASP.NET Core Web API(我使用的是 .NET Core 7),并将其命名为 Goldlight.VirtualServer

下图显示了我们正在使用的设置。

The settings I'm using for the Goldlight.VirtualServer application

Visual Studio 将创建我们所需的基本结构。由于我们使用 Minimal APIs,我们的程序文件如下所示:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
  app.UseSwagger();
  app.UseSwaggerUI();
}

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", 
                "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
  var forecast = Enumerable.Range(1, 5).Select(index =>
      new WeatherForecast
      (
          DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
          Random.Shared.Next(-20, 55),
          summaries[Random.Shared.Next(summaries.Length)]
      ))
      .ToArray();
  return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();

app.Run();

internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
  public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

那里有很多样板代码,但重要的是我们有足够的代码来添加我们所需的内容,以支持我们将要添加的代码以返回 200 响应。

为了返回响应,无论请求是什么,我们都需要使用一种称为中间件的东西。使用我们的中间件,我们希望拦截传入的请求并创建我们想要的响应版本。添加我们自己的中间件是一种将我们自己的行为插入请求管道的有用方法,我们将使用此功能来返回自定义响应。

我们要做的第一件事是创建一个简单的辅助方法,我们将在开发过程中在各个阶段对其进行扩展。

static async Task WriteResponse<T>(HttpContext context, T result, 
       int statusCode = 200, string contentType = "application/json")
{
  HttpResponse response = context.Response;
  response.StatusCode = statusCode;
  response.ContentType = contentType;
  await response.WriteAsync(JsonSerializer.Serialize(result));
}

此代码设置了我们的状态码和我们想要的 content type;由于我们默认为状态码 200application/json,我们只需要提供上下文和要作为结果返回的内容。现在我们只需要将它连接到我们的程序文件中。

app.Run(); 调用之前,添加以下代码:

app.Run(async context => { await WriteResponse(context,"Hello ServiceVirtualization"); });

注意 - 我们需要两个版本的 app.Run,所以不要删除另一个。

这个异步方法等待一个请求进来,并使用该请求的 web 上下文向其写入响应。如果我们现在运行该服务,我们可以测试是否成功收到了消息。显示 GET 调用上的 Hello ServiceVirtualization 消息

我们的网络流量如下所示:网络选项卡显示 SV 结果

恭喜,我们刚刚创建了 SV。确实,它现在还不是很实用。让我们开始通过添加为不同公司拥有不同服务器的功能来充实它。

SV 设计目标

虽然我们还没有向我们的应用程序添加用户或组织管理功能,但我们将假设我们将允许公司或个人使用 SV。在任何一种情况下,我们都将以组级别管理所有 SV,所以一个公司是一个组,一个单独的个人是一个只有一个人的组。我们为什么这样做?我们这样做是为了让我们的用户只能访问他们自己的 SV,这将构成请求路径的一部分。

我们设想的是,而不是当我们调用 https://:5106/myvirtualizedservice 时 SV 返回响应,路径会更像这样: https://:5106/deployed/acmecorp/myvirtualizedservice

我们不会硬编码这一点,而是开始添加将数据存储到 DynamoDB 并利用此功能来开始填充公司详细信息的能力。是时候编写一些 AWS 代码了。

在我们开始之前,我们需要决定要为我们的组织存储什么。我们知道我们需要一个哈希键,它是一个 UUID,我们希望它是 string 表示形式,但我们还想要什么?嗯,我们还需要一个名称,所以我们将使其成为必需的数据项,最小长度为一,这样我们总会有一个值。由于数据将存储为 JSON 实体,因此任何其他属性都应该很容易处理。

在填充我们的数据库之前,我们想创建一个将从 Web 客户端使用的 API 和数据模型。考虑到这一点,我们将使用此合约(我将其放在一个名为 /models/v1 的文件夹中)。

[DataContract]
public class Organization
{ 
  [DataMember(Name="id")]
  public Guid? Id { get; set; }

  [DataMember(Name="name")]
  public string? Name { get; set; }
}

我们将添加一个 Minimal POST API,它映射到端点 /api/organization。目前,我们不会担心 API 版本控制,我们稍后会添加。

我们不想让代码充斥着示例 Weather API,因此我们将从代码库中删除所有痕迹。完成后,我们添加以下方法:

app.MapPost("/api/organization", ([FromBody]Organization organization) =>
{
  System.Diagnostics.Debug.WriteLine($"Received organization: 
                     {JsonSerializer.Serialize(organization)}");
  return TypedResults.Created($"/api/organizations/{organization.Id}", organization);
})

目前,我们所做的只是打印出组织的详细信息,并返回一个 201(已创建)响应。此时,我真的想强调我们应该认真考虑我们返回的状态码,以免给人们带来意外。我已经看到太多 API 在许多情况下都返回 200 OK 响应。当我们成功地在我们的服务中创建了某些内容时,我们应该始终返回 201 状态码。

一个小说明:如果您以前见过 Minimal APIs,您可能会看到人们返回 Result 而不是 TypedResult。如果您看到了,您可能会想知道区别是什么。当我们返回 TypedResult 时,除了返回的对象之外,我们还会返回有关它的元数据,这些元数据可用于 Swagger 文件以识别我们从方法返回的不同状态码和对象。如果我们返回 Result,并且想记录返回类型,我们必须使用 Produces 扩展来指定我们正在返回什么。

如果我们现在运行应用程序并尝试发布一个组织,我们会看到一些起初令人惊讶的事情。当我们运行应用程序时,我们在 app.Run 调用中创建的中间件正在拦截所有流量,并且我们的 API 没有被调用,所以我们想改变行为。

为了解决中间件问题,我们将改变我们的行为,检查路径是否以 /api/ 开头。如果是,我们希望继续处理。为此,我们将把我们的中间件从 app.Run 改为 app.Use。通过这样做,我们可以同时访问当前上下文(允许我们检查路径)和指向请求管道下一部分的引用。当我们调用下一阶段时,我们确保 return,以免落入我们的 WriteResponse 代码。

app.Use(async (context, next) =>
{
  if (context.Request.Path.Value!.StartsWith("/api/"))
  {
    await next.Invoke();
    return;
  }
  await WriteResponse(context, "Hello ServiceVirtualization");
});

此时,我们的代码如下所示:

using System.Text.Json;
using Goldlight.VirtualServer.Models.v1;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
  app.UseSwagger();
  app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapPost("/api/organization", ([FromBody] Organization organization) =>
{
  System.Diagnostics.Debug.WriteLine($"Received organization: 
                     {JsonSerializer.Serialize(organization)}");
  return TypedResults.Created($"/api/organizations/{organization.Id}", organization);
});

app.Use(async (context, next) =>
{
  if (context.Request.Path.Value!.StartsWith("/api/"))
  {
    await next.Invoke();
    return;
  }
  await WriteResponse(context, "Hello ServiceVirtualization");
});

app.Run();

static async Task WriteResponse<T>(HttpContext context, 
       T result, int statusCode = 200, string contentType = "application/json")
{
  HttpResponse response = context.Response;
  response.StatusCode = statusCode;
  response.ContentType = contentType;
  await response.WriteAsync(JsonSerializer.Serialize(result));
}

令人难以置信的是,Minimal APIs 让我们可以如此快速地获得一个可运行的 API。

设置我们的项目以使用 Localstack

我们需要预先安装少量软件包。我们将添加 LocalStack.ClientLocalStack.Client.Extensions Nuget 程序包。安装完成后,我们想在我们的程序文件中添加以下条目:

builder.Services.AddLocalStack(builder.Configuration);
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());

在我们的 app settings 文件中,我们需要添加以下条目:

"LocalStack": {
  "UseLocalStack": true,
  "Session": {
    "AwsAccessKeyId": "goldlight",
    "AwsAccessKey": "goldlight",
    "AwsSessionToken": "my-AwsSessionToken",
    "RegionName": "eu-west-2"
  },
  "Config": {
    "LocalStackHost": "localhost",
    "UseSsl": false,
    "UseLegacyPorts": false,
    "EdgePort": 4566
  }
}

平心而论,我们不会想在生产系统中使用这个;我们肯定不想以这种方式硬编码值,但作为一个开发环境,在我们本地工作时,这是可以的。现实情况是,我们希望使用一种更安全的方法来读取一些配置,但目前,这已经足够了。

配置将被读取并由上面的选项使用。LocalStackHostEdgePort 将提供我们的服务所需的有关 LocalStack 运行位置以及我们的 DynamoDB 实例将位于何处的信息。

保存数据

现在我们有了传入的组织,并且 AWS 注册已在我们的应用程序中完成,我们就可以编写代码将数据保存到 DynamoDB 了。我们将把数据库代码隔离到自己的库中,所以让我们创建一个名为 Goldlight.Database 的 .NET Core 类库(同样以 .NET 7 为目标)。

在此库中,我们想安装 AWSSDK.DynamoDBv2LocalStack.Client.Extensions NuGet 程序包。这些程序包将为我们提供对我们将要在应用程序中使用的 DynamoDBLocalStack 功能的访问。安装完成后,我们将创建一个模型表示,我们将把该模型推送到数据库。我们的模型类如下所示:

[DynamoDBTable("organizations")]
public class OrganizationTable
{
  [DynamoDBHashKey("id")] 
  public string? Id { get; set; }
  [DynamoDBProperty] 
  public string? Name { get; set; }
  [DynamoDBProperty] 
  public int ModelVersion { get; set; } = 1;
  [DynamoDBVersion]
  public long? Version { get; set; };
}

我们在这里创建一个我们要放入表结构中的数据表示。在顶层,我们想要 id,它与我们在 Terraform 中创建的哈希键匹配。我们正在写入名为 organizationsDynamoDBTable,这应该不足为奇。这会与我们 API 中的 id 相关联,但我们可以看到这里的类型是 string。正如我们之前提到的,DynamoDB 对其支持的类型有限,因此我们不能直接使用 GUID。

我们创建了两个属性:NameModelVersion。Name 是我们将从 API 传入的组织的名称,而 ModelVersion 是我们用于填充此条目的模型的版本。我们在这里所做的是在早期引入一些未来保障。如果随着时间的推移,我们在 API 中引入了模型的重大更改,我们可以使用版本来确定应该填充哪个特定模型。

目前,我们暂时不担心 Version 属性。当我们更新记录时,我们会看到它的用途。

创建 table 对象后,我们就可以编写保存实际数据到 DynamoDB 的代码了。我们将创建一个名为 OrganizationDataAccess 的类。它将包含我们将用于将数据保存到数据库的逻辑。代码非常简单,并且依赖于我们能够访问 DynamoDB 数据模型功能。

创建以下类:

public class OrganizationDataAccess
{
  private readonly IDynamoDBContext _dynamoDbContext;
  public OrganizationDataAccess(IDynamoDBContext dbContext)
  {
    _dynamoDbContext = dbContext;
  }

  public virtual async Task SaveOrganizationAsync(OrganizationTable organization)
  {
    await _dynamoDbContext.SaveAsync(organization);
  }
}

IDynamoDBContext 接口为我们提供了访问保存数据(稍后加载)所需的功能。通过它,我们的 SaveOrganizationAsync 方法只需调用 DynamoDBSaveAsync 实现。由于我们创建了一个映射模型,代码知道要将记录保存到哪个表,以及要保存什么。我认为这是一种操作数据非常直接的方式,我们将在以后利用这种能力。

我们需要注册这个类。我选择在此类库中管理所有与数据相关的活动的注册。因此,我们将创建一个接受 IServiceCollection 接口的扩展方法,并添加相关详细信息。

public static class DataRegistration
{
  public static IServiceCollection AddData(this IServiceCollection services)
  {
    return services.AddTransient<OrganizationDataAccess>().AddAwsService<IAmazonDynamoDB>()
      .AddTransient<IDynamoDBContext, DynamoDBContext>();
  }
}

我们在这里添加了三项:

  1. 我们数据访问类的瞬态注册。我们可以看到我们没有在此周围封装接口;如果我们创建一个接口,就意味着我们应该创建该类型的其他实现,而目前我们没有这样做。
  2. 当我们调用 AddAwsService 时,我们实际上是在这里挂钩 LocalStack。这是一个 LocalStack 扩展,它基本上是说:“当您尝试与 IAmazonDynamoDB 服务进行交互,并且配置指示使用 LocalStack 时,LocalStack 将处理它”。如果我们不进行此调用,我们的代码将认为它正在尝试连接到 AWS,并且我们会因 Region 端点故障而失败。这在过去曾让我感到困惑几次,所以我在这里强调它很重要。
  3. 最后,我们将 IDynamoDBContext 注册为瞬态的,以便每次连接到 DynamoDB 时我们都能获得一个新实例。

我们之所以将 DynamoDB 注册添加到此类中,是因为它为我提供了一个所有与数据相关的活动的位置。当我添加其他功能时,我将遵循类似的模式。

回到主服务

有了注册代码后,我们需要将其添加到我们的 program.cs 代码中。在 AddDefaultAWSOptions 行之后,添加以下内容以调用数据注册:

builder.Services.AddData();

我们现在差不多完成了。我们的最后一步是更新我们的 POST 调用以调用新的保存例程。由于我们将使用我们刚刚创建的 OrganizationDataAccess 类,我们需要将其注入到我们的 MapPost 调用中。这是异步的,所以我们不能忘记将其标记为 async 方法。

post 代码内部,我们将创建一个 OrganizationTable 类的实例,并将值从请求映射进来。一旦填充完成,我们就调用我们的 SaveOrganizationAsync 代码,一切就绪。

app.MapPost("/api/organization", 
    async (OrganizationDataAccess oda, [FromBody] Organization organization) =>
{
  OrganizationTable organizationTable = new()
  {
    Id = organization.Id.ToString(),
    Name = organization.Name,
    ModelVersion = 1
  };
  await oda.SaveOrganizationAsync(organizationTable);
  return TypedResults.Created($"/api/organizations/{organization.Id}", organization);
});

我们的代码现在看起来是这样的:

using System.Text.Json;
using Goldlight.Database;
using Goldlight.Database.DatabaseOperations;
using Goldlight.Database.Models.v1;
using Goldlight.VirtualServer.Models.v1;
using LocalStack.Client.Extensions;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddLocalStack(builder.Configuration);
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());

builder.Services.AddData();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
  app.UseSwagger();
  app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapPost("/api/organization", 
    async (OrganizationDataAccess oda, [FromBody] Organization organization) =>
{
  OrganizationTable organizationTable = new()
  {
    Id = organization.Id.ToString(),
    Name = organization.Name,
    ModelVersion = 1
  };
  await oda.SaveOrganizationAsync(organizationTable);
  return TypedResults.Created($"/api/organizations/{organization.Id}", organization);
});

app.Use(async (context, next) =>
{
  if (context.Request.Path.Value!.StartsWith("/api/"))
  {
    await next.Invoke();
    return;
  }
  await WriteResponse(context, "Hello ServiceVirtualization");
});

app.Run();

static async Task WriteResponse<T>(HttpContext context, T result, 
       int statusCode = 200, string contentType = "application/json")
{
  HttpResponse response = context.Response;
  response.StatusCode = statusCode;
  response.ContentType = contentType;
  await response.WriteAsync(JsonSerializer.Serialize(result));
}

我们现在在哪里?

让我们暂停一下,检查一下我们到目前为止所取得的成就。

  1. 我们已经安装了 AWS 的本地副本,它在功能上与云实例几乎完全相同。这意味着如果我们想迁移到 AWS,不会太难。
  2. 我们使用 Terraform 在 DynamoDB 中创建了我们的第一个表实例。
  3. 我们创建了一个返回虚拟化响应的应用程序。
  4. 我们有一个应用程序可以将组织详细信息保存到 DynamoDB 中。

我希望您会同意,这相当令人印象深刻,而且实际上并没有花费我们多少精力。既然我们已经创建了一个组织,我们将开始通过添加获取、更新和删除组织的功能来充实行为。一旦我们完成了这些,我们就可以创建一个服务于它的前端。

为不断变化的架构而设计

在我们继续之前,我想解决一个让我感到恼火的问题,即我们创建的 API 设计方法非常脆弱,即处理 API 中重大更改的能力。为了解决这个问题,我们将为我们的 API 添加版本控制。为了避免在最后几个部分引入过多内容,我们刚刚创建了一个简单的非版本化 API,但当我们在处理可能增长的应用程序时,这是一个糟糕的做法。

由于我们以“敏捷”的心态构建此应用程序,因此我们不断构建所谓的最小可行产品。换句话说,我们期望我们的应用程序将不断添加越来越多的功能,并且我们会对其进行越来越多的打磨。这就是为什么我们现在 API 中还没有任何验证;以及我们如何能够快速地从使用中间件返回硬编码响应(无论我们发送什么到我们的 API)转变为能够处理将数据保存到数据库,但仍然在命中不以 /api/ 开头的端点时由中间件响应。在这种心态下,理解我们所做事情的含义非常重要。我们从一开始就在为变化而设计。我们接受事情会随着时间而改变,我们也接受改变会破坏我们以前做的事情。

我们现在引入版本控制的原因是我们承认变化的速度意味着我们的 API 将会改变,这可能会破坏事物。这就是为什么我们将版本号放在数据库中保存的实体中。我们传输和保存数据的“语言”是 JSON(目前;谁知道呢,我们以后可能会将传输格式更改为其他格式)。但如果我们的 API 使用 JSON,我们必须明白 JSON 可能存在重大更改。考虑以下 JSON:

{
  "id": "ea080555-57bd-4d8f-9c18-53c7395a743f",
  "addressline1": "31 Front Street",
  "addressline2": "Pant-y-gyrdl",
  "addressline3": "Somewhereinwalesshire",
  "postcode": "PS1P4T"
}

随着时间的推移,我们的需求发生了变化,我们意识到我们的客户可能不再只有一个地址,他们可能是拥有数百个地址的连锁酒店。为了满足这一需求,我们决定我们的地址应该是一个数组。突然之间,同一个客户现在由这个版本的 JSON 来表示:

{
  "id": "ea080555-57bd-4d8f-9c18-53c7395a743f",
  [
    {
      "type": "home",
      "addressline1": "31 Front Street",
      "addressline2": "Pant-y-gyrdl",
      "addressline3": "Somewhereinwalesshire",
      "postcode": "PS1P4T"
    }
  ]
}

正如我们在这里清楚看到的,这是一个重大更改,一个简单的 API 无法处理这两种情况而不做重大妥协。为了避免这种情况,我们使用 API 版本控制。换句话说,第一种格式将由 v1 处理,而重大更改将由 v2 API 处理。

我们可以通过多种方式处理版本控制。我们可以将其添加为查询字符串参数,将其添加到路径,或者以某种方式添加到标头,或者使用自定义媒体类型进行版本控制。无论我们选择哪种格式,我们都将把这种方法应用于每个 API。

无论如何,让我们回到添加版本支持。我们将首先在我们的 API 库中安装 Asp.Versioning.Http 程序包。

我们将使用的版本控制方法是使用媒体类型。换句话说,我们的 Content-Type 标头需要设置为 application/json;v=1.0。为了添加对此的支持,我们将像这样向我们的服务添加 API 版本控制:

ApiVersion version1 = new(1, 0);
builder.Services.AddApiVersioning(options =>
{
  options.ReportApiVersions = true;
  options.DefaultApiVersion = version1;
  options.ApiVersionReader = new MediaTypeApiVersionReader();
});

我们在这里所做的是将默认 API 版本设置为 1.0,并设置 API 版本功能以使用媒体类型。当我们设置 ReportApiVersionstrue 时,我们会向 HTTP 标头添加信息,告知它们 api-supported-versionsapi-deprecated-versions

我们仍然有一些工作要做来完成版本支持的添加。我们将创建一个 ApiVersionSet 来表示我们 API 的分组。我们必须像这样将此调用添加到我们的应用程序中:

ApiVersionSet organizations = app.NewApiVersionSet("Organizations").Build();

最后,我们需要实际将版本信息添加到我们的 API 中。我们将版本集添加到 API,然后使用 WithApiVersionSetHasApiVersion 方法说明 API 的版本是什么。

app.MapPost("/api/organization", async (OrganizationDataAccess oda, 
             [FromBody] Organization organization) =>
{
  OrganizationTable organizationTable = new()
  {
    Id = organization.Id.ToString(),
    Name = organization.Name,
    ModelVersion = 1
  };
  await oda.SaveOrganizationAsync(organizationTable);

  return TypedResults.Created($"/api/organizations/{organization.Id}", organization);
}).WithApiVersionSet(organizations).HasApiVersion(version1);

从 DynamoDB 读取

我们能够写入 DynamoDB 已经很好了。如果无法从它读取,那似乎毫无意义。为此,我们将添加一个 API,该 API(目前)将从 DynamoDB 检索所有组织详细信息。要实现这一点,我们需要为我们的 API 创建一个端点,并添加从 DynamoDB 读取所有行的功能。我们的 API 的样板代码如下所示:

app.MapGet("/api/organizations", async (OrganizationDataAccess oda) =>
{
}).WithApiVersionSet(organizations).HasApiVersion(version1);

一旦我们完成了这一点,我们就可以更新我们的 OrganizationDataAccess 代码了。DynamoDB 允许我们执行两种类型的操作:扫描或查询。如果我们有需要应用的搜索条件(例如,按哈希键搜索),查询操作将为我们提供更高的效率。由于对于获取所有类型的调用,我们没有任何要查询的标准,因此我们将使用 ScanAsync 扫描表。

public virtual async Task<IEnumerable<OrganizationTable>> GetOrganizationsAsync()
{
  return await _dynamoDbContext.ScanAsync<OrganizationTable>
               (new List<ScanCondition>()).GetRemainingAsync();
}

代码看起来有点奇怪,因为我们有一个 ScanAsync 方法和一个 GetRemainingAsync 来执行读取。调用 ScanAsync 会配置我们要执行的扫描操作,然后将其传递给 GetRemainingAsync,后者实际上执行扫描。

我们想要记住的一件事。我们在这里处理的是云数据库,默认情况下它被设置为最终一致性。这可能会让习惯于数据即时写入数据库的人感到非常困惑。最终一致性数据库接受数据库填充可能存在一些延迟,其好处是这种模式适合扩展服务和功能。

就代码而言,如果我们只是从 GetOrganizationsAsync 调用直接返回组织,我们将返回一个 OrganizationTable 的可枚举列表。这并不是我们想返回给调用代码的内容,所以我们需要将其转换回 Organization。为此,我们将编写 API 处理程序来调用数据访问方法,然后将结果重塑为我们期望的格式。

app.MapGet("/api/organizations", async (OrganizationDataAccess oda) =>
{
  IEnumerable<OrganizationTable> allOrganizations = await oda.GetOrganizationsAsync();
  return allOrganizations
    .Select(organization => new Organization { Id = Guid.Parse(organization.Id!), 
                            Name = organization.Name });
}).WithApiVersionSet(organizations).HasApiVersion(version1);

由于这已经完成了获取完整记录列表的工作,我们还需要考虑我们希望能够根据 id 获取单个组织。我们将从数据访问代码开始。对于此操作,由于我们根据哈希键(id)获取单个行,因此我们可以使用 LoadAsync 方法,该方法接受哈希键作为参数。此代码比完整的扫描操作更简单,因为我们只返回一行。

public virtual async Task<OrganizationTable?> GetOrganizationAsync(Guid id)
{
  return await _dynamoDbContext.LoadAsync<OrganizationTable>(id.ToString());
}

我们返回一个可为空的 OrganizationTable,因为我们可能找不到匹配的条目。如果我们找不到匹配项,我们希望我们的 API 调用返回 NotFound。正如我们所记得的,我们的 API 方法还没有显式返回类型。这是因为 Minimal API 的内部机制能够找出

app.MapPost("/api/organization", 
    async (OrganizationDataAccess oda, [FromBody] Organization organization)

等同于

app.MapPost("/api/organization", async Task<Created<Organization>> 
    (OrganizationDataAccess oda, [FromBody] Organization organization)

我为什么要提这个?嗯,我们新的 GET 调用将返回 200 状态码(带组织信息),或者返回 404 未找到状态码。对于更复杂的返回类型,我们必须在 API 签名中告诉代码我们要返回什么,如下所示:

app.MapGet("/api/organization/{id}", 
    async Task<Results<Ok<Organization>, NotFound>> (OrganizationDataAccess oda, Guid id)

调用中的 {id} 与参数列表中的 Guid id 匹配。

注意:我借此机会纠正我之前犯的一个错误。当我们编写 post 方法时,我们返回了 /api/organizations/id。这个条目暗示我们将使用 id 来搜索资源集合。我们不想这样做,所以我们将将其更正为使用单数而不是复数,以便我们使用 /api/organization/id

在 API 方法内部,我们将调用 GetOrganizationAsync 方法。如果 id 与我们的任何组织不匹配,我们将得到一个 null,在这种情况下,我们将返回一个 NotFound。如果我们得到一个值,我们将将其转换回组织并返回一个 Ok

app.MapGet("/api/organization/{id}", async Task<Results<Ok<Organization>, NotFound>> 
          (OrganizationDataAccess oda, Guid id) =>
{
  OrganizationTable? organization = await oda.GetOrganizationAsync(id);
  if (organization == null)
  {
    return TypedResults.NotFound();
  }
  return TypedResults.Ok(new Organization { Id = Guid.Parse(organization.Id!), 
                         Name = organization.Name });
}).WithApiVersionSet(organizations).HasApiVersion(version1);

不要忘记,当我们想测试我们的 API 时,我们需要在请求标头中将 Content-Type 设置为 application/json;v=1.0,如下所示(我使用 Postman 进行测试):

Demonstrating using the Content-Type to set the header version

更新组织

当我们处理 API 时,我们通常有两种方法来更新记录。我们可以使用 PUT,也可以使用 PATCH。使用 PUT 更新,其理念是用一个记录版本替换另一个版本,而 PATCH 则旨在更新记录的单个部分。我们将替换整个记录,所以我们将使用 PUT API。

我们为添加 PUT 操作需要设置的 Minimal 结构。

app.MapPut("/api/organization", 
    async (OrganizationDataAccess oda, Organization organization) =>
{

}).WithApiVersionSet(organizations).HasApiVersion(version1);

对于此方法,我省略了 Organization 上的 [FromBody]。代码足够智能,可以弄清楚这是我们要传递的内容。我们也可以省略之前 MapPost 上的此参数,但我们最初将其保留是为了演示正在发布的内容。

此时,我想解决一个我故意忽略的问题。当我们创建 OrganizationTable 时,我们添加了以下映射:

[DynamoDBVersion] 
public long? Version { get; set; }

此值由系统在添加记录时自动填充。它的值非常重要,因为它是用于乐观锁定的。换句话说,当记录创建时,它以 Version 0 创建。如果我想更新记录,我传入 Version,它用于将记录与 DynamoDB 中的条目进行比较。因此,如果两个人检索记录并尝试更新它,到第二个更新到达系统时,版本将已递增,DynamoDB 将通知我们无法更新记录,因为我们存在冲突。

我们的代码中缺少的是处理乐观锁定的能力。这没问题,因为我们正在逐步构建功能,一步一步地进行,这符合我们之前设定的原则。为了处理版本,我们必须将其添加到我们的 Organization 模型中。在执行此操作的同时,我们将添加几个辅助方法;一个用于从 OrganizationTable 转换为 Organization,另一个方法用于执行相反的操作,将 Organization 转换为 OrganizationTable。如果我在这里 100% 遵循 SOLID 原则,我会将这些辅助方法创建在合同之外,但此解决方案将所有行为保存在一个易于查找的位置。

[DataContract]
public class Organization
{ 
  [DataMember(Name="id")]
  public Guid? Id { get; set; }

  [DataMember(Name="name")]
  public string? Name { get; set; }

  [DataMember(Name="version")]
  public long? Version { get; set; }

  public static Organization FromTable(OrganizationTable table)
  {
    return new()
    {
      Id = Guid.Parse(table.Id!),
      Name = table.Name,
      Version = table.Version ?? 0
    };
  }

  public OrganizationTable ToTable(int modelVersion = 1) {
    return new()
    {
      Id = Id.ToString(),
      Name = Name,
      Version = Version,
      ModelVersion = modelVersion
    };
  }
}

有了这个功能,我们现在可以传入与我们从 DynamoDB 检索到的版本匹配的版本,并且 PUT 操作将调用现有的 save 方法来更新底层记录。

我们将对主代码进行一次快速重构,以使用辅助方法,从而得到以下结果:

app.MapPost("/api/organization", 
    async (OrganizationDataAccess oda, Organization organization) =>
{
  await oda.SaveOrganizationAsync(organization.ToTable());

  return TypedResults.Created($"/api/organization/{organization.Id}", organization);
}).WithApiVersionSet(organizations).HasApiVersion(version1);

app.MapGet("/api/organization/{id}", 
 async Task<Results<Ok<Organization>, NotFound>> (OrganizationDataAccess oda, Guid id) =>
{
  OrganizationTable? organization = await oda.GetOrganizationAsync(id);
  if (organization is null)
  {
    return TypedResults.NotFound();
  }
  return TypedResults.Ok(Organization.FromTable(organization));
}).WithApiVersionSet(organizations).HasApiVersion(version1);

app.MapGet("/api/organizations", async (OrganizationDataAccess oda) =>
{
  IEnumerable<OrganizationTable> allOrganizations = await oda.GetOrganizationsAsync();
  return allOrganizations
    .Select(Organization.FromTable);
}).WithApiVersionSet(organizations).HasApiVersion(version1);

app.MapPut("/api/organization", 
    async (OrganizationDataAccess oda, Organization organization) =>
{
  await oda.SaveOrganizationAsync(organization.ToTable());
  return TypedResults.Ok();
}).WithApiVersionSet(organizations).HasApiVersion(version1);

删除不需要的记录

如承诺的,我们要添加的最后一个操作是删除记录的能力。由于我们已经涵盖了许多基础知识,因此这在数据访问代码中应该是一个简单的操作,这并不奇怪。

public virtual async Task DeleteOrganizationAsync(Guid id)
{
  await _dynamoDbContext.DeleteAsync<OrganizationTable>(id.ToString());
}

API 代码现在也应该很熟悉了。

app.MapDelete("/api/organization/{id}", async (OrganizationDataAccess oda, Guid id) =>
{
  await oda.DeleteOrganizationAsync(id);
  return TypedResults.Ok();
}).WithApiVersionSet(organizations).HasApiVersion(version1);

我们现在在哪里

这是一篇内容丰富的文章。在本文中,我们学习了如何安装 LocalStack 和 AWS CLI。我们使用 Terraform 创建了我们的第一个 DynamoDB 表,然后创建了一个 Minimal API 应用程序,我们使用它来添加基本的 CRUD 操作,允许我们管理组织。我们为 API 应用了版本控制,并看到了如何使用中间件作为服务虚拟化。

有了基础之后,我们现在需要做什么?请记住,我们正在逐步构建我们的功能,值得思考我们的用户将如何使用该应用程序。我们创建了一个组织,并且我们知道我们希望用户能够通过 /deployed/organization 访问他们的服务,这暗示了什么?假设我们有一个名为 Five Nights at Freddy's 的组织。有很多原因我们不希望我们的用户在他们的路径中使用它。我们需要一种方法来获取该组织名称并使其对 URL 友好。我们还希望用户能够根据需要对其进行修改,并且它必须是唯一的。

同时,我开始想,我希望组织有一个访问密钥,该密钥仅供该组织的成员使用。我这样做是因为我想拥有创建公共和私有服务的可能性,因此访问密钥将用于限制对私有服务的访问。

我为什么现在要考虑未来的需求?当我们构建应用程序时,我们不应该仅仅从一个需求跳到另一个需求,而不知道我们的目标是什么。在敏捷开发团队中,我将扮演产品负责人的角色;这个人知道他想要什么应用程序,我知道我想要应用程序拥有公共和私有虚拟化服务。同样,如果我考虑故事的优先级,我可能会决定现在是时候解决它们了。

注意:本文无意就各种敏捷方法及其价值进行辩论。这只是一个概念化为什么我选择现在解决这两个需求的方法。

添加访问密钥

这很简单。我希望服务在 POST 时创建访问密钥,并希望在 GET 调用中返回它;我还希望在 POST 调用中返回它。PUT 调用不应允许更改它,并且它不应出现在 POSTPUT 的合同中。一旦创建,它就是不可变的。这意味着我们实际上需要两个独立的合同;第一个是我们目前拥有的,我们将将其用于 POSTPUT。第二个合同将从 POST 返回,并将是我们从 GET 调用返回的合同。

由于我们将保存到 DynamoDB,我们将首先更新数据访问代码。

[DynamoDBTable("organizations")]
public class OrganizationTable
{
  [DynamoDBHashKey("id")] 
  public string? Id { get; set; }
  [DynamoDBProperty] 
  public string? Name { get; set; }
  [DynamoDBProperty] 
  public int ModelVersion { get; set; } = 1;
  [DynamoDBVersion] 
  public long? Version { get; set; }
  [DynamoDBProperty]
  public string? ApiKey { get; set; };
}

我们使用一个简单的算法设置密钥;我们想要一个新密钥,一个 Guid 就可以满足我们。我们不希望它看起来太像 Guid,所以我们在文本中删除了 -。

为了满足我们上面的要求,我们将创建一个 OrganizationResponse 表,它将处理我们 POSTGET 调用的响应。为了处理 POST 调用,我们允许代码使用 Organization 信息实例化此类。由此,我们可以添加我们的 API 密钥。

[DataContract]
public class OrganizationResponse : Organization
{
  public OrganizationResponse() { }
  public OrganizationResponse(Organization organization)
  {
    Id = organization.Id;
    Name = organization.Name;
    Version = organization.Version;
  }

  [DataMember(Name="api-key")]
  public string? ApiKey { get; set; }
  
  public OrganizationResponse(OrganizationResponse organization)
  {
    Id = organization.Id;
    Name = organization.Name;
    Version = organization.Version;
    ApiKey = organization.ApiKey;
  }

  public override OrganizationTable ToTable(int modelVersion = 1)
  {
    OrganizationTable table = base.ToTable(modelVersion);
    table.ApiKey = ApiKey;
    return table;
  }

  public static OrganizationResponse FromTable(OrganizationTable table)
  {
    return new()
    {
      Id = Guid.Parse(table.Id!),
      Name = table.Name,
      ApiKey = table.ApiKey,
      Version = table.Version ?? 0
    };
  }
}

当我们 POST 记录时,我们接受 Organization 并使用它来创建上面创建的 OrganizationResponse 对象。这时,我们想创建我们的 API 密钥。我们将把这个新的 OrganizationResponse 保存到 DynamoDB,在数据记录中设置 ApiKey。我将借此机会修复我原始实现中的一个缺陷,即响应没有反映数据库中记录的版本。我们知道 DynamoDB 会自动将第一个版本保存为 0,所以我们可以硬编码我们的响应。这里的最后一件事是返回 OrganizationResponse

app.MapPost("/api/organization", 
    async (OrganizationDataAccess dataAccess, Organization organization) =>
{
  OrganizationResponse organizationResponse = new(organization)
  {
    ApiKey = Guid.NewGuid().ToString().Replace("-", "")
  };
  await dataAccess.SaveOrganizationAsync(organizationResponse.ToTable());
  organizationResponse.Version = 0;
  return TypedResults.Created($"/api/organization/{organization.Id}", 
                                organizationResponse);
}).WithApiVersionSet(organizations).HasApiVersion(version1);

我们现在处于可以更改 get 调用以返回 OrganizationResponse 实例而不是 Organization 的位置。这相对简单,因为它只是涉及更改 MapGet 方法中 Organization 的所有引用为 OrganizationResponse

app.MapGet("/api/organization/{id}", 
    async Task<Results<Ok<OrganizationResponse>, NotFound>> 
    (OrganizationDataAccess oda, Guid id) =>
{
  OrganizationTable? organization = await oda.GetOrganizationAsync(id);
  if (organization is null)
  {
    return TypedResults.NotFound();
  }
  return TypedResults.Ok(OrganizationResponse.FromTable(organization));
}).WithApiVersionSet(organizations).HasApiVersion(version1);

app.MapGet("/api/organizations", async (OrganizationDataAccess oda) =>
{
  IEnumerable<OrganizationTable> allOrganizations = await oda.GetOrganizationsAsync();
  return allOrganizations
    .Select(OrganizationResponse.FromTable);
}).WithApiVersionSet(organizations).HasApiVersion(version1);

现在我们可以开始考虑我们其他的需求了;即我们的 SV 端点中的 URL 应该包含组织。

友好的组织名称

当我们创建组织时,我们想创建一个“友好”的组织名称版本。我们首先要做的是从组织名称中删除任何空格。我们不会删除或更改非空格字符,因为我们希望名称尽可能符合区域性。我们想要一个国际化受众,所以我们不必将名称进行英语化。我们还希望这个友好名称是唯一的。让我们先解决最后一个要求。

我们已经有可用的东西可以让我们拥有唯一的名称。我们有哈希键,它不能有冲突。这似乎表明这是一个非常好的唯一名称候选。支持此功能的唯一更改是更改合同上的数据类型。

“但 Pete,这不意味着我们必须为 API 使用新版本吗?”还记得我们之前讨论过重大更改和 API 合同吗?严格来说,我们应该认为这是一个重大更改,因为它将数据类型从 Guid 更改为 string。由于我们还没有向任何人发布合同,所以我倾向于我们可以灵活一点,说我们不必立即引入新版本。我们不会让不同的团队连接到我们的服务并为我们提供 Guidstring,因为他们有旧的使用系统的场景,所以我们只需要一个版本。为了让我们的工作更轻松一些,我们将把条目名称保留为 id。我们需要进行四组更改:

首先,我们更改 Organization 合同,并更改 Id 字段的数据类型。我们将借此机会删除不再被调用(因为 get 调用现在使用 OrganizationResponse 类型)的 FromTable 方法。

[DataContract]
public class Organization
{ 
  [DataMember(Name="id")]
  public string? Id { get; set; }

  [DataMember(Name="name")]
  public string? Name { get; set; }

  [DataMember(Name="version")]
  public long? Version { get; set; }

  public virtual OrganizationTable ToTable(int modelVersion = 1) {
    return new OrganizationTable
    {
      Id = Id!,
      Name = Name!,
      Version = Version,
      ModelVersion = modelVersion
    };
  }
}

其次,我们更改 OrganizationResponse 合同。

[DataContract]
public class OrganizationResponse : Organization
{
  public OrganizationResponse() { }
  public OrganizationResponse(Organization organization)
  {
    Id = organization.Id;
    Name = organization.Name;
    Version = organization.Version;
  }

  [DataMember(Name="api-key")]
  public string? ApiKey { get; set; }

  public override OrganizationTable ToTable(int modelVersion = 1)
  {
    OrganizationTable table = base.ToTable(modelVersion);
    table.ApiKey = ApiKey;
    return table;
  }

  public static OrganizationResponse FromTable(OrganizationTable table)
  {
    return new()
    {
      Id = table.Id,
      Name = table.Name,
      ApiKey = table.ApiKey,
      Version = table.Version ?? 0
    };
  }
}

第三个更改是更改 API 调用中此类型的任何 Guid 引用。我们唯一需要担心的两个调用是按 id 获取和删除调用。只需将数据类型中的 Guid 更改为 string

app.MapGet("/api/organization/{id}", 
    async Task<Results<Ok<OrganizationResponse>, NotFound>> 
    (OrganizationDataAccess oda, string id) =>
{
  OrganizationTable? organization = await oda.GetOrganizationAsync(id);
  if (organization is null)
  {
    return TypedResults.NotFound();
  }
  return TypedResults.Ok(OrganizationResponse.FromTable(organization));
}).WithApiVersionSet(organizations).HasApiVersion(version1);

app.MapDelete("/api/organization/{id}", async (OrganizationDataAccess oda, string id) =>
{
  await oda.DeleteOrganizationAsync(id);
  return TypedResults.Ok();
}).WithApiVersionSet(organizations).HasApiVersion(version1);

第四个也是最后一个更改取决于我们更改数据访问代码以更改字段的数据类型。

public virtual async Task<OrganizationTable?> GetOrganizationAsync(string id)
{
  return await _dynamoDbContext.LoadAsync<OrganizationTable>(id);
}

public virtual async Task DeleteOrganizationAsync(string id)
{
  await _dynamoDbContext.DeleteAsync<OrganizationTable>(id);
}

现在我们有了一个可以保存具有唯一名称的组织的 API。现在让我们解决围绕此的其他要求。我一直在考虑的一件事是,友好名称应该为客户端自动生成。我们不希望用户创建友好名称,所以我们将添加一个 API 来为我们生成它。

关于名称,我们有两个要求:它应该没有空格,并且它不应关心区域性问题。让我们通过一个新的 API 来解决这两个要求,我们将其称为 friendlyname。这将接受组织名称作为参数,我们将从它获取友好名称。

重要提示:通过采用这种方法,我们规定友好名称只创建一次。如果用户在注册其组织时输入了错误,除了删除组织并创建一个新组织之外,没有其他机会进行更改。这不会阻止他们以后更改组织名称;只有初始调用才会创建此名称。

友好名称 API 的实现只是将 string 转换为不区分区域设置的lowercase,并删除空格条目。

app.MapGet("/api/friendlyname/{organization}",
  (string organization) => TypedResults.Ok(string.Join("",
    organization.ToLowerInvariant().Split(default(string[]), 
    StringSplitOptions.RemoveEmptyEntries))));

当我们为应用程序编写前端时,我们可以使用此方法来创建我们的友好 API 名称。

结论

在本文中,我们涵盖了大量内容。为了构建我们的虚拟化服务,我们决定创建 LocalStack 的代码表示,我们可以在 AWS 上托管。目前,代码非常“happy path”(正常路径),并且不执行错误处理或验证,但我们已经创建了一个 C# 服务,它使用 Minimal APIs 来为 DynamoDB 添加基本的 CRUD 操作。

为了使事情更有趣,我们已经开始使用 Terraform 描述我们的 DynamoDB 表将如何看待组织。我们已经达到了一个自然的节点,可以开始创建我们的用户界面了。在 下一篇文章中,我们将添加用户界面,并通过添加基本验证和错误处理来收紧我们的应用程序。我们还将添加与我们的组织相关的请求和响应项的第一个粗略实现。

历史

  • 2023 年 10 月 24 日:初始版本
© . All rights reserved.