仓库模式,正确实现
本文旨在解释为什么仓库模式仍然是一个不错的选择。
仓库模式最近被广泛讨论。特别是自 OR/M 库引入以来,关于其有用性的讨论。本文(这是关于数据层的系列文章的第三篇)旨在解释为什么它仍然是一个绝佳的选择。
让我们从 定义 开始
仓库在领域层和数据映射层之间进行协调,充当内存中的领域对象集合。客户端对象声明式地构建查询规范,并将它们提交给仓库以获得满足。对象可以添加到仓库中,也可以从仓库中移除,就像从简单的对象集合中一样,并且仓库封装的映射代码将在后台执行适当的操作。
仓库模式是一种抽象。它的目的是降低复杂性,并使其余代码与持久化隔离。此外,它允许您编写单元测试而不是集成测试。问题在于许多开发人员未能理解模式的目的,并创建了将持久化特定信息泄露给调用者的仓库(通常是通过公开 IQueryable<T>
)。
通过这样做,他们并没有比直接使用 OR/M 获得任何好处。
常见误解
以下是关于该模式目的的一些常见误解。
仓库是关于能够切换 DAL 实现
使用仓库并不是为了能够切换持久化技术(即更改数据库或使用 Web 服务等)。
仓库模式确实允许您这样做,但这并不是主要目的。
一个更实际的方法是,在 UserRepository.GetUsersGroupOnSomeComplexQuery()
中直接使用 ADO.NET,而在 UserRepository.Create()
中使用 Entity Framework。这样做,您可能会节省大量时间,而不是为了让复杂查询运行而与 LinqToSql 搏斗。
仓库模式允许您选择适合当前用例的技术。
单元测试
当人们谈论仓库模式和单元测试时,他们并不是说该模式允许您对数据访问层使用单元测试。
他们的意思是它允许您单元测试业务层。这是可能的,因为您可以模拟仓库(这比模拟 nhibernate/EF 接口容易得多),通过这样做,您可以为业务逻辑编写清晰易读的测试。
由于您将业务与数据分开,因此您还可以为数据层编写集成测试,以确保该层与您当前的数据库架构一起正常工作。
如果您在业务逻辑中使用 ORM/LINQ,您永远无法确定测试失败的原因。可能是因为您的 LINQ 查询不正确,因为您的业务逻辑不正确,或者因为 ORM 映射不正确。
如果您混合使用它们并模拟 ORM 接口,您也无法确定。因为 Linq to Objects 的工作方式与 Linq to SQL 不同。
仓库模式降低了测试的复杂性,并允许您针对当前层专门化您的测试。
如何创建仓库
构建正确的仓库实现非常容易。事实上,您只需要遵循一条规则
在真正需要之前,不要在仓库类中添加任何内容。
许多编码员很懒惰,试图创建一个通用的仓库并使用一个带有许多他们可能需要的方法的基类。YAGNI(你现在不需要)。您编写一次仓库类,并将其保留到应用程序生命周期结束(可能数年)。为什么因为懒惰而弄乱它?保持它干净,没有任何基类继承。这将使其更容易阅读和维护。
上述陈述是一项指导方针,而非法律。基类可能非常有道理。我的观点是,您应该先思考再添加它,这样您就是出于正确的原因添加它。
混合 DAL/业务
这里有一个简单的例子,说明如果混合使用 LINQ 和业务逻辑,发现错误是多么困难。
var brokenTrucks = _session.Query<Truck>().Where(x => x.State == 1);
foreach (var truck in brokenTrucks)
{
if (truck.CalculateReponseTime().TotalDays > 30)
SendEmailToManager(truck);
}
这给我们带来了什么?损坏的卡车?
嗯。不是。该语句是从代码中的另一个地方复制的,开发人员忘记更新查询。任何单元测试很可能只会检查是否返回了一些卡车,并将它们发送给经理。
所以我们基本上有两个问题
- 大多数开发人员可能只会检查变量名,而不是查询。
- 任何单元测试都是针对业务逻辑而不是查询。
这两个问题都可以通过仓库来解决。因为如果我们创建仓库,我们就有业务的单元测试和数据层的集成测试。
领域(业务)实体与 DAL 实体
有一个常见的讨论总是会出现。那就是仓库应该返回哪种类型的对象。
由于仓库是抽象,它应该始终返回上层想要使用的任何东西,在大多数情况下是领域实体,即封装业务代码中逻辑的对象。
我通常从直接在数据层映射我的领域实体开始。在此文章的情况下,这意味着我正在使用 Entity Framework CodeFirst 或 nhibernate 的 FluentMappings。
nhibernate 和 entity framework CodeFirst 都可以返回断开连接的实体,即不被透明代理(又名更改跟踪)包装的实体。当您禁用它时,您将获得常规的 POCO,它们既不会惰性加载也不会被跟踪。它们就像任何其他对象一样工作。
EF 和 Nhibernate 支持非公共 setter(属性),而对 entity framework 唯一的妥协是它无法使用字段初始化列表。因此,您仍然可以保护您的对象状态并使用方法来更改状态(就像任何其他定义良好的领域实体一样)。
因此,您可以直接将您的领域实体映射到 nhibernate/entity framework,而无需调整它们以适应持久化技术。但是,如果您必须开始调整您的领域实体,您当然应该创建一个特定的类来与持久化存储一起使用。
我的意思是,我在这里也尽量遵循 YAGNI。因此,我从将领域实体映射到持久化库开始。但是,我从来不需要做出任何妥协。如果我无法完全按照我需要的方式构建领域实体来表示业务,我总是会创建 DAL 特定的实体。通过这样做,我还必须在领域实体和 DAL 实体之间进行转换。但是,嘿,这正是仓库的用途。
实现
这里有一些不同的实现及其描述。
基类
这些类可用于所有不同的实现。
UnitOfWork (工作单元)
在数据层中使用时,工作单元代表一个事务。通常,工作单元会在被释放之前回滚事务,如果 SaveChanges()
未被调用。
public interface IUnitOfWork : IDisposable
{
void SaveChanges();
}
分页
我们还需要分页结果。
public class PagedResult<TEntity>
{
IEnumerable<TEntity> _items;
int _totalCount;
public PagedResult(IEnumerable<TEntity> items, int totalCount)
{
_items = items;
_totalCount = totalCount;
}
public IEnumerable<TEntity> Items { get { return _items; } }
public int TotalCount { get { return _totalCount; } }
}
借助它,我们可以创建方法,如
public class UserRepository
{
public PagedResult<User> Find(int pageNumber, int pageSize)
{
}
}
排序
最后,我们更喜欢进行排序和分页,对吧?
var constraints = new QueryConstraints<User>()
.SortBy("FirstName")
.Page(1, 20);
var page = repository.Find("Jon", constraints);
请注意,我使用了属性名称,但也可以写成 constraints.SortBy(x => x.FirstName)
。然而,在 Web 应用程序中,当我们收到作为 string
的 sort
属性时,这有点难写。
该类有点大,但您可以在 github 上找到它。
在我们的仓库中,我们可以应用这些约束(如果它支持 LINQ)
public class UserRepository
{
public PagedResult<User> Find(string text, QueryConstraints<User> constraints)
{
var query = _dbContext.Users.Where
(x => x.FirstName.StartsWith(text) || x.LastName.StartsWith(text));
var count = query.Count();
//easy
var items = constraints.ApplyTo(query).ToList();
return new PagedResult(items, count);
}
}
扩展方法也可用 于 github。
实体框架
请注意,只有当您拥有使用代码优先映射的 POCO 时,仓库模式才有用。否则,您将与实体打破抽象(= 仓库模式 then 就不那么有用了)。如果您想获得一个为您生成的基础,您可以关注 这篇文章。
我通常从一个小的仓库定义开始
public interface IRepository<TEntity, in TKey> where TEntity : class
{
TEntity Get(TKey id);
void Save(TEntity entity);
void Delete(TEntity entity);
}
然后我为每个领域模型进行专门化
public interface ITruckRepository : IRepository<Truck, string>
{
IEnumerable<Truck> FindAll();
IEnumerable<Truck> Find(string text);
}
这种专门化很重要。它保持了合同的简单性。只创建您知道需要的方法。
然后我继续进行实现
public class TruckRepository : ITruckRepository
{
private readonly TruckerDbContext _dbContext;
public TruckRepository(TruckerDbContext dbContext)
{
_dbContext = dbContext;
}
public Truck Get(string id)
{
return _dbContext.Trucks.FirstOrDefault(x => x.Id == id);
}
public void Save(Truck entity)
{
_dbContext.Trucks.Attach(entity);
}
public void Delete(Truck entity)
{
_dbContext.Trucks.Remove(entity);
}
public IEnumerable<Truck> FindAll()
{
return _dbContext.Trucks.ToList();
}
public IEnumerable<Truck> Find(string text)
{
return _dbContext.Trucks.Where(x => x.ModelName.StartsWith(text)).ToList();
}
}
工作单元
Entity Framework 的工作单元实现很简单
public class EntityFrameworkUnitOfWork : IUnitOfWork
{
private readonly DbContext _context;
public EntityFrameworkUnitOfWork(DbContext context)
{
_context = context;
}
public void Dispose()
{
}
public void SaveChanges()
{
_context.SaveChanges();
}
}
nhibernate
我通常使用 fluent nhibernate 来映射我的实体。IMHO,它的语法比内置的代码映射更漂亮。您可以使用 nhibernate mapping generator 为您生成一个基础。但您通常需要清理一下生成的文件的。
我们可以使用与 EF 相同的基本定义
public interface IRepository<TEntity, in TKey> where TEntity : class
{
TEntity Get(TKey id);
void Save(TEntity entity);
void Delete(TEntity entity);
}
nhibernate 与 Entity Framework 非常相似,但它有一个我们可以使用的 Get
方法。因此,我们创建一个基类
public class NHibernateRepository<TEntity, in TKey> where TEntity : class
{
ISession _session;
public NHibernateRepository(ISession session)
{
_session = session;
}
protected ISession Session { get { return _session; } }
public TEntity Get(string id)
{
return _session.Get<TEntity>(id);
}
public void Save(TEntity entity)
{
_session.SaveOrUpdate(entity);
}
public void Delete(TEntity entity)
{
_session.Delete(entity);
}
}
专门化接口看起来相同
public interface ITruckRepository : IRepository<Truck, string>
{
IEnumerable<Truck> FindAll();
IEnumerable<Truck> Find(string text);
}
但实现变得更小
public class TruckRepository : NHibernateRepository<Truck, string>, ITruckRepository
{
public TruckRepository(ISession session)
: base(session)
{
}
public IEnumerable<Truck> FindAll()
{
return _session.Query<Truck>().ToList();
}
public IEnumerable<Truck> Find(string text)
{
return _session.Query<Truck>().Where(x => x.ModelName.StartsWith(text)).ToList();
}
}
工作单元
public class NHibernateUnitOfWork : IUnitOfWork
{
private readonly ISession _session;
private ITransaction _transaction;
public NHibernateUnitOfWork(ISession session)
{
_session = session;
_transaction = _session.BeginTransaction();
}
public void Dispose()
{
if (_transaction != null)
_transaction.Rollback();
}
public void SaveChanges()
{
if (_transaction == null)
throw new InvalidOperationException("UnitOfWork have already been saved.");
_transaction.Commit();
_transaction = null;
}
}
典型错误
以下是使用 OR/M 时可能会遇到的错误。
不要公开 LINQ 方法
让我们说清楚。没有完整的 LINQ to SQL 实现。它们要么缺少功能,要么以自己的方式实现像急切/惰性加载这样的东西。这意味着它们都是泄露抽象。所以,如果您将 LINQ 公开到您的仓库之外,您就会得到一个泄露抽象。那时您真的可以停止使用仓库模式,而是直接使用 OR/M。
public interface IRepository<TEntity>
{
IQueryable<TEntity> Query();
// [...]
}
那些仓库真的没有任何目的。它们只是锦上添花。而是直接使用您的 ORM。
了解惰性加载
惰性加载可能很棒。但对于所有不了解它的人来说,它是一种诅咒。如果您不知道它是什么,请 Google。
如果您不小心,遍历 100 个项目的列表可能会产生 101 个执行的查询,而不是 1 个。
返回前调用 ToList()
查询直到您调用 ToList()
、FirstOrDefault()
等后才会在数据库中执行。因此,如果您想将所有与数据相关的异常保留在仓库中,您必须调用这些方法。
Get 与 Search 不同
在数据库中进行读取有两种。
第一个是搜索项目,即用户想要识别他/她喜欢处理的项目。
第二个是当用户识别出项目并想处理它时。
这些查询是不同的。在前者中,用户只想要获得最相关的信息。在后者中,用户很可能想要获得所有信息。因此,在前者中,您应该返回 UserListItem
或类似的,而后者则返回 User
。这也有助于您避免惰性加载问题。
我通常让搜索方法以 FindXxxx()
开头,而那些获取整个项目的以 GetXxxx()
开头。也不要害怕为搜索创建专门的 POCO。两次搜索不一定必须返回相同类型的实体信息。
摘要
不要懒惰并尝试创建过于通用的仓库。与直接使用 ORM 相比,它没有任何优势。如果您想使用仓库模式,请确保您正确地使用它。