Ruby 库实现的 Linters
已发表: 2022-03-11当您听到“ linter ”或“ lint ”这个词时,您可能已经对此类工具的工作原理或应该做什么有一定的期望。
您可能会想到由 Toptal 开发人员维护的 Rubocop,或者 JSLint、ESLint 或其他不太知名或不太流行的东西。
本文将向您介绍另一种 linter。 他们不检查代码语法,也不验证抽象语法树,但他们确实验证代码。 他们检查实现是否遵循某个接口,不仅在词汇上(就鸭子类型和经典接口而言),有时也在语义上。
为了熟悉它们,让我们分析一些实际示例。 如果你不是一个狂热的 Rails 专业人士,你可能想先阅读这篇文章。
让我们从一个基本的 Lint 开始。
ActiveModel::Lint::Tests
这个 Lint 的行为在 Rails 官方文档中有详细解释:
“您可以通过在TestCase中包含ActiveModel::Lint::Tests来测试对象是否符合 Active Model API。 它将包括测试,告诉您您的对象是否完全兼容,或者如果不是,API 的哪些方面没有实现。 请注意,为了与 Action Pack 一起使用,对象不需要实现所有 API。 该模块仅旨在为您提供开箱即用的所有功能时提供指导。”
因此,如果您正在实现一个类,并且希望将它与现有的 Rails 功能(例如redirect_to, form_for )一起使用,您需要实现几个方法。 此功能不限于ActiveRecord对象。 它也可以与您的对象一起使用,但他们需要学会正确地嘎嘎。
执行
实现相对简单。 它是一个被创建为包含在测试用例中的模块。 以test_开头的方法将由您的框架实现。 预计@model实例变量将由用户在测试之前设置:
module ActiveModel module Lint module Tests def test_to_key assert_respond_to model, :to_key def model.persisted?() false end assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false" end def test_to_param assert_respond_to model, :to_param def model.to_key() [1] end def model.persisted?() false end assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false" end ... private def model assert_respond_to @model, :to_model @model.to_model end用法
class Person def persisted? false end def to_key nil end def to_param nil end # ... end # test/models/person_test.rb require "test_helper" class PersonTest < ActiveSupport::TestCase include ActiveModel::Lint::Tests setup do @model = Person.new end endActiveModel::Serializer::Lint::Tests
主动模型序列化器并不新鲜,但我们可以继续向它们学习。 您包括ActiveModel::Serializer::Lint::Tests以验证对象是否符合 Active Model Serializers API。 如果不是,测试将指出缺少哪些部分。
但是,在文档中,您会发现一个重要的警告,即它不检查语义:
“这些测试并不试图确定返回值的语义正确性。 例如,您可以实现serializable_hash以始终返回{} ,并且测试将通过。 确保这些值在语义上有意义是由您决定的。”
换句话说,我们只检查界面的形状。 现在让我们看看它是如何实现的。
执行
这与我们刚才看到的ActiveModel::Lint::Tests实现非常相似,但在某些情况下更严格一些,因为它检查返回值的数量或类别:
module ActiveModel class Serializer module Lint module Tests # Passes if the object responds to <tt>read_attribute_for_serialization</tt> # and if it requires one argument (the attribute to be read). # Fails otherwise. # # <tt>read_attribute_for_serialization</tt> gets the attribute value for serialization # Typically, it is implemented by including ActiveModel::Serialization. def test_read_attribute_for_serialization assert_respond_to resource, :read_attribute_for_serialization, 'The resource should respond to read_attribute_for_serialization' actual_arity = resource.method(:read_attribute_for_serialization).arity # using absolute value since arity is: # 1 for def read_attribute_for_serialization(name); end # -1 for alias :read_attribute_for_serialization :send assert_equal 1, actual_arity.abs, "expected #{actual_arity.inspect}.abs to be 1 or -1" end # Passes if the object's class responds to <tt>model_name</tt> and if it # is in an instance of +ActiveModel::Name+. # Fails otherwise. # # <tt>model_name</tt> returns an ActiveModel::Name instance. # It is used by the serializer to identify the object's type. # It is not required unless caching is enabled. def test_model_name resource_class = resource.class assert_respond_to resource_class, :model_name assert_instance_of resource_class.model_name, ActiveModel::Name end ...用法
这是ActiveModelSerializers如何通过将 lint 包含在其测试用例中来使用 lint 的示例:
module ActiveModelSerializers class ModelTest < ActiveSupport::TestCase include ActiveModel::Serializer::Lint::Tests setup do @resource = ActiveModelSerializers::Model.new end def test_initialization_with_string_keys klass = Class.new(ActiveModelSerializers::Model) do attributes :key end value = 'value' model_instance = klass.new('key' => value) assert_equal model_instance.read_attribute_for_serialization(:key), value end机架::皮棉
前面的例子不关心语义。
然而, Rack::Lint是完全不同的野兽。 可以将应用程序包装在其中的是 Rack 中间件。在这种情况下,中间件扮演着 linter 的角色。 linter 将检查请求和响应是否根据 Rack 规范构建。 如果您正在实现一个将为 Rack 应用程序提供服务的 Rack 服务器(即 Puma)并且您希望确保遵循 Rack 规范,这将非常有用。
或者,当您实现一个非常简单的应用程序并希望确保不会犯与 HTTP 协议相关的简单错误时使用它。
执行
module Rack class Lint def initialize(app) @app = app @content_length = nil end def call(env = nil) dup._call(env) end def _call(env) raise LintError, "No env given" unless env check_env env env[RACK_INPUT] = InputWrapper.new(env[RACK_INPUT]) env[RACK_ERRORS] = ErrorWrapper.new(env[RACK_ERRORS]) ary = @app.call(env) raise LintError, "response is not an Array, but #{ary.class}" unless ary.kind_of? Array raise LintError, "response array has #{ary.size} elements instead of 3" unless ary.size == 3 status, headers, @body = ary check_status status check_headers headers hijack_proc = check_hijack_response headers, env if hijack_proc && headers.is_a?(Hash) headers[RACK_HIJACK] = hijack_proc end check_content_type status, headers check_content_length status, headers @head_request = env[REQUEST_METHOD] == HEAD [status, headers, self] end ## === The Content-Type def check_content_type(status, headers) headers.each { |key, value| ## There must not be a <tt>Content-Type</tt>, when the +Status+ is 1xx, 204 or 304. if key.downcase == "content-type" if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i raise LintError, "Content-Type header found in #{status} response, not allowed" end return end } end ## === The Content-Length def check_content_length(status, headers) headers.each { |key, value| if key.downcase == 'content-length' ## There must not be a <tt>Content-Length</tt> header when the +Status+ is 1xx, 204 or 304. if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i raise LintError, "Content-Length header found in #{status} response, not allowed" end @content_length = value end } end ...在您的应用中使用
假设我们构建了一个非常简单的端点。 有时它应该回复“无内容”,但我们故意犯了错误,我们会在 50% 的情况下发送一些内容:

# foo.rb # run with rackup foo.rb Foo = Rack::Builder.new do use Rack::Lint use Rack::ContentLength app = proc do |env| if rand > 0.5 no_content = Rack::Utils::HTTP_STATUS_CODES.invert['No Content'] [no_content, { 'Content-Type' => 'text/plain' }, ['bummer no content with content']] else ok = Rack::Utils::HTTP_STATUS_CODES.invert['OK'] [ok, { 'Content-Type' => 'text/plain' }, ['good']] end end run app end.to_app 在这种情况下, Rack::Lint将拦截响应、验证它并引发异常:
Rack::Lint::LintError: Content-Type header found in 204 response, not allowed /Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:21:in `assert' /Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:710:in `block in check_content_type'在彪马中的使用
在这个例子中,我们看到 Puma 如何包装一个非常简单的应用程序lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] }首先在ServerLint (继承自Rack::Lint )然后在ErrorChecker 。
如果不遵循规范,lint 会引发异常。 检查器捕获异常并返回错误代码 500。测试代码验证异常没有发生:
class TestRackServer < Minitest::Test class ErrorChecker def initialize(app) @app = app @exception = nil end attr_reader :exception, :env def call(env) begin @app.call(env) rescue Exception => e @exception = e [ 500, {}, ["Error detected"] ] end end end class ServerLint < Rack::Lint def call(env) check_env env @app.call(env) end end def setup @simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } @server = Puma::Server.new @simple port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1] @tcp = "http://127.0.0.1:#{port}" @stopped = false end def test_lint @checker = ErrorChecker.new ServerLint.new(@simple) @server.app = @checker @server.run hit(["#{@tcp}/test"]) stop refute @checker.exception, "Checker raised exception" end这就是 Puma 被证明可与机架兼容的方式。
RailsEventStore - 存储库 Lint
Rails Event Store 是一个用于发布、使用、存储和检索事件的库。 它旨在帮助您为 Rails 应用程序实现事件驱动架构。 它是一个模块化库,由存储库、映射器、调度程序、调度程序、订阅和序列化程序等小组件构建而成。 每个组件都可以有一个可互换的实现。
例如,默认存储库使用 ActiveRecord 并采用特定的表布局来存储事件。 但是,您的实现可以使用 ROM 或在内存中工作而不存储事件,这对于测试很有用。
但是你怎么知道你实现的组件的行为是否符合库的预期呢? 当然,通过使用提供的 linter。 它是巨大的。 它涵盖了大约80个案例。 其中一些相对简单:
specify 'adds an initial event to a new stream' do repository.append_to_stream([event = SRecord.new], stream, version_none) expect(read_events_forward(repository).first).to eq(event) expect(read_events_forward(repository, stream).first).to eq(event) expect(read_events_forward(repository, stream_other)).to be_empty end还有一些更复杂一些,并且与不愉快的路径有关:
it 'does not allow linking same event twice in a stream' do repository.append_to_stream( [SRecord.new(event_id: "a1b49edb")], stream, version_none ).link_to_stream(["a1b49edb"], stream_flow, version_none) expect do repository.link_to_stream(["a1b49edb"], stream_flow, version_0) end.to raise_error(EventDuplicatedInStream) end在将近 1,400 行 Ruby 代码中,我相信它是用 Ruby 编写的最大的 linter。 但如果你知道一个更大的,让我知道。 有趣的部分是它 100% 是关于语义的。
它也对接口进行了大量测试,但鉴于本文的范围,我会说这是事后的想法。
执行
存储库 linter 是使用 RSpec Shared Examples 功能实现的:
module RubyEventStore ::RSpec.shared_examples :event_repository do let(:helper) { EventRepositoryHelper.new } let(:specification) { Specification.new(SpecificationReader.new(repository, Mappers::NullMapper.new)) } let(:global_stream) { Stream.new(GLOBAL_STREAM) } let(:stream) { Stream.new(SecureRandom.uuid) } let(:stream_flow) { Stream.new('flow') } # ... it 'just created is empty' do expect(read_events_forward(repository)).to be_empty end specify 'append_to_stream returns self' do repository .append_to_stream([event = SRecord.new], stream, version_none) .append_to_stream([event = SRecord.new], stream, version_0) end # ...用法
这个 linter 与其他 linter 类似,希望您提供一些方法,最重要的是repository ,它返回要验证的实现。 使用内置的 RSpec include_examples方法包含测试示例:
RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } end包起来
如您所见,“ linter”的含义比我们通常想到的要广泛一些。 每当您实现一个需要一些可互换协作者的库时,我鼓励您考虑提供一个 linter。
即使一开始唯一通过此类测试的类也是您的库提供的类,这表明您作为软件工程师认真对待可扩展性。 它还将挑战您考虑代码中每个组件的接口,这不是偶然而是有意识的。
资源
- ActiveModel:让任何 Ruby 对象都像 ActiveRecord
- RailsCast 关于活动模型
- 全机架皮棉实施
- Puma 中的衣架皮棉使用情况
- 了解有关 Rails 活动商店的更多信息
