目录
介绍
为应用程序设计一个企业架构是一个很大的挑战,在这一点上有一个共同的问题:根据我们公司选择的技术,按照最佳实践解决这个问题的最佳方法是什么。
本教程使用.Net Core,因此我们将使用Entity Framework Core,但这些概念适用于其他技术,如Dapper或其他ORM。
实际上,我们将在本文中介绍企业架构师设计的常见要求。
本教程中提供的示例数据库代表在线商店。
因为我们正在使用Entity Framework Core并ASP.NET Core,使用内存数据库提供程序进行单元测试; 使用Test Web Server进行集成测试。
所有测试(单元测试和集成测试)都是用xUnit框架编写的。
背景
根据我的经验,应用程序的企业架构应该具有以下层:
- 实体层:包含实体(POCO)
- 数据层:包含与数据库访问相关的对象
- 业务层:包含与业务相关的定义和验证
- 外部服务层(可选):包含外部服务的调用(ASMX,WCF,RESTful)
- 通用层:包含层的公共对象(例如Loggers,Mappers,Extensions)
- 测试(QA):包含后端测试(单元和集成)
- 表示层:这是UI
- UI测试(QA):包含前端的自动测试
架构:整体结构
数据库 |
SQL Server |
数据库 |
实体层 |
POCO |
后端 |
数据层 |
DbContext,配置,约定,数据约定和存储库 |
|
业务层 |
服务,约定,数据约定,异常和记录器 |
|
外部服务层 |
ASMX,WCF,RESTful |
|
通用层 |
记录器,映射器,扩展 |
|
表示层 |
UI框架(AngularJS | ReactJS | Vue.js |其他) |
前端 |
用户 |
|
|
先决条件
技能
在继续之前,请记住我们需要掌握以下技能才能理解本教程:
- OOP(面向对象编程)
- AOP(面向切面编程)
- ORM(对象关系映射)
- 设计模式:领域驱动设计,存储库&工作单元以及IoC
软件
- .Net Core
- Visual Studio 2017
- SQL Server实例(本地或远程)
- SQL Server Management Studio
使用代码
第01章——数据库
查看示例数据库以了解体系结构中的每个组件。在这个数据库中有4个架构:Dbo,HumanResources,Warehouse和Sales。
每个模式代表商店公司的一个部门,请记住这一点,因为所有代码都是按照这个方面设计的; 此时此代码仅实现Production和Sales架构的功能。
所有表都有一个包含一列的主键,并且包含用于创建,最新更新和并发令牌的列。
表
架构 |
名称 |
dbo |
ChangeLog |
dbo |
ChangeLogExclusion |
dbo |
Country |
dbo |
CountryCurrency |
dbo |
Currency |
dbo |
EventLog |
HumanResources |
Employee |
HumanResources |
EmployeeAddress |
HumanResources |
EmployeeEmail |
Sales |
Customer |
Sales |
OrderDetail |
Sales |
OrderHeader |
Sales |
OrderStatus |
Sales |
PaymentMethod |
Sales |
Shipper |
Warehouse |
Location |
Warehouse |
Product |
Warehouse |
ProductCategory |
Warehouse |
ProductInventory |
您可以在此链接中找到数据库脚本:OnLine Store GitHub上的数据库脚本。
请记住:这是一个示例数据库,仅用于演示概念。
第02章——核心项目
核心项目代表解决方案的核心,在本教程中核心项目包括实体,数据和业务层。
我们正在使用.NET Core,命名约定是.NET命名约定,因此定义命名约定表以显示如何在代码中设置名称非常有用,如下所示:
识别码 |
案例 |
例子 |
命名空间 |
PascalCase |
Store |
类 |
PascalCase |
Product |
接口 |
前缀+ PascalCase |
ISalesRepository |
方法 |
PascalCase中的动词+ PascalCase中的名词 |
GetProducts |
异步方法 |
PascalCase中的动词+ PascalCase中的名词 + Async 后缀 |
GetOrdersAsync |
属性 |
PascalCase |
Description |
参数 |
camelCase |
connectionString |
此约定很重要,因为它定义了体系结构的命名准则。
这是Store.Core项目的结构:
- EntityLayer
- DataLayer
- DataLayer\Configurations
- BusinessLayer
- BusinessLayer\Contracts
- BusinessLayer\Requests
- BusinessLayer\Responses
在Entitylayer内部,我们将放置所有实体,在此上下文中,实体表示一个表示数据库中的表或视图的类,有时实体被命名为POCO(普通的旧公共语言运行时对象),而不是只具有属性、不包含方法或其他事物(事件)的类; 根据wkempf反馈,有必要明确POCO,POCO可以有方法和事件以及其他成员,但在POCO中添加这些成员并不常见。
在DataLayer里面,我们将放置DbContext,因为它是DataLayer中的通用类。
对于DataLayer\ Contracts,我们将放置代表操作目录的所有接口,我们专注于模式,我们将为每个模式创建一个接口并为默认模式(dbo)Store契约。
对于DataLayer\ DataContracts,我们将为Contracts命名空间中的返回值放置所有对象定义,现在该目录包含OrderInfo类定义。
对于DataLayer\ Mapping,我们将所有对象定义与数据库的映射类相关联。
对于DataLayer\ Repositories,我们将放置Contracts定义的实现。
一个储存库包括与以下一个架构操作,所以我们有4个仓库:DboRepository,HumanResourcesRepository,ProductionRepository和SalesRepository。
在EntityLayer和DataLayer\Mapping之内,我们将为每个模式创建一个目录。
在BusinessLayer内部,我们将创建服务的接口和实现,在这种情况下,服务将根据用例(或类似的东西)包含方法,并且这些方法必须执行验证并处理与业务相关的异常。
对于BusinessLayer\Responses,我们将创建响应:single,list和paged来表示服务的结果。
我们将检查代码以理解这些概念,但是每个级别的审查将使用一个对象,因为剩余的代码是相似的。
实体层
OrderHeader 类:
using System;
using System.Collections.ObjectModel;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.HumanResources;
namespace OnLineStore.Core.EntityLayer.Sales
{
public class OrderHeader : IAuditableEntity
{
public OrderHeader()
{
}
public OrderHeader(long? orderHeaderID)
{
OrderHeaderID = orderHeaderID;
}
public long? OrderHeaderID { 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 short? 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; } = new Collection<OrderDetail>();
}
}
请看一下POCO,我们使用可空类型而不是本机类型,因为如果属性有值或者没有,可以很容易地评估可空类型,这与数据库模型更相似。
在EntityLayer有两个接口:IEntity和IAuditEntity,IEntity代表了我们的应用程序的所有实体,而IAuditEntity表示允许保存审计信息的所有实体:创建和最新更新; 作为特殊点,如果我们有视图映射,那些类没有实现IAuditEntity因为视图不允许添加,更新和删除操作。
数据层
对于此源代码,存储库的实现是按功能而不是泛型存储库; 如果我们需要实现特定的操作,泛型存储库需要创建派生的存储库。我更喜欢按功能部署的存储库,因为不需要创建派生对象(接口和类),但按功能的存储库将包含大量操作,因为它是功能中所有操作的占位符。
本文的示例数据库在数据库中包含4个模式,因此我们将拥有4个存储库,此实现提供了概念的分离。
我们在本教程中使用EF Core,因此我们需要一个DbContext和允许映射类和数据库对象(表和视图)的对象。
存储库与DbHelper与数据访问对象的对比
这个问题与命名对象有关,几年前我用DataAccessObject作为包含数据库操作(select,insert,update,delete等)的类的后缀。其他开发人员用DbHelper作为后缀来表示这种对象,在我开始使用EF时,我了解了存储库设计模式,因此从我的角度来看,我更喜欢使用Repository后缀来命名包含数据库操作的对象。
OnLineStoreDbContext 类:
using Microsoft.EntityFrameworkCore;
using OnLineStore.Core.DataLayer.Configurations;
using OnLineStore.Core.DataLayer.Configurations.Dbo;
using OnLineStore.Core.DataLayer.Configurations.HumanResources;
using OnLineStore.Core.DataLayer.Configurations.Warehouse;
using OnLineStore.Core.DataLayer.Configurations.Sales;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.HumanResources;
using OnLineStore.Core.EntityLayer.Warehouse;
using OnLineStore.Core.EntityLayer.Sales;
namespace OnLineStore.Core.DataLayer
{
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<EntityLayer.Warehouse.Location> Warehouses { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<OrderDetail> OrderDetails { get; set; }
public DbSet<OrderHeader> Orders { 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 ProductInventoryConfiguration())
.ApplyConfiguration(new ProductConfiguration())
.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.EntityLayer.Sales;
namespace OnLineStore.Core.DataLayer.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.OrderHeaderID);
// Set identity for entity (auto increment)
builder.Property(p => p.OrderHeaderID).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("smallint");
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,并且在这种设计中,我们有两个定义在我们的体系结构中工作。
对于此版本的DbContext,我们将动态使用DbSet,而不是在DbContext中声明DbSet属性。我认为这更多是关于架构师偏好,我喜欢动态使用DbSet因为我不担心添加所有的DbSets到DbContext中,但如果您认为在DbContext中使用声明DbSet属性更准确,则此样式将会更改。
异步操作怎么样?在这篇文章的先前版本中,我说我们将在最后一级实现异步操作:REST API,但我错了,因为.NET Core更多的是关于异步编程,所以最好的决定是使用EF Core提供的异步方式处理所有数据库操作。
我们可以看一看Repository类,有两种方法:Add和Update,在这个例子中Order类有审计属性:CreationUser, CreationDateTime, LastUpdateUser和LastUpdateDateTime同时Order类实现IAuditEntity的接口,该接口是用于设置审计属性的值。
对于本文的当前版本,我们将省略服务层,但在某些情况下,有一个层包含外部服务的连接(ASMX,WCF和RESTful)。
存储过程与LINQ查询
在数据层中,有一个非常有趣的点:我们如何使用存储过程?对于当前版本的EF Core,不支持存储过程,所以我们不能以本机方式使用它们,DbSet里面有一个方法来执行查询同时对于存储过程有效但是不返回结果集合(columns),我们可以添加一些扩展方法并添加包来使用经典的ADO.NET,所以在这种情况下我们需要处理动态创建对象来表示存储过程的结果; 那讲得通?如果我们使用名称为GetOrdersByMonth的过程并且该过程返回一个包含7列的选中集合,为了以相同的方式处理所有结果,我们需要定义对象来表示那些结果,对象必须根据我们在DataLayer\ DataContracts命名空间内部的命名约定进行定义。
在企业环境中,常见的讨论是关于LINQ查询或存储过程。根据我的经验,我认为解决这个问题的最佳方法是:与架构师和数据库管理员一起审查设计惯例; 现在,在异步模式中使用LINQ查询而不是存储过程,但有时一些公司有限制约定并且不允许使用LINQ查询,因此需要使用存储过程,我们需要使我们的架构灵活,因为我们不要不对开发者经理说“业务逻辑将被重写,因为Entity Framework Core不允许调用存储过程”
我们可以看到,直到现在,假设我们有EF Core的扩展方法来调用存储过程和数据契约来表示存储过程调用的结果,我们在哪里放置这些方法?最好使用相同的约定,因此我们将在契约和存储库中添加这些方法; 只是要清楚,如果我们有一个名为Sales.GetCustomerOrdersHistory和HumanResources.DisableEmployee的存储程序; 我们必须在Sales和HumanResources存储库中放置方法。
需要明确的是:远离存储过程!
以前的概念对数据库中的视图应用相同的方式。此外,我们只需要检查存储库是否允许对视图进行添加,更新和删除操作。
更改跟踪:Repository类内部有一个名称为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,因为我知道有一种方法可以从数据库端启用更改跟踪,但是在这篇文章中,我向您展示了如何从后端实现此功能; 如果此功能位于后端或数据库端,则由您的领导决定。在时间轴中,我们可以在此表中检查实体中的所有更改,某些实体具有审计属性,但这些属性仅反映创建和上次更新的用户和日期,但不提供有关数据更改方式的完整详细信息。
业务层
控制器与服务与业务对象
在这一点上有一个共同的问题,我们必须如何命名代表业务操作的对象:对于本文的第一个版本,我将此对象命名为BusinessObject,这可能会让一些开发人员感到困惑,一些开发人员不会将此命名为业务对象因为Web API中的控制器代表业务逻辑,但是Service是开发人员使用的是另一个名称,因此从我的观点来看,将Service作为这个对象的后缀会更清楚。如果我们有一个在控制器中实现业务逻辑的Web API,我们可以省略服务,但如果有业务层,那么拥有服务更有用,这些类必须实现逻辑业务,控制器必须调用服务的方法。
业务层:处理与业务相关的方面
- 记录:我们需要一个记录器对象,这意味着一个在文本文件、数据库、电子邮件等上记录我们体系结构中所有事件的对象; 我们可以创建自己的记录器实现或选择现有的日志。我们已经使用Microsoft.Extensions.Logging包添加了日志记录,这样我们就可以使用.NET Core中的默认日志系统了,我们可以使用另一种日志机制,但此时我们将使用这个记录器,在控制器和业务对象的每个方法中,有一个像这样的代码行:Logger?.LogInformation("{0} has been invoked", nameof(GetOrdersAsync));,这样我们确保调用logger ,如果它是一个有效的实例,并使用nameof运算符来检索成员的名称而不使用神奇的字符串,之后我们将添加代码将所有日志保存到数据库中。
- 业务异常:处理向用户发送消息的最佳方式是使用自定义异常,在业务层内,我们将添加异常定义以表示体系结构中的所有句柄错误。
- 事务:正如我们在Sales业务对象内部所看到的,我们已经实现了事务来处理数据库中的多个更改; 在CreateOrderAsync方法内部,我们从存储库调用方法,在存储库内部我们没有任何事务,因为服务是负责事务处理的过程,我们还添加了逻辑来处理与自定义消息业务相关的异常,因为我们需要提供友好的消息给最终用户。
- 有一个CloneOrderAsync方法,这种方法提供现有订单的副本,这是ERP的常见要求,因为创建新订单更容易,但添加一些修改而不是创建整个订单。有些情况下,销售代理创建新订单,但从详细信息中删除1或者2行,或者加1或2个详细信息,无论如何,绝不允许前段开发人员在UI中添加此逻辑,API必须提供此功能。
- 在SalesRepository中的GetCreateOrderRequestAsync方法提供创建订单所需的信息,来自外键的信息:产品和其他。使用此方法,我们提供了一个包含外键列表的模型,这样我们就可以减少前端的工作,从而知道如何创建创建订单操作。
Service 类:
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.DataLayer;
namespace OnLineStore.Core.BusinessLayer
{
public abstract class Service : IService
{
protected bool Disposed;
protected ILogger Logger;
protected IUserInfo UserInfo;
public Service(ILogger logger, IUserInfo userInfo, OnLineStoreDbContext dbContext)
{
Logger = logger;
UserInfo = userInfo;
DbContext = dbContext;
}
public void Dispose()
{
if (!Disposed)
{
DbContext?.Dispose();
Disposed = true;
}
}
public OnLineStoreDbContext DbContext { get; }
}
}
SalesService 类:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.BusinessLayer.Requests;
using OnLineStore.Core.BusinessLayer.Responses;
using OnLineStore.Core.DataLayer;
using OnLineStore.Core.DataLayer.Repositories;
using OnLineStore.Core.DataLayer.Sales;
using OnLineStore.Core.DataLayer.Warehouse;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.Sales;
using OnLineStore.Core.EntityLayer.Warehouse;
namespace OnLineStore.Core.BusinessLayer
{
public class SalesService : Service, ISalesService
{
public SalesService(ILogger<SalesService> logger, IUserInfo userInfo, OnLineStoreDbContext dbContext)
: base(logger, userInfo, dbContext)
{
}
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 = 10, int pageNumber = 1, short? orderStatusID = null, int? customerID = null, int? employeeID = null, int? shipperID = null, short? currencyID = null, Guid? paymentMethodID = null)
{
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 = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, 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.Warehouses.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.ProductID));
// Throw exception if quantity for product is invalid
if (detail.Quantity <= 0)
throw new InvalidQuantityException(string.Format(SalesDisplays.InvalidQuantityExceptionMessage, product.ProductID));
// 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.OrderHeaderID;
// Add order detail
DbContext.Add(detail, UserInfo);
await DbContext.SaveChangesAsync();
// Create product inventory instance
var productInventory = new ProductInventory
{
ProductID = detail.ProductID,
LocationID = warehouses.First().LocationID,
OrderDetailID = detail.OrderDetailID,
Quantity = detail.Quantity * -1,
CreationDateTime = DateTime.Now,
CreationUser = header.CreationUser
};
// 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)
{
// Create a new instance for order and set values from existing order
response.Model = new OrderHeader
{
OrderHeaderID = entity.OrderHeaderID,
OrderDate = entity.OrderDate,
CustomerID = entity.CustomerID,
EmployeeID = entity.EmployeeID,
ShipperID = entity.ShipperID,
Total = entity.Total,
Comments = entity.Comments
};
if (entity.OrderDetails != null && entity.OrderDetails.Count > 0)
{
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> RemoveOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(RemoveOrderAsync));
var response = new Response();
try
{
// Retrieve order by id
var entity = await DbContext.GetOrderAsync(new OrderHeader(id));
if (entity != null)
{
// 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(RemoveOrderAsync), ex);
}
return response;
}
}
}
在BusinessLayer中,最好是有代表错误的自定义异常,而不是向客户机发送简单的字符串消息,显然,自定义异常必须有一个消息,但在Logger中会有一个关于自定义异常的引用。对于此体系结构,这些是自定义异常:
业务异常 |
|
名称 |
描述 |
AddOrderWithDiscontinuedProductException |
表示添加已停产产品的订单的异常 |
ForeignKeyDependencyException |
表示删除包含详细信息行的订单的异常 |
DuplicatedProductNameException |
表示添加具有现有名称的产品的异常 |
NonExistingProductException |
表示使用非现有产品添加订单的异常 |
第03章——将所有代码放在一起
我们需要在OnModelCreating方法中创建一个OnLineStoreDbContext实例,该实例与SQL Server一起使用,所有配置都应用于ModelBuilder实例。
稍后,将使用OnLineStoreDbContext的有效实例创建SalesService的实例,以获取对服务操作的访问权限。
得到所有
这是我们如何检索订单列表的示例:
// Create logger instance
var logger = LoggerMocker.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo("admin");
// 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.GetOrdersAsync(pageSize, pageNumber);
// Validate if there was an error
var valid = !response.DidError;
}
我们可以看到,GetOrdersAsync方法SalesService从Sales.Order表中检索行作为通用列表。
通过key获取
这是我们如何通过密钥检索实体的示例:
// Create logger instance
var logger = LoggerMocker.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo("admin");
// 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.GetOrderAsync(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会创建一年的行。
Program 类:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OnLineStore.Common;
using OnLineStore.Core.EntityLayer.Sales;
namespace OnLineStore.Mocker
{
public class Program
{
private 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()).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].CustomerID;
header.CurrencyID = currencies[selectedCurrency].CurrencyID;
header.PaymentMethodID = paymentMethods[selectedPaymentMethod].PaymentMethodID;
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)].ProductID,
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章——Web API
有一个名称为OnlineStore.WebAPI的项目,这表示此解决方案的Web API,此项目引用了OnLineStore.Core项目。
我们来看看SalesController类:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.WebAPI.Requests;
using OnLineStore.WebAPI.Responses;
namespace OnLineStore.WebAPI.Controllers
{
#pragma warning disable CS1591
[ApiController]
[Route("api/v1/[controller]")]
public class SalesController : ControllerBase
{
protected ILogger Logger;
protected ISalesService SalesService;
public SalesController(ILogger<SalesController> logger, ISalesService salesService)
{
Logger = logger;
SalesService = salesService;
}
#pragma warning restore CS1591
/// <summary>
/// Retrieves the orders generated by customers
/// </summary>
/// <param name="pageSize">Page size</param>
/// <param name="pageNumber">Page number</param>
/// <param name="orderStatusID">Order status</param>
/// <param name="customerID">Customer</param>
/// <param name="employeeID">Employee</param>
/// <param name="shipperID">Shipper</param>
/// <param name="currencyID">Currency</param>
/// <param name="paymentMethodID">Payment method</param>
/// <returns>A sequence of orders</returns>
[HttpGet("Order")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
public async Task<IActionResult> GetOrdersAsync(int? pageSize = 50, int? pageNumber = 1, short? orderStatusID = null, int? customerID = null, int? employeeID = null, int? shipperID = null, short? currencyID = null, Guid? paymentMethodID = null)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrdersAsync));
// Get response from business logic
var response = await SalesService.GetOrdersAsync((int)pageSize, (int)pageNumber, orderStatusID, customerID, employeeID, shipperID, currencyID, paymentMethodID);
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Retrieves an existing order by id
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>An existing order</returns>
[HttpGet("Order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
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.ToHttpResponse();
}
/// <summary>
/// Retrieves the request model to create a new order
/// </summary>
/// <returns>A model that represents the request to create a new order</returns>
[HttpGet("CreateOrderRequest")]
public async Task<IActionResult> GetCreateOrderRequestAsync()
{
Logger?.LogDebug("{0} has been invoked", nameof(GetCreateOrderRequestAsync));
// Get response from business logic
var response = await SalesService.GetCreateOrderRequestAsync();
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Creates a new order
/// </summary>
/// <param name="request">Model request</param>
/// <returns>A result that contains the order ID generated by application</returns>
[HttpPost]
[Route("Order")]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<IActionResult> CreateOrderAsync([FromBody] OrderHeaderRequest request)
{
Logger?.LogDebug("{0} has been invoked", nameof(CreateOrderAsync));
// Get response from business logic
var response = await SalesService.CreateOrderAsync(request.GetOrder(), request.GetOrderDetails().ToArray());
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Creates a new order from existing order
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>A model for a new order</returns>
[HttpGet("CloneOrder/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
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.ToHttpResponse();
}
/// <summary>
/// Deletes an existing order
/// </summary>
/// <param name="id">ID for order</param>
/// <returns>A success response if order is deleted</returns>
[HttpDelete("Order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
public async Task<IActionResult> DeleteOrderAsync(int id)
{
Logger?.LogDebug("{0} has been invoked", nameof(DeleteOrderAsync));
// Get response from business logic
var response = await SalesService.RemoveOrderAsync(id);
// Return as http response
return response.ToHttpResponse();
}
}
}
ViewModel与Request
ViewModel是一个包含行为的对象,request是与调用Web API方法相关的动作,这是误解:ViewModel是一个链接到视图的对象,包含处理更改和与视图同步的行为; 通常,Web API方法的参数是一个具有属性的对象,因此这个定义被命名Request; MVC不是MVVM,模型的生命周期在这些模式中是不同的,这个定义不保持UI和API之间的状态,而且在查询字符串的请求中设置属性值的过程由模型绑定器处理。
现在来看看Startup.cs类:
using System;
using System.IO;
using System.Reflection;
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.Core;
using OnLineStore.Core.BusinessLayer;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.DataLayer;
using Swashbuckle.AspNetCore.Swagger;
namespace OnLineStore.WebAPI
{
#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)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddMvc().AddJsonOptions(options =>
{
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
// Setting dependency injection
// For DbContext
services.AddDbContext<OnLineStoreDbContext>(options => options.UseSqlServer(Configuration["AppSettings:ConnectionString"]));
// User info
services.AddScoped<IUserInfo, UserInfo>();
// Logger for services
services.AddScoped<ILogger, Logger<Service>>();
// Services
services.AddScoped<IHumanResourcesService, HumanResourcesService>();
services.AddScoped<IWarehouseService, WarehouseService>();
services.AddScoped<ISalesService, SalesService>();
// Configuration for Help page
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "OnLine Store 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();
// todo: Set port number for client app
app.UseCors(policy =>
{
// Add client origin in CORS policy
policy.WithOrigins("http://localhost:4200");
policy.AllowAnyHeader();
policy.AllowAnyMethod();
});
// Configuration for Swagger
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "OnLine Store API");
});
app.UseMvc();
}
}
#pragma warning restore CS1591
}
这个类是Web API项目的配置点,在这个类中有依赖注入的配置,API的配置和其他设置。
对于Web API项目,这些是控制器的路由:
动词 |
路线 |
描述 |
GET |
api/v1/Sales/Order |
获得订单 |
GET |
api/v1/Sales/Order/1 |
按ID获得订单 |
GET |
api/v1/Sales/CreateOrderRequest |
获取模型以创建订单 |
GET |
api/v1/Sales/CloneOrder/3 |
克隆现有订单 |
POST |
api/v1/Sales/Order |
创建新订单 |
DELETE |
api/v1/Sales/Order |
删除现有订单 |
我们可以看到在每条路径中都有一个v1,这是因为Web API的版本是1,并且该值是Route在Web API项目中的控制器的属性中定义的。
第06章——Web API的帮助页面
Web API使用Swagger来显示帮助页面。
需要以下软件包才能显示Swagger的帮助页面:
- Swashbuckle.AspNetCore
Swagger的配置位于Startup类中,Swagger附加的配置在ConfigureServices方法中:
// Configuration for Help page
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "OnLine Store 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(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "OnLine Store API");
});
Swagger允许显示控制器中操作的描述,这些描述来自xml注释。
帮助页面:
帮助页面中的模型部分:
Web API的帮助页面是一种很好的做法,因为它提供了有关客户端API的信息。
第07章——Web API的单元测试
现在我们继续为Web API项目添加单元测试,这些测试在内存数据库中工作,单元测试和集成测试之间有什么区别?对于单元测试,我们模拟Web API项目的所有依赖项,对于集成测试,我们运行一个模拟Web API执行的过程。我的意思是模拟Web API(接受Http请求),显然有关于单元测试和集成测试的更多信息,但是在此文中这个基本想法就足够了。
什么是TDD?测试在这些日子里很重要,因为通过单元测试,在发布之前很容易对功能进行测试,测试驱动开发(TDD)是定义单元测试和验证代码行为的方式。TDD的另一个概念是AAA:安排,行动和断言 ; arrange是用于创建对象的块,act是用于放置方法的所有调用的块,assert是用于验证方法调用的结果的块。
现在,看看SalesControllerTests类:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using OnLineStore.Common;
using OnLineStore.Core.BusinessLayer.Requests;
using OnLineStore.Core.BusinessLayer.Responses;
using OnLineStore.Core.DataLayer.Sales;
using OnLineStore.Core.EntityLayer.Sales;
using OnLineStore.WebAPI.Controllers;
using OnLineStore.WebAPI.Requests;
using OnLineStore.WebAPI.UnitTests.Mocks;
using Xunit;
namespace OnLineStore.WebAPI.UnitTests
{
public class SalesControllerTests
{
[Fact]
public async Task TestGetOrdersAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersAsync));
var controller = new SalesController(logger, service);
// Act
var response = await controller.GetOrdersAsync() as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetOrdersByCurrencyAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByCurrencyAsync));
var controller = new SalesController(logger, service);
var currencyID = (short?)1000;
// Act
var response = await controller.GetOrdersAsync(currencyID: currencyID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count() > 0);
Assert.True(value.Model.Count(item => item.CurrencyID == currencyID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrdersByCustomerAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByCustomerAsync));
var controller = new SalesController(logger, service);
var customerID = 1;
// Act
var response = await controller.GetOrdersAsync(customerID: customerID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.CustomerID == customerID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrdersByEmployeeAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByEmployeeAsync));
var controller = new SalesController(logger, service);
var employeeID = 1;
// Act
var response = await controller.GetOrdersAsync(employeeID: employeeID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.EmployeeID == employeeID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrderAsync));
var controller = new SalesController(logger, service);
var id = 1;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetNonExistingOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetNonExistingOrderAsync));
var controller = new SalesController(logger, service);
var id = 0;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetCreateOrderRequestAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetCreateOrderRequestAsync));
var controller = new SalesController(logger, service);
// Act
var response = await controller.GetCreateOrderRequestAsync() as ObjectResult;
var value = response.Value as ISingleResponse<CreateOrderRequest>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Products.Count() > 0);
Assert.True(value.Model.Customers.Count() > 0);
}
[Fact]
public async Task TestCreateOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestCreateOrderAsync));
var controller = new SalesController(logger, service);
var model = new OrderHeaderRequest
{
CustomerID = 1,
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
Comments = "Order from unit tests",
CreationUser = "unitests",
CreationDateTime = DateTime.Now,
Details = new List<OrderDetailRequest>
{
new OrderDetailRequest
{
ProductID = 1,
ProductName = "The King of Fighters XIV",
Quantity = 1,
}
}
};
// Act
var response = await controller.CreateOrderAsync(model) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.OrderHeaderID.HasValue);
}
[Fact]
public async Task TestCloneOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestCloneOrderAsync));
var controller = new SalesController(logger, service);
var id = 1;
// Act
var response = await controller.CloneOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
}
}
我们可以看到这些方法在Web API项目中对Urls执行测试,请注意测试是异步方法。
第08章——Web API的集成测试
为了使用集成测试,我们需要创建一个类来提供一个Web Host来执行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 Store.API.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(""))
{
}
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));
Server = new TestServer(webHostBuilder);
Client = Server.CreateClient();
Client.BaseAddress = new Uri("http://localhost:1234");
Client.DefaultRequestHeaders.Accept.Clear();
Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public void Dispose()
{
Client.Dispose();
Server.Dispose();
}
public HttpClient Client { get; }
protected virtual void InitializeServices(IServiceCollection services)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var manager = new ApplicationPartManager();
manager.ApplicationParts.Add(new AssemblyPart(startupAssembly));
manager.FeatureProviders.Add(new ControllerFeatureProvider());
manager.FeatureProviders.Add(new ViewComponentFeatureProvider());
services.AddSingleton(manager);
}
}
}
现在,这是SalesTests类的代码:
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using OnLineStore.WebAPI.IntegrationTests.Helpers;
using Xunit;
namespace OnLineStore.WebAPI.IntegrationTests
{
public class SalesTests : IClassFixture<TestFixture<Startup>>
{
private HttpClient Client;
public SalesTests(TestFixture<Startup> fixture)
{
Client = fixture.Client;
}
[Fact]
public async Task TestGetOrdersAsync()
{
// Arrange
var request = "/api/v1/Sales/Order";
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByCurrencyAsync()
{
// Arrange
var currencyID = (short)1;
var request = string.Format("/api/v1/Sales/Order?currencyID={0}", currencyID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByCustomerAsync()
{
// Arrange
var customerID = 1;
var request = string.Format("/api/v1/Sales/Order?customerID={0}", customerID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByEmployeeAsync()
{
// Arrange
var employeeID = 1;
var request = string.Format("/api/v1/Sales/Order?employeeID={0}", employeeID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrderByIdAsync()
{
// Arrange
var id = 1;
var request = string.Format("/api/v1/Sales/Order/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrderByNonExistingIdAsync()
{
// Arrange
var id = 0;
var request = string.Format("/api/v1/Sales/Order/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
Assert.True(response.StatusCode == HttpStatusCode.NotFound);
}
[Fact]
public async Task TestGetCreateOrderRequestAsync()
{
// Arrange
var request = "/api/v1/Sales/CreateOrderRequest";
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestCreateOrderAsync()
{
// Arrange
var request = "/api/v1/Sales/Order";
var model = new
{
CustomerID = 1,
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
Comments = "Order from integration tests",
CreationUser = "integrationtests",
Details = new[]
{
new
{
ProductID = 1,
Quantity = 1
}
}
};
// Act
var response = await Client.PostAsync(request, ContentHelper.GetStringContent(model));
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestCloneOrderAsync()
{
// Arrange
var id = 1;
var request = string.Format("/api/v1/Sales/CloneOrder/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
}
}
不要忘记我们可以有更多的测试,我们有名称为ProductionTests的类来为ProductionController执行请求。
原文地址:https://www.codeproject.com/Articles/1160586/Entity-Framework-Core-for-Enterprise#chapter08