项目地址:https://github.com/rails-engine/form_core
在 知人,我们的系统里有很多依赖用户定义的表单,比如不同的公司会在员工档案中添加额外的项目,审批流程的表单内容,则完全交给用户自行设计,字段的数据类型也多种多样,如简单的文本字段、数字字段、开关字段,复杂的有聚合字段(如地址字段含省市区)、数据引用字段、嵌套字段(如报销清单)等,其丰富程度并不亚于同样适用 Rails 技术开发的产品 —— 金数据。
前一段时间,我负责了重构这个模块,在这过程中,我发现了一种方法使得我在一天不到的时间内就完成了原型的开发。并且我相信这也是一个很有趣、很有普遍需求(主要是企业系统)的功能,所以,我通过实现一个非常简单的 Gem,来介绍这个思路。
这个 Gem 包含了两部分:
你不必直接使用 Form Core,它主要展示了一种手法。
我将在后文介绍思路,实际上,我并未完成这个 Gem,我的个人能力有限,有一些做法我仍未找到最好的方式,并且接下来我要把精力用到组织本年的 RubyConf China 上(9.16 - 17,杭州),在这种情况下与其闭门造车,不妨在此期间公开出来听取社区朋友们的想法。我也有意把随附的 Dummy app 打造成一个生产级标准的项目,作为笔记。
此外,随着代码的推进,我也会不时更新这篇文章。
FormCore 的核心代码约二十行,其思路的主要特点是充分利用 Rails 提供已有的机制,避免了重复实现
User.validates :name, presence: true
的写法为 User
模型增加新的验证class
关键字定义的类(例如通常定义在 app/models
目录里的模型)的生命周期是跟 APP 的生命周期一致的,所以不可以直接操作他们Class.new
方法可以实现动态创建类型,其形参为基类,于是如下代码是可行的new_user_class = Class.new(User)
new_user_class.validates :name, presence: true
Class.new(ActiveType::Object)
构造一个临时的模型类对象,遍历相关的字段定义的记录,根据其数据调用相应的模型的 DSL 方法即可Form Core 提供的一套最小的表单(Form
)和表单字段(Field
)的定义模式,以及通用的虚拟模型类的生成逻辑。
在你的应用中根据需要扩展它们,具体的实例参考 Dummy app。
上文提到了提供虚拟模型的 Gem ActiveType
,但在 FormCore 里,实际使用的是我实现的 duck_record,是我大概两个通宵的试验性成果(虽然是试验性成果,但偷偷跑在知人生产系统上两个多月了,目前还未收集到相关的 Bug...)。
区别在于,ActiveType
的原理是通过 MonkeyPatch 去屏蔽掉 ActiveRecord
和数据库相关的行为,而 DuckRecord
在代码层面剔除了所有与数据库相关的代码,并且强化了一部分功能,例如移植了 ActiveRecord PostgreSQL-adapter 中的 Array、支持 has_many
虚拟模型(其实叫做 embeds_many
更合意图)、真正的支持 attr_readonly
(对象初始化后,对只读字段赋值不会造成改变)等。
Field 是一个抽象类,所有的字段类型都应继承它,并覆写 stored_type
方法,其返回值对应了 ActiveModel::Type 注册的类型(关于此见附录)
定义了如下字段:
name
:字段的内部标识accessibility
:字段的可访问性,分别为“可读写”、“只读”、“隐藏”validations
:序列化字段,存储字段验证static_default_value
:静态的默认值options
:序列化字段,存储字段的个性化配置type
:用于单表继承的记录类型的字段Form 模型只有一个用于 STI 的 type
字段,此外它 has_many :fields
虚拟模型类的基类,其继承了 DuckRecord::Base
,并做了两处 hack。
define_method
方法model_name
,这个类属性的原始实现和 Class.new
冲突(因为 Class.new
生成的类没有类名)入口为 Form#to_virtual_model
,其中利用 Class.new(VirtualModel)
生成表单类之后,遍历 fields
并依此调用他们的 Field#interpret_to
方法
文档在此,实际上 ActiveRecord 的字段也是通过这些来进行类型转换的,实际上这部分是 AM 设计很糟糕的部分,它和 AM 的其它部分是完全无关并且没有引用的,抽取自 AR,但是抽取的很失败以至于抽离后 ActiveRecord 几乎又重新实现了一遍,详细原因可以研读下 AR 覆写的代码,此外抽取的时候少删了点代码,以至于让我做了一点微小的贡献 :)。
但无论如何,它是有使用价值的,举例,正确的处理 true
值:前端传回来的布尔值,有可能为 1
、t
、true
、on
等等,可以通过 ActiveModel::Type.lookup(:boolean).cast(value)
安全准确的得到 Ruby 的真假值。
继承 ActiveRecord::Base
的模型,都可以享受到,作用是显式的声明字段,强大的是,他支持默认值,并且这个默认值可以是一个 proc
。
举例,设置默认值的时候可以 attribute :published_at, :datetime, default: -> { Time.now }
这样做
另外你需要在模型中的数据类型和数据库中的不一致的时候,也可以考虑使用它
从 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
的 i18nenum
字段是 Rails 5 的新功能,但遗憾的是,官方没有提供枚举字段的 i18n 方法,坛友 @zmbacker 的 enum_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 %>
不过稍微啰嗦了一点(眼尖的估计能看出来这是魔改自哪里的代码了...)
利用 SimpleDelegator 就可以实现一个超简单的 Presenter。
如果理解不了就千万不要使用
比如 Dummy 的模型间关系 Form has_many Fields; Form has_many Sections; Section has_many Fields
,当同时需要 form.fields
和 section.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