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





5.00/5 (6投票s)
该系列文章的第二部分,我们将构建一个应用程序,展示编写整个应用程序的思考过程。
引言
我记得,在我还是个年轻的开发人员时,我曾敬畏那些似乎毫不费力就能坐下来写代码的人。系统似乎从他们的指尖流淌出来,轻松地构建、优雅而精致。感觉就像我在亲眼目睹米开朗基罗在西斯廷教堂创作,或者莫扎特坐在新五线谱前创作一样。当然,随着经验的积累,我现在知道我所看到的是开发人员在做开发人员的工作。有些人做得很好,真正理解开发的技艺,而另一些人则产出了不够优雅、不够完善的作品。多年来,我有幸向一些出色的开发人员学习,但我一次又一次地回到同一个基本问题,那就是……
如果我能听到其他开发者在编写应用程序时的思考过程,我现在作为开发者会好多少?在这个系列文章中,我将带领大家了解我在开发应用程序时正在思考的内容。本文的代码将以“优点缺点全展示”的方式编写,以便您可以看到我如何将某项内容从初始需求阶段一直开发到我乐意让其他人使用的程度。这意味着文章将展示我犯下的每一个错误以及在充实想法时采取的捷径。我不会声称自己是位伟大的开发人员,但我足够胜任且经验丰富,这应该能帮助那些刚入行的人尽早克服敬畏感,建立自信。
场景设定
本文是对第一部分的后续,我们在第一部分中介绍了我们将要构建的基础代码。在本文中,我将添加实际的 GET 调用,并进行重构,以便支持未来的 HTTP 操作。
源代码
本文的代码可从https://github.com/ohanlon/goldlight.xlcr/tree/article2下载。
从重构练习开始
在编写原始的 GetRequest
方法时,我脑海深处有一个想法:我们当时正在构建的功能不仅仅用于调用 GET
方法。考虑到这一点,我首先要做的就是重构 GetRequest
类,并引入一个基类供继承。这个类将命名为 Request
。
在编写这个类时,我将把注意力转向 Execute
方法的预期用途。此方法旨在执行实际操作,无论是 POST
、GET
还是其他。操作本身是异步的,因此我想考虑使其异步友好。例如,如果我调用 GetAsync
,该方法将返回一个 Task<HttpResponseMessage>
,因此我可以通过 Task.Run
语义或使用带 await
的 async
来管理其生命周期。如果我要批量处理 HTTP 调用并需要等待它们全部完成,那么我将使用 Task
本身,并结合 WaitAll
。不过,我在这里不需要这样做,所以我将使用极其方便的 async
/await
选项,这意味着我需要使方法签名返回 Task
。
在脑海中更改方法签名时,我还考虑了当前版本代码返回一个对象。由于每个异步方法调用都返回 Task<HttpResponseMessage>
,我认为这将是新 Execute
方法的一个合适的返回类型。我必须记住,我编写了许多单元测试来调用 Execute
方法,因此这些测试需要进行一些小的重构来支持签名更改。
我对这个新类的初步尝试如下:
public abstract class Request
{
public Task<HttpResponseMessage> Execute(string endpoint)
{
return Execute(new Endpoint(endpoint, QueryString));
}
public QueryString QueryString { get; set; }
protected abstract Task<HttpResponseMessage> Execute(Endpoint endpoint);
}
我选择将此类设为 abstract
,因为我希望强制继承此类派生类必须实现 abstract
方法。这意味着,任何继承我们基类的类都不必重复创建 Endpoint
实例的代码。这虽然是件小事,但它确实意味着无论从哪里调用,我们的 Execute
方法都能以一致的方式运行。您可能会注意到,我在基类中添加了一个 QueryString
属性。我添加它是为了完成转换 endpoint
的能力。
在编写初始 GetRequest
实现时,我还没有实际执行 HTTP 操作。我现在准备解决这个不足之处,首先使用 Microsoft 提供的 HttpClient
类。我只需要对 Request
类进行两个小的补充。首先,我将添加一个 protected
的 HttpClient
属性,所有继承类都可以访问它。
protected HttpClient Client { get; }
接下来,我将提供一个接受 HttpClient
作为参数的构造函数。我选择将其作为构造函数,因为它意味着每个实现类都将被强制提供它。如果继承类未能填充它,我希望强制抛出异常,以确保此类用户无法完成实例化,从而防止他们尝试发出 HTTP 请求。
public Request(HttpClient client)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
}
我现在准备更改我的 GetRequest
类,使其执行实际的 GET 操作。通过继承,该类被转换为了非常精简的
public class GetRequest : Request
{
public GetRequest(HttpClient client) : base(client) { }
protected async override Task<HttpResponseMessage> Execute(Endpoint endpoint)
=> await Client.GetAsync(endpoint.Address);
}
测试 Redux
这里的更改意味着构建将失败,因为我有一些单元测试使用默认构造函数,因此它们不会传递客户端。
在 GetRequest.Execute
方法中,我调用了 GetAsync
,所以如果我的测试调用此方法,人们会认为测试将执行实际的 HTTP 调用。虽然我可以这样做,但问题在于这使得测试变得脆弱且容易中断,因为我可能无法控制端点。我可以编写自己的一组 API 并启动一个 Web 服务器来执行测试,但这只会减慢测试速度。
我通过一点技巧来解决使用 HTTP 操作的问题,这个技巧是略微“欺骗” HttpClient
。出于多种原因,HttpClient
不适合模拟。幸运的是,我知道 HttpClient
实例可以接受一个 HttpMessageHandler
实例。正是这个实例最终通过一个名为 SendAsync
的 protected
方法使得所有 HTTP 调用成为可能。我需要能够做的是用我自己的实现来替换 HttpMessageHandler
类。幸运的是,我最近在 CodeProject 上上传了一个项目,它允许我做到这一点。在我的测试中,我现在可以做类似的事情
public async Task GivenCallToExecuteGetRequest_WhenEndpointIsSet_ThenNonNullResponseIsReturned()
{
FakeHttpMessageHandler fakeHttpMessageHandler = new FakeHttpMessageHandler();
HttpClient httpClient = new HttpClient(fakeHttpMessageHandler);
GetRequest getRequest = new GetRequest(httpClient);
Assert.NotNull(await getRequest.Execute("http://www.google.com"));
}
在我构建代码的过程中,我将展示更多使用 FakeHttpMessageHandler
来构建我们期望响应的高级方法。
请求不仅仅有查询字符串和端点
Web API 的一个特点是它们允许开发人员通过请求头提供信息。这些键/值对用于设置 Web 浏览器的用户代理、授权头、时间戳以及开发团队和 Web 框架认为有用的任何其他信息。我需要支持向 HttpClient
实例添加请求头的能力,我将把它提供给一个名为 RequestHeaders
的类。
这可能看起来不多,但我花了很多时间思考这个类的命名。我想要它被命名为 RequestHeader
还是 RequestHeaders
?毕竟,我将 QueryString
命名为单数,而不是复数形式的 QueryStrings
。我之所以争论这个问题,是因为它们名义上是相似的类。它们都在内部维护一个以 string
键和 string
值为键的字典,并且它们都在类本身内部执行某种形式的转换操作。我之所以选择这个名字,归根结底是因为类的意图。QueryString
类旨在转换 QueryString
,这是一个单一的实体。RequestHeaders
类旨在将值添加到 HttpClient DefaultRequestHeaders
集合中,因此它作用于多个项。基本上,我根据类的意图而不是其内部存储机制来命名它。
在我构思要编写的代码时,我不得不问自己一个问题:请求头是否可以有一个空值?我的假设是这是允许的,但做出假设很危险,所以我需要研究一下事实是否如此。我的第一站是查看 DefaultRequestHeaders 的文档,看看它对此有何说明。在阅读过程中,我没有看到任何表明我不能添加空 string
的迹象,所以这些将是我编写的第一个测试。在考虑代码的“不正常路径”时,我不得不问自己是否需要验证值是否唯一。这是因为 HTTP 头集合允许我们为一个头添加多个值。同样,也没有值必须唯一的约束。
由于一个头可以有多个值,我决定使用一个 dictionary
,其中值将是 string
列表。这意味着,当我添加一个头时,我首先需要查看该头是否存在。如果不存在,我将用一个空列表创建它。然后我将值添加到列表中。
我的第一个代码版本如下:
public class RequestHeaders
{
private readonly Dictionary<string, List<string>> _headers =
new Dictionary<string, List<string>>();
public void Add(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("You must supply the header name.");
if (!_headers.ContainsKey(key))
{
_headers[key] = new List<string>();
}
_headers[key].Add(value);
}
}
我现在需要一种方法来将头应用于 HttpClient
。我将在 RequestHeaders
中添加以下方法,该方法将应用转换。
public void Apply(HttpClient httpClient)
{
foreach (KeyValuePair<string, List<string>> header in _headers)
{
httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
}
}
在我编写此代码的测试时,我错误地调用了两次 Apply
(我说是错误地调用,但幸好我这样做了,因为我忽略了它会在头中重复值)。为了防止这种情况成为潜在问题,我必须稍微修改 Apply
方法。为了满足以下测试。
[Fact]
public void GivenRequestHeader_WhenCallingApplyMoreThanOnce_ThenOnlyOneValueIsPresent()
{
HttpClient httpClient = new HttpClient();
RequestHeaders requestHeader = new RequestHeaders();
requestHeader.Add("test", "value");
requestHeader.Apply(httpClient);
requestHeader.Apply(httpClient);
IEnumerable<string> headers = httpClient.DefaultRequestHeaders.GetValues("test");
Assert.Single(headers);
}
我需要对 Apply
方法进行一个小修改,在重新添加头之前先清除它们。
public void Apply(HttpClient httpClient)
{
httpClient.DefaultRequestHeaders.Clear();
foreach (KeyValuePair<string, List<string>> header in _headers)
{
httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
}
}
回到请求
我已经能够支持请求头。我有一个 Request
类。现在我只需要对 Request
类进行一些修改来支持这一点。我将向该类添加一个 RequestHeaders
属性,然后在 Execute
方法中使用此属性。现在 Request
类看起来是这样的:
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace Goldlight.Xlcr.Core
{
public abstract class Request
{
public Request(HttpClient client)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
}
public Task<HttpResponseMessage> Execute(string endpoint)
{
RequestHeaders?.Apply(Client);
return Execute(new Endpoint(endpoint, QueryString));
}
public QueryString QueryString { get; set; }
public RequestHeaders RequestHeaders { get; set; }
protected HttpClient Client { get; }
protected abstract Task<HttpResponseMessage> Execute(Endpoint endpoint);
}
}
顺便说一句,因为有可能我不会总是传递 RequestHeaders
,所以我会在调用 Apply
之前检查 null
。我本可以把 Apply
部分包装在 if (RequestHeaders != null)
块中,但我喜欢使用 ?
空操作符,因为它不会改变我的阅读流程。这里有一个插曲;因为我知道我打算在 Execute
中调用一个额外的方法,所以我没有使用表达式语法来将方法精简下来。
public Task<HttpResponseMessage> Execute(string endpoint) =>
Execute(new Endpoint(endpoint, QueryString));
结论
好了,就到这里。我们用相对简单的代码为 GetRequest
创建了一个 MVP。我没有试图耍小聪明。我没有试图走捷径,而且这个代码还有很多可以改进的地方。在下一篇文章中,我将处理如何从用户界面调用它。作为剧透,我还没有决定将使用哪种技术来构建 UI;我将在下一部分中讨论我的决定,以便您可以看到我如何做出选择。
希望您喜欢这个系列。有很多内容要讲,我对此乐在其中。
历史
- 2021 年 5 月 13 日:初始版本