相关资料
我们都知道 rails 初始化项目,在层级拆分上只有两层
controller 负责接收请求,model 负责实现存储,那业务逻辑在哪里实现?
可能为了 model 的干净,将业务逻辑写到 controller 中。也有可能为了代码复用,将业务逻辑写在 model 中。但不管选择哪种方式,如果不进行谨慎的管理,随着业务的不断迭代,你的 controller 或者 model 也会逐渐的臃肿,变得难以维护。自然而然的大家决定抽离出业务层,比如:Service,Command 等等
比如我有一个报价单模型,我可能有类似的结构
# 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 即可。
我们目前的方案有什么问题呢?
比如ExportCommand
导出功能是在列表查询的基础上做的,比如我有一个后台管理页面,它拥有一个列表搜索页,通过传递不同的筛选项,查询出不同的数据,使用ListCommand
。现在我希望能将查询的数据导成 Excel,也就是ExportCommand
负责的内容,毫无疑问我查询的功能已经有了,我只需要在ExportCommand
中使用ListCommand
即可,
# 紧耦合的Command示例
class ListCommand
def initialize(params)
@params = params
end
def call
# 复杂的查询逻辑
scope = User.all
scope = scope.where(role: @params[:role]) if @params[:role]
scope = scope.search(@params[:q]) if @params[:q]
scope.order(created_at: :desc)
end
end
class ExportCommand
def initialize(params)
@params = params
end
def call
# 直接依赖ListCommand的实现
users = ListCommand.new(@params).call
# 导出逻辑
CSV.generate do |csv|
csv << %w[id name email]
users.each { |u| csv << [u.id, u.name, u.email] }
end
end
end
那这就牵扯到一个问题:
Command 之前是否允许相互调用?
如果相互允许调用,势必引发耦合性,也就是ListCommand
的修改势必会影响到ExportCommand
, 或者说因为 ExportCommand 需要一些其他内容,反而去调整ListCommand
的行为。
更好的方式应该将 Command 共用的业务逻辑,封装到一个独立的 Poro(Plain Old Ruby Object) 中,或者将业务封装到 Model 中,通过 Concern 来组织。
# 将查询逻辑提取到独立模块
module Search
class UserSearch
def initialize(scope = User.all)
@scope = scope
end
def search(params)
scope = @scope
scope = filter_by_role(scope, params[:role]) if params[:role]
scope = filter_by_keyword(scope, params[:q]) if params[:q]
scope.order(created_at: :desc)
end
private
def filter_by_role(scope, role)
scope.where(role: role)
end
def filter_by_keyword(scope, keyword)
scope.search(keyword) # 假设有search方法
end
end
end
# 解耦后的Command
class ListCommand
def initialize(params)
@params = params
end
def call
Search::UserSearch.new.search(@params)
end
end
class ExportCommand
def initialize(params)
@params = params
end
def call
users = Search::UserSearch.new.search(@params)
CSV.generate do |csv|
csv << %w[id name email]
users.each { |u| csv << [u.id, u.name, u.email] }
end
end
end
过度抽离 Command 本身可能会有什么问题?
我们引用一些评论
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
# 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
# app/models/user.rb
class User < ApplicationRecord
include User::Authenticatable
include User::Profileable
include User::Roleable
include User::Reportable
# 这里只保留模型特有的或无法分类的方法
end
这样拆分后,可能都不用构建特定的 Command,模型本身维护拆分良好的功能,来让 Controller 直接进行调用
但是这也要求团队内的成员遵守统一的规范和要求,并且具有良好的抽象化思想,这一点很难,最难的可能是如何给你的模块命名🤣,因为它直接反映了该模块儿的内涵。
如何管理 Controller 或者说是路由,通过面向资源 (resource) 的方式。这是有些有趣的观点值得思考
引用
传统的 Rails Controller 设计容易陷入"动作膨胀"的陷阱:
最初 Controller 只有默认的七个标准方法(index, show, new, edit, create, update, destory),对应代表的含义也很清晰明确,针对某一资源的查询,详情,新建,编辑,删除操作。但是随着业务的不断膨胀,Controller 内的非标准方法会越来越多,对于一个资源的操作也渐渐不再清晰。
比如说我有个一个 Topic(Model),最初我只需要使用 CRUD 操作,七个标准方法能满足我的要求,后面我需要新加一个功能:喜欢(favorite)/取消喜欢(unfavorite)
,我可能直接这么写
# 传统方式 - 动作膨胀
class TopicsController < ApplicationController
# 标准CRUD动作
def index; end
def show; end
# ...
# 自定义动作越来越多
def favorite; end
def unfavorite; end
def publish; end
def archive; end
def recommend; end
# ... 随着业务增长,这里会越来越长
end
这种设计会导致:
# routes.rb
resources :topics do
member do
post :favorite # /topics/:id/favorite
post :unfavorite # /topics/:id/unfavorite
end
end
# controllers/topics_controller.rb
def favorite
current_user.favorites.create(topic: @topic)
redirect_to @topic, notice: "已收藏"
end
def unfavorite
current_user.favorites.where(topic: @topic).destroy_all
redirect_to @topic, notice: "已取消收藏"
end
# routes.rb
resources :topics do
resource :favorite, only: [:create, :destroy] # 单数资源
end
# controllers/favorites_controller.rb
class FavoritesController < ApplicationController
before_action :set_topic
def create
current_user.favorites.create(topic: @topic)
redirect_to @topic, notice: "已收藏"
end
def destroy
current_user.favorites.where(topic: @topic).destroy_all
redirect_to @topic, notice: "已取消收藏"
end
private
def set_topic
@topic = Topic.find(params[:topic_id])
end
end
那如果按照资源的方式思考,是不是可以理解为
create favorite
请求 喜欢 这个行为 => 创建 喜欢 这个资源
destory favorite
请求 取消喜欢 这个行为 => 删除 喜欢 这个资源
两个 Controller 都保持了简单和整洁。需要注意这里的 Favorite 可能是一个数据库存储的表,也可能只是一个业务逻辑模型。
其实这个思维更打动我的是,在 Model 设计上我们可以利用类似的想法,Model 并不代表一定要和数据库交互,我们可以通过 ActiveModel 来直接使用 Rails 提供的便利,比如校验,回调等行为。通过这种方式将抽象复杂的业务逻辑,思考为一个个小的资源对象,并相互发送信息。
简略总结几点:
rich domain model
,不过为了避免Fat
37signal
很激进,觉得 Service 层都是没有必要的。)