65.9K
CodeProject 正在变化。 阅读更多。
Home

多层应用程序的可定制 ORM

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2014年6月19日

CPOL

15分钟阅读

viewsIcon

16669

downloadIcon

565

如何编写自己的定制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定义了EntityFieldJoin类的属性,这些属性通过反射提取的额外信息进行扩展
  • 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文件夹或类似位置)

一些实际方面

多进程、多层系统配置、部署和调试都很困难——因此,我创建了一个从一开始就易于管理和使用的环境。标准的配置数据保存在二进制目录外的单独配置文件中,因此安装可以简单地复制可执行文件和其他配置文件,而不会覆盖每台机器的特定配置。

定义实体

示例数据库

Sample Database

    [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进行连接,那么您将需要每个方向的逻辑实体(在示例数据库中是PersonExpenseFromExpenseItemPersonExpenseFromPerson)。这提供了最大的灵活性,但并不妨碍在没有中间“链接表”实体的情况下对多对多连接进行进一步优化。

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()?)。事实上,一种常见的用法是使用现有的实体对象并对其执行实体操作,例如CreateUpdate。默认情况下,在这种情况下,主键用作选择器。

动态对象的一个缺点是后期绑定可能导致错误未被发现,因此与广泛的单元测试一起工作并确保代码覆盖率高至关重要。一旦ORM本身经过测试,实体类的使用就会变得简单明了。

由于Base类中的任何public属性都将被序列化,所以我使用了protected internalEntity属性暴露给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”这样的输出,您必须先选择strings

var x = list.Select(field => field.Name).Aggregate((result, fieldname) => result + "," + fieldname));

对我来说,这是一种有点不显眼的语法。

在研究本文时遇到的一些有趣的SQL问题

像我的大多数读者一样,我猜,我是一名开发人员而不是DBA。如果是这样,有几件事可能值得您去研究

下一步?

这篇文章已经很长了,所以我决定将ORM的一些进一步开发留给“第二部分”。主要是关于服务层和缓存的讨论,而这又需要管理对象图的干净和脏部分。我还想对列表项的删除进行简化管理——目前,您需要为每个删除的列表项手动维护一个DELETE操作列表。同时,如果您发现任何错误,请发送一个单元测试给我!

历史

  • 2014年6月18日:第一个版本
© . All rights reserved.