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

Excelsior!在没有安全网的情况下构建应用程序 - 第 1 部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2021年5月12日

CPOL

28分钟阅读

viewsIcon

8348

该系列文章的第一部分,我们将构建一个应用程序,展示编写整个应用程序的思考过程。

引言

我记得,作为一个年轻的开发者,我曾经非常敬佩那些似乎毫不费力就能坐下来编码的人。系统似乎从他们的指尖流淌而出,制作精良、优雅、精致。我感觉自己正在见证西斯廷教堂里的米开朗基罗,或是莫扎特坐在崭新的五线谱前。当然,随着经验的增长,我现在知道我所看到的只是开发者在做开发者该做的事。有些人做得很好,真正理解了开发的技艺,而另一些人则产出了不那么优雅、不那么精良的代码。多年来,我有幸向一些杰出的开发者学习,但我一次又一次地回到同一个基本问题上,那就是……

如果我能听到其他开发者在编写应用程序时的思考过程,我现在作为开发者会好多少?

在本系列文章中,我将带你了解我在开发应用程序时的思考过程。文章附带的代码将以“原汁原味”的开发方式编写,这样你就可以看到我是如何将一个东西从初始需求阶段一直做到我乐意让其他人使用的状态。这意味着文章将展示我犯的每一个错误以及我在构思想法时采取的捷径。我不会声称自己是一名伟大的开发者,但我有足够的能力和经验,这应该能帮助该领域的新手更早地克服他们的敬畏之心,并获得自信。

场景设定

在本文中,我将着手为 GET API 编写一个最小可行产品 (MVP)。当我们创建 MVP 时,我们提供执行特定功能的最小代码。换句话说,在这个阶段我们将避免添加过多的功能,同时专注于为我们的开发构建坚实的基础。GET API 是一个合适的起点,因为在我看来,它将为我们提供基本功能。作为一名经验丰富的开发者,其中一个陷阱是,你一开始思考就会有解决方案化的危险,所以选择一个单一领域来处理的另一个优点是,它将帮助我专注于系统的一小部分,并研究我将如何仅构建该部分。

代码在哪里?

由于这是一个系列文章,每篇文章都建立在前一篇的基础上,我将项目托管在 https://github.com/ohanlon/goldlight.xlcr。每篇文章都会有其自己的代码版本在一个分支中,所以本文会有 article1 分支,下一篇会有 article2,以此类推。完成版本的代码将在 master 分支中。

开始编码

当我创建这两个项目时,添加了两个文件;一个在我的单元测试项目中,一个在我的类库中。我将从类库中删除 *Class1.cs* 文件,但保留单元测试文件。我使用 TDD 编写代码的方法总是从一个单一的测试开始,没有其他代码。只留下测试文件,我就可以避免任何走捷径的诱惑。

面对一个空白的单元测试,我必须决定要测试什么。我将编写的第一个测试是:当我向一个端点发出 GET 请求时,我能得到一个响应。它不会说明响应是什么样子,只是它不为空。那么,我为什么决定从这里开始呢?在这个阶段,我只决定了要执行一个 GET 请求。考虑到这一点,我潜意识里得出了两个结论。

  1. 我将我的类命名为 GetRequest。当我定义要测试什么时,我决定要测试一个 GET 请求。在编写代码时,良好的命名至关重要,因为它告诉我们代码的用途。这是一个我经常会遇到困难的领域,因为我会尝试许多名称变体,然后才确定最合适的那个。有时,在我编写代码时,我只是简单地将一个类或方法命名为 A,直到我决定要使用的名称。
  2. 在决定要执行此请求的过程中,我使用了“执行”一词。这似乎是一个很好的方法名称,用于发送请求并获取响应,所以就用 Execute 吧。

检查是否返回对象是一个简单的测试,因此在测试文件中默认存在的 Test1 方法中,让我们编写测试。

[Fact]
public void Test1()
{
    GetRequest getRequest = new GetRequest();
    Assert.NotNull(getRequest.Execute("http://www.google.com"));
}

现在,这段代码甚至无法构建,这本身就是一种失败的测试形式。为了解决这个问题,我将做最少的事情来让这段代码构建成功。由于我现在在 Visual Studio 中,我将使用它来帮助我,所以首先要让 Visual Studio 为我创建一个 GetRequest 类。如果我点击 GetRequest 文本,我可以按下 AltEnter 来调出“快速操作”。第一个“快速操作”允许我生成一个类型。我可以选择在自己的文件中创建类,作为嵌套类,或作为同一文件中的类。我将在一个新文件中创建该类,因为下一步将展示 Visual Studio 如何真正帮助我。

快速操作是 Visual Studio 在认为可以帮助您处理代码时提供的功能(也有一个 Ctrl 和 . 操作会触发快速操作)。

所以,我现在已经在单元测试项目中创建了 GetRequest 类。显然,我不想把这个文件留在这里,我希望它被放到我的类库中。我将把 *GetRequest.cs* 文件从单元测试项目拖到类库中,这样我就有了同一文件的两个版本。现在我可以从单元测试项目中删除 *GetRequest.cs* 文件了。如果你在阅读本文的同时进行此练习,你可能不会对 GetRequest 类型再次显示为损坏而感到惊讶。发生这种情况是因为 GetRequest 类是内部的;当它在同一个项目中时这并不重要,但现在我们已经将它移到类库中,这意味着它不再可见。让我们将 GetRequest 类的作用域更改为 public

public class GetRequest
{
}

虽然我把这个类设为 public,但它仍然在错误的命名空间中。现在,它在 goldlight.xlcr.core.tests 命名空间中。正如我之前提到的,我需要修复命名空间,让它们遵循帕斯卡命名约定。我还需要将 GET 请求类从测试命名空间移动到更合适的核心命名空间。我将首先解决帕斯卡大小写问题。

在类库中,我将添加 RootNamespace 元素,所以我的项目文件看起来像这样

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <RootNamespace>Goldlight.Xlcr.Core</RootNamespace>
  </PropertyGroup>

</Project>

在单元测试项目中,我将添加 Goldlight.Xlcr.Core.Tests 作为 RootNamespace

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <RootNamespace>Goldlight.Xlcr.Core.Tests</RootNamespace>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; 
       buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="1.3.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; 
       buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\goldlight.xlcr.core\goldlight.xlcr.core.csproj" />
  </ItemGroup>

</Project>

根命名空间已修复为 Pascal 大小写而不是全小写,我已经准备好解决 GetRequest 类位于测试命名空间中的问题。我可以手动将命名空间更改为正确的命名空间,但这意味我要在单元测试文件中添加一个 using 语句。或者,我可以让 Visual Studio 在这里替我完成繁重的工作,并使用快速操作来重命名 GetRequest 文件中的命名空间。这是我倾向于采取的方法,如果我以后要在解决方案中移动类,这会很方便。修复很简单,在 *GetRequest.cs* 中的命名空间上,我调出快速操作(Alt + Enter),然后选择 将命名空间更改为 Goldlight.Xlcr.Core 选项。此操作的好处是自动在单元测试文件中添加以下行。

using Goldlight.Xlcr.Core;

回到代码,单元测试仍然无法编译,因为 GetRequest 没有 Execute 方法。我将使用另一个快速操作来解决这个问题。我将点击测试中的 Execute 名称,并调出快速操作以生成 GetRequest.Execute 方法。这会在 GetRequest 类中添加以下骨架方法。

public object Execute(string v)
{
    throw new NotImplementedException();
}

我现在可以运行我的单元测试。我知道测试会失败,因为我没有返回任何东西,但重要的是我现在运行测试。

你可能会好奇我为什么这样编写测试。我刚刚花了很多时间讨论一些事情,这意味着编写测试来创建类和方法需要很长时间。我的工作流程理应是在类库中创建类和方法,然后为其编写单元测试。毕竟,除非我遵循这些步骤,否则我的代码甚至无法编译。实际上,这种做法最多只需要我几秒钟。通过使用快速操作,我让 Visual Studio 替我完成了繁重的工作(Rider 也能做到这一点)。当我到达为类添加额外方法的阶段时,这种工作流程会变得更加高效。
回到我写的代码,运行测试后,我现在有一个失败的测试,所以我需要修复它。正如我前面所说,遵循 TDD 意味着我应该用最简单的代码来修复它。好吧,我这里能写的最简单的代码是从我的方法返回某个对象的新实例。我将要做的就是更改一个关键字,这样我将返回它,而不是抛出新的 NotImplementedException

public object Execute(string v)
{
    return new NotImplementedException();
}

我重新运行测试,测试通过。我成功运行了我的第一个通过测试。我使用 TDD 的原因之一是它帮助我更密切地关注每个功能可能出现的问题。我不再只关注积极的情况。相反,TDD 帮助我养成质疑我编写的每个公共方法可能出现问题的思维方式。

在我们进一步探讨之前,我需要考虑我代码中使用的名称是否真正传达了测试的含义,以及方法参数名称是否合适。在这个项目中,我开始使用 dotnet 命令已经创建的 UnitTest1 类。对我来说,这似乎不是一个好的测试类名称,因为它没有告诉我正在测试什么。在我修复名称之前,我刚刚意识到我之前设置 RootNamespace 时忘记纠正单元测试命名空间的名称。我将应用与之前相同的快速操作“重命名命名空间”操作;现在,您应该熟悉我是如何做到的,所以我在这里不再重复。

回到 UnitTest1。它所在的文件名和单元测试类名都是错的。它们需要更有意义,所以我必须问自己,一个包含 GetRequest 类测试的类的良好名称应该是什么。对我来说,这似乎是一个简单的选择,我正在测试 GetRequest,所以测试类将被称为 GetRequestTests(“Tests”部分是因为这里将有多个测试)。

虽然类名为 GetRequestTests,但底层文件仍然名为 *UnitTest1.cs*。如果我点击类名并选择“快速操作”操作,我现在就可以将文件重命名以匹配类名。这是一个我倾向于相当频繁使用的功能,因为当我赋予类名更多上下文时,我经常会对其进行细化。
我需要考虑的最后一件事是,我唯一的测试是在一个名为 Test1 的方法中。同样,当我在测试输出窗口中看到这个测试名称时,它没有提供任何上下文。如果我对这些测试不熟悉,那么 Test1 完全是一个无用的名称。我需要做的是添加一个名称,告诉我测试正在做什么。为此,我倾向于编写遵循以下模式的测试名称:

GivenSomeOperation_WhenSomeConditionsArePresent_ThenThisIsTheResultIExpect

基本上,这个模式被分解为三个部分,我可以很容易地进行视觉解析。下划线分隔了句子的各个部分,这样我就可以形成对正在发生的事情的心理模型。如果我将这种方法应用于我当前的测试,那么我将得到一个类似于这样的名称:

GivenCallToExecuteGetRequest_WhenTheEndpointIsSet_ThenNonNullResponseIsReturned

这样一个名字的妙处在于,它迫使我开始提出关于我正在编写的方法的问题。最明显的问题(至少对我来说是)是,如果我不设置端点,会发生什么?我应该得到什么样的响应?最初,似乎这里有两个选择摆在我面前。我既可以返回一个空响应,因为我在这里没有请求任何东西,也可以验证输入,看看输入是否已设置,如果未设置,则抛出异常。

在我看来,用一个空值来阻止操作发生比在这里返回一个空响应更有意义。我这里的想法是,在这里抛出异常是在告诉开发者,此操作的输入状态无效,因此无法对其进行进一步处理,这在我实际调用此代码时可以在系统其他地方轻易发现。如果我返回一个空响应,我是在假设我只会在输入为空时才得到一个空响应。老实说,在这个阶段,我仍然对 Execute 方法是否应该返回任何东西存有疑问。这是我在不考虑整体设计的情况下贸然开发应用程序时遇到的问题之一。随着开发的进行,我将重新审视是否应该返回响应,或者是否应该将响应作为 GetRequest 内部状态的问题。

上一段让我相信,如果端点为空,我应该抛出异常,但我现在必须决定要抛出哪种异常。选择合适的异常似乎有点像一门艺术,但选择最合适的异常至关重要。我试图防止的是 stringnull 或空白;这对于标准 string 函数来说很容易测试。如果我只考虑 string 可能为 null,那么抛出 ArgumentNullException 将是合适的。我必须考虑的事实是,string 也可能是空的或空白的,所以这不再仅仅是 null。现在,为了方便起见,我可以将所有这三者都视为 null,但这对我来说似乎不对。排除了 ArgumentNullException 后,我现在处于这样的境地:我真正想说的是,参数可能不是 null,但它还有其他问题。我既可以创建自己的 ArgumentNullOrWhitespaceException 类,也可以使用更通用的 ArgumentException 实例。在这个阶段,我很高兴知道问题只是端点有问题,所以我会坚持使用 ArgumentException

考虑到这些信息,我现在知道我的测试需要是什么样的了

[Fact]
public void GivenCallToExecuteGetRequest_WhenNoEndpointIsSet_ThenArgumentExceptionIsThrown()
{
    GetRequest getRequest = new GetRequest();
    Assert.Throws<ArgumentException>(() => getRequest.Execute(""));
}

我现在可以验证 Execute 方法的输入了。现在,我一次只尝试失败一个测试条件,所以我不会将我的 Execute 方法更改为抛出新的 ArgumentException。如果我这样做,我的其他测试将失败,我应该在修复失败时保持其他测试的稳定。这里最简单的修复实际上是在方法中放置 null 或空白修复(同时,我将修复参数名 v 的糟糕选择)。

public object Execute(string endpoint)
{
    if (string.IsNullOrWhiteSpace(endpoint))
    {
        throw new ArgumentException();
    }
    return new NotImplementedException();
}

我正在考虑这是否是我可以在 Execute 方法中唯一可以进行的检查。我是否只关心这个 string 是否为空?我必须考虑 Execute 方法期望接收一个 HTTP 端点,并且我希望它仅限于 HTTP 或 HTTPS,因此端点的开头实际上应该以 HTTP 或 HTTPS 开头。有了这个知识,我可以看到我必须编写单元测试来验证如果端点不是以这些值之一开头,我们将会看到一个错误。这确实需要在多个测试中覆盖(作为提示,我们已经为 HTTPS 端点准备了一个合适的测试)。
回到决策过程,尝试决定如果端点无效,我们需要抛出哪种类型的异常。如果我愿意,我可以创建一个自定义异常,例如 EndpointInvalidException,或者我可以在这里抛出 ArgumentException。一方面,EndpointInvalidException 感觉它可能真的很有用,但另一方面,ArgumentException 旨在表示参数有问题。正是在这一点上,我意识到我实际上一直在看错误的问题,我犯了一个经典的错误,在没有考虑整体设计的情况下就跳入了代码。

全面思考的重要性

我从这里开始我的代码“旅程”,我跳进去选择了一个类并开始编写它。我甚至解释了为什么我认为这是一个合适的起始类,这没关系。我对这些决定很满意;我甚至在脑海中有一个想法,即每个操作可能都会表示为一个 Request 类,并且它们可能会有一些共同点,所以当我们重构代码时,很可能可以建立一个接口或抽象类关系。然而(好吧,我知道你不应该用然而开始一个句子,但在这里适用),事实是,我跳得有点太快了。我有一个 Request 类,它期望一个端点,那么我是否应该把端点放在 Execute 方法中呢?如果我为每个操作重用相同的 Request 实例,那么我确实应该把端点放在 Execute 方法签名中,但如果我为每个实际请求都有一个新的 Request 实例,那么我实际上不需要在 Execute 方法中设置端点。如果我愿意,我可以将端点完全从 Execute 方法中移出。

让我们思考两种方法的含义。

  • 如果我设置一个单一的 Request 实例,那么我可能应该将 Request 类设为单例。如果你不知道什么是单例,它是一种设计模式,我们确保我们只能拥有该类的一个实例。有很多方法可以做到这一点,无论是通过在 IoC(控制反转)容器中将 Request 类注册为单一实例,还是通过在 Request 内部管理实例的创建。如果你想了解更多信息,Jon Skeet 在这里有一篇精彩的文章。
  • 如果我想遵循单一 Request 实例的方法,那么我必须考虑维护我可能需要的任何状态会变得更加困难。当我继续本系列文章时,你会发现这不一定是看起来那样的问题,但目前我确实必须考虑是否要维护状态。
  • 如果我每次调用都使用一个新实例,那么每次实例化 Request 类时都会有轻微的性能损失,因为我期望 .NET 为我跟踪生命周期,并且创建新实例是有成本的。我在这里可以将这排除为我的用例的问题,因为我不关心在这里为我的代码榨取最后一滴性能。换句话说,我不关心过早的性能优化。
  • 使用新实例,我可以通过更简单的方式维护状态。请求会关联一些状态,因此我能做的任何让事情变得更容易的事情显然都会有所帮助。

回到基础,我这里有一个端点。在某些方面,这个端点是 .NET Uri 类的特化。虽然端点可以表示为 string,但它不仅仅是一个简单的 string。一个端点是地址和验证的组合,以证明端点是有效的。在我目前的设计中,我试图在一个旨在做完全不同事情的类中验证端点。这告诉我我违反了单一职责原则,所以是时候通过创建自己的 Endpoint 实现来解决这个问题了(我不会使用标准的 .NET Uri 类,因为我故意将端点限制为 HTTP 或 HTTPS,而 Uri 类比这更开放)。

解决这个问题的第一步是在我的单元测试中创建一个 EndpointTest 类。我知道我有使这篇文章变得非常“现在按此键”的危险,所以请接受我将要编写的第一个测试遵循先前测试类的模式,即我创建测试并使用快速操作生成相关的类和方法。

在我的测试类中设置第一个测试,我得到以下代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace Goldlight.Xlcr.Core.Tests
{
    public class EndpointTests
    {
        [Fact]
        public void GivenInstantiationOfAnEndpoint_WhenTheEndpointIsSet_ThenEndpointIsCreated()
        {
            Endpoint endpoint = new Endpoint("http://www.google.com");
            Assert.Equal(endpoint.Address, "http://www.google.com");
        }
    }
}

当我使用快速操作并执行与我早期测试类似的操作将代码移动到类库中时,我得到了这段代码

using System.Collections.Generic;

namespace Goldlight.Xlcr.Core
{
    public class Endpoint
    {
        private string v;

        public Endpoint(string v)
        {
            this.v = v;
        }

        public IEnumerable<char> Address { get; set; }
    }
}

记住 TDD 让我们先做最快的修复,让代码通过测试,我将把构造函数改为这样:

public Endpoint(string v)
{
    Address = "http://www.google.com";
}

在撰写本文时,我发现为每个方法描述 TDD 方法对于读者来说会过于冗长。我现在将只描述代码和设计决策;单元测试可以在最终解决方案中查看。

回到 Endpoint,我说过我想验证端点以确保它以 HTTP 或 HTTPS 开头。在我为 Execute 方法编写测试时,我将 null/空白视为一个单独的条件。考虑到这一点,端点有效的唯一时间是它以 HTTP 或 HTTPS 开头(或者我们可以推断出开头)。无论是 null、空白还是相对 URI 都没有关系。除了 http:https: 方案之外的任何其他情况都应该抛出 ArgumentException

我将添加来覆盖这些情况的测试是

[Fact]
public void GivenInstantiationOfEndpoint_WhenTheEndpointIsNull_ThenArgumentExceptionIsThrown()
{
    Assert.Throws<ArgumentException>(() => new Endpoint(null));
}

[Fact]
public void 
GivenInstantiationOfEndpoint_WhenTheEndpointIsHttpWithoutTheColon_ThenArgumentExceptionIsThrown()
{
    Assert.Throws<ArgumentException>(() => new Endpoint("httpwww.google.com"));
}

[Fact]
public void 
GivenInstantiationOfEndpoint_WhenTheEndpointIsHttpsWithoutTheColon_ThenArgumentExceptionIsThrown()
{
    Assert.Throws<ArgumentException>(() => new Endpoint("httpswww.google.com"));
}
[Fact]
public void 
GivenInstantiationOfEndpoint_WhenTheEndpointHasLeadingSpaces_ThenTheEndpointIsAccepted()
{
    Endpoint endpoint = new Endpoint(" HTTPs://www.google.com");
    Assert.Equal(endpoint.Address, " HTTPs://www.google.com");
}
[Fact]
public void 
GivenInstantiationOfEndpoint_WhenTheEndpointIsCaseInsensitive_ThenTheEndpointIsAccepted()
{
    Endpoint endpoint = new Endpoint("HTTP://www.google.com");
    Assert.Equal(endpoint.Address, "HTTP://www.google.com");
}

这可能令人惊讶,但是如果我要尝试自己编写正则表达式,验证 URI 可能会导致一系列复杂的测试。我可以执行简单的字符串操作来获取开头——例如,我可以根据冒号分割字符串,然后检查第一部分是否符合我们的任一条件。虽然这完全可以做到,但现实是,我们在这里处理的是模式匹配,所以使用已经在此任务上完成大量工作的工具是有意义的。具体来说,我将使用标准的 .NET URI 实现。特别是,我将查看是否可以使用 TryCreate 来创建一个映射到端点的 URI,并检查以确保方案是 HTTP 或 HTTPS。

考虑到这个 Uri 验证检查,我还会对 Endpoint 类内部进行一些重构,使 Address 值返回一个 string,并且它将直接从构造函数赋值,因此可以删除 private 变量。在构造函数参数检查中添加了检查后,我们的代码现在看起来像这样

public class Endpoint
{
    public Endpoint(string endpoint)
    {
        if (string.IsNullOrWhiteSpace(endpoint) || !IsHttpFormatEndpoint(endpoint))
        {
            throw new ArgumentException(null, nameof(endpoint));
        }
        Address = endpoint;
    }

    public string Address { get; }

    private bool IsHttpFormatEndpoint(string endpoint)
    {
        return Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uriResult)
          && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
    }
}

你会发现,随着我继续写作,我更喜欢小类,所以这对我来说长度正好合适,而且这个类确实很符合单一职责原则。功能简洁且自包含,将创建有效端点的知识与实际使用它的任何东西解耦。我说这个类接近遵循单一职责原则的原因是,我在类中混合了一个简单的验证。严格来说,我应该将端点验证分离到它自己的类中,但我决定在这里务实地将其混合在一起。随着代码库的进展,我可能会重新审视这个决定并分离验证,但目前,我很高兴把它留在原位。

我已经准备好了 Endpoint 类,所以我准备重新审视 GetRequest 类来使用它。我需要进行一些重构,以便使用 Endpoint,而不是在 Execute 方法中传入 string。我可以用 Endpoint 实例化该类,但我现在打算保持简单,并更改 Execute 方法,使其使用传入的值实例化一个 Endpoint 实例。由于我已经有单元测试,我应该能够验证更改是否按预期工作,现在 GetRequest 类看起来像这样:

using System;

namespace Goldlight.Xlcr.Core
{
    public class GetRequest
    {
        public object Execute(string endpoint)
        {
            Endpoint uriEndoint = new Endpoint(endpoint);
            return new NotImplementedException();
        }
    }
}

添加查询字符串参数

现在我有一个基本的 GetRequest 类和一个简洁的 Execute 方法,我想把注意力转向如何处理查询字符串参数等功能。一种方法是强制用户将这些值作为端点的一部分传入,这样像 https://peters.dummyapi.com/get/{id} 这样的查询就会被传入为 https://peters.dummyapi.com/get/1。虽然这当然是一种选择,但我更希望提供在 API 名称中使用替换参数的能力。在我开始草拟代码之前,我将分解我将尝试实现的目标。

首先,我们应该允许在这个 API 中使用任意数量的替换参数。替换由键和值组成,因此,在上面的例子中,键是 id,值是 1。如果我的查询字符串列表中有一个键,那么该键必须有一个相应的值,如下所示。

 
id   这是不允许的
id 1 这是允许的

由于我知道我们想允许多个键/值对,我这里的想法是我想使用一个 Dictionary 来管理它。我需要决定在键/值对中使用什么类型。虽然 ID 的例子中的值是一个数字,但我们这里可以有任意数量的不同值,所以我将选择使用字符串键和字符串值。

我可以选择将 Dictionary 直接放入 GetRequest 类中,但我更希望创建一个单独的类来管理查询字符串值,因为我现在脑海中正在思考的是,我们将通过查询字符串类传递端点以获取转换后的端点。

考虑到这个初始设计,我准备开始实施。我将创建一个 QueryString 类,它封装了添加查询字符串参数的能力,所以我将编写一个 Add 方法,以防止空键和空值。我本可以避免为键设置防护,让底层字典在我尝试使用无效键创建条目时抛出错误,但是,通过将其封装起来,我将控制返回的消息,使其成为我将来编写 UI 时可以使用的内容。Add 方法将如下所示:

public void Add(string key, string value)
{
  if (string.IsNullOrWhiteSpace(key)) 
      throw new ArgumentException("You must supply a key for this query string parameter.");
  if (string.IsNullOrWhiteSpace(value)) 
      throw new ArgumentException("You must supply a value for this query string parameter.");

  _queryStringParameters[key] = value;
}

在我思考代码的时候,有一个问题一直困扰着我;那就是,这将如何与我之前编写的端点类交互?我是否会先转换地址,然后将其传递给端点?我是否会获取一个端点实例并转换地址?我将如何在我的 GetRequest 类中连接起来?

如果我将 QueryString 转换放在 Endpoint 类中,那么,以我当前的架构,我将不得不传入 QueryString 实例以及 URL。如果我将 QueryString 保留在 GetRequest.Execute 方法中,那么当我想要添加 PostRequestPatchRequest 等实现时,我将不得不在每个实例中应用相同的 QueryString 应用。每种不同的实现都有其优缺点。目前,解决方案似乎是将 QueryString 封装在 Endpoint 类中,以便我们内部化地址的转换。

如果我们将 QueryString 类移到 Endpoint 内部,那么我们需要更新构造函数以接受它。我知道用户可能不总是提供查询字符串,所以如果此参数为 null,我将使用它的空实例。当应用 URL 转换时,空实例将只返回原始 URL 的副本。

让我们看看我们想对 Endpoint 构造函数进行哪些更改。我们将其从这样更改:

public Endpoint(string endpoint)
{
  if (!IsHttpFormatEndpoint(endpoint))
  {
    throw new ArgumentException(null, nameof(endpoint));
  }
  Address = endpoint;
}

变为这样:

public Endpoint(string endpoint, QueryString queryString)
{
  if (!IsHttpFormatEndpoint(endpoint))
  {
    throw new ArgumentException(null, nameof(endpoint));
  }
  Address = endpoint;
}

显然,此时这不会转换端点,因此我将把此功能添加到我们的 QueryString 类中。同样,遵循 TDD 原则,我将首先解决最简单的问题,并添加一个 Transform 方法,该方法只返回我传入的 string。在我编写 Transform 方法后,我的 Endpoint 构造函数看起来像这样:

public Endpoint(string endpoint, QueryString queryString = null)
{
  if (queryString != null)
  {
    endpoint = queryString.Transform(endpoint);
  }
  if (!IsHttpFormatEndpoint(endpoint))
  {
    throw new ArgumentException(null, nameof(endpoint));
  }

  Address = endpoint;
}

请注意,我本可以选择许多不同的设计来将 EndpointQueryString 处理联系起来。由于这是一个 MVP,我选择了采用最简单的方法。通过在构造函数中将 QueryString 参数的默认值设为 null,我最大限度地减少了需要触及以修复代码的地方。

转换查询字符串

到目前为止,我们代码的问题是它实际上并没有立即转换 URL。我还没有完成转换代码的编写,现在是时候做这件事了。我将采用的方法是简单地遍历字典中的键/值对,并使用 string.Replace 将参数替换为值。我决定用户提供的 URL 将使用大括号 {} 来表示可以替换的值,所以像 https://www.dummyapi.com/get/{id} 这样的 API 的键将是 id,值将被设置为一个合适的选择。

应用此逻辑,我们的 Transform 可以更改为如下所示

public string Transform(string url)
{
  foreach (KeyValuePair<string, string> parameter in _queryStringParameters)
  {
    url = url.Replace($"{{{parameter.Key}}}", parameter.Value);
  }
  return url;
}

对于键匹配的情况,这段代码就足够了,但我必须问:如果 URL 中的键是 id,而我的 QueryString 中的键是 ID,会发生什么?我只需在 Replace 方法中添加 StringComparison.OrdinalIgnoreCase 作为参数。记住,由于 string 是一个不可变对象,每次调用 Replace 都会在内存中创建一个新实例,这一点很重要。我选择接受这作为性能方面的适当权衡,以换取开发速度。

现在,我将把这段代码保持原样,满足于如果 URL 中没有匹配的键,那么 Endpoint 类将标记 URL 无效的知识。这有助于我将代码限制在实现 MVP 的最低限度。

结论

在本文中,我介绍了 GET 方法的类似 Postman 的基本实现。目前代码并未调用任何实际的端点,但我们已经初步了解了 TDD 以及我做出这些决定的原因。在下一篇文章中,我们将通过添加请求头支持并演示如何实际调用 HTTP 来完成我们 GET 功能的第一部分。我还将演示如何在不需要物理端点的情况下测试 HTTP 调用。

历史

  • 2021年5月12日:初始版本
© . All rights reserved.