在ASP.NET Core应用程序中实现仓储模式和工作单元的指南






3.88/5 (15投票s)
在ASP.NET Core应用程序中实现仓储模式和工作单元的指南
引言
在学习后实现仓储模式时,我遇到了许多正确实现的问题。但我没有找到一个完整的解决方案来正确实现。这促使我写了这篇文章。
本文将指导您使用ASP.NET Core中的仓储模式和工作单元创建一个小型应用程序。本文主要面向初级到中级程序员。在本文中,我想提供一个整体的实现概览。
在这里,我不想提供通用仓储模式实现的细节。每当我搜索仓储模式实现时,我都会遇到大量使用通用仓储模式的示例。
完成本文后,您将对特定仓储模式的实现有一个正确的理解。
仓储模式
仓储模式介于领域和数据映射层之间,充当内存中的领域对象集合。
当我们想要封装访问数据源的逻辑时,仓储模式很有用。在这里,仓储描述了访问数据源的类或组件。
仓储充当数据源和应用程序业务层之间的中介。它向数据源查询数据,将数据从数据源映射到业务实体,并将业务实体中的更改持久化到数据源。
我们为什么要封装?
当我们将数据访问功能与业务层解耦时,我们会转向仓储模式。
通用仓储模式在定义最常见数据操作类型(如更新、获取和删除)的通用方法时很有用。
在某些情况下,我们可能不需要所有类型仓储的通用操作。
所以我们需要特定的仓储。这取决于我们要实现的项。
在仓储模式实现中,业务逻辑与数据访问逻辑以及API与业务逻辑通过接口相互通信。数据访问层向业务逻辑隐藏数据访问的细节。详细来说,业务逻辑可以在不了解数据源的情况下访问数据访问层。
例如,业务层不知道数据访问层使用的是LINQ to SQL还是ADO.NET等。
优点
以下是仓储模式的主要优点。
隔离数据访问逻辑
数据访问功能是集中的。因此,业务层将不知道数据来自何处。它可能来自任何数据源、缓存或模拟数据。
单元测试
基于上述内容,可以理解业务层不知道数据来自何处。模拟数据访问层很容易。因此,这将帮助我们为业务逻辑编写单元测试。
我们不能为数据访问层编写任何测试吗?为什么不?我们可以为这一层编写集成测试。
缓存
由于数据访问功能是集中的,我们可以为这一层实现缓存。
数据源迁移
我们可以轻松地从一个数据源迁移到另一个数据源。这在迁移时不会影响我们的业务逻辑。
复杂查询被封装
复杂查询被封装并移动到这一层。因此,查询可以从业务层复用。
当任何开发人员擅长编写查询时,她/他可以独立地进行查询工作,而另一个开发人员可以专注于业务逻辑。
实现原则
- 每个仓储都应基于领域而不是基于数据库实体实现。
- 每个仓储不应相互联系。
IQueryable
不应该是仓储模式实现的返回类型。它们应该只返回IEnumerable
。- 它们不应该将任何数据保存/删除/添加到数据库中。所有细节都应该在内存中完成。我们可能会思考如何进行 CRUD 操作。在这里,工作单元扮演着这个角色。工作单元会将细节保存到数据库或回滚。这有什么优点?它将一次性保存仓储中发生的多个事务。
- 数据层不应实现业务逻辑。业务逻辑应在业务层实现。它们应返回数据的表示形式,业务层应封装返回或解封装请求。
项目结构
以下是我们即将实现的项目结构。请从链接下载示例。这里 PL 使用 Angular 应用程序。ASP.NET Core 用于 API 和业务层,然后是数据访问层。
业务层和数据访问层将有单独的契约(接口)。业务层和数据访问层将依赖于抽象而不是具体实现。
这是因为依赖注入。所以没有任何一层会了解另一层。这在模拟和测试时很容易。
- 表示层 (PL)
- API
- 业务层 (BL)
- 数据访问层 (DAL)
请参考下图了解应用程序流。PL 将联系 API。API 将联系 BL。BL 将联系 DAL。
我们将实现松散耦合的实现。业务层将不了解数据访问层。API 将不了解 BL。
为了实现这一点,我们将实现依赖注入 (DI)。
依赖注入 (DI)
什么是依赖注入?
高级模块不应依赖于低级模块。依赖注入主要是将具体实现注入到使用抽象(即内部接口)的类中。这使得可以开发松散耦合的代码。
详细来说,如果您的 ClassA
需要使用 ClassB
,请让您的 ClassA
了解 IClassB
接口而不是 ClassB
。通过这种执行,我们可以多次更改 ClassB
的实现,而不会破坏宿主代码。
DI 的优点
- 代码更简洁,可读性更强
- 类或对象松散耦合
- 模拟对象很容易
Using the Code
考虑以下示例进行此实现。
- 用户的 CRUD 操作
- 产品的 CRUD 操作
- 向用户添加或从用户删除产品。只能为用户分配一个产品。
数据访问层
现在我们必须确定问题的领域。根据以上示例,我们确定了两个领域。
- 用户领域
- 产品领域
根据经验法则,我们需要根据领域创建仓储。因此在此示例中,我们将为上述两个领域创建两个仓储
- 用户仓储
- 产品仓储
要创建UserRepository
和ProductRepository
,请创建将分别实现仓储接口IUserRepository
、IProductRepository
的类。
IUserRepository
public interface IUserRepository
{
void AddUser(User user);
IEnumerable<User> GetUsers();
bool DeleteUser(long userId);
User GetUser(long Id);
}
IProductRepository
public interface IProductRepository
{
void AddProduct(Product product);
Product GetProduct(long id);
IEnumerable<Product> GetProducts();
bool DeleteProduct(long productId);
IEnumerable<Product> GetUserProducts(long userId);
void AddProductToUser(long userId, long productId);
}
现在创建将实现抽象(即接口)的具体类。
这些具体类将包含实际实现。在这里,我们可以注意到
- 每个添加或删除都在内存中实现,而不是在数据源中实现
- 数据源没有更新。
UserRepository
public class UserRepository : IUserRepository
{
private readonly AppDbContext context;
public UserRepository(AppDbContext dbContext)
{
this.context = dbContext;
}
public void AddUser(User user)
{
context.Users.Add(user);
}
public bool DeleteUser(long userId)
{
var removed = false;
User user = GetUser(userId);
if (user != null)
{
removed = true;
context.Users.Remove(user);
}
return removed;
}
public User GetUser(long Id)
{
return context.Users.Where(u => u.Id == Id).FirstOrDefault();
}
public IEnumerable<User> GetUsers()
{
return context.Users;
}
}
ProductRepository
public class ProductRepository : IProductRepository
{
private readonly AppDbContext context;
public ProductRepository(AppDbContext dbContext)
{
this.context = dbContext;
}
public void AddProduct(Product product)
{
context.Products.Add(product);
}
public void AddProductToUser(long userId, long productId)
{
context.UserProducts.Add(new UserProduct()
{
ProductId = productId,
UserId = userId
});
}
public bool DeleteProduct(long productId)
{
var removed = false;
Product product = GetProduct(productId);
if (product != null)
{
removed = true;
context.Products.Remove(product);
}
return removed;
}
public Product GetProduct(long id)
{
return context.Products.Where(p => p.Id == id).FirstOrDefault();
}
public IEnumerable<Product> GetProducts()
{
return context.Products;
}
public IEnumerable<Product> GetUserProducts(long userId)
{
return context.UserProducts
.Include(up => up.Product)
.Where(up => up.UserId == userId)
.Select(p => p.Product)
.AsEnumerable();
}
}
工作单元(UOW)
从上述实现中,我们可以理解仓储应该用于
- 从数据源读取数据
- 在内存中添加/删除数据
那么添加/更新/删除如何影响数据源呢?在这里,UOW 扮演着这个角色。UOW 了解每个仓储。这有助于一次实现多个事务。
对于此实现,需要如上实现。创建一个具体UnitOfWork
,它将实现抽象,即接口IUnitOfWork
。
IUnitOfWork
public interface IUnitOfWork
{
IUserRepository User { get; }
IProductRepository Product { get; }
Task<int> CompleteAsync();
int Complete();
}
UnitOfWork
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext dbContext;
public UnitOfWork(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
private IUserRepository _User;
private IProductRepository _Product;
public IUserRepository User
{
get
{
if (this._User == null)
{
this._User = new UserRepository(dbContext);
}
return this._User;
}
}
public IProductRepository Product
{
get
{
if (this._Product == null)
{
this._Product = new ProductRepository(dbContext);
}
return this._Product;
}
}
public async Task<int> CompleteAsync()
{
return await dbContext.SaveChangesAsync();
}
public int Complete()
{
return dbContext.SaveChanges();
}
public void Dispose() => dbContext.Dispose();
}
我们已经完成了带有UOW的DAL的仓储模式实现。
以下内容可能很愚蠢。在做完这些之后,我对如何在保存数据之前从另一个仓储获取数据进行检查感到困惑。例如,在向用户添加产品时,检查用户或产品是否存在。
这种情况将违反规则,即仓储之间不应相互交互。发生了什么?我现在该怎么办?这里,我的理解是错误的。业务逻辑不应存在于仓储模式中。这只是数据访问的封装。所有逻辑验证都应移至业务层。业务层将了解所有将负责验证的仓储。
业务层
现在我们需要专注于业务层。在这一层中,我们将注入 UOW 而不是所有必要的仓储。UOW 了解所有仓储,我们可以使用 UOW 进行访问。
例如,要实现 Product
的 BL,我们将创建一个接口 IProduct
,并需要创建一个具体类 BLProduct
来实现 IProduct
。
在下面的 BLProduct
中,已经完成了所有必要的验证和业务逻辑,我们可以在 AddProductToUser
方法中看到一个多仓储用法的示例。
IProduct
public interface IProduct
{
Product UpsertProduct(Product product);
IEnumerable<Product> GetProducts();
bool DeleteProduct(long productId);
IEnumerable<Product> GetUserProducts(long userId);
bool AddProductToUser(long userId, long productId);
}
BLProduct
public class BLProduct : IProduct
{
private readonly IUnitOfWork uow;
public BLProduct(IUnitOfWork uow)
{
this.uow = uow;
}
public bool AddProductToUser(long userId, long productId)
{
if (userId <= default(int))
throw new ArgumentException("Invalid user id");
if (productId <= default(int))
throw new ArgumentException("Invalid product id");
if (uow.Product.GetProduct(productId) == null)
throw new InvalidOperationException("Invalid product");
if (uow.User.GetUser(userId) == null)
throw new InvalidOperationException("Invalid user");
var userProducts = uow.Product.GetUserProducts(userId);
if (userProducts.Any(up => up.Id == productId))
throw new InvalidOperationException("Products are already mapped");
uow.Product.AddProductToUser(userId, productId);
uow.Complete();
return true;
}
public bool DeleteProduct(long productId)
{
if (productId <= default(int))
throw new ArgumentException("Invalid produt id");
var isremoved = uow.Product.DeleteProduct(productId);
if (isremoved)
uow.Complete();
return isremoved;
}
public IEnumerable<Product> GetProducts()
{
// May implement role based access
return uow.Product.GetProducts();
}
public IEnumerable<Product> GetUserProducts(long userId)
{
if (userId <= default(int))
throw new ArgumentException("Invalid user id");
return uow.Product.GetUserProducts(userId);
}
public Product UpsertProduct(Product product)
{
if (product == null)
throw new ArgumentException("Invalid product details");
if (string.IsNullOrWhiteSpace(product.Name))
throw new ArgumentException("Invalid product name");
var _product = uow.Product.GetProduct(product.Id);
if (_product == null)
{
_product = new Product
{
Name = product.Name
};
uow.Product.AddProduct(_product);
}
else
{
_product.Name = product.Name;
}
uow.Complete();
return _product;
}
}
在AddProductToUser
方法中,我想为用户添加一个产品。因此,在为用户添加产品之前,我已在方法中进行了以下验证:
- 参数验证
- 检查产品是否已删除
- 检查用户是否存在
- 检查产品是否已添加到用户
- 最后,将产品添加到集合中
完成上述步骤后,最后保存用户产品。
在UpsertProduct
方法中,我们将实现添加或更新。如果产品不可用,则添加。如果产品可用,则更新。为此:
- 需要检查有效值
- 然后尝试获取产品并检查产品是否可用
- 如果不可用,则添加到集合中
- 如果可用,则更新集合中必要的值
完成上述操作后,保存值。
这意味着什么?它有助于控制我们何时可以保存值。我们没有在添加或更新时立即保存。我们可以在这里进行更多操作,然后最终保存。
API
随着流程的进行,我们可以看到我们已经完成了 DAL 和 BL。现在我们将 BL 注入到 API 中并执行必要的动作。
这里,我正在使用 ASP.NET CORE。我们需要将依赖项注册到服务容器中,如下所示:
// Inject BL
services.AddScoped<IUser, BLUser>();
services.AddScoped<IProduct, BLProduct>();
// Inject unit of work
services.AddScoped<IUnitOfWork, UnitOfWork>();
注册后,我们需要将此依赖项注入到控制器中。请参考以下代码。
ProductController
[Route("api/Product")]
[ApiController]
public class ProductController : ControllerBase
{
private readonly IMapper mapper;
private readonly IProduct blProduct;
public ProductController(IMapper mapper, IProduct product)
{
this.mapper = mapper;
this.blProduct = product;
}
// GET: api/Product
[HttpGet]
public IEnumerable<ProductModel> Get()
{
var products = blProduct.GetProducts();
return mapper.Map<IEnumerable<Product>, IEnumerable<ProductModel>>(products);
}
// GET: api/Product/5
[HttpGet("{id}")]
public IEnumerable<ProductModel> Get(int userId)
{
var products = blProduct.GetUserProducts(userId);
return mapper.Map<IEnumerable<Product>, IEnumerable<ProductModel>>(products);
}
// POST: api/Product
[HttpPost]
public void Post([FromBody] ProductModel product)
{
}
// DELETE: api/ApiWithActions/5
[HttpDelete("{id}")]
public void Delete(int id)
{
}
}
争议
当我开始学习和实现仓储模式时,我发现许多文章都说我们不应该将仓储模式与Entity Framework(EF)一起实现。
为什么?
因为 EF 是用仓储模式和工作单元实现的。为什么我们需要一个层来访问另一个使用相同模式实现的层?
是的,这听起来不错。对吧?
我的结论
是的,上面是一个很好的观点。在思考了以下内容后,我得出结论,我们用EF实现仓储模式并没有错。
- 将来,如果我们因任何类型的问题而迁移 ORM,那么我们的实现部分将为迁移提供更好的解决方案。
- 我们可以将复杂且大量的查询移动到 DAL 内部。
- 当我们进行单元测试时,这种实现提供了一种简单的方式来模拟 DAL。
- 我们可以只关注 DAL 进行缓存实现。
关注点
当我开始实现仓储模式时,我没有找到合适的指导。我希望这篇文章能为寻求正确实现方法的开发人员提供一个恰当的思路。
Github
参考文献
- https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design
- https://programmingwithmosh.com/entity-framework/common-mistakes-with-the-repository-pattern/
- https://www.dotnettricks.com/learn/mvc/implementing-repository-and-unit-of-work-patterns-with-mvc
- https://www.youtube.com/watch?v=rtXpYpZdOzM
- 关于依赖倒置原则、控制反转和依赖注入的教程