由Ruby库实现的Linters
当你听到 "linter"或 "lint"这个词时,你可能已经对这种工具如何工作或它应该做什么有了一定的期望。
你可能想到的是Rubocop,这是一个Toptal开发者维护的,或者是JSLint、ESLint,或者一些不太知名或不太受欢迎的东西。
这篇文章将向你介绍一种不同类型的译码器。它们不检查代码的语法,也不验证抽象语法树,但它们确实验证代码。它们检查一个实现是否遵守了某个接口,不仅是词法上的(在鸭子类型和经典接口方面),有时也是语义上的。
为了熟悉它们,我们来分析一下一些实际的例子。如果你不是狂热的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。
即使一开始唯一通过这种测试的类将是一个同样由你的库提供的类,这也是你作为一个软件工程师认真对待扩展性的标志。这也将挑战你去思考你代码中每个组件的接口,不是偶然的,而是有意识的。