写点什么

低代码平台需要什么样的 ORM 引擎?(2)

作者:canonical
  • 2023-05-15
    北京
  • 本文字数:22539 字

    阅读完需:约 74 分钟

书接上回。在上一篇文章中,我对 ORM 的设计进行了初步的理论分析,并提出了 SQL 语言的最小延拓:EQL 对象查询语言,然后在 EQL 语言的基础上实现了多种用户可定制的动态存储结构。在本文中,我将首先介绍 NopOrm 引擎中所做的一些功能取舍,以及在这种功能取舍的情况下如何解决 ORM 常见的性能问题。然后我将介绍如何实现可定制的 Dialect,如何用 200 行代码实现类似 MyBatis 的 SQL 管理功能,以及如何实现 GraphQL 集成和可视化集成等。

四. Less is More

坊间一直存在一种传言:Hibernate 入门容易,精通难。但是,什么技术不是这样呢?Hibernate 的问题在于它似乎提供了太多的选择,而且总是迫使我们在不停的选择。比如说,将关联表映射为集合对象,Set/Bag/List/Collection/Map 等众多选项哪个最好?delete/update/insert 操作要不要 cascade 到关联实体?从集合中删除是否意味着也要从数据库中删除?关联对象应该 eager 还是 lazy 加载?这种选择上的自由会让强迫症和选择困难症患者非常的纠结。如果选错了怎么办?如果改变选择影响别人的代码怎么办?如果后悔了怎么办?


如果我们总是在不断的做出选择,但是每个选择都产生了不可逆的后果,那么丰富的选择带给我们的多半不是 happy life,而是深深的悔恨。


NopOrm 引擎大幅削减了程序员的决策点,将应用层可以自行完成的封装排除到引擎内核之外。比如说,我们有什么必要一定要把关联表映射为 List,将 index 字段映射到列表元素的下标,同时还要补充一堆 List 相关的的 HQL 特殊查询语法?

4.1 ORM 自动映射

NopOrm 引擎的第一个设计决策就是:不需要补充任何额外的设计决策就可以把数据库设计的物理模型自动映射为 Java 实体模型。这里没有以数据库设计的逻辑模型为基础,因为从逻辑模型到物理模型,路径是不确定的,必须补充额外的信息,而从物理模型出发,可以自动完成,不需要再做出任何选择。


物理模型本身已经是各种设计决策最终综合作用的结果,而且将在未来稳定存在。如果在 ORM 映射中再以逻辑模型为基础,则相当于是重复表达选择过程。


具体来说,NopOrm 将每一个数据库字段都映射为实体上的一个 java 属性(与 MyBatis 相同),同时每一个外键关联再映射为一个 Lazy 加载的实体对象。也就是说,同一个字段有可能会被映射为多个属性,一个原子字段属性加上一个(或多个)关联对象属性,它们之间会自动保持同步。如果更新原子字段属性,则会自动将关联对象设置为 null,当下次访问关联对象时再从 session 中查找。


如果外键关联明确标记了要生成一对多集合属性,则会自动生成一个 Set 类型的属性(不提供不同集合类型的选择)。按照 ORM 的基本原理,同一个 Session 中对象指针保持唯一性,因此可以推论出它们自然构成一个 Set 集合,而如果采用其他类型的属性映射都必然需要增加额外的假设。因为采用指针相等,我们也不需要覆写实体对象的 equals 方法。


只有当我们明确需要使用 Component/Computed/Alias 的时候,我们才会增加对应配置,而这些配置是增量表达的,即它们的存在与否不会影响此前所有的字段和关联映射,不会影响到数据库结构定义本身。因为 NopOrm 的实现符合可逆计算原理,所以这些增量的配置可以在 delta 文件中表达,而不用修改原始的模型设计文件

4.2 再见, POJO

NopOrm 的第二个重要的设计决策是:放弃 POJO 的假定。POJO(Plain Old Java Object)对于当年的 Hibernate 来说非常的重要,因为它帮助 Hibernate 摆脱了 EJB(Enterprise Java Bean)的容器环境,并最终摧毁了 EJB 的生态系统。但是 POJO 是不足以完成工作的,Hibernate 必须通过 AOP(Aspect Oriented Progamming)技术对 Java 实体对象进行增强,为它增加附加功能,同时在内存维持一个 EntityEntryMap,用于管理附加的状态数据。


在低代码的应用背景下,实体类本身是代码生成的,而 AOP 本质上也是一种代码生成的手段(一般会采用运行期的字节码生成机制)。既然如此,一次性把最终代码生成好不就行了吗,有必要拆分成两个不同的生成阶段吗?


随着技术的发展,POJO 的隐性成本还在不断的增加,导致使用它的理由持续被削弱。


  1. AOP 的字节码生成速度很慢,而且不好进行代码调试。

  2. 使用 POJO 需要用到反射机制,性能上有较大损耗,而且 GraalVM 等原生 Java 技术需要尽量回避使用反射机制。

  3. POJO 对象无法维护比较复杂的实体持久化状态,导致无法进行有效的优化。例如,Hibernate 无法通过简单的 dirty flag 来识别实体是否被修改,被迫在内存中维护了一个对象数据的副本,每次 session flush 的时候都需要遍历对象,逐个比较对象属性数据和副本数据是否一致。这影响 Hibernate 的性能,并消耗了更多的内存。

  4. 为了实现一些必须的业务功能,我们往往需要选择实体类从一个公共的基类继承,这实际上是破坏了对象的 POJO 假定。例如为实体增加动态属性映射,自动记录实体修改前和修改后的字段数据等都需要实体的基类提供一系列的成员变量和方法。

  5. 集合属性的实现对性能不友好且容易发生误用。对象初始化的时候集合属性一般为 HashSet 类型,而当对象与 session 建立关联之后,集合属性会被自动替换为 ORM 引擎内部的 PersistSet 实现类,相当于是要新建一个集合来替换原有的 POJO 的集合。同时,按照 ORM 的实现原理,集合对象为了支持延迟加载,它必然是和某个实体绑定的,因此不允许将一个实体的集合属性直接赋值给另外一个实体的集合属性。但是 POJO 实现了 get/set 方法,很容易发生误用。例如 otherEntity.setChildren(myEntity.getChildren()) 这种调用是错误的,myEntity.getChildren()返回的集合是与 mgyEntity 绑定的,它无法再成为 otherEntity 的属性。


NopOrm 中所有实体类都要求实现 IOrmEntity 接口,并提供了一个缺省实现 OrmEntity


IOrmEntity


每一个 column 模型都具有一个唯一的 propId 属性,通过 IOrmEntity.orm_propValue(int propId)方法可以代替反射机制来存取属性数据。


所有集合属性都是 OrmEntitySet 类型,它实现了 IOrmEntitySet 接口。代码生成时实体的集合属性只会生成 get 方法,并不会生成 set 方法,从而杜绝了误用的可能。


IOrmEntitySet


自动生成代码时对每个实体会生成两个 Java 类,例如 SimsExam 和_SimsExam,_SimsExam 类每次都会被自动覆盖,而 SimsExam 类如果已经存在则会保持原有内容,因此手工调整的代码可以写在 SimsExam 类中。参见


_SimsExam


SimsExam

4.3 Lazy and Cascade all

NopOrm 中所有关联实体和关联集合都是延迟加载的,同时也不支持类继承机制。这一设计决策极大简化了 ORM 引擎内部的实现,并为统一的批量加载优化奠定了基础。


实体模型的 column 定义中可以增加 lazy 设置,指定该数据列延迟加载。缺省情况下,实体第一次加载时只加载所有 eager 的属性,lazy 属性只有具体被使用时才会被加载。同时,引擎提供了批量预加载机制,可以明确指定一次性要加载哪些数据列,从而避免多次数据库访问。


EQL 语法中没有提供 eager fetch 语法支持,因为使用 eager fetch 会导致 SQL 语句与想象中不一致。例如如果主表加载时通过 join 语句同时加载子表记录信息,则会导致返回结果集的条目数增加,且返回大量冗余数据,本身对性能并不友好。在 NopOrm 中统一采用 BatchLoadQueue 提供的批量加载队列来实现性能优化。


Hibernate 中 cascade 是由 action 动作触发的,例如调用 session.save 的时候会 cascade 执行关联属性的 save 动作。它的这个设计最初的目的可能是了性能优化,例如某些属性不需要 cascade,会被自动跳过等。但是基于动作触发会导致意料之外的结果,比如在 save 之后如果再次修改实体可能会生成两条 sql 语句:一条 insert,一条 update,原本只需要生成一条 insert 语句就可以了。


Hibernate 的 FlushMode 设置也容易产生令人迷惑的结果。FlushMode 缺省设置为 auto,由 hibernate 自行判断是否要主动刷新数据库,导致 Java 代码层面一些微妙而等价的逻辑调整会误导 hibernate 错误判断为需要刷新数据库,从而发出大量 sql 调用,产生比较严重的性能问题。


NopOrm 的设计思想是彻底的 lazy,因此它取消了 FlushMode 概念,仅在明确调用 session.flush()的时候才会刷新数据库,而结合 OrmTemplate 模式,由模板方法在事务提交前负责调用 session.flush(),从而在概念层面提高了 ORM 引擎执行结果的可预测性。


NopOrm 采用状态驱动的 cascade 设计,即每次操作时不执行 cascade,仅在 session.flush 时对所有实体都执行一次 cascade 操作。同时利用 dirty flag 标识来实现剪枝优化,如果某个类型的所有实体都没有被修改,则该类型对应的 dirty 标识为 false,这个类型的所有实例会自动跳过 flush 操作。如果整个 session 中所有实体都没有被修改,则全局的 dirty 标识为 false,整个 session.flush()操作会被跳过。


Hibernate 基于动作触发 cascade 还有一个副作用,即具体 SQL 语句的执行顺序难以被精确控制。而在 NopOrm 中,flush 产生的动作会被缓存到 actionQueue 队列中,然后统一按照数据库表的拓扑依赖顺序进行排序后再执行,从而确保总是按照确定的表顺序进行数据库修改,可以在一定程度上避免数据库死锁的发生。


死锁产生的原因一般是线程 A 先修改表 A,然后再修改表 B,而线程 B 先修改表 B,再修改表 A。按照确定顺序执行数据库更新语句相当于是起到了锁排序的作用。

五. More is Better

NopOrm 放弃了 Hibernate 中大量的功能特性,但同时它又提供了很多 Hibernate 所缺乏的,而在一般业务开发中又非常常见、往往需要不少开发量的功能特性。区别在于,这些特性全部都是可选特性,无论是否启用它们对已经实现的其他功能都不会造成任何影响。

5.1 Good parts of Hibernate

NopOrm 继承了 Hibernate 和 Spring 框架中一些非常优秀的设计:


  1. 二级缓存和查询缓存:缺省情况下就限制了缓存大小,避免内存溢出。

  2. 复合主键支持:在业务系统开发中很难完全避免复合主键。NopOrm 内置复合主键类 OrmCompositePk,并自动生成 Builder 辅助函数,自动实现 String 与 OrmCompositePk 之间的转换,从而简化了复合主键的使用。

  3. 主键生成器:只要 column 模型上标记了 seq 标签,就可以在 java 代码中自动生成主键。如果实体上已经设置了主键,则以用户设置的值为准。与 Hibernate 不同的是,NopOrm 使用全局统一的 SequenceGenerator.generate(entityName)来生成主键,便于在运行期动态调整主键生成策略。NopOrm 放弃了数据库自增主键的支持,因为这个特性很多数据库不支持,在分布式环境下也存在问题。

  4. JDBC Batch:自动合并数据库更新语句,减少数据库交互次数。调试模式下会打印合并前 SQL 语句的具体参数值,便于出错时诊断问题。

  5. 乐观锁:通过 update xxx set version=version+1 where version= :curVersion 的方式更新数据库,从而避免并发修改冲突

  6. 模板方法模式:改进了 JdbcTemplate/TransactionManager/OrmTemplate 的配合方式,减少了冗余的封装转换,并增加了异步处理支持,可以在异步环境中使用 OrmSession。

  7. Interceptor:可以通过 OrmInterceptor 的 preSave/preUpdate/preDelete 等方法拦截 ORM 引擎内部针对单实体的操作,从而实现类似数据库中触发器的功能。

  8. 分页:借助 Dialect 统一不同数据库的分页机制。同时为 EQL 语法增加了类似 MySQL 的 offset/limit 语法支持。

  9. SQL 兼容性:借助 Dialect 实现跨数据库的 SQL 语法兼容性转换,包括语法格式与 SQL 函数的翻译等。

5.2 更懂需求的 ORM

一些常见的业务需求借助 ORM 引擎可以很容易的实现,因此 NopOrm 为它们提供了开箱即用的支持,不需要再安装额外的插件。


  1. 多租户:为启用租户的表增加 tenantId 过滤条件,并禁止跨租户访问数据

  2. 分库分表:通过 IShardSelector 动态选择分库分表

  3. 逻辑删除:将 delete 操作转换为设置 delFlag=1 的修改操作,并在一般查询语句中自动增加增加 delFlag=0 的过滤条件

  4. 时间戳:自动记录修改人、修改时间等操作历史信息

  5. 修改日志:通过 OrmInterceptor 拦截实体修改操作,可以获取到实体被修改前以及修改后的字段值信息,并记录到单独的修改日志表中。

  6. 历史表支持:为记录表增加 revType/beginVer/endVer 字段,为每个记录分配一个起始版本号和结束版本号,修改记录被转化为新增一条记录,并设置上一条记录的结束版本号为最新记录的起始版本号。在一般查询语句中自动增加过滤条件,只查找最新版本的记录。

  7. 字段加密:在 column 模型上增加 enc 标签表示该字段需要进行加密存储。此时系统会使用定制的 IDataParameterBinder 来读取数据库字段值,从而实现以加密形式保存到数据库中,而以解密形式存放在 java 属性中。EQL 解析器通过语法分析可以获知参数类型,从而透明的使用 encode binder 来对 SQL 语句的参数进行加解密。

  8. 敏感数据掩码:用户的卡号和身份证号等敏感信息字段可以增加 mask 标签,从而在系统内部打印日志时自动对该字段值进行掩码处理,避免泄露到日志文件中。

  9. 组件逻辑复用:一组相关的字段可能组成一个可以复用的组件,通过 OrmComponent 机制可以对这些逻辑进行复用。例如,数据库中的 Decimal 类型精度必须事先指定,但是客户要求必须按照输入时指定的精度来进行显示和计算,这要求我们在记录表中增加一个 VALUE_SCALE 字段来保留精度信息,但是当我们从数据库中取出值的时候我们又希望直接得到一个 scale 已经被设置为指定值的 BigDecimal。NopOrm 提供了一个 FloatingScaleDecimal 组件来完成这件工作。对于附件、附件列表等具有复杂关联逻辑的字段可以采用类似的方式进行封装。

  10. FloatingScaleDecimal


与外围框架相结合,Nop 平台还内置了更多常用的解决方案。比如


  1. 通用查询:前后端无需编写代码,只要表单按照一定格式提交查询请求,后台就可以根据 meta 元数据配置进行格式校验以及权限校验,通过后自动执行查询并按照 GraphQL 结果格式返回结果。

  2. 修改确认及审批:与 CRUD 服务和 API 调用服务相结合,用户提交请求后不直接修改数据库或者发出 API 调用,而是自动生成一条审批申请,审批人在审批界面上可以看到修改前后的内容,批准同意后再实际执行后续的动作。通过这个方案可以把任意表单界面都转化为申请提交页面和审批确认页面。

  3. 复制新建:一个复杂的业务对象可以通过复制已有对象的方式新建。需要复制的字段可以通过类似 GraphQL 查询语法的方式指定。

  4. 字典表翻译:前端显示的时候需要把 statusId 这样的字段通过字典表翻译为对应的显示文本,而且需要根据当前登录用户的 locale 设置选择对应的多语言版本。Nop 平台在元编程阶段会自动发现所有配置了 dict 属性的字段,并自动为它们所对应的 GraphQL 描述增加一个关联的显示文本字段,例如根据 statusId 增加 statusId_text 字段。前台 GraphQL 请求 statusId_text 字段即可得到字典表翻译后的结果,同时仍然可以通过 statusId 字段来获得字段原始的值。

  5. 批量导入导出:可以通过上传 CSV 文件或者 Excel 文件的方式导入数据,导入时执行的逻辑与手工通过界面提交完全一致,并会自动校验数据权限。可以按照 CSV 或者 Excel 格式导出文件。

  6. 分布式事务:自动与 TCC 分布式事务引擎结合。


NopOrm 遵循可逆计算原理,因此它的底层模型都是可定制的。用户可以根据自己的需求随时为模型增加自定义的属性,然后再通过元编程、代码生成器等机制利用这些信息。上面介绍的大量功能实现其实都是采用类似机制实现的,它们很多都不属于引擎内核完成的功能,而是定制机制引入的。

5.3 拥抱异步的新世界

传统上 JDBC 访问接口全部是同步的,因此 JdbcTemplate 和 HibernateTemplate 的封装风格也是同步调用风格。但是随着异步高并发编程思想的传播,响应式编程风格逐渐开始进入主流框架。Spring 目前是提出了R2DBC标准,而vertx框架也内置了对 MySQL、PostgreSQL 等主流数据库的异步连接器支持。另一方面,ORM 引擎如果作为一个数据融合访问引擎,它的底层存储可能是 Redis、ElasticSearch、MongoDB 这种支持异步访问的 NoSQL 数据源,而且 ORM 需要和 GraphQL 异步执行引擎相配合。考虑到这些情况,NopOrm 的 OrmTemplate 封装也增加了异步调用模式


 public interface IOrmTemplate extends ISqlExecutor {    <T> CompletionStage<T> runInSessionAsync(          Function<IOrmSession, CompletionStage<T>> callback); }
复制代码


OrmSession 在设计上是线程不安全的,同一时刻只允许一个线程访问。为了实现多线程访问同一个线程不安全的数据结构,一个基本的设计方案是采用类似 Actor 的任务队列模式,


class Context{    ContextTaskQueue taskQueue;
public void runOnContext(Runnable task) { if (!taskQueue.enqueue(task)) { taskQueue.flush(); } }}
复制代码


Context 是跨线程传递的上下文对象,它具有一个对应的任务队列。任意时刻只有一个线程会执行该任务队列中注册的任务。runOnContext 函数向任务队列中注册任务,如果发现没有其他线程正在执行该任务队列,则由当前线程负责执行。


对于递归调用,taskQueue 实际上起到了类似trampoline function的作用。


如果引入了异步 Context 的概念,我们还可以改进对远程服务调用的超时支持。远程服务调用超时之后,客户端会抛出异常或者发起重试,但是此时服务端并不知道已经超时,仍在继续执行。服务函数一般会多次访问数据库,如果此时叠加上重试导致的流量,会导致数据库的实际压力远大于未超时的场景。一个改进策略就是在 Context 上增加一个超时时间属性


class Context{    long callExpireTime;}
复制代码


当跨系统调用时,通过 RPC 的消息头可以传递一个 timeout 超时时间间隔,在服务端接收到 timeout 之后,加上当前时间得到 callExpireTime(callExpireTime = currentTime + timeout)。然后在 JdbcTemplate 中,每次发出数据库请求之前都会检查一下是否已经到达 callExpireTime,从而及时发现服务端已超时的情况。如果在服务端要调用第三方系统的 API,则重新计算 timeout = callExpireTime - currentTime 得到剩余的超时时间间隔,并传递到第三方系统。

5.4 Dialect 的差量化定制

NopOrm 通过 Dialect 模型来封装不同数据库之间的差异。


default dialect


mysql dialect


postgresql dialect


参考上面的示例,mysql.dialect.xml 和 postgresql.dialect.xml 均从 default.dialect.xml 继承。与 Hibernate 通过编程方式构造 Dialect 对象相比,使用 dialect 模型文件明显信息密度更高,表达形式更加直观。更重要的是,在 postgresql.dialect.xml 中可以清楚的识别出相对于 default.dialect.xml 所增加、修改和减少的配置。


因为整个 Nop 平台的底层都是基于可逆计算原理构建的,因此 dialect 模型文件的解析和验证可以由通用的 DslModelParser 完成,同时自动支持 Delta 定制,即在不修改 default.dialect.xml 文件,也不修改所有对 default.dialect.xml 文件的引用的情况下(例如不需要修改 postgresql.dialect.xml 中的 x:extends 属性),我们可以在/_delta 目录下增加一个 default.dialect.xml 文件,通过它来定制系统内置的模型文件。


<!-- /_delta/myapp/nop/dao/dialect/default.dialect.xml --><dialect x:extends="raw:/nop/dao/dialect/default.dialect.xml">  这里只需要描述差量变化的部分</dialect>
复制代码


Delta 定制类似 Docker 技术中的 overlay fs 差量文件系统,允许多个 Delta 层的叠加。与 Docker 不同的是,Delta 定制不仅发生在文件层面,它还延展到文件内部的差量结构运算。借助于 xdef 元模型定义,Nop 平台中的所有模型文件都自动支持 Delta 差量化定制

5.5 可视化集成

hibernate 的 hbm 定义文件和 JPA 注解都是针对数据库结构映射而设计的,它们并不适合于可视化模型设计。为 Hibernate 增加可视化设计器是一件相对复杂的事情。


NopOrm 采用 orm.xml 模型文件来定义实体模型。首先,它是一个完整的结构定义模型,可以根据模型中的信息生成建库脚本,以及与当前数据库结构自动进行差异比较和自动进行数据迁移等。


app.orm.xml


为 NopOrm 增加可视化设计器是一件非常简单的事情,简单到只需要增加一个元编程标签调用


<orm ... >    <x:gen-extends>        <pdman:GenOrm src="test.pdma.json" xpl:lib="/nop/orm/xlib/pdman.xlib"                      versionCol="REVISION"                      createrCol="CREATED_BY" createTimeCol="CREATED_TIME"                      updaterCol="UPDATED_BY" updateTimeCol="UPDATED_TIME"                      tenantCol="TENANT_ID"        />          ...    </x:gen-extends></orm>  
复制代码


Pdman是一个开源的数据库建模工具,它将模型信息保存为 json 文件格式。<pdman:GenOrm>是一个在编译期元编程阶段运行的 XPL 模板语言标签,它会根据 pdman 的 json 模型自动生成 orm 模型文件。这种生成是即时生效的,即只要修改了 test.pdma.json 文件,OrmModel 的解析缓存就会失效,再次访问时会重新解析得到新的模型对象。


根据可逆计算理论,所谓的可视化设计界面不过是领域模型的一种图形化表示形式(Representation),而模型文件文本可以看作是领域模型的文本表示形式。可逆计算理论指出,一个模型可以有多种表示形式,可视化编辑不过是说图形化表示形式和文本表示形式之间存在可逆转换而已。沿着这个方向进行推理,我们可以得出一个推论,即一个模型的可视化展现形式并不是唯一的,完全可以有多种不同形态的可视化设计器用于设计同一个模型对象。


对应 orm 模型而言,除了 pdman,我们还可以选择用 powerdesigner 设计工具来设计,同样


通过一个类似的<pdm:GenOrm>标签可以将 pdm 模型文件转换为 orm 所需的模型格式。


在 Nop 平台中,我们还支持通过 Excel 文件格式来定义实体数据模型。


test.orm.xlsx


同样的,我们只需要引入一个标签调用 <orm-gen:GenFromExcel>,然后就可以快乐的在 Excel 中进行 ORM 模型设计了。


<orm ...>  <x:gen-extends>     <orm-gen:GenFromExcel path="test.orm.xlsx" />  </x:gen-extends></orm>
复制代码


值得一提的是,Nop 平台中的 Excel 模型文件解析也是基于可逆计算理论设计的。可逆计算理论将 Excel 模型文件解析看作是从 Excel 范畴映射到 DSL AST(Abstract Syntax Tree)范畴的一个函子(同样是一种等价的表象转换),因此可以实现一个通用的 Excel 模型解析器,仅需要输入 orm 元模型文件所定义的结构信息,不需要进行任何特殊编码,即可实现 Excel 模型的解析。这种机制完全是通用的,即针对任何 Nop 平台中定义的模型文件,我们都可以免费获得它对应的 Excel 可视化编辑模型,同时这些 Excel 文件的格式是相对自由的,我们可以随意在其中调整单元格的位置、样式、前后顺序等。只要它们能够按照某种确定性的规则被识别为 Tree 结构即可。


关于模型转换的进一步介绍,可以参考以下文章


从张量积看低代码平台的设计


Nop 平台中的模型信息还可以通过通用的 Word 模板形式对外导出,具体技术方案可以参见


如何用800行代码实现类似poi-tl的Word模板


Nop 平台所支持的所有业务功能都是通过模型驱动的方式实现,因此通过分析模型信息我们有可能导出大量有用的信息。比如,我们曾经根据内部模型导出过数据库模型文档,数据字典文档、API 接口文档、单元测试文档等。

六. 老大难的 N+1 问题

自从 Hibernate 诞生之日起,所谓的 N+1 问题就一直是笼罩在 ORM 引擎头上的一朵乌云。假设我们有这样一套模型


class Customer{    Set<Order> orders;}
class Order{ Set<OrderDetail> details; }
复制代码


如果我们想处理某个客户的订单明细信息,则会需要遍历 orders 集合,


Customer customer = ... // 假设已经获取到customerSet<Order> orders = customer.getOrders();for(Order order: orders){    process(order.getDetails());}
复制代码


从 customer 装载 orders 集合需要发出一条 SQL 语句,遍历 orders 集合,对每个 order 获取它的 details 集合又会发出一条 SQL 语句,最后导致整个处理过程发出 N+1 条查询语句。


N+1 问题之所以臭名昭著,原因在于开发阶段数据量很小,性能问题往往被忽略,而在上线后发现问题时,我们却没有任何通过局部调整进行补救的手段。所有的修改往往都需要对代码进行重写,甚至完全改变程序设计。


这个问题一直困扰着 Hibernate,直到很多年以后 JPA(Java Persistence API)标准提出了一个 EntityGraph 的概念。


@NamedEntityGraph(   name = "customer-with-orders-and-details",   attributeNodes = {       @NamedAttributeNode(value = "orders", subgraph = "order-details"),   },   subgraphs = {@NamedSubgraph(       name = "order-details",       attributeNodes = {           @NamedAttributeNode("deails")       }   )})@Entity       class Customer{    ...}
复制代码


在实体类上增加 NamedEntityGraph 注解,声明加载对象时要把 orders 集合以及 details 集合都一次性加载出来。然后在调用 find 方法的时候指定需要使用哪个 EntityGraph 配置。


EntityGraph entityGraph = entityManager.getEntityGraph("customer-with-orders-and-detail");Map<String,Object> hints = new HashMap<>();hints.put("javax.persistence.fetchgraph", entityGraph);Customer customer = entityManager.find(Customer.class, customerId, hints);
复制代码


除了使用注解来声明之外,EntityGraph 还可以通过代码来构造


EntityGraph graph = entityManager.createEntityGraph(Customer.class);Subgraph detailGraph = graph.addSubgraph("order-details");detailGraph.addAttributeNodes("details");
复制代码


实际会生成类似如下 SQL 语句


select customer0.*,       order1.*,       detail2.*from      customer customer0        left join order order1 on ...       left join order_detail detail2 on ... where customer0.id = ?   
复制代码


Hibernate 会使用一条 SQL 语句把所有数据都取出来,代价就是需要多个表进行关联,并返回了大量冗余数据。


这个问题是否还存在其他解决方案?从数据模型本身的结构来看, Customer -> orders -> details 的嵌套结构是非常直观自然的,并没有什么问题,但是问题出在我们只能按照对象结构定义好的方式去获取数据,而且我们只能逐个遍历对象结构,从而导致产生大量数据查询语句。如果我们能够绕过对象结构,直接通过某种方式获取到对象数据,并把它们在内存中按照需要的对象结构组织好,这个问题不就解决了吗?


Customer customer = ...// 插入一条神秘的数据获取指令fetchAndAssembleDataInAMagicalWay(customer);// 数据已经在内存中存在,可以安全的遍历并使用,不再产生数据加载动作Set<Order> orders = customer.getOrders();for(Order order: orders){    process(order.getDetails());}
复制代码


NopOrm 中通过 OrmTemplate 提供了一个批量加载属性的接口。


ormTemplate.batchLoadProps(Arrays.asList(customer), Arrays.asList("orders.details"));// 数据已经在内存中存在,可以安全的遍历并使用,不再产生数据加载动作Set<Order> orders = customer.getOrders();
复制代码


OrmTemplate 内部通过 IBatchLoadQueue 加载队列来实现功能


IBatchLoadQueue queue = session.getBatchLoadQueue();queue.enqueue(entity);queue.enqueueManyProps(collection,propNames);queue.enqueueSelection(collection,fieldSelection);queue.flush();
复制代码


BatchLoadQueue 的内部实现原理其实和 GraphQL 的 DataLoader 机制类似,都是先收集要加载的实体或者实体集合对象,然后用一条select xxx from ref__entity where ownerId in :idList来批量获取数据,接着再按 ownerId 拆分到不同的对象和集合中。因为 BatchLoadQueue 具有实体模型的全部信息,而且具有统一的加载器,所以它的内部实现相比于 DataLoader 要更加优化。同时,在外部接口方面,需要表达的信息量要更少。例如 orders.details 就表示需要先加载 orders,然后再加载 details 集合,并取得 OrderDetail 对象的所有 eager 属性。如果是使用 GraphQL 描述,则需要明确指定获取 OrderDetail 对象上的哪些属性,描述要更加复杂一些。


BatchLoadQueue 并不是受 GraphQL 启发而设计的。GraphQL 于 2015 年开源,在此之前我们已经在使用 BatchLoadQueue 了。


如果需要加载的实体对象非常多,层次非常深,则按照 id 批量获取在性能上也有一些影响。为此,NopOrm 保留了一个仅供专家使用的超级后门,


 session.assembleAllCollectionInMemory(collectionName); 或者 session.assembleCollectionInMemory(entitySet);
复制代码


assembleAllCollectionInMemory 假定所有涉及到的实体对象都已经被加载到内存中了,因此它不再访问数据库,而是直接通过对内存中的数据进行过滤来确定集合元素。至于如何将所有相关实体都加载到内存中,方法就很多了。例如


orm().findAll(new SQL("select o from Order o"));orm().findAll(new SQL("select o from OrderDetail o"));session.assembleAllCollectionInMemory("test.Customer@orders");session.assembleAllCollectionInMemory("test.Order@details");
复制代码


这种方法有一定危险性,因为如果在调用 assemble 函数之前没有将所有关联实体都加载到内存中,那么组装出来的集合对象就是错误的。


如果我们再回想一下前面 EntityGraph 所生成的那条 SQL 语句,它其实对应于如下 EQL 查询


select c, o, dfrom Customer c left join c.orders o left join o.detailswhere c.id = ?
复制代码


按照 ORM 的基本原理,虽然查询语句返回了很多重复的 Customer 和 Order 对象,但是因为它们的主键都相同,所以最后在内存中构造为对象时只会保留唯一一个实例。甚至如果此前已经装载过某个 Customer 或者 Order 对象的话,那么它的数据会以此前装载的结果为准,本次查询得到的数据会自动被忽略。


也就是说,ORM 提供了一种类似数据库中Repeatable Read事务隔离级别的效果。当重复读取的时候只是读取到一个寂寞,ORM 引擎只会保留第一次读取的结果。基于同样的原因,对于 Load X, Update X , Load X 的情况,第二次加载读到的数据会被自动丢弃,从而我们所观察到的总是第一次加载的结果,以及后续我们对实体所做的修改,这相当于是实现了Read your writes这样的因果一致性


基于以上认知,EntityGraph 的执行过程等价于如下调用


orm().findAll(new SQL("select c,o,d from Customer c left join ..."));session.assembleSelectionInMemory(c, FieldSelectionBean.fromProp("orders.details"));// assembleSelection的执行过程等价于如下调用session.assembleCollectionInMemory(c.getOrders());for(Order o: c.getOrders()){    session.assembleCollectionInMemory(o.getDetails());}
复制代码

七. QueryBuilder 很重要,但和 ORM 没关系

有些人认为ORM的作用很大程度上在于QueryBuilder,我认为这是一种误解。QueryBuilder 有用仅仅是因为 Query 对象需要被建模而已。在 Nop 平台中我们提供了 QueryBean 模型对象,它支持如下功能


  1. QueryBean 在前台对应于 QueryForm 和 QueryBuilder 控件,可以直接由这些控件来构造复杂查询条件

  2. 后台数据权限过滤所对应的 filter 条件可以直接插入到 QueryBean 中,相比于 SQL 拼接,结构清晰且不会出现 SQL 注入攻击。queryBean.appendFilter(filter)

  3. QueryBean 支持自定义查询算子和查询字段,可以在后台通过 queryBean.transformFilter(fn)把它转换为内置的查询算子。例如我们可以定义一个虚拟字段 myField,然后查询内存中的状态数据以及其他关联表的数据,将它转换为一个子查询条件等,这样在单表查询框架下实际上可以实现多表联合查询的效果

  4. DaoQueryHelper.queryToSelectObjectSql(query)可以将查询条件转换为 SQL 语句

  5. QueryBeanHelper.toPredicate(filter)可以将过滤条件转换为 Predicate 接口,从而在 java 中直接过滤。

  6. 通过 FilterBeans 中定义的 and,eq 等算子,结合代码生成时自动生成的属性名常量,我们可以实现如下编译期安全的构造方式。

  7. filter = and(eq(PROP_NAME_myFld,"a"), gt(PROP_NAME_otherFld,3))


QueryBuilder 本质上是与 ORM 无关的,因为在完全脱离关系数据库和 SQL 语句的情况下,我们仍然可以使用 Query 模型。例如,在业务规则配置中


<decisionTree>    <children>      <rule>        <filter>          <eq name="message.type" vaule="@:1" />          <match name="message.desc" value="a.*" />        </filter>        <output name="channel" value="A" />      </rule>      <rule>        ...      </rule>    </children></decisionTree>
复制代码


可以直接复用前台的 QueryBuilder 来实现对于后台决策规则的可视化配置。


在 Java 代码中通过所谓的 QueryDsl 来构造 SQL 语句本质上说并没有什么优势。因为如果采用模型驱动的方式,直接使用前台传入的 QueryBean 就好了,补充少量查询条件可以使用 FilterBeans 中定义的 and/or/eq 等静态组合函数。如果是非常复杂的 SQL 构造,那么直接采用类似 MyBatis 的方案,在独立的外部文件中统一管理无疑是更好的选择。在 sql-lib 中,我们可以实现 QueryDsl 所无法达到的直观性、灵活性和可扩展性(在后面后有更详细的介绍)。


QueryBean

八. OLAP 分析能用 ORM 吗?

一直有一种说法是 ORM 只适用于 OLTP 应用,对于 OLAP 数据分析所需的复杂查询语句无能为力。但偏偏有人要知难而上,就是要用 ORM,还要用得更快、更高、更强!


说实话,难道用 SQL 去写汇总分析语句就简单吗?太多的关联和子查询仅仅是为了把数据按照某个维度组织到一起。拆分成多个查询去做,然后在程序中再组装到一起会不会更简单?


润乾报表是一家非常独特的公司,创始人蒋步星是写入了中国历史的传奇人物(国际奥林匹克数学竞赛的首届中国金牌得主,来自新疆石河子,参见顾险峰教授的回忆),他发明了中国式报表模型相关的理论,并引领了整整一代报表软件的技术潮流。虽然由于种种原因,润乾公司最后的发展不尽如人意,但它在设计理论方面还是发表了不少独特的见解。


润乾开源了一个前端BI系统,它虽然颜值有点低,但是在技术层面却提出了一个别致的 DQL(Dimentinal Query Language)语言。具体介绍可以参考乾学院的文章


告别宽表,用 DQL 成就新一代 BI - 乾学院


润乾的观点是终端用户难以理解复杂的 SQL JOIN,为了便于多维分析,只能使用大宽表,这为数据准备带来一系列困难。而 DQL 则是简化了对终端用户而言 JOIN 操作的心智模型,并且在性能上相比于 SQL 更有优势。


以如何查找中国经理的美国员工为例


-- SQLSELECT A.*FROM  员工表 AJOIN 部门表  ON A.部门 = 部门表.编号JOIN  员工表 C ON  部门表.经理 = C.编号WHERE A.国籍 = '美国'  AND C.国籍 = '中国'
-- DQLSELECT *FROM 员工表WHERE 国籍='美国' AND 部门.经理.国籍='中国'
复制代码


这里的关键点被称为:外键属性化,也就是说外键指向表的字段可直接用子属性的方式引用,也允许多层和递归引用。


另一个类似的例子是根据订单表 (orders),区域表(area),查询订单的发货城市名称、以及所在的省份名称、地区名称。


-- DQLSELECT    send_city.name city,    send_city.pid.name province,    send_city.pid.pid.name regionFROM     orders
复制代码


DQL 的第二个关键思想是:同维表等同化,也就是一对一关联的表,不用明确写关联查询条件,可以认为它们的字段是共享的。例如,员工表和经理表是一对一的,我们需要查询所有员工的收入


-- SQLSELECT 员工表.姓名, 员工表.工资 + 经理表.津贴FROM 员工表LEFT JOIN 经理表 ON 员工表.编码 = 经理表.编号
-- DQLSELECT 姓名,工资+津贴FROM 员工表
复制代码


DQL 的第三个关键思想是:子表集合化,例如订单明细表可以看作是订单表的一个集合字段。如果要计算每张订单的汇总金额,


-- SQLSELECT T1.订单编号,T1.客户,SUM(T2.价格)  FROM 订单表T1  JOIN 订单明细表T2 ON T1.订单编号=T2.订单编号  GROUP BY T1.订单编号,T1.客户
-- DQLSELECT 订单编号,客户,订单明细表.SUM(价格) FROM 订单表
复制代码


"如果有多个子表时,SQL 需要分别先做 GROUP, 然后在一起和主表 JOIN 才行,会写成子查询的形式,但是 DQL 则仍然很简单,SELECT 后直接再加字段就可以了"。


DQL 的第四个关键思想是:数据按维度自然对齐。我们不用特意指定关联条件,最终数据之所以能够放在同一张表里展示,原因不是因为它们之间存在什么先验的关联关系,仅仅是因为它们共享了最左侧的维度坐标而已。例如:我们希望按日期统计合同额、回款额和库存金额。我们需要从三个表分别取数据,然后按照日期对齐,汇总到结果数据集中。


-- SQLSELECT T1.日期,T1.金额,T2.金额, T3.金额FROM (SELECT  日期, SUM(金额) 金额  FROM  合同表  GROUP  BY  日期)T1LEFT JOIN (SELECT  日期, SUM(金额) 金额  FROM  回款表  GROUP  BY  日期)T2ON T1.日期 = T2.日期LEFT JOIN (SELECT  日期, SUM(金额) 金额  FROM  库存表  GROUP  BY  日期 ) T3ON T2.日期 = T3.日期
-- DQLSELECT 合同表.SUM(金额),回款表.SUM(金额),库存表.SUM(金额) ON 日期FROM 合同表 BY 日期LEFT JOIN 回款表 BY 日期LEFT JOIN 库存表 BY 日期
复制代码


在 DQL 中,维度对齐可以和外键属性化结合,例如


-- DQLSELECT 销售员.count(1),合同表.sum(金额) ON 地区FROM 销售员 BY 地区JOIN 合同表 BY 客户表.地区SELECT 销售员.count(1),合同表.sum(金额) ON 地区FROM 销售员 BY 地区JOIN 合同表 BY 客户表.地区
复制代码


如果从 NopOrm 的角度去看 DQL 的设计,则显然 DQL 本质上也是一种 ORM 的设计。


  1. DQL 需要通过设计器定义主外键关联,并为每个字段指定界面上的显式名称,这一做法完全与 ORM 模型设计相同。

  2. DQL 的外键属性化、同维等同化和子表集合化本质上就是 EQL 语法中的对象属性关联语法,只是它直接用数据库的关联字段作为关联对象名。这种做法比较简单,但缺点是对于复合主键关联的情况不太好处理。

  3. DQL 的维度对齐是一个有趣的思想。它的具体实现应该是分多个 SQL 语句去加载数据,然后在内存中通过 Hash Join 来实现关联,速度很快。特别是在分页查询的情况下,我们可以只对主表进行分页查询,然后其他子表通过 in 条件只取本页数据涉及到的记录即可,在大表的情况下有可能加速很多。


基于 EQL 语言去实现 DQL 的功能是一件比较简单的事情。读了润乾的这篇文章之后,我大概花了一个周末的时间实现了一个 MdxQueryExecutor,用于执行维度对齐查询。因为 EQL 已经内置支持了对象属性关联,所以只要实现对 QueryBean 对象的拆分、分片执行、数据并置融合就可以了。

九. SQL 模板管理,你值得拥有

当我们需要构造比较复杂的 SQL 或者 EQL 语句的时候,通过一个外部模型文件对它们进行管理无疑是有着重要价值的。MyBatis 提供了这样一种把 SQL 语句模型化的机制,但是仍然有很多人倾向于在 Java 代码中通过 QueryDsl 这样的方案来动态拼接 SQL。这实际上是在说明 MyBatis 的功能实现比较单薄,没有能够充分发挥模型化的优势


在 NopOrm 中,我们通过 sql-lib 模型来统一管理所有复杂的 SQL/EQL/DQL 语句。在利用 Nop 平台已有基础设施的情况下,实现类似 MyBatis 的这一 SQL 语句管理机制,大概只需要 200 行代码。具体实现代码参见


SqlLibManager


SqlItemModel


SqlLibInvoker


测试用的 sql-lib 文件参见


test.sql-lib.xml


sql-lib 提供了如下特性

9.1 统一管理 SQL/EQL/DQL

在 sql-lib 文件中存在三种节点,sql/eql/query 分别对应于 SQL 语句,EQL 语句和上一节介绍的润乾 DQL 查询模型,对它们可以采取统一的方式进行管理。


<sql-lib>  <sqls>     <sql name="xxx" > ... </sql>     <eql name="yyy" > ... </eql>     <query name="zz" > ... </query>  </sqls></sql-lib>
复制代码


模型化的第一个好处就是 Nop 平台内置的 Delta 定制机制。假设我们已经开发了一个 Base 产品,在客户处部署的时候需要针对客户的数据情况进行 SQL 优化,则我们无需修改任何 Base 产品的代码,只需要添加一个 sql-lib 的差量化模型文件,就可以实现对任意 SQL 语句的定制。例如


<sql-lib x:extends="raw:/original.sql-lib.xml">   <sqls>      <!-- 同名的sql语句会覆盖基类文件中的定义 -->      <eql name="yyy"> ...</eql>   </sqls></sql-lib>
复制代码


关于 Delta 定制,另一个常见用法是结合元编程机制。假设我们的系统是一个领域模型很规整的系统,存在大量类似的 SQL 语句,则我们可以通过元编程机制先在编译期自动生成这些 SQL 语句,然后再通过 Delta 定制来对它们进行改进就可以了。例如


<sql-lib>   <x:gen-extends>       <app:GenDefaultSqls ... />   </x:gen-extends>
<sqls> <!-- 在这里可以对自动生成SQL进行定制 --> <eql name=”yyy“>...</eql> </sqls></sql-lib>
复制代码

9.2 XPL 模板的组件抽象能力

MyBatis 只提供了 foreach/if/include 等少数几个固定标签,真正编写起高度复杂的动态 SQL 语句时可以说是有心无力。很多人觉得在 xml 中拼接 sql 比较麻烦,归根结底是因为 MyBatis 提供的是一个不完善的解决方案,它缺少二次抽象的机制。 而在 java 程序中我们总可以通过函数封装来实现对某一段 SQL 拼接逻辑的复用,对比 MyBatis 却只有内置的三板斧,基本没有提供任何辅助复用的能力。


NopOrm 直接采用 XLang 语言中的 XPL 模板语言来作为底层的生成引擎,因此它自动继承了 XPL 模板语言的标签抽象能力。


XLang 是专为可逆计算理论而生的程序语言,它包含 XDefinition/XScript/Xpl/XTransform 等多个部分,其核心设计思想是对抽象语法树 AST 的生成、转换和差量合并,可以认为它是针对 Tree 文法而设计的程序语言。


<sql name="xxx">  <source>   select <my:MyFields />       <my:WhenAdmin>         ,<my:AdmninFields />       </my:WhenAdmin>   from MyEntity o   where <my:AuthFilter/>  </source></sql>
复制代码


Xpl 模板语言不仅内置了<c:for>,<c:if>等图灵完备语言所需的语法元素,而且允许通过自定制标签机制引入新的标签抽象(可以类比于前端的 vue 组件封装)。


有些模板语言要求所有能在模板中使用的函数需要提前注册,而 Xpl 模板语言可以直接调用 Java。


<sql>  <source>    <c:script>       import test.MyService;
let service = new MyService(); let bean = inject("MyBean"); // 直接获取IoC容器中注册的bean </c:script> </source></sql>
复制代码

9.3 宏(Macro)标签的元编程能力

MyBatis 拼接动态 SQL 的方式很笨拙,因此一些类 MyBatis 的框架会在 SQL 模板层面提供一些特殊设计的简化语法。例如有些框架引入了隐式条件判断机制


select xxxfrom my_entitywhere id = :id[and name=:name]
复制代码


通过自动分析括号内的变量定义情况,自动增加一个隐式的条件判断,仅当 name 属性值不为空的时候才输出对应的 SQL 片段。


在 NopOrm 中,我们可以通过宏标签来实现类似的局部语法结构变换


<sql>  <source>    select o from MyEntity o    where 1=1     <sql:filter> and o.classId = :myVar</sql:filter>  </source></sql>
复制代码


<sql:filter>是一个宏标签,它在编译期执行,相当于是对源码结构进行变换,等价于手写的如下代码


<c:if test="${!_.isEmpty(myVar)}">   and o.classId = ${myVar}</c:if>
复制代码


具体标签的实现参见


sql.xlib


本质上这个概念等价于 Lisp 语言中的宏,特别是它与 Lisp 宏一样,可以用于程序代码中的任意部分(即 AST 的任意节点都可以被替换为宏节点)。只不过,它采用 XML 的表现形式,相比于 Lisp 惜字如金的数学符号风格而言,显得更加人性化一些。


微软 C#语言的 LINQ(语言集成查询)语法,其实现原理是在编译期获取到表达式的抽象语法树对象,然后交由应用代码执行结构变换,本质上也是一种编译期的宏变换技术。在 XLang 语言中,除了 Xpl 模板所提供的宏标签之外,还可以使用 XScript 的宏函数来实现 SQL 语法和对象语法之间的转换。例如


<c:script>function f(x,y){    return x + y;} let obj = ...let {a,b} = linq `  select sum(x + y) as a , sum(x * y) as b  from obj  where f(x,y) > 2 and sin(x) > cos(y)`</c:script>
复制代码


XScript 的模板表达式会自动识别宏函数,并在编译期自动执行。因此我们可以定义一个宏函数 linq,它将模板字符串在编译期解析为 SQL 语法树,然后再变换为普通的 JavaScript AST,从而相当于是在面向对象的 XScript 语法(类似 TypeScript 的脚本语言)中嵌入类 SQL 语法的 DSL,可以完成类似 LinQ 的功能,但是实现方式要简单得多,形式上也更接近 SQL 的原始形式。


以上仅为概念示例,目前 Nop 平台仅提供了 xpath/jpath/xpl 等宏函数,并没有提供内置的 linq 宏函数。

9.4 模板语言的 SQL 输出模式

模板语言相对于普通程序语言而言,它的设计偏置是将输出(Output)这一副作用作为第一类(first class)的概念。当我们没有做任何特殊修饰的时候,就表示对外输出,而如果我们要表示执行其他逻辑,则需要用表达式、标签等形式明确的隔离出来。Xpl 模板语言作为一种 Generic 的模板语言,它对输出这一概念进行了强化,增加了多模式输出的设计。


Xpl 模板语言支持多种输出模式(Output Mode)


  • text: 普通文本的输出,不需要进行额外转义

  • xml: XML 格式文本的输出,自动按照 XML 规范进行转义

  • node: 结构化 AST 的输出,会保留源码位置

  • sql:支持 SQL 对象的输出,杜绝 SQL 注入攻击


sql 模式针对 SQL 输出的情况做了特殊处理,主要增加了如下规则


  1. 如果输出对象,则替换为?,并把对象收集到参数集合中。例如 id = ${id} 实际将生成 id=?的 sql 文本,同时通过一个 List 来保存参数值。

  2. 如果输出集合对象,则自动展开为多个参数。例如 id in (${ids}) 对应生成 id in (?,?,?)。


如果确实希望直接输出 SQL 文本,拼接到 SQL 语句中,可以使用 raw 函数来包装。


from MyEntity_${raw(postfix)} o
复制代码


此外,NopOrm 对于参数化 SQL 对象本身也建立了一个简单的包装模型


SQL = Text + Params
复制代码


通过 sql = SQL.begin().sql("o.id = ? ", name).end() 这种形式可以构造带参数的 SQL 语句对象。Xpl 模板的 sql 输出模式会自动识别 SQL 对象,并自动对文本和参数集合分别进行处理。

9.5 自动验证

外部文件中管理 SQL 模板存在一个缺点:它无法依赖类型系统进行校验,只能期待运行时测试来检查 SQL 语法是否正确。如果数据模型发生变化,则可能无法立刻发现哪些 SQL 语句受到影响。对于这个问题,其实存在一些比较简单的解决方案。毕竟,SQL 语句既然已经作为结构化的模型被管理起来了,我们能够对它们进行操作的手段就变得异常丰富起来。NopOrm 内置了一个类似 Contract Based Programming 的机制:每个 EQL 语句的模型都支持一个 validate-input 配置,我们可以在其中准备一些测试数据,然后 ORM 引擎在加载 sql-lib 的时候会自动运行 validate-input 得到测试数据,并以测试数据为基础执行 SQL 模板来生成 EQL 语句,然后交由 EQL 解析器来分析它的合法性,从而实现以一种准静态分析的方式检查 ORM 模型与 EQL 语句的一致性。

9.6 调试支持

与 MyBatis 内置的自制简易模板语言不同,NopOrm 使用 Xpl 模板语言来生成 SQL 语句,因此可以很自然的可以利用 XLang 语言调试器来调试。Nop 平台提供了 IDEA 开发插件,支持 DSL 语法提示和断点调试功能。它会自动读取 sql-lib.xdef 元模型定义文件,根据元模型自动校验 sql-lib 文件的语法正确性,并提供语法提示功能,支持在 source 段增加断点,进行单步调试等。


Nop 平台中所有的 DSL 都是基于可逆计算原理构建的,它们都使用统一的元模型定义语言 XDefinition 来描述,所以并不需要针对每一种 DSL 来单独开发 IDE 插件和断点调试器。为了给自定义的 sql-lib 模型增加 IDE 支持,唯一需要的就是在模型根节点上增加属性 x:schema="/nop/schema/orm/sql-lib.xdef",引入 xdef 元模型。


XLang 语言还内置了一些调试特性,方便在元编程阶段对问题进行诊断。


  1. outputMode=node 输出模式下生成的 AST 节点会自动保留源文件的行号,因此当生成的代码编译报错时,我们直接对应到源文件的代码位置。

  2. Xpl 模板语言节点上可以增加 xpl:dump 属性,打印出当前节点经动态编译后得到的 AST 语法树

  3. 任何表达式都可以追加调用扩展函数 $,它会自动打印当前表达式对应的文本、行号以及表达式执行的结果, 并返回表达式的结果值。例如


x = a.f().$(prefix) 实际对应于x = DebugHelper.v(location,prefix, "a.f()",a.f())
复制代码

十. GraphQL over ORM

如果从比较抽象的角度上去考察,前后台交互的方式无非就是:请求后台业务对象 O 上的业务方法 M,传给它参数 X,返回结果 Y。如果把这句话写成 url 的形式,得到的结果类似


view?bizObj=MyObj&bizAction=myMethod&arg=X
复制代码


具体来说,bizObj 可以对应于后台的 Controller 对象,而 bizAction 对应于 Controller 上定义的业务方法,view 表示呈现给调用者的结果信息,它的数据来源是业务方法获取到的数据。对于普通的 AJAX 请求,返回的 json 数据格式是由业务方法所唯一确定的,因此可以写成一个固定的 json。对于通用的 RESTful 服务而言,view 的选择可以更加灵活,例如可以根据 Http 的 contentType header 来决定是返回 json 格式还是 xml 格式。如果 view 是由请求的业务对象和方法所唯一确定的,我们称 Web 请求是 push 模式,而如果客户端可以选择返回的 view,我们说对应的 Web 请求是 pull 模式。基于这个认知,我们可以将 GraphQL 看作是 Composable Pull-mode Web Request


GraphQL 与普通的 REST 请求或者 RPC 请求的最显著的区别在于,它的请求模式对应于


selection?bizObj=MyObj&bizField=myField&arg=X
复制代码


GraphQL 是一种 pull 模式的请求,它会指定返回的结果数据。但是这种指定又不是完全的新建,而是在已有数据结构的基础上所作的选择和局部的重组(重命名)。正是因为 selection 信息是高度结构化的,所以它能够被提前解析,成为指导业务方法执行的蓝图。同样因为它是高度结构化的,所以针对多个业务对象的业务请求可以有序的组合在一起。


从某种意义上说,Web 框架的逻辑结构实际上是唯一的。为了实现有效的逻辑拆分,我们必然需要区分后台不同的业务对象,为了实现灵活的组织,我们必然需要指定返回的 view。推论就是 url 的格式应为 view?bizObj=MyObj&bizAction=myAction&arg=X


很多年以前,我写过一篇文章,分析了 WebMVC 框架的设计原理: WebMVC的前世今生。这篇文章的分析在今天仍然是有效的。


基于以上的认知,GraphQL 与 ORM 的结合可以非常的简单。在 Nop 平台中,GraphQL 服务通过确定性的映射规则可以直接映射到底层的 ORM 实体对象上,无需编程即可得到可运行的 GraphQL 服务。在这种自动映射规则的基础上,我们可以逐步补充其他业务规则,例如权限过滤、业务流程、调整数据结构等。具体来说,每一个数据库表都作为一个备选的业务对象,代码生成器自动为它们生成如下代码:


/entity/_MyObj.java       /MyObj.java/model/_MyObj.xmeta      /MyObj.xmeta      /MyObj.xbiz/biz/MyObjBizModel.java
复制代码


  • MyObj.java 是根据 ORM 模型定义自动生成的实体类,我们可以直接在实体类上增加辅助属性和函数。

  • MyObj.xmeta 为外部可见的业务实体数据结构,系统根据它生成 GraphQL 对象的 Schema 定义。

  • MyObjBizModel.java 中则定义了定制的 GraphQL 服务响应函数和数据加载器。

  • MyObj.xbiz 涉及到更复杂的业务切面的概念,在本文中不再赘述。


GraphQL 与 ORM 本质上提供的是不同层面的信息结构。GraphQL 是针对外部视角的,而 ORM 更强调应用程序内部使用,因此它们必然不会共享同样的 Schema 定义。但是,在一般的业务应用中它们又是明显相似的,具有很大的共同性。可逆计算为处理相似而不相同的信息结构提供了标准化的解决方案


针对以上的情况,Nop 平台的设计是,_MyObj.java_MyObj.xmeta都根据 ORM 模型直接生成,它们之间的信息是完全同步的。MyObj.java 继承自_MyObj.java,在其中可以增加应用程序内部可见的额外的属性和方法。MyObj.xmeta 中通过 x:extends 差量合并机制对_MyObj.xmeta进行定制,支持增加、修改以及删除对象属性和方法定义,同时我们还可以在 xmeta 中指定 auth 权限检查规则,对属性进行重命名等。例如


<meta>  <props>    <prop name="propA" x:override="remove" />    <prop name="propB" mapTo="internalProp">      <auth roles="admin" />      <schema dict="/app/my.dict.yaml" />    </prop>  </props></meta>
复制代码


上面的例子中,propA 属性将会被删除,因此 GraphQL 查询无法访问到该属性。同时内部的 internalProp 属性被重命名为 propB,即 GraphQL 查询到 propB 时实际加载的是 internalProp 属性。propB 配置了 auth roles=admin,表示只有管理员才有权限访问该属性。schema 中的 dict 配置表示它的值限定在字典表 my.dict.yaml 的范围内。在 5.2 节中,我们介绍了 NopOrm 中的字典表翻译机制:在元编程阶段,底层的引擎发现了 dict 设置,会自动生成一个 propB_text 字段,它将返回经过字典表翻译后得到的国际化文本。


对于最顶层的 GraphQL 对象,Nop 平台会自动生成如下结构定义:


extend type Query{    MyObj__get(id:String): MyObj    MyObj__findPage(query:String): PageBean_MyObj    ...}
复制代码


除了缺省的 get/findPage 等操作之外,我们可以在 MyObjBizModel 中定义扩展属性和方法。


@BizModel("MyEntity")public class MyEntityBizModel {
@BizLoader("children") @BizObjName("MyChild") public List<MyChild> getChildren(@ContextSource MyEntity entity) { ... }
@BizQuery("get") @BizObjName("MyEntity") public MyEntity getEntity(@ReflectionName("id") String id, IEvalScope scope, IServiceContext context, FieldSelectionBean selection) { ... }
@BizQuery @BizObjName("MyEntity") public PageBean<MyEntity> findPage(@ReflectionName("query") QueryBean query) { ... }}
@BizModel("MyChild")public class MyChildBizModel {
/** * 批量加载属性 */ @BizLoader("name") public List<String> getNames(@ContextSource List<MyChild> list) { List<String> ret = new ArrayList<>(list.size()); for (MyChild child : list) { ret.add(child.getName() + "_batch"); } return ret; }}
复制代码


BizModel 中通过 @BizQuery 和 @BizMutation 来分别定义 GraphQL Query 和 Mutation 操作,GraphQL 操作名称的格式为 {bizObj}__{bizAction}。同时,我们可以通过 @BizLoader 来增加 GraphQL 的 fetcher 定义,通过 @ContextSource 来引入 GraphQL 的父对象实例,通过 @ReflectionName 来标记 argument,参数映射时会自动进行类型转换。


如果 BizModel 中也定义了 get/findPage 等函数,则会覆盖缺省的 MyObj__get 等函数的实现。


BizModel 的设计空间中只存在业务对象、业务方法和业务参数的概念,它与 GraphQL 是完全解耦的,因此我们可以很容易的为 BizModel 提供 REST 服务绑定或者其他 RPC 调用接口标准的绑定。在我们的具体实现中,我们甚至为它提供了一个批处理文件的绑定,即后台批处理任务定期运行,解析批处理文件得到请求对象,然后调用 BizModel 执行业务逻辑,将返回对象作为结果写入到结果文件中。这其中设计的关键是实现批处理的优化,即批处理任务每批次处理 100 条记录,应该整个批次完全处理完毕之后再一次性更新数据库,而不是处理每个业务请求后都立刻更新数据库。借助于 ORM 引擎的 session 机制,这种批处理优化完全是免费附赠的。

结语

可逆计算理论并不是一个聪明的设计模式,也不是一组基于最佳实践的经验总结。它是根植于我们这个世界真实存在的物理规律,从第一性原理出发基于严密的逻辑推导所得到的,关于大范围软件结构构造的一种创新性技术思想。


在可逆计算理论的指导下,NopOrm 的技术方案体现出了底层逻辑结构的完备性和一致性,这使得它能够以单刀直入的简单方式解决一系列棘手的技术问题。(很多时候系统本质的复杂性并不是很高,但是多种组件相互配置时出现的结构障碍和概念冲突导致产生了大量的偶然复杂性)


NopOrm 并不是一个专用于低代码的 ORM 引擎,它支持从 LowCode 到 ProCode 的平滑过渡。它既支持开发期代码生成,也支持运行期动态增加字段,同时为用户自定义存储提供了完整的解决方案。


它非常轻量级,在包含了 Hibernate + MyBatis + SpringData JDBC + GraphQL 的主要功能的情况下,手工编写的有效代码量只有不到 2 万行(还有大量代码是自动生成的,因为 Nop 平台正努力采用低代码的方式来开发它自身的所有组件)。它不仅适用于小型单体项目的开发,同样也适用于分布式、高性能、高复杂性的大型业务系统的开发,同时还为 BI 系统提供了一定的语法支持,还支持通过 GraalVM 编译为原生应用等。


NopOrm 遵循可逆计算原理,可以通过 Delta 定制和元编程对底层模型进行定制化增强,用户可以在自己的业务领域中不断积累可复用的领域模型,甚至开发自己专有的 DSL。


更重要的是,NopOrm 是开源的!(目前尚在代码整理阶段,会随着 Nop Platform 2.0 一起发布)


最后,对于能坚持看到这里的同学说一句,真的是太不容易了,真心为你的好学精神点个赞!


基于可逆计算理论设计的低代码平台 NopPlatform 已开源:


发布于: 33 分钟前阅读数: 2
用户头像

canonical

关注

还未添加个人签名 2022-08-30 加入

还未添加个人简介

评论

发布
暂无评论
低代码平台需要什么样的ORM引擎?(2)_开源_canonical_InfoQ写作社区