很难找到一个没有听说过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 只检查当前节点),其次,它根据传递的方法名称返回一个值。如果它以?结尾,生成的方法将检查是否至少有一个匹配的子节点,并返回true 或false ;否则,它将返回一个匹配节点的列表。在我们的例子中,我们寻找所有的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_matcher 和def_node_search 。但我们也可以使它们成为可共享的:把它们移到一个模块中,并把它们包含在我们想要的地方。在这种情况下,我们将include NodePattern::Macros 加入到模块中。
编写规范
当涉及到测试我们的警察时,我们应该始终为几十种边缘情况做好准备。幸运的是,为警察编写规范是一件不费吹灰之力的事情:你只需要向expect_offense 或expect_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 ,以测试不同的行为。
高级警察配置
有时我们需要针对一组特定的文件来运行警察。
- rubocop-rspec只关心
*_spec.rb文件。 - rubocop-graphql只检查
graphql文件夹内的文件。
在这种情况下,我们添加一个默认的顶层配置。
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
谢谢你的阅读!我们希望你能使用这个小指南作为参考,通过适合你独特项目需求的定制规则,进一步整理你的代码库。如果你想讨论你公司目前的工具,或者需要邪恶的火星人帮助你建立和自动执行更好的开发实践,请随时给我打电话。