面向企业的 Entity Framework Core 2
面向企业的 Entity Framework Core 2
引言
设计和实现企业应用程序架构是一个巨大的挑战,这一点上有一个常见的问题:根据我们公司选择的技术,遵循最佳实践的最佳方式是什么。
本指南使用 .NET Core
,所以我们将使用 Entity Framework Core
,但这些概念也适用于其他技术,如 Dapper
或其他 ORM。
事实上,我们将在本文中探讨设计企业架构的常见要求。
本指南中提供的示例数据库代表了一个在线商店。
这是此解决方案的技术栈
Entity Framework Core
ASP.NET Core
- 用于单元测试的
xUnit
- 用于集成测试的
xUnit
Identity Server 4
支付网关
在线商店需要通过支付服务接收付款,为此我创建了一个名为 Rothschild House
的支付网关,您可以在此链接中找到源代码。
Rothschild House
允许使用信用卡接收付款。
运行支付网关
运行 RothschildHouse
解决方案,该解决方案包含以下项目
项目 | 运行在端口 |
---|---|
RothschildHouse | 18000 |
RothschildHouse.IdentityServer | 19000 |
以上输出是 Identity Server 的配置,RothschildHouse.IdentityServer
提供身份验证和授权以接收付款。
在线商店的凭据
用户名 | 密码 |
---|---|
administrator@onlinestore.com | onlinestore1 |
Rothschild House
提供给客户端。支付 Schema
RothschildHouse
项目包含 PaymentDbContext
类,此此类提供了处理支付的模型。
实体
人员
信用卡
支付方式
支付交易
支付数据
PaymentDbContext
接受这些客户
用户名 | 密码 | 持卡人姓名 | 发卡网络 | 卡号 | 有效期 | CVV |
---|---|---|---|---|---|---|
jameslogan@walla.com | wolverine | James Logan | Visa | 4024007164051145 | 6/1/2024 | 987 |
ororo_munroe@yahoo.com | storm | Ororo Munroe | MasterCard | 5473913699329307 | 1/1/2023 | 147 |
背景
根据我的经验,企业应用程序应具有以下层次
- 数据库:是关系型数据库管理系统
- 通用:包含各层通用对象(例如:日志器、映射器、扩展)
- 核心:包含与业务逻辑和数据库访问相关的对象
- 测试:包含后端测试(单元和集成)
- 外部服务(可选):包含对外部服务的调用(ASMX、WCF、RESTful)
- 安全:提供身份验证和授权的 API
- 展现:是用户界面
架构:大图
数据库 | SQL Server | 数据库 |
通用 | 扩展、帮助器(日志器和映射器) | 后端 |
核心 | 服务、异常、DbContext、实体、配置和数据契约 | 后端 |
测试 | 单元测试和集成测试 | 后端 |
外部服务 | ASMX、WCF、RESTful | 后端 |
安全 | 身份验证和授权(Identity Server | 其他) | 后端 |
展现 | UI 框架(Angular | ReactJS | Vue.js | 其他) | 前端 |
必备组件
技能
在继续之前,请记住我们需要具备以下技能才能理解本指南
- 面向对象编程
- 面向切面编程
- 对象关系映射
- 设计模式
软件
- .NET Core
- Visual Studio 2017
- SQL Server 实例(本地或远程)
- SQL Server Management Studio
目录
Using the Code
第01章 - 数据库
查看示例数据库以了解架构中的每个组件。该数据库中有4个 schema
- Dbo
- 人力资源
- 销售
- 仓库
每个 schema 都代表在线商店公司的一个部门,请记住这一点,因为所有代码都是按照这个方面设计的;目前,此代码仅实现 Warehouse
和 Sales
schema 的功能。
所有表都有一个包含一列的主键,并包含用于创建、上次更新和并发令牌的列。
表格
架构 | 名称 |
---|---|
dbo | 变更日志 |
dbo | 变更日志排除 |
dbo | 国家 |
dbo | 国家货币 |
dbo | 货币 |
dbo | EventLog(事件日志) |
人力资源 | 员工 |
人力资源 | 员工地址 |
人力资源 | 员工邮箱 |
销售 | 客户 |
销售 | 订单明细 |
销售 | 订单头 |
销售 | 订单状态 |
销售 | 支付方式 |
销售 | 发货人 |
仓库 | Location |
仓库 | 产品 |
仓库 | 产品类别 |
仓库 | 产品库存 |
您可以在此链接找到数据库脚本:GitHub 上的在线商店数据库脚本。
第02章 - 核心项目
核心项目代表解决方案的核心,本指南中的核心项目包括领域和业务逻辑。
在线商店使用 .NET Core
,命名约定是 .NET
命名约定,因此定义一个命名约定表来显示如何在代码中设置名称非常有用,如下所示
标识符 | 情况 | 示例 |
---|---|---|
命名空间 | 帕斯卡命名法 | 商店 |
类 | 帕斯卡命名法 | 产品 |
接口 | I 前缀 + 帕斯卡命名法 | ISalesRepository |
方法 | 动词采用帕斯卡命名法 + 名词采用帕斯卡命名法 | 获取产品 |
异步方法 | 动词采用帕斯卡命名法 + 名词采用帕斯卡命名法 + Async 后缀 | 获取订单异步 |
属性 | 帕斯卡命名法 | 描述 |
参数 | 驼峰命名法 | 连接字符串 |
此约定很重要,因为它定义了架构的命名指南。
这是 OnlineStore.Core
项目的结构
定义域
领域
\配置
领域
\数据契约
商用版
业务
\契约
业务
\响应
在 Domain
内部,我们将放置所有实体,在此上下文中,实体表示数据库中的表或视图的类,有时实体被称为 POCO(Plain Old Common language runtime Object),这意味着一个只包含属性而不包含方法或其他内容(事件)的类;根据 wkempf 的反馈,有必要明确 POCO,POCO 可以有方法、事件和其他成员,但通常不会在 POCO 中添加这些成员。
对于 Domain
\Configurations
,存在与数据库映射类相关的对象定义。
在 Domain
和 Domain
\Configurations
中,每个 schema 都有一个目录。
在 Business
内部,有服务的接口和实现,在这种情况下,服务将根据用例(或类似用例)包含方法,这些方法必须执行验证并处理与业务相关的异常。
对于 Business\Responses
,这些是响应定义:单一、列表和分页,以表示服务的结果。
我们将检查代码以理解这些概念,但由于其余代码类似,我们将逐层审查一个对象。
定义域
请注意 POCOs,我们使用可空类型而不是原生类型,因为可空类型更容易评估属性是否有值,这与数据库模型更相似。
在 Domain
中有两个接口:IEntity
和 IAuditEntity
,IEntity
代表我们应用程序中的所有实体,IAuditEntity
代表所有允许保存审计信息(创建和上次更新)的实体;特别指出,如果视图有映射,这些类不实现 IAuditEntity
,因为视图不允许插入、更新和删除操作。
OrderHeader
类
using System;
using System.Collections.ObjectModel;
using OnlineStore.Core.Domain.Dbo;
using OnlineStore.Core.Domain.HumanResources;
namespace OnlineStore.Core.Domain.Sales
{
public class OrderHeader : IAuditableEntity
{
public OrderHeader()
{
}
public OrderHeader(long? id)
{
ID = id;
}
public long? ID { get; set; }
public short? OrderStatusID { get; set; }
public DateTime? OrderDate { get; set; }
public int? CustomerID { get; set; }
public int? EmployeeID { get; set; }
public int? ShipperID { get; set; }
public decimal? Total { get; set; }
public string CurrencyID { get; set; }
public Guid? PaymentMethodID { get; set; }
public int? DetailsCount { get; set; }
public long? ReferenceOrderID { get; set; }
public string Comments { get; set; }
public string CreationUser { get; set; }
public DateTime? CreationDateTime { get; set; }
public string LastUpdateUser { get; set; }
public DateTime? LastUpdateDateTime { get; set; }
public byte[] Timestamp { get; set; }
public virtual OrderStatus OrderStatusFk { get; set; }
public virtual Customer CustomerFk { get; set; }
public virtual Employee EmployeeFk { get; set; }
public virtual Shipper ShipperFk { get; set; }
public virtual Currency CurrencyFk { get; set; }
public virtual PaymentMethod PaymentMethodFk { get; set; }
public virtual Collection<OrderDetail> OrderDetails { get; set; }
}
}
密钥
实体层还有一个重要方面,所有允许创建操作的实体都将键属性设置为可空类型,这与软件开发理念有关。
让我们来看一下,我们将以 Product
实体为例
public class Product
{
public int? ID { get; set; }
public string Name { get; set; }
}
让我们创建一个 Product
类的实例
var entity = new Product(); // ID property has null value instead of 0
现在让我们在数据库中创建一个新的 Product
using (var ctx = new OnlineStore())
{
// Create an instance of Product class
// The ID property will be initialized with null
// This is logic because the entity is not saved in database yet
var newProduct = new Product
{
Name = "Product sample"
};
// Add instance of product in memory
ctx.Products.Add(newProduct);
// Save all changes in database
await ctx.SaveChangesAsync();
// Now the ID property has the generated value from database side
//
// To validate if property has value We can peform a check like this:
//
// if (newProduct.ID.HasValue)
// {
// // Get value from property and use it...
// }
}
所以,现在将 ID 属性的类型从 int?
更改为 int
,属性值将初始化为 0,0 是一个整数,请考虑在某些情况下 ID 可以为负数,因此仅验证属性值是否大于零是不够的。
这个解释有意义吗?请在评论中告诉我 :)
数据层
此解决方案不再使用存储库模式,稍后我将添加解释原因。
本指南中我们使用 Entity Framework Core
,因此我们需要一个 DbContext
和允许映射数据库对象(如表和视图)的对象。
命名问题
存储库 vs DbHelper vs 数据访问对象。
这个问题与对象命名有关,几年前我使用 DataAccessObject
作为包含数据库操作(选择、插入、更新、删除等)的类的后缀。其他开发人员使用 DbHelper
作为后缀来表示这类对象,在我初学 EF 时,我了解了存储库设计模式,所以从我的角度来看,我更喜欢使用 Repository
后缀来命名包含数据库操作的对象。
OnlineStoreDbContext
类
using Microsoft.EntityFrameworkCore;
using OnlineStore.Core.Domain.Configurations;
using OnlineStore.Core.Domain.Configurations.Dbo;
using OnlineStore.Core.Domain.Configurations.HumanResources;
using OnlineStore.Core.Domain.Configurations.Sales;
using OnlineStore.Core.Domain.Configurations.Warehouse;
using OnlineStore.Core.Domain.Dbo;
using OnlineStore.Core.Domain.HumanResources;
using OnlineStore.Core.Domain.Sales;
using OnlineStore.Core.Domain.Warehouse;
namespace OnlineStore.Core.Domain
{
public class OnlineStoreDbContext : DbContext
{
public OnlineStoreDbContext(DbContextOptions<OnlineStoreDbContext> options)
: base(options)
{
}
public DbSet<ChangeLog> ChangeLogs { get; set; }
public DbSet<ChangeLogExclusion> ChangeLogExclusions { get; set; }
public DbSet<CountryCurrency> CountryCurrencies { get; set; }
public DbSet<Country> Countries { get; set; }
public DbSet<Currency> Currencies { get; set; }
public DbSet<EventLog> EventLogs { get; set; }
public DbSet<Employee> Employees { get; set; }
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }
public DbSet<EmployeeEmail> EmployeeEmails { get; set; }
public DbSet<ProductCategory> ProductCategories { get; set; }
public DbSet<ProductInventory> ProductInventories { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<ProductUnitPriceHistory> ProductUnitPriceHistories { get; set; }
public DbSet<Location> Locations { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<OrderDetail> OrderDetails { get; set; }
public DbSet<OrderHeader> OrderHeaders { get; set; }
public DbSet<OrderStatus> OrderStatuses { get; set; }
public DbSet<OrderSummary> OrderSummaries { get; set; }
public DbSet<PaymentMethod> PaymentMethods { get; set; }
public DbSet<Shipper> Shippers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all configurations
modelBuilder
.ApplyConfiguration(new ChangeLogConfiguration())
.ApplyConfiguration(new ChangeLogExclusionConfiguration())
.ApplyConfiguration(new CountryCurrencyConfiguration())
.ApplyConfiguration(new CountryConfiguration())
.ApplyConfiguration(new CurrencyConfiguration())
.ApplyConfiguration(new EventLogConfiguration())
;
modelBuilder
.ApplyConfiguration(new EmployeeConfiguration())
.ApplyConfiguration(new EmployeeAddressConfiguration())
.ApplyConfiguration(new EmployeeEmailConfiguration())
;
modelBuilder
.ApplyConfiguration(new ProductCategoryConfiguration())
.ApplyConfiguration(new ProductConfiguration())
.ApplyConfiguration(new ProductUnitPriceHistoryConfiguration())
.ApplyConfiguration(new ProductInventoryConfiguration())
.ApplyConfiguration(new LocationConfiguration())
;
modelBuilder
.ApplyConfiguration(new CustomerConfiguration())
.ApplyConfiguration(new OrderDetailConfiguration())
.ApplyConfiguration(new OrderHeaderConfiguration())
.ApplyConfiguration(new OrderStatusConfiguration())
.ApplyConfiguration(new OrderSummaryConfiguration())
.ApplyConfiguration(new PaymentMethodConfiguration())
.ApplyConfiguration(new ShipperConfiguration())
;
base.OnModelCreating(modelBuilder);
}
}
}
OrderHeaderConfiguration
类
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using OnlineStore.Core.Domain.Sales;
namespace OnlineStore.Core.Domain.Configurations.Sales
{
public class OrderHeaderConfiguration : IEntityTypeConfiguration<OrderHeader>
{
public void Configure(EntityTypeBuilder<OrderHeader> builder)
{
// Mapping for table
builder.ToTable("OrderHeader", "Sales");
// Set key for entity
builder.HasKey(p => p.ID);
// Set identity for entity (auto increment)
builder.Property(p => p.ID).UseSqlServerIdentityColumn();
// Set mapping for columns
builder.Property(p => p.OrderStatusID).HasColumnType("smallint").IsRequired();
builder.Property(p => p.OrderDate).HasColumnType("datetime").IsRequired();
builder.Property(p => p.CustomerID).HasColumnType("int").IsRequired();
builder.Property(p => p.EmployeeID).HasColumnType("int");
builder.Property(p => p.ShipperID).HasColumnType("int");
builder.Property(p => p.Total).HasColumnType("decimal(12, 4)").IsRequired();
builder.Property(p => p.CurrencyID).HasColumnType("varchar(10)");
builder.Property(p => p.PaymentMethodID).HasColumnType("uniqueidentifier");
builder.Property(p => p.DetailsCount).HasColumnType("int").IsRequired();
builder.Property(p => p.ReferenceOrderID).HasColumnType("bigint");
builder.Property(p => p.Comments).HasColumnType("varchar(max)");
builder.Property(p => p.CreationUser).HasColumnType("varchar(25)").IsRequired();
builder.Property(p => p.CreationDateTime).HasColumnType("datetime").IsRequired();
builder.Property(p => p.LastUpdateUser).HasColumnType("varchar(25)");
builder.Property(p => p.LastUpdateDateTime).HasColumnType("datetime");
// Set concurrency token for entity
builder.Property(p => p.Timestamp).ValueGeneratedOnAddOrUpdate().IsConcurrencyToken();
// Add configuration for foreign keys
builder
.HasOne(p => p.OrderStatusFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.OrderStatusID);
builder
.HasOne(p => p.CustomerFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.CustomerID);
builder
.HasOne(p => p.ShipperFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.ShipperID);
}
}
}
关于工作单元?在 EF 6.x 中,通常会创建一个存储库类和工作单元类:存储库提供数据库访问操作,工作单元提供保存数据库更改的操作;但在 EF Core 中,通常只使用存储库,而没有工作单元;无论如何,对于此代码,我们在 Repository
类中添加了两个方法:CommitChanges
和 CommitChangesAsync
,因此只需确保所有数据写入方法都调用 CommitChanges
或 CommitChangesAsync
,通过这种设计,我们的架构中就有两个定义在工作。
关于异步操作?在本文的早期版本中,我说我们将在最后一层:REST API 中实现异步操作,但我错了,因为 .NET Core 更多地关注异步编程,所以最好的决定是使用 Entity Framework Core
提供的异步方法以异步方式处理所有数据库操作。
对于本文的最新版本,我们有一个名为 Rothschild House 的支付网关,此 API 提供支付授权,此 API 属于外部服务层。
稍后,我将添加一个部分来解释支付网关。
存储过程与 LINQ 查询
在数据层,有一个非常有趣的点:我们如何使用存储过程?对于当前版本的 EF Core,它不支持存储过程,所以我们无法以原生方式使用它们。在 DbSet
内部,有一个方法可以执行查询,但它适用于不返回结果集(列)的存储过程。我们可以添加一些扩展方法并添加包来使用经典的 ADO.NET,在这种情况下,我们需要处理对象的动态创建以表示存储过程的结果;这有意义吗?如果我们调用一个名为 GetOrdersByMonth
的过程,并且该过程返回一个包含 7 列的 select 语句,为了以相同的方式处理所有结果,我们需要定义对象来表示这些结果,这些对象必须根据我们的命名约定定义在 DataLayer
\DataContracts
命名空间内。
在企业环境中,一个常见的讨论是关于 LINQ 查询或存储过程。根据我的经验,我认为解决这个问题的最佳方法是:与架构师和数据库管理员一起审查设计约定;如今,异步模式下的 LINQ 查询比存储过程更常用,但有时一些公司有严格的约定,不允许使用 LINQ 查询,因此需要使用存储过程,我们需要使我们的架构灵活,因为我们不能对开发经理说“业务逻辑需要重写,因为 Entity Framework Core
不允许调用存储过程”。
正如我们目前所见,假设我们拥有用于 EF Core 调用存储过程的扩展方法和用于表示存储过程调用结果的数据契约,我们应该将这些方法放在哪里?最好使用相同的约定,因此我们将这些方法添加到契约和存储库中;为了明确起见,如果我们有名为 Sales.GetCustomerOrdersHistory
和 HumanResources.DisableEmployee
的过程;我们必须将方法放在 Sales
和 HumanResources
存储库中。
您还可以通过此链接阅读更多关于此点的文章。
需要明确的是:远离存储过程!
前面的概念以同样的方式适用于数据库中的视图。此外,我们只需要检查存储库不允许对视图进行添加、更新和删除操作。
变更追踪
在 OnLineStoreDbContextExtensions
类中,有一个名为 GetChanges
的方法,该方法通过 ChangeTracker 从 DbContext 获取所有更改,并返回所有更改,因此这些值会保存在 CommitChanges
方法中的 ChangeLog
表中。您可以使用业务对象更新现有实体,之后您可以检查您的 ChangeLog
表。
ChangeLogID ClassName PropertyName Key OriginalValue CurrentValue UserName ChangeDate ----------- ------------ -------------- ---- ---------------------- ---------------------- ---------- ----------------------- 1 Employee FirstName 1 John John III admin 2017-02-19 21:49:51.347 2 Employee MiddleName 1 Smith III admin 2017-02-19 21:49:51.347 3 Employee LastName 1 Doe Doe III admin 2017-02-19 21:49:51.347 (3 row(s) affected)
正如我们所看到的,实体中所有更改都将保存在此表中,作为未来的改进,我们需要为此变更日志添加排除项。在本指南中,我们正在使用 SQL Server,据我所知,有一种方法可以在数据库端启用变更跟踪,但在这篇文章中,我将向您展示如何从后端实现此功能;此功能是在后端还是数据库端将由您的领导决定。在时间轴中,我们可以检查此表中实体的所有更改,一些实体具有审计属性,但这些属性仅反映创建和上次更新的用户和日期,而不提供有关数据如何更改的完整详细信息。
商用版
命名问题
控制器 vs 服务 vs 业务对象
在这一点上有一个常见问题,我们应该如何命名表示业务操作的对象:在本文的早期版本中,我将此对象命名为 BusinessObject
,这可能会让一些开发人员感到困惑,一些开发人员不将其命名为业务对象,因为 Web API 中的控制器表示业务逻辑,但 Service
是开发人员使用的另一个名称,所以从我的角度来看,使用 Service
作为此对象的后缀更清晰。如果我们有一个在控制器中实现业务逻辑的 Web API,我们可以省略服务,但如果存在业务层,拥有服务会更有用,这些类必须实现逻辑业务,控制器必须调用服务的方法。
业务层:处理与业务相关的方面
- 日志记录:我们需要有一个日志记录对象,这意味着一个将所有事件记录到文本文件、数据库、电子邮件等中的对象。我们可以创建自己的日志记录实现,也可以选择现有的日志。我们已经使用
Microsoft.Extensions.Logging
包添加了日志记录,通过这种方式,我们正在使用 .NET Core 中的默认日志系统,我们可以使用其他日志机制,但目前我们将使用此日志记录器。在控制器和业务对象中的每个方法中,都有一行代码,例如:Logger?.LogInformation("{0} has been invoked", nameof(GetOrdersAsync));
,通过这种方式,我们确保在实例有效时调用日志记录器,并使用nameof
运算符检索成员名称,而无需使用魔术字符串,之后我们将添加代码将所有日志保存到数据库中。 - 业务异常:处理用户消息的最佳方式是使用自定义异常,在业务层内部,我们将为异常添加定义,以表示架构中的所有处理错误。
- 事务:正如我们在
Sales
业务对象中看到的那样,我们已经实现了事务来处理数据库中的多重更改;在CreateOrderAsync
方法内部,我们调用了存储库中的方法,在存储库内部我们没有任何事务,因为服务负责事务过程,我们还添加了逻辑来处理与业务相关的异常,并带有自定义消息,因为我们需要向最终用户提供友好的消息。 - 有一个
CloneOrderAsync
方法,该方法提供了现有订单的副本,这是 ERP 中的常见要求,因为创建新订单但进行一些修改比创建整个订单更容易,有些情况下销售代理会创建新订单,但会删除 1 或 2 行明细或添加 1 或 2 个明细,无论如何,永远不要让前端开发人员在 UI 中添加此逻辑,API 必须提供此功能。 SalesService
中的GetPostOrderModelAsync
方法提供了创建订单所需的信息,包括外键信息:产品和其他信息。通过此方法,我们提供了一个包含外键列表的模型,从而减少了前端了解如何创建订单操作的工作量。
服务
类
using Microsoft.Extensions.Logging;
using OnlineStore.Core.Business.Contracts;
using OnlineStore.Core.Domain;
namespace OnlineStore.Core.Business
{
public abstract class Service : IService
{
protected bool Disposed;
protected readonly ILogger Logger;
public Service(ILogger logger, OnlineStoreDbContext dbContext, IUserInfo userInfo)
{
Logger = logger;
DbContext = dbContext;
UserInfo = userInfo;
}
public void Dispose()
{
if (Disposed)
return;
DbContext?.Dispose();
Disposed = true;
}
public OnlineStoreDbContext DbContext { get; }
public IUserInfo UserInfo { get; set; }
}
}
SalesService
类
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OnlineStore.Core.Business.Contracts;
using OnlineStore.Core.Business.Requests;
using OnlineStore.Core.Business.Responses;
using OnlineStore.Core.Domain;
using OnlineStore.Core.Domain.Dbo;
using OnlineStore.Core.Domain.Repositories;
using OnlineStore.Core.Domain.Sales;
using OnlineStore.Core.Domain.Warehouse;
namespace OnlineStore.Core.Business
{
public class SalesService : Service, ISalesService
{
public SalesService(ILogger<SalesService> logger, OnlineStoreDbContext dbContext, IUserInfo userInfo)
: base(logger, dbContext, userInfo)
{
}
public async Task<IPagedResponse<Customer>> GetCustomersAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetCustomersAsync));
var response = new PagedResponse<Customer>();
try
{
// Get query
var query = DbContext.Customers;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCustomersAsync), ex);
}
return response;
}
public async Task<IPagedResponse<Shipper>> GetShippersAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetShippersAsync));
var response = new PagedResponse<Shipper>();
try
{
// Get query
var query = DbContext.Shippers;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetShippersAsync), ex);
}
return response;
}
public async Task<IPagedResponse<Currency>> GetCurrenciesAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetCurrenciesAsync));
var response = new PagedResponse<Currency>();
try
{
// Get query
var query = DbContext.Currencies;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCurrenciesAsync), ex);
}
return response;
}
public async Task<IPagedResponse<PaymentMethod>> GetPaymentMethodsAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetPaymentMethodsAsync));
var response = new PagedResponse<PaymentMethod>();
try
{
// Get query
var query = DbContext.PaymentMethods;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetPaymentMethodsAsync), ex);
}
return response;
}
public async Task<IPagedResponse<OrderInfo>> GetOrdersAsync(int? pageSize, int? pageNumber, short? orderStatusID, int? customerID, int? employeeID, int? shipperID, string currencyID, Guid? paymentMethodID)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetOrdersAsync));
var response = new PagedResponse<OrderInfo>();
try
{
// Get query
var query = DbContext
.GetOrders(orderStatusID, customerID, employeeID, shipperID, currencyID, paymentMethodID);
// Set information for paging
response.PageSize = (int)pageSize;
response.PageNumber = (int)pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging((int)pageSize, (int)pageNumber)
.ToListAsync();
response.Message = string.Format("Page {0} of {1}, Total of rows: {2}", response.PageNumber, response.PageCount, response.ItemsCount);
Logger?.LogInformation(response.Message);
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetOrdersAsync), ex);
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> GetOrderAsync(long id)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetOrderAsync));
var response = new SingleResponse<OrderHeader>();
try
{
// Retrieve order by id
response.Model = await DbContext.GetOrderAsync(new OrderHeader(id));
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetOrderAsync), ex);
}
return response;
}
public async Task<ISingleResponse<CreateOrderRequest>> GetCreateOrderRequestAsync()
{
Logger?.LogDebug("'{0}' has been invoked", nameof(GetCreateOrderRequestAsync));
var response = new SingleResponse<CreateOrderRequest>();
try
{
// Retrieve products list
response.Model.Products = await DbContext.GetProducts().ToListAsync();
// Retrieve customers list
response.Model.Customers = await DbContext.Customers.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCreateOrderRequestAsync), ex);
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> CreateOrderAsync(OrderHeader header, OrderDetail[] details)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(CreateOrderAsync));
var response = new SingleResponse<OrderHeader>();
// Begin transaction
using (var transaction = await DbContext.Database.BeginTransactionAsync())
{
try
{
// todo: Retrieve available warehouse to dispatch products
var warehouses = await DbContext.Locations.ToListAsync();
foreach (var detail in details)
{
// Retrieve product by id
var product = await DbContext.GetProductAsync(new Product(detail.ProductID));
// Throw exception if product no exists
if (product == null)
throw new NonExistingProductException(string.Format(SalesDisplays.NonExistingProductExceptionMessage, detail.ProductID));
// Throw exception if product is discontinued
if (product.Discontinued == true)
throw new AddOrderWithDiscontinuedProductException(string.Format(SalesDisplays.AddOrderWithDiscontinuedProductExceptionMessage, product.ID));
// Throw exception if quantity for product is invalid
if (detail.Quantity <= 0)
throw new InvalidQuantityException(string.Format(SalesDisplays.InvalidQuantityExceptionMessage, product.ID));
// Set values for detail
detail.ProductName = product.ProductName;
detail.UnitPrice = product.UnitPrice;
detail.Total = product.UnitPrice * detail.Quantity;
}
// Set default values for order header
if (!header.OrderDate.HasValue)
header.OrderDate = DateTime.Now;
header.OrderStatusID = 100;
// Calculate total for order header from order's details
header.Total = details.Sum(item => item.Total);
header.DetailsCount = details.Count();
// Save order header
DbContext.Add(header, UserInfo);
await DbContext.SaveChangesAsync();
foreach (var detail in details)
{
// Set order id for order detail
detail.OrderHeaderID = header.ID;
detail.CreationUser = header.CreationUser;
// Add order detail
DbContext.Add(detail, UserInfo);
await DbContext.SaveChangesAsync();
// Create product inventory instance
var productInventory = new ProductInventory
{
ProductID = detail.ProductID,
LocationID = warehouses.First().ID,
OrderDetailID = detail.ID,
Quantity = detail.Quantity * -1,
CreationUser = header.CreationUser,
CreationDateTime = DateTime.Now
};
// Save product inventory
DbContext.Add(productInventory);
}
await DbContext.SaveChangesAsync();
response.Model = header;
// Commit transaction
transaction.Commit();
Logger.LogInformation(SalesDisplays.CreateOrderMessage);
}
catch (Exception ex)
{
response.SetError(Logger, nameof(CreateOrderAsync), ex);
}
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> CloneOrderAsync(long id)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(CloneOrderAsync));
var response = new SingleResponse<OrderHeader>();
try
{
// Retrieve order by id
var entity = await DbContext.GetOrderAsync(new OrderHeader(id));
if (entity == null)
return response;
// Create a new instance for order and set values from existing order
response.Model = new OrderHeader
{
ID = entity.ID,
OrderDate = entity.OrderDate,
CustomerID = entity.CustomerID,
EmployeeID = entity.EmployeeID,
ShipperID = entity.ShipperID,
Total = entity.Total,
Comments = entity.Comments
};
if (entity.OrderDetails?.Count > 0)
{
response.Model.OrderDetails = new Collection<OrderDetail>();
foreach (var detail in entity.OrderDetails)
{
// Add order detail clone to collection
response.Model.OrderDetails.Add(new OrderDetail
{
ProductID = detail.ProductID,
ProductName = detail.ProductName,
UnitPrice = detail.UnitPrice,
Quantity = detail.Quantity,
Total = detail.Total
});
}
}
}
catch (Exception ex)
{
response.SetError(Logger, nameof(CloneOrderAsync), ex);
}
return response;
}
public async Task<IResponse> CancelOrderAsync(long id)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(CancelOrderAsync));
var response = new Response();
try
{
// Retrieve order by id
var entity = await DbContext.GetOrderAsync(new OrderHeader(id));
if (entity == null)
return response;
// Restrict remove operation for orders with details
if (entity.OrderDetails.Count > 0)
throw new ForeignKeyDependencyException(string.Format(SalesDisplays.RemoveOrderExceptionMessage, id));
// Delete order
DbContext.Remove(entity);
await DbContext.SaveChangesAsync();
Logger?.LogInformation(SalesDisplays.DeleteOrderMessage);
}
catch (Exception ex)
{
response.SetError(Logger, nameof(CancelOrderAsync), ex);
}
return response;
}
}
}
在 Business
中,最好使用自定义异常来表示错误,而不是向客户端发送简单的字符串消息,显然自定义异常必须包含一条消息,但在日志记录器中将有一个关于自定义异常的引用。对于此架构,以下是自定义异常
名称 | 描述 |
---|---|
添加带有停产产品的订单异常 | 表示添加带有停产产品的订单的异常 |
外键依赖异常 | 表示删除带有明细行的订单的异常 |
重复产品名称异常 | 表示添加具有现有名称的产品的异常 |
不存在产品异常 | 表示添加不存在产品的订单的异常 |
第03章 - 将所有代码整合
我们需要创建一个 OnlineStoreDbContext
实例,该实例与 SQL Server 配合使用,在 OnModelCreating
方法中,所有配置都应用于 ModelBuilder
实例。
之后,将创建一个 SalesService
实例,并使用 OnlineStoreDbContext
的有效实例来访问服务操作。
获取所有
这是一个检索订单的例子
// Create logger instance
var logger = LoggingHelper.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo();
// Create options for DbContext
var options = new DbContextOptionsBuilder<OnlineStoreDbContext>()
.UseSqlServer("YourConnectionStringHere")
.Options;
// Create instance of business object
// Set logger, application user and context for database
using (var service = new SalesService(logger, userInfo, new OnlineStoreDbContext(options)))
{
// Declare parameters and set values for paging
var pageSize = 10;
var pageNumber = 1;
// Get response from business object
var response = await service.GetOrderHeadersAsync(pageSize, pageNumber);
// Validate if there was an error
var valid = !response.DidError;
}
SalesService
中的 GetOrderHeadersAsync
方法将 Sales.OrderHeader
表中的行检索为通用列表。
按键获取
这是一个按键检索实体的例子
// Create logger instance
var logger = LoggingHelper.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo();
// Create options for DbContext
var options = new DbContextOptionsBuilder<OnlineStoreDbContext>()
.UseSqlServer("YourConnectionStringHere")
.Options;
// Create instance of business object
// Set logger, application user and context for database
using (var service = new SalesService(logger, userInfo, new OnlineStoreDbContext(options)))
{
// Declare parameters and set values for paging
var id = 1;
// Get response from business object
var response = await service.GetOrderHeaderAsync(id);
// Validate if there was an error
var valid = !response.DidError;
// Get entity
var entity = response.Model;
}
在本文的未来版本中,将提供其他操作的示例。
第04章 - Mocker
Mocker
是一个项目,它允许在指定日期范围内在 Sales.OrderHeader
、Sales.OrderDetail
和 Warehouse.ProductInventory
表中创建行,默认情况下 Mocker
会创建一年的行。
程序
类
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OnlineStore.Common.Helpers;
using OnlineStore.Core.Domain.Sales;
namespace OnlineStore.Mocker
{
public class Program
{
static readonly ILogger Logger;
static Program()
{
Logger = LoggingHelper.GetLogger<Program>();
}
public static void Main(string[] args)
=> MainAsync(args).GetAwaiter().GetResult();
static async Task MainAsync(string[] args)
{
var year = DateTime.Now.AddYears(-1).Year;
var ordersLimitPerDay = 3;
foreach (var arg in args)
{
if (arg.StartsWith("/year:"))
year = Convert.ToInt32(arg.Replace("/year:", string.Empty));
else if (arg.StartsWith("/ordersLimitPerDay:"))
ordersLimitPerDay = Convert.ToInt32(arg.Replace("/ordersLimitPerDay:", string.Empty));
}
var start = new DateTime(year, 1, 1);
var end = new DateTime(year, 12, DateTime.DaysInMonth(year, 12));
if (start.DayOfWeek == DayOfWeek.Sunday)
start = start.AddDays(1);
do
{
if (start.DayOfWeek != DayOfWeek.Sunday)
{
await CreateDataAsync(start, ordersLimitPerDay);
Thread.Sleep(1000);
}
start = start.AddDays(1);
}
while (start <= end);
}
static async Task CreateDataAsync(DateTime date, int ordersLimitPerDay)
{
var random = new Random();
var warehouseService = ServiceMocker.GetWarehouseService();
var salesService = ServiceMocker.GetSalesService();
var customers = (await salesService.GetCustomersAsync()).Model.ToList();
var currencies = (await salesService.GetCurrenciesAsync()).Model.ToList();
var paymentMethods = (await salesService.GetPaymentMethodsAsync()).Model.ToList();
var products = (await warehouseService.GetProductsAsync(10, 1)).Model.ToList();
Logger.LogInformation("Creating orders for {0}", date);
for (var i = 0; i < ordersLimitPerDay; i++)
{
var header = new OrderHeader
{
OrderDate = date,
CreationDateTime = date
};
var selectedCustomer = random.Next(0, customers.Count - 1);
var selectedCurrency = random.Next(0, currencies.Count - 1);
var selectedPaymentMethod = random.Next(0, paymentMethods.Count - 1);
header.CustomerID = customers[selectedCustomer].ID;
header.CurrencyID = currencies[selectedCurrency].ID;
header.PaymentMethodID = paymentMethods[selectedPaymentMethod].ID;
var details = new List<OrderDetail>();
var detailsCount = random.Next(1, 5);
for (var j = 0; j < detailsCount; j++)
{
var detail = new OrderDetail
{
ProductID = products[random.Next(0, products.Count - 1)].ID,
Quantity = (short)random.Next(1, 5)
};
if (details.Count > 0 && details.Count(item => item.ProductID == detail.ProductID) == 1)
continue;
details.Add(detail);
}
await salesService.CreateOrderAsync(header, details.ToArray());
Logger.LogInformation("Date: {0}", date);
}
warehouseService.Dispose();
salesService.Dispose();
}
}
}
现在在同一个终端窗口中,我们需要运行以下命令:dotnet run
,如果一切顺利,我们可以在数据库中检查 OrderHeader
、OrderDetail
和 ProductInventory
表的数据。
Mocker
如何工作? 设置日期范围和每日订单限制,然后迭代日期范围内的所有日期,除了星期日,因为我们假设星期日不允许创建订单;然后创建 DbContext
和 Services
实例,使用随机索引安排数据以从产品、客户、货币和支付方式中获取元素;然后调用 CreateOrderAsync
方法。
您可以根据您的要求调整日期范围和每日订单数量以模拟数据,一旦 Mocker
完成,您就可以检查数据库中的数据。
第05章 - 支付网关
支付网关将 Identity Server
作为身份验证和授权 API。
支付网关有两个项目
RothschildHouse.IdentityServer
RothschildHouse
RothschildHouse.IdentityServer
支付网关的 Identity Server API
运行在端口 18000。
在浏览器中,打开 https://:18000/.well-known/openid-configuration 网址
{
"issuer":"https://:18000",
"jwks_uri":"https://:18000/.well-known/openid-configuration/jwks",
"authorization_endpoint":"https://:18000/connect/authorize",
"token_endpoint":"https://:18000/connect/token",
"userinfo_endpoint":"https://:18000/connect/userinfo",
"end_session_endpoint":"https://:18000/connect/endsession",
"check_session_iframe":"https://:18000/connect/checksession",
"revocation_endpoint":"https://:18000/connect/revocation",
"introspection_endpoint":"https://:18000/connect/introspect",
"device_authorization_endpoint":"https://:18000/connect/deviceauthorization",
"frontchannel_logout_supported":true,
"frontchannel_logout_session_supported":true,
"backchannel_logout_supported":true,
"backchannel_logout_session_supported":true,
"scopes_supported":[
"RothschildHouseApi",
"offline_access"
],
"claims_supported":[
],
"grant_types_supported":[
"authorization_code",
"client_credentials",
"refresh_token",
"implicit",
"password",
"urn:ietf:params:oauth:grant-type:device_code"
],
"response_types_supported":[
"code",
"token",
"id_token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
],
"response_modes_supported":[
"form_post",
"query",
"fragment"
],
"token_endpoint_auth_methods_supported":[
"client_secret_basic",
"client_secret_post"
],
"subject_types_supported":[
"public"
],
"id_token_signing_alg_values_supported":[
"RS256"
],
"code_challenge_methods_supported":[
"plain",
"S256"
]
}
为了允许连接,我们需要为 API 资源和客户端添加配置,此配置位于 Config
类中。
using System.Collections.Generic;
using System.Security.Claims;
using IdentityModel;
using IdentityServer4.Models;
namespace RothschildHouse.IdentityServer
{
public static class Config
{
public static IEnumerable<ApiResource> GetApiResources()
=> new List<ApiResource>
{
new ApiResource("RothschildHouseAPI", "Rothschild House API")
};
public static IEnumerable<Client> GetClients()
=> new List<Client>
{
new Client
{
ClientId = "onlinestoreclient",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets =
{
new Secret("onlinestoreclientsecret1".Sha256())
},
AllowedScopes =
{
"RothschildHouseAPI"
},
Claims =
{
new Claim(JwtClaimTypes.Role, "Customer")
}
}
};
}
}
让我们看一下 Startup
代码
using IdentityServer4.Services;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RothschildHouse.IdentityServer.Domain;
using RothschildHouse.IdentityServer.Services;
using RothschildHouse.IdentityServer.Validation;
namespace RothschildHouse.IdentityServer
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
/* Setting up dependency injection */
// For DbContext
services.AddDbContext<AuthDbContext>(options => options.UseInMemoryDatabase("Auth"));
// Password validator and profile
services
.AddTransient<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>()
.AddTransient<IProfileService, ProfileService>();
/* Identity Server */
// Use in-memory configurations
services
.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients());
// Add authentication
services
.AddAuthentication()
.AddIdentityServerAuthentication();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
/* Seed AuthDbContext in-memory */
var authDbContext = app
.ApplicationServices
.CreateScope()
.ServiceProvider
.GetService<AuthDbContext>();
authDbContext.SeedInMemory();
app.UseIdentityServer();
}
}
}
RothschildHouse
RothschildHouse
运行在端口 19000。
Startup
类的配置
using System;
using System.IO;
using System.Reflection;
using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RothschildHouse.Controllers;
using RothschildHouse.Domain;
using Swashbuckle.AspNetCore.Swagger;
namespace RothschildHouse
{
#pragma warning disable CS1591
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
/* Setting up dependency injection */
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddTransient<ILogger<TransactionController>, Logger<TransactionController>>();
// In-memory DbContext
services.AddDbContext<PaymentDbContext>(options =>
{
options
.UseInMemoryDatabase("Payment")
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning));
});
// Identity Server
services
.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
var settings = new IdentityServerAuthenticationOptions();
Configuration.Bind("IdentityServerSettings", settings);
options.Authority = settings.Authority;
options.RequireHttpsMetadata = settings.RequireHttpsMetadata;
options.ApiName = settings.ApiName;
options.ApiSecret = settings.ApiSecret;
});
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "RothschildHouse API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.IncludeXmlComments(xmlPath);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
// Seed initial data in-memory for DbContext
var paymentDbContext = app
.ApplicationServices
.CreateScope()
.ServiceProvider
.GetService<PaymentDbContext>();
paymentDbContext.SeedInMemory();
app.UseAuthentication();
// Enable middleware to serve generated Swagger as a JSON endpoint.
app.UseSwagger();
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "RothschildHouse API V1");
});
app.UseMvc();
}
}
#pragma warning restore CS1591
}
TransactionController
类的代码
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RothschildHouse.Domain;
using RothschildHouse.Requests;
using RothschildHouse.Responses;
namespace RothschildHouse.Controllers
{
#pragma warning disable CS1591
[Route("api/v1/[controller]")]
[ApiController]
[Authorize]
public class TransactionController : ControllerBase
{
readonly ILogger<TransactionController> Logger;
readonly PaymentDbContext DbContext;
public TransactionController(ILogger<TransactionController> logger, PaymentDbContext dbContext)
{
Logger = logger;
DbContext = dbContext;
}
#pragma warning restore CS1591
/// <summary>
/// Places a new payment
/// </summary>
/// <param name="request">Payment request</param>
/// <returns>A payment response</returns>
[HttpPost("Payment")]
public async Task<IActionResult> PostPaymentAsync([FromBody]PostPaymentRequest request)
{
Logger?.LogDebug("'{0}' has been invoked", nameof(PostPaymentAsync));
var creditCards = await DbContext.GetCreditCardByCardHolderName(request.CardHolderName).ToListAsync();
var creditCard = default(CreditCard);
var last4Digits = request.CardNumber.Substring(request.CardNumber.Length - 4);
if (creditCards.Count > 1)
creditCard = creditCards.FirstOrDefault(item => item.CardNumber == request.CardNumber);
else if (creditCards.Count == 1)
creditCard = creditCards.First();
if (creditCard == null)
return BadRequest(string.Format("There is not record for credit card with last 4 digits: {0}.", last4Digits));
/* Check credit card information */
if (!creditCard.IsValid(request))
return BadRequest(string.Format("Invalid information for card payment."));
/* Check if customer has available credit (limit) */
if (!creditCard.HasFounds(request))
return BadRequest(string.Format("There are no founds to approve the payment."));
using (var txn = await DbContext.Database.BeginTransactionAsync())
{
try
{
var paymentTxn = new PaymentTransaction
{
PaymentTransactionID = Guid.NewGuid(),
CreditCardID = creditCard.CreditCardID,
ConfirmationID = Guid.NewGuid(),
Amount = request.Amount,
PaymentDateTime = DateTime.Now
};
DbContext.PaymentTransactions.Add(paymentTxn);
creditCard.AvailableFounds -= request.Amount;
await DbContext.SaveChangesAsync();
txn.Commit();
Logger?.LogInformation("The payment for card with last 4 digits: '{0}' was successfully. Confirmation #: {1}", last4Digits, paymentTxn.ConfirmationID);
var response = new PaymentResponse
{
ConfirmationID = paymentTxn.ConfirmationID,
PaymentDateTime = paymentTxn.PaymentDateTime,
Last4Digits = creditCard.Last4Digits
};
return Ok(response);
}
catch (Exception ex)
{
Logger?.LogCritical("There was an error on '{0}': {1}", nameof(PostPaymentAsync), ex);
txn.Rollback();
return new ObjectResult(ex.Message)
{
StatusCode = (int)HttpStatusCode.InternalServerError
};
}
}
}
}
}
支付请求
using System;
using System.ComponentModel.DataAnnotations;
namespace RothschildHouse.Requests
{
/// <summary>
/// Represents the model for payment request
/// </summary>
public class PostPaymentRequest
{
/// <summary>
/// Initializes a new instance of <see cref="PostPaymentRequest"/>
/// </summary>
public PostPaymentRequest()
{
}
/// <summary>
/// Gets or sets the card holder's name
/// </summary>
[Required]
[StringLength(30)]
public string CardHolderName { get; set; }
/// <summary>
/// Gets or sets the issuing network
/// </summary>
[Required]
[StringLength(20)]
public string IssuingNetwork { get; set; }
/// <summary>
/// Gets or sets the card number
/// </summary>
[Required]
[StringLength(20)]
public string CardNumber { get; set; }
/// <summary>
/// Gets or sets the expiration date
/// </summary>
[Required]
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// Gets or sets the CVV (Card Verification Value)
/// </summary>
[Required]
[StringLength(4)]
public string Cvv { get; set; }
/// <summary>
/// Gets or sets the amount
/// </summary>
[Required]
[Range(0.0, 10000.0)]
public decimal? Amount { get; set; }
}
}
第 06 章 - 在线商店身份
此解决方案将 Identity Server 作为身份验证和授权 API。
在线商店的 Identity
API 运行在端口 56000。
为了允许连接,我们需要为 API 资源和客户端添加配置,此配置位于 Config
类中。
using System.Collections.Generic;
using System.Security.Claims;
using IdentityModel;
using IdentityServer4.Models;
namespace OnlineStore.API.Identity
{
public static class Config
{
public static IEnumerable<ApiResource> GetApiResources()
=> new List<ApiResource>
{
new ApiResource("OnlineStoreAPI", "Online Store API")
{
ApiSecrets =
{
new Secret("Secret1")
}
}
};
public static IEnumerable<Client> GetClients()
=> new List<Client>
{
new Client
{
ClientId = "OnlineStoreAPI.Client",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets =
{
new Secret("OnlineStoreAPIClientSecret1".Sha256())
},
AllowedScopes =
{
"OnlineStoreAPI"
},
Claims =
{
new Claim(JwtClaimTypes.Role, "Administrator"),
new Claim(JwtClaimTypes.Role, "Customer"),
new Claim(JwtClaimTypes.Role, "WarehouseManager"),
new Claim(JwtClaimTypes.Role, "WarehouseOperator")
}
}
};
}
}
我们还需要在 Identity Server API 的 Startup
类中添加服务配置
using IdentityServer4.Services;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OnlineStore.API.Identity.Domain;
using OnlineStore.API.Identity.Services;
using OnlineStore.API.Identity.Validation;
namespace OnlineStore.API.Identity
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
/* Setting up dependency injection */
// DbContext
services
.AddDbContext<IdentityDbContext>(options => options.UseInMemoryDatabase("Identity"));
// Password validator and profile
services
.AddTransient<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>()
.AddTransient<IProfileService, ProfileService>();
/* Identity Server */
services
.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients());
services
.AddAuthentication()
.AddIdentityServerAuthentication();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
// Seed IdentityDbContext in-memory
var authDbContext = app
.ApplicationServices
.CreateScope()
.ServiceProvider
.GetService<IdentityDbContext>();
authDbContext.SeedInMemory();
app.UseIdentityServer();
}
}
}
如您所知,我们正在使用内存中的配置,这些配置适用于 DbContext
。
为了处理身份验证和授权,有两个实体:User
和 UserClaim
。
using Microsoft.EntityFrameworkCore;
namespace OnlineStore.API.Identity.Domain
{
public class IdentityDbContext : DbContext
{
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; }
public DbSet<UserClaim> UserClaims { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Set key for entities
modelBuilder
.Entity<User>(builder => builder.HasKey(e => e.UserID));
modelBuilder
.Entity<UserClaim>(builder => builder.HasKey(e => e.UserClaimID));
base.OnModelCreating(modelBuilder);
}
}
}
OnlineStore 不再使用 Repository
和 Unit of Work
设计模式,这些设计模式的替代方案是为 DbContext
类提供扩展方法,稍后我将通过示例解释这一点。
这是 AuthDbContextExtentions
类的代码
using System;
using System.Collections.Generic;
using System.Linq;
using IdentityModel;
namespace OnlineStore.API.Identity.Domain
{
public static class IdentityDbContextExtentions
{
public static bool ValidatePassword(this IdentityDbContext dbContext, string userName, string password)
{
var user = dbContext.Users.FirstOrDefault(item => item.Email == userName);
if (user == null)
return false;
if (user.Password == password.ToSha256())
return true;
return false;
}
public static User GetUserByUserName(this IdentityDbContext dbContext, string userName)
=> dbContext.Users.FirstOrDefault(item => item.Email == userName);
public static User GetUserByID(this IdentityDbContext dbContext, string id)
=> dbContext.Users.FirstOrDefault(item => item.UserID == id);
public static IEnumerable<UserClaim> GetUserClaimsByUserID(this IdentityDbContext dbContext, string userID)
=> dbContext.UserClaims.Where(item => item.UserID == userID);
public static void SeedInMemory(this IdentityDbContext dbContext)
{
dbContext.Users.Add(new User("1000", "erik.lehnsherr@outlook.com", "magneto".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.Subject, "1000"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.PreferredUserName, "eriklehnsherr"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.Role, "Administrator"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.Email, "erik.lehnsherr@outlook.com"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.GivenName, "Erik"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.MiddleName, "M"),
new UserClaim(Guid.NewGuid(), "1000", JwtClaimTypes.FamilyName, "Lehnsherr")
);
dbContext.SaveChanges();
dbContext.Users.Add(new User("2000", "charlesxavier@gmail.com", "professorx".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.Subject, "2000"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.PreferredUserName, "charlesxavier"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.Role, "Administrator"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.Email, "charlesxavier@gmail.com"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.GivenName, "Charles"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.MiddleName, "F"),
new UserClaim(Guid.NewGuid(), "2000", JwtClaimTypes.FamilyName, "Xavier")
);
dbContext.SaveChanges();
dbContext.Users.Add(new User("3000", "jameslogan@walla.com", "wolverine".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.Subject, "3000"),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.PreferredUserName, "jameslogan"),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.Role, "Customer"),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.Email, "jameslogan@walla.com"),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.GivenName, "James"),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.MiddleName, ""),
new UserClaim(Guid.NewGuid(), "3000", JwtClaimTypes.FamilyName, "Logan")
);
dbContext.SaveChanges();
dbContext.Users.Add(new User("4000", "ororo_munroe@yahoo.com", "storm".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.Subject, "4000"),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.PreferredUserName, "ororo_munroe"),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.Role, "Customer"),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.Email, "ororo_munroe@yahoo.com"),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.GivenName, "Ororo"),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.MiddleName, ""),
new UserClaim(Guid.NewGuid(), "4000", JwtClaimTypes.FamilyName, "Munroe")
);
dbContext.SaveChanges();
dbContext.Users.Add(new User("5000", "warehousemanager1@onlinestore.com", "password1".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "5000", JwtClaimTypes.Subject, "5000"),
new UserClaim(Guid.NewGuid(), "5000", JwtClaimTypes.PreferredUserName, "warehousemanager1"),
new UserClaim(Guid.NewGuid(), "5000", JwtClaimTypes.Role, "WarehouseManager"),
new UserClaim(Guid.NewGuid(), "5000", JwtClaimTypes.Email, "warehousemanager1@onlinestore.com")
);
dbContext.SaveChanges();
dbContext.Users.Add(new User("6000", "warehouseoperator1@onlinestore.com", "password1".ToSha256(), true));
dbContext.UserClaims.AddRange(
new UserClaim(Guid.NewGuid(), "6000", JwtClaimTypes.Subject, "6000"),
new UserClaim(Guid.NewGuid(), "6000", JwtClaimTypes.PreferredUserName, "warehouseoperator1"),
new UserClaim(Guid.NewGuid(), "6000", JwtClaimTypes.Role, "WarehouseOperator"),
new UserClaim(Guid.NewGuid(), "6000", JwtClaimTypes.Email, "warehouseoperator1@onlinestore.com")
);
dbContext.SaveChanges();
}
}
}
OnlineStore
API 中的所有操作都需要从 Identity
API 获取令牌。
第 07 章 - API
有三个项目
OnlineStore.API.Common
OnlineStore.API.Sales
OnlineStore.API.Warehouse
所有这些项目都包含对 OnlineStore.Core
项目的引用。
支付网关客户端
支付网关提供两个 API,一个用于身份验证,另一个用于支付,为了执行支付请求,Rothschild House
有两个客户端。
RothschildHouseIdentityClient
类的代码
using System.Net.Http;
using System.Threading.Tasks;
using IdentityModel.Client;
using Microsoft.Extensions.Options;
using OnlineStore.API.Common.Clients.Contracts;
namespace OnlineStore.API.Common.Clients
{
#pragma warning disable CS1591
public class RothschildHouseIdentityClient : IRothschildHouseIdentityClient
{
private readonly RothschildHouseIdentitySettings Settings;
public RothschildHouseIdentityClient(IOptions<RothschildHouseIdentitySettings> settings)
{
Settings = settings.Value;
}
public async Task<TokenResponse> GetRothschildHouseTokenAsync()
{
using (var client = new HttpClient())
{
// todo: Get identity server url from config file
var disco = await client.GetDiscoveryDocumentAsync(Settings.Url);
// todo: Get token request from config file
return await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = Settings.ClientId,
ClientSecret = Settings.ClientSecret,
UserName = Settings.UserName,
Password = Settings.Password
});
}
}
}
#pragma warning restore CS1591
}
RothschildHousePaymentClient
类的代码
using System.Net.Http;
using System.Threading.Tasks;
using IdentityModel.Client;
using Microsoft.Extensions.Options;
using OnlineStore.API.Common.Clients.Contracts;
using OnlineStore.API.Common.Clients.Models;
namespace OnlineStore.API.Common.Clients
{
#pragma warning disable CS1591
public class RothschildHousePaymentClient : IRothschildHousePaymentClient
{
private readonly RothschildHousePaymentSettings Settings;
private readonly ApiUrl apiUrl;
public RothschildHousePaymentClient(IOptions<RothschildHousePaymentSettings> settings)
{
Settings = settings.Value;
apiUrl = new ApiUrl(baseUrl: Settings.Url);
}
public async Task<HttpResponseMessage> PostPaymentAsync(TokenResponse token, PostPaymentRequest request)
{
using (var client = new HttpClient())
{
client.SetBearerToken(token.AccessToken);
return await client.PostAsync(
apiUrl.Controller("Transaction").Action("Payment").ToString(),
request.GetStringContent()
);
}
}
}
#pragma warning restore CS1591
}
销售 API
让我们看看 SalesController
类
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using OnlineStore.API.Common.Clients.Contracts;
using OnlineStore.API.Common.Clients.Models;
using OnlineStore.API.Common.Controllers;
using OnlineStore.API.Common.Filters;
using OnlineStore.API.Common.Responses;
using OnlineStore.API.Sales.Requests;
using OnlineStore.API.Sales.Security;
using OnlineStore.Core.Business.Contracts;
namespace OnlineStore.API.Sales.Controllers
{
#pragma warning disable CS1591
[Route("api/v1/[controller]")]
[ApiController]
public class SalesController : OnlineStoreController
{
readonly ILogger Logger;
readonly IRothschildHouseIdentityClient RothschildHouseIdentityClient;
readonly IRothschildHousePaymentClient RothschildHousePaymentClient;
readonly ISalesService SalesService;
public SalesController(ILogger<SalesController> logger, IRothschildHouseIdentityClient rothschildHouseIdentityClient, IRothschildHousePaymentClient rothschildHousePaymentClient, ISalesService salesService)
: base()
{
Logger = logger;
RothschildHouseIdentityClient = rothschildHouseIdentityClient;
RothschildHousePaymentClient = rothschildHousePaymentClient;
SalesService = salesService;
}
#pragma warning restore CS1591
/// <summary>
/// Retrieves the orders placed by customers
/// </summary>
/// <param name="request">Search request</param>
/// <returns>A sequence of orders</returns>
/// <response code="200">Returns a list of orders</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="500">If there was an internal error</response>
[HttpGet("order")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
public async Task<IActionResult> GetOrdersAsync([FromQuery]GetOrdersRequest request)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrdersAsync));
// Get response from business logic
var response = await SalesService
.GetOrdersAsync(request.PageSize, request.PageNumber, request.OrderStatusID, request.CustomerID, request.EmployeeID, request.ShipperID, request.CurrencyID, request.PaymentMethodID);
// Return as http response
return response.ToHttpResult();
}
/// <summary>
/// Retrieves an existing order by id
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>An existing order</returns>
/// <response code="200">If id exists</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="404">If id is not exists</response>
/// <response code="500">If there was an internal error</response>
[HttpGet("order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
public async Task<IActionResult> GetOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrderAsync));
// Get response from business logic
var response = await SalesService.GetOrderAsync(id);
// Return as http response
return response.ToHttpResult();
}
/// <summary>
/// Retrieves the request model to create a new order
/// </summary>
/// <returns>A model that represents the request to create a new order</returns>
/// <response code="200">Returns the model to create a new order</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="500">If there was an internal error</response>
[HttpGet("order-model")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
[Authorize(Policy = Policies.CustomerPolicy)]
public async Task<IActionResult> GetPostOrderModelAsync()
{
Logger?.LogDebug("{0} has been invoked", nameof(GetPostOrderModelAsync));
// Get response from business logic
var response = await SalesService.GetCreateOrderRequestAsync();
// Return as http response
return response.ToHttpResult();
}
/// <summary>
/// Creates a new order
/// </summary>
/// <param name="request">Request</param>
/// <returns>A result that contains the order ID generated by API</returns>
/// <response code="200">If order was created successfully</response>
/// <response code="400">If the request is invalid</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="500">If there was an internal error</response>
[HttpPost("order")]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
[Authorize(Policy = Policies.CustomerPolicy)]
public async Task<IActionResult> PostOrderAsync([FromBody]PostOrderRequest request)
{
Logger?.LogDebug("{0} has been invoked", nameof(PostOrderAsync));
var token = await RothschildHouseIdentityClient
.GetRothschildHouseTokenAsync();
if (token.IsError)
return Unauthorized();
var paymentRequest = request.GetPostPaymentRequest();
var paymentHttpResponse = await RothschildHousePaymentClient
.PostPaymentAsync(token, paymentRequest);
if (!paymentHttpResponse.IsSuccessStatusCode)
return BadRequest();
var paymentResponse = await paymentHttpResponse
.GetPaymentResponseAsync();
var entity = request.GetHeader();
entity.CreationUser = UserInfo.UserName;
// Get response from business logic
var response = await SalesService
.CreateOrderAsync(entity, request.GetDetails().ToArray());
// Return as http response
return response.ToHttpResult();
}
/// <summary>
/// Creates a new order model from existing order
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>A model for a new order</returns>
/// <response code="200">If order was cloned successfully</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="404">If id is not exists</response>
/// <response code="500">If there was an internal error</response>
[HttpGet("order/{id}/clone")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
[Authorize(Policy = Policies.CustomerPolicy)]
public async Task<IActionResult> CloneOrderAsync(int id)
{
Logger?.LogDebug("{0} has been invoked", nameof(CloneOrderAsync));
// Get response from business logic
var response = await SalesService.CloneOrderAsync(id);
// Return as http response
return response.ToHttpResult();
}
/// <summary>
/// Cancels an existing order
/// </summary>
/// <param name="id">ID for order</param>
/// <returns>A success response if order is cancelled</returns>
/// <response code="200">If order was cancelled successfully</response>
/// <response code="401">If client is not authenticated</response>
/// <response code="403">If client is not autorized</response>
/// <response code="404">If id is not exists</response>
/// <response code="500">If there was an internal error</response>
[HttpDelete("order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
[OnlineStoreActionFilter]
[Authorize(Policy = Policies.CustomerPolicy)]
public async Task<IActionResult> DeleteOrderAsync(int id)
{
Logger?.LogDebug("{0} has been invoked", nameof(DeleteOrderAsync));
// Get response from business logic
var response = await SalesService.CancelOrderAsync(id);
// Return as http response
return response.ToHttpResult();
}
}
}
ViewModel 与 Request
ViewModel
是一个包含行为的对象,请求是与调用 Web API 方法相关的动作,这是误解:ViewModel
是一个链接到视图的对象,包含处理更改和与视图同步的行为;通常 Web API 方法的参数是一个带有属性的对象,因此此定义称为 Request
;MVC 不是 MVVM,这些模式中模型的生命周期不同,此定义在 UI 和 API 之间不保持状态,而且从查询字符串设置请求中属性值的过程由模型绑定器处理。
设置
要为 API 提供设置,首先我们需要在 appsettings.json
文件中定义配置
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"OnlineStore": "server=(local);database=OnlineStore;integrated security=yes;MultipleActiveResultSets=True;"
},
"IdentityServerSettings": {
"Authority": "https://:5100",
"RequireHttpsMetadata": false,
"ApiName": "OnlineStoreAPI",
"ApiSecret": "Secret1"
},
"OnlineStoreIdentityClientSettings": {
"Url": "https://:5100",
"ClientId": "OnlineStoreAPI.Client",
"ClientSecret": "OnlineStoreAPIClientSecret1",
"UserName": "",
"Password": ""
},
"RothschildHouseIdentitySettings": {
"Url": "https://:18000",
"ClientId": "onlinestoreclient",
"ClientSecret": "onlinestoreclientsecret1",
"UserName": "administrator@onlinestore.com",
"Password": "onlinestore1"
},
"RothschildHousePaymentSettings": {
"Url": "https://:19000"
}
}
然后查看 Startup.cs
类
using System;
using System.IO;
using System.Reflection;
using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using OnlineStore.API.Common.Clients;
using OnlineStore.API.Common.Clients.Contracts;
using OnlineStore.API.Sales.PolicyRequirements;
using OnlineStore.API.Sales.Security;
using OnlineStore.Core;
using OnlineStore.Core.Business;
using OnlineStore.Core.Business.Contracts;
using OnlineStore.Core.Domain;
using Swashbuckle.AspNetCore.Swagger;
namespace OnlineStore.API.Sales
{
#pragma warning disable CS1591
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
/* Configuration for MVC */
services
.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
.AddJsonOptions(options =>
{
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
/* Setting dependency injection */
// For DbContext
services.AddDbContext<OnlineStoreDbContext>(builder =>builder.UseSqlServer(Configuration["ConnectionStrings:OnlineStore"]));
// User info
services.AddScoped<IUserInfo, UserInfo>();
// Logger for services
services.AddScoped<ILogger, Logger<Service>>();
/* Rothschild House Payment gateway */
services.Configure<RothschildHouseIdentitySettings>(Configuration.GetSection("RothschildHouseIdentitySettings"));
services.AddSingleton<RothschildHouseIdentitySettings>();
services.Configure<RothschildHousePaymentSettings>(Configuration.GetSection("RothschildHousePaymentSettings"));
services.AddSingleton<RothschildHousePaymentSettings>();
services.AddScoped<IRothschildHouseIdentityClient, RothschildHouseIdentityClient>();
services.AddScoped<IRothschildHousePaymentClient, RothschildHousePaymentClient>();
/* Online Store Services */
services.AddScoped<ISalesService, SalesService>();
/* Configuration for authorization */
services
.AddMvcCore()
.AddAuthorization(options =>
{
options.AddPolicy(Policies.CustomerPolicy, builder =>
{
builder.Requirements.Add(new CustomerPolicyRequirement());
});
});
/* Configuration for Identity Server authentication */
services
.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
var settings = new IdentityServerAuthenticationOptions();
Configuration.Bind("IdentityServerSettings", settings);
options.Authority = settings.Authority;
options.RequireHttpsMetadata = settings.RequireHttpsMetadata;
options.ApiName = settings.ApiName;
options.ApiSecret = settings.ApiSecret;
});
/* Configuration for Help page */
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "Online Store Sales API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.IncludeXmlComments(xmlPath);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors(builder =>
{
// Add client origin in CORS policy
// todo: Set port number for client app from appsettings file
builder
.WithOrigins("https://:4200")
.AllowAnyHeader()
.AllowAnyMethod()
;
});
/* Use authentication for Web API */
app.UseAuthentication();
/* Configuration for Swagger */
app.UseSwagger();
app.UseSwaggerUI(options => options.SwaggerEndpoint("/swagger/v1/swagger.json", "Online Store Sales API"));
app.UseMvc();
}
}
#pragma warning restore CS1591
}
此此类是 Web API 项目的配置点,在此类中包含依赖注入、API 配置和其他设置的配置。
对于 API 项目,这些是控制器的路由
动词 | 路线 | 描述 |
---|---|---|
GET | api/v1/Sales/order | 获取订单 |
GET | api/v1/Sales/order/5 | 按 ID 获取订单 |
GET | api/v1/Sales/order-model | 获取模型以创建订单 |
GET | api/v1/Sales/order/5/clone | 克隆现有订单 |
POST | api/v1/Sales/order | 创建新订单 |
删除 | api/v1/Sales/order/5 | 删除现有订单 |
每条路由中都有一个 v1
,这是因为 API 版本为 1,该值在 API 项目中控制器的 Route
属性中定义。
第08章 - API 帮助页面
API 使用 Swagger 显示帮助页面。
显示带有 Swagger 的帮助页面需要以下包
Swashbuckle.AspNetCore
Swagger 的配置位于 Startup
类中,Swagger 的添加位于 ConfigureServices
方法中
/* Configuration for Help page */
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "Online Store Sales API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.IncludeXmlComments(xmlPath);
});
端点的配置在 Configure
方法中
/* Configuration for Swagger */
app.UseSwagger();
app.UseSwaggerUI(options => options.SwaggerEndpoint("/swagger/v1/swagger.json", "Online Store Sales API"));
Swagger 允许显示控制器中操作的描述,这些描述取自 xml 注释。
帮助页面
帮助页面中的模型部分
API 的帮助页面是一种很好的实践,因为它为客户端提供了关于 API 的信息。
第 09 章 - API 单元测试
现在我们开始解释 API 项目的单元测试,这些测试使用内存数据库。单元测试和集成测试有什么区别?对于单元测试,我们模拟 Web API 项目的所有依赖项,而对于集成测试,我们运行一个模拟 Web API 执行的进程。我的意思是模拟 Web API(接受 Http 请求),显然有更多关于单元测试和集成测试的信息,但在这一点上,这个基本思想就足够了。
什么是 TDD? 如今测试非常重要,因为通过单元测试,在发布之前对功能进行测试很容易,测试驱动开发是定义测试和验证代码行为的方式。
与 TDD 相关的另一个概念是 AAA:Arrange、Act 和 Assert 是一种用于在测试方法中安排和格式化代码的模式。
- Arrange:是创建对象的块
- Act:是放置所有方法调用的块
- Assert:是验证方法调用结果的块
让我们看一下 SalesControllerTests
类
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using OnlineStore.API.Common.UnitTests.Mocks;
using OnlineStore.API.Sales.Controllers;
using OnlineStore.API.Sales.Requests;
using OnlineStore.API.Sales.UnitTests.Mocks;
using OnlineStore.Core.Business.Requests;
using OnlineStore.Core.Business.Responses;
using OnlineStore.Core.Domain.Sales;
using Xunit;
namespace OnlineStore.API.Sales.UnitTests
{
public class SalesControllerTests
{
[Fact]
public async Task TestSearchOrdersAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestSearchOrdersAsync), true);
var controller = new SalesController(null, null, null, service);
var request = new GetOrdersRequest();
// Act
var response = await controller.GetOrdersAsync(request) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestSearchOrdersByCurrencyAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestSearchOrdersByCurrencyAsync), true);
var controller = new SalesController(null, null, null, service);
var request = new GetOrdersRequest
{
CurrencyID = "USD"
};
// Act
var response = await controller.GetOrdersAsync(request) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.CurrencyID == request.CurrencyID) == value.Model.Count());
}
[Fact]
public async Task TestSearchOrdersByCustomerAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestSearchOrdersByCustomerAsync), true);
var controller = new SalesController(null, null, null, service);
var request = new GetOrdersRequest
{
CustomerID = 1
};
// Act
var response = await controller.GetOrdersAsync(request) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.CustomerID == request.CustomerID) == value.Model.Count());
}
[Fact]
public async Task TestSearchOrdersByEmployeeAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestSearchOrdersByEmployeeAsync), true);
var controller = new SalesController(null, null, null, service);
var request = new GetOrdersRequest
{
EmployeeID = 1
};
// Act
var response = await controller.GetOrdersAsync(request) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.EmployeeID == request.EmployeeID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrderAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestGetOrderAsync), true);
var controller = new SalesController(null, null, null, service);
var id = 1;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetNonExistingOrderAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestGetNonExistingOrderAsync), true);
var controller = new SalesController(null, null, null, service);
var id = 0;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetCreateOrderRequestAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestGetCreateOrderRequestAsync), true);
var controller = new SalesController(null, null, null, service);
// Act
var response = await controller.GetPostOrderModelAsync() as ObjectResult;
var value = response.Value as ISingleResponse<CreateOrderRequest>;
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestPostOrderAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestPostOrderAsync), true);
var identityClient = new MockedRothschildHouseIdentityClient();
var paymentClient = new MockedRothschildHousePaymentClient();
var controller = new SalesController(null, identityClient, paymentClient, service);
var request = new PostOrderRequest
{
ID = 2,
CustomerID = 1,
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
CurrencyID = "USD",
Comments = "Order from unit tests",
Details = new List<OrderDetailRequest>
{
new OrderDetailRequest
{
ID = 2,
ProductID = 1,
Quantity = 1
}
}
};
// Act
var response = await controller.PostOrderAsync(request) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.ID.HasValue);
}
[Fact]
public async Task TestCloneOrderAsync()
{
// Arrange
var userInfo = IdentityMocker.GetCustomerIdentity().GetUserInfo();
var service = ServiceMocker.GetSalesService(userInfo, nameof(TestCloneOrderAsync), true);
var controller = new SalesController(null, null, null, service);
var id = 1;
// Act
var response = await controller.CloneOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
// Assert
Assert.False(value.DidError);
}
}
}
IdentityMocker
类提供用户身份,GetUserInfo
扩展方法返回 IUserInfo
接口的实现,其中包含已验证用户的信息。
第 10 章 - API 集成测试
与单元测试一样,集成测试也应根据 Web API 创建,因此让我们选择 SalesController
来解释集成测试模型。
现在,这是 SalesTests
类的代码
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using OnlineStore.API.Common.IntegrationTests;
using OnlineStore.API.Common.IntegrationTests.Helpers;
using Xunit;
namespace OnlineStore.API.Sales.IntegrationTests
{
public class SalesTests : IClassFixture<TestFixture<Startup>>
{
readonly HttpClient Client;
public SalesTests(TestFixture<Startup> fixture)
{
Client = fixture.Client;
}
[Fact]
public async Task GetOrdersAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order?pageSize=10&pageNumber=1"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
var content = await response.Content.ReadAsStringAsync();
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetOrdersByCurrencyAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order?pageSize=10&pageNumber=1¤cyID=1"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task SearchOrdersByCustomerAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetOrdersByEmployeeAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order?employeeId=1"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetOrderByIdAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order/1"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetOrderByNonExistingIdAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order/0"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
Assert.True(response.StatusCode == HttpStatusCode.NotFound);
}
[Fact]
public async Task GetPostOrderRequestAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order-model"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetPlaceOrderRequestAsWarehouseOperatorAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWarehouseOperatorAsync();
var request = new
{
Url = "/api/v1/Sales/order-model"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
Assert.True(response.StatusCode == HttpStatusCode.Forbidden);
}
[Fact]
public async Task PlaceOrderAsCustomerAsync()
{
// Arrange
var request = new
{
Url = "/api/v1/Sales/order",
Body = new
{
UserName = "jameslogan@walla.com",
Password = "wolverine",
CardHolderName = "James Logan",
IssuingNetwork = "Visa",
CardNumber = "4024007164051145",
ExpirationDate = new DateTime(2024, 6, 1),
Cvv = "987",
Total = 29.99m,
CustomerID = 1,
CurrencyID = "USD",
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
Comments = "Order from integration tests",
Details = new[]
{
new
{
ProductID = 1,
Quantity = 1
}
}
}
};
var token = await TokenHelper
.GetOnlineStoreTokenAsync(request.Body.UserName, request.Body.Password);
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client
.PostAsync(request.Url, ContentHelper.GetStringContent(request.Body));
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task CloneOrderAsCustomerAsync()
{
// Arrange
var token = await TokenHelper.GetTokenForWolverineAsync();
var request = new
{
Url = "/api/v1/Sales/order/1/clone"
};
// Act
Client.SetBearerToken(token.AccessToken);
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
}
}
IdentityServerHelper
类提供静态方法以检索有效令牌。
SetBearerToken
是一个扩展方法,允许设置带有 Bearer 令牌的授权头。
正如我们所看到的,这些方法对 Web API 项目中的 URL 执行测试,请注意,所有测试都是异步方法。
Warehouse
API。为了进行集成测试,我们需要创建一个类来提供 Web 主机以执行 Http 行为,这个类将是 TestFixture
,为了表示 Web API 的 Http 请求,有一个名为 SalesTests
的类,这个类将包含 SalesController
类中定义的所有操作的请求,但使用模拟的 Http 客户端。
TestFixture
类的代码
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace OnlineStore.API.Common.IntegrationTests
{
public class TestFixture<TStartup> : IDisposable
{
public static string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
{
var projectName = startupAssembly.GetName().Name;
var applicationBasePath = AppContext.BaseDirectory;
var directoryInfo = new DirectoryInfo(applicationBasePath);
do
{
directoryInfo = directoryInfo.Parent;
var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
if (projectDirectoryInfo.Exists)
if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)
return Path.Combine(projectDirectoryInfo.FullName, projectName);
}
while (directoryInfo.Parent != null);
throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");
}
private TestServer Server;
public TestFixture()
: this(Path.Combine(""))
{
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
Server.Dispose();
}
protected virtual void InitializeServices(IServiceCollection services)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var manager = new ApplicationPartManager
{
ApplicationParts =
{
new AssemblyPart(startupAssembly)
},
FeatureProviders =
{
new ControllerFeatureProvider(),
new ViewComponentFeatureProvider()
}
};
services.AddSingleton(manager);
}
protected TestFixture(string relativeTargetProjectParentDir)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly);
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json");
var webHostBuilder = new WebHostBuilder()
.UseContentRoot(contentRoot)
.ConfigureServices(InitializeServices)
.UseConfiguration(configurationBuilder.Build())
.UseEnvironment("Development")
.UseStartup(typeof(TStartup));
// Create instance of test server
Server = new TestServer(webHostBuilder);
// Add configuration for client
Client = Server.CreateClient();
Client.BaseAddress = new Uri("https://:5001");
Client.DefaultRequestHeaders.Accept.Clear();
Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
}
}
代码改进
- 将日志保存到文本文件
- 实现 Money Pattern 以在应用程序中表示金额
- 添加一个部分来解释为什么此解决方案不实现存储库和工作单元设计模式
相关链接
- 使用 CatFactory 脚手架 Entity Framework Core 2。
- 带有 Swagger / OpenAPI 的 ASP.NET Core Web API 帮助页面
- 使用 dotnet test 和 xUnit 在 .NET Core 中进行 C# 单元测试
- ASP.NET Core 中的集成测试
关注点
- 在本文中,我们使用
Entity Framework Core
。 Entity Framework Core
具有内存数据库。OnLineStoreDbContext
类的扩展方法允许我们公开特定操作,在某些情况下我们不希望有GetAll
、Add
、Update
或Remove
操作。- Web API 的帮助页面已使用
Swagger
构建。 - 单元测试对程序集执行测试。
- 集成测试对 Web 服务器执行测试。
- 单元测试和集成测试已使用
xUnit
框架构建。 - Mocker 是一个在测试中创建对象实例的对象。
历史
- 2016年12月12日:初始版本
- 2016年12月13日:添加业务层
- 2016年12月15日:添加 Mocker
- 2016年12月31日:添加 Web API
- 2017年1月5日:添加 Web API 单元测试
- 2017年1月22日:添加变更日志
- 2017年2月4日:添加异步操作
- 2017年5月15日:添加日志
- 2017年10月29日:代码重构,在业务层使用服务
- 2018年2月10日:添加变更日志排除项
- 2018年5月28日:添加 Web API 集成测试
- 2018年10月2日:为单元测试添加内存数据库
- 2018年11月25日:添加 Web API 帮助页面
- 2018年11月27日:添加相关链接部分
- 2019年1月16日:添加 Identity Server
- 2019年1月23日:添加支付网关
- 2019年2月10日:添加支付网关客户端
- 2020年2月9日:添加微服务架构