Rails层级设计演化

相关资料

  1. 37signal - 层级设计
  2. 37signal - concern设计
  3. 层级设计翻译
  4. concern设计翻译
  5. gitlab-ddd应用
  6. 面向资源编程

最初的样子

我们都知道rails初始化项目,在层级拆分上只有两层

  1. controller
  2. model
image-20250709225003138

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

image-20250709225052038

可以看到我将增删改查的复杂业务逻辑统一放入到Command中,假如我这时候再来一个新的需求比如我要导出一部分数据,我就简单的称为export吧,根据惯性我会将这个功能也实现到QuotationCommand中。一切自然而然,但是很明显我的这个类早已违反了单一功能原则(Single responsibility principle),直到有一天这个巨无霸的Command也变得臃肿和难以维护,随便一个修改都可能是牵一发而动全身的。没办法我们再按照功能拆分一下吧。

image-20250709225127210

看起来很漂亮,每个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/

https://www.bilibili.com/video/BV19z411B7Qy

最初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提供的便利,比如校验,回调等行为。通过这种方式将抽象复杂的业务逻辑,思考为一个个小的资源对象,并相互发送信息。

总结

简略总结几点:

  1. 鼓励构建rich domain model,不过为了避免Fat
    1. 使用Concern来组织Model的代码
    2. 将复杂功能委托到额外的对象系统(PORO)
    3. ActiveRecord和Poro视为一整套领域模型,而至于是否需要持久化,业务逻辑消费者并不在意
  2. 对于复杂的行为,可构建Service/Command,但是需要谨慎和良好的设计(比较有趣的是:37signal很激进,觉得Service层都是没有必要的。)
  3. 利用面向资源的方式进行思考和建模