【问题标题】:Metrics/AbcSize Too High: How do I decrease the ABC in this method?Metrics/AbcSize Too High:我如何减少这种方法中的 ABC?
【发布时间】:2020-08-01 22:07:06
【问题描述】:

我最近开始使用 Rubocop 来“标准化”我的代码,它帮助我优化了很多代码,并帮助我学习了很多 Ruby“技巧”。我知道我应该使用自己的判断并在必要时禁用警察,但我发现自己非常坚持以下代码:

def index
  if params[:filters].present?
    if params[:filters][:deleted].blank? || params[:filters][:deleted] == "false"
      # if owned is true, then we don't need to filter by admin
      params[:filters][:admin] = nil if params[:filters][:admin].present? && params[:filters][:owned] == "true"
      # if admin is true, then must not filter by owned if false
      params[:filters][:owned] = nil if params[:filters][:owned].present? && params[:filters][:admin] == "false"
      companies_list =
        case params[:filters][:admin]&.to_b
        when true
          current_user.admin_companies
        when false
          current_user.non_admin_companies
        end
      if params[:filters][:owned].present?
        companies_list ||= current_user.companies
        if params[:filters][:owned].to_b
          companies_list = companies_list.where(owner: current_user)
        else
          companies_list = companies_list.where.not(owner: current_user)
        end
      end
    else
      # Filters for deleted companies
      companies_list = {}
    end
  end
  companies_list ||= current_user.companies
  response = { data: companies_list.alphabetical.as_json(current_user: current_user) }
  json_response(response)
end

除其他外,我得到的错误如下:

C: Metrics/AbcSize: Assignment Branch Condition size for index is too high. [<13, 57, 16> 60.61/15]

我了解它背后的数学原理,但我不知道如何简化此代码以达到相同的结果。

有人可以给我一些指导吗?

提前致谢。

【问题讨论】:

  • 与其对这段特定的代码进行评论,我建议您阅读 Martin Fowler 的 Refactoring 或 Sandi Metz 的 99 Bottles of OOP 的副本。您将学到很多关于减少复杂代码和改进设计的方法。
  • @AndyWaite 谢谢你的建议。我查阅了您推荐的 Martin Fowler 的书,并已开始阅读。

标签: ruby ruby-on-rails-5 rubocop


【解决方案1】:

首先,这段代码是否经过全面测试,包括所有各种条件?它是如此复杂,除非测试套件是严格的,否则重构肯定会是灾难性的。因此,如果您还没有,请编写一个全面的测试套件。如果已经有一个测试套件,请确保它测试了所有条件。

其次,应用“胖模型瘦控制器”范式。所以将所有的复杂性转移到一个模型中,我们称之为CompanyFilter

def index
  companies_list = CompanyFilter.new(current_user, params).list
  response = { data: companies_list.alphabetical.as_json(current_user: current_user) }
  json_response(response)
end

并将所有这些 if/then/else 语句移到 CompanyFilter#list 方法中

测试仍然通过?太好了,您仍然会收到 Rubocop 警告,但与 CompanyFilter 类有关。

现在你需要解开所有的条件。我有点难以理解发生了什么,但看起来它应该可以简化为一个案例陈述,有 5 种可能的结果。所以 CompanyFilter 类可能看起来像这样:

class CompanyFilter
  attr_accessors :current_user, :params

  def initialize(current_user, params)
    @current_user = current_user
    @params = params
  end

  def list
    case
    when no_filter_specified
      {}
    when user_is_admin
      @current_user.admin_companies
    when user_is_owned
      # etc
    when # other condition
      # etc
    end
  end

  private
  def no_filter_specified
    @params[:filter].blank?
  end

  def user_is_admin
    # returns boolean based on params hash
  end

  def user_is_owned
    # returns boolean based on params hash
  end
end

测试仍然通过?完美的! [编辑] 现在您可以将大部分控制器测试移动到 CompanyFilter 类的模型测试中。

最后,我会将所有不同的 company_list 查询定义为 Company 模型的范围,例如

class Company < ApplicationRecord
  # some examples, I don't know what's appropriate in this app
  scope :for_user, ->(user){ where("...") }
  scope :administered_by, ->(user){ where("...") }
end

【讨论】:

  • 是的,我什至在编写代码之前就已经进行了测试。我还为这个项目实施了 TDD,它已经获得了十倍的回报。我从来没有想过这样做,因为在控制器中有条件是合乎逻辑的,但你建议的方式是有道理的。另外,我想过把它“拆分”成多种方法,但我觉得那是作弊。您的方式提出了一种更好的方法来处理需要它们的所有模型的所有过滤器(它们会)。我将对此进行测试并回复您。谢谢
  • 感谢 TDD,Theo。我添加了另一个关于测试的注释。
  • 谢谢莱斯。这很好用并且很容易实现。最好的是我可以将这种方法用于我的其他过滤器。我看到了你在编辑时做了什么哈哈。只是一个问题:为什么我需要自己测试CompanyFilter?通过index action的请求进行测试还不够吗?
  • 很高兴这对你有用,西奥。请考虑将其指定为“已接受的答案”。您不需要在模型和控制器中测试 CompanyFilter 行为,是的,在控制器中测试它就足够了。在我看来,这是一种更好的方式来组织你的测试规范,拥有一个只关注模型功能的规范。它通常性能更高,但测试控制器是等效的。将行为移至模型后,将测试移至模型测试是“好习惯”。
  • 这是关于测试模型与控制器的事情...您的测试可以更加精细...您已经分解了难以测试的 if/then/else 语句易于测试的个别方法。一些开发人员甚至会进行下一步并重构他们的测试规范。
【解决方案2】:

在编写数据库范围时,ActiveRecord::SpawnMethods#merge 是你的朋友。

Post.where(title: 'How to use .merge')
    .merge(Post.where(published: true))

虽然它看起来并不多,但它可以让您以编程方式组合作用域,而不会过度依赖变异赋值和 if/else 树。例如,您可以组合一组条件并将它们合并到一个带有Array#reduceActiveRecord::Relation 对象中:

[Post.where(title: 'foo'), Post.where(author: 'bar')].reduce(&:merge)
# => SELECT "posts".* FROM "posts" WHERE "posts"."title" = $1 AND "posts"."author" = $2 LIMIT $3

因此,让我们将其与在单独对象中处理过滤的瘦控制器方法相结合:

class ApplicationFilter
  include ActiveModel::Attributes 
  include ActiveModel::AttributeAssignment 
  attr_accessor :user

  def initialize(**attributes)
    super()
    assign_attributes(attributes)
  end

  # A convenience method to both instanciate and apply the filters
  def self.call(user, params, scope: model_class.all)
    return scope unless params[:filters].present?
    scope.merge(
      new(
        permit_params(params).merge(user: user)
      ).to_scope
    )
  end

  def to_scope
    filters.map { |filter| apply_filter(filter) }
           .compact
           .select {|f| f.respond_to?(:merge) }
           .reduce(&:merge)
  end

  private
  # calls a filter_by_foo method if present or 
  # defaults to where(key => value)
  def apply_filter(attribute)
    if respond_to? "filter_by_#{attribute}"
      send("filter_by_#{attribute}")
    else 
      self.class.model_class.where(
        attribute => send(attribute)
      )
    end
  end
  # Convention over Configuration is sexy.
  def self.model_class 
    name.chomp("Filter").constantize 
  end

  # filters the incoming params hash based on the attributes of this filter class
  def self.permit_params
    params.permit(filters).reject{ |k,v| v.blank? }
  end
  
  # provided for modularity
  def self.filters
    attribute_names
  end
end

这使用了 Rails 提供的一些优点来设置具有属性的对象,这些属性将动态处理过滤属性。它会查看您已声明的属性列表,然后将这些属性从参数中分割出来,并为该过滤器应用一个方法(如果存在)。

然后我们可以写一个具体的实现:

class CompanyFilter < ApplicationFilter
  attribute :admin, :boolean, default: false
  attribute :owned, :boolean 

  private

  def filter_by_admin
    if admin
      user.admin_companies
    else
      user.non_admin_companies
    end
  end

  # this should be refactored to use an assocation on User
  def filter_by_owned
    case owned
    when nil
      nil
    when true
      Company.where(owner: user)
    when false
      Company.where.not(owner: user)
    end
  end
end

你可以这样称呼它:

# scope is optional
@companies = CompanyFilter.call(current_user, params), scope: current_user.companies)

【讨论】:

  • 这是一个非常优雅的解决方案,有助于使我的过滤器更通用。我肯定会研究它,因为它引入了很多我以前没有使用过的概念(我倾向于坚持使用更简单的代码——这当然效率不高)。
猜你喜欢
  • 2018-06-14
  • 2022-08-18
  • 2015-02-12
  • 1970-01-01
  • 2019-03-30
  • 1970-01-01
  • 1970-01-01
  • 2020-05-11
  • 2013-12-01
相关资源
最近更新 更多