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也会逐渐的臃肿,变得难以维护。自然而然的大家决定抽离出业务层,比如:Service,Command等等

引入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

image-20250709225052038

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

image-20250709225127210

看起来很漂亮,每个Command只负责自己的业务,保证职责单一,如果再出现新的业务只需要构建新的Command即可。

管理Concern

我们目前的方案有什么问题呢?

比如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

拆分后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)的方式。这是有些有趣的观点值得思考

引用

dhh如何管理Controller

Reconsider REST by 陈金洲

为什么需要它

传统的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

这种设计会导致:

  1. Controller变得臃肿,难以维护
  2. 动作之间缺乏明确的组织原则
  3. 路由变得复杂且不一致

如何用资源思维重构?

重构前 - 传统方式

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

总结

简略总结几点:

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