The Code Project API - 第 2 部分 - 获得一些 REST






4.97/5 (23投票s)
在本文中,我们将扩展第一部分的工作,开始从 REST API 中读取数据。
系列文章
- CodeProject API - 第一部分
- CodeProject API - 获取 RESTful 数据(本文)
引言
欢迎来到本系列文章的第二篇,我们将在此基础上构建 CodeProject API 的包装器。在第一篇文章中,我们开始开发一个可移植类库实现,该实现允许我们开发运行在多种平台上的使用该 API 的应用程序,而无需担心 API 的实际实现细节。为此,我们开发了一个从 CodeProject 检索授权令牌的实现。此令牌将与我们将要进行的其他 API 调用一起使用,因此这是一个非常好的起点。
在第一篇文章中,我们将接口定义与实际的 API 实现分开了。当时这可能看起来很奇怪,但这将允许我们提供一个参考数据实现,我们可以使用它来设计我们的 UI 和测试代码,而无需实际调用该站点。我们还看到了我 API 目标的开端。如果它们仍然有效,我们将重申它们,并添加我们希望遵守的新目标。
目标 1
包装器必须符合可移植类库(Portable Class Library)的标准。只要我遵守严格的 PCL 标准,我就应该能够创建一个可以在许多环境中使用的包装器。包装器的第一个版本目标是 .NET 4.5、Windows 8、Windows Phone 8 和 Windows Phone Silverlight 8。稍后我将为该包装器目标 Xamarin,因此我们将进行重新定位,使其易于在 Android 和 iOS 上使用。
目标 2
API 必须尽可能遵循我认为良好的设计原则。这是一个高度主观的事情,但如果可能,我将致力于广泛使用 SOLID 原则。虽然开发主要遵循 TDD,但有时我会为了使用某些不易测试的功能(例如 HttpClient)而偏离这一原则。如果我尽可能遵循单一职责原则,我将最大限度地减少“接触点”。最终,我将为他人生产一些东西,所以我必须尽可能务实——这意味着会有一些设计上的妥协,但我会在本文中记录下来。
目标 3
这是一个迭代式构建。我将第一版的功能限制为仅获取访问令牌——当我们将使用 API 时,该令牌至关重要。这可能看起来是一件微不足道的事情,但由于将有大量设计决策进入初始设计,这为“快速推出”一个初步验证版本奠定了良好的基础。我建议不要过分依赖此版本,因为将来很可能会进行大量重构,但这确实是一个不错的起点。
目标 4
我想使用 IoC,但我不想强迫你使用 我选择的 IoC。好的,这听起来有点奇怪,但由于我希望尽可能多的人采用它,所以我设计它的目的是让你可以使用任何适合你的机制。最终,如果你想自己“new”一切,那完全是你的选择,但我喜欢 IoC。话虽如此,如果你想使用 IoC,我不期望你必须自己注册所有东西。代码提供了一个抽象,我们可以用它来隐藏我们使用 IoC 的事实,让我们可以插入我们喜欢的 IoC 容器。我将提供一个 Autofac 实现,这将演示如何将事物封装在合适的 IoC 容器中。
目标 5
除非有非常充分的理由,否则代码将尽可能遵循单一职责原则。这意味着将会有许多小类来将事物连接起来。我非常喜欢 SRP。
目标 6
我不会写我不需要写的东西。这意味着我将使用其他人编写的包来处理 JSON 转换等事宜,除非我发现我需要一些 PCL 形式中不存在的东西。
目标 7
除了示例项目和测试,所有库都将是可移植类库。这只是为了说明我希望它尽可能广泛可用。
目标 8
输入不可信。如果方法或构造函数接受一个类型,我们至少应该测试以确保它已设置。将会引入一些有效性检查,但第一步是确保每个公共构造函数或方法都对其输入不信任。这可以通过测试轻松验证。
目标 9
最初,我只会提供单元级测试。我不会执行系统待测(SUT)测试,因此输入将尽可能地进行模拟。
目标 10
在此阶段,我不太担心异常处理或日志记录。对于此迭代,代码将天真地假设调用外部源时一切正常。在下一迭代中,我将寻求进行更正式的处理,以应对所谓的“不顺利的情况”。这意味着在此阶段不会抛出自定义异常——这些将在后期引入。
目标 11
模型将由接口支持。虽然这对模型来说不是严格必需的,但我喜欢从接口开始工作,所以我将在这里使用接口。这确实带来了一些设计限制,我们稍后在解码结果时会看到,但我喜欢接口是一个确定的契约。
目标 12
重构是可以的。我预计在这个阶段,我将不得不开始重构一些以前的代码库。没关系,毕竟如果我们正确地进行了重构,我们的单元测试应该仍然通过。
好了,这就是我的十二个本迭代目标。让我们来看看代码。
重新审视 HttpClientBuilder
我为检索访问令牌创建的 HttpClientBuilder 版本在该特定情况下运行良好,但其他 API 需要将访问令牌添加到默认请求标头中。其他设置将与 HttpClientBuilder 相同,因此似乎需要创建一个特殊的 ClientRequest 版本,该版本接受 IAccessToken 实例以添加缺失的标头。你可能会想到的第一个方法是尝试从 HttpClientBuilder 派生我们的专用类。虽然将 HttpClient 字段从私有更改为受保护确实允许我们在派生类构造函数中添加额外细节,但我们有一个问题;我们必须传入 IAccessToken 实例,该实例将访问令牌检索公开为异步方法。这意味着我们必须能够 await 访问令牌检索的结果,而你不能在构造函数中使用 async/await(这意味着你的构造函数必须能够返回 Task,这自然是不允许的)。
现在我们只剩下两个选择。要么在新的类中复制代码,要么创建一个包含通用代码的基类,并让 HttpClientBuilder 和我们的新类都从该基类派生。不幸的是,我们将失去 HttpClient 实例的 readonly 保护,但这是我们无法避免的。此时,我们可能会认为放弃 readonly 属性意味着我们可以跳过一步,直接从 HttpClientBuilder 派生——添加缺失的部分。请记住,我曾说过我们需要使用异步调用检索访问令牌,这意味着我们的 GetToken 调用需要是异步的。这自然意味着我们将暴露普通的 GetClient 和我们新的异步 GetClientAsync 方法——违反 SRP。这就是为什么我们要创建一个通用的基类,并只暴露我们需要的部分。
请注意,构造函数初始化问题有一些解决方法,但它们会遇到这个问题,即我们必须等到用户详细信息已填充才能获取访问令牌。这将阻止我们正确地构建对象图。
我要做的第一件事是创建一个抽象 HttpClientBuilderBase 类。更确切地说,我要做的第一件事是构建一个 HttpClientBuilderBaseTest 类。仅仅因为我们正在移动代码,并不意味着我们可以忽略测试。我不会详细介绍每个测试——你可以自己阅读它们,看看我是如何根据测试来构建 HttpClientBuilderBase 类的。最终,我们的实际类实现就是这样。
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using CodeProject.Api.Interfaces.Settings;
namespace CodeProject.Api.Http
{
public abstract class HttpClientBuilderBase
{
protected HttpClient Client;
protected HttpClientBuilderBase(HttpClient client, IClientSettings clientSettings)
{
if (client == null) throw new ArgumentNullException("client");
if (clientSettings == null) throw new ArgumentNullException("clientSettings");
client.BaseAddress = new Uri(clientSettings.BaseUrl);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
Client = client;
}
}
}
现在我们只需要将 HttpClientBuilder 改为从这个类派生。如果我们运行 HttpClientBuilder 的测试,它们应该仍然都能通过。
using System.Net.Http;
using CodeProject.Api.Interfaces.Http;
using CodeProject.Api.Interfaces.Settings;
namespace CodeProject.Api.Http
{
public class HttpClientBuilder : HttpClientBuilderBase, IHttpClientBuilder
{
public HttpClientBuilder(HttpClient client, IClientSettings clientSettings)
: base(client, clientSettings)
{
}
public HttpClient GetClient()
{
return Client;
}
}
}
这就是拥有单元测试的优点。它们让你在重构时能够相对较好地了解你的实现是否仍然有效。
HttpClientWithAccessTokenBuilder
由于我们希望我们的 HttpClient 构建版本带有授权设置,以将访问令牌作为 Bearer 添加,所以我们希望这个类接受 IAccessToken。这样做的好处是,我们可以确保在实际使用此类进行任何处理之前都会分配访问令牌——即使我们在应用程序中没有显式调用它。现在我们想创建我们的新类。我们仍然遵循先编写单元测试再添加代码的原则。完成后,我们将得到一个可以与访问令牌一起使用的实现。
using System;
using System.Net.Http;
using System.Threading.Tasks;
using CodeProject.Api.Interfaces.Settings;
using CodeProject.Api.Interfaces.Token;
using CodeProject.Api.Interfaces.Http;
namespace CodeProject.Api.Http
{
public class HttpClientWithAccessTokenBuilder : HttpClientBuilderBase, IHttpClientWithAccessTokenBuilder
{
private readonly IAccessToken accessToken;
public HttpClientWithAccessTokenBuilder(HttpClient client, IClientSettings clientSettings, IAccessToken accessToken)
: base(client, clientSettings)
{
if (accessToken == null) throw new ArgumentNullException("accessToken");
this.accessToken = accessToken;
}
public async Task<HttpClient> GetClientAsync()
{
string token = await accessToken.GetTokenAsync();
Client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
return Client;
}
}
}
随着我们构建的每个类,我们都将自己限制在提供小型、易于测试的代码。我们唯一需要添加的是新的请求标头。这很容易测试,而且完全明确。还有什么比这更好的呢?
那些阅读了我第一篇文章代码的人会意识到我将在 ModuleRegistration 中注册它。现在,我只是将其注册为单个实例。如果我们现在将其设置为多个实例,这将对访问令牌处理方式产生影响,因为访问令牌的访问可能会出现竞态条件。目前,这不是我们需要担心的问题,所以我们将将其保留为单个实例。
FetchReputation
好了,我们的新 HttpClient 基础设施已经就位,让我们开始实际使用它。现在是时候稍微变得“自恋”一点,看看如何使用 API 来获取我们的声誉。对于实际的 API 调用,我将采用这样的约定:执行 API 调用的类将位于 CodeProject API 文档中API 标识的命名空间内。由于 Reputation 定义在“My”区域,我们将使用这个命名空间。正如我在第一篇文章中讨论过的,我采取的方法是希望将请求的检索与实际的请求模型本身分开,因此将定义的第一个类是 FetchReputation。
using System.Net.Http;
using System.Threading.Tasks;
using CodeProject.Api.Interfaces.Http;
using CodeProject.Api.Interfaces.My;
using Newtonsort.Json;
namespace CodeProject.Api.My
{
public class FetchReputation :IFetchReputation
{
private const string Url = "/v1/My/Reputation";
private readonly IHttpClientWithAccessTokenBuilder accessTokenBuilder;
public FetchReputation(IHttpClientWithAccessTokenBuilder accessTokenBuilder)
{
this.accessTokenBuilder = accessTokenBuilder;
}
public async Task<string> GetReputationAsync()
{
HttpClient client = await accessTokenBuilder.GetClientAsync();
HttpResponseMessage responseMessage = await client.GetAsync(Url);
string jsonMessage = await responseMessage.Content.ReadAsStringAsync();
return jsonMessage;
}
}
}
正如我们在这里看到的,这是一段极其简单的代码,但由于我们迄今为止构建基础设施的方式,它实际上做了很多工作。我知道其他 API 调用将遵循与此类似的模式,但就目前而言,我不需要为这些 API 实现这一点;因此,遵循 YAGNI 原则,我不会担心将部分移动到基类中。只做我们需要的,不多做,因为我们随时可以根据需要重新访问它。
ReputationModel
由于我们要拉取数据来显示,我们真的不希望处理原始 JSON。我们希望构建一个封装 JSON 所代表内容的模型。我们将从 Reputation 调用中收到的 JSON 大致如下所示:
{"totalPoints":582336, "reputationTypes":[ {"name":"Author","points":45366,"level":"platinum","designation":"Legend"}, {"name":"Authority","points":140493,"level":"platinum","designation":"Scholar"}, {"name":"Debator","points":359941,"level":"platinum","designation":"Master"}, {"name":"Organiser","points":21683,"level":"platinum","designation":"Custodian"}, {"name":"Participant","points":12250,"level":"platinum","designation":"Addict"}, {"name":"Editor","points":1127,"level":"silver","designation":"Reviser"}, {"name":"Enquirer","points":1476,"level":"silver","designation":"Quester"}], "graphUrl":"//codeproject.org.cn/script/Reputation/ReputationGraph.aspx?mid=213147"}
这给了我们一个想法,即我们的顶级模型将包含一个整数值,表示用户拥有的总积分,一个包含声誉类型细分的枚举,以及一个指向用户声誉图的 URL。由此,我们决定我们的模型可能看起来像这样:
public class ReputationModel : IReputationModel
{
public int TotalPoints { get; set; }
public IEnumerable<IReputationType> ReputationTypes { get; set; }
public string GraphUrl { get; set; }
}
ReputationType
显然,我们还需要定义声誉类型应该是什么样子。查看 JSON,我们看到有一个声誉类型名称、在该类型下赚取的积分数、一个级别以及该级别的用户称号。我们决定我们的模型应该看起来像这样:
public class ReputationType : IReputationType
{
public string Name { get; set; }
public int Points { get; set; }
public string Level { get; set; }
public string Designation { get; set; }
}
这样,我们就拥有了数据的模型。现在,我们只需要将它连接到 FetchReputation 来检索我们的数据,并将其从 JSON 转换为我们的模型。
声誉
将检索机制与模型绑定的粘合剂是 Reputation 类。在我解释它做什么之前,值得看一下类本身。
public class Reputation : IReputation { private readonly IFetchReputation fetchReputation; private readonly IReputationToConverterMapping mapping; private readonly IJsonConverter converter; public Reputation(IFetchReputation fetchReputation, IReputationToConverterMapping mapping, IJsonConverter converter) { if (fetchReputation == null) throw new ArgumentNullException("fetchReputation"); if (mapping == null) throw new ArgumentNullException("mapping"); if (converter == null) throw new ArgumentNullException("converter"); this.fetchReputation = fetchReputation; this.mapping = mapping; this.converter = converter; } public async Task<IReputationModel> GetReputationAsync() { string reputation = await fetchReputation.GetReputationAsync(); return converter.Deserialize<IReputationModel>(reputation, mapping.GetReputationMapping()); } }
我们将从简单部分开始——调用 GetReputationAsync。显然,这是在调用 FetchReputation 中的方法,该方法会外出到 API 并检索我们将要转换为模型的 JSON 字符串。这就是我们将从我们的类中返回的内容——声誉模型。然而,声誉模型在这里是使用接口而不是具体类来定义和返回的。如果我们尝试使用 Json.NET 将 JSON 解码到接口,你很快就会遇到一个限制——即 Json.NET 不直接支持转换为接口。
JsonModelConverter
幸运的是,Json.NET 允许我们在转换管道中插入自定义转换器。为了提供我们所需的支持,我们创建了一个转换器,它将使用我们提供的接口到类型映射,在模型类型使用接口时反序列化为适当的具体类型。特别巧妙的是,我们只需要负责单个接口映射;由于反序列化过程是逐行进行的,转换器会正确映射可枚举元素——我们不需要告诉它如何填充 IEnumerable。
public class JsonModelConverter : Newtonsoft.Json.JsonConverter, IJsonModelConverter
{
private readonly Dictionary<Type, Type> converters = new Dictionary<Type, Type>();
public void MapInterfaceToType(Type typeName, Type type)
{
if (typeName == null) throw new ArgumentNullException("typeName");
if (type == null) throw new ArgumentNullException("type");
converters.Add(typeName, type);
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
JsonSerializer serializer)
{
Type type;
if (converters.TryGetValue(objectType, out type))
{
return serializer.Deserialize(reader, type);
}
throw new JsonSerializationException(string.Format("Type {0} unexpected.", objectType.FullName));
}
public override bool CanConvert(Type objectType)
{
return converters.ContainsKey(objectType);
}
}
由于我们只关心反序列化 JSON,所以我们不必担心 WriteJson 的代码。
当我们使用这个类时,我们需要告诉它哪些接口映射到哪些具体类型。我们使用 MapInterfaceToType 来(顾名思义)将接口映射到相关的具体类型。通过这样做,当转换器运行时,CanConvert 的调用只会检查转换器是否包含该键,而 ReadJson 会从字典中获取适当的具体类型(如果已注册),以进行反序列化。转换器的使用就是这么简单。
ReputationToConverterMapping
这个类是我们注册相关接口到适当具体类型的地方。现在,你可能会想,为什么我们不使用某种 IoC 解析器来自动执行此映射。我们不这样做有几个原因:
- 我们不会将模型接口映射到 IoC 容器。我们应该看到这些类型的唯一方式是通过模型反序列化过程。
- 如果我们自动解析 IoC 容器中包含的所有内容,我们将注册大量转换器不需要知道的类型。
public class ReputationToConverterMapping : IReputationToConverterMapping
{
private readonly IJsonSettings settings;
public ReputationToConverterMapping(IJsonSettings settings)
{
if (settings == null) throw new ArgumentNullException();
settings.ModelConverter.MapInterfaceToType(typeof(IReputationModel), typeof(ReputationModel));
settings.ModelConverter.MapInterfaceToType(typeof(IReputationType), typeof(ReputationType));
this.settings = settings;
}
public IJsonSettings GetReputationMapping()
{
return settings;
}
}
JsonSettings
我们在上面的代码中看到了一些对 ModelConverter 的调用。这是通过 JsonSettings 类提供的。这个类方便地允许我们将自定义 JsonModelConverter 作为转换器映射到 JsonSerializerSettings。当我们尝试反序列化模型时,我们将使用这些设置。
public class JsonSettings : IJsonSettings
{
private readonly JsonSerializerSettings serializerSettings;
private readonly IJsonModelConverter modelConverter;
public JsonSettings(IJsonModelConverter modelConverter)
{
if (modelConverter == null) throw new ArgumentNullException("modelConverter");
serializerSettings = new JsonSerializerSettings {TypeNameHandling = TypeNameHandling.None};
Newtonsoft.Json.JsonConverter converter = (Newtonsoft.Json.JsonConverter)modelConverter;
serializerSettings.Converters.Add(converter);
this.modelConverter = modelConverter;
}
public IJsonModelConverter ModelConverter { get { return modelConverter; } }
public JsonSerializerSettings GetSerializerSettings()
{
return serializerSettings;
}
}
JsonConverter
拼图的最后一块是调用 Deserialize 方法。你可能还记得,在第一篇文章中,我们有一个 JsonConverter 类,它允许我们从一个令牌中获取单个值。显然,当处理更复杂的模型时,这永远不够,所以我们需要扩展转换器以支持反序列化更复杂的类型。这就是 Deserialize 方法的作用。此方法负责使用适当的序列化设置来反序列化模型。
public T Deserialize<T>(string model, IJsonSettings settings)
{
if (string.IsNullOrWhiteSpace(model)) throw new ArgumentException("You must specify the model.");
if (settings == null) throw new ArgumentNullException();
return JsonConvert.DeserializeObject<T>(model, settings.GetSerializerSettings());
}
目前,我们暂时保留原始的单个令牌转换,因为它满足我们当前的需求。但是,最终,我们将创建一个封装访问令牌的模型,届时我们将能够删除对单个令牌的支持,因为我们将直接反序列化到模型。但目前,我们不会采取这一步。
就是这样。处理声誉 API 的反序列化代码就是这么简单。现在,我们只需要更新我们的示例应用程序以调用我们的声誉 API。
ReputationSample
在我们的控制台应用程序中,我创建了这个类来处理对声誉 API 的调用。
public class ReputationSample : SampleBase, IReputationSample
{
private readonly IReputation fetchReputation;
public ReputationSample(IReputation fetchReputation, IUserDetails userDetails)
: base(userDetails)
{
if (fetchReputation == null) throw new ArgumentNullException("fetchReputation");
this.fetchReputation = fetchReputation;
}
public async Task GetReputationAsync()
{
var token = await fetchReputation.GetReputationAsync();
Console.WriteLine("Total points: {0}", token.TotalPoints);
foreach (var repType in token.ReputationTypes)
{
Console.WriteLine("{0} - {1} - {2}", repType.Name, repType.Points, repType.Designation);
}
}
}
SampleBase 是对旧代码库的一个简单重构,用于封装对 EnterCredentials 的调用。如果你进一步查看原始示例,你会发现控制台应用程序不再直接调用访问令牌 API(尽管我们实际上并未从示例中删除此代码部分)。这表明,如果尚未指定访问令牌,我们的 API 将自动获取它。现在,当我们运行应用程序时,我们会看到这个。
结论
到目前为止,我们在调用 CodeProject API 和处理结果数据方面做得很好。同样,我们的代码库仍然假设一切都“处于良好状态”,并且我们不会处理失败。目前,我对此并不担心,因为我们仍在建立处理这种情况的基础设施。在上一篇文章的结尾,我曾想过要处理文章 API,但在我开始编写代码时,我意识到“虚荣”应用是一个不错的起点,这样每个人,即使是那些没有写过文章的人,也能从代码中受益。
下一步是开始引入更多 API,并开始研究我们是否需要将所有接口都公开为公共接口,或者我们是否可以开始将一些实现保持为内部的。我们需要开始收紧人们在我们 API 中看到的“表面区域”。我也很想开始引入其他示例。控制台应用程序都很好,但让我们开始让事物变得更“闪亮”。
at this point, I hope I have managed to convey the thought processes and design decisions I have made. Again, did I meet my objectives? Well, I'd like to think I did. I've tried to adhere to "best practices" but I've also tried to be pragmatic. Where compromises are made, I'll keep on documenting what they are and we'll hopefully keep addressing them as the code progresses.
音乐
我写的新文章都不会缺少音乐列表。在迄今为止本系列的创作过程中,我听了以下专辑/艺术家。
- Joe Satriani – The Complete Albums
- Joe Bonamassa – Live From the Royal Albert Hall
- Brad Gillis – Alligator
- Pink Floyd – The Wall
- Rush – Moving Pictures
- Sixx A.M. – Modern Vintage
- The Answer – Everyday Demons
- Thunder – Wonder Days
- Andrea Bocelli - Sogno
- Andrea Bocelli - Opera
- John Lee Hooker - Mr Lucky
- John Lee Hooker - The Healer
- Eric Clapton and BB King - Riding With The King