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 活動商店的更多信息
