通过Ruby库实现Linters教程

128 阅读7分钟

由Ruby库实现的Linters

当你听到 "linter"或 "lint"这个词时,你可能已经对这种工具如何工作或它应该做什么有了一定的期望。

你可能想到的是Rubocop,这是一个Toptal开发者维护的,或者是JSLintESLint,或者一些不太知名或不太受欢迎的东西。

这篇文章将向你介绍一种不同类型的译码器。它们不检查代码的语法,也不验证抽象语法树,但它们确实验证代码。它们检查一个实现是否遵守了某个接口,不仅是词法上的(在鸭子类型和经典接口方面),有时也是语义上的。

为了熟悉它们,我们来分析一下一些实际的例子。如果你不是狂热的Rails专业人员,你可能想先读一下这个

让我们从一个基本的Lint开始。

ActiveModel::Lint::Tests

这个Lint的行为在Rails官方文档中有详细解释。

TestCase"你可以通过在你的ActiveModel::Lint::Tests ,测试一个对象是否符合Active Model的API。它将包括一些测试,告诉你你的对象是否完全符合,如果不符合,那么API的哪些方面没有被实现。注意一个对象不需要实现所有的API就可以和Action Pack一起工作。这个模块只打算提供指导,以防你想要开箱即用的所有功能。"

因此,如果你正在实现一个类,并且你想把它与现有的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
end

ActiveModel::Serializer::Lint::Tests

主动模型序列化器并不新鲜,但我们可以继续向其学习。你包括ActiveModel::Serializer::Lint::Tests ,以验证一个对象是否符合主动模型序列化器的API。如果它不符合,测试会指出哪些部分是缺失的。

然而,在文档中,你会发现一个重要的警告,它并不检查语义。

"这些测试并不试图确定返回值的语义正确性。例如,你可以实现serializable_hash ,使其总是返回{} ,并且测试会通过。确保这些值在语义上是有意义的,这取决于你"。

换句话说,我们只是在检查接口的形状。现在让我们看看它是如何实现的。

实现

这与我们刚才看到的ActiveModel::Lint::Tests 的实现非常相似,但在某些情况下更严格一些,因为它检查返回值的arity或class。

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,把它包含在它的测试案例中。

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::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中的用法

在这个例子中,我们看到Puma是如何将一个非常简单的应用程序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被验证为与Rack兼容的方式。

RailsEventStore - Repository 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

在将近1400行的Ruby代码中,我相信它是用Ruby编写的最大的linter。但如果你知道有一个更大的,请告诉我。有趣的是,它是100%关于语义的。

它也对接口进行了大量的测试,但考虑到本文的范围,我想说这是一个事后的想法。

实施

存储库接口是用RSpec共享实例功能实现的。

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 ,它返回要验证的实现。测试实例包括使用内置的RSpecinclude_examples 方法。

RSpec.describe EventRepository do
    include_examples :event_repository
    let(:repository) { EventRepository.new(serializer: YAML) }
end

总结

正如你所看到的,"linter " 的含义比我们通常所想的稍微宽泛一些。任何时候,当你实现一个期望有一些可互换的合作者的库时,我鼓励你考虑提供一个linter。

即使一开始唯一通过这种测试的类将是一个同样由你的库提供的类,这也是你作为一个软件工程师认真对待扩展性的标志。这也将挑战你去思考你代码中每个组件的接口,不是偶然的,而是有意识的。