我们需要什么样的 ORM 框架

了解我的人都知道, 本人一直非常排斥 ORM 框架, 由于对象关系阻抗不匹配, 一直觉得它没有什么用, 操作数据库最好的手段是 sql+动态语言. 但这两年想法有了重大改变.

2013 年用 js 实践过一个 GUI 的开发, 结论是对于软件工程来说, 静态类型是必须的.

但在数据库方面我却一直回避了这个问题, 实际上这个问题在数据库的交互中同样存在. 数据库的 scheme 可以认为是静态的, 不惟表结构, 随表结构产生的视图等, 其实都是静态的. 所以说数据库是动态语言或者动态VM, 这个说法是错误的. 但是静态的数据库却提供了一种动态手段, 这就是即兴化的查询. 关系运算可以打破静态结构提供一次性的 scheme, 这是一个特别的现象. 数据库在静态类型的基础上又提供了动态类型————当然, 关系运算的设计者并没有考虑这些, 关系和关系运算, 是关系型数据库的全部出发点.

静态类型适合工程化, 动态类型也有不可或缺的价值, 我们看到, 如果从动态类型静态类型的角度来考察, 关系型数据库协调的很好, 我们甚至意识不到问题的存在. 我们可以像画静态图一样画 ER 图, 又能在图的基础上即兴的给出查询, 想要几个字段就要几个字段, 甚至支持运算列. 而这些即兴的查询随时可以建一个视图把它们固化下来, 变成静态类型.

数据库字段一旦有变动, 相关的视图会失效, 这时数据库会提醒受到影响的数据库对象. 如果 IDE 得当, 可以很容易实现重构数据库字段同时重构相应视图, 即使不支持复杂的重构行为, 如分拆表, 至少可以列出引用者, 确定受影响的范围再行重构.

那么, 数据库很完美我们不要用编程语言如何?

也不尽然, 数据库的 scheme 对于编程语言来说并不完美, 如果我们把表看做类的话, 数据库里类的级别很高, 不支持一些即兴的如匿名内部类这样的东西. 不过这也是一个方向, 有人甚至基于数据库做操作系统.

现在我们回到 ORM 问题.

可能你已经意识到我在想什么了.

面向对象编程语言和关系型数据库虽然位于两个领域, 但在静态类型上, 它们是可以对应的, 这也就是 ORM 框架可以适配的地方. 但是, 我们经常在程序里放即兴的 SQL 代码, 这些代码都会面临阻抗失配的问题:

  1. 首先这种即兴的关系运算的结果没有对应的类. 在 Java 实践中通常用 Map 来表达, 这种蹩脚的表达远不如动态语言方便
  2. 一旦发生数据库重构, 这些 SQL 仅仅是字符串, 重构时这些 SQL 无法被 IDE 覆盖, 甚至在编译时都无法抛出错误.

在我以往的认知里 ORM 只是增删改方便, 对 SQL 查询很不适用. 故 MyBatis 大大优于 hibernate, 而 d2js 大大优于 MyBatis. groovy sql 则在 d2js 和 mybatis 之间, 而 ORM 带来的好处可以忽略不计. 最近两年我发现这个认知有很大问题, 关键就在上面的第 2 点. 静态类型的好处不光是增删改方便, 重要的是只有静态类型适合工程化, 所以 ORM 不是削足适履, 而是必须的.

遗憾的是 ORM 在应对即兴的 SQL 方面仍然有欠缺, 并且流行的解决方案都没有处理好这个问题.

  1. MyBatis 通过 XML 写 SQL, 导致 SQL 和业务场景分离, 代码跳来跳去, 更难工程化
  2. 也有通过注解写 SQL 的, 解决了场景割裂的问题, 如 JPA 等, MyBatis 好像也支持了, 新版的 Java 有了文档字符串支持多行了, 似乎好用起来了, 但同样也无法避免上面谈到的问题
  3. JPA 提出了一种自己的 SQL, Hibernate 也搞了一个 HQL, 这可以说毫无意义, 首先, 这些 SQL 还是字符串, 同样面临重构问题, 其次, 数据库的 SQL 功能远比这些低级 SQL 强, 反过来如果数据库比它弱它也映射不过去. 比如现在 pg 有一个 pgvector, Hibernate 要把它包进去难度可想而知
  4. 还有一些 ORM 搞起了 reactive, 号称 DSL, 像这样 .select().from() ... 这种甚至不是 SQL, 表达关系运算捉襟见肘, 更无法复制到 SQL Console 执行, 大大增加了迭代难度
  5. ORM 自己搞的 SQL 或 reactive dsl 的学习成本进一步降低了开发效率, 除了显示开发者会搞 SQL Parser 没有别的价值
  6. DLINQ 风格, DLINQ 解决了重构问题, 但 DLINQ 支持的 SQL 毕竟是有限的, 同样无法发挥数据库 SQL 的全部功能

本文提出一点新思路:

方案 1. 在 ORM 基础上使用动态语言搞即兴查询, 例如 groovy, 我试过这个方案, 开发效率很高, 和 d2js 接近, 当然这个方案同样面临重构的困境, 毕竟 SQL 仍然是字符串, 但它和数据库的开发方式最像, ORM 对应静态类型, groovy 可以以动态类型写即兴查询, 有必要固化下来 groovy 又可以把动态类型转为静态.
方案 2. 加强静态语言, 为每个即兴查询创造相应的静态类型. 这涉及到一个工序的调整, 不能按现在的 MyBatis 等方式开发. 开发过程应当是这样, 编写完 SQL 后, 粘贴到 Java 代码前, 使用某些数据库手段, 如 EXPLAIN 等, 得出所有关联的字段, 再通过 ORM 逆映射, 得到一个即兴查询类. 上述"使用某些数据库手段, 如 EXPLAIN 等, 得出所有关联的字段, 再通过 ORM 逆映射, 得到一个即兴查询类"可以作为一个 IDE 小工具提供.

这种类可以是这样

class OrderByUserIdQuery extends Query{
    class Params{
        Object[] placeHolders = [User.PlaceHolder, Order.PlaceHolder];
        int userId = placeHodlers[0].id;
        getUserId, setUserId...
        String orderNum = placeHolders[1].orderNum;
        getOrderNum, setOrderNum...
    }    
    class Result{
        Object[] placeHolders = [Order.PlaceHolder];        
        Date created = placeHolders[0].created;
        getter; setter;
        Date expired = placeHolders[1].expired;
        getter; setter;
    }
    
    String sql = "select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum";

    @Override
    Class getResultClass = Result.class;
    @Override
    Class getParamsClass = Params.class;
}

这个方案较为完美, 只需要调整一下工作流做一个小工具就可以————我发现这和 DataSet 设计器做的事情很像. 这个方案唯一的缺陷是这些代码毕竟看起来就很累.

如进一步吸收 d2js 的特色, 这个方案可进一步升级

sql{.
    select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum
.}

这里 IDE 应通过分析 SQL 识别出 SQL 中可以关联到 OO 的相关 class 及 field. 这个策略对 groovy 的方案同样有效.

刚才分析的是即兴查询问题, ORM 方案还有两个具体问题:

  1. 数据库字段和 OO 语言数据类型的适配
  2. 数据库外键和 OO 语言的适配

第一个问题现在往往搞一堆注解来解决, 但以注解对应数据库的字段很不 OO, 例如在字段里, NUMBER(64) 构成一个类型, 而在Java里都用 int 去对应. 我的想法是应当全盘类型化.
什么意思? 看代码:

interface Column{
    boolean nullable();
    boolean primary();
    void validate() throws ValidationError;
}
class Number extends java.lang.Number implements Column{
    int pricision;
}
class Number64 extends Number{
    pricision = 64;
}

可能有人要问了, Number(N) 中 N 是可变的, 难道要搞无穷无尽的 Number? 这就多虑了, N 固然是无限的, 但一个数据库中有哪些是有限的, 框架提供几个常用的, 没有提供的项目自己补充就好了.

当然即使如此上面的 nullable 和 primary 也是行不通的, 声明字段提供的是类型而非实例, Column 的实例才有 nullable 和 primary. 如改为静态方法, 静态方法又不支持覆盖. 所以只能

PrimaryKey<Nullable<Number64>> userId

这里一个可能的解决方案是即兴类, 但无疑 Java 并不支持————Java 的匿名类只能定义在实例部分, 除了动态语言比如 ruby, scala kotlin 等等都不支持这种科幻特性.

即兴类退一步的版本是为每个字段定义为一个类, 这个方案的好处是很容易引用字段名, 对工程化非常有用. 例如上面的 sql 语句, 假如每个字段都是一个类型名, 识别起来就极为简单了, 用起来也不费劲. 如上面的

sql{.
    select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum
.}

假如 User.Created 是一个类型, 则从 sql 中的 o.created 很容易就定位到该字段了, 对工程化非常有利.
设想一下它的形态大致是这样:

class User extends Entity{
    class Id extends Integer implements PrimaryKey, Serial;
    class Name extends VarChar64 implements Nullable{
        @Override void valiate() throws ValidationError {

        }
    } 
    public Id id;
    public Name name;
}

这也很有意思. 当然, 这种代码在 Java 里就只能生成了.

第二个问题似乎框架都解决的不错了, JPA 也给了一些注解方案, 凡是支持 JPA 必然支持外键关联, 我就不多废话了.

posted @ 2024-03-22 18:33  Inshua  阅读(11)  评论(3编辑  收藏  举报