本文要点:
- .NET 的第三方 ORM 已经进入利基市场,其中大多数已经被淘汰。如果你比较 2008 年和现在的 ORM 列表,你会发现剩下的并没有多少。
- 在实体内部对变更进行跟踪有很多优点,其中一个是你可以有一个独立的工作对象单元。
- 每一个 ORM 开发人员都知道,如果 ORM 里包含了 LINQ Provider,它会带来无尽的麻烦。因为总是会出现各种问题,比如树结构中出现的非预期表达式。
- 当你必须在一段代码中处理大量数据时,在应用程序代码里使用存储过程会非常有用:你不需要事先把数据从数据库中传输出来。
- 对于基本的 CRUD 来说,使用存储过程会带来维护问题,存储过程数量会因此激增。
我们今年的第一次关于.NET 的采访对象是 LLBLGen Pro 的 Frans Bouma。这个工具的历史几乎与.NET 一样长,但是由于其商业产品的特殊性,它并没有其他一些免费工具那样知名。
InfoQ:LLBLGen Pro 不仅仅是一个 ORM,它是一个“实体建模解决方案”。这应该怎么理解?
Frans Bouma:一开始 LLBLGen Pro 由两部分组成:LLBLGen Pro Designer 和 LLBLGen Pro 运行时框架。你在 Designer 中对(抽象)实体模型进行建模,将模型转换两部分,一部分是源代码,另一部分是关系数据库 schema。就像其它 ORM 框架一样,LLBLGen Pro 运行时框架在实体模型源代码和关系数据库 schema 之间进行转化工作:将实体模型实例转化成数据库实例。
这两个部分一起组成一个方便的系统,借助它可以将抽象实体模型转化成可运行的源代码,用于访问关系数据库。
InfoQ:为什么你当初在设计 LLBLGen Pro 的时候不仅使用它自带的 ORM,而且支持其他的 ORM,例如 EF 和 NHibernate?
Frans Bouma: 这里面确实有许多原因。我们的 ORM,也就是 LLBLGen Pro 运行时框架,它并不是一个 POCO 框架,它的实体类继承了在运行时定义的基类。虽然这有很多优点,不过有些人想要使用 POCO 类,他们不希望他们的实体类继承不属于他们的基类。因此,他们不会使用我们的 ORM。通过为其他 ORM 开放我们的设计器,这些框架的用户现在可以使用我们的设计器来对他们的实体模型建模,而不是手工完成。如果我们将设计器限制在我们自己的 ORM,我们会失去这些客户。
另一个原因在于,对于使用 NHibernate 和 Entity Framework 的用户,没有真正好用的设计器工具,而这些 ORM 的许多用户都想使用设计器。通过在设计器中为这些框架提供支持,这些用户可以使用全功能的建模系统,并仍然使用他们想要(或必须)使用的 ORM。
最主要的原因当然是微软推动 Entity Framework 作为“官方”ORM 框架,并使它看起来没有其他竞争对手。这使得 EF 成为.NET 上使用最广泛的 ORM。但是这样产生的副作用就是,.NET 上的第三方 ORM 被驱赶到一个利基市场,其中的大多数已经被淘汰。如果你比较 2008 年和现在的 ORM 列表,你会发现剩下的并没有多少。通过支持 EF、NHibernate 和 Linq to SQL,我们依然可以作为 ISV 存在,同时与免费 ORM 竞争,尽管它们被大量开发人员使用。
InfoQ:下一个版本的 Entity Framework,也就是 EF Core,它将完全放弃设计器,只支持代码优先的实体建模。这样做的一个重要原因是它使用了大量的 XML 文件,导致在源代码管理方面效果不佳,特别是在合并分支时。那么 LLBLGen 设计器是如何处理这些问题的?
Frans Bouma:映射文件的合并冲突实际上与典型的代码冲突是一样的,但是由于合并冲突是在 XML 文件中,而不是在 C#文件中,所以它们往往看起来更加可怕。将冲突的编辑合并到同一个 C#文件往往更加容易,因为我们能够理解这两个代码变更之间的区别,并可以合理地判断选择哪一个。但是在 EDMX 文件的 XML 中,这是很困难的,因为它们是通过复杂的元素彼此联系在一起的,我们并不知道它们做了什么。对于我们的项目格式,我们尽可能保持简单和直接:如果你查看项目文件的 XML,你就可以理解每个元素做了什么以及为什么。此外,模型元素本身的排序和命名也采用了可以最小化冲突的方式。
InfoQ:LLBLGen Pro 的运行时框架与其他 ORM(如 Entity Framework 和 NHibernate)有何不同?
Frans Bouma:每个 ORM 都有其特点和共同点。LLBLGen Pro 运行时框架和其他 ORM 相比最显着的区别之一是它在实体类实例内进行变更跟踪,因此不需要上下文或会话对象(Scott Ambler 在 ORM 中所做的旧有设计)。在实体本身内部进行变更跟踪有许多优点,一个是你可以有一个独立的工作对象单元。这使得你可以使用独立的工作对象单元来跟踪内存实体图的变更,然后可以将其存储到持久层。这将提供你想要的一切:不管在新建、更新或删除这些实体的时候,都不会产生冲突,因为这些信息就在工作单元和实体内部。
这种设计为实体类带来了无冲突解耦和持久性,而 ORM 里的 Ambler 模型无法提供这些特性:我们需要告诉集中式会话类或上下文类该如何操作实体类对象图。一个很好的例子是 Entity Framework 中的添加 / 附加 API。
除了上面这些特性外,在跟实体相关的使用场景方面,它比 EF 和 NHibernate 要快 10 倍以上,这也是很好的一个特点。
InfoQ:EF 的添加 / 附加 API 绝对是一个问题,特别是我们从富客户端(可以保持上下文开放)切换到 REST style 服务(对象从传入的 JSON 重新创建)的时候。你可以用一个简单的例子展示一下 LLBLGen Pro 是如何在服务环境中执行插入 / 更新操作的吗?
Frans Bouma:由于变更跟踪是在实体内部完成的,所以如果在.NET 客户端中使用它们,那么这些数据就会被序列化 / 反序列化。这就意味着,当你得到返回的实体结果,你可以简单地保存图:新创建的实体将被插入,需要被更新的实体将得到更新。这个过程并不需要微管理。以下是一个接受客户实体作为参数的方法示例,客户实体可能在它的“订单”集合里引用到订单实体,而订单实体可能还包含对其他实体的引用:
复制代码
public void StoreChanges(CustomerEntity myCustomer) { using(var adapter = new DataAccessAdapter()) { adapter.SaveEntity(myCustomer); } }就是这样。当然你也可以使用客户端中的工作单元,通过如下代码提交保存:
复制代码
public void StoreChanges(UnitOfWork2 uow) { using(var adapter = new DataAccessAdapter()) { uow.Commit(adapter); } }第二个例子将持久性与应用程序本身所需的工作管理单元分开。有人可能会注意到 UnitOfWork2 的后缀“2”:其实我们的框架支持两种范式:自助服务(实体自身的持久性,比如 myCustomer.Save(),并且支持延迟加载)和上面例子中的适配器。适配器是在自助服务之后引入的,当时我们不想把适配器的不同接口和类绑定到名称“Adapter”上,所以我们使用了一个数字后缀。这是我们比较后悔的一个决定,但是一旦决定了就必须坚持下去,因为要避免大量重大更改,以免影响客户的使用。所以我们保留了后缀。
InfoQ:审计是安全性问题中越来越重要的一个部分。过去,我们只需要记录最后一次接触记录的人就足够了,但是现在我们经常需要一个完整的变化历史记录,甚至是读取数据的时间列表。你可以谈一下 LLBLGen Pro 是怎样处理授权和审计的吗?
Frans Bouma: LLBLGen Pro 运行时框架具有内置的审计和授权功能。这些功能是通过 auditor 和 authorizer 类完成的,这些类的实例可以在运行时被添加到实体实例中。而且,你也可以选择通过覆盖一些方法来实现。实体中的 auditor 对象会在各种操作下被框架调用,比如读取或写入字段时,实体被保存或删除时。开发者在这个阶段可以自由选择,包括创建新的实体,比如映射到审计数据表的实体。在事务的最后阶段,参与事务的实体会被询问包含审计信息实体需要持久化,这些新创建的审计信息实体就是在这个时候被保存的。
授权以相同的方式实现:当一个给定的操作需要授权,比如读取字段或者实例化一个实体对象,框架就会调用实体中的 authorizer 对象进行授权。同样,开发者可以自由选择处理授权请求的方式,只需要在最后返回一个布尔值来说明授权结果。如果授权失败,则读取的数据将会被视为“null”或“void”。
这一切对开发者来说都是透明的:authorizer 和 auditor 类(以及 validators)是在实体类之外编写的,可以通过内置的注入功能或者其他 DI 框架注入,或者通过覆盖来实现。使用实体类,你完全不必担心数据级别的审核或授权。
InfoQ:LLBLGen 设计器或 ORM 是否支持生成数据库表?
Frans Bouma: LLBLGen Pro 的设计器当然能为你创建数据库表的 DDL SQL:这是模型优先机制的关键部分。你可以对实体模型进行建模,然后单击按钮将其与项目中的相关模型数据同步。接下来,设计器将基于实体模型中的更改来更新相关的模型数据。之后,你就可以生成 DDL SQL 更新脚本,用所做的更改来更新真实数据库 schema。
数据库的创建是通过 DDL SQL 脚本实现的,因为在实际的数据库服务器环境中,DBA 的规则是首先使用脚本进行迁移测试。这对于新开始的项目来说不是大问题,但对于已经进入生产环境的系统来说(占应用程序生命周期的大部分!),在发布之前需要仔细进行迁移测试。在这些情况下,通过 DDL SQL 脚本来交付变更是最理想的方式。这也是为什么运行时不会创建表的原因:因为没有必要。如果需要从头开始,你可以从设计器生成 DDL SQL 脚本。但是一般来说,还有其他元素也是数据库 schema 的一部分,它们不能从模型中创建:视图,表值函数,存储过程。因此,新安装的应用程序通常从包含视图和其他元素的完整的数据库 schema 创建 DDL SQL 脚本开始,例如 SSMS。
InfoQ:LLBLGen Pro 运行时架构独有的一个特性是 QuerySpec。那是什么促使你开发这种 LINQ 表达式树的替代品?
Frans Bouma:在 2008 年的大部分时间里,我为我们的运行时框架编写了一个完整的 LINQ Provider,而且最后的结果我认为还是可以的。但是,每一个 ORM 开发人员都知道,如果 ORM 里包含了 LINQ Provider,它会带来无尽的麻烦。因为总是会出现各种问题,比如树结构中出现的非预期表达式。主要原因是表达式树就像一个 AST,但没有正式的语法。所以,在把具有正式语法的输入转换成 AST 时并不存在预定义的规则,它只是某种表达式树,你需要使用访问者来把它重写成你所期望的结果。
LINQ 的另一个问题是,它到 SQL 的映射不是 1 对 1 的。在某些情况下,它是这样的。但有时候你必须解释表达式树,并将其转换为符合开发人员实现意图的 SQL。解释表达式树的整体复杂性是难以想象的,这使得 LINQ Provider 变成一个非常复杂的系统。再加上与大量输入树的耦合,你将很难得到一个没有问题的程序。
从用户的角度来看,LINQ 也是非常复杂的,如果你使用过“from x in name”这样的查询,你就会知道:用户很难确定 SQL 会是什么样的(比如,它将执行正确的操作吗?)或反过来:他们知道想要执行的 SQL,但很难将其转换回 LINQ。
这些原因使我意识到我们需要一个替代的查询系统。我们的运行时框架已经有一个,但是我们的底层 API 使用的还是 2003 年的 1.0 版本的查询 API,它很冗长。我使用扩展方法构建了一个流畅的查询系统,它非常接近 SQL 的流程,同时在运行时更容易处理。它解决了用户如何编写查询语句的问题,如果他们知道 SQL(它 1:1 映射到 SQL,也可以选择一些更高级的方法,比如 Any() call),他们就知道该如何操作。对我们来说,它解决了非预期的树的问题,因为系统是简单易用的。整个查询系统写了不到 2 个月就完成了。
InfoQ:你能给出一些 QuerySpec 可以做但 EF 的 LINQ Provider 却做不了的例子吗?
Frans Bouma:一个例子就是 LINQ 不能为连接提供一个特定的 ON 子句:在 LINQ 中,连接总是使用“a = b”谓词的形式。它不能给你提供一个类似’A JOIN B ON A.F> B.F’这样的连接语法。但是在 QuerySpec 中,你就可以实现:On 子句只是一个谓词表达式:它可以是任何你想要的子句。此外,你可以进行全连接、右连接和左连接,而无需了解复杂的结构。
QuerySpec 仍然是一个高级查询 API,所以它不像 jOOQ 一样在.NET 中为你提供每个 SQL 关键字:它抽象出 SQL 语句细节,而且没有作用域和范式转移问题。
InfoQ:你对存储过程和在应用程序代码中单独生成查询有什么看法?
Frans Bouma:当你开始解决一个问题时,你会发现一连串的其他问题。十多年前,人们对存储过程的利弊进行了热烈的讨论,作为 ORM 的开发者,我当然参与了这些辩论,我认为存储过程被高估了,我们不应该使用存储过程。这个观点在当时算是应了景,但如果将它作为真实的建议却有点太过极端。在应用程序代码中使用存储过程可能会非常有用,换句话说,当你需要在代码里处理大量的数据时,让代码的执行尽量地接近数据会带来很大的好处,因为你不需要把数据从数据库传输到其它域,而是直接在数据上执行代码。而且,SQL 作为面向集合的语言更适合于表达面向集合的逻辑,而不是在 C#中使用一系列命令式语句。
也就是说,对于基本的 CRUD,比如插入 X、更新 X、删除 X,使用存储过程会带来维护问题,因为应用程序代码所需的任何操作都必须在存储过程 API 中进行:所以对该 API 的修改变得很困难,因为存储过程也可能被其他代码调用。这往往导致存储过程数量激增:为了避免影响到其它代码,我们简单粗暴地对需要变更的存储过程进行复制和修改。
存储过程依靠动态性质的更新,这也是其效果往往不如那些运行时生成的 SQL 的另一个原因:哪些字段更新只有在运行时才知道,所以使用 SQL 语句生成更有效率,而不是用一个通用的存储过程简单地更新每个字段。图表获取 / 预先加载也很难用存储过程操作,因为这些本质上也是动态的。
LLBLGen Pro 设计器允许你在模型中使用存储过程,然后生成可供调用的方法。无论是模型优先还是数据库优先,这种方法都是可行的。你可以使用工具中的 model first -> tables 功能对实体模型建模,同时将存储过程与数据库 schema 进行同步。
InfoQ:你有没有想过在 LLBLGen 设计器上支持 NoSQL 风格的数据库?
Frans Bouma:是的,从 v5 版本以来,我们已经开始支持“派生模型”,它是在实体模型上定义的模型,由派生元素组成,你可以将其视为文档或 DTO。这些派生的元素是分层的,它们派生自实体,并且可以包含一些非正式的字段,比如其它相关的派生元素,这跟为 NoSQL 文档数据库设计文档有点类似。
设计器为这些派生模型生成.NET 代码,然后可以与 RavenDB、MongoDB 或任何其他文档数据库(如微软的 DocumentDB)一起使用。其背后的想法是,在 nosql 数据库中使用的文档模型实际上是从抽象实体模型派生出来的。通过在 LLBLGen Pro 设计器中对它们进行定义,对实体模型所做的更改将贯穿到在其上定义的派生模型,因此,在运行时使用文档数据库的文档定义有一个理论基础:它不存在于其他地方,因为不存在 schema(除了序列化为 JSON 的 POCO 之外)。
目前实体模型仍然需要一个关系映射,但在不久的将来这种情况可能会改变。
关于被访者
Frans Bouma是 LLBLGen Pro 的创建者和主要开发人员,LLBLGen Pro 是领先的.NET 的 ORM 和实体建模解决方案。Frans 有超过 22 年的专业软件工程师经验。自 2003 年以来,他全职开发 LLBLGen Pro 和他的公司 Solutions Design 的另一个项目:ORM Profiler。在此之前,他在各种项目中广泛涉猎各种技术,从 VB 到 C++。在业余时间,Frans 喜欢在游戏中截图并编程实现必要的工具:从使用 HLSL 的着色程序到使用 C++ 和 x86/x64 汇编程序的视频工具。
查看英文原文: Interview with Entity Modelling Tool Creator, Frans Bouma
感谢薛命灯对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论