多层应用程序的可定制 ORM





5.00/5 (5投票s)
如何编写自己的定制ORM,面向多层应用程序
引言
选择ORM可能是一个重要的决定,它将锁定你的应用程序多年;但你可能会发现——在开发后期——当生产系统开始扩展时,其实现并没有你期望的那么可扩展。ORM的设计应该能够轻松地升级到多层环境,并且现在可以轻松地启动几个额外的虚拟机,这使得这一点尤其必要。尽管避免样板代码总是被引用为使用ORM的首要原因,但你可能仍然发现大量的样板代码散落在各处。如果以后需要升级,这将是很困难的。
但事情不必如此:这个项目的核心代码基本上是十个源文件,你可以很容易地掌握这些代码并根据自己项目的具体需求进行定制。
背景
什么是实体?
实体是没有行为的类,除了保存和读取自身;它的被动性与数据传输对象(DTO)有一些相似之处。它的目的是在不同的应用程序层和/或层之间传递数据;但它最终总会落入数据库。实体行为被假定在多层应用程序的服务层中实现。
什么是层,它们为什么重要?
层是可以运行在多台机器上的层,可以是串行或并行的。请注意,并非所有层都可以运行在单独的层上,事实上,多层边界通常由客户端和服务器各自的专用层支持。
多服务器上的多层实现当然具有更高的可扩展性——但也可能存在严重的潜在性能问题;主要是,在层内传递非常复杂的参数(如对象图)几乎不需要精力(只是传递一个引用),而层之间传递参数需要序列化和潜在的大量数据传输。在层之间传递数据也有很大的延迟,并且为了效率,最好有一个能够将多个数据项一次性打包的设计。
一些关键的设计决策
ORM可以从定制的基于XML的文件创建,也可以是具有ORM行为的实际类,并通过自定义属性标记。由于你将在代码的其他部分每天处理实体类,所以我们选择后者——这比管理另一个概念层更容易。稍后,可以生成附加工具来自动从数据库生成实体文件,或支持代码优先的实体实现,但这里不提供这些。
实体当然是模板实现的最佳候选者,但这可能导致复杂的语法——这个ORM的内部大量使用dynamic关键字来简化代码,但外部视图是简单的类。
我希望能够注入不同类型的层,例如WCF或RabbitMQ远程接口,或不同类型的序列化;特别是DAL层,需要对实体属性有一定的“友好”访问权限。因此,为了使序列化能够与不同的序列化和DAL模块一起工作,我们不能依赖自定义属性——所以我们被迫将可序列化属性设置为public
,而需要“友好”访问的属性设置为protected internal
(或将属性实现为方法调用,如“GetValue()
”)。如果你注入可选的序列化实现,你就不能依赖自定义属性来控制序列化。
Static
构造函数保证只被调用一次,使用模板化的Entity基类确保每个实体的static
构造函数都是该实体特有的。DAL的类型(本地、WCF或RabbitMQ)是可注入的,并且DALBase
基类确保了配置等通用功能是共享的。
检测和管理“脏”实体对于良好的性能是一个重要因素,这样你就不必不必要地访问数据库。有两种选择:一是使用自定义属性setter,它们也设置脏标志(这是糟糕的样板代码);二是自动使用完整对象的哈希值来检测脏实体。后者不仅在美学上站得住脚,而且因为用户经常编辑实体,然后又改变主意并恢复到原样——即使不频繁地检测到这种情况,也能防止不必要的更新,并在性能方面“物有所值”。
这个实现的一个关键因素是认识到跨进程通信可能非常昂贵——因此,这个ORM可以轻松地打包多个实体操作——例如,一个应用程序可能只使用几个复杂对象图的特定部分,并且可能还需要多个实体列表。设想一个显示员工信息的网页表单:你需要获取并显示个人详细信息,可能还有其公司、部门和直线经理的详细信息,以及(例如)职位、国家、培训课程等的下拉列表。一次性获取所有这些信息(但不是拖入整个对象图)穿过多层边界,可以带来巨大的性能提升,并且易于理解。
实体的一个实现将需要一些反射,所以将这个性能开销一次性地通过实现一个static
构造函数来完成所有繁重的工作,这样后续的使用就不需要这些昂贵的性能操作了。高效的Getter和Setter意味着外部开发人员不必处理复杂的反射代码,如果他们没有经验的话。
使所有接口都可注入(序列化器、传输机制、缓存),这样你就可以轻松地插入自己的实现。
数据访问层(DAL)功能
实体由数据访问层(DAL)支持。除了管理CRUD操作外,这里的DAL还执行以下操作:
- 管理可注入的序列化
- 管理可注入的缓存
- 管理可注入的DAL,如WCF或RabbitMQ
- (乐观地)检测数据库中过时的数据,并阻止会覆盖其他已保存数据的更新
- 维护“脏”标志,自动更新仅脏数据
- 支持跨层的延迟加载
- 支持预加载多个实体(包括预加载通常延迟加载的对象)
- 支持在单个事务或多个事务中创建和更新对象图中的多个实体
- 支持将自定义SQL与实体操作一起批处理
- 提供“部分实体”,出于安全原因或支持从缓存共享数据
- 支持针对实际本地数据库进行验证,即使跨层,这也对于生产环境是安全的
- 支持只读字段,如计算字段或时间戳字段
- 支持标识字段
- 支持“即时发送”(“离线”或“单向”)实体操作
- 不需要特定类型的标识键,如Guid,甚至可能不需要定义任何标识键
其他ORM支持列表管理,但我不认为列表最好作为对象数组来管理——这只会增加从SQL Server金属提取数据到网格的开销。列表管理是一项专业业务,需要能够渐进地管理大量数据,同时灵活地进行排序和过滤,所有这些都不会导致缓存或传输缓冲区过载。这些与ORM的要求非常不同。请查看我在CodeProject上的文章《MVC的轻量级AjaxGrid用户控件》。
非功能性选项
非功能性特性是指那些有利于开发过程而非具体功能的特性。这些是
- 客户端和服务器代码保存在同一个DLL中,便于同步机器之间的更新
- 客户端和服务器还可以在单个共享DLL中共享实体定义,确保它们也保持同步
- 对
entity.config
的单个更改即可切换本地或远程DAL层,极大地简化了调试和系统开发 - 可以在运行的系统中关闭缓存,并设置备用缓存,这简化了逐步升级到更新的实体版本,同时旧系统继续运行
- 如果您将所有DLL构建到同一个目标目录,这将极大地简化管理多个服务器并保持它们同步的任务(只需复制该目录中的所有文件,就不会影响每台服务器的配置)
要求和限制
- 目前,所有表必须通过单个键字段(但不一定是主键)进行连接
- 实体是在编译时创建的。虽然您可以动态创建或配置实体,但我认为这会使它们难以被其他代码使用。
Using the Code
设置
- 下载代码
- 在数据部分,您将找到一个测试数据库脚本,将其应用于从SQL Server Management Studio创建的空白数据库
- 编译(我使用了Visual Studio 2013 Express,.NET 4.5.1)
- 编辑Data目录中的entity.config文件(可能是连接字符串),并将其复制到c:\config\entity.config——或者您的编译常量设置为的任何位置
- 运行
EntityTest
项目中的单元测试 - 示例服务器以控制台应用程序的形式提供(尽管您通常会将它们作为Windows服务运行)
- 将
entity.config
配置为server="wcf"
或server="rabbit"
(一行代码),然后重新运行单元测试,并运行相应的服务器 - 编写您自己的实体,并将DLL包含到您的项目中
- 查看单元测试以获取有关如何使用ORM的示例
- 查看
TestSqlTypes
实体以了解从SQL到C#类型的映射
项目详解
- ORM是ORM的实现
TestEntities
是示例实体ORMTest
是单元测试RabbitMQEntityServer
是一个简单的远程层服务器WCFEntityServer
是一个简单的远程层服务器
关键源文件详解
- Entity.cs主要管理
static
构造函数逻辑,通过反射提取字段和连接信息。利用这些数据,它生成将在数据层中使用的一些基本SQL语句。它还支持“脏”管理,并为CRUD操作提供简单的包装器 - EntityAttributes.cs定义了
Entity
、Field
和Join
类的属性,这些属性通过反射提取的额外信息进行扩展 - OrmCommands.cs定义了命令(如CRUD)的实例化,使它们能够被序列化到远程层。为了方便本地实现,每个命令都有一个虚拟函数
ToSql()
——它构建SQL——和一个虚拟函数FromSql()
——它从SQL命令读取结果。这两个虚拟函数以相同的方式遍历对象图,以便SQL命令能够与从SQL响应创建或编辑的对象相匹配 - DalBase.cs管理读取配置文件和加载指定的序列化器和DAL
- DalLocal.cs是本地ORM的实现
- DalWCF.cs使用WCF提供了一个远程ORM的客户端和服务器实现
- DalRabbit.cs使用RabbitMQ提供了一个远程ORM的客户端和服务器实现
- DataLayer.cs生成SQL以执行CRUD操作,以及级联读取和数据库验证
- Serializer.cs包含JSON和二进制序列化器的实现
- SubEntity.cs支持仅显示父实体部分子集的实体
- Data文件夹包含一个用于构建数据库的脚本和一个示例entity.config文件(复制到c:\config文件夹或类似位置)
一些实际方面
多进程、多层系统配置、部署和调试都很困难——因此,我创建了一个从一开始就易于管理和使用的环境。标准的配置数据保存在二进制目录外的单独配置文件中,因此安装可以简单地复制可执行文件和其他配置文件,而不会覆盖每台机器的特定配置。
定义实体
示例数据库
[Serializable]
[Entity("Person")]
public class Person : Entity<person>
{
public Person()
{
address2 = new LazyEntity<list<address>>
(this,"Addresses2"); // only required for Lazy Load fields
}
[SqlField("id", IsPK:true, IsIdentity:true)]
public int Id { get; set; }
[SqlField("forename")]
public string ForeName { get; set; }
[SqlField("lastname","varchar(50)")]
public string LastName { get; set; }
[SqlField("shoesize")]
public int ShoeSize { get; set; }
[SqlJoin(TableJoinType.myKey,"PersonExtraData",
"id","PersonExtraData","personid")] // one-to-one join
public PersonExtraData Extra { get; set; }
[SqlJoin(TableJoinType.myKey,"Addresses", "id",
"Address", "personid" )] // one-to-many join
public List<address> address { get; set; }
[SqlJoin(TableJoinType.fKey,"Person", "ManagerId",
"Person", "Id")] // many-to-one join (also illustrates self-join)
public Person Manager { get; set; }
[SqlJoin(TableJoinType.myKey,"Expense", "id",
"PersonExpense", "personid" )] // many-to-many join (part 1)
public List<PersonExpenseFromPerson>
PersonExpenses { get; set; } // class PersonExpenseFromPerson provides part 2
[SqlJoin(TableJoinType.myKey,"Addresses2",
"id", "Address", "personid")] // lazyload (duplicates the one-to-many join in this case)
public LazyEntity<list<address>> address2
{ get; set; } // (C# 6.0) = new LazyEntity<list<address>>(this,"addresses2"); }
}
每个实体必须
- 具有
[Serializable]
属性 - 具有
[Entity]
属性,至少有一个参数是SQL表名 - 类必须派生自
Entity<T>
- 字段属性必须具有
[SqlField]
属性,至少有一个参数是SQL字段名 Join
属性必须具有[SqlJoin]
属性,参数指定连接类型和匹配的键字段和表LazyEntity
字段在类构造函数中需要一个构造函数(至少在C# 6.0之前)
使用实体
首先,传统的CRUD操作接口...
person = new Person();
person.ShoeSize = 7;
person.Create();
person.ShoeSize = 9;
person.Update();
var p = person.Read();
var p2 = person.ReadCascade();
var list = p.List();
p.Delete();
上面每个CRUD调用都会导致一次往返到实体服务器和数据库服务器,但相反,您也可以将多个命令批处理在一起,如下所示
var results = person.SendCommand(new List<EntityOperation>()
{
new EntityRead(person, new List<OrmJoin< ()
{ new OrmJoin ("Addresses"), new OrmJoin
("Manager") }), // read Person + its joins to addresses and Manager
new EntityList(jobTypes), // read list of jobtypes (for dropdown)
new EntityList(companyTypes,
new List<KeyValuePair<string,
dynamic>> () // read list of company types with custom selectors
{ new KeyValuePair<string, dynamic> ("id", 55),
new KeyValuePair<string, dynamic> ("country","uk") }}
});
person = results[0];
var jobtypes = results[1];
var companyTypes = results [2];
.. do some work ...
results = person.SendCommand
(new ListList<EntityOperation>() // update all objects in one round-trip
{
new EntityUpdate(person),
new EntityUpdate(company),
new EntityUpdate(address)
});
这里的重点是,决定加载哪些对象和扩展哪些连接的是使用该实体的代码,而不是ORM。这可能导致效率的大幅提高,因为所需的对象图由调用代码而不是ORM最好地了解。但是,代码也可以选择调用ReadCascade
,在这种情况下,将返回完整的对象图。
对于大多数操作,您可以专门定义自定义选择器,但如果没有定义并且为实体定义了主键字段,则默认使用它。
在创建或更新时,整个工作对象图可供ORM代码进行检查,因此它可以选择如何持久化对象图的脏部分。删除时,ORM会自动级联删除将阻止删除发生的连接实体。如果需要的话,也可以执行孤儿删除,但作为清理过程的一部分,可能还有更好的方法来处理它,并且我在我的实现中不这样做。
关注点
SQL如何构建
以下是一些典型的SQL语句,用于更新一列
UPDATE person SET lastname=’Mandela’ WHERE personId=454; return @@ROWCOUNT;
现在使用SQL参数防止SQL注入
UPDATE person SET lastname=@lastname WHERE personId=@id; return @@ROWCOUNT;
支持命名最佳实践
UPDATE dbo.[person] SET [lastname]=@lastname WHERE [personId]=@id; return @@ROWCOUNT;
如果数据库架构支持,请检查时间戳字段,以防止过时的更新
UPDATE dbo.[person] SET [lastname]=@lastname WHERE [personId]=@id AND [timestamp] = @timestamp;
if @@ROWCOUNT = 1 select @timestamp FROM dbo.[person] where [personId] = @id;
现在支持多个SQL语句,这需要为每个参数加上一个“命令编号”前缀,以便可以使用相同的参数名称多次
UPDATE dbo.[person] SET [lastname]=@0lastname WHERE [personId]=@0id;
UPDATE dbo.[person] SET [lastname]=@1lastname WHERE [personId]=@1id;
对于具有IDENTITY
字段的一对多对象图的创建,我们需要保留标识值供对象图中的其他实体使用,并将每个标识返回给调用者。SQL变量用于此目的
INSERT INTO dbo.[person] ([lastname]) VALUES (@lastname0);
DECLARE @Identity0 = Cast(@@Identity as int);
SELECT @Identity0 AS Identity;
INSERT INTO dbo.[address] [(street],[town],[personid]) VALUES (@street1,@town1, @Identity0);
DECLARE @Identity1 = Cast(@@Identity as int);
SELECT @Identity1 AS Identity;
INSERT INTO dbo.[address] [(street],[town],[personid]) VALUES (@street2,@town2, @Identity0);
DECLARE @Identity2 = Cast(@@Identity as int);
SELECT @Identity2 AS Identity;
对于DELETE
,所有一对一、一对多和多对多连接也必须首先删除,因此删除也必须按正确的顺序执行。您也可以对多对一执行孤儿删除,但作为清理过程的一部分,可能有更好的方法来做到这一点,而且我在我的实现中不尝试这样做。请注意,这里使用临时表而不是SQL变量,以便可以执行多个级联的DELETE
操作
SELECT [id] INTO #tmpJ1 FROM dbo.[Person] WHERE id=@0id;
DELETE FROM PersonExtraData WHERE personid IN (SELECT id FROM #tmpJ1);
DELETE FROM Address WHERE personid IN (SELECT id FROM #tmpJ1);
DELETE FROM PersonExpense WHERE personid IN (SELECT id FROM #tmpJ1);
DELETE FROM Address WHERE personid IN (SELECT id FROM #tmpJ1);
DELETE FROM Person WHERE id IN (SELECT id FROM #tmpJ1);
SELECT @@ROWCOUNT;
DROP TABLE #tmpJ1;
每个SQL命令本身都是由基本的文本块构建而成,用于执行所有字段和连接的创建、读取、更新、删除——这些都是一次性创建以提高效率,并在每个实体的静态构造函数中使用反射。
构建连接
连接可以定义为返回多少项(通过反射发现是单个对象还是List
),以及一个属性参数,该参数定义连接键是在父实体(myKey
)还是在子实体(fKey
)中。
在跨连接执行级联读取时,临时表会保留嵌套层次结构的每个级别,以便较低级别可以在同一SQL块中使用它们。请注意,多对多连接是通过一个中间表实现的,该表本身就是一个实体,并且可以包含自己的字段。但是,如果您想从B->A以及A->B进行连接,那么您将需要每个方向的逻辑实体(在示例数据库中是PersonExpenseFromExpenseItem
和PersonExpenseFromPerson
)。这提供了最大的灵活性,但并不妨碍在没有中间“链接表”实体的情况下对多对多连接进行进一步优化。
BEGIN TRANSACTION;
SELECT [id] ,[forename] ,[lastname] ,[shoesize] ,dbo.[Person].[managerid] _
INTO #tmpJ1 FROM dbo.[Person] WHERE id=@0id;
SELECT * FROM #tmpJ1;
SELECT dbo.[PersonExtraData].[personid] ,dbo.[PersonExtraData].[extradata] _
FROM dbo.[PersonExtraData] JOIN #tmpJ1 AS myjoin ON myjoin.[id]=[personextradata].[personid];
SELECT dbo.[Address].[id] ,dbo.[Address].[personid] ,dbo.[Address].[addr1] ,_
dbo.[Address].[addr2] ,dbo.[Address].[postcode] FROM dbo.[Address] _
JOIN #tmpJ1 AS myjoin ON myjoin.[id]=[address].[personid];
SELECT dbo.[Person].[id] ,dbo.[Person].[forename] ,dbo.[Person].[lastname] ,_
dbo.[Person].[shoesize] ,dbo.[Person].[managerid] INTO #tmpJ2 FROM dbo.[Person] _
JOIN #tmpJ1 AS myjoin ON myjoin.[managerid]=[person].[id];
SELECT * FROM #tmpJ2;
SELECT dbo.[PersonExtraData].[personid] ,dbo.[PersonExtraData].[extradata] _
FROM dbo.[PersonExtraData] JOIN #tmpJ2 AS myjoin ON myjoin.[id]=[personextradata].[personid];
SELECT dbo.[Address].[id] ,dbo.[Address].[personid] ,dbo.[Address].[addr1] ,_
dbo.[Address].[addr2] ,dbo.[Address].[postcode] FROM dbo.[Address] _
JOIN #tmpJ2 AS myjoin ON myjoin.[id]=[address].[personid];
SELECT dbo.[PersonExpense].[expenseitemid] ,dbo.[PersonExpense].[personid] ,_
dbo.[PersonExpense].[value of expense] ,dbo.[PersonExpense].[expenseid] _
INTO #tmpJ3 FROM dbo.[PersonExpense] JOIN #tmpJ2 AS myjoin ON myjoin.[id]=[personexpense].[personid];
SELECT * FROM #tmpJ3;
SELECT dbo.[ExpenseItem].[expenseitemid] ,dbo.[ExpenseItem].[name] _
FROM dbo.[ExpenseItem] JOIN #tmpJ3 AS myjoin ON myjoin.[expenseid]=[expenseitem].[expenseitemid];
DROP TABLE #tmpJ3;
DROP TABLE #tmpJ2;
SELECT dbo.[PersonExpense].[expenseitemid] ,dbo.[PersonExpense].[personid] ,_
dbo.[PersonExpense].[value of expense] ,dbo.[PersonExpense].[expenseid] _
INTO #tmpJ4 FROM dbo.[PersonExpense] JOIN #tmpJ1 AS myjoin ON myjoin.[id]=[personexpense].[personid];
SELECT * FROM #tmpJ4;
SELECT dbo.[ExpenseItem].[expenseitemid] ,dbo.[ExpenseItem].[name] _
FROM dbo.[ExpenseItem] JOIN #tmpJ4 AS myjoin ON myjoin.[expenseid]=[expenseitem].[expenseitemid];
DROP TABLE #tmpJ4;
DROP TABLE #tmpJ1;
COMMIT TRANSACTION ;
风格问题
模板化的Entity类型是这个实现的核心,因为它允许在通用的Entity
基类中为每个实体调用一个唯一的static
构造函数,从而保持Entity
类的整洁。然而,由于模板化的转换很麻烦,所以大量使用了dynamic
关键字。出于同样的原因,static
方法已移至DAL,并通过实例化对象的包装器进行访问(您宁愿调用person.List()
还是Entity<Person>.List()
?)。事实上,一种常见的用法是使用现有的实体对象并对其执行实体操作,例如Create
或Update
。默认情况下,在这种情况下,主键用作选择器。
动态对象的一个缺点是后期绑定可能导致错误未被发现,因此与广泛的单元测试一起工作并确保代码覆盖率高至关重要。一旦ORM本身经过测试,实体类的使用就会变得简单明了。
由于Base
类中的任何public
属性都将被序列化,所以我使用了protected internal
将Entity
属性暴露给DAL层,并使用public
方法调用来暴露可以在每个层生成的数据——而不是让这些数据被不必要地序列化和复制。
for
-each
语句非常简单,即使是初级开发人员也能轻松理解,但它可能导致代码膨胀,并出现大量的开闭括号。一般来说,我使用for
-each
进行简单的循环,并在可以使用一行代码替换复杂代码的地方使用LINQ。
例如,LINQ聚合语句可能会带来回报,但也可能令人恼火:如果您可以接受类似CSV的输出,前面带有一个逗号,例如“,a,b,c
”,那么您可以简单地这样转换输出
var x = list.Aggregate(string.Empty, (result, field) => result + "," + field.name));
但如果您想要“a,b,c
”这样的输出,您必须先选择string
s
var x = list.Select(field => field.Name).Aggregate((result, fieldname) => result + "," + fieldname));
对我来说,这是一种有点不显眼的语法。
在研究本文时遇到的一些有趣的SQL问题
像我的大多数读者一样,我猜,我是一名开发人员而不是DBA。如果是这样,有几件事可能值得您去研究
下一步?
这篇文章已经很长了,所以我决定将ORM的一些进一步开发留给“第二部分”。主要是关于服务层和缓存的讨论,而这又需要管理对象图的干净和脏部分。我还想对列表项的删除进行简化管理——目前,您需要为每个删除的列表项手动维护一个DELETE
操作列表。同时,如果您发现任何错误,请发送一个单元测试给我!
历史
- 2014年6月18日:第一个版本