要点概述:
.NET Core 最初是在 2016 年发布的,随着.NET Core 2.0 的发布,微软拥有了下一个通用、模块化、跨平台和开源的平台主版本。.NET Core 已经创建了许多 API,在当前版本的.net 框架中均可用。它最初是为下一代 ASP.NET 解决方案而创建的,但现在成了许多其他场景的驱动和基础,包括物联网、云计算和下一代移动解决方案。在本系列文章中,我们将探讨.NET Core 的一些好处,以及它如何不仅能使传统的.NET 开发人员受益,还能使所有需要为市场带来健壮、高效和经济的解决方案的技术人员受益。
如今的互联网与五年前已经完全不同了,Web api 连接了由 Web 应用和移动应用驱动的现代互联网。有一种技能非常需要,那就是创建其他开发人员也可以使用的健壮的 Web api。驱动大多数现代 web 和移动应用的 API 都需要具有稳定性和可靠性,以便在流量达到性能限制时仍能继续服务。
本文的目的是描述 ASP.NET Core 2.0 Web API 解决方案的体系结构,它使用了 Hexagonal 架构和端口和适配器模式。首先,我们来看看.NET Core 和 ASP.NET Core 的新特性,它们对现代 Web API 很有帮助。
ASP.NET Core 是微软在.NET Core 的基础上构建的一个新的 web 框架,用来摆脱.NET 1.0 以来的遗留技术。相比之下,ASP.NET 4.6 仍然使用 System.WebAssembly(它包含了所有 WebForms 类库),也因此引入了最近的 ASP.NET MVC 5 解决方案。通过摆脱这些遗留依赖和从头开始开发框架,并为跨平台执行进行了架构设计,ASP.NET Core 2.0 为开发人员提供了更好的性能。
更多关于.NET Core 和 ASP.NET Core 的好处,你可以阅读本系列的其他文章: Maarten Balliauw 的《性能是.NET 的核心特性》和 Chris Klug 的《 ASP.NET Core -- 简洁的力量》。
体系架构
构建一个优秀的 API 依赖于伟大的架构。我们将从 ASP.NET Core 的内置功能来研究 API 设计和开发的许多方面以形成哲学和最终设计模式的架构。
依赖注入
在深入研究 ASP 的架构之前.NET Core Web API 解决方案,我想讨论一下我所认为的使.NET 核心开发人员过得更好的单一好处 :即依赖注入 (DI)。现在,我知道你会说我们在.NET 框架和 ASP.NET 解决方案中有依赖注入。我同意,但是我们过去使用的依赖注入是来自第三方的商业提供商或者开源库。他们做得很好,但是对于.NET 开发人员来说,有一个很陡峭的学习曲线,并且所有的依赖注入库都有自己独特的处理方法。今天,有了.NET Core,我们从一开始就将依赖注入集成到框架中了。此外,它的用法非常简单,它是立即可用的。
我们需要在 API 中使用依赖注入的原因是,它允许我们有最好的经验来解耦架构层,并允许我们模拟数据层,或者为 API 构建多个数据源。
要使用.NET Core 依赖注入框架,请确保您的项目引用了 Microsoft.AspNetCore.AllNuGet 包 (它包含对 Microsoft.Extnesions. DependencyInjection.Abstractionspackage 的依赖关系)。这个包提供了对 IServiceCollection 接口的访问,该接口具有一个 System.IServiceProvider 接口,您可以调用 GetService
要了解更多关于.NET Core 依赖注入的信息,我建议您阅读以下关于 MSDN 的文档:ASP.NET 中依赖注入的介绍。
现在我们来看看为什么要像我一样做 API 架构设计的原理。设计任何架构的两个方面都依赖于这两个概念: 允许深度可维护性,以及在解决方案中使用经过验证的模式和架构。
API 的可维护性
对于任何工程过程来说,可维护性是指一个产品易于被维护:发现缺陷、纠正发现的缺陷、无需替换仍在工作的组件即可修复或更换有缺陷的组件、预防意外故障、最大限度地提高产品的使用寿命、有能力满足新的需求、使未来的维护更容易,以及能应对环境变化。如果没有精心规划和可执行的架构,就很难做到以上种种。
可维护性是一个长期的问题,应该从您的 API 的远景来看。考虑到这一点,你需要做出决定,实现未来的愿景而不是走那些看上去能过得更轻松的捷径。在一开始就做出艰难的决定将使你的项目有一个很长的生命周期,并提供用户所需的好处。
什么使软件架构具有高可维护性? 如何评估 API 是否可被维护?
我的 API 体系结构的关键是使用 C#接口来支持其他实现。如果您已经用 C#编写了.NET 代码,那么您可能已经使用了接口。我在解决方案中使用接口在领域层中构建一个契约,该契约保证我为 API 开发的任何数据层都遵循数据存储库的契约。它还允许我的 API 项目中的控制器遵守另一个已设立的契约,以获得正确的方法来处理领域项目的 Supervisor 中的 API 方法。接口对于.NET Core 是非常重要的,如果您需要了解更多的信息,请点击此处。
我们希望整个 API 解决方案中的对象具有单一职责。这将使我们在需要修复缺陷或增强代码时让对象保持简单和易于修改。如果您的代码中有一些“代码异味”,那么您可能违反了单一责任原则。一般情况下,我关注接口契约的实现的长度和复杂性。我的方法中没有代码行限制,但是如果它已经超过了您 IDE 中的一个视图,那么它可能就太长了。此外,我还检查方法的圈复杂度,以确定项目方法和函数的复杂性。
端口和适配器模式 (又称六角形架构) 可以解决业务逻辑与其他依赖项 (如数据访问或 API 框架) 耦合过于紧密的问题。使用此模式将允许您的 API 解决方案具有清晰的边界、具有单一职责的良好命名的对象,最终使其更容易开发和维护。
我们可以很直观地把这个模式看作一个洋葱,端口位于六边形的外部,而适配器和业务逻辑的位置更靠近核心。我将架构的外部连接视为端口。被消费的 API 端点或 Entity Framework Core 2.0 所使用的数据库连接将成为典型的端口范例,而内部数据存储库则是适配器。
接下来,让我们看看架构的逻辑部分和一些演示代码示例。
领域(Domain)层
在查看 API 和领域层之前,我们需要解释如何通过接口和 API 业务逻辑的实现构建契约。我们来看看领域层。领域层具有以下功能:
我们的域实体对象代表我们用来存储和检索用于 API 业务逻辑的数据的数据库。每个实体对象都将包含 SQL 表中的属性。如下即为照片实体 Album。
public sealed class Album
{
public int AlbumId { get; set; }
public string Title { get; set; }
public int ArtistId { get; set; }
public ICollection
SQL 数据库中的 Album 表有三表:AlbumId、Title 和 ArtistId。这三个属性是专辑实体的一部分,另外还有艺术家的名字以及相关艺术家和一组相关歌曲。正如我们将在 API 体系结构的其他层中看到的,我们将针对该项目中的视图模型构建此实体对象的定义。
视图模型是实体的扩展,并帮助为 api 的使用者提供更多的信息。让我们看看视图模型。它与相册实体非常相似,但具有额外的属性。在 API 的设计中,我确定每个相册应该在从 API 返回的有效负载中包含艺术家的名字。这能让 API 使用者拥有关于相册的关键信息,而不必在数据载荷中再传递 Artist 视图模型 (特别是当我们返回大量 Album 时)。下面是我们的 Album 视图模型的一个示例。
public class AlbumViewModel
{
public int AlbumId { get; set; }
public string Title { get; set; }
public int ArtistId { get; set; }
public string ArtistName { get; set; }
public ArtistViewModel Artist { get; set; }
public IList Tracks { get; set; }
}
在领域层中另一部分需要开发的是契约,它们会经过该层中为每个实体定义的接口。同样,我们将使用 Album 实体来展示所定义的接口。
public interface IAlbumRepository : IDisposable
{
Task> GetAllAsync(CancellationToken ct = default(CancellationToken));
Task GetByIdAsync(int id, CancellationToken ct = default(CancellationToken));
Task> GetByArtistIdAsync(int id, CancellationToken ct = default(CancellationToken));
Task AddAsync(Album newAlbum, CancellationToken ct = default(CancellationToken));
Task UpdateAsync(Album album, CancellationToken ct = default(CancellationToken));
Task DeleteAsync(int id, CancellationToken ct = default(CancellationToken));
}
如上例所示,接口定义了为 Album 实体实现数据访问方法所需的方法。每个实体对象和接口都有良好的定义和简单化,使下一层可以得到良好的定义。
最后,领域项目的核心是 Supervisor 类。它的用途是在实体和视图模型之间进行转换,并在 API 端点和数据访问逻辑之外执行业务逻辑。让 Supervisor 来处理这些还将隔离逻辑,使转换和业务逻辑能够进行单元测试。
查看获取和传递单个 Album 到 API 端点的 Supervisor 方法,我们可以看到将 API 前端连接到数据访问的逻辑注入到了 Supervisor 中,而仍然保持每个 Album 是独立的。
public async Task GetAlbumByIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
var albumViewModel = AlbumCoverter.Convert(await _albumRepository.GetByIdAsync(id, ct));
albumViewModel.Artist = await GetArtistByIdAsync(albumViewModel.ArtistId, ct);
albumViewModel.Tracks = await GetTrackByAlbumIdAsync(albumViewModel.AlbumId, ct);
albumViewModel.ArtistName = albumViewModel.Artist.Name;
return albumViewModel;
}
在领域项目中维护大部分代码和逻辑会使每个项目保持并遵守单一职责原则。
数据层
我们将看到的 API 体系结构的下一层是数据层。在我们所示例的解决方案中,使用的是 Entity Framework Core 2.0。这意味着我们不仅拥有已定义的 Entity Framework Core 的 DBContext,还有为 SQL 数据库中的每个实体生成的数据模型。如果我们以专辑实体的数据模型为例来看,会发现在数据库中存有三个属性,还有包含一组与专辑相关的歌曲,以及艺术家对象的相关属性。
虽然您可以拥有大量的数据层实现,但请记住,它必须遵守在领域层上记录的要求 ; 每个数据层实现必须与领域层中详细的视图模型和存储库接口一起工作。我们为 API 开发的体系结构使用仓储模式将 API 层连接到数据层。使用依赖注入 (正如我们前面讨论过的) 对我们实现的每个存储库对象进行了处理。我们将讨论在着眼于 API 层时如何使用依赖项注入和代码。数据层的关键是使用领域层中开发的接口实现每个实体存储库。以领域层的专辑存储库为例,它就是实现了 IAlbumRepository 接口。每个存储库都将注入 DBContext,允许使用实体框架核心访问 SQL 数据库。
public class AlbumRepository : IAlbumRepository
{
private readonly ChinookContext _context;
public AlbumRepository(ChinookContext context)
{
_context = context;
}
private async Task AlbumExists(int id, CancellationToken ct = default(CancellationToken))
{
return await GetByIdAsync(id, ct) != null;
}
public void Dispose()
{
_context.Dispose();
}
public async Task> GetAllAsync(CancellationToken ct = default(CancellationToken))
{
return await _context.Album.ToListAsync(ct);
}
public async Task GetByIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
return await _context.Album.FindAsync(id);
}
public async Task AddAsync(Album newAlbum, CancellationToken ct = default(CancellationToken))
{
_context.Album.Add(newAlbum);
await _context.SaveChangesAsync(ct);
return newAlbum;
}
public async Task UpdateAsync(Album album, CancellationToken ct = default(CancellationToken))
{
if (!await AlbumExists(album.AlbumId, ct))
return false;
_context.Album.Update(album);
_context.Update(album);
await _context.SaveChangesAsync(ct);
return true;
}
public async Task DeleteAsync(int id, CancellationToken ct = default(CancellationToken))
{
if (!await AlbumExists(id, ct))
return false;
var toRemove = _context.Album.Find(id);
_context.Album.Remove(toRemove);
await _context.SaveChangesAsync(ct);
return true;
}
public async Task> GetByArtistIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
return await _context.Album.Where(a => a.ArtistId == id).ToListAsync(ct);
}
}
拥有封装所有数据访问的数据层将有助于更好地测试 API。我们可以构建多个数据访问实现: 一个用于 SQL 数据库存储,另一个可以用于云 NoSQL 存储模式,最后一个用于解决方案中的单元测试的模拟存储实现。
API 层
我们将看到的最后一层是您的 API 使用者将发生交互的区域。这一层包含 Web API 端点逻辑的代码,包括控制器。这个解决方案的 API 项目将有一个单独的职责,那就是处理 web 服务器接收到的 HTTP 请求并返回 HTTP 响应,无论成功还是失败。在这个项目中,将会有非常少的业务逻辑。我们将处理在领域或数据项目中发生的异常和错误,以有效地与 API 的使用者进行通信。此通信将使用 HTTP 响应代码和在 HTTP 响应报文中返回的任何数据。
在 ASP.NET Core 2.0 Web API,路由是使用 Routing 属性来处理的。如果您需要了解更多关于 ASP.NET Core 中 Routing 属性的内容,请移步此处。我们还使用依赖项注入将Supervisor 分配给每个控制器。每个控制器的操作方法都有一个相应的Supervisor 方法,用于处理API 调用的逻辑。下面我有一个Album 控制器的片段来展示这些概念。
[Route("api/[controller]")]
public class AlbumController : Controller
{
private readonly IChinookSupervisor _chinookSupervisor;
public AlbumController(IChinookSupervisor chinookSupervisor)
{
_chinookSupervisor = chinookSupervisor;
}
[HttpGet]
[Produces(typeof(List))]
public async Task Get(CancellationToken ct = default(CancellationToken))
{
try
{
return new ObjectResult(await _chinookSupervisor.GetAllAlbumAsync(ct));
}
catch (Exception ex)
{
return StatusCode(500, ex);
}
}
...
}
这个解决方案的 Web API 项目非常简略。我努力让这个解决方案中的代码尽可能的少,因为将来它可以被另一种交互形式所替代。
正如我所展示的,设计和开发一个伟大的 ASP.NET Core 2.0 Web API 解决方案具有洞察力,以便拥有一个解耦的体系结构,该体系结构将允许每个层都是可测试的,并遵循单一的职责原则。我希望我的信息将允许您创建和维护您的产品 Web api,以满足您的组织的需要。
查看英文原文: Advanced Architecture for ASP.NET Core Web API