Rails层级设计演化
Rails层级设计演化
相关资料
最初的样子
我们都知道rails初始化项目,在层级拆分上只有两层
- controller
- model

controller负责接收请求,model负责实现存储,那业务逻辑在哪里实现?
可能为了model的干净,你可能将业务逻辑写到controller中。也有可能为了复用,将业务逻辑写在model中。但不管选择哪种方式,随着业务逻辑的不断迭代,你的controller或者model也逐渐的臃肿。变得难以维护,也有很多人批评说不应该controller直接访问model层,自然而然的大家决定抽离出业务层,比如:Service,Command等等
引入Service
比如我有一个报价单模型,我可能有类似的结构
# controller
class QuotationController < ApplicationController
end
# Command
class QuotationCommand
def create
end
def list
end
def modify
end
def delete
end
end
# model
class Quotation < ApplicationRecord
end
所有关于报价单的操作我都封装到QuotationCommand中,通过Controller调用Command,最终调用Model
可以看到我将增删改查的复杂业务逻辑统一放入到Command中,假如我这时候再来一个新的需求比如我要导出一部分数据,我就简单的称为export
吧,根据惯性我会将这个功能也实现到QuotationCommand中。一切自然而然,但是很明显我的这个类早已违反了单一功能原则(Single responsibility principle),直到有一天这个巨无霸的Command也变得臃肿和难以维护,随便一个修改都可能是牵一发而动全身的。没办法我们再按照功能拆分一下吧。
看起来很漂亮,每个Command只负责自己的业务,保证职责单一,如果再出现新的业务只需要构建新的Command即可。
管理Concern
我们目前的方案有什么问题呢?
比如ExportCommand
导出功能是在列表查询的基础上做的,比如我有一个后台管理页面,它拥有一个列表搜索页,通过传递不同的筛选项,查询出不同的数据,使用ListCommand
。现在我希望能将查询的数据导成Excel,也就是ExportCommand
负责的内容,毫无疑问我查询的功能已经有了,我只需要在ExportCommand
中使用ListCommand
即可,那这就牵扯到一个问题:
Command之前是否允许相互调用?
如果相互允许调用,势必引发耦合性,也就是ListCommand
的修改势必会影响到ExportCommand
抽离Command本身可能会有什么问题?
我们引用一些DDD书评论
Now, the more common mistake is to give up too easily on fitting the behavior into an appropriate object, gradually slipping towards procedural programming.*
现在,更常见的错误是过于轻易地放弃将行为适配到适当的对象中,逐渐滑向过程编程。
Don’t lean too heavily toward modeling a domain concept as a Service. Do so only if the circumstances fit. If we aren’t careful, we might start to treat Services as our modeling “silver bullet.” Using Services overzealously will usually result in the negative consequences of creating an Anemic Domain Model, where all the domain logic resides in Services rather than mostly spread across Entities and Value Objects.
不要过于倾向于将领域概念建模为一个 Service。仅仅在情况适合时才这样做。如果我们不仔细地话,就可能会开始将 Services 视为建模的“银弹”。过度使用 Services 通常会导致创建贫血领域模型的负面后果,其中所有领域逻辑都驻留在 Services 中,而不是主要分布在实体和值对象中。
也就是说,如果我们如果不假思索的将所有的行为一股脑的塞进Service层级中,而不考虑内聚性,抽象性,我们会渐渐走向面向过程编程,并且会导致模型贫血,即为携带数据的空壳。
所以我们再看看该怎么组织Model?
假设我们有一个User
模型,随着业务发展,它包含了用户认证、资料管理、权限检查、统计报表等多种功能,导致代码量过大。
# app/models/user.rb
class User < ApplicationRecord
# 验证相关
validates :email, presence: true, uniqueness: true
validates :password, presence: true, length: { minimum: 8 }
# 认证相关
has_secure_password
has_many :sessions
def generate_auth_token
# 生成认证token的逻辑
end
# 资料管理相关
before_save :format_name
has_one :profile
def full_name
"#{first_name} #{last_name}"
end
private def format_name
self.first_name = first_name.capitalize
self.last_name = last_name.capitalize
end
# 权限相关
ROLES = %w[admin moderator user guest]
def admin?
role == 'admin'
end
def can_edit?(resource)
admin? || resource.user == self
end
# 统计相关
def self.active_users_count
where(last_active_at: 1.week.ago..Time.current).count
end
def activity_score
# 计算用户活跃度分数
end
# ... 更多方法 ...
end
里面混乱的组织着各种方法,假如我想找到某个功能,我需要一个个的看,哪些对我是有用的。这也称为Fat Model
但如果我们能用Concern进行合理化的拆分聚合,效果就会完全不同
拆分的目录结构:
app/
models/
concerns/
user/
authenticatable.rb
profileable.rb
roleable.rb
reportable.rb
拆分后Concern模块
# app/models/concerns/user/authenticatable.rb
module User::Authenticatable
extend ActiveSupport::Concern
included do
validates :email, presence: true, uniqueness: true
validates :password, presence: true, length: { minimum: 8 }
has_secure_password
has_many :sessions
end
def generate_auth_token
# 生成认证token的逻辑
end
end
# app/models/concerns/user/profileable.rb
module User::Profileable
extend ActiveSupport::Concern
included do
before_save :format_name
has_one :profile
end
def full_name
"#{first_name} #{last_name}"
end
private
def format_name
self.first_name = first_name.capitalize
self.last_name = last_name.capitalize
end
end
# app/models/concerns/user/roleable.rb
module User::Roleable
extend ActiveSupport::Concern
included do
ROLES = %w[admin moderator user guest]
end
def admin?
role == 'admin'
end
def can_edit?(resource)
admin? || resource.user == self
end
end
# app/models/concerns/user/reportable.rb
module User::Reportable
extend ActiveSupport::Concern
class_methods do
def active_users_count
where(last_active_at: 1.week.ago..Time.current).count
end
end
def activity_score
# 计算用户活跃度分数
end
end
拆分后的User模型
# app/models/user.rb
class User < ApplicationRecord
include User::Authenticatable
include User::Profileable
include User::Roleable
include User::Reportable
# 这里只保留模型特有的或无法分类的方法
end
这样拆分后,可能都不用构建特定的Command,模型本身维护拆分良好的功能,来让Controller直接进行调用
但是这也要求团队内的成员遵守统一的规范和要求,并且具有良好的抽象化思想,这一点很难,最难的可能是如何给你的模块命名🤣,因为它直接反映了该模块儿的内涵。
面向资源思考
如何管理Controller或者说是路由,通过面向资源(resource)的方式。这是有些有趣的观点值得思考
引用
https://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/
最初Controller只有默认的七个标准方法(index, show, new, edit, create, update, destory),对应代表的含义也很清晰明确,针对某一资源的查询,详情,新建,编辑,删除操作。但是随着业务的不断膨胀,Controller内的非标准方法会越来越多,对于一个资源的操作也渐渐不再清晰。
比如说我有个一个 Topic(Model),最初我只需要使用CRUD操作,七个标准方法能满足我的要求,后面我需要新加一个功能:喜欢(favorite)/取消喜欢(unfavorite)
,我可能直接这么写
class TopicController
def index; end
def show; end
def new; end
def edit; end
# ... 其余的标准方法
# 喜欢
def favorite
end
# 取消喜欢
def unfavorite
end
end
那如果按照资源的方式思考,是不是可以理解为
create favorite
喜欢 这个行为 => 创建 喜欢 这个资源
destory favorite
取消喜欢 这个行为 => 删除 喜欢 这个资源
在Controller设计中,将喜欢这个资源抽离出来, 通过标准的7个方法进行表述这个资源
class Topice::FavoriteController
def create
end
def destory
end
end
两个Controller都保持了简单和整洁。需要注意这里的Favorite可能是一个数据库存储的表,也可能只是一个业务逻辑模型。
其实这个思维更打动我的是,在Model设计上我们可以利用类似的想法,Model并不代表一定要和数据库交互,我们可以通过ActiveModel来直接使用Rails提供的便利,比如校验,回调等行为。通过这种方式将抽象复杂的业务逻辑,思考为一个个小的资源对象,并相互发送信息。
总结
简略总结几点:
- 鼓励构建
rich domain model
,不过为了避免Fat
- 使用Concern来组织Model的代码
- 将复杂功能委托到额外的对象系统(PORO)
- ActiveRecord和Poro视为一整套领域模型,而至于是否需要持久化,业务逻辑消费者并不在意
- 对于复杂的行为,可构建Service/Command,但是需要谨慎和良好的设计(比较有趣的是:
37signal
很激进,觉得Service层都是没有必要的。) - 利用面向资源的方式进行思考和建模