Ruby Rails 层级设计演化(笔记)

qinsicheng · July 13, 2025 · 43 hits

相关资料

  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. 利用面向资源的方式进行思考和建模
No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.