Linters Implementiert von Ruby Libraries

Veröffentlicht: 2022-03-11

Wenn Sie das Wort „ Linter “ oder „ Lint “ hören, haben Sie wahrscheinlich bereits gewisse Erwartungen daran, wie ein solches Tool funktioniert oder was es tun sollte.

Sie denken vielleicht an Rubocop, das ein Toptal-Entwickler pflegt, oder an JSLint, ESLint oder etwas, das weniger bekannt oder weniger beliebt ist.

Dieser Artikel stellt Ihnen eine andere Art von Linters vor. Sie überprüfen weder die Codesyntax noch den Abstract-Syntax-Tree, aber sie verifizieren den Code. Sie prüfen, ob eine Implementierung einer bestimmten Schnittstelle entspricht, nicht nur lexikalisch (im Sinne von Ententypisierung und klassischen Schnittstellen), sondern manchmal auch semantisch.

Um sich mit ihnen vertraut zu machen, analysieren wir einige praktische Beispiele. Wenn Sie kein begeisterter Rails-Profi sind, sollten Sie dies vielleicht zuerst lesen.

Beginnen wir mit einem einfachen Lint.

ActiveModel::Lint::Tests

Das Verhalten dieses Lints wird ausführlich in der offiziellen Rails-Dokumentation erklärt:

„Sie können testen, ob ein Objekt mit der Active Model API konform ist, indem ActiveModel::Lint::Tests in Ihren TestCase . Es enthält Tests, die Ihnen sagen, ob Ihr Objekt vollständig konform ist oder, falls nicht, welche Aspekte der API nicht implementiert sind. Beachten Sie, dass ein Objekt nicht alle APIs implementieren muss, um mit Action Pack zu arbeiten. Dieses Modul soll nur eine Anleitung bieten, falls Sie alle Funktionen sofort einsatzbereit haben möchten.“

Wenn Sie also eine Klasse implementieren und diese mit vorhandener Rails-Funktionalität wie redirect_to, form_for “ verwenden möchten, müssen Sie einige Methoden implementieren. Diese Funktionalität ist nicht auf ActiveRecord Objekte beschränkt. Es kann auch mit Ihren Objekten funktionieren, aber sie müssen lernen, richtig zu quaken.

Implementierung

Die Umsetzung ist relativ einfach. Es ist ein Modul, das erstellt wurde, um in Testfälle aufgenommen zu werden. Die Methoden, die mit test_ beginnen, werden von Ihrem Framework implementiert. Es wird erwartet, dass die @model vom Benutzer vor dem Test eingerichtet wird:

 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

Verwendungszweck

 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

Aktive Modellserialisierer sind nicht neu, aber wir können weiterhin von ihnen lernen. Sie schließen ActiveModel::Serializer::Lint::Tests ein, um zu überprüfen, ob ein Objekt mit der Active Model Serializers API kompatibel ist. Wenn dies nicht der Fall ist, zeigen die Tests an, welche Teile fehlen.

In den Dokumenten finden Sie jedoch eine wichtige Warnung, dass die Semantik nicht überprüft wird:

„Diese Tests versuchen nicht, die semantische Korrektheit der zurückgegebenen Werte zu bestimmen. Beispielsweise könnten Sie serializable_hash so implementieren, dass immer {} zurückgegeben wird, und die Tests würden bestanden. Es liegt an Ihnen, sicherzustellen, dass die Werte semantisch sinnvoll sind.“

Mit anderen Worten, wir prüfen nur die Form der Grenzfläche. Sehen wir uns nun an, wie es implementiert wird.

Implementierung

Dies ist sehr ähnlich zu dem, was wir gerade bei der Implementierung von ActiveModel::Lint::Tests haben, aber in einigen Fällen etwas strenger, weil es die Arität oder Klassen der zurückgegebenen Werte überprüft:

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

Verwendungszweck

Hier ist ein Beispiel dafür, wie ActiveModelSerializers den Lint verwendet, indem er ihn in seinen Testfall aufnimmt:

 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

Gestell::Lint

Die vorherigen Beispiele haben sich nicht um Semantik gekümmert.

Rack::Lint ist jedoch ein ganz anderes Biest. Es ist Rack-Middleware, in die Sie Ihre Anwendung einpacken können. Die Middleware spielt in diesem Fall die Rolle eines Linters. Der Linter prüft, ob Requests und Responses gemäß der Rack-Spezifikation aufgebaut sind. Dies ist nützlich, wenn Sie einen Rack-Server (z. B. Puma) implementieren, der die Rack-Anwendung bedient, und Sie sicherstellen möchten, dass Sie die Rack-Spezifikation befolgen.

Alternativ wird es verwendet, wenn Sie eine sehr einfache Anwendung implementieren und sicherstellen möchten, dass Sie keine einfachen Fehler im Zusammenhang mit dem HTTP-Protokoll machen.

Implementierung

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

Verwendung in Ihrer App

Nehmen wir an, wir bauen einen sehr einfachen Endpunkt. Manchmal sollte es mit „Kein Inhalt“ antworten, aber wir haben einen absichtlichen Fehler gemacht und senden in 50 % der Fälle einige Inhalte:

 # 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

In solchen Fällen fängt Rack::Lint die Antwort ab, überprüft sie und löst eine Ausnahme aus:

 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'

Verwendung in Puma

In diesem Beispiel sehen wir, wie Puma eine sehr einfache Anwendung lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } zuerst in einem ServerLint (das von Rack::Lint erbt) dann in ErrorChecker .

Der Lint löst Ausnahmen aus, falls die Spezifikation nicht befolgt wird. Der Prüfer fängt die Ausnahmen ab und gibt den Fehlercode 500 zurück. Der Testcode überprüft, ob die Ausnahme nicht aufgetreten ist:

 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

Auf diese Weise wird Puma als zertifiziertes Rack-kompatibel bestätigt.

RailsEventStore - Repository Lint

Rails Event Store ist eine Bibliothek zum Veröffentlichen, Konsumieren, Speichern und Abrufen von Ereignissen. Es zielt darauf ab, Sie bei der Implementierung der ereignisgesteuerten Architektur für Ihre Rails-Anwendung zu unterstützen. Es ist eine modulare Bibliothek, die aus kleinen Komponenten wie einem Repository, Mapper, Dispatcher, Scheduler, Abonnements und Serializer besteht. Jede Komponente kann eine austauschbare Implementierung haben.

Beispielsweise verwendet das Standard-Repository ActiveRecord und geht von einem bestimmten Tabellenlayout zum Speichern von Ereignissen aus. Ihre Implementierung kann jedoch ROM verwenden oder im Speicher arbeiten, ohne Ereignisse zu speichern, was zum Testen nützlich ist.

Aber wie können Sie wissen, ob sich die von Ihnen implementierte Komponente so verhält, wie es die Bibliothek erwartet? Natürlich unter Verwendung des mitgelieferten Linters. Und es ist immens. Es umfasst etwa 80 Fälle. Einige von ihnen sind relativ einfach:

 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

Und einige sind etwas komplexer und beziehen sich auf unglückliche Pfade:

 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

Mit fast 1.400 Zeilen Ruby-Code ist es meiner Meinung nach der größte in Ruby geschriebene Linter. Aber wenn Sie einen größeren kennen, lassen Sie es mich wissen. Das Interessante daran ist, dass es zu 100 % um die Semantik geht.

Es testet auch die Benutzeroberfläche ausgiebig, aber ich würde sagen, das ist angesichts des Umfangs dieses Artikels ein nachträglicher Einfall.

Implementierung

Der Repository-Linter wird mithilfe der RSpec Shared Examples-Funktionalität implementiert:

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

Verwendungszweck

Dieser Linter erwartet ähnlich wie die anderen, dass Sie einige Methoden bereitstellen, vor allem das repository , das die zu überprüfende Implementierung zurückgibt. Die Testbeispiele werden mithilfe der integrierten include_examples Methode include_examples eingebunden:

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

Einpacken

Wie Sie sehen können, hat „ Linter“ eine etwas breitere Bedeutung als das, was wir normalerweise im Sinn haben. Jedes Mal, wenn Sie eine Bibliothek implementieren, die einige austauschbare Mitarbeiter erwartet, ermutige ich Sie, einen Linter bereitzustellen.

Auch wenn die einzige Klasse, die solche Tests anfangs besteht, eine Klasse ist, die ebenfalls von Ihrer Bibliothek angeboten wird, ist dies ein Zeichen dafür, dass Sie als Softwareentwickler die Erweiterbarkeit ernst nehmen. Es wird Sie auch herausfordern, über die Schnittstelle für jede Komponente in Ihrem Code nachzudenken, nicht zufällig, sondern bewusst.

Ressourcen

  • ActiveModel: Lassen Sie jedes Ruby-Objekt sich wie ActiveRecord anfühlen
  • RailsCast über aktives Modell
  • Vollständige Rack-Lint-Implementierung
  • Rack Lint-Nutzung in Puma
  • Erfahren Sie mehr über den Rails Event Store