Kerosene 动态数据服务
使用 Kerosene ORM 动态实现 Repository 和 Unit of Work 模式
引言
本文分为三个主要部分。第一部分提出了一种动态且独立的方法,将仓库模式和单元工作模式相结合,实现动态数据服务
解决方案,从而扩展它们的功能,并克服了它们当前大多数常见实现的烦恼。此提案采用独立的方式构建,只要所讨论的概念得到支持,就可以使用任何任意的底层数据库或 ORM 技术来实现。
第二部分介绍了基于Kerosene ORM
的“实体映射”操作模式的具体实现。即使使用此实现不是必需的,您也可以看一下 Kerosene ORM 入门文章,其中包含有关此库及其扩展功能的背景信息。
第三部分包含了一组关于如何使用提议的动态数据服务
解决方案的示例。它带有三个完整的 N 层项目,每个项目都有自己的前端、中间层和后端库,可用于遵循本文的示例,也可以用作启动您自己项目的模板。
一些动机
如今,除了最简单的企业应用程序外,所有应用程序都使用某种形式的仓库和单元工作模式。许多应用程序依赖于它们使用的特定 ORM 或数据库技术提供的功能,甚至没有意识到有时这些技术仅在一定程度上实现了这些模式。其他应用程序则采取抽象的路线,为这些模式开发特定的接口,然后将它们与它们将要使用的底层 ORM 或数据库技术绑定。无论哪种情况,总的来说,当前的实现都有一些限制和烦恼,让我们感到不舒服。我们将在本文中尝试克服它们。
让我们从定义我们将要理解的关于这些模式的含义开始。我在这里无意原创,因为我已从许多来源(包括 Martin Fowler 的网站)复制并改编了以下内容。
- 仓库充当领域对象(也称为实体或 POCO 类实例)与数据映射和数据库层之间的中介,充当访问这些领域实体的类似内存集合的接口。仓库将您的应用程序与底层数据库或 ORM 技术的细节隔离开来,提供了一个集中所有查询构造的地方。
- 单元工作机制负责维护可能受到业务级别操作影响的实体列表,当该操作希望将其更改持久化到数据库中时,它会在事务上下文中协调数据库相关命令的执行,并控制可能的并发问题。
当前仓库模式实现存在的问题
当前仓库模式实现的典型情况是,我们将最终得到大量“FindByXxx()
”方法,导致我们的接口维护和演进可能成为一场噩梦(在某种程度上,这就是为什么一些作者称仓库模式为新的单例模式)。
public interface ICustomerRepository
{
ICustomer FindById(Guid uid);
ICustomer FindByFirstName(string firstName);
ICustomer FindByLastName(string lastName);
ICollection<ICustomer> FindByBirthDateYear(int year);
ICollection<ICustomer> FindByCountry(Guid countryId);
...
}
这也不是我们唯一的问题!如果我们需要的查询逻辑涉及多个表怎么办?例如,假设我们被要求查找属于给定国家的所有员工。或者属于一组国家的所有员工。然后我们想根据国家所属的地区来对该列表进行排序……
好吧,我们总是可以使用相应的工厂将国家列表加载到内存中,然后为我们找到的每个国家的员工提供一个临时列表。说到底,在内存中拥有这些列表并没有什么大不了,对吧?不,抱歉,我无法同意。在这个简单的例子中,这可能是真的,但在一般情况下,我们很容易最终将巨大的对象图加载到内存中,可能加载整个数据库。这不仅在内存消耗方面效率低下,而且在加载这些图时,它甚至可能导致应用程序响应出现明显的延迟,更不用说这种方法可能涉及到的对数据库发出的海量调用了。
好吧,我们可以使用 Entity Framework、nHibernate 或几乎所有其他 ORM 来解决这些需求,对吧?嗯,它们也有一些问题。
- 首先,如果您尝试编写任何需要涉及 POCO 类中不存在成员的查询逻辑,您就会遇到麻烦。我们被迫以这种方式编写查询来解决这种情况,这远非直观或简单。更不用说这种程序最终会产生大量代码,并且可能再次导致大量对数据库的调用。
- 其次,如果我们采取这种方法,那么我们就将我们的解决方案与其中一项特定技术绑定。它的替换或演进将成为一场维护的噩梦。例如,如果明天您的公司决定更改底层数据库引擎,使用一种新的引擎,而您的 ORM 尚不支持该引擎怎么办?您准备好承受来自另一个部门您非常喜欢的那些人的多少半笑?
- 最后,这种方法本身就意味着我们将 ORM 或数据库技术的细节可见化或“导出”到我们应用程序的业务级别。这正是我们想要通过使用这些模式来避免的事情之一。
当前单元工作模式实现存在的问题
好的,现在您已经半信半疑了。但您仍然认为这些解决方案非常符合单元工作模式的规范。无论如何,请允许我指出一些考虑因素。
- 它们通常要求您编写和维护外部映射和配置文件,遵循一些约定,或遵循关于魔法单词和配置的规则,或者用与 ORM 相关的东西污染您的 POCO 类。在我看来,这打破了关注点分离的原则,更不用说有时我们甚至无法修改包含我们 POCO 类的这些漂亮的部门库了。
- 情况甚至可能更糟,一些解决方案甚至要求您从特定于 ORM 的类派生您的类。呸!我看到这些东西时只能浑身发抖。
- 如果您想在 N 层应用程序场景中使用它们怎么办?如果您想非常严格地将前端和业务层拆分到不同的机器上怎么办?您现在如何保证 HTML 端客户端的身份在受保护的业务层机器上按预期进行?如果您想使用 WCF(例如)在这两个层之间进行通信怎么办?很多时候,您将不得不进行一些复杂的配置,同时在双方编写代码,其复杂性您真的无法,嗯,轻易地证明。
- 最后,关于将我们的解决方案与给定的具体技术绑定,以及需要在不应该拥有此类信息的级别上提供对数据库相关内容的访问和/或可见性的考虑因素也同样适用。
独立动态数据服务提案
我希望,至少,您现在认为上述考虑因素是一个需要解决的问题。上述解决方案是非常好的解决方案(它们毕竟是迄今为止这个领域的大腕),如果您准备接受我们简要看到的烦恼,这当然是一个完全有效的选择。
如果您更喜欢我们提到的内容,并且准备采用不同的方法,或者您只是对这种方法感到好奇,那么我们将在本文中提出另一种克服这些问题的方法。
该提案包括定义一小组接口,它们协同工作,为我们的应用程序提供“独立且动态的数据服务”功能。其目标是简化您的应用程序代码,减轻您的维护和演进需求,并解决上述烦恼。
独立意味着我们的接口中没有任何内容会绑定到任何特定的 ORM 或数据库技术。事实上,它们允许在前端场景中使用这些接口时,它们不会提供任何关于底层数据库或支持的 ORM 技术(如果有的话)的指示。
动态意味着我们将挤压 C# 动态和 lambda 表达式协同工作提供的可能性。Java 将需要等待它实现对这些功能的承诺,并以适当的方式。目前还没有为 C++、Visual Basic 或任何其他语言开发等效的提案。
为简单起见,本提案中包含的所有接口都已包含在下载中的 'Kerosene.DataServices.Agnostic
' 程序集中。您可以将此类库放在解决方案的其他位置,在您的前端应用程序中包含对其的引用,并开始享受它。
数据上下文接口
我们的第一个接口将是“IDataLink
”接口。它的工作是代表到任意数据库类服务的给定数据上下文或连接,完全隐藏在我们的业务级别之外,我们可以在其中为单元工作模式提供支持。它还将充当创建数据服务对象的工厂。其定义如下:
public interface IDataLink : IDisposable
{
void SubmitChanges();
void DiscardChanges();
IDataService<T> DataService<T>() where T : class;
}
如前所述,这个通用数据上下文可以引用任何类数据库服务。它可以是与常规数据库的直接连接。但它也可以是与模拟此类数据库功能的 WCF 服务连接。或者,只要服务器端部分能够满足此接口集中定义的功能,它就可以使用 REST 或 OData。在任何情况下,此接口都不需要此连接的具体详细信息,并且它不提供任何方法或属性来访问它们。此提案保持完全独立,并且对此不作任何假设。
此外,此提案不定义此接口必须如何实现,因此由实现者自行使用任何适当的底层技术。最后,此提案也不定义如何创建实现此接口的对象,是使用构造函数、IoC 框架还是任何其他机制。
请在本文第二部分稍后找到一个具体的实现示例,该示例使用我们提到的 Kerosene ORM
“实体映射”操作模式构建。
SubmitChanges()
和 DiscardChanges()
方法允许该接口为单元工作模式提供支持。
SubmitChanges()
将把所有待处理的更改操作(定义为“Insert
”、“Update
”和“Delete
”)持久化到数据库中,这些操作可能已在当前数据上下文管理的任何实体上进行了标注。我们将要求此方法代表我们打开和关闭任何必要的底层数据库连接;它将在事务上下文中执行所有更改操作,以便它们全部成功或全部失败;并且它将以适当的方式控制并发。在我们的业务应用程序级别,我们不需要有关如何满足这些要求的任何信息。一旦执行此方法,我们就要求它向我们保证,无论该方法成功还是失败,在当前数据上下文管理的任何实体上都不会剩余任何更改操作。
第二个方法,即 'DiscardChanges()
' 方法,将在任何情况下,当我们需要不再执行可能已在此数据上下文中标记的待处理更改操作时使用。调用此方法时,所有这些待处理的更改操作都将被清除和丢弃。
此接口实现 'IDisposable
' 接口,以便我们可以等待 GC 进行处理,或者我们可以在应用程序需要时处理它以及它可能持有的所有资源。请注意,在此提案中,我们假设对于一般情况,显式处理不是强制性的。
作为附注,我们假设实现此接口的对象将具有某种内部缓存来存储与之关联的实体。在业务级别,此提案不提供访问和管理此缓存(如果存在)的方法或属性,这些功能留给具体实现。
最后,'DataService<T>()
' 方法是一个工厂方法,它允许我们实例化管理给定类型实体的具体数据服务。如果此数据上下文中没有可用的数据服务来处理所请求类型的实体,此方法应返回 'null
'。类型在数据上下文中的注册,以及它们成员如何映射到数据库中的列的具体定义,不在本提案的范围内。这些活动不被认为是前端活动,而是中间层甚至后端活动,因此应在这些级别以及这些接口的具体实现中进行处理。
我们将强制要求这些实体必须是引用类型,类而不是结构,以便如果我们的应用程序从查询操作中收到 'null
' 引用,这将是一个完全有效的结果,并且在此情况下不应抛出任何异常。如果您的应用程序使用不同的方法,请随时在返回此类 'null
' 值时,或在获取空列表或结果数组时抛出您自己的异常。
数据服务接口
我们的第二个接口本身就是数据服务接口,如下所示:
public interface IDataService<T> : IDisposable where T : class
{
IDataLink DataLink { get; }
IDataQuery<T> Query();
IDataQuery<T> Where(Func<dynamic, object> where);
T Find(params Func<dynamic, object>[] specs);
T Refresh(T entity);
IDataInsert<T> Insert(T entity);
IDataUpdate<T> Update(T entity);
IDataDelete<T> Delete(T entity);
}
它的工作是为我们提供应用程序需要为相关实体执行的 CRUD 操作。在一般情况下,我们可以使用 'IDataLink
' 接口实现的总机方法获得一个实现此接口的对象。
事实上,它的 'DataLink
' 属性允许我们访问此数据服务所属的数据上下文。我们的应用程序可以根据需要拥有任意数量的数据上下文,每个上下文都有自己的一组可用数据服务,实现任何单例、每次连接或每次请求模式,其具体实现已决定使用。此提案在这方面不施加任何限制。
'Query()
'、'Where()
'、'Insert()
'、'Update()
' 和 'Delete()
' 方法是实例化表示它们名称所暗示的具体操作的对象的工厂。它们将在下面的各自部分中进行讨论。
Find()
方法将立即返回一个内容匹配给定规范的实体,最好是从数据上下文的缓存中返回(如果可用)。如果缓存中不存在此类实体,则此方法将创建并执行一个查询命令以在底层数据库或服务中查找该实体。如果最终数据库中不存在此类实体,此方法将返回 'null
' 引用。
此方法接受可变数量的“动态 Lambda 表达式”作为参数。在一般情况下,这些 DLE 被定义为 lambda 表达式,其中至少有一个参数是动态的。在我们的例子中,这些 DLE 具有 'Func<dynamic, object>
' 签名,并且必须解析为比较表达式,例如 'x => x.LastName == "Bond"
'。请注意,通过使用 DLE,我们的比较表达式可以使用我们需要的任何逻辑,将 'Column
' 元素或 'Table.Column
' 元素与任何值或任何可以解析为有效 SQL 命令的动态表达式进行比较。我们的实体类甚至不需要具有与这些规范中使用的元素相对应的成员。
这些动态 Lambda 表达式如何被解析并转换为有效的 SQL 命令超出了此通用提案的范围(有关更多详细信息,请稍后参阅 Kerosene
实现)。假定它可以处理底层数据库可以理解的任何有效 SQL 语法,并且在解析对象或表达式时,它可以提取遇到的参数并使其准备好,以便稍后在生成适当的命令时使用,从而避免 SQL 注入攻击。
Refresh()
方法以一个给定的实体作为参数,用于从数据库中刷新其内容为最新。此方法立即返回一个具有此类刷新内容的实体的引用,如果无法在数据库中找到,则返回 null
。在任何情况下,此引用都不保证是对作为方法参数传递的原始实体的引用。
最后,此接口实现 'IDisposable
' 接口,以向其数据上下文发出信号,表明它不再需要了。此处理过程的详细信息不包含在本提案中,并留给具体实现。
查询接口
查询接口代表对数据上下文表示的底层数据库的查询操作。其定义如下:
public interface IDataQuery<T> : IDisposable, IEnumerable<T> where T : class
{
IDataService<T> DataService { get; }
string TraceString();
new IDataQueryEnumerator<T> GetEnumerator();
IDataQuery<T> Top(int top);
IDataQuery<T> Where(Func<dynamic, object> where);
IDataQuery<T> OrderBy(params Func<dynamic, object>[] orderings);
IDataQuery<T> Skip(int skip);
IDataQuery<T> Take(int take);
IDataQuery<T> TableAlias(Func<dynamic, object> alias);
IDataQuery<T> From(params Func<dynamic, object>[] froms);
}
此定义允许我们以动态且灵活的方式表达我们可能需要的任何逻辑,从而避免了仓库模式其他实现中找到的 find 方法的泛滥,同时允许我们表达解决给定业务问题所需的任何复杂的任意逻辑。
它的 'DataService
' 属性获取此命令关联的数据服务引用,通过其自身的 'DataLink
' 属性,应用程序将能够访问将执行命令的数据上下文,以及最终将获得的实体存储在其缓存中的数据上下文。
它的 'TraceString()
' 方法允许应用程序根据命令的当前规范获取跟踪字符串,包括 SQL 命令文本和解析过程捕获的参数。此字符串的具体格式留给此方法的具体实现。
它的 'GetEnumerator()
' 方法允许接口实现 'IEnumerable<T>
'。它返回一个实现 'IDataQueryEnumerator
' 接口的对象,该接口又实现 'IEnumerator<T>
',最终将执行此命令。此枚举器的实现方式不由本提案定义,但预计它将返回与它关联的数据服务类型相对应的实际实体,并加载从数据库或服务中获取的内容。
它的 'Top()
'、'Where()
' 和 'OrderBy()
' 方法允许应用程序指定查询操作的最常见内容。请注意,这些方法将返回其底层命令的引用,以便它们都可以以流畅的语法使用。
Top()
接受一个整数作为参数,用于定义 'Top' 子句的内容:要返回的最大记录数。如果此整数为负值,则将其解释为清除此子句内容的信号,并且预计在此情况下不会抛出异常。
Where()
允许我们指定 'Where' 子句的内容:实体必须满足的条件才能作为此命令的结果返回。它接受一个 DLE 作为参数,该参数将用于表达这些条件,并且必须解析为布尔值。此 DLE 应接受任何有效的 SQL 代码,这些代码在其他情况下可被底层数据库接受。此方法可以根据需要使用任意多次,并且默认情况下,新内容将使用 'AND' 运算符与任何先前内容连接。
本提案定义了一个语法约定来修改此默认行为:DLE 应解析为 'And()
' 或 'Or()
' 虚拟扩展方法,其参数将是要附加到任何先前内容的 'Where' 子句的内容,如下所示:
var query = ...
.Where(x => x.Or( ... ));
虚拟扩展方法,在一般情况下,定义为不应用于元素的任何元素的方法,但由于它在 DLE 的动态上下文中被使用,因此可以用来表达我们对先前元素的任何所需限定。在我们的例子中,应用于动态参数本身的 'Or()
' 方法。
OrderBy()
允许应用程序设置用于返回结果的排序。它接受可变数量的 DLE,这些 DLE 应解析为用于排序的列名或表.列组合。默认排序(除非修改)将是升序。本提案定义了一种修改此默认排序的语法。它由在列或表.列规范后附加一个名为 'Ascending()
'、'Asc()
'、'Descending()
' 或 'Desc()
' 的虚拟扩展方法组成。例如:
var query = ...
.OrderBy(x => x.FirstName.Descending(), ...);
Skip()
和 Take()
用于指示命令应该最多丢弃前 'skip' 条记录,如果仍有可用结果,则最多返回 'next' 条记录。这两个方法都接受一个整数作为参数,如果它是负值,将指示解决方案清除它们各自的子句。这些方法的具体实现,最终将其内容转换为底层数据库支持的有效 SQL 语法,或者通过软件实现此功能,不在本通用提案中定义。
此接口实现 'IDisposable
' 接口,以指示它不再需要。此处理过程的详细信息不包含在本提案中,并留给具体实现。但是,假定在常规场景中不需要处理。
复杂查询和可扩展性语法
本提案包含一系列方法,通过这些方法,查询命令可以将应用程序所需的几乎任何高级逻辑包含在其中。它们假设,在幕后,数据上下文知道数据库类服务中的主表是什么,以便找到数据服务管理的类型对应的实体。因此,当我们使用涉及多个表的查询逻辑时,必须有一种方法可以为该主表分配别名,即使不知道具体是哪个表,以便在生成最终命令并将其发送到数据库时不会发生名称冲突。
提议使用 'TableAlias()
' 方法来实现这一功能。它接受一个 DLE 作为参数,该参数必须解析为用于该隐藏主表的别名。
var query = ...
.TableAlias(x => x.Emp);
当使用此方法时,我们可以在此具体命令的逻辑中通过使用分配给它的别名来引用该主表。在示例中,我们将 'Emp
' 别名分配给我们的主表,可能它对应 'Employees
' 表,但甚至不需要知道这一点,以便在仅此查询命令的上下文中按需使用它。其他查询命令如果需要,将需要定义它们自己的主表别名。
其次,为了将其他源合并到查询命令的 'From' 子句中,应用程序可以使用 'From()
' 方法。它接受可变数量的 DLE,每个 DLE 指定一个附加表或内容源。
var query = ...
.From(x => x.Countries.As(x.Ctry));
本提案定义了一个语法约定,用于为 DLE 中的任何元素分配别名。通过在要限定的元素上使用 'As()
' 虚拟扩展方法来实现,该方法的参数是一个 DLE,它应解析为该别名。在我们的示例中,我们将 'Ctry
' 别名分配给 'Countries
' 表,我们可以使用其中任何一个来表达我们的查询条件,这与底层数据库语法所支持的相符。
一旦我们使用了这两个方法,我们就可以编写使用定义的别名的查询逻辑,例如:
var query = ...
.Where(x => x.Emp.CountryId == x.Ctry.Id);
表规范不是 'From()
' 方法支持的唯一内容源,我们可以根据需要使用任何其他命令或 SQL 表达式。但是有一个注意事项:任何附加内容源都必须与自己的别名相关联,即使该元素不支持特定的 'As()
' 方法。
为了解决这个问题以及许多其他可扩展性场景,本提案提供了一种通过使用对先前动态元素的“直接调用”来扩展表达式语法的方法。这种机制被称为“转义语法”。任何时候,任何元素被直接调用,就像它是一个方法或函数一样,解析器必须解析调用中使用的任何参数,将它们连接起来,并将它们按原样注入。例如:
var other = ...; // whatever command, expression, or SQL text
var query = ...
.From(x => x(other).As(x.OtherAlias));
我们在这里所做的是将我们感兴趣的附加内容源包装在一个动态调用中,然后在返回的动态元素上应用 'As()
' 虚拟扩展方法。假定解析引擎能够在构建方法内容时识别并适当使用它遇到的任何别名。
此机制还可以用于包含任何元素,这些元素虽然受底层数据库支持,但可能没有被解析引擎正确拦截和处理。例如,如果我们的数据库服务支持一个名为 'Foo()
' 的方法,该方法可以应用于规范中的给定先前元素,并且它还支持一个带有参数的 'Bar()
' 方法,我们可以编写如下内容:
var cmd = ...
.Where(x => x.Element.Foo() && x.Bar(x.Emp.Id, "whatever"));
为了完整起见,此示例将生成的 'Where' 子句应如下所示:
... WHERE Element.Foo() AND Bar(Emp.Id, 'whatever')
其他查询方法
本提案未包含其他可能的方法和构造,例如 'Select
'、'Distinct
'、'GroupBy
'、'Having
' 和 'With / CTEs
'。这取决于具体实现决定它们是否带来了足够的价值,使其值得包含。
无论如何,具体实现都需要知道从数据库中获取哪些元素以及如何将它们映射到数据服务关联的 POCO 类型的相应成员。因此,在一般情况下,不需要 'Select
' 类的方法,即使有它们也是不希望的。其他提到的方法也存在类似的考虑,更严重的是,我们期望在使用数据服务提供的方法时获得我们 POCO 类的唯一实例,而不是在这种情况下获得具有任意内容集合的记录。
无论如何,下面的 Kerosene Dynamic Data Services
实现基于 <Kerosene ORM
"实体映射",它确实包含了这些方法以及更多功能。将它们导出到您定制的接口中(如果需要)是一项微不足道的任务。
(*) 如果这正是您的应用程序在给定上下文中可能需要的功能,我鼓励您查看 Kerosene ORM
的“动态记录”操作模式,该模式专门用于提供此类功能——但代价是打开潘多拉的盒子,并让您的业务层可见或访问底层数据库。但是,没有力量可以不付出代价。
Insert、Delete 和 Update 接口
'IDataInsert
'、'IDataUpdate
' 和 'IDataDelete
' 接口表示它们名称所暗示的操作。这三者共享相同的通用结构,如下所示:
public interface IDataInsert<T> : IDisposable where T : class
{
IDataService<T> DataService { get; }
T Entity { get; }
string TraceString();
void Submit();
bool Executed { get; }
}
'DataService
' 属性允许我们的应用程序访问此命令关联的数据服务引用。我们可以使用它的 'DataLink
' 属性来访问数据上下文,即将标记更改操作的数据上下文。
'TraceString()
' 方法允许应用程序根据命令的当前规范获取跟踪字符串,包括 SQL 命令文本和解析过程捕获的参数。如果不需要执行任何具体的 SQL 操作,此字符串可以为 'null
'。这种情况被称为“幂等”命令,并且可能出现在某些场景中;例如,当我们的更新命令是为实体定义的,而底层实现找不到要持久化的更改时。此字符串的格式留给此方法的具体实现。
'Entity
' 属性维护一个指向将受命令执行影响的实体的引用。或者,换句话说,是待处理更改操作将被标注的实体。
创建更改操作的实例只是创建一个能够存储其规范的对象,但仅此而已。执行它是一个两步过程。第一步是提交操作,以便它被标注或与其实体关联,以供将来执行。这可以通过使用更改操作接口的 'Submit()
' 方法来实现。
之后,当应用程序对数据上下文管理的实体上所有已标注的更改操作都满意时,它可以使用数据上下文的 'SubmitChanges()
' 方法将它们一次性持久化,如前所述。请记住,数据上下文接口还有一个 'DiscardChanges()
' 方法,可用于丢弃所有待处理的操作而不执行它们。
最后,'Executed
' 属性返回此更改操作是否已执行。请注意,所有这些接口都只能使用一次,即使它们的实例已经执行。尝试回收这些对象中的任何一个是不支持的,并且可能导致意外结果。
这些接口实现 'IDisposable
' 接口,以指示它们不再需要。例如,数据上下文的 'DiscardChanges()
' 方法将处理可能已在其管理的实体上标注的任何待处理操作。无论如何,此处理过程的详细信息不包含在本提案中,并留给具体实现。假定在常规场景中不需要显式处理。
Kerosene 动态数据服务实现
下载中包含的 'Kerosene.DataServices.Concrete
' 类库程序集包含本文所述的 '动态数据服务
' 提案的具体实现,该实现基于 Kerosene ORM
的“实体映射”操作模式库。
事实上,“实体映射”提供的核心元素与“动态数据服务”提出的核心元素之间几乎是一一对应的关系。我们不会在这里重复有关前者在我们将在入门文章及其附带文章中找到的所有讨论。相反,我们将只关注如何使用它来实现后者。
实现类
DataLink 类
这是实现 'IDataLink
' 接口的类。它内部只是 Kerosene ORM
的 'KMetaLink
' 类的包装器,该类提供了我们需要的功能。特别是,它提供了对 'IKLink
' 对象的访问,该对象最终维护着与它将使用的数据库服务的独立连接,以及它管理实体缓存所需的所有内部结构。事实上,它的构造函数将该 'IKLink
' 引用作为其参数。
为了完整起见,我再次强调,这个 'IKLink
' 对象可以引用任何有效的机制,通过该机制它可以访问数据库类服务,无论是直接连接,还是与 WCF 服务连接,或其他任何安排。如前所述,使用“动态数据服务”接口的前端应用程序甚至不需要知道哪个具体服务正在处理其请求。
DataService<T> 类
这是实现 'IDataService<T>
' 接口的类。它内部只是 'KMetaMap<T>
' 类的包装器,顾名思义,它提供了一种将您的 POCO 类成员映射到底层数据库的主表相应列的方法。它的构造函数将上述 'DataLink
' 类的实例作为参数。
它的属性和方法基本上与上面提出的通用接口定义的相同,所以我们不再在这里讨论它们。有关更多详细信息,请参阅入门文章及其附带材料。
Command 类
同样,此程序集提供了 'DataQuery<T>
'、'DataInsert<T>
'、'DataUpdate<T>
' 和 'DataDelete<T>
' 类,它们实现了上面部分中针对各自接口讨论的方法。为避免本文篇幅过长,我们在此不再重复这些讨论。
为了完整起见,'DataQuery<T>
' 类的构造函数只有一个构造函数,即此新实例将与之关联的数据服务。类似地,'DataInsert<T>
'、'DataUpdate<T>
' 和 'DataDelete<T>
' 的构造函数也接受相同的参数以及它们引用的实例的引用。
推荐的 N 层项目结构
我想指出一些关于 N 层场景中推荐的项目结构的考虑因素。这实际上并非“Kerosene Dynamic Data Services
”实现的一部分,但值得在此提及。请看以下屏幕截图:
这张图片反映了一个虚构应用程序“Blazer”的结构,这是一个极简的 HR 系统,我用它来提供下载附带的示例。实际上,这张截图与我们将在本文第三部分看到的三个示例之一相关。
前端层
这一层包含一个简单的控制台应用程序:我不想深入探讨重型客户端或 MVC 实现的细节,因为它会分散我们对正在讨论内容的注意力。更有趣的是,这一层只对中间层中的程序集可见,例如 POCO 类型程序集和 Blazer 相关的服务,正如我们在其引用中看到的:
是的,我们必须添加对独立数据服务程序集以及支持工具库的引用,以及您可能期望的其他库。请注意第二行,“Resolver
”行,我们稍后将讨论。
在 MVC 应用程序的上下文中,例如,我们可能会在此层添加大量其他元素。一个例子是此前端可能使用的视图模型。无论如何,当这些视图模型或任何其他元素需要执行任何与数据库相关的操作时,它们将回退到中间层提供的数据服务。
中间层
这一层中包含的两个主要程序集是包含 POCO 类的程序集(在上面的示例中为 'Blazer.LazyMaps.Pocos
'),以及包含我们应用程序业务逻辑的程序集,实现为一组数据服务(在上面的示例中为 'Blazer.LazyMaps.DataServices.Agnostic
')。POCO 程序集的引用对我们当前的讨论兴趣不大,但查看应用程序级别数据服务中的引用可能很有帮助:
好的,所以我们只需要在这里看到我们的应用程序将使用的 POCO 以及我们在本文第一部分中提出的通用数据服务接口。即使正如预期的那样,我一直觉得这个事实很有趣。
作为附注,我想指出,在哪里实现您的业务逻辑,归根结底,很大程度上取决于个人品味。如果您愿意,您可以选择将其实现到您的 POCO 类中,只使用通用数据服务来持久化和从您的底层数据库类服务中获取实体。我选择将业务逻辑实现到数据服务本身,为每个 POCO 类创建一个特定的接口。例如:
public interface IEmployeeDataService : IDataService<Employee>
{
decimal CalculateBonus(int year, int quarter);
}
我真的不想在这里讨论每种方法的优缺点;请选择您觉得更舒适的方法。
此应用程序相关的数据服务程序集还包含一个针对数据上下文对象的专用接口。最终,它不过是一个工厂,允许我们实例化这些应用程序级别的数据服务,如下所示:
public interface IBlazerDataLink : IDataLink
{
IRegionDataService CreateRegionDataService();
ICountryDataService CreateCountryDataService();
IEmployeeDataService CreateEmployeeDataService();
}
在这种情况下,我选择了按请求创建模式,但如上所述,我们也可以使用单例或按连接模式,这更符合我们的应用程序需求。请注意,这是一个应用程序级别的设计决策,与我们正在讨论的 Kerosene Dynamic Data Services
实现无关。
我们暂时推迟讨论 'Resolver
' 程序集。稍后它的意图会更清楚。
后端层
这一层包含我们所有的基础设施相关内容,例如初始化数据库的脚本,以及关于这些数据库具体是什么的知识。这也是实现我们应用程序具体数据服务的地方。在我们这个例子中,这一层只有一个程序集,'Blazer.LazyMaps.DataServices.Concrete
',其结构和引用如下:
让我们先看看引用。我们需要了解我们中间层中定义的所有元素,因此有了对 'Blazer.xxx
' 程序集的引用。然后我们添加了本文中讨论的独立动态数据服务的引用,以及我们打算使用的具体 'Kerosene
' 实现的引用。由于此实现后台使用了 'Kerosene ORM
' 库,特别是其实体映射操作模式,因此我们需要包含支持该库集引用的。作为附注,我们还添加了对 MS SQL Server 2008 和 2012 数据库定制 'Kerosene ORM
' 支持的引用,因为在这一层,我们恰好涉及这类知识。所有这些都非常直接。
现在让我们看一下“Maps”文件夹。由于我们在这里使用 Kerosene ORM
,因此我们定义了将我们的 POCO 类链接到数据库中相应表的具体“Maps”。请也请记住,这些映射甚至是可选的,具体取决于我们使用的具体映射模式。我将把这个讨论推迟到本文的第三部分。
向上移动下一个文件夹是“Domain”文件夹。这非常有趣,因为我们在这里包含了我们应用程序特定数据实现的实现。如果我们选择不使用它们,那么这个文件夹将是空的。在我们的例子中,请记住我们将业务逻辑移到了这些对象中,所以在这里我们提供了它们接口的适当实现。请看以下示例:
public class EmployeeDataService : DataService<Employee>, IEmployeeDataService
{
public EmployeeDataService(BlazerDataLink dataLink) : base(dataLink) { }
public new BlazerDataLink DataLink { get { return (BlazerDataLink)base.DataLink; } }
IDataLink IDataService<Employee>.DataLink { get { return this.DataLink; } }
public decimal CalculateBonus(int year, int quarter) { ... }
}
此类派生自基类 'DataService<T>
',该类为给定类型提供了核心数据服务功能,并实现了我们业务逻辑级别定义的 'IEmployeeDataService
' 接口。它的构造函数接受一个定制数据上下文对象的实例,我们稍后会看到,然后只需实现接口即可。
现在让我们再次向上移动,看一下剩下的 'BlazerDataLink
' 类。顾名思义,这是我们应用程序的定制数据上下文对象。在此示例中,我选择使用每个线程的单例实例结合工厂模式来实例化此对象,如下所示:
public class BlazerDataLink : DataLink, IBlazerDataLink
{
[ThreadStatic] static BlazerDataLink _Instance = null;
static string _ConnectionString = null;
static string _ServerVersion = null;
static bool _EnginesAreRegistered = false;
public static void Configure(string cnstr, string version)
{
_ConnectionString = cnstr == null ? null : ((cnstr = cnstr.Trim()).Length == 0 ? null : cnstr);
_ServerVersion = version == null ? null : ((version = version.Trim()).Length == 0 ? null : version);
}
public static BlazerDataLink Instance { get { ... } }
private BlazerDataLink(IKLink link) : base(link) { }
...
}
这种安排允许我们为每个线程提供一个单例实例,这对于基于 internet 的应用程序来说非常合适,因为在这种情况下,每个连接的用户都会创建一个实例,并且当用户不再连接时,GC 会自动处理它。当然,在您的具体场景中,您可以选择使用任何更适合您需求的模式。
为了支持这种安排,我包含了两个额外的元素。第一个是静态 'Instance
' 属性,其 getter 将返回当前线程正在使用的实例,如果需要则创建它:
...
public static BlazerDataLink Instance
{
get
{
if(_Instance == null)
{
if(!_EnginesAreRegistered)
{
KEngineFactory.Register(new KEngineSqlServer2008());
KEngineFactory.Register(new KEngineSqlServer2012());
_EnginesAreRegistered = true;
}
var link = KLinkDirectFactory.Create(_ConnectionString, _ServerVersion);
link.AddTransformer<CalendarDate>(x => x.ToDateTime());
link.AddTransformer<ClockTime>(x => x.ToString());
new RegionMap(link);
new CountryMap(link);
new EmployeeMap(link);
_Instance = new BlazerDataLink(link);
}
return _Instance;
}
}
...
如果每个线程的静态实例已经创建,那么我们就完成了,可以直接返回它。否则,我们需要采取一些步骤。第一步是确保我们将要使用的自定义数据库引擎已注册到引擎工厂。有关更多详细信息,请参阅入门文章。
第二步是创建将支持此数据链接的内部 Kerosene ORM
链接实例。在这种情况下,我们使用了直接链接工厂来实现这一点。在创建它的同时,我们还向其中注册了我们将用作命令参数的自定义类型的转换器。有关这两点,同样,请参阅入门文章以获取更多详细信息。
第三步是创建我们的基础结构将使用的映射,以用于它所管理的实体。我选择在这里提前创建它们,因为我个人喜欢将所有这些活动集中在一个地方。但如果您愿意,也可以选择按需创建它们。
最后,我们使用了我们新创建的 Kerosene ORM
链接来创建当前线程将使用的实例,并将其返回。
第二个附加元素是静态 'Configure()
' 方法。其想法是,启动代码可以调用此方法来指定要使用的连接字符串条目,或要使用的引擎的名称及其请求的版本。请注意,此方法是可选的,如果启动代码不使用它,Kerosene ORM
将使用应用程序配置文件中的预定义选择器条目来查找相应的值。同样,抱歉,有关更多详细信息,请参阅入门文章及相关材料。
让我们继续。请注意,除了派生自基类 'DataLink
' 外,它还实现了应用程序级别的 'IBlazerDataLink
' 接口,我们已经定义了该接口。也请记住,我们已将其创建为我们解决方案实体的数据服务的工厂。看起来阻止创建该接口未提供的数据服务是有意义的,因此我们可以通过编写以下内容来完成我们的类定义:
...
IDataService<T> IDataLink.DataService<T> { ... }
public RegionDataService CreateRegionDataService() { return new RegionDataService(this); }
IRegionDataService IBlazerDataLink.CreateRegionDataService() { return this.CreateRegionDataService(); }
...
第一行仅用于隐藏从该类的任何外部用户创建任何通用数据服务的能力。您选择实现为 'return base.DataService<T>();
',还是抛出异常,完全取决于您的意愿。
第二行和第三行只是实现了该接口创建和返回我们已知实体类型之一的特定数据服务的功能。我只包含了一个示例,因为其他已知类型的代码遵循相同的模式。
解析器
好的,现在让我们来讨论 'Resolver
' 程序集。请记住,我们的应用程序级别独立数据服务定义了 'IBlazerDataLink
' 接口,该接口最终将由我们的前端应用程序使用。问题在于如何向该应用程序提供适当的实例。
是的,在生产代码中,我可能更倾向于选择 IoC 框架,如 Unity、nInject、LinFu 或许多其他框架。但我不想分散他们的注意力,所以我走了写这个单独程序集的艰难路线,它只包含一个静态 'Factory
' 类,其唯一的 'DataLink
' 属性返回我们之前定义过的每个线程的实例。它必须是一个单独的程序集,以避免在解决方案的主要程序集之间产生循环引用,如上图所示。
就是这样!这个故事的寓意是,您应该有一种方法来实例化您应用程序特定的数据上下文实例,以您想要的方式,并使用最适合您具体应用程序需求的方式。
维护和演进考虑
为了完成本节,我只想提及两个相关事实。第一个是,在我们整个项目结构中,这是我们唯一包含底层 ORM 技术细节的地方,在本例中是 'Kerosene ORM
'。如果将来我们想替换它,那么这里就是进行所有修改的地方,因此维护和演进得到了显著简化。
第二个事实与我们对底层数据库的了解有关。冒着可能让您感到厌烦的风险,我只想重申 'Kerosene ORM
' 只需要一个简单的指示,说明您将使用哪种具体的数据库引擎,以及对数据库结构的最低限度的了解:基本上只有表和列的名称。它不需要我们编写任何外部映射或配置文件,也不需要我们用与 ORM 相关的东西污染我们的 POCO 类,显然,我们也无需从任何 ORM 基类派生这些类。
其他项目结构
下载的内容,我们到目前为止所讨论的内容,可能是您可以使用“动态数据服务”功能的最复杂场景之一。大部分复杂性来自于将其用于 N 层场景,并且我们还希望非常严格地规定谁可以看到什么,而不是来自其定义或具体实现。
事实上,例如,在重型应用程序场景中,上述结构要简单得多。更重要的是,如果我们想走最简单的路线,我们可以选择不使用它,而是使用 'Kerosene ORM
' 的“实体映射”功能。无论如何,如果我们选择使用本文的提案,我们将获得应用程序级别代码与所使用的 ORM 技术细节之间解耦结构的优势。
其他可能的场景是前端和中间层之间的通信通过 WCF 服务进行。这是 Kerosene ORM
支持的核心场景之一,我鼓励您查看入门文章及相关材料以获取更多详细信息。在此场景中,上述结构在某种程度上得到了简化,但代价是使用 WCF 服务,它们有自己的特殊性。
还有一些其他可能的配置。这个 'Dynamic Data Services
' 的定义和核心实现是以如此独立的方式设计的,以至于它可以轻松地适应几乎任何可能的配置,同时提供我们前面提到的所有好处。
示例应用程序
为了完成本文,让我们快速浏览一下下载附带的示例应用程序。它提供了三个 N 层应用程序堆栈,就像我们上面讨论的那样,每个堆栈都针对一种定义底层映射的方式进行了调整:
- 第一个是为“表映射”设计的。在此模式下,您定义的映射基本上模仿了数据库表中结构的副本。这是
Kerosene ORM
“实体映射”的默认模式,如果这种方法适合您的应用程序,毫无疑问,它能获得最佳性能,并且如果您觉得足够懒惰,并且不需要自定义,您甚至不需要为自定义映射创建类,因为它们的内容可以默认获得。
如果您有导航或依赖属性,其中您的 POCO 类的某些成员引用其他实体的实例,或实例列表,即使您可以手动处理这些依赖关系,也不是推荐的方法。所以提供了另外两个示例堆栈:
- 第二个是为“贪婪映射”设计的。在这种情况下,您的导航或依赖属性不是虚拟的,并且它们的内容与它们所属实体的 <$>内容一起立即从数据库中获取。此方法的主要缺点是,您最终可能会在内存中预加载巨大的对象图,根据这些属性的配置方式,可能加载整个数据库,这不仅会影响内存消耗,还会影响这些图加载到内存中的延迟,以及缓存管理的性能。
- 第三个是为“懒惰映射”设计的。在这种情况下,这些属性被标记为虚拟,并且
Kerosene ORM
实现的逻辑仅在这些属性被使用时加载其内容。在几乎所有情况下,这种模式都优于贪婪模式,特别是在您的表有数千条记录时。其主要缺点是,与任何其他基于代理的解决方案一样,您必须习惯它的工作方式,并接受,有时,当您的属性的 getter 首次使用时,会出现额外的数据库往返来获取它们的内容。
请,再次抱歉,参阅入门文章及其相关材料,了解有关这些模式的更多详细信息以及提高它们各自性能的一些考虑。
关于调试的说明
如果您以 DEBUG 模式编译示例应用程序,您将在控制台中收到大量消息。这有助于理解库的内部工作原理,或用于任何其他调试目的。您可以通过取消定义文件顶部的 DEBUG 符号来阻止来自某个文件的调试消息。
显然,不用说,您更愿意为您的生产环境编译 RELEASE 模式的库。
还有什么?
好了,我们完成了。本文描述的“动态数据服务”功能目前正在生产环境中使用,使用 MS SQL Server 和 Azure 数据库。我还收到了一些关于使用 Oracle 和 MySQL 数据库的初步部署通知。现在该轮到您在自己的环境中进行尝试了。祝您愉快!