Gem FormCore —— 动态表单系统的一种参考实现 (带一点私货技巧)

jasl · May 31, 2017 · Last by zzz6519003 replied at June 14, 2021 · 18631 hits
Topic has been selected as the excellent topic by the admin.

项目地址:https://github.com/rails-engine/form_core

缘起

知人,我们的系统里有很多依赖用户定义的表单,比如不同的公司会在员工档案中添加额外的项目,审批流程的表单内容,则完全交给用户自行设计,字段的数据类型也多种多样,如简单的文本字段、数字字段、开关字段,复杂的有聚合字段(如地址字段含省市区)、数据引用字段、嵌套字段(如报销清单)等,其丰富程度并不亚于同样适用 Rails 技术开发的产品 —— 金数据。

前一段时间,我负责了重构这个模块,在这过程中,我发现了一种方法使得我在一天不到的时间内就完成了原型的开发。并且我相信这也是一个很有趣、很有普遍需求(主要是企业系统)的功能,所以,我通过实现一个非常简单的 Gem,来介绍这个思路。

这个 Gem 包含了两部分:

  • FormCore,一个 Rails Engine 提供了动态表单系统的核心逻辑
  • Dummy app,一个类似金数据的演示系统

但请注意

你不必直接使用 Form Core,它主要展示了一种手法。

我将在后文介绍思路,实际上,我并未完成这个 Gem,我的个人能力有限,有一些做法我仍未找到最好的方式,并且接下来我要把精力用到组织本年的 RubyConf China 上(9.16 - 17,杭州),在这种情况下与其闭门造车,不妨在此期间公开出来听取社区朋友们的想法。我也有意把随附的 Dummy app 打造成一个生产级标准的项目,作为笔记。

此外,随着代码的推进,我也会不时更新这篇文章。

核心思路

FormCore 的核心代码约二十行,其思路的主要特点是充分利用 Rails 提供已有的机制,避免了重复实现

TL;DR

  • ActiveModel(以下简称 AM)提供了 Rails 框架(主要是 ActionPack)和模型交互的接口
  • AM 提供的 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
  • 另一个问题是基于 ActiveRecord 的模型是要与数据库中的表对应,可以使用 ActiveType 这种和 AR 行为一致但不需要有数据库实体表依赖的虚拟模型来解决
  • 由于动态表单是由用户定义的,所以需要一个存储表单字段定义的模型(表)。
  • 综上,利用 Class.new(ActiveType::Object) 构造一个临时的模型类对象,遍历相关的字段定义的记录,根据其数据调用相应的模型的 DSL 方法即可

Form Core 提供了什么

Form Core 提供的一套最小的表单(Form)和表单字段(Field)的定义模式,以及通用的虚拟模型类的生成逻辑。

在你的应用中根据需要扩展它们,具体的实例参考 Dummy app。

DuckRecord

上文提到了提供虚拟模型的 Gem ActiveType,但在 FormCore 里,实际使用的是我实现的 duck_record,是我大概两个通宵的试验性成果(虽然是试验性成果,但偷偷跑在知人生产系统上两个多月了,目前还未收集到相关的 Bug...)。

区别在于,ActiveType 的原理是通过 MonkeyPatch 去屏蔽掉 ActiveRecord 和数据库相关的行为,而 DuckRecord 在代码层面剔除了所有与数据库相关的代码,并且强化了一部分功能,例如移植了 ActiveRecord PostgreSQL-adapter 中的 Array、支持 has_many 虚拟模型(其实叫做 embeds_many 更合意图)、真正的支持 attr_readonly(对象初始化后,对只读字段赋值不会造成改变)等。

Field 模型

Field 是一个抽象类,所有的字段类型都应继承它,并覆写 stored_type 方法,其返回值对应了 ActiveModel::Type 注册的类型(关于此见附录)

定义了如下字段:

  • name:字段的内部标识
  • accessibility:字段的可访问性,分别为“可读写”、“只读”、“隐藏”
  • validations:序列化字段,存储字段验证
  • static_default_value:静态的默认值
  • options:序列化字段,存储字段的个性化配置
  • type:用于单表继承的记录类型的字段

Form 模型

Form 模型只有一个用于 STI 的 type 字段,此外它 has_many :fields

VirtualModel

虚拟模型类的基类,其继承了 DuckRecord::Base,并做了两处 hack。

  • 公开 define_method 方法
  • 重新实现 model_name,这个类属性的原始实现和 Class.new 冲突(因为 Class.new 生成的类没有类名)

表单模型类的生成方式

入口为 Form#to_virtual_model,其中利用 Class.new(VirtualModel) 生成表单类之后,遍历 fields 并依此调用他们的 Field#interpret_to 方法

附录

隐藏在 Rails 中的宝贝

ActiveModel::Type

文档在此,实际上 ActiveRecord 的字段也是通过这些来进行类型转换的,实际上这部分是 AM 设计很糟糕的部分,它和 AM 的其它部分是完全无关并且没有引用的,抽取自 AR,但是抽取的很失败以至于抽离后 ActiveRecord 几乎又重新实现了一遍,详细原因可以研读下 AR 覆写的代码,此外抽取的时候少删了点代码,以至于让我做了一点微小的贡献 :)。

但无论如何,它是有使用价值的,举例,正确的处理 true 值:前端传回来的布尔值,有可能为 1ttrueon等等,可以通过 ActiveModel::Type.lookup(:boolean).cast(value) 安全准确的得到 Ruby 的真假值。

ActiveRecord::Attributes#attribute

文档在此

继承 ActiveRecord::Base 的模型,都可以享受到,作用是显式的声明字段,强大的是,他支持默认值,并且这个默认值可以是一个 proc

举例,设置默认值的时候可以 attribute :published_at, :datetime, default: -> { Time.now } 这样做

另外你需要在模型中的数据类型和数据库中的不一致的时候,也可以考虑使用它

Dummy app 的设计和实现细节

app/lib 线程安全的加载 lib 代码

从 Rails 4 起,/lib 的加载不再线程安全,你可能会在生产环境中少见的遇到 RuntimeError: Circular dependency detected while autoloading constant 那就是了。

你可以考虑在 app 中创建 lib 目录,然后把过去需要 autoload 的代码放进去,不需要额外的声明,直接用,额外的还能获得一个好处,开发时其中的代码改变无需重启 Rails。

当然 /lib 还是有他的意义的,里面可以继续放一些需要初始化 Rails 前显式 require 的代码,比如一些 Monkey Patch

ActiveSupport::Concern 支持 prepend

(这个是为下一条做铺垫)

ActiveSupport::Concern 在使用 prepend 方式混入类的时候,included block 是不会执行的,同时也没有提供 prepended block,这在实现某些目标的时候造成阻碍,请见 lib/concern+prependable.rb,为 Concern 打上这个补丁之后,prepend 也可以正常玩耍啦。

prepend 实现 AOP(面向切面编程)

直接通过代码看原理比较直观:

[1] pry(main)> module A
[1] pry(main)*   def foo
[1] pry(main)*     super # 注意这里
[1] pry(main)*     puts "A"
[1] pry(main)*   end
[1] pry(main)* end
=> :foo
[2] pry(main)> module B
[2] pry(main)*   def foo
[2] pry(main)*     super # 注意这里
[2] pry(main)*     puts "B"
[2] pry(main)*   end
[2] pry(main)* end
=> :foo
[3] pry(main)> class MyClass
[3] pry(main)*   prepend A
[3] pry(main)*   prepend B
[3] pry(main)*   def foo
[3] pry(main)*     puts "MyClass"
[3] pry(main)*   end
[3] pry(main)* end
=> :foo
[4] pry(main)> MyClass.new.foo
MyClass
A
B
=> nil

不同的表单字段的可用的验证逻辑可以是交叉的,所以把不同的验证器的逻辑用 module 分离是个不错的方法。

enum 的 i18n

enum 字段是 Rails 5 的新功能,但遗憾的是,官方没有提供枚举字段的 i18n 方法,坛友 @zmbackerenum_help 是个不错的方案,但是,没有考虑 fallback,在 STI 的时候,就会遇到一点问题,我的方案参考 lib/enum_translate.rb

这么用:

<% Field.accessibilities.each do |k, _| %>
    <label class="radio">
        <%= f.radio_button :accessibility, k %>
        <%= Field.human_enum_value :accessibility, k %>
    </label>
<% end %>

不过稍微啰嗦了一点(眼尖的估计能看出来这是魔改自哪里的代码了...)

简易的 Presenter

利用 SimpleDelegator 就可以实现一个超简单的 Presenter。

黑魔法:强制注入结果集避免重复查询

如果理解不了就千万不要使用

比如 Dummy 的模型间关系 Form has_many Fields; Form has_many Sections; Section has_many Fields,当同时需要 form.fieldssection.fields 的时候,无论是否使用 eager loading 都会多出一次查询,可以这样:

grouped_fields = @form.fields.group_by(&:section_id)
@form.sections.each do |section|
  association = section.fields.instance_variable_get(:@association)
  association.target.concat grouped_fields.fetch(section.id, [])
  association.loaded!
end

以后就没时间了...只好放个半成品先...

本来想给自己在大会上投个稿,但是想来想去没啥好讲的,写个流水账就好了...

感觉会用到😁

huacnlee mark as excellent topic. 01 Jun 10:10

conf 的票已经可以买了吗?

Reply to gaogao1030

过一两周~

RuntimeError: Circular dependency detected while autoloading constant 我就碰到了,终于找到解决的曙光了

Reply to chunlea

其实还是小众问题,重启即可解决

Reply to jasl

只出现过一次,是 Sidekiq 的 Job,然后估计它自己重试之后又好了。

我也是放半成品,没啥时间维护(我说的是我的 Autumn),对于动态表单系统,我有 Moo.js 实现,json 作为配置就够了,没必要数据库,有个 UI 设计器(UI Builder),其实总的来说,就是跟 Android / iOS 一样,用 XML <=> UI 的形式是一致的,但是最近不打算开源。

Reply to jakitto

这里的问题不是做得出来的问题,而是仅用 20 行左右实现行为和 ActiveRecord 模型一致的动态表单模型(是 FormCore 的内部实现核心二十行不到),另外已经完成了,没完成的是文档、测试(让人相信可以大胆用的东西)

Reply to jakitto

另外 ActiveRecord 的模型做的事情其实很多的,毕竟 AR 源码剃掉所有跟数据库的部分还有将近 3000 行(当然也去掉注释和空行啦),这可是 Ruby 的 3000 行啊,这部分我就可以写出跟这篇文章长度相近的文章来,不过精力有限,这一节被我删去了

Reply to jasl

这个能理解,其实我目前在公司也做了一套面向前端的 ActiveRecord,不过是 JS 实现的(MooJS - MooModel - MooData),一个人能做的事情、产品本来就很有限,Rails 肯定绝非一个人能维护和开发到今天地步的,Active 系列也绝非 Rails 作者一个人去完成。我自己也不过是做点类似于这种模型,但是比它简单,其实都一样,能体会,彼此彼此。

Reply to jakitto

Ruby 的社区文化是 语言(或者语言的库)的设计者、语言(或者语言的库)的实现者还有语言(或者语言的库)的使用者三者分离,DHH 的最大功绩是发明了 Rails 的体系结构还有核心的 API,说白了就是 DHH 告诉大家要有什么,应该长什么样子,其他人跟上完善,Ruby 语言也是这样,其实你读过早年 Ruby 的实现就知道 Matz 的编程水平并不高明,但是有高手的加入,MRI 在 benchmark 上,已经超越了 py

Reply to jakitto

另外说白了,你能做什么,做过什么,做得有多好,除了你自己,别人根本不会关心,这里不是面试。

搞一个 AR、Rails 出来,很多人都可以,有一个系列的课程就是教如何去设计一个 Rails 出来(Rebuiding Rails),你在另外一个帖子介绍 Autumn,这个论坛很多人都玩过类似的了,包括你的那个想法,好几年前就有人做过了,你可以搜一下。

@jasl 请问你们这种动态表单提交的数据在知人里是怎么持久化的呢?也是类似 form_core_fields 这种 EAV 的方式么?

Reply to darkbaby123

我们现在是把整体序列化,老系统是这样做的,而且也没有更复杂的查询上的需求了。

我以后有机会的话,会在 demo 里实现持久化吧,最近的工作确实写了套 EAV 相关的东西...

@jasl 👍 ,期待你开源相关的存储方案

Reply to darkbaby123

你有没有什么样的场景也可以讲下,Demo 是我对照金数据的功能来做的,想象不到的需求估计做出来也没有参考价值,所以就没去做

实现一个 web 版 excel😁 😁

@jasl 我现在在巧思负责新后端的开发。但我的场景不算是 form 的场景,只是同一个大类型的不同 object 都会有一些额外的属性,而且这些额外属性是可预测的(除了需求)而不是用户自定义的。目前是在应用层面用 hash/map 表示并存进数据库的 jsonb 字段里。

我们的问卷系统倒是跟自定义 form 有少数重叠部分,不过因为题目的渲染和验证等逻辑都是在前端实现的,后端更多的是持久化“元数据” ,所以也不存在用 Elixir 动态渲染页面的情况。

我和 @hooopo 讨论过这个问题,我俩的结论是,像我在 dummy 演示的做法(为额外属性建模但最终序列化入某列)可以,但是是反模式,用 jsonb 道理也是一样的,反模式在于这样做没办法定义一个很强的 schema,项目长久下来的维护会成为问题。

有可能的话,最好的做法还是为这些扩展属性建立实体模型。

Reply to darkbaby123

其实好奇你们怎么做的问题间跳转的,看巧思 cform 的问卷设计器,很像我们的工作流的各道审批流转,我下一步计划是规则引擎、工作流引擎,还没什么头绪但是,知人这块味做的味道不是很好。

Reply to stephen

前端苦手...

@jasl 嗯,我是为额外属性建立 Ecto schema 嵌套在主 schema 下面,方便 app struct 和 database row 的互相转换。大概这样:

defmodule Question do
  schema "questions" do
    field :type,   TypeEnum
    # 存放额外属性,Elixir 里为 map ,对应 Ruby 的 hash ,PostgreSQL 里面为 jsonb
    field :config, :map
  end

  # 每题定义一个子类型,用来格式化 config 的数据
  defmodule Select do
    embedded_schema do
      field :multi_sel, :boolean
      field :min_sel,   :integer
      field :max_sel,   :integer
    end
  end

  defmodule ValueMark do
    # ...
  end

  # 根据 type 选择题型相关的 schema 来转换数据
  def cast_config(ques, params) do
    mod = get_config_module(params[:type]) # get Select module
    config = params["config"] |> transform_to_schema(mod) |> transform_back_to_map_if_valid
    put_change(ques, :config, config)
  end
end

用 jsonb 有两个原因:

  • 每个题型子类型的属性太多了,如果少的话也可以用类似 STI 那样的做法,建立一堆额外的数据库字段。
  • 受制于 Ecto,没办法动态扩展 field,只能用嵌套的形式了。不过我最近碰到了不同的子类型有公用属性的情况(比如所有选择类的题目都有多选和随机),打算对子类型用动态的 schema 重构一下。

这种方法的主要缺陷在于没法保证引用完整性,比如额外属性里有图片资源的 id,我也在考虑要不要特事特办,额外开字段存储部分数据。

问题间跳转就一言难尽了,前端流程处理可以问问 @nightire ,后端这边我们是存储了每个题对应的下一题的路径数据,因为也有额外属性不定的问题,所以也是 jsonb 存的。

Reply to darkbaby123

我没实际写过 Ecto,但看你的代码,感觉跟我在 dummy 里的道理是一样的,总之原则就是:显式定义 schema;避免脏数据;结构化设计;数据的可读性。

@hooopo 觉得这种配置字段完全是可以分别单独建表的,可读性最佳,并且可以在数据库层面保证约束完整性

@jasl 嗯,是的。一个明确的结构更容易维护。分别建表完全可行,我这样就是偷懒 😄

增加了资源选择字段的演示~

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |    451 |    328 |      14 |      64 |   4 |     3 |
| Helpers              |     48 |     39 |       0 |       7 |   0 |     3 |
| Jobs                 |      2 |      2 |       1 |       0 |   0 |     0 |
| Models               |    989 |    809 |      37 |     113 |   3 |     5 |
| Channels             |      8 |      8 |       2 |       0 |   0 |     0 |
| JavaScripts          |      5 |      0 |       0 |       0 |   0 |     0 |
| Libraries            |     76 |     64 |       0 |       5 |   0 |    10 |
| Controller tests     |    224 |    177 |       7 |      32 |   4 |     3 |
| Model tests          |     35 |     15 |       5 |       0 |   0 |     0 |
| System tests         |     36 |     12 |       4 |       0 |   0 |     0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                |   1874 |   1454 |      70 |     221 |   3 |     4 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 1250     Test LOC: 204     Code to Test Ratio: 1:0.2

变长字段填上了~

@darkbaby123 有个问题...你们是怎么给表单里每项做标识的?就是字段名怎么取?

Reply to jasl

问卷其实跟自定义表单有些差别。自定义表单更类似标准的 form,区别只是每一个 item 的 key 是一个动态 id(对应静态表单中中每一项的 name),然后 value 可以多种不同的类型。具体做法取决于实现。但问卷就不那么标准。比如:

  1. 一个选择题加若干个其他选项,这几乎等于一个选择题加多个填空题。
  2. 有些异形题(比如矩阵)是二维的,需要两个 key 来关联答案。
  3. 循环的加入会导致下面的子题目扩展成多份,这时候很难用一个 model 的 id 作为 item key。目前的实现是用多个不同属性组合来关联答案的。

目前系统的做法其实挺简单,就是提交一个数组,每一项是答案。每个答案有各自不同的关联属性。举个例子:

[
  {NodeID: "{uuid}", OptionRID: "{uuid}", OptionCID: "{uuid or null}", OtherKeys: "..."},
  {...}
]

我现在在重构 (xie) 后端系统,也在思考更好的做法,不过要兼顾现在所有的功能实在很麻烦……

@jasl 你如果写 gem 的话,完全可以定义一个 serializer API,其他的让别人折腾去 😂

Reply to darkbaby123

嗯,有道理,搞个这东西来...

jasl in RoleCore —— 快速构建定制化的 RBAC 系统 mention this topic. 16 Jul 02:28

动态表单最麻烦的在于字段特殊处理,例如对字段有特殊的编辑或者显示需求,就需要添加例外情况来处理。不知道关于这点,作者有什么好办法?

Reply to zlx_star

有没有点具体的例子?

我现在的做法是把不同类型的字段抽象成模型(性质接近的字段可以通过继承来复用),模型包含字段的配置,在渲染字段的视图的时候,使用了 presenter 去封装页面的逻辑

假设 A,B 模型都有一个 Datetime 类型的字段 D1 和 D2。 现在 A 模型要求 D1 按照 YY-mm-dd 显示,B 模型要求 D2 按照 YY-mm-dd HH:MM 显示。

这种情况下如何来定义不同的显示逻辑?

Reply to zlx_star

我的做法是 设 A 的 D1 为 A.D1,B 的 为 B.D1 两个字段虽然名字相同,但是是不同的两条字段记录,字段的显示的方式是存储在字段记录中的,自然就分开了。

我的 demo 里虽然没有 Datetime 的演示,但是我的 text 字段有两种显示风格:单行和多行,应该性质和你说的情况类似吧

这是一个好办法。

赶上假期改进了一下,增加了序列化反序列化的支持,另外增加了多选字段的支持

提交之后可以序列化成 YAML(可以自己实现序列化器,这里用 YAML 是因为可视化的效果比较好)

可以还原回表单

拉最新的代码可以在 dummy 应用里体验~

求解释核心代码~。~

prepend ->append

You need to Sign in before reply, if you don't have an account, please Sign up first.