Linters zaimplementowany przez biblioteki Ruby

Opublikowany: 2022-03-11

Kiedy słyszysz słowo „ linter ” lub „ lint ”, prawdopodobnie masz już pewne oczekiwania co do tego, jak takie narzędzie działa lub co powinno robić.

Możesz myśleć o Rubocop, który utrzymuje jeden z programistów Toptal, lub o JSLint, ESLint lub czymś mniej znanym lub mniej popularnym.

Ten artykuł wprowadzi Cię w inny rodzaj linterów. Nie sprawdzają składni kodu ani nie weryfikują drzewa składni abstrakcyjnej, ale weryfikują kod. Sprawdzają, czy implementacja jest zgodna z określonym interfejsem, nie tylko leksykalnie (w zakresie pisania kaczką i klasycznymi interfejsami), ale czasami także semantycznie.

Aby się z nimi zapoznać, przeanalizujmy kilka praktycznych przykładów. Jeśli nie jesteś zapalonym profesjonalistą Rails, możesz najpierw przeczytać to.

Zacznijmy od podstawowego Linta.

ActiveModel::Lint::Testy

Zachowanie tego Linta jest szczegółowo wyjaśnione w oficjalnej dokumentacji Railsów:

„Możesz sprawdzić, czy obiekt jest zgodny z interfejsem API Active Model, ActiveModel::Lint::Tests w swoim TestCase . Będzie zawierać testy, które powiedzą Ci, czy Twój obiekt jest w pełni zgodny, a jeśli nie, które aspekty API nie są zaimplementowane. Zauważ, że obiekt nie jest wymagany do zaimplementowania wszystkich interfejsów API w celu pracy z Action Pack. Ten moduł ma na celu dostarczenie wskazówek tylko w przypadku, gdy chcesz mieć wszystkie funkcje po wyjęciu z pudełka”.

Tak więc, jeśli implementujesz klasę i chciałbyś jej używać z istniejącą funkcjonalnością Rails, taką jak redirect_to, form_for , musisz zaimplementować kilka metod. Ta funkcjonalność nie ogranicza się do obiektów ActiveRecord . Może również działać z twoimi obiektami, ale muszą nauczyć się poprawnie kwakać.

Realizacja

Implementacja jest stosunkowo prosta. Jest to moduł, który został stworzony w celu dołączenia do przypadków testowych. Metody zaczynające się od test_ zostaną zaimplementowane przez Twój framework. Oczekuje się, że zmienna instancji @model zostanie ustawiona przez użytkownika przed testem:

 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

Stosowanie

 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::Testy

Aktywne serializatory modeli nie są nowe, ale możemy nadal się od nich uczyć. ActiveModel::Serializer::Lint::Tests , aby sprawdzić, czy obiekt jest zgodny z interfejsem API serializatorów Active Model. Jeśli tak nie jest, testy wskażą, których części brakuje.

Jednak w dokumentacji znajdziesz ważne ostrzeżenie, że nie sprawdza semantyki:

„Testy te nie mają na celu określenia poprawności semantycznej zwracanych wartości. Na przykład możesz zaimplementować serializable_hash , aby zawsze zwracał {} , a testy zakończą się pomyślnie. Od Ciebie zależy, czy wartości te mają znaczenie semantyczne”.

Innymi słowy, sprawdzamy tylko kształt interfejsu. Zobaczmy teraz, jak to jest zaimplementowane.

Realizacja

Jest to bardzo podobne do tego, co widzieliśmy przed chwilą przy implementacji ActiveModel::Lint::Tests , ale w niektórych przypadkach nieco bardziej rygorystyczne, ponieważ sprawdza arność lub klasy zwracanych wartości:

 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 ...

Stosowanie

Oto przykład, w jaki sposób ActiveModelSerializers używa lint, dołączając go do swojego przypadku testowego:

 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

Stojak::Lint

Poprzednie przykłady nie dbały o semantykę .

Jednak Rack::Lint to zupełnie inna bestia. To oprogramowanie pośredniczące w szafie, w które można zawinąć swoją aplikację. W tym przypadku oprogramowanie pośrednie pełni rolę lintera. Linter sprawdzi, czy żądania i odpowiedzi są skonstruowane zgodnie ze specyfikacją Rack. Jest to przydatne, jeśli wdrażasz serwer Rack (np. Puma), który będzie obsługiwał aplikację Rack i chcesz mieć pewność, że postępujesz zgodnie ze specyfikacją Rack.

Alternatywnie jest używany, gdy implementujesz bardzo prostą aplikację i chcesz mieć pewność, że nie popełnisz prostych błędów związanych z protokołem HTTP.

Realizacja

 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 ...

Wykorzystanie w Twojej aplikacji

Powiedzmy, że budujemy bardzo prosty punkt końcowy. Czasami powinien odpowiedzieć „Brak treści”, ale popełniliśmy celowy błąd i wyślemy zawartość w 50% przypadków:

 # 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

W takich przypadkach Rack::Lint przechwyci odpowiedź, zweryfikuje ją i zgłosi wyjątek:

 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'

Wykorzystanie w Pumie

W tym przykładzie widzimy, jak Puma owija bardzo prostą lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } najpierw w ServerLint (który dziedziczy po Rack::Lint ), a następnie w ErrorChecker .

Lint powoduje wyjątki w przypadku nieprzestrzegania specyfikacji. Kontroler wychwytuje wyjątki i zwraca kod błędu 500. Kod testowy weryfikuje, czy wyjątek nie wystąpił:

 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

W ten sposób sprawdza się, czy Puma jest kompatybilna z szafą rack.

RailsEventStore - Repozytorium Lint

Rails Event Store to biblioteka do publikowania, wykorzystywania, przechowywania i pobierania zdarzeń. Ma na celu pomóc ci we wdrożeniu architektury opartej na zdarzeniach dla twojej aplikacji Railsowej. Jest to modułowa biblioteka zbudowana z małych komponentów, takich jak repozytorium, mapper, dyspozytor, harmonogram, subskrypcje i serializator. Każdy składnik może mieć wymienną implementację.

Na przykład domyślne repozytorium używa ActiveRecord i przyjmuje określony układ tabeli do przechowywania zdarzeń. Jednak Twoja implementacja może używać pamięci ROM lub pracować w pamięci bez przechowywania zdarzeń, co jest przydatne do testowania.

Ale skąd możesz wiedzieć, czy zaimplementowany komponent zachowuje się w sposób, którego oczekuje biblioteka? Oczywiście za pomocą dostarczonego lintera. I jest ogromny. Obejmuje około 80 przypadków. Niektóre z nich są stosunkowo proste:

 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

A niektóre są nieco bardziej złożone i odnoszą się do nieszczęśliwych ścieżek:

 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

Z prawie 1400 linijkami kodu Rubiego, uważam, że jest to największy linter napisany w Rubim. Ale jeśli jesteś świadomy większego, daj mi znać. Ciekawe jest to, że chodzi w 100% o semantykę.

To również mocno testuje interfejs, ale powiedziałbym, że jest to refleksja, biorąc pod uwagę zakres tego artykułu.

Realizacja

Linter repozytorium jest zaimplementowany przy użyciu funkcjonalności RSpec Shared Example:

 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 # ...

Stosowanie

Ten linter, podobnie jak inne, oczekuje od Ciebie dostarczenia pewnych metod, przede wszystkim repository , które zwraca implementację do weryfikacji. Przykłady testów są zawarte przy użyciu wbudowanej metody RSpec include_examples :

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

Zawijanie

Jak widać, „ linter” ma nieco szersze znaczenie niż to, co zwykle mamy na myśli. Za każdym razem, gdy wdrażasz bibliotekę, która oczekuje wymiennych współpracowników, zachęcam do rozważenia udostępnienia lintera.

Nawet jeśli jedyną klasą, która zda takie testy na początku, będzie klasa dostarczana również przez Twoją bibliotekę, to znak, że Ty jako inżynier oprogramowania poważnie traktujesz rozszerzalność. Będzie to również wyzwaniem, aby pomyśleć o interfejsie dla każdego komponentu w kodzie, nie przypadkowo, ale świadomie.

Zasoby

  • ActiveModel: spraw, aby każdy obiekt Ruby wyglądał jak ActiveRecord
  • RailsCast O aktywnym modelu
  • Pełna implementacja kłaczków w szafie
  • Użycie Rack Lint w Puma
  • Dowiedz się więcej o Rails Event Store