如何为你的Ruby代码提供紧急服务

114 阅读5分钟

很难找到一个没有听说过RuboCop的Ruby开发者。在所有最近的Evil Martians项目中,RuboCop从早期就被包含在CI管道中。然而,有时手动编写更多的规则并执行一些特定项目的做法可能会有帮助。在文章结束时,你将处于一个很好的位置来应对自定义警察,将它们添加到你的应用程序中,甚至将它们提取到一个单独的宝石中。让我们开始吧!

根据我们的经验,大多数维护良好的、大型的、前沿的Ruby应用程序都使用RuboCop--一个用于检查和维护Ruby代码风格的工具。只需将RuboCop纳入你的CI套件,它将在审查拉动请求时节省时间和精力,以防止基本的样式违规。

在某些时候,你意识到内置的规则并不能完全满足你的应用,所以你想增加一些。使用自定义警察的另一个好处是,当重构一个成熟的应用程序时,它们会很有帮助:添加一个规则来阻止一些不幸的做法,生成一个TODO配置来使RuboCop通过,并逐一重构所有发生的情况。另外,你也可以实现自动更正,RuboCop会处理剩下的事情。请看一些打击遗留代码和执行一些做法的警察。

  • 一个限制访问ENV的警察
  • 一个防止特定项目的不良做法并知道如何自动纠正的警察
  • 一个要求在Sidekiq作业中明确定义队列并确保它存在于sidekiq.yml (感谢来自epicery@stem)。
  • 一些由charitywater提供的定制警察
  • 来自Airbnb的几十个自定义警察
  • 来自TestDouble的一个强制执行单行大括号的警察
  • 用于Sorbet(一个静态类型检查器)的警察

编写代码101

让我们写一个简单的cop,以确保你的GraphQL突变中的所有参数都是蛇形结构的。

# good
class BanUser < BaseMutation
  argument :user_id, ID, required: true
end

# bad
class BanUser < BaseMutation
  argument :userId, ID, required: true
end

在这里,它开始了。

class ArgumentName < RuboCop::Cop::Cop
  def_node_matcher :argument_name, <<~PATTERN
    (send nil? :argument (:sym $_) ...)
  PATTERN

  MSG = "Use snake_case for argument names".freeze
  SNAKE_CASE = /^[\da-z_]+[!?=]?$/.freeze

  def on_send(node)
    argument_name(node) do |name|
      next if name.match?(SNAKE_CASE)

      add_offense(node)
    end
  end
end

让我们一行一行地探索代码。我们首先从RuboCop::Cop::Cop 继承我们的类,它是所有警察的基类。如果你愿意,你也可以定义你自己的基类(它继承自RuboCop::Cop::Cop )。

注意了!本节是对精彩的官方指南的简要回顾。如果你需要更多关于AST和节点模式的细节,请参考它。

然后我们定义一个名为argument_name 的助手,它可以确定一个给定的节点是否是一个参数定义。传递给def_node_matcher 帮助器的第二个参数是一个*模式,*它描述了我们想要寻找的节点。由于RuboCop使用解析器,我们可以应用它来检查任何使用它的代码。

ruby-parse -e 'argument :user_id, ID, required: true'

(send nil :argument
  (sym :user_id)
  (const nil :ID)
  (kwargs
    (pair
      (sym :required)
      (true))))

你能看到我们传递给def_node_matcher 的模式与上面的AST相似吗?我们寻找带有:sym 参数的:argument 方法调用(send),并使用 来告诉RuboCop,我们不关心其余的参数。我们还使用nil? 谓词来指定argument 方法不用于任何对象。$_ 是一个捕获:如果RuboCop决定所传递的节点与一个给定的模式相匹配,它就会将捕获的内容输出到块中或从方法调用中返回。

让我们转到on_send 方法。它是使用最广泛的回调方法之一;当RuboCop在AST中找到一个send 的节点时,它就会被调用。现在我们可以使用我们的argument_name 帮助器来获得参数名称(如果给定的节点不符合模式,它甚至不会产生)。我们唯一需要做的是进行额外的检查(在我们的例子中,检查参数名是否在蛇形的情况下),并添加进攻。默认情况下,add_offense ,搜索当前cop类中定义的MSG 常量。

想在一个真正的项目中试试吗?让我们把我们的cop移到lib/custom_cops 文件夹中,把它包到CustomCops 模块中,并要求我们的cop在.rubocop.yml:

# .rubocop.yml
require:
  - ./lib/custom_cops/argument_name.rb

在这一点上,RuboCop应该把我们的cop添加到它的列表中,并在常规的rubocop 检查中运行它。🥳

让我们再写一个cop来学习更多的技巧。想象一下,比如说,你想让Rails模型中的作用域按字母顺序排序。

# bad
class User < ApplicationRecord
  scope :inactive, -> { where(active: false) }
  scope :active, -> { where(active: true) }
end

# good
class User < ApplicationRecord
  scope :active, -> { where(active: true) }
  scope :inactive, -> { where(active: false) }
end

下面是这个cop。

class OrderedScopes < RuboCop::Cop::Cop
  def_node_search :scope_declarations, <<~PATTERN
    (send nil? :scope (:sym _) ...)
  PATTERN

  MSG = "Scopes should be sorted in an alphabetical order. " \
        "Scope `%<current>s` should appear before `%<previous>s`.".freeze

  def on_class(node)
    scope_declarations(node).each_cons(2) do |previous, current|
      next if scope_name(current) > scope_name(previous)

      register_offense(previous, current)
    end
  end

  private

  def register_offense(previous, current)
    message = format(
      MSG,
      previous: scope_name(previous),
      current: scope_name(current)
    )
    add_offense(current, message: message)
  end

  def scope_name(node)
    node.first_argument.value.to_s
  end
end

在最开始的时候,我们使用了一个新的辅助工具,叫做def_node_search 。与def_node_matcher 有什么不同? 首先,它在子节点上进行遍历(而def_node_matcher 只检查当前节点),其次,它根据传递的方法名称返回一个值。如果它以?结尾,生成的方法将检查是否至少有一个匹配的子节点,并返回truefalse ;否则,它将返回一个匹配节点的列表。在我们的例子中,我们寻找所有的scope 的调用。

早些时候,我们使用on_send 回调来独立检查所有的send 节点,但现在我们有一个新的挑战:我们需要比较类内一个接一个的作用域。所以我们使用on_class 钩子。在这个方法里面,我们找到所有的作用域声明(使用由def_node_search 生成的帮助器),将它们组合成对,确保它们的排序正确,并在排序不正确时增加一个违规行为。

由于我们想把作用域的名称包括在冒犯信息中,我们必须把它们插入MSG中,并把产生的信息明确地传递给add_offense

⚠️可选作业:我们应该怎么改才能只检查分组中作用域的排序,用空行或其他代码分开?例如,下面的代码应该是有效的。

class User < ApplicationRecord
  # the first group starts
  scope :banned, -> { where(status: STATUS_BANNED) }

  # the second group starts
  scope :active, -> { where(active: true) }
  scope :inactive, -> { where(active: false) }
end

自动更正

在某些情况下,一条规则是如此简单,以至于我们可以通过改变几行来自动修正违规的代码。这是自动更正的完美案例,它与rubocop --auto-correct-all 一起运行。支持自动更正无非是将AutoCorrecor 混合到cop中并使用corrector 对象。让我们为我们的ArgumentName 规则实现自动更正。

class ArgumentName < RuboCop::Cop::Cop
  extend AutoCorrector

  def_node_matcher :argument_name, <<~PATTERN
    (send nil? :argument (:sym $_) ...)
  PATTERN

  MSG = "Use snake_case for argument names".freeze
  SNAKE_CASE = /^[\da-z_]+[!?=]?$/.freeze

  def on_send(node)
    argument_name(node) do |name|
      next if name.match?(SNAKE_CASE)

      add_offense(node) do |corrector|
        downcased_name = name.to_s.gsub!(/(.)([A-Z])/, '\1_\2').downcase
        corrector.replace(node, node.source.sub(name.to_s, downcased_name))
      end
    end
  end
end

这里有什么新东西?首先,一个具有自动更正模式的cop应该扩展AutoCorrector 模块。其次,我们应该向add_offense ,其中的corrector 参数将被传递给一个块。这个对象允许我们在节点上进行各种操作:在我们的例子中,我们把蛇形的参数名称,变成骆驼形的,并在节点中替换它。

将警察提取到一个单独的宝石中

有一天,你可能会决定在一个不同的项目中使用你的自定义警察,或者你可能想在你的开源项目中包含一些最佳实践。无论你是如何走到这一步的,你现在都想把你的警察转移到一个单独的 gem 中。

我想你可以认为这个任务是如此的有规律,以至于应该有一个生成器,这也是事实!这个生成器叫做rubocop-e。这个生成器被称为rubocop-extension-generator

$ gem install rubocop-extension-generator
$ rubocop-extension-generator rubocop-custom

这个命令的结果是一个带有rubocop 依赖关系的 gem 和一个用于警察的文件夹(在我们的例子中是lib/rubocop/cop )。顺便说一下,它还生成了一个特殊的Inject hack。

module RuboCop
  module Custom
    module Inject
      def self.defaults!
        path = CONFIG_DEFAULT.to_s
        hash = ConfigLoader.send(:load_yaml_configuration, path)
        config = Config.new(hash, path).tap(&:make_excludes_absolute)
        puts "configuration from #{path}" if ConfigLoader.debug?
        config = ConfigLoader.merge_with_default(config, path)
        ConfigLoader.instance_variable_set(:@default_configuration, config)
      end
    end
  end
end

这个文件将你的gem的默认配置添加到RuboCop-RuboCop还没有自然地支持扩展配置。

⚠️目前,生成器使用旧版本的RuboCop(0.8)。如果你想运行本文的例子,请将Gemfile中的版本更新为 "1.1"。

当然,也有一个生成器可以创建新的警察。

$ bundle exec rake 'new_cop[Custom/MaxScopes]'

Custom 是一个命名空间(我们的gem被称为 ),而 是一个新警察的名字。这个生成器做了三件事:它为新的cop创建了一个文件,在 中请求它,并在 中添加了一个配置存根。让我们来实现这个cop,它限制了模型中作用域的数量。rubocop-custom MaxScopes lib/rubocop/cop/custom_cops config/default.yml

module RuboCop
  module Cop
    module Custom
      class MaxScopes < Base
        def_node_search :scope_declarations, <<~PATTERN
          (send nil? :scope (:sym _) ...)
        PATTERN

        MSG = "Class should not have more than %<max_scopes>s scopes.".freeze

        def on_class(node)
          scope_count = scope_declarations(node).count
          return if scope_count <= max_scopes

          register_offense(node)
        end

        private

        def register_offense(node)
          message = format(MSG, max_scopes: max_scopes)
          add_offense(node, message: message)
        end

        def max_scopes
          cop_config["MaxScopes"]
        end
      end
    end
  end
end

我们使用on_class 回调来获得类的根:def_node_search 是为了找到所有的作用域声明,如果作用域的数量超过了预先设定的最大值,错误将被添加到类中。为了读取配置,我们使用一个cop_config 对象。如果没有任何配置,就会出现错误,所以我们在config/default.yml 中设置一个默认值。

Custom/MaxScopes:
  Description: "Limit scope count in the Rails model"
  MaxScopes: 2

也许你注意到,早些时候我们用同样的模式来检测作用域。那时候,我们就在cops里面定义了def_node_matcherdef_node_search 。但我们也可以使它们成为可共享的:把它们移到一个模块中,并把它们包含在我们想要的地方。在这种情况下,我们将include NodePattern::Macros 加入到模块中。

编写规范

当涉及到测试我们的警察时,我们应该始终为几十种边缘情况做好准备。幸运的是,为警察编写规范是一件不费吹灰之力的事情:你只需要向expect_offenseexpect_no_offenses 帮助器传递一个包含样本代码的字符串。

RSpec.describe RuboCop::Cop::Custom::MaxScopes do
  subject(:cop) { described_class.new(config) }

  let(:config) { RuboCop::Config.new("Custom/MaxScopes" => { "MaxScopes" => 2 }) }

  context "when class has more than MaxScopes scopes" do
    it 'registers an offense when using `#bad_method`' do
      expect_offense(<<~RUBY)
        class User < ApplicationRecord
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Class should not have more than 2 scopes.
          scope :active, -> { where(active: true) }
          scope :inactive, -> { where(active: false) }
          scope :banned, -> { where(status: "banned") }
        end
      RUBY
    end
  end

  context "when class has scope count equal to MaxScopes" do
    it 'does not register an offense`' do
      expect_no_offenses(<<~RUBY)
        class User < ApplicationRecord
          scope :active, -> { where(active: true) }
          scope :inactive, -> { where(active: false) }
        end
      RUBY
    end
  end
end

你可以用任何选项初始化config ,以测试不同的行为。

高级警察配置

有时我们需要针对一组特定的文件来运行警察。

在这种情况下,我们添加一个默认的顶层配置。

GraphQL:
  Include:
    - "**/graphql/**/*"

如果你曾经探索过各种警察,你很可能已经注意到他们的配置有很多相似之处。例如,当你需要有一个可配置的常数时(像MaxScopes 中的那个常数),有一个方便的帮助工具ExcludeLimit。让我们把它添加到我们的cop中。

module RuboCop
  module Cop
    module Custom
      class MaxScopes < Base
        extend RuboCop::ExcludeLimit

        exclude_limit 'MaxScopes'

        def_node_search :scope_declarations, <<~PATTERN
          (send nil? :scope (:sym _) ...)
        PATTERN

        MSG = "Class should not have more than %<max_scopes>s scopes.".freeze

        def on_class(node)
          scope_count = scope_declarations(node).count
          return if scope_count < max_scopes

          register_offense(node)
        end

        private

        def register_offense(node)
          message = format(MSG, max_scopes: max_scopes)
          add_offense(node, message: message)
        end

        def max_scopes
          cop_config["MaxScopes"]
        end
      end
    end
  end
end

为什么有时候我们更喜欢这个模块而不是好的老的cop_config["MaxScopes"]ExcludeLimit ,当RuboCop与--auto-gen-config 选项一起使用时,会产生一个TODO配置,允许迭代的代码库迁移到新的规则。

在某些情况下,警察允许应对完全不同的可配置行为。例如,Style/NilComparisoncop让你选择喜欢x.nil? 还是x == nil 。有一个内置的模块ConfigurableEnforcedStyle负责cop样式的配置:你只需要包含这个模块,而style 方法将管理其余部分。下面是ConfigurableEnforcedStyle 的使用例子

另一个有用的混合器是RuboCop::Cop::CodeLength ,它有助于检查代码段的长度。它支持以下配置选项。

  • Max-最大的行数。
  • CountComments-可将注释排除在计数之外的选项。
  • CountAsOne-允许将数组、哈希和heredocs作为一行来计算。

CodeLength 被包含在cop中时,你需要做的就是将一个代码块传递给check_code_length 方法。下面是一个来自Metrics/MethodLength cop的例子。

module RuboCop
  module Cop
    module Metrics
      class MethodLength < Base
        include CodeLength

        def on_def(node)
          return if ignored_method?(node.method_name)

          check_code_length(node)
        end

        def on_block(node)
          return unless node.send_node.method?(:define_method)

          check_code_length(node)
        end
      end
    end
  end
end

谢谢你的阅读!我们希望你能使用这个小指南作为参考,通过适合你独特项目需求的定制规则,进一步整理你的代码库。如果你想讨论你公司目前的工具,或者需要邪恶的火星人帮助你建立和自动执行更好的开发实践,请随时我打电话。