注:本文翻译自《Development For Winners》的 Repository 章节。英文原文连接:https://grofit.gitbooks.io/development-for-winners/content/development/general/data-patterns/repository.html
这是一个更高层级的模式,抽象了数据的访问和查找,例如在你的游戏中有一个存储物品的仓库, 你想要使用某种方式来取出带有特定名字的武器或者将一个新的物品存储到仓库中等等,仓库模式可以让你以一种更加简单合理的方式来做这些事情。
错误的看法
之所以在这里提到是因为很多人对这个模式都有着错误的理解,你可以现在就去Google上搜索一下,几乎每个有关于仓库模式的连接中的内容都是提倡要有一个 IRepository<T> 的接口,然后每个类型都继承这个借口做自己的实现,例如 UserRepository : IRepository<User> ,其中会包含仓库的标准操作 CRUD 以及适用于 User 的特殊方法,像是 GetAllActiveUsers。
当CRUD在仓库或者数据的语境中被提到时,它们指的是 Create,Retrieve,Update,Delete(通常所说的增删查改) 四个单词的首字母的缩写。这些就是与数据交互的常用方式,尽管有时你可能会使用不同的名字,像是 Get,Save,Update,Remove,但它们仍然符合 CRUD 的概念,因此,与其说它是一种模式,不如说它更像是一种概念。
我不喜欢这种方法,我有一个更好的方法,有着更好的模块化,可重用性以及灵活性。前者的方法中存在很大问题的地方是它倾向于将所有的逻辑放在仓库中,因此,你的 UserRepository 不仅有基本的 CRUD 方法,也包含大量的业务逻辑,每一次当你想要增加更多的查找类型时,它就像滚雪球一样越来越庞大。
制作仓库
让我们从制作一个仓库的接口开始,后续几乎所有的例子都基于此。
public interface IRepository<TItem, TKey>
{
TItem Get(TKey key);
void Save(TItem item);
void Update(TItem item);
void Delete(TItem item);
}
在我们继续之前先让我们看一下这个基础的东西。这个接口有两个泛型的类型,第一个是用于检索和存储的数据对象,像是你的物品,角色,任务等,第二个是这个资源的键值类型。
如果你没有用过关系型的数据库的话,这个键值是可以省略的,在这里我们将会简化,并且移除这个键值类型,因为在游戏世界中你接触的更可能是内存中的扁平化类型的数据库。
现在我们已经展示了一般的仓库模式,我们将要开始一些与其他多数仓库模式不同的东西了,我们将会增加一个 Execute(执行) 方法,并且使用 Find 方法来替代 Get 方法。我们也会移除键值类型,因为 Find 方法会给我们带来更大的灵活性。
这里也包含了 HybirdQuery(混合查找) 的概念,但是我将会留待后面解释,在看过解释之后,你再决定是否要增加它。
public interface IRepository<TItem>
{
void Save(TItem item);
void Update(TItem item);
void Delete(TItem item);
IEnumerable<TItem> Find(IFindQuery<TItem> query);
void Execute(IExecuteQuery<TItem> query);
}
从代码中你可以看到,我们现在有了可以从数据源中获取 TItem 实例集合的方法,并且也有了向数据源中执行某些逻辑的方法。
模拟查找
查找和执行的接口像下面这样:
public interface IFindQuery<T>
{
IEnumerable<T> Find(IDataSource dataSource);
}
public interface IExecuteQuery<T>
{
void Execute(IDataSource dataSource);
}
这看起来可能有些令人困惑,那是因为我们还没有讨论数据源接口 IDataSource, 这是一个定义你如何访问数据的抽象的概念。如果你正在使用数据库,你可以简单的将它替换为 IDBConnection 接口,然后使用这个接口对数据库进行抽象化。在多数情况下,游戏数据都会被读进内存中,你可能只是将它作为一个包装器来包装内存中的数据列表,然而,如果你想要手动的对文件进行读写操作的话,就可以利用它将文件系统进行抽象化。
抽象数据源
在这个例子中,我们假设你的游戏数据是一些文件,例如XML/JSON或者二进制文件,在游戏运行期间,你将它们读取到内存中的一个大的列表中,所以,IDataSource 接口将会对其进行包装。
它将会像是下面这样:
public interface IDataSource<T>
{
IList<T> DataItems {get;}
public void SaveChanges();
}
接着是我们的内存列表对象的具体实现:
public class InMemoryDataSource<T> : IDataSource<T>
{
private readonly IList<T> _entries;
public InMemoryDatabase()
{ _entries = new List<T>(); }
public InMemoryDatabase(IList<T> entries)
{ _entries = entries; }
public IList<T> DataItems
{
get { return _entries; }
}
public void SaveChanges()
{
// do something like serialize back out
}
}
如果你想要的话可以暴露出一些用于查找数据的方法,让它看起来更像一个数据库,但是,我们会保持简单化,所以这里只能看到高层级的内容。然而,你可以根据自己的情况定制下面的类和接口。
实现仓库
我们已经创建了 IRepository 接口,查找类, IDataSource 接口以及实现,现在,让我们来看一下如何创建一个仓库实例,并且用它做一些查找操作。
public class InMemoryRepository<T> : IRepository<T>
{
private IDataSource<T> _dataSource;
public InMemoryRepository(IDataSource<T> dataSource)
{ _dataSource = dataSource; }
public void Save(TItem item)
{
_dataSource.DataItems.Add(item);
_dataSource.SaveChanges();
}
public void Update(TItem item)
{
// Method only saves
_dataSource.SaveChanges();
}
public void Delete(TItem item)
{
_dataSource.DataItems.Remove(item);
_dataSource.SaveChanges();
}
public IEnumerable<T> Find(IFindQuery<T> query)
{ return query.Find(_dataSource); }
public void Execute(IExecuteQuery<T> query)
{
query.Execute(_dataSource);
_dataSource.SaveChanges();
}
}
我们有一个可以与数据源进行交互的仓库,而不是为每一种类型都创建一个仓库。像上方例子中展示的我们不需要去更新数据项的实例,因为所有的数据项都是引用类型(在当前的情景中),因此,你修改的数据项将会自动的在内存中进行更新。
在上方的例子中你可能已经注意到了,每次交互我们都进行了存储操作。现在看起来是不错的,然而,在未来你可能想要在一系列修改操作过后,再统一的进行一次存储,像是数据库的事务处理。在当前的情景下,你可以简单的创建一个 IRepository 接口的实现,不进行自动的存储操作,然后创建事务处理来管理存储。这个被称作是工作单元模式(Unit Of Work Pattern),我们稍后会介绍。当然,如果你使用的是文件系统仓库(FileSystemRepository),IDataSource 接口是一个文件系统的处理,那么每次只要有修改发生,你就必须手动的更新文件系统,或者数据库,这将对性能产生很大的损耗。
使用案例
不论如何让我们为上方的代码做一个快速的使用案例。
var aLotOfUsersFromAFile = // imagine this is populated;
var userDataSource = new InMemoryDataSource<User>(aLotOfUsersFromAFile);
var userRepository = new InMemoryRepository<User>(userDataSource);
var getActiveUsersQuery = new GetActiveUsersQuery();
var activeUsers = userRepository.Find(getActiveUsersQuery);
实现查找操作
现在,上方的例子像是异想天开,但是你想象一下:有一个用户(User)模型,我们需要获得所有的活跃用户,我们可以用 GetActiveUsersQuery (是一个 IFindQuery 接口的实现)来表示这个查找,让我们想象一下这个查找的实现,并且看一下它是如何工作的。
public class GetActiveUsersQuery : IFindQuery<User>
{
public IEnumerable<User> Query(IDataSource<User> dataSource)
{
return dataSource.DataItems.Where(x => x.IsActive);
}
}
我们只是再一次补充了 User 类,但是这里展示了如何将查找逻辑分离出来,放到指定的类中。
之前提到过,在普通的仓库模式中,查找逻辑是被放在仓库内的,所以,你通常必须要写越来越多的方法来暴露这些逻辑,但是,使用了查找的概念,就可以将查找的内容进行包装以及重用,你可以保持仓库对象的轻量级,而把查找的逻辑放到它们自己的类中。
这么做有两个好处,一个是你无需存储背后的数据源,它会通过执行者(这个例子中的仓库)传递过来,因此你可以用一种漂亮的方式将内容分离。另一个好处是,你可以向类中传递参数来避免很多问题。所以,你可以简单的写成下面这样:
public class PredicateFindQuery<T> : IFindQuery<T>
{
private Func<T, bool> _predicate;
public PredicateFindQuery(Func<T, bool> predicate)
{
_predicate = predicate;
}
public IEnumerable<T> Query(IDataSource<T> dataSource)
{
return dataSource.DataItems.Where(_predicate);
}
}
这允许你写任何的断言来对数据源进行查找。我仍旧提倡使用类型的查找来表示你的逻辑,以便于它能够显示出你的意图。然而,如果你仅仅是想要写出来做一些测试,或者仅仅是不想要保存实例化的查找对象,那么只需要将它设置为公有属性就可以了。
实现执行查找
现在,我们也还没有讨论 ExecuteQuery(执行查找)类型。在这里,FindQuery 做的是获取一个与查找结果匹配的只读数据的集合, ExecuteQuery 在这里所做的是以某种方式去修改数据,因此,它向查找到的只读内容中写入了一些内容,让我们快速的看一个例子:
public class BanAllCheatersQuery : IExecuteQuery<User>
{
public void Query(IDataSource<User> dataSource)
{
var allCheatingUsers = dataSource.DataItems.Where(x => x.HasCheated && x.IsActive);
foreach(var cheater in allCheatingUsers)
{
cheater.IsActive = false;
}
}
}
你可以检索到所有的骗子用户,然后让他们失效。这个方法可以用来更新一组数据,而无需对它们一一设置。
更多信息
已经花了很大的篇幅来讲解这个模式,尽管对于大部分的游戏开发场景,上述的用例都能很好的工作,但是,在Web或者App的世界中,你最终更可能处理的是数据库,在多数情况下,你可能需要修改某些部分的抽象。
抽象化和范型是完全有可能的,你可以将这种方式复制到任何的情景和下层的关系中,然而,在多数情况下这是一种无意义的努力,你应该只关注于你期望使用的东西,在游戏开发环境中,上述的例子应该能够提供足够的服务了。
现在,我们还没有涉及混合查找(Hybrid queries),就其本身而言它们不是必须的,但是这个概念给你提供了一种查找方式,可以返回定义的类型,如果你是想选择数据模型中的一小部分,或者获取所有的激活的用户名而不需要它们的整个用户模型,或者像下面这样:
public interface IHybridQuery<TInput, TOutput>
{
TOutput Query(IDataSource<TInput> dataSource);
}
public class GetUserMetaDataQuery : IHybridQuery<User, IDictionary<string, string>>
{
public IDictionary<string, string> Query(IDataSource<User> dataSource)
{
return dataSource.DataItems.Select(x => x.Metadata);
}
}
这需要在仓库中增加一个新的方法,但是如果你发现你需要剔除组件之间的数据杂项,这个方法将会给你更多的灵活性,来决定你将怎样获取到数据。
如果你打算设置键值的话,你可以增加 Get(Tkey key) 方法,这可以让你轻松的获取到指定Id的模型,在多数情况下这需要你的模型有一个描述键值的接口。在我所熟知的游戏开发领域中大部分人不会这样做,除非你是要和数据库打交道。