Линтеры, реализованные библиотеками Ruby

Опубликовано: 2022-03-11

Когда вы слышите слово « линтер » или « линт », у вас, вероятно, уже есть определенные представления о том, как работает такой инструмент или что он должен делать.

Возможно, вы думаете о Rubocop, который поддерживает один разработчик Toptal, или о JSLint, ESLint или о чем-то менее известном или менее популярном.

Эта статья познакомит вас с другим видом линтеров. Они не проверяют синтаксис кода и не проверяют абстрактное синтаксическое дерево, но проверяют код. Они проверяют, соответствует ли реализация определенному интерфейсу не только лексически (с точки зрения утиной типизации и классических интерфейсов), но иногда и семантически.

Для ознакомления с ними разберем несколько практических примеров. Если вы не являетесь заядлым профессионалом в области Rails, вы можете сначала прочитать это.

Давайте начнем с базового Lint.

ActiveModel::Lint::Тесты

Поведение этого Lint подробно описано в официальной документации Rails:

«Вы можете проверить, совместим ли объект с API Active Model, включив ActiveModel::Lint::Tests в свой TestCase . Он будет включать в себя тесты, которые сообщат вам, полностью ли соответствует вашему объекту, а если нет, то какие аспекты 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::Тесты

Активные сериализаторы моделей не новы, но мы можем продолжать учиться у них. Вы включаете ActiveModel::Serializer::Lint::Tests , чтобы проверить, совместим ли объект с API Active Model Serializers. Если это не так, тесты укажут, какие части отсутствуют.

Однако в документах вы найдете важное предупреждение о том, что он не проверяет семантику:

«Эти тесты не пытаются определить семантическую правильность возвращаемых значений. Например, вы можете реализовать 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, включив его в свой тестовый пример:

 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, в которое вы можете обернуть свое приложение. В этом случае промежуточное ПО играет роль линтера. Линтер проверит, построены ли запросы и ответы в соответствии со спецификацией Rack. Это полезно, если вы реализуете Rack-сервер (например, Puma), который будет обслуживать Rack-приложение, и вы хотите убедиться, что вы следуете спецификации 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 .

Линт вызывает исключения в случае несоблюдения спецификации. Проверка перехватывает исключения и возвращает код ошибки 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 и предполагает определенный макет таблицы для хранения событий. Однако ваша реализация может использовать ПЗУ или работать в памяти без сохранения событий, что полезно для тестирования.

Но как узнать, ведет ли себя реализованный компонент так, как ожидает библиотека? С помощью предоставленного линтера, разумеется. И это огромно. Он охватывает около 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. Но если вы знаете о большей, дайте мне знать. Интересно то, что это на 100% связано с семантикой.

Он также тщательно тестирует интерфейс, но я бы сказал, что это запоздалая мысль, учитывая объем этой статьи.

Реализация

Линтер репозитория реализован с использованием функциональности 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 # ...

Применение

Этот линтер, как и другие, ожидает от вас предоставления некоторых методов, в первую очередь repository , который возвращает реализацию для проверки. Тестовые примеры включаются с помощью встроенного в RSpec метода include_examples :

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

Подведение итогов

Как видите, « линтер» имеет несколько более широкое значение, чем то, что мы обычно имеем в виду. Каждый раз, когда вы реализуете библиотеку, которая предполагает наличие взаимозаменяемых соавторов, я рекомендую вам подумать о предоставлении линтера.

Даже если единственным классом, прошедшим такие тесты в начале, будет класс, также предоставленный вашей библиотекой, это признак того, что вы, как разработчик программного обеспечения, серьезно относитесь к расширяемости. Это также заставит вас подумать об интерфейсе для каждого компонента в вашем коде не случайно, а сознательно.

Ресурсы

  • ActiveModel: сделать любой объект Ruby похожим на ActiveRecord
  • RailsCast об активной модели
  • Полная реализация Rack Lint
  • Использование стойки Lint в Puma
  • Узнайте больше о магазине событий Rails