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

从零开始构建服务虚拟化能力(第二部分)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2023年10月24日

CPOL

49分钟阅读

viewsIcon

6096

通过 LocalStack (AWS)、Minimal APIs 和 Terraform 构建云工具以实现服务虚拟化的案例研究

引言

这是我系列文章中第二篇比较不寻常的文章。这项服务是我三年工作的成果,灵感来自于观察团队在处理非正常路径测试时遇到的困难。它可以轻松集成到您的应用程序空间中。

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

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

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

第一篇文章中,我们构建了 API 结构的基础。我们使用 C# 构建了一个 Minimal API 服务,并由 Docker、LocalStack(代表 AWS)和 DynamoDB 提供支持,以构建版本化的 API 集。在本文中,我们将进入下一步,并为用户添加一个在 Blazor 应用程序中管理组织详细信息的功能。在此过程中,我们将了解如何添加 Keycloak 功能来支持身份验证和授权。

本系列之前的文章

源代码

  • 本文的源代码可在GitHub上获得。

Minimal API:服务器
Blazor:客户端

非常高层次的架构

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

  • 我们的服务将利用 AWS(Amazon Web Services)功能。我们可以使用 Azure,但我选择了 AWS。
  • 我们将部署一个服务来管理服务虚拟化(我们将在下面称之为 SV)。
  • 我们将只虚拟化 HTTP/HTTPS。
  • 我们将通过 API 控制我们的 SV 功能。
  • 我们将在我们的 SV 中托管多个用户/组织。
  • 我们将使用 DynamoDB 来控制请求/响应项目的存储。
  • 为了降低开发成本,所有开发都将使用LocalStack(免费套餐功能齐全)。
  • 我们将使用一种称为基础设施即代码 (IaC) 的技术来定义我们的云基础设施。我们将尽可能脚本化我们的 AWS 部署的各个区域。
  • 我们将使用 Blazor 作为我们的前端应用程序。我们可以使用任何客户端技术,但对于这次开发,我们将使用 Web Assembly (WASM) 框架。
  • 我们将使用 Docker 安装 LocalStack。
  • 我们将逐步构建我们的 SV 功能,所以我们会丢弃代码。这没关系,是很正常的事情。
  • 我们不会在代码中广泛添加单元测试。我想通过将 SV 构建和部署到云环境的过程,而不是让人们迷失在各种测试的细节中。
  • 我们一开始不会担心性能。如果我们需要提高速度,我们会对其进行性能分析,然后再做。
  • 我们不会花太多时间去纠结 AWS 的工作方式。在本系列中,我们将逐步积累我们的知识。
  • 所有对我们服务的 API 调用都将遵循创建在 /api 下的约定。
  • 我将创建尽可能少的接口。我将注册具体类型。为了使类型可测试,如果需要,我们可以将方法标记为 virtual
  • 我们的代码主要遵循“正常路径”。随着应用程序的构建,我们将开始加入错误处理和错误预防,但一开始,我们假设代码总是能正常工作。

先决条件

为了构建本文的代码,我们需要安装以下功能。

MudBlazor

MudBlazor 是 Blazor 的一个出色的组件框架。它完全开源,我必须承认,它是我最喜欢的 OSS 项目之一。要安装它的支持,我们只需要运行以下命令。

dotnet new install MudBlazor.Templates

创建我们的(Mud)Blazor 应用程序

为了简化运行我们的应用程序,我们将在新的 Visual Studio 实例中创建我们的 MudBlazor 应用程序。这使我们能够轻松地在需要时启动我们代码的不同部分。选择 创建新项目,然后选择 MudBlazor 模板模板。显然,下一步是选择项目名称(我选择了 Goldlight.Blazor.VirtualServer)。向导的最后一步很有趣,因为它允许我们选择 Blazor 应用的部署方式。我们将使用 Wasm-PWA 选项,兼顾两全其美,既可以部署为网站,用户也可以在本地桌面上运行。

Project type showing the different ways Blazor can be served up (WASM, WASM Hosted, WASM PWA, WASM PWA Hosted, Server)

注意:在撰写本文时,该模板会创建一个 .NET 6 项目。我将我的项目更新为 .NET 7(运行此代码所需的最低 .NET SDK 为 7.0.400)。

我们不会详细介绍当前存在的模板。相反,我们将一边添加功能一边移除一些样板代码。

添加组织设置

目前,我假设用户通过我们的后端服务创建了一个名为 codeproject 的组织。如果我们这样假设,就可以忽略用户尚未登录的事实。

我们将向屏幕左侧的导航链接添加一个设置部分。在此部分,我们将添加一个名为组织的导航链接,该链接允许我们维护有关我们组织的信息。

我们需要打开 Shared 文件夹并打开 NavMenu.razor。此文件包含我们将显示在侧面的链接。现在,此文件中的代码如下所示

<MudNavMenu>
  <MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">
   Home</MudNavLink>
  <MudNavLink Href="counter" Match="NavLinkMatch.Prefix" 
   Icon="@Icons.Material.Filled.Add">Counter</MudNavLink>
  <MudNavLink Href="fetchdata" Match="NavLinkMatch.Prefix" 
   Icon="@Icons.Material.Filled.List">Fetch data</MudNavLink>
</MudNavMenu>

我们需要做的是添加一个称为导航组的东西。这会将导航项分组到一个通用位置。在 </MudNavMenu> 行之前添加以下代码。

<MudNavGroup Title="Settings" Icon="@Icons.Material.Filled.Settings">
</MudNavGroup>

这会创建导航组并为其提供设置标题。为了增加视觉吸引力,我们还添加了一个设置图标,它将出现在文本旁边。在本系列中,我们将尽可能利用 MudBlazor 的功能;我们可以通过控件以 Mud 开头来识别它们。

现在我们要添加我们将点击以查看详细信息的实际导航链接。在 MudNavGroup 部分内,添加以下行。

<MudNavLink Href="settings/organization" Match="NavLinkMatch.All">Organization</MudNavLink>

在这里,我们添加了一个 MudNavLink,当 href 明确匹配 settings/organization 时,它将导航到 Organization 页面。

我们的导航文件现在应该如下所示

<MudNavMenu>
  <MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">
   Home</MudNavLink>
  <MudNavLink Href="counter" Match="NavLinkMatch.Prefix" 
   Icon="@Icons.Material.Filled.Add">Counter</MudNavLink>
  <MudNavLink Href="fetchdata" Match="NavLinkMatch.Prefix" 
   Icon="@Icons.Material.Filled.List">Fetch data</MudNavLink>
  <MudNavGroup Title="Settings" Icon="@Icons.Material.Filled.Settings">
    <MudNavLink Href="settings/organization" Match="NavLinkMatch.All">
     Organization</MudNavLink>
  </MudNavGroup>
</MudNavMenu>

设置好导航后,我们就可以添加我们将导航到的页面了。在 Pages 文件夹内,我们添加一个名为 Settings 的新文件夹。我们将在其中添加 Organization 组件。

在 razor 文件顶部添加此行,将此组件设置为导航的目标。

@page "/settings/organization"

现在运行应用程序,我们可以导航到组织页面。让我们考虑一下我们在这个页面上需要做什么。这是一个设置页面,将显示组织的详细信息,因此我们需要从服务器检索该数据。为此,我们将需要做三件事。

  1. 在我们的服务中启用 CORS 支持,以便提供请求。
  2. 向后端服务发出 API 调用。
  3. 显示 API 调用结果。

启用 CORS 支持

这需要更改后端,以便支持我们的前端与 API 进行交互。这是一个简单的更改,只要我们假设我们不会对允许进入我们应用程序的源、方法和标头进行限制。首先添加以下命名的 CORS 策略。

builder.Services.AddCors(options =>
{
  options.AddPolicy("AllAllowed",
    policy =>
    {
      policy
        .AllowAnyOrigin()
        .AllowAnyMethod()
        .AllowAnyHeader();
    });
});

设置好之后,我们只需告诉我们的服务我们要使用我们的 CORS 策略。

app.UseCors("AllAllowed");

调用后端服务

我们不会直接从页面调用服务器,而是创建一个封装调用逻辑的类,该类将返回适当“形状”的模型。我们将为 GET 调用创建一个简单的模型。

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

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

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

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

使用 Blazor 的好处在于,我们可以像从一个服务调用另一个服务一样编写 API 代码。这提供了一个非常熟悉的结构,并利用了使用 HttpClient 的能力。

我在这里要坦白一件事。当我设置服务器代码中的版本控制时,我并没有真正考虑过在客户端扩展 content-type 时会遇到的困难。为了让我的生活更轻松,我扩展了服务器中的版本控制以支持一个名为 x-api-version 的属性。在服务器中,将版本注册更改为以下内容。

builder.Services.AddApiVersioning(options =>
{
  options.ReportApiVersions = true;
  options.DefaultApiVersion = version1;
  options.ApiVersionReader = ApiVersionReader.Combine(
    new MediaTypeApiVersionReader(),
    new HeaderApiVersionReader("x-api-version"));
});

通过添加 ApiVersionReader.Combine 方法,我们可以提供多种方法来支持版本控制。HeaderApiVersionReader 的用户允许我们将属性添加到我们的标头中。

回到 Blazor 应用程序,我们将添加一个 OrganizationApi 实现。由于我们将 HttpClient 注入到此中,我们知道我们将此注册为注入到页面中。API 客户端。

public class OrganizationApi
{
  private readonly HttpClient httpClient;

  public OrganizationApi(HttpClient httpClient)
  {
    this.httpClient = httpClient;
    this.httpClient.BaseAddress = new Uri("https://:5106/");
    this.httpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-api-version", "1.0");
  } 

  public async Task<ExtendedOrganization?> GetOrganization(string? id)
  {
    var response = await httpClient.GetAsync($"api/organization/{id}");
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<ExtendedOrganization>();
  }
}

我们像这样注册这个类进行注入。

builder.Services.AddScoped<OrganizationApi>();

在屏幕上显示结果。

在 breakdown 代码如何工作之前,让我们看一下最终代码实际上是什么样的。

@inject OrganizationApi OrganizationApi

<PageTitle>Organization</PageTitle>

<DataContainer HasData="@(organization is not null)">
  <NoData>
    <MudText>No organization details present</MudText>
  </NoData>
  <DataTemplate>
    <h2>Organization details</h2>
    <h3>Name:</h3>
    <MudText>@organization!.Name</MudText>
    <h2>API</h2>
    <h3>API Key:</h3>
    <MudText>@organization!.ApiKey</MudText>
  </DataTemplate>
</DataContainer>

@code {
  private ExtendedOrganization? organization;
  protected override async Task OnInitializedAsync()
  {
    organization = await OrganizationApi.GetOrganization("goldlight");
  }
}

注意:我在这篇文章中解释了 DataContainer 的工作原理。在 Github 项目中,这个类位于 Components 文件夹中。

代码首先将我们的 OrganizationApi 注入到服务中。有了这个可用,我们在页面初始化代码中调用 GetOrganization 方法来检索显示所需的信息。

DataTemplate 部分包含显示的“核心”。当我们收到 API 调用的结果时,organization 字段就会被填充。我们在显示中显示这些字段的值。

在 Blazor 中,我们需要编写多少代码就能检索和显示此类信息,这真是令人印象深刻。

稍后,当我们讨论需要做什么来更改组织名称时,我们会回到这个页面。目前,我们将其保持为只读,因为我们希望达到上传单个请求和响应的程度,这将是第一个“真正”的虚拟化服务。这将使我们能够前后端协同工作以添加此额外功能。

我们的应用程序看起来像这样

Organization settings showing in the settings tab

我知道它目前很难看,而且细节周围有很多空白。整理空白很简单,而且只需要在一个地方进行。

MainLayout 文件中,我们有以下元素。

Razor

<MudMainContent>
    <MudContainer MaxWidth="MaxWidth.Large" Class="my-16 pt-16">
        @Body
    </MudContainer>
</MudMainContent>

容器设置了边距和内边距。使用 my-16 设置页面顶部和底部的边距,pt-16 设置顶部的内边距。您可以将这些设置中的数字替换为 0 到 16 之间的值,其中每个数字代表 4 像素的增量。因此,my-16 设置 64 像素的边距(16*4)。无边距为 my-0,4 像素为 my-1。如果我想调整左边距或右边距,我可以使用 mlmr。我们将删除所有边距,并在顶部保留 4 像素的内边距,如下所示。

Razor

<MudMainContent>
    <MudContainer MaxWidth="MaxWidth.Large" Class="my-0 ml-0 mr-0 pt-1">
        @Body
    </MudContainer>
</MudMainContent>

稍后,当我们讨论需要做什么来更改组织名称时,我们会回到设置。目前,我们只是将其保持为只读,因为我们希望达到上传单个请求和响应的程度,这将是我们第一个“真正”的虚拟化服务。这将使我们能够前后端协同工作以添加此额外功能。

添加请求和响应对

向组织添加项目

为了使事物整洁有序,每个请求/响应都将属于一个虚拟分组,该分组将相关的操作组合在一起。起初,我曾考虑将这些称为服务,但这对我来说感觉不对,因为它往往会导致一种心智模型,即 RR 对与某个服务相关,感觉我限制了用户实际使用服务的方式。我决定,相反,将这些分组中的每一个称为项目。

从用户的角度来看,我的想法是,主页将列出组织的所有项目。用户将能够编辑项目详细信息、删除项目或添加新项目,所有这些都由主屏幕驱动。最重要的是,他们将能够打开一个项目并查看已添加到其中的所有请求响应对,从而允许他们进行操作。

这次我们将反向进行。通常,我倾向于从 API 合同开始,但让我们尝试以一种能够快速迭代更改的方式构建屏幕,然后再决定我们的 API 需要做什么。这是一种有趣且交互式的原型设计更改并近乎实时地创建线框图的方法。

让我们决定项目需要什么。我认为应该将项目用作 URL 的一部分,因此我们需要一个名称,以及一个 URL 友好的名称版本。添加描述也会很有用。

我们将快速创建两个类来模拟一些数据。

private class Projects : List<Project>
{
  public Projects()
  {
    Add(new Project { Name = "Code Project", FriendlyName = "codeproject", 
                      Description = "Code Project APIs" });
    Add(new Project { Name = "Goldlight Samples", FriendlyName = "goldlight", 
                      Description = "Goldlight APIs" });
  }
}

private class Project
{
  public string Name { get; set; }
  public string FriendlyName { get; set; }
  public string Description { get; set; }
}

快速提示:我们将主页设置为项目视图,并将向 Index.razor 添加功能。所以,请继续删除 PageTitle 下方的所有内容。我们不需要那个。

最初,我一直在使用 MudBlazor 中的 table 或 datagrid 组件之间来回切换。阅读文档后,我决定使用 datagrid,因为它让我想起了我们的用户可能会觉得有用的东西;如果他们有很多项目,能够搜索一个项目会很有用。

在 razor 文件中添加一个代码部分,并实例化一个 Projects 类的实例。我们将将其绑定到我们的 datagrid 中。

@code {
  private Projects projects = new();
}

我们将使用的 datagrid 称为 MudDataGrid。它很容易设置,只需要将 Items 属性设置为我们要绑定的项。由于网格是通用的,我们使用 T 属性来指定我们要显示的对象的类型。要将列添加到显示中,我们添加一个 Columns 条目,其中包含 PropertyColumn 条目,这些条目绑定到列上的特定属性。

<MudDataGrid T="Project" Items="projects">
  <Columns>
    <PropertyColumn Property="x => x.Name" Title="Name" />
    <PropertyColumn Property="x => x.FriendlyName" Title="Friendly name" />
  </Columns>
</MudDataGrid>

运行应用程序会得到这个结果

Datagrid displaying two rows of bound data

在每个条目旁边显示基本 URL 会很好,我们有足够的能力来做到这一点。与设置页面一样,我们将注入 OrganizationApi 的实例,并使用它来获取组织 ID,它将作为 API 的一部分。 (顺便说一句,我注意到我给我的 GetOrganization 方法起了一个我不喜欢的名字。由于这是一个异步方法,我想让消费者知道它可以与 async/await 一起调用,所以我将方法重命名为 GetOrganizationAsync。)

顺便说一句,我注意到我给我的 GetOrganization 方法起了一个我不喜欢的名字。由于这是一个异步方法,我想让消费者知道它可以与 async/await 一起调用,所以我将方法重命名为 GetOrganizationAsync

private ExtendedOrganization? organization;
protected override async Task OnInitializedAsync()
{
  organization = await OrganizationApi.GetOrganizationAsync("goldlight");
}

我们现在只需要更新我们的 datagrid 添加一个新的列。此列实际上不映射到属性,因此我们将使用不同的列类型,即 TemplateColumn,它允许我们将任意信息添加到行中。在这种情况下,我们将添加一个包含 URL 不同部分的文本 string

<MudDataGrid T="Project" Items="projects" Hover="true">
  <Columns>
    <PropertyColumn Property="x => x.Name" Title="Name"/>
    <PropertyColumn Property="x => x.FriendlyName" Title="Friendly name"/>
    <TemplateColumn Title="Base URL">
      <CellTemplate>
        <DataContainer HasData="organization != null">
          <DataTemplate>
            <MudText>https://:5601/@organization!.Id/@context.Item.FriendlyName
            </MudText>
          </DataTemplate>
        </DataContainer>
      </CellTemplate>
    </TemplateColumn>
  </Columns>
</MudDataGrid>

为了从当前行中获取项目,我们使用 @context.Item,它为我们提供了对相关绑定条目的访问。

我提到我们可能希望我们的用户能够搜索网格中匹配的条目。添加此功能是一项微不足道的任务。第一部分是在我们的代码中添加一个搜索 string 和过滤器函数。我们很快就会绑定到这些。

private string? searchString;

private Func<Project, bool> QuickFilterFunc => row => 
                            string.IsNullOrWhiteSpace(searchString) 
  || row.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase) 
  || row.FriendlyName.Contains(searchString, StringComparison.OrdinalIgnoreCase);

QuickFilter 函数将在 searchString 为空或通过的行包含 NameFriendlyName 属性中的搜索 string 时返回 true

我们将为我们的 datagrid 添加一个工具栏。此工具栏包含一个绑定到搜索 string 的文本字段。QuickFilterFunc 绑定到我们的 datagrid,为我们提供了搜索功能。

<MudDataGrid T="Project" Items="projects" Hover="true" QuickFilter="QuickFilterFunc">
  <ToolBarContent>
    <MudText Typo="Typo.h6">Projects</MudText>
    <MudSpacer/>
    <MudTextField @bind-Value="searchString" Placeholder="Search" 
                  Adornment="Adornment.Start" Immediate="true"
                  AdornmentIcon="@Icons.Material.Filled.Search" 
                  IconSize="Size.Medium" Class="mt-0" />
    
  </ToolBarContent>
  <Columns>
    <PropertyColumn Property="x => x.Name" Title="Name"/>
    <PropertyColumn Property="x => x.FriendlyName" Title="Friendly name"/>
    <TemplateColumn Title="Base URL">
      <CellTemplate>
        <DataContainer HasData="organization != null">
          <DataTemplate>
            <MudText>https://:5601/@organization!.Id/@context.Item.FriendlyName
            </MudText>
          </DataTemplate>
        </DataContainer>
      </CellTemplate>
    </TemplateColumn>
  </Columns>
</MudDataGrid>

注意ToolBarContent 直接取自 MudBlazor 数据网格示例。

我们应该花点时间为自己鼓掌。在几分钟内,我们就创建了一个绑定到模型属性、添加派生列并提供轻松搜索我们数据的网格。

Project grid with search capability, and derived column.

添加新项目

除了显示项目之外,我们还应该给自己添加添加新项目的能力。就在数据网格下方,我们添加一个导航到新页面的按钮。显然,这也需要我们在应用程序中创建一个新的 Razor 页面。

<MudButton Href="/projects/addproject" Variant="Variant.Filled" 
  StartIcon="@Icons.Material.Filled.Add" Color="Color.Primary" 
  Class="mt-2">Add project</MudButton>

到达 /projects/addproject 页将使用 MudBlazor 的表单功能。我们将创建一个编辑表单,支持添加我们感兴趣的信息,并自动验证条目以确保它们已被设置。

为了开始这个过程,我们将更新模型以包含一些简单的数据注释。

public class Project
{
  [Required]
  public string? Name { get; set; }
  [Required]
  public string? FriendlyName { get; set; }
  [Required] 
  public string? Description { get; set; }
}

有了这些注释,我们就可以创建编辑表单,并告诉它使用 DataAnnotations 进行验证。这是一种让我们自己生活变得非常简单的好方法。在表单中,我们将为名称创建一个文本字段,为友好名称创建一个文本字段,并为描述创建一个多行文本字段。由于我们希望验证自动应用,我们将告诉编辑表单我们使用数据注释进行验证,并且在每个字段中,我们将使用 For 属性告诉它验证什么。

我们的全部代码看起来像这样

@page "/projects/addproject"

<PageTitle>Add project</PageTitle>

<EditForm Model="@project" OnValidSubmit="OnValidSubmit">
  <DataAnnotationsValidator/>
  <MudCard>
    <MudCardContent>
      <MudTextField Label="Project name" @bind-Value="project.Name" 
       For="@(() => project.Name)"/>
      <MudTextField Label="Friendly name" @bind-Value="project.FriendlyName" 
       For="@(() => project.FriendlyName)"/>
      <MudTextField Label="Project description" Lines="10" 
       @bind-Value="project.Description" For="@(() => project.Description)"/>
      <MudButton StartIcon="@Icons.Material.Filled.Save" Variant="Variant.Filled" 
       Color="Color.Primary" ButtonType="ButtonType.Submit" Class="mt-2">
       Add project</MudButton>
    </MudCardContent>
  </MudCard>
</EditForm>

@code {

  readonly Project project = new();
  private void OnValidSubmit(EditContext context)
  {
    StateHasChanged();
  }
}

这里有几点需要注意。第一点是我们实际上在提交时没有做什么,这与我们之前所说的相符,即我们目前只优先考虑设计屏幕。第二点更微妙,并且可能在将来给我们的用户带来问题。这与屏幕的初始状态有关。

当我们显示此表单时,我们没有验证失败。在触碰并移出字段之前,我们看不到验证失败。这意味着模型注释说字段是必需的,但我们的代码尚未设置它。换句话说,此版本的代码允许我们将空数据添加到我们的数据库。为避免任何问题,我们只需要禁用提交按钮,直到用户输入所有必需的数据。为了获得此能力,我们将设置 Disabled 状态,使其取决于编辑表单是否已修改以及验证是否已完成。在我们的按钮标签中,我们只需要添加以下属性。

Disabled="@(!context.IsModified() || !context.Validate())"

现在,友好名称是我们将在 URL 中使用的名称,因此显示它将是什么样子会很有用。同样,我正在重构并根据需要更改内容,因此会有一些领域我需要再次触及代码。如果您还记得,从第一篇文章开始,我删除了 string 中的空格以创建“独特”的友好名称。正是在处理这段代码时,我意识到这是一个愚蠢的决定。想象一下这种情况;组织的名称是 App Le,他们注册了我们。按照我们的指南,这将给我们一个友好名称 apple。当 Apple 加入时,他们尝试注册 API 并发现 apple 已经被占用了。显然,他们不会高兴。

那么,友好名称是什么?那个问题的答案非常简单。我们以前应该做过,现在要做的是对友好名称进行 UrlEncode;因此,App Le 的组织友好名称将是 app+le。我们将对我们的名称应用类似的规则。这意味着我们需要进行以下更改。

  1. 纠正我们的后端服务以删除 friendlyname API。我们有一种更简单的方法来实现有意义的 URL。由于组织是通过 POST 操作创建的,我们只需要在后端对 ID 字段进行 URL 编码。
  2. 显示 URL 编码版本的友好名称,以向用户显示它的外观。

我们将从一个简单的派生属性开始,该属性将显示在 URL 中。使用以下代码更新 Project 类。

public string UrlName => FriendlyName is not null ? WebUtility.UrlEncode(FriendlyName) : "";

现在我们有了可以在 UI 上显示的内容。由于在项目页面和添加项目页面中显示此 URL 名称的代码是重复的,我们将简化工作。我们将创建一个组件来为我们完成此任务。让我们创建一个名为 UrlFriendlyProject 的组件。组件中的大部分代码将是熟悉的,因此我们不会深入研究。

@inject OrganizationApi OrganizationApi

<DataContainer HasData="organization is not null">
  <DataTemplate>
    <MudText>https://:5601/@organization!.Id/@Project.UrlName</MudText>
  </DataTemplate>
</DataContainer>

@code {
  [Parameter]
  public Project Project { get; set; } = new();

  private ExtendedOrganization? organization;
  protected override async Task OnInitializedAsync()
  {
    organization = await OrganizationApi.GetOrganizationAsync("goldlight");
  }
}

Parameter 属性意味着我们可以直接将项目传递到我们的组件。有了这个,我们的视图页面可以轻松重构以移除重复的区域。

@page "/"

<PageTitle>Projects</PageTitle>

<MudDataGrid T="Project" Items="projects" Hover="true" 
 QuickFilter="QuickFilterFunc" Style="align-items: center;">
  <ToolBarContent>
    <MudText Typo="Typo.h6">Projects</MudText>
    <MudSpacer/>
    <MudTextField @bind-Value="searchString" Placeholder="Search" 
     Adornment="Adornment.Start" Immediate="true"
                  AdornmentIcon="@Icons.Material.Filled.Search" 
                  IconSize="Size.Medium" Class="mt-0" />
    
  </ToolBarContent>
  <Columns>
    <PropertyColumn Property="x => x.Name" Title="Name"/>
    <PropertyColumn Property="x => x.FriendlyName" Title="Friendly name"/>
    <TemplateColumn Title="Base URL">
      <CellTemplate>
        <UrlFriendlyProject Project="context.Item"></UrlFriendlyProject>
      </CellTemplate>
    </TemplateColumn>
  </Columns>
</MudDataGrid>
<MudButton Href="/projects/addproject" Variant="Variant.Filled" 
 StartIcon="@Icons.Material.Filled.Add" Color="Color.Primary" 
 Class="mt-2">Add project</MudButton>

@code {

  private readonly Projects projects = new();
  private string? searchString;

  private Func<Project, bool> QuickFilterFunc => row => 
          string.IsNullOrWhiteSpace(searchString) || 
          row.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase) || 
          row.FriendlyName.Contains(searchString, StringComparison.OrdinalIgnoreCase);
}

我们的添加页面也将进行类似的改造,现在应该看起来像这样

@page "/projects/addproject"
<PageTitle>Add project</PageTitle>

<EditForm Model="@project" OnValidSubmit="OnValidSubmit">
  <DataAnnotationsValidator/>
  <MudCard>
    <MudCardContent>
      <MudTextField Label="Project name" @bind-Value="project.Name" 
       For="@(() => project.Name)"/>
      <MudTextField Label="Friendly name" @bind-Value="project.FriendlyName" 
       For="@(() => project.FriendlyName)"/>
      <UrlFriendlyProject Project="@project"></UrlFriendlyProject>
      <MudTextField Label="Project description" Lines="10" 
       @bind-Value="project.Description" For="@(() => project.Description)"/>
      <MudButton StartIcon="@Icons.Material.Filled.Save" Variant="Variant.Filled" 
       Color="Color.Primary" ButtonType="ButtonType.Submit" 
       Disabled="@(!context.IsModified() || !context.Validate())" Class="mt-2">
       Add project</MudButton>
    </MudCardContent>
  </MudCard>
</EditForm>

@code {

  readonly Project project = new();

  private void OnValidSubmit(EditContext context)
  {
    StateHasChanged();
  }
}

除了将内容连接到 API 之外,我们在此页面上需要做的最后一件事是在添加条目后导航回主页。让我们将此导航添加到我们的表单提交中。

导航从将 NavigationManager 的实例注入到添加页面开始。

@inject NavigationManager NavigationManager

然后,我们只需将 StateHasChanged 行替换为以下内容

NavigationManager.NavigateTo("/");

处理实际项目

在这一点上,我对我们在思考项目视图和添加项目屏幕的外观方面所取得的进展感到满意。现在是时候为这项功能设置后端了。这是我们需要开始决定项目是否属于组织表的地方。为了做出这个决定,我们需要考虑我们产品的层次结构。我的意思是,每个组织可以有多个项目,每个项目可以有多个请求/响应对。理论上,我们可以认为它看起来像这样

Overview of the organization to rest/response links

最终,每个组织都可以有多个项目,每个项目可以有任意数量的 API,这些 API 由请求/响应对表示。如果我们想将这个完整的层次结构嵌入到我们的组织表中,我们很快就会遇到 DynamoDB 的一个基本问题;即它每个行只能存储 400KB 的数据。这将限制每个组织可以存储的 API 数量。

考虑到这一限制,我们必须选择是否认为我们需要另一个表。目前,我们假设我们将创建一个新的表来模拟项目。在每个项目内,我们将存储一些 API。这意味着我们将修改我们的 Terraform 开发作为起点。

我们将编写的 Terraform 代码需要创建一个初看之下类似于外键的东西。我们将添加一个 organization_id 属性,并用对组织名称的引用来填充它。这样做的原因是,我们希望有一种方法来搜索属于父组织的项目的,这需要我们在 DynamoDB 中创建所谓的全局二级索引。代码如下

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

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

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

  global_secondary_index {
    name            = "project-organization_id-index"
    hash_key        = "organization_id"
    write_capacity  = 20
    read_capacity   = 20
    projection_type = "ALL"
  }
}

我们的 Terraform 脚本创建了一个具有两个定义属性的表; idorganization_id。它们代表键字段(id),当我们向其中添加数据时,它将是一个 GUID,以及一个看起来像外键的东西(organization_id)。全局二级索引在 global_secondary_index 片段中创建。我们通过创建一个名为 project-organization_id-index 的索引,该索引映射到 organization_id 条目,这样当我们查询项目时,我们使用组织作为哈希进行搜索。通过这样做,我们消除了扫描表中所有数据以查找感兴趣的数据的需要。

你可能想知道的一件事;为什么我们添加了 ALLprojection_type?这里有几种不同的值我们可以应用; KEYS_ONLY, ALL,或 INCLUDE。这些值的作用是识别添加到索引中的内容。如果我们指定 KEYS_ONLY,则只添加键字段。如果我们使用 INCLUDE,我们可以包含其他字段,但必须明确指定。我们将使用一个额外的属性,我们称之为 Details,它将作为 JSON 有效负载存储其他属性。由于我们使用了一个额外的属性,我们将使用 ALL 将其投影到索引中。这应该给我们一个提示,当我们查询时,我们将查询索引,而不是表。我们将在查询代码中看到这一点。

继续,并为这个新表应用 terraform。

项目服务器

我喜欢我们刚刚编写的代码的一点是,我们已经有了 API 的基础。我们可以利用 Project 类,并直接将其放入服务器实现中。唯一需要添加的是 ToTable 方法,以便在我们创建 DynamoDB 表时将其转换为 DynamoDB 表。让我们从 DynamoDB 表开始。

[DynamoDBTable("projects")]
public class ProjectTable
{
  [DynamoDBHashKey("id")]
  public string Id { get; set; } = "";
  [DynamoDBProperty("organization_id")]
  public string OrganizationId { get; set; } = "";
  [DynamoDBProperty]
  public int ModelVersion { get; set; } = 1;
  [DynamoDBVersion]
  public long? Version { get; set; }
  [DynamoDBProperty("details")] 
  public Details? Details { get; set; }
}

public class Details
{
  [DynamoDBProperty]
  public string Name { get; set; }
  [DynamoDBProperty]
  public string Description { get; set; }
  [DynamoDBProperty]
  public string FriendlyName { get; set; }
}

ProjectTable 中的大多数概念现在应该很熟悉了,但你可能已经注意到,我们在这里没有直接包含友好名称、名称或描述的条目。我们没有这些属性的原因是我们有 Details 属性,它映射到 Details 类。通过这样做,我们可以存储项目下的子对象,使我们能够创建我们需要的尽可能复杂的类集。这种能力是我们使用 NoSQL 数据库而不是 SQL 数据库的主要原因,以便我们可以在需要时扩展我们的模型。它确实存在一些限制,例如无法搜索嵌套在这些子对象中的对象,但如果需要,还有其他搜索功能。

我们还想创建一个项目的DataAccess 类,这应该不足为奇。最初,我们只会实现两个方法,一个用于保存项目详细信息,另一个用于获取属于特定组织的所有项目。在解释 getAll 方法如何工作之前,让我们看看该类是什么样的。

public class ProjectDataAccess
{
  private readonly IDynamoDBContext dynamoDbContext;
  public ProjectDataAccess(IDynamoDBContext dbContext)
  {
    dynamoDbContext = dbContext;
  }

  public virtual async Task SaveProjectAsync(ProjectTable project)
  {
    await dynamoDbContext.SaveAsync(project);
  }

  public virtual async Task<IEnumerable<ProjectTable>> 
                 GetProjectsAsync(string organization)
  {
    DynamoDBOperationConfig queryOperationConfig = new DynamoDBOperationConfig
    {
      IndexName = "project-organization_id-index"
    };

    List<ProjectTable> projects = new();
    AsyncSearch<ProjectTable>? search = 
      dynamoDbContext.QueryAsync<ProjectTable>(organization, queryOperationConfig);
    while (!search.IsDone)
    {
      projects.AddRange(await search.GetNextSetAsync());
    }
    return projects;
  }
}

SaveProjectAsync 看起来很熟悉,并且在看到 OrganizationDataAccess 类的代码后应该不足为奇。这里不同的是 GetProjectsAsync 方法。在此方法中,我们创建一个 DynamoDBOperationConfig 实例,该实例说明我们将使用在 Terraform 脚本中刚刚创建的相同 IndexName 来查找项目。我们将此与组织一起传递到 QueryAsync 方法中,以便进行搜索。

在组织的 getAll 代码中,我们直接使用 GetRemainingAsync 返回数据。我们这样做是为了获得一个可以玩耍的实现,但它只适用于小数据集。GetRemainingAsync 方法处理分页数据集。我们真正需要做的是构建完整的数据集,这就是我们使用 IsDone 属性的原因,如果我们还没有读取完数据,我们就会获取下一组数据并将其添加到项目列表中。在我添加这段代码时,我已经纠正了组织代码以使用相同的方法。(不要忘记在 IoC 容器中注册 ProjectDataAccess 类)。

现在我们剩下要做的就是添加我们要使用的 API 类,然后在我们的服务器中创建实际的端点。一旦我们做到了这一点,我们就可以回到我们的前端代码。我们不会详细介绍模型类,因为我们现在应该对这种方法感到满意。我们唯一真正要看的是我们不会传入 id,所以我们将在服务器端创建它,并将详细信息传回。

public class Project
{
  [Required]
  public string? Name { get; set; }
  [Required]
  public string? FriendlyName { get; set; }
  [Required]
  public string? Description { get; set; }
  [Required]
  public string Organization { get; set; }

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

public class ExtendedProject : Project
{
  public ExtendedProject() {}
  public ExtendedProject(Project project)
  {
    Organization = project.Organization;
    Name = project.Name;
    Description = project.Description;
    FriendlyName = project.FriendlyName;
  }

  [DataMember]
  public Guid Id { get; set; }

  public virtual ProjectTable ToTable(int modelVersion = 1) =>
    new()
    {
      Id = Id.ToString(),
      OrganizationId = Organization,
      Details = new ()
      {
        Description = Description, FriendlyName = FriendlyName, Name = Name
      },
      ModelVersion = modelVersion,
      Version = Version
    };

  public static ExtendedProject FromTable(ProjectTable table)
  {
    ExtendedProject project = new ExtendedProject
    {
      Id = Guid.Parse(table.Id),
      Organization = table.OrganizationId,
      Name = table.Details.Name,
      Description = table.Details.Description,
      FriendlyName = table.Details.FriendlyName,
      Version = table.Version
    };
    return project;
  }
}

现在我们有了类,我们就可以创建我们的 Minimal API 了。我们可以从为它们创建新的版本集开始。

ApiVersionSet projects = app.NewApiVersionSet("Projects").Build();

POSTGET 方法现在都应该是我们熟悉的。对于 POST,我们传入 Project,并将其转换为 ExtendedProject,然后将其保存到数据库中。

GET 方法将组织作为路径的一部分接受,并在查询数据库时使用它。

app.MapPost("/api/project", async (ProjectDataAccess dataAccess, Project project) =>
{
  ExtendedProject extendedProject = new(project)
  {
    Id = Guid.NewGuid()
  };
  await dataAccess.SaveProjectAsync(extendedProject.ToTable());
  extendedProject.Version = 0;
  return TypedResults.Created($"/api/organization/{extendedProject.Id}", extendedProject);
}).WithApiVersionSet(projects).HasApiVersion(version1);

app.MapGet("/api/{organization}/projects/", 
  async Task<Results<Ok<IEnumerable<ExtendedProject>>, NotFound>> 
    (ProjectDataAccess dataAccess, string organization) =>
{
  IEnumerable<ProjectTable> allProjects = await dataAccess.GetProjectsAsync(organization);
  if (!allProjects.Any()) return TypedResults.NotFound();
  return TypedResults.Ok(allProjects.Select(ExtendedProject.FromTable));
}).WithApiVersionSet(projects).HasApiVersion(version1);

现在我们有了将项目添加到前端所需的一切,所以我们将回到代码,并开始移除硬编码的示例数据。

将项目 API 与我们的项目屏幕集成

我最初的想法是关于我们需要在前台提供哪些更改。

  • 我们需要确保我们有一个适合传输到服务器的模型,并且它能满足我们从后端提供的大量额外信息。
  • 我们需要添加一个 API 类来从前端调用后端。
  • 我们想从我们的项目屏幕调用这些 API。

这些要求足够简单,我们可以处理,所以我们将从更新我们的 Project 类开始。当我们最初创建它时,我们使用了 [Required] 属性。虽然这些对于我们的验证很有用,但它们无助于我们将数据传输到服务器。为此,我们需要为每个属性添加 DataMember 属性。当我们在此类中时,我们将扩展它,使其包含服务器上 ExtendedProject 类中存在的所有属性。你可能会想为什么我们在服务器端有两个独立的模型,但我们将在前端使用一个。这背后的原因是理解,任何发送到服务器的属性,如果它不存在于 API 模型所期望的属性上,都会被忽略。所以,如果我们要在前端将 id 添加到我们的 POST 调用中,后端代码就不会使用它,因为它在那里不是一个已知的属性。

public class Project
{
  [DataMember]
  public Guid Id { get; set; }
  [Required]
  [DataMember]
  public string? Organization { get; set; }
  [Required]
  [DataMember]
  public string? Name { get; set; }
  [Required]
  [DataMember]
  public string? FriendlyName { get; set; }
  [Required]
  [DataMember]
  public string? Description { get; set; }
  [DataMember(Name = "version")]
  public long? Version { get; set; }

  public string UrlName => FriendlyName is not null ? 
         WebUtility.UrlEncode(FriendlyName.ToLowerInvariant()) : "";
}

让我担心 OrganizationApi 的代码是我们在 DefaultRequestHeaders 中添加 API 版本。另一个让我担心的是,我们不应该在每次调用中都覆盖 BaseAddress。在 program 类中,我们创建一个作用域 HttpClient,这意味着每个屏幕最终都可能向不同的端点发出多个 HTTP 调用。当前实现在这两方面都让我们失望。我们要做的第一件事是覆盖 BaseAddress。为了支持这一点,我们将创建一个 appsettings 文件。这需要在 wwwroot 中创建,以便在应用程序启动时被我们的应用程序拾取。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Server": {
    "BaseAddress": "https://:5106/",
  }
}

请注意,BaseAddress 是服务器的 url,这意味着我们可以更改我们的作用域 URL 创建以从配置中读取。

builder.Services.AddScoped(sp => 
  new HttpClient { BaseAddress = new Uri(builder.Configuration["Server:BaseAddress"]!) }
);

我非常赞成不一遍又一遍地编写相同的代码。我说的不是 SOLID 原则,而是指避免大量重复或复制代码。我们将利用一个事实,即从 HttpClient 到服务器的所有调用实际上都在内部使用 SendAsync 方法。有了这些知识,我们将创建一个 HttpClientExtensions 类,为我们的调用提供扩展方法。在我最终确定以下代码之前,我经历了几次迭代。

public static class HttpClientExtensions
{
  public static async Task<T?> Get<T>(this HttpClient client, string uri) =>
    await GetResponseFromServer<T>(client, GetRequestMessage(HttpMethod.Get, uri));

  public static async Task<T?> Post<T>
         (this HttpClient client, string uri, T requestBody) =>
    await GetResponseFromServer<T>(client, GetRequestMessage
                                  (HttpMethod.Post, uri, requestBody));

  public static async Task<T?> Put<T>
        (this HttpClient client, string uri, T requestBody) =>
    await GetResponseFromServer<T>(client, GetRequestMessage
                                  (HttpMethod.Put, uri, requestBody));

  public static async Task Delete<T>(this HttpClient client, string uri) =>
    await GetResponseFromServer<T>(client, GetRequestMessage(HttpMethod.Delete, uri));

  private static async Task<T?> GetResponseFromServer<T>
       (HttpClient client, HttpRequestMessage requestMessage)
  {
    HttpResponseMessage response = await client.SendAsync(requestMessage);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<T>();
  }

  private static HttpRequestMessage GetRequestMessage(HttpMethod method, string uri)
  {
    HttpRequestMessage requestMessage = new HttpRequestMessage(method, uri);
    requestMessage.Headers.Add("x-api-version", "1.0");
    return requestMessage;
  }

  private static HttpRequestMessage GetRequestMessage<T>
         (HttpMethod method, string uri, T requestBody)
  {
    HttpRequestMessage requestMessage = GetRequestMessage(method, uri);
    requestMessage.Content = 
      new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8,
      "application/json");
    return requestMessage;
  }
}

有了这些扩展,我们就可以创建一个简化的 ProjectApi 类,我们将把它注入到我们的 razor 页面中。首先,我们的 API 类。

public class ProjectApi
{
  private readonly HttpClient httpClient;

  public ProjectApi(HttpClient httpClient)
  {
    this.httpClient = httpClient;
  }

  public async Task<ObservableCollection<Project>?> GetProjects(string organization) => 
    await httpClient.Get<ObservableCollection<Project>>($"api/{organization}/projects");

  public async Task<Project?> GetProject(string id) =>
    await httpClient.Get<Project>($"api/project/{id}");

  public async Task<Project?> SaveProject(Project project) =>
    await httpClient.Post("api/project", project);

  public async Task<Project?> UpdateProject(Project project) =>
    await httpClient.Put("/api/project", project);

  public async Task DeleteProject(string id) =>
    await httpClient.Delete<string>($"api/project/{id}");
}

(我也用类似的技术简化了组织 API。)

现在我们只需要将 API 注入到我们的 IndexAddProject 页面,我们就可以实际从服务器检索数据了。在 Index 页面(假设我们已经注入了 ProjectApi 页面),将代码部分替换为以下内容。

@code {

  private string? searchString;
  private Func<Project, bool> QuickFilterFunc => row => 
          string.IsNullOrWhiteSpace(searchString) || 
          row.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase) || 
          row.FriendlyName.Contains(searchString, StringComparison.OrdinalIgnoreCase);

  private List<Project>? projects;
  protected override async Task OnInitializedAsync()
  {
    projects = await ProjectApi.GetProjects("goldlight");
  }

}

AddProject 页面(同样,这假设我们已经注入了 ProjectApi 页面)中,更改甚至更简单。将 OnValidSubmit 方法更改为以下内容。

readonly Project project = new() { Organization = "goldlight" };
private async Task OnValidSubmit(EditContext context)
{
  await ProjectApi.SaveProject(project);
  NavigationManager.NavigateTo("/");
}

既然我们在这里,让我们移除 MudButton 中的 Disabled 检查。我已经反复修改了这个屏幕,它只是让我烦恼。最初,我认为这能提供良好的用户体验,但事实上,我必须填写所有字段然后离开它们才能启用按钮,这感觉不对。我能想到的唯一有效的原因是禁用按钮是为了防止用户多次按下按钮,所以让我们通过添加一个 submitted 标志并将 Disabled 绑定到它来修复它。

bool submitted;
private async Task OnValidSubmit(EditContext context)
{
  submitted = true;
  await ProjectApi.SaveProject(project);
  NavigationManager.NavigateTo("/");
}

别忘了更改 MudButton Disabled 属性上的绑定。有了这个更改,一旦用户按下保存项目按钮,按钮就会被禁用,项目就会被保存。当我们导航回主页时,数据会重新加载,因此会显示新添加的数据。

添加请求/响应对

恭喜你坚持到现在。我们现在可以添加将请求和响应保存到特定 API 的功能了。目前,我们将坚持使用一种简单的方法来存储这些配对对象。在后面的文章中,我们将看到如何避免 DynamoDB 表的 400KB 限制。

什么是请求/响应对

我从事服务虚拟化工作这么久,很容易忘记并非每个人都熟悉它们的工作方式。从根本上说,它们所做的是将一个请求与一个响应匹配起来,因此我们有一个“如果我的服务收到对 X 的请求,它将发送响应 Y”的吞吐量过程。我通过使用文件来了解 RR 对,因此这似乎是一个有用的起点。文件的格式通常包含以下信息。

  • HTTP 方法(例如,GETPOSTPUTDELETE
  • 请求消息的标头(可选)
  • 请求消息的正文(可选)

如果需要,我们可以选择增强这些,但这些是基本要素。匹配的响应倾向于包含以下内容。

  • 响应消息的 HTTP 状态码
  • 响应消息的标头(可选)
  • 响应消息的正文(可选)

一个示例 GET 调用可能如下所示

GET /Articles/5367867/Power-Your-NET-REST-API-with-Data-Queries-and-Repo
Host: codeproject.com 

以及相应的响应。

HTTP/1.1 200 OK
Content-Type: application/json

{
  ..... content removed for brevity
}

让我们为我们的请求文件建立一些规则。

  1. 我们的 RRPairs 将仅用于 JSON 实体。
  2. 第一条非空行包含 API 类型和操作地址。可选地,它可能包含传输类型(例如,HTTP/1.1)。
  3. 如果接下来的行不为空,则为标头。
  4. 下一空白行后的内容表示响应的 JSON 条目(如果存在)。

一个示例请求文件可能如下所示

GET /v1/Articles?tags=webapi,asp.net,c%23&minRating=4.5&page=3 HTTP/1.1
Accept: application/json
Authorization: Bearer VSo3gOu-X7nimE0xcav8ftN_Cb0aAOKeNIdR88K ...
Host: testapi.codeproject.com
Pragma: no-cache

相应的响应文件

HTTP/1.1 200 OK
Content-Type: application/json

{
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalPages": 1,
    "totalItems": 2
  },
  "items": [
    {
      "id": "2_781740",
      "title": "Sample Article about source code control systems.",
      "authors": [
        {
          "name": "Bob the Alien",
          "id": 1
        },
        {
          "name": "Martian Manhunter",
          "id": 2
        }
      ],
      "summary": "This is the first sample item.",
      "contentType": "The content type such as 'article', 'forum message', ...",
      "docType": {
        "name": "Article",
        "id": 1
      },
... Content removed for brevity

    }
  ]
}

我们添加的任何项目都将能够托管多个此类条目。我们不详细介绍服务器的 DynamoDB 和 API,以及客户端的 API,我们只看构成客户端 API 的类。请查看服务器源代码以了解那里的情况。

每个请求和响应对连接到一个顶层模型,称为 RequestResponsePair

[DataContract]
public class RequestResponsePair
{
  [DataMember(Name = "name")] public string? Name { get; set; }
  [DataMember(Name = "description")] public string? Description { get; set; }
  [Required, DataMember(Name="request")] public Request Request { get; set; } = new();
  [Required, DataMember(Name="response")] public Response Response { get; set; } = new();
}

除了实际的请求和响应之外,我们还提供了一些空间来允许用户输入 API 的名称和描述。

RequestResponse 类都提供了用于保存摘要信息、标头和任何内容的地方。

[DataContract]
public class Request
{
  [Required, DataMember(Name = "summary")]
  public HttpRequestSummary Summary { get; set; } = new();
  [DataMember(Name = "headers")] 
   public ObservableCollection<HttpHeader> Headers { get; set; } = new();
  [DataMember(Name = "content")] public string? Content { get; set; }
}
[DataContract]
public class Response
{
  [Required, DataMember(Name = "summary")] 
   public HttpResponseSummary Summary { get; set; } = new();
  [DataMember(Name = "headers")]
  public ObservableCollection<HttpHeader> Headers { get; set; } = new();

  [DataMember(Name = "content")]
  public string? Content { get; set; }
}
[DataContract]
public class HttpHeader
{
  [Required, DataMember(Name = "name")] public string? Name { get; set; } = "Unset";
  [Required, DataMember(Name = "value")] public string? Value { get; set; } = "Unset";
}
[DataContract]
public class HttpRequestSummary
{
  [Required, DataMember(Name = "method")]
  public string Method { get; set; } = "ANY";
  [Required, DataMember(Name = "path")] public string? Path { get; set; }
  [DataMember(Name = "protocol")] public string? Protocol { get; set; }
}
[DataContract]
public class HttpResponseSummary
{
  [DataMember(Name="protocol"), Required] public string? Protocol { get; set; }
  [DataMember(Name = "status"), Required] public int? Status { get; set; }
}

要支持请求/响应对所做的最后一个更改是在 Project 类中添加一个适当的列表。

[DataMember(Name="requestResponses")] 
 public List<RequestResponsePair>? RequestResponses { get; set; }

在我们继续之前,我想向您展示我们应用程序的目标。

Application runninng, complete with projects that have been added by the request response pairs route.

我们在这里看到的页面是项目概览页面的扩展版本。一旦我们能够添加请求/响应对,我们将将其显示在项目下方,并允许用户执行各种操作,如克隆、复制 URL、编辑和删除条目。

由于此操作的最基本元素是添加对,我们将从这里开始。这段代码位于 pages/RequestResponsePairs 文件夹中。我们不会从页面内的代码开始;相反,我们将先看一下我们将在页面上应用的验证。我们不依赖 MudBlazor 使用模型注释进行默认验证,而是使用更复杂的验证。我们这样做是为了演示我们可以以其他方式验证数据;在这种情况下,使用一个名为FluentValidation的外部库。

FluentValidation 以创建一个继承自通用 AbstractValidator 类的类开始。该类使用泛型类型创建必须遵循有效 RequestResponsePair 对象的几个规则,包括 NameDescription 属性的最小长度,以及摘要项(包括 ProtocolStatusMethodPath 属性)的非空值。我们还将使用 HeaderValidator 类来验证 RequestResponse 标头。

为了方便起见,我们将添加一个名为 ValidateValue 的属性,它使用指定的属性来验证模型,从而创建一个基于该模型的验证上下文。然后它验证上下文并返回错误消息集合(如果验证失败)。

public class RequestResponsePairValidator : AbstractValidator<RequestResponsePair>
{
  public RequestResponsePairValidator()
  {
    RuleFor(rr => rr.Name).NotEmpty()
      .WithMessage("The name needs a minimum of 10 characters.")
      .MinimumLength(10)
      .WithMessage("The name needs a minimum of 10 characters.");
    RuleFor(rr => rr.Description).NotEmpty()
      .WithMessage("The description needs a minimum of 10 characters.")
      .MinimumLength(10)
      .WithMessage("The description needs a minimum of 10 characters.");
    RuleFor(rr => rr.Response.Summary.Status).NotEmpty();
    RuleFor(rr => rr.Response.Summary.Protocol).NotEmpty();
    RuleFor(rr => rr.Request.Summary.Method).NotEmpty();
    RuleFor(rr => rr.Request.Summary.Path).NotEmpty();
    RuleForEach(rr => rr.Response.Headers).SetValidator(new HeaderValidator());
    RuleForEach(rr => rr.Request.Headers).SetValidator(new HeaderValidator());
  }

  public Func<object, string, Task<IEnumerable<string>>> ValidateValue => 
    async (model, propertyName) =>
  {
    ValidationResult? result =
      await ValidateAsync(ValidationContext<RequestResponsePair>
        .CreateWithOptions((RequestResponsePair)model,
        context => context.IncludeProperties(propertyName)));
    return result.IsValid ? Array.Empty<string>() 
      : result.Errors.Select(e => e.ErrorMessage);
  };
}

我们可以看到规则可以为单个属性链接在一起;如果我们需要,我们可以有不同的验证消息,但我认为最好立即告诉人们他们需要做什么来满足规则。

我们规则集所缺少的只是确保标头不包含空的键或值。RuleForEach 调用将规则应用于每个标头实例。

public class HeaderValidator : AbstractValidator<HttpHeader>
{
  public HeaderValidator()
  {
    RuleFor(hdr => hdr.Name).NotEmpty();
    RuleFor(hdr => hdr.Value).NotEmpty();
  }
}

回到页面本身,我们面临一个问题。我们如何知道我们要将对添加到哪个项目?由于项目是一个复杂的对象,它不是我们可以通过 NavigationManager 轻松传输的东西,所以我们需要考虑一种方法来将项目获取到页面中。我们可以将项目标识符传递给页面,并使用它通过我们的项目 API 读取项目信息,或者我们可以将项目信息作为状态注入到页面中。而不是在每次请求时重新加载项目,我们将采取一种捷径,创建一个我们将注入到页面导航时的项目属性服务。此页面还将捕获活动的 RequestResponsePair,这意味着我们可以使用相同的属性机制来管理请求的添加和编辑。

public class ProjectProps
{
  public Project? SelectedProject { get; private set; }
  public RequestResponsePair? SelectedRequestResponse { get; private set; }

  public void Set(Project project, RequestResponsePair? requestResponsePair)
  {
    SelectedProject = project;
    SelectedRequestResponse = requestResponsePair;
  }

  public void Clear()
  {
    SelectedProject = null;
    SelectedRequestResponse = null;
  }
}

注意ProjectProps 类在 IoC 中注册为作用域实例。

razor 页面的标记非常长,所以让我们从查看其背后的代码开始。当我们看到标记时,知道代码的作用将使标记更容易理解。我们将从声明开始。

[Parameter]
[SupplyParameterFromQuery]
public bool Edit { get; set; }

private RequestResponsePair requestResponsePair = new();
private bool requestHeaderExpanded = true;
private bool responseHeaderExpanded = true;
private bool submitted;
private MudForm form = null!;
private readonly RequestResponsePairValidator validator = new();

private readonly string[] httpMethods =
{
  "POST",
  "GET",
  "PUT",
  "PATCH",
  "DELETE",
  "OPTIONS",
  "ANY"
};

Edit 属性是一个查询字符串参数,传递给页面。因此,如果路径是 /rrpair/upload?Edit=false,这将告诉我们我们将添加一个新条目。

requestResponsePair 字段是 RequestResponsePair 模型的新实例。这将由 blazor 表单绑定。表单进一步绑定到属性,即 requestHeaderExpandedresponseHeaderExpandedvalidator 字段。在标记中,我们有两个可折叠部分,一个用于请求标头,另一个用于响应标头。这些绑定到扩展字段,允许用户独立于另一个来折叠相关的标头部分。validator 字段绑定到表单的验证功能,这是我们通过 form 字段进行交互的内容。

当我们向后端提交数据时,我们希望阻止用户能够再次发送数据。submitted 字段在提交按钮标记中绑定,以禁用按钮单击。

此时的最后一块拼图是添加一个服务虚拟化将响应的方法数组。httpMethods 字符串数组在标记中迭代,以创建用户的选择。

有了这些声明,我们就可以处理此屏幕背后的功能了。首先要考虑的领域是我们希望在屏幕初始化时做什么。这主要分为三件事。

  1. 如果我们直接来到此页面而没有先选择项目,我们必须导航回主页。如果我们没有要添加的项目,我们不应该尝试添加对。
  2. 如果没有请求响应,则向所选项目添加一个新列表。
  3. 如果我们正在添加一个新对,我们将 requestResponsePair 添加到我们的数组中。如果不是,我们将 requestResponsePair 变量更新为 ProjectProps 实例中选定的对。
protected override void OnInitialized()
{
  if (ProjectProps.SelectedProject is null)
  {
    NavigationManager.NavigateTo("/");
  }
  ProjectProps.SelectedProject!.RequestResponses ??= new List<RequestResponsePair>();
  if (!Edit)
  {
    ProjectProps.SelectedProject.RequestResponses.Add(requestResponsePair);
  }
  else
  {
    requestResponsePair = ProjectProps.SelectedRequestResponse!;
  }
}

向我们的请求或响应添加和删除标头通过两个简单的方法完成。

private void DeleteHeader(ObservableCollection<HttpHeader> headers, HttpHeader header)
{
  headers.Remove(header);
}

private void AddHeader(ObservableCollection<HttpHeader> headers)
{
  headers.Add(new HttpHeader());
}

保存数据足够简单。将 submitted 标志设置为 true 以防止用户重新提交相同的数据;只有在通过 form.Validate 进行验证后,我们发现表单无效时,我们才会重置它。如果验证通过,我们将调用 UpdateProject 方法将数据 Put 回服务器。在后端更新成功完成后,我们清除 ProjectProps 以确保我们不会意外地允许在不遵循导航过程的情况下重新进入编辑记录。我们使用 NavigationManager 确保在完成后导航回主页。

private async Task OnSubmitAsync()
{
  submitted = true;
  await form.Validate();
  if (form.IsValid)
  {
    await ProjectApi.UpdateProject(ProjectProps.SelectedProject!);
    ProjectProps.Clear();
    NavigationManager.NavigateTo("/");
  }
  submitted = false;
}

最后,我们有两个方法,它们看起来几乎相同。它们负责用上传请求的结果更新请求和响应对象。

private async Task OnResponseUploadedAsync(Response response)
{
  requestResponsePair.Response = response;
  StateHasChanged();
  await form.Validate();
}

private async Task OnRequestUploadedAsync(Request request)
{
  requestResponsePair.Request = request;
  StateHasChanged();
  await form.Validate();
}

执行上传的能力由一个单独的组件管理,这些方法是从文件上传触发的事件处理程序。此组件提供了触发文件上传的能力,以及将上传的文件翻译成请求或响应所需的编排,具体取决于文件类型。

上传组件的参数是通用的,因此它们能够处理请求和响应上传。有趣的属性是 EventCallback 属性。这些属性会获取任何文件处理的输出,并将它们返回给托管页面。

public enum UploadType
{
  Request,
  Response
}

[Parameter] public UploadType Upload { get; set; }
[Parameter] public string FileTypes { get; set; } = null!;
[Parameter] public string ButtonText { get; set; } = "Upload Request";
[Parameter] public EventCallback<Request> OnRequestUploaded { get; set; }
[Parameter] public EventCallback<Response> OnResponseUploaded { get; set; }

private readonly RequestParser requestParser = new();
private readonly ResponseParser responseParser = new();

此组件中的实际功能很简单。当用户选择上传文件时,UploadFileAsync 方法选择一个单独的解析器来解析请求或响应。一旦文件被解析,就会调用相应的事件回调。

private async Task UploadFileAsync(IBrowserFile file)
{
  switch (Upload)
  {
    case UploadType.Request:
      var request = requestParser.Parse(await ReadFileAsync(file));
      await OnRequestUploaded.InvokeAsync(request);
      break;
    case UploadType.Response:
      var response = responseParser.Parse(await ReadFileAsync(file));
      await OnResponseUploaded.InvokeAsync(response);
      break;
    default:
      throw new ArgumentOutOfRangeException();
  }
}

private static async Task<string> ReadFileAsync(IBrowserFile file)
{
  await using Stream stream = file.OpenReadStream();
  using StreamReader reader = new StreamReader(stream);
  string fileContents = await reader.ReadToEndAsync();
  return fileContents;
}

此组件的标记非常简单,并使用 MudBlazor 的文件上传组件来处理上传相应的文件。

<MudCard Class="d-flex align-center justify-center mud-width-full" Elevation="0">
  <MudFileUpload T="IBrowserFile" FilesChanged="UploadFileAsync" Accept="@FileTypes">
    <ButtonTemplate>
      <MudButton HtmlTag="label"
                 Variant="Variant.Filled"
                 Color="Color.Primary"
                 StartIcon="@Icons.Material.Filled.CloudUpload"
                 for="@context">
        @ButtonText
      </MudButton>
    </ButtonTemplate>
  </MudFileUpload>
</MudCard>

组件的实际核心在于调用解析器类来管理输入文件的解析。RequestParserResponseParser 都有类似的实现,所以我们将看一下 RequestParser,并让您自己查看响应解析器的区别。

主解析器类继承自一个抽象的通用基类。该类接受两个类型参数,T 是我们希望从 Parse 调用返回的类型,TK 用于处理解析特定行。在查看 LineContent 实现之前,我们将逐步介绍 Parse 方法。

方法的第一部分将输入文件拆分为新行,以便我们可以单独解析每一行。我们将循环遍历这些行,并根据某些条件,填充返回类型的不同部分。

循环的第一部分获取数组中的当前行,并更新数组以删除第一行。这样做的原因是,我们将始终从查看数组中的当前行开始;这可能不是我们能创建的最具性能的循环,但一开始它对我们来说足够了。

我们检查摘要是否缺失。如果没有任何摘要,我们就会添加它并继续循环,这样我们就可以跳到下一行。我们这样做是因为我们知道我们的摘要信息永远只有一行。SummaryIsMissingFillSummary 方法的实际实现由 LineContent 实现处理,因此我们很快就会回到那里。

接下来我们要检查的是我们是否已经解析了标头。如果还没有,我们将检查当前行是否为空。如果为空,则标头部分已完成,我们可以继续解析实际的请求/响应内容。如果行中有内容,我们将让 LineContent 代码将标头添加到返回的实例中。

完成标头后,我们将查找第一个非空行。一旦找到,我们将设置返回实例的内容部分。while 循环可能看起来做了很多事情,但实际上,它只是处理文件中的三个离散部分。这是一个非常简单的实现,目前对我们来说已经足够了。

public abstract class RequestResponseParser<T, TK> where T : class, new()
  where TK : LineContent<T>, new()
{
  public T Parse(string file)
  {
    T response = new();
    string[] lines = file.Split(Environment.NewLine);
    TK lineContent = new();
    while (lines.Length > 0)
    {
      string line = lines[0];
      lines = lines.Skip(1).ToArray();
      if (lineContent.SummaryIsMissing(response, line))
      {
        lineContent.FillSummary(response, line);
        continue;
      }
      if (!lineContent.HeaderParseCompleted)
      {
        lineContent.AddHeader(response, headerParser, line);
        continue;
      }

      if (string.IsNullOrWhiteSpace(line))
      {
        continue;
      }
      lineContent.SetContent(response, line + string.Concat(lines));
      break;
    }
    return response;
  }
}

在描述解析器时,我们一直在谈论 LineContent,但它包含什么?这是另一个 abstract 基类,派生实现是我们作为 TK 参数传递给解析器的。基类如下所示

public abstract class LineContent<T>
{
  public abstract bool SummaryIsMissing(T content, string line);

  public void FillSummary(T content, string line)
  {
    Parse(content, line);
  }

  public bool HeaderParseCompleted;

  public void AddHeader(T content, string line)
  {
    HeaderParseCompleted = string.IsNullOrWhiteSpace(line);
    if (HeaderParseCompleted)
    {
      return;
    }

    AddHeader(content, ParseHeader(line));
  }

  public abstract void SetContent(T content, string lines);
  protected abstract void AddHeader(T content, HttpHeader headerLine);

  protected abstract void Parse(T content, string requestLine);

  private HttpHeader ParseHeader(string requestLine)
  {
    HttpHeader header = new();
    var match = Regex.Match(requestLine.Trim(), @"(?<name>[\w\-]+):\s+(?<value>.*)");
    {
      header.Name = match.Groups["name"].Value;
      header.Value = match.Groups["value"].Value;
    }
    return header;
  }
}

为了填充标头,我们使用正则表达式来检索标头名称和值。

那么,我们的请求 LineContent 实现是什么样的?我们可以看到,我们为它编写的代码非常简单。让我们从 SummaryIsMissing 方法开始。当我们发现 Path 未设置且当前行不为空时,我们就确定我们没有指定摘要。这使我们能够处理用户在请求或响应文件顶部添加空行的情况,这意味着我们只会在遇到第一个非空行时尝试添加摘要。

Parse 方法看起来比实际的复杂得多。它看起来很吓人,因为我们使用正则表达式来分解摘要,使用我们已经建立的规则,即 HTTP 方法构成行的第一部分,后面是路径,可选地在末尾是协议。一旦我们克服了对正则表达式的恐惧,解析器就相当直接了。

public class RequestLineContent : LineContent<Request>
{
  public override bool SummaryIsMissing(Request request, string line) =>
    string.IsNullOrWhiteSpace(request.Summary.Path) && !string.IsNullOrWhiteSpace(line);

  public override void SetContent(Request request, string lines) => 
                                  request.Content = lines;

  protected override void AddHeader(Request request, HttpHeader headerLine)
  {
    request.Headers.Add(headerLine);
  }

  protected override void Parse(Request content, string requestLine)
  {
    HttpRequestSummary requestSummary = new();
    Match match = Regex.Match(requestLine.Trim(),
      @"(?<method>[\w]+)\s+(?<path>[\w\/%$-_.+!*'(),]+)
        (\s+)?(?<protocol>HTTP/\d+\.\d+)?");
    if (match.Success)
    {
      requestSummary.Method = match.Groups["method"].Value;
      requestSummary.Path = match.Groups["path"].Value;
      if (!requestSummary.Path.StartsWith("/"))
      {
        requestSummary.Path = "/" + requestSummary.Path;
      }

      requestSummary.Protocol = match.Groups["protocol"].Value;
    }

    content.Summary = requestSummary;
  }
}

有了所有这些部分,我们现在就可以创建一个继承自 RequestResponseParser 基类的类。这会将相关的返回类型和行内容能力嵌入到位,我们就可以读取我们一个请求/响应文件的内容了。

public class RequestParser : RequestResponseParser<Request, RequestLineContent>
{
}

响应解析器遵循类似的方法,所以我将把实际实现代码留给您在相关项目中查看。

此时,我们已经有了文件上传组件和文件解析功能。我们可以继续回到我们的对上传页面。我们要添加到实现的最后一部分是实际的标记部分。虽然它看起来很复杂,但当我们意识到几件事时,标记就很简单了。

  1. 我们将实现一个表单。为了连接到我们创建的 fluent validation,我们将 @ref 设置为 form,这样我们就可以检查表单 IsValid,并将 Validation 设置为使用我们已经创建的 ValidateValue 函数。我们将使用的 Model 是上面定义的 requestResponsePair 属性。
  2. 我们将使用网格布局,使用 MudGrid 来控制我们如何显示项目。这意味着名称和描述将被添加为全宽 MudItem 到我们的包含 MudGrid 中。由于网格可以接受 12 列,将大小设置为 12(使用 xs)意味着我们是全宽。
  3. 请求将添加到网格的左侧,响应将添加到右侧。同样,我们使用 MudItem 来管理布局,这次将大小设置为 6。
  4. 如果我们添加新条目,我们将使用 UploadRequestResponse 组件。如果我们在编辑条目,上传按钮将被隐藏。这被包装在 DataContainer 中,以使我们的代码更干净。
  5. 请求中的 MudSelect 条目迭代我们之前定义的 httpMethods 数组,并绑定我们请求中选择的 Method。默认情况下,我们有一个 ANY 条目作为我们的方法,所以表单上总会有一个条目。
  6. 如果一个字段需要验证,标记将包含一个 For= 部分,它说明需要验证什么。如果字段是 string,我们使用 MudTextField;如果它是数字,我们使用 MudNumericField
<MudForm @ref="form" Model="requestResponsePair" 
 Validation="@(validator.ValidateValue)" ValidationDelay="0">
  <MudGrid>
    <MudItem xs="12">
      <MudTextField Label="Name" Immediate @bind-Value="requestResponsePair.Name" 
       For="@(() => requestResponsePair.Name)"/>
      <MudTextField Label="Description" Immediate Lines="3" 
       @bind-Value="requestResponsePair.Description" 
       For="@(() => requestResponsePair.Description)"/>
    </MudItem>
    <MudItem xs="6">
      <DataContainer HasData="!Edit">
        <DataTemplate>
          <UploadRequestResponse FileTypes=".request" 
           OnRequestUploaded="OnRequestUploadedAsync" 
           Upload="UploadRequestResponse.UploadType.Request"/>
        </DataTemplate>
      </DataContainer>
      <MudSelect Dense Immediate 
       @bind-Value="requestResponsePair.Request.Summary.Method" 
       For="() => requestResponsePair.Request.Summary.Method">
        @foreach (string method in httpMethods)
        {
          <MudSelectItem T="string" Value="@method">@method</MudSelectItem>
        }
      </MudSelect>
      <MudTextField Label="Protocol" 
       @bind-Value="requestResponsePair.Request.Summary.Protocol" 
       For="@(() => requestResponsePair.Request.Summary.Protocol)"/>
      <MudTextField Label="Path" 
       @bind-Value="requestResponsePair.Request.Summary.Path" 
       For="@(() => requestResponsePair.Request.Summary.Path)"/>
      <MudExpansionPanels Dense Class="mt-0" Elevation="0">
        <MudExpansionPanel Dense Class="mt-0 pl=0" 
         Text="Headers" @bind-IsExpanded="requestHeaderExpanded">
          <MudButton OnClick="@(() => AddHeader(requestResponsePair.Request.Headers))" 
           Variant="Variant.Filled" Color="Color.Success">Add Header</MudButton>
          <MudDataGrid Dense EditMode="DataGridEditMode.Cell" ReadOnly="false" 
           T="HttpHeader" Items="requestResponsePair.Request.Headers" 
           Style="align-items: center;">
            <Columns>
              <PropertyColumn Property="hdr => hdr.Name"/>
              <PropertyColumn Property="hdr => hdr.Value"/>
              <TemplateColumn CellClass="d-flex justify-end">
                <EditTemplate>
                  <MudTooltip Text="Delete this header">
                    <MudIconButton Icon="@Icons.Material.Filled.Delete" 
                     OnClick="@(() => DeleteHeader(requestResponsePair.Request.Headers, 
                     context.Item))"/>
                  </MudTooltip>
                </EditTemplate>
              </TemplateColumn>
            </Columns>
          </MudDataGrid>
        </MudExpansionPanel>
      </MudExpansionPanels>
      <MudTextField Label="Content" 
       Immediate @bind-Value="requestResponsePair.Request.Content" 
       For="@(() => requestResponsePair.Request.Content)" Lines="20"/>
    </MudItem>
    <MudItem xs="6">
      <DataContainer HasData="!Edit">
        <DataTemplate>
          <UploadRequestResponse FileTypes=".response" 
           ButtonText="Upload Response" OnResponseUploaded="OnResponseUploadedAsync" 
           Upload="UploadRequestResponse.UploadType.Response"/>
        </DataTemplate>
      </DataContainer>
      <MudTextField Label="Protocol" 
       Immediate @bind-Value="requestResponsePair.Response.Summary.Protocol" 
       For="@(() => requestResponsePair.Response.Summary.Protocol)"/>
      <MudNumericField Label="Status" 
       Immediate @bind-Value="requestResponsePair.Response.Summary.Status" 
       For="@(() => requestResponsePair.Response.Summary.Status)"/>
      <MudExpansionPanels Dense Class="mt-0" Elevation="0">
        <MudExpansionPanel Dense Class="mt-0 pl=0" DisableGutters Text="Headers" 
         @bind-IsExpanded="responseHeaderExpanded">
          <MudButton OnClick="@(() => AddHeader(requestResponsePair.Response.Headers))" 
           Variant="Variant.Filled" Color="Color.Success">Add Header</MudButton>
          <MudDataGrid Dense EditMode="DataGridEditMode.Cell" ReadOnly="false" 
           T="HttpHeader" Items="requestResponsePair.Response.Headers" 
           Style="align-items: center;">
            <Columns>
              <PropertyColumn Property="hdr => hdr.Name"/>
              <PropertyColumn Property="hdr => hdr.Value"/>
              <TemplateColumn CellClass="d-flex justify-end">
                <EditTemplate>
                  <MudTooltip Text="Delete this header">
                    <MudIconButton Icon="@Icons.Material.Filled.Delete" 
                     OnClick="@(() => DeleteHeader(requestResponsePair.Response.Headers, 
                     context.Item))"/>
                  </MudTooltip>
                </EditTemplate>
              </TemplateColumn>
            </Columns>
          </MudDataGrid>
        </MudExpansionPanel>
      </MudExpansionPanels>
      <MudTextField Label="Content" 
       Immediate @bind-Value="requestResponsePair.Response.Content" 
       For="@(() => requestResponsePair.Response.Content)" Lines="20"/>
    </MudItem>
  </MudGrid>
  <MudButton Disabled="submitted" OnClick="async () => await OnSubmitAsync()" 
   StartIcon="@Icons.Material.Filled.Save" Variant="Variant.Filled" 
   Color="Color.Primary" Class="mt-2">Save</MudButton>
</MudForm>

此时,我们已经准备好从客户端添加 RR 对了。在服务器端,这只是复制我们的 API 数据模型,然后将它们映射到适当的 DynamoDB 模型。我们还需要添加缺失的 delete 和 put 处理程序,这样我们就可以使用了。这就是 DynamoDB 的真正发挥作用的地方;我们正在构建一个复杂的对象,因此我们可以将其复杂性存储在项目内的子对象中。我们选择将请求响应对存储在我们之前创建的 Details 类的子对象中。同样,我将把这部分留给您,请查看服务器实现中的代码以了解我们添加了什么。(在撰写本文的过程中,我一直在重构代码,因此 ToTable 实现已移至服务器实现中的扩展方法。)

最后,我们需要显示这些对的代码,以及维护它们的操作。这部分会很长,所以如果你想“就地”阅读代码,请查看 pages/projects/Index.razor

我们已经在该页面上看到了一些功能,所以让我们开始扩展我们需要的字段。

private string baseUrl = null!;
private string? searchString;
private Func<Project, bool> QuickFilterFunc => row => 
  string.IsNullOrWhiteSpace(searchString) || 
  row.Name!.Contains(searchString, StringComparison.OrdinalIgnoreCase) || 
  row.FriendlyName!.Contains(searchString, StringComparison.OrdinalIgnoreCase);
private ObservableCollection<Project>? projects;
private bool submitting;
private string infoMessage = "";
static Color GetColor(string methodName) => 
    methodName switch
    {
      "GET" => Color.Success,
      "POST" => Color.Primary,
      "PUT" => Color.Info,
      "PATCH" => Color.Dark,
      "DELETE" => Color.Error,
      "OPTIONS" => Color.Warning,
      _ => Color.Dark
    };

我们使用 baseUrl 方法在屏幕上显示有关端点的信息,以及当用户单击显示中的 Copy URL 图标时。searchStringQuickFilterFunc 参数自我们在文章前面首次遇到它们以来没有改变。

我们将 projects 创建为 ObservableCollection,因为 datagrid 响应 INotifyCollectionChanged 事件。这允许我们“就地”删除项目。submitting 字段具有与对屏幕类似的功能;防止用户将相同的操作再次发送到服务器。

当用户单击 Copy URL 图标时,infoMessage 会填充有关复制内容的信息。这将在屏幕顶部显示为警报。

最后,我们希望显示每个 HTTP 方法,并使用其自己的颜色。GetColor 方法根据方法返回适当的颜色。

现在我们来看看最有趣的代码部分。OnInitializedAsync 方法从 appsettings.json 文件中读取 baseUrl(该文件位于 wwwroot 中 - 如果我们不小心,很容易被放在哪里)。我们在这里清除 ProjectProps,以便下次刷新此页面时,我们确保以干净的项目属性开始。

protected override async Task OnInitializedAsync()
{
  baseUrl = Configuration.GetValue<string>("Server:BaseAddress")!;
  if (!baseUrl.EndsWith("/"))
  {
    baseUrl += "/";
  }
  ProjectProps.Clear();
  projects = await ProjectApi.GetProjects("goldlight");
}

根据我们是添加还是编辑请求/响应对,我们将使用 NavigateToUpload 方法。此方法将 ProjectProps 设置为具有适当的 RequestResponsePair 对象(添加时为 null,或从标记中选择的对象)。

private void NavigateToUpload(Project project, bool edit, 
                              RequestResponsePair? requestResponsePair = null)
{
  ProjectProps.Set(project, requestResponsePair);
  NavigationManager.NavigateTo($"/rrpair/upload?Edit={edit}");
}

当我们想将 URL 复制到剪贴板时,我们的标记将调用以下方法。Clipboard 服务是我们添加到项目中的,因为 Blazor 目前不提供此标准功能。

private async Task CopyUrlToClipboard(Project project, RequestResponsePair rrpair)
{
  string endpoint = $"{project.ServiceBaseUrl(baseUrl)}{rrpair.UrlFriendlyPath()}";
  await Clipboard.CopyToClipboard(endpoint);
  infoMessage = $"{endpoint} copied to clipboard";
}

Clipboard 服务像这样利用 JavaScript 运行时。

public class Clipboard
{
  private readonly IJSRuntime jsInterop;

  public Clipboard(IJSRuntime jsInterop)
  {
    this.jsInterop = jsInterop;
  }

  public async Task CopyToClipboard(string text)
  {
    await jsInterop.InvokeVoidAsync("navigator.clipboard.writeText", text);
  }
}

Clone 方法利用一个扩展方法,该方法获取原始实例,并返回一个新实例,其名称开头添加了 [CLONE]。这由我们的 API 保存,然后我们导航以编辑克隆的对象。

private async Task Clone(Project project, RequestResponsePair rrpair)
{
  submitting = true;
  RequestResponsePair rrpairClone = rrpair.Clone();
  project.RequestResponses!.Add(rrpairClone);
  Project? updatedProject = await ProjectApi.UpdateProject(project);
  project.Version = updatedProject!.Version;
  NavigateToUpload(project, true, rrpairClone);
}

Clone 扩展方法。

public static RequestResponsePair Clone(this RequestResponsePair pair)
{
  ObservableCollection<HttpHeader> requestHeaders = new();
  foreach (HttpHeader hdr in pair.Request.Headers)
  {
    HttpHeader header = new() { Name = hdr.Name, Value = hdr.Value };
    requestHeaders.Add(header);
  }

  ObservableCollection<HttpHeader> responseHeaders = new();
  foreach (HttpHeader hdr in pair.Response.Headers)
  {
    HttpHeader header = new() { Name = hdr.Name, Value = hdr.Value };
    responseHeaders.Add(header);
  }

  return new RequestResponsePair
  {
    Name = $"[CLONE] {pair.Name}",
    Description = pair.Description,
    Request = new Request
    {
      Content = pair.Request.Content,
      Headers = requestHeaders,
      Summary = new HttpRequestSummary
      {
        Method = pair.Request.Summary.Method,
        Path = pair.Request.Summary.Path,
        Protocol = pair.Request.Summary.Protocol
      }
    },
    Response = new Response()
    {
      Content = pair.Response.Content,
      Headers = responseHeaders,
      Summary = new HttpResponseSummary
      {
        Status = pair.Response.Summary.Status,
        Protocol = pair.Response.Summary.Protocol
      }
    }
  };
}

最后,我们来处理 delete 方法。这些方法用于删除项目及其所有关联的对,或仅删除单个对。

private async Task Delete(Project project)
{
  submitting = true;
  bool? result = await DialogService.ShowMessageBox("Warning",
    "Deleting a project cannot be undone. Any endpoints will no longer be available.", 
     yesText: "Delete", cancelText: "Cancel");
  if (result.HasValue && result.Value)
  {
    projects!.Remove(project);
    await ProjectApi.DeleteProject(project.Id.ToString());
  }
  submitting = false;
}

private async Task Delete(Project project, RequestResponsePair rrpair)
{
  submitting = true;
  bool? result = await DialogService.ShowMessageBox("Warning",
    "Deleting cannot be undone. The endpoint will no longer be available", 
     yesText: "Delete", cancelText: "Cancel");
  if (result.HasValue && result.Value)
  {
    project.RequestResponses!.Remove(rrpair);
    var updatedProject = await ProjectApi.UpdateProject(project);
    project.Version = updatedProject!.Version;
  }
  submitting = false;
}

剩下要添加的是我们使用的标记。由于我们添加了创建警报的功能,我们将把警报作为标记的第一部分。只要 infoMessage 中有内容,我们就会显示警报。当用户单击关闭按钮时,通过将 string 变为空来关闭警报。

<DataContainer HasData="!string.IsNullOrWhiteSpace(infoMessage)">
  <DataTemplate>
    <MudAlert Severity="Severity.Success" 
     ContentAlignment="HorizontalAlignment.Center" Variant="Variant.Filled" 
     ShowCloseIcon="true" CloseIconClicked="()=>infoMessage = string.Empty">
     @infoMessage</MudAlert>
  </DataTemplate>
</DataContainer>

与其显示完整的 DataContainer 实现,不如集中讨论对 MudDataGrid 的更改。此组件现在功能更全面,值得仔细研究。最初的 ToolBarContent 应该已经很熟悉了,但我们已经为我们的 Columns 添加了一些额外功能。我们希望每一行项目都添加一种层次结构,项目显示在一行上,然后对作为子内容显示在下面。为此,我们添加了一个 HierarchyColumn,其类型设置为 Project。子内容添加在 ChildRowContent 部分内。

在我们查看子行之前,我们想添加两个不绑定到特定值的列。这些将作为 TemplateColumn 条目添加,第一个条目显示客户端为该特定项目需要调用的 baseUrl。第二个模板列使我们能够调用 Delete 方法来删除项目和关联的对。

查看我们的 ChildRowContent,我们将遍历 Project 中的 RequestResponses。对于每个条目,我们将添加一个 MudCard。我必须承认,我喜欢这个功能,因为我们在卡片标题中添加了两个头像。第一个头像显示请求 Method,第二个显示响应 Status。这种简单的方法能够非常直观地表示重要信息。

我们卡片的内容是 NameDescription 条目。这些出现在卡片标题下方,并横跨卡片的全部宽度。

最后,我们在卡片中添加了一些操作,它们出现在卡片标题的右侧。这些操作调用我们之前创建的方法,从左到右分别是:将 URL 复制到剪贴板、克隆对条目、删除对条目以及编辑对条目。通过在标记中最小的努力,我们添加了大量信息和操作。

我最初的想法是将操作添加为卡片的菜单,但目前,我选择将其添加为水平操作集。如果我们开始添加更多操作,我可能会将其重新排列回菜单,但目前不需要。

<MudDataGrid T="Project" Items="projects" Hover="true" 
 QuickFilter="QuickFilterFunc" Style="align-items: center;">
  <ToolBarContent>
    <MudText Typo="Typo.h6">Projects</MudText>
    <MudSpacer/>
    <MudTextField @bind-Value="searchString" Placeholder="Search" 
     Adornment="Adornment.Start" Immediate="true"
     AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"/>
  </ToolBarContent>
  <Columns>
    <HierarchyColumn T="Project"/>
    <PropertyColumn Property="x => x.Name" Title="Name"/>
    <PropertyColumn Property="x => x.FriendlyName" Title="Friendly name"/>
    <TemplateColumn Title="Base URL">
      <CellTemplate>
        <MudText>@context.Item.ServiceBaseUrl(baseUrl)</MudText>
      </CellTemplate>
    </TemplateColumn>
    <TemplateColumn CellClass="d-flex justify-end">
      <CellTemplate>
        <MudTooltip Text="Delete this project">
          <MudIconButton Disabled="submitting" Icon="@Icons.Material.Filled.Delete" 
           Color="Color.Default" OnClick="@(() => Delete(context.Item))"/>
        </MudTooltip>
      </CellTemplate>
    </TemplateColumn>
  </Columns>
  <ChildRowContent>
    <DataContainer HasData="context.Item.RequestResponses is not null && 
                            context.Item.RequestResponses.Any()">
      <DataTemplate>
        @foreach (RequestResponsePair rrpair in context.Item.RequestResponses!)
        {
          <MudCard Class="px-2 py-2 pl-2 pr-2">
            <MudCardHeader>
              <CardHeaderAvatar>
                <MudChip Color="@GetColor(rrpair.Request.Summary.Method)">
                                @rrpair.Request.Summary.Method</MudChip>
                <MudChip Color="Color.Primary">
                 @rrpair.Response.Summary.Status.ToString()</MudChip>
              </CardHeaderAvatar>
              <CardHeaderContent>
                <MudText Typo="Typo.subtitle1">@rrpair.Name</MudText>
                <MudText Typo="Typo.subtitle2" Class="ml-2 pr-2">
                 @rrpair.Request.Summary.Path</MudText>
              </CardHeaderContent>
              <CardHeaderActions>
                <MudTooltip Text="Copy the URL to the clipboard.">
                  <MudIconButton Disabled="submitting" 
                   Icon="@Icons.Material.Filled.ContentCopy" Color="Color.Default" 
                   OnClick="@(() => CopyUrlToClipboard(context.Item, rrpair))"/>
                </MudTooltip>
                <MudTooltip Text="Clone this request/response">
                  <MudIconButton Disabled="submitting" 
                   Icon="@Icons.Material.Filled.PlusOne" Color="Color.Default" 
                   OnClick="@(() => Clone(context.Item, rrpair))"/>
                </MudTooltip>
                <MudTooltip Text="Delete this request/response">
                  <MudIconButton Disabled="submitting" 
                   Icon="@Icons.Material.Filled.Delete" Color="Color.Default" 
                   OnClick="@(() => Delete(context.Item, rrpair))"/>
                </MudTooltip>
                <MudTooltip Text="Edit this request/response">
                  <MudIconButton Disabled="submitting" 
                   Icon="@Icons.Material.Filled.Edit" Color="Color.Default" 
                   OnClick="@(() => NavigateToUpload(context.Item, true, rrpair))"/>
                </MudTooltip>
              </CardHeaderActions>
            </MudCardHeader>
            <MudText Typo="Typo.body2">@rrpair.Description</MudText>
          </MudCard>
        }
      </DataTemplate>
    </DataContainer>
    <MudButton Disabled="submitting" 
     OnClick="@(() => NavigateToUpload(context.Item, false))" 
     Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.Add" 
     Color="Color.Primary" Class="mt-2">Add Request/Response</MudButton>
  </ChildRowContent>
</MudDataGrid>

就是这样,我们已经添加了足够的功能来将请求/响应对添加到我们的项目中,以及创建新项目。我们还有很多工作要做,但我们的小 Blazor/AWS 项目已经开始成形。

结论

在本文中,我们创建了一个客户端 Blazor 应用程序,该应用程序与我们在上一篇文章中创建的服务器进行交互。该应用程序允许我们添加 JSON 请求/响应对,这些对描述我们将对特定服务器操作做出的响应。

在下一篇文章中,我们将介绍如何为我们的应用程序添加身份验证和授权,以便只有授权用户才能维护和使用特定项目。我们还将解决第一个主要技术问题,并处理请求/响应的 400KB 大小限制。我们还将对基于客户端请求返回虚拟化数据进行第一次尝试。

历史

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