重构 反模式之虚拟模型模式及其应用

jasl · 2018年06月20日 · 最后由 jasl 回复于 2018年10月17日 · 8227 次阅读

前言

本篇来自去年在公司内部的分享的提纲,一直静静躺在我的网盘里。

模型在 Rails 已经被潜移默化的特指继承自 ActiveRecord 的类或者说是有实体表对应的模型类,而实际工作中,特别是在数据展示层,表单、页面上的数据渲染,并不一定完全的跟数据模型对应,所以实践中也会为表单建模,或者使用 reform 等 gem,这边是虚拟模型的最初用途——静态的为特定需求建立模型。

在复杂的信息系统中,存在定制字段的情况:如 金数据 这样的问卷系统;复杂门类的电商系统中为不同品类的商品定义不同的商品参数;在企业信息系统中,针对档案、工作流需定义字段。这些场景的共同点是:数据是有结构的、数据是有类型的、数据是有约束的。建模恰恰是解决这类问题的最佳手段,而难点在于设计一套动态的建模机制。

而动态是 Ruby 最擅长的,所以,我发明了虚拟模型模式来解决动态定义数据结构场景的问题。

我也相信,Ruby 和 Rails 在信息系统的开发上大有可为,从很多媒体的文章看,这个企业 SaaS 在国内仍是蓝海。希望这个技巧能够让基于 Rails 的系统在交付效率和功能性上拥有优势。

本篇只涉及原理的介绍。

应用

  • 动态定义数据结构的场景,如表单
  • 无查询需要,数据结构复杂,在关系型数据库上难以建模或维护数据,如配置(典型的,表单字段不同的字段类型的配置)

反模式

反模式是一种试图解决问题的方法,但通常会同时引发别的问题。反模式虽以不同的形式被广泛实践,但这其中仍存在一定的共通性。

...

规则总有例外。在某些情况下,本来认为是反模式的设计却可能是合理的,或者说至少是所有的方案中最合理的。

—— 摘自《SQL 反模式》:引言

前置知识

ActiveModel

http://guides.rubyonrails.org/active_model_basics.html

本质是一组接口,定义了 Rails 的模型应具有的行为以及和视图、控制器的交互规范。 从 Rails 5 开始,越来越多的 ActiveRecord 的功能逐步移动到 ActiveModel 当中。

(个人观点)代码的设计的可改进空间很大,由于 ActiveRecord 和 Arel 强耦合以致部分移动到 AM 的模块在 AR 中又被较大规模的重写了一遍,没有继承或复用。

  • 属性(Attribute)的赋值、读取接口
  • 模型的回调事件声明(回调的注册和触发属于 ActiveSupport)
  • 视图和控制器的一些调用约定(例:render @person render 通过对象的 to_partial_path 方法来确定使用哪个视图文件进行渲染)
  • 脏属性追踪
  • 数据验证和验证错误信息的容器
  • SecurePassword
  • 序列化的基础支持:基于属性的读取接口,返回可用于序列化的 Hash 对象
  • I18n
  • 类型转换(小技巧:ActiveModel::Type.lookup(:boolean).cast('0') #=> false

虚拟模型

实现了 ActiveModel 接口的 Ruby 对象,通常用来做 PresenterForm Object

https://github.com/makandra/active_type https://github.com/solnic/virtus

ActiveRecord::Attributes::ClassMethods#attribute

http://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-define_attribute

用于显式声明属性,类似 Mongoid 的 field,需要指明属性的类型,可在赋值时自动进行类型转换。

在 ActiveRecord 中,属性的存储分为三个层次:

  • 来自数据库
  • 来自用户(类型转换的)
  • 来自用户(未经类型转换)

有修饰符(modifier)机制,部分(其实只有 pg)数据库适配器会实现修饰符,如 pg 增加了 array 修饰符,使得属性可以变成强类型的数组字段!

也可以指定默认值,虽然可以使用 proc 却执行 proc 时不包含上下文,作用很受限。

ActiveRecord::AttributeMethods::Serialization#serialize

http://api.rubyonrails.org/v5.1/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize

  • 也是一种反模式
  • 将 Ruby 对象 marshaling 后存入数据库字段中,易于简化如 Array、Hash 等类型数据的持久化
  • 在一些知名开源项目中有广泛应用,Redmine、Gitlab 等

考虑:有几种固定类型的商品,不同类型的商品有不同的商品参数

一种可行的传统做法

可以为不同类型的商品的商品参数分别建模(表),然后通过 STI 区分不同类型的商品并增加相应的关联。

好处:

  • 关系型数据库的标准使用方式
  • 标准的 Rails 开发体验,实现难度极低

缺点:

  • 更改商品参数的时候同时需要修改表结构,进行数据迁移

另一种可行的做法:AR#serialize + 虚拟模型

  • 在商品表上增加 text 类型的 spec
  • 为不同类型的商品的商品参数分别建立虚拟模型
  • 为这些虚拟模型实现 AR serialize 约定的 dumpload 即序列化和反序列化方法
  • 在不同类型商品的模型中分别声明 serialize :spec, 对应的商品参数虚拟模型类

比较

优点:

  • 节省了查询
  • MongoDB 的 schema-less 的开发体验

缺点:

  • 虚拟模型中的字段无法用于查询

可以考虑使用使用现代数据库的 NoSQL 数据类型存储(如 hstorejson)、使用 elastic 或者索引表

再考虑:商品类型由运营管理,商品参数也由运营设置

如何设计来支撑运营能够自由创建商品类型并且可以为商品类型定制商品参数?

大体上

  • 我们需要一个模型来记录运营输入的商品参数的字段定义
  • 我们需要想办法存储运营为商品添加的额外的商品参数数据

细节上

  • 商品字段的参数的校验方式要如何定义和存储?
  • 数据提交后要如何进行校验?
  • 要如何提取这些数据以便使用?
  • 商品类型的参数变更,已有数据要如何处理?

同时兼顾

  • 可维护性
  • 扩展性(新的类型的字段、验证规则等)
  • 易用性

传统做法

开源项目中的实现,可参考 Redmine。

CustomField(id: integer, name: string, field_format: string, ...)

CustomValue(id: integer, customized_type: string, customized_id: integer, custom_field_id: integer, value: text)

模型设计清晰易懂,但是表单的渲染、字段的验证、数据提交的处理都要实现,相关代码将近 2000 行。

技巧:动态模型类生成

  • AM、AR 提供的 DSL,都是以公开的类方法的形式混入模型类的,也就是说可以通过 User.validates :name, presence: true 的写法为 User 模型增加新的验证
  • 同理可以在运行时为模型增加新的行为(字段、回调等)
  • 通过 class 关键字定义的类(例如定义在 app/models 目录里的模型类)的生命周期是跟 APP 的生命周期一致的,故不可以直接操作他们
  • Ruby 的 Class.new 方法可以实现动态创建类型,其形参为基类,于是如下代码是可行的
new_user_class = Class.new(User)
new_user_class.validates :name, presence: true

将得到一个匿名的 User 的子类,对其修改将不会污染 User

另一种可行的做法:动态虚拟模型生成

和传统做法一样,我们需要一个模型来记录运营输入的商品参数的字段定义,然后,我们将这些定义转换成虚拟模型类

model = Class.new(VirtualModel::Base)
custom_fields.each do |field|
  model.attribute field.name, field.field_format
  # model.validates
end

便可得到标准的 Rails 模型,渲染也很简单

<%= form_for @instance do |f| %>
  <% custom_fields.each do |field| %>
    <% case field.field_type %>
    <% when "text" %>
      <% f.text_field field.name %>
    <%# ... %>
    <% end %>
  <% end %>
<% end %>

坑:model_name

Class.new 生成的匿名类 name 方法返回值为 nil,而 ActiveModel::Namingmodel.model_name 依赖这个方法的返回值,为 nil 时,将会产生异常,所以需要在虚拟模型的基类上覆写这个方法

示例:FormCore

https://github.com/rails-engine/form_core

dummy 应用包含了:

  • 虚拟模型基类的封装
  • 表单和表单字段的模型设计
  • 实现了各种字段类型
    • 多种常见的字段类型:文本、整形、Decimal、日期、时间等
    • 复杂字段类型:单选、多选等
    • 外部数据源字段
    • 嵌套子表单
  • 字段类型支持配置验证器,验证器通过 AOP 实现代码复用
  • 字段类型支持配置
  • 字段的配置存储易于进行数据迁移
  • 可配置的表单结果的序列化方式
  • 定义了标准的虚拟模型生成方式
  • 虚拟模型的服务器端渲染参考实现
  • ...

DuckRecord

https://github.com/jasl-lab/duck_record

Fork 自 ActiveRecord 5.0,外科手术式的剥离了 Arel,并引入了类似 Mongoid 的 embeds_oneembeds_many DSL 用于支持嵌套子模型,从 pg 中剥离了类型的 array modifier,用于支撑多重选择

其他应用:RoleCore

https://github.com/rails-engine/role_core

介绍见:https://ruby-china.org/topics/36065

缺了什么或者哪里说得不细可以留言我抽空补充一下

前排鼓掌

@jasl 请教个问题,Rails Guides 里面有些文章没有在菜单中显示出来的,比如 http://guides.rubyonrails.org/active_model_basics.html,你是在哪翻出来的?

victor 回复

好的,谢谢。

@hooopo

赞美炮哥!

jasl 回复

看到了,谢谢

jasl 回复

菜鸟问个问题,虚拟模型最终还是要和真实的 ActiveRecord 做关联,把 form 里面的内容写进数据库,你的 form_core 用的 duck_record,如何声明和真实 ActiveRecord 之间的关系,说白了就是 virtual_model 和 real_model 之间的关系,怎么处理

ad583255925 回复

其实 duck_record 支持和真实模型的关联,比如有一个真实模型 Post,建立关联可以这样

class VirtualPost < DuckRecord::Base
  attribute :post_id, :integer
  belongs_to :post
end

应该就可以了,不过我后来反思过,虚拟模型和真实模型的关联可能会有很多边界情况,所以我在 FormCore 的例子里把所有这种写法都改写掉了,在 Presenter 层根据虚拟模型里的字段值去做查询获取真实模型的数据

比如附件字段的 [AttachmentFieldPresenter](https://github.com/rails-engine/form_core/blob/master/test/dummy/app/presenters/fields/attachment_field_presenter.rb 这里我嫁接的 ActiveStorage

jasl 回复

如果我想要继承 Post 的所有字段和方法,这样子是不行的吧,而且 belongs_to 的模型多了之后,可能会混在一堆声明里面,无法直接知道这个是哪个模型的表单抽象类,后面维护起来会不会有点问题

class VirtualPost < DuckRecord::Base[Post]
  attribute :post_id, :integer
  belongs_to :post
end

如果能这样用就更好了

ad583255925 回复

ActiveType 可以,理论上可以移植到 DuckRecord,我没做这个主要俩原因,首先是懒,其次是其实我没想通这样做的场景,比如 ActiveType 文档里演示的注册表单的情况(SignUp 虚拟模型继承 User 模型),我来做的话,直接面对业务的部分我更倾向显式声明字段,这样维护性才高。

至于命名啥的,我觉得靠命名规范可以解决,Rails 本身就是这样做的嘛。

虚拟模型这个手法是基于 Rails 有 ActiveModel 这个公共接口,所以如果你有这方面需要可以直接用 ActiveType,不影响这套设计模式的应用

ad583255925 回复

另外我在设计这个模式的时候,研究过 Rails 4 - 5 的 ActiveModel 的演进,预判 ActiveRecord 会把更多的功能移动到 AM 中去,果不其然,最关键的显式声明字段 DSL attribute 和储存字段的 AttributeSet 都挪到了 AM 中,也就是说,Rails 6 发布后,DuckRecord 就完成历史使命了,当然我准备到时候做一套 AM 的扩展来加入嵌套模型的支持

需要 登录 后方可回复, 如果你还没有账号请 注册新账号