Linters implementati da Ruby Libraries
Pubblicato: 2022-03-11Quando senti la parola " linter " o " lint ", probabilmente hai già determinate aspettative su come funziona uno strumento del genere o su cosa dovrebbe fare.
Potresti pensare a Rubocop, che mantiene uno sviluppatore di Toptal, o a JSLint, ESLint o qualcosa di meno noto o meno popolare.
Questo articolo ti introdurrà a un diverso tipo di linter. Non controllano la sintassi del codice né verificano l'albero della sintassi astratta, ma verificano il codice. Verificano se un'implementazione aderisce a una determinata interfaccia, non solo lessicalmente (in termini di tipizzazione duck e interfacce classiche) ma talvolta anche semanticamente.
Per familiarizzare con loro, analizziamo alcuni esempi pratici. Se non sei un appassionato professionista di Rails, potresti voler leggere prima questo.
Iniziamo con un Lint di base.
Modello attivo::Lint::Test
Il comportamento di questo Lint è spiegato in dettaglio nella documentazione ufficiale di Rails:
"Puoi verificare se un oggetto è conforme all'API Active Model includendo ActiveModel::Lint::Tests nel tuo TestCase . Includerà test che ti diranno se il tuo oggetto è completamente conforme o, in caso contrario, quali aspetti dell'API non sono implementati. Nota che un oggetto non è necessario per implementare tutte le API per poter lavorare con Action Pack. Questo modulo intende solo fornire una guida nel caso in cui desideri che tutte le funzionalità siano pronte all'uso".
Quindi, se stai implementando una classe e desideri usarla con funzionalità Rails esistenti come redirect_to, form_for , devi implementare un paio di metodi. Questa funzionalità non è limitata agli oggetti ActiveRecord . Può funzionare anche con i tuoi oggetti, ma devono imparare a ciarlare correttamente.
Implementazione
L'implementazione è relativamente semplice. È un modulo creato per essere incluso nei casi di test. I metodi che iniziano con test_ saranno implementati dal tuo framework. Si prevede che la variabile di istanza @model venga impostata dall'utente prima del test:
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 endUtilizzo
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
I serializzatori di modelli attivi non sono nuovi, ma possiamo continuare a imparare da loro. Include ActiveModel::Serializer::Lint::Tests per verificare se un oggetto è conforme all'API Active Model Serializers. In caso contrario, i test indicheranno quali parti mancano.
Tuttavia, nei documenti, troverai un avviso importante che non controlla la semantica:
“Questi test non tentano di determinare la correttezza semantica dei valori restituiti. Ad esempio, potresti implementare serializable_hash per restituire sempre {} e i test passerebbero. Sta a te assicurarti che i valori siano semanticamente significativi”.
In altre parole, stiamo solo controllando la forma dell'interfaccia. Ora vediamo come viene implementato.
Implementazione
Questo è molto simile a quello che abbiamo visto un momento fa con l'implementazione di ActiveModel::Lint::Tests , ma in alcuni casi un po' più rigoroso perché controlla l'arity o le classi di valori restituiti:
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 ...Utilizzo
Ecco un esempio di come ActiveModelSerializers utilizza il lint includendolo nel suo test case:
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 endCremagliera::pelucchi
Gli esempi precedenti non si preoccupavano della semantica .
Tuttavia, Rack::Lint è una bestia completamente diversa. È il middleware Rack in cui puoi avvolgere la tua applicazione. Il middleware svolge il ruolo di linter in questo caso. Il linter verificherà se le richieste e le risposte sono costruite secondo le specifiche del Rack. Ciò è utile se stai implementando un server Rack (ad es. Puma) che servirà l'applicazione Rack e vuoi assicurarti di seguire le specifiche Rack.
In alternativa, viene utilizzato quando si implementa un'applicazione molto semplice e si desidera assicurarsi di non commettere semplici errori relativi al protocollo HTTP.
Implementazione
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 ...Utilizzo nella tua app
Diciamo di costruire un endpoint molto semplice. A volte dovrebbe rispondere con "Nessun contenuto", ma abbiamo commesso un errore deliberato e invieremo alcuni contenuti nel 50% dei casi:

# 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 questi casi, Rack::Lint intercetterà la risposta, la verificherà e solleverà un'eccezione:
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'Utilizzo in Puma
In questo esempio vediamo come Puma esegue il wrapping di un'applicazione molto semplice lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } prima in un ServerLint (che eredita da Rack::Lint ) poi in ErrorChecker .
Il lint solleva eccezioni nel caso in cui la specifica non venga seguita. Il controllo cattura le eccezioni e restituisce il codice di errore 500. Il codice di test verifica che l'eccezione non si sia verificata:
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" endfermareclass 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
È così che Puma è certificata compatibile con Rack.
RailsEventStore - Repository Lint
Rails Event Store è una libreria per la pubblicazione, il consumo, l'archiviazione e il recupero di eventi. Ha lo scopo di aiutarti nell'implementazione dell'architettura basata su eventi per la tua applicazione Rails. È una libreria modulare costruita con piccoli componenti come repository, mapper, dispatcher, scheduler, abbonamenti e serializzatore. Ogni componente può avere un'implementazione intercambiabile.
Ad esempio, il repository predefinito utilizza ActiveRecord e presuppone un determinato layout di tabella per la memorizzazione degli eventi. Tuttavia, la tua implementazione può utilizzare ROM o lavorare in memoria senza memorizzare eventi, il che è utile per il test.
Ma come puoi sapere se il componente che hai implementato si comporta in un modo che la libreria si aspetta? Usando la linter fornita, ovviamente. Ed è immenso. Copre circa 80 casi. Alcuni di loro sono relativamente semplici:
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 endE alcuni sono un po' più complessi e si riferiscono a percorsi infelici:
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) endCon quasi 1.400 righe di codice Ruby, credo che sia il più grande linter scritto in Ruby. Ma se sei a conoscenza di uno più grande, fammi sapere. La parte interessante è che riguarda al 100% la semantica.
Testa pesantemente anche l'interfaccia, ma direi che è un ripensamento dato lo scopo di questo articolo.
Implementazione
Il repository linter viene implementato utilizzando la funzionalità 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 # ...Utilizzo
Questo linter, simile agli altri, si aspetta che tu fornisca alcuni metodi, soprattutto il repository , che restituisce l'implementazione da verificare. Gli esempi di test sono inclusi utilizzando il metodo integrato RSpec include_examples :
RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } endAvvolgendo
Come puoi vedere, " linter" ha un significato leggermente più ampio di quello che di solito abbiamo in mente. Ogni volta che implementi una libreria che prevede alcuni collaboratori intercambiabili, ti incoraggio a considerare di fornire un linter.
Anche se l'unica classe che supera tali test all'inizio sarà una classe fornita anche dalla tua libreria, è un segno che tu come ingegnere del software prendi sul serio l'estendibilità. Ti sfiderà anche a pensare all'interfaccia per ogni componente del tuo codice, non accidentalmente ma consapevolmente.
Risorse
- ActiveModel: fai sentire qualsiasi oggetto Ruby come ActiveRecord
- RailsCast sul modello attivo
- Implementazione completa di lanugine su rack
- Utilizzo di pelucchi sui rack in Puma
- Ulteriori informazioni sul negozio di eventi Rails
