Linters implementat de bibliotecile Ruby
Publicat: 2022-03-11Când auziți cuvântul „ linter ” sau „ lint ”, probabil că aveți deja anumite așteptări cu privire la modul în care funcționează un astfel de instrument sau ce ar trebui să facă.
S-ar putea să vă gândiți la Rubocop, pe care un dezvoltator Toptal îl întreține, sau la JSLint, ESLint sau la ceva mai puțin cunoscut sau mai puțin popular.
Acest articol vă va prezenta un alt tip de linters. Ei nu verifică sintaxa codului și nici nu verifică Abstract-Syntax-Tree, dar verifică codul. Ei verifică dacă o implementare aderă la o anumită interfață, nu doar lexical (în ceea ce privește tastarea de rață și interfețele clasice), ci uneori și semantic.
Pentru a ne familiariza cu ele, să analizăm câteva exemple practice. Dacă nu sunteți un profesionist pasionat al Rails, poate doriți să citiți mai întâi acest lucru.
Să începem cu un Lint de bază.
ActiveModel::Lint::Teste
Comportamentul acestui Lint este explicat în detaliu în documentația oficială Rails:
„Puteți testa dacă un obiect este compatibil cu API-ul Active Model incluzând ActiveModel::Lint::Tests în TestCase . Acesta va include teste care vă vor spune dacă obiectul dvs. este pe deplin compatibil sau, dacă nu, ce aspecte ale API-ului nu sunt implementate. Rețineți că un obiect nu este necesar pentru a implementa toate API-urile pentru a funcționa cu Action Pack. Acest modul intenționează doar să ofere îndrumări în cazul în care doriți să scoateți din cutie toate funcțiile.”
Deci, dacă implementați o clasă și doriți să o utilizați cu funcționalitatea Rails existentă, cum ar fi redirect_to, form_for , trebuie să implementați câteva metode. Această funcționalitate nu se limitează la obiectele ActiveRecord . Poate funcționa și cu obiectele tale, dar acestea trebuie să învețe să ciarla în mod corespunzător.
Implementarea
Implementarea este relativ simplă. Este un modul care este creat pentru a fi inclus în cazurile de testare. Metodele care încep cu test_ vor fi implementate de cadrul dumneavoastră. Este de așteptat ca variabila instanță @model să fie configurată de utilizator înaintea testului:
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 endUtilizare
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::Serializator::Lint::Teste
Serializatoarele de modele active nu sunt noi, dar putem continua să învățăm din ele. Includeți ActiveModel::Serializer::Lint::Tests pentru a verifica dacă un obiect este compatibil cu API-ul Active Model Serializers. Dacă nu este, testele vor indica care piese lipsesc.
Cu toate acestea, în documente, veți găsi un avertisment important că nu verifică semantica:
„Aceste teste nu încearcă să determine corectitudinea semantică a valorilor returnate. De exemplu, puteți implementa serializable_hash pentru a returna întotdeauna {} , iar testele ar trece. Depinde de tine să te asiguri că valorile sunt semnificative din punct de vedere semantic.”
Cu alte cuvinte, verificăm doar forma interfeței. Acum să vedem cum este implementat.
Implementarea
Acest lucru este foarte asemănător cu ceea ce am văzut cu un moment în urmă cu implementarea ActiveModel::Lint::Tests , dar puțin mai strict în unele cazuri, deoarece verifică aritatea sau clasele de valori returnate:
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 ...Utilizare
Iată un exemplu despre cum ActiveModelSerializers utilizează scamele incluzându-l în cazul său de testare:
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 endRack::Lint
Exemplele anterioare nu le-a păsat de semantică .
Cu toate acestea, Rack::Lint este o fiară complet diferită. Este un middleware Rack în care vă puteți împacheta aplicația. Middleware-ul joacă rolul unui linter în acest caz. Linter va verifica dacă cererile și răspunsurile sunt construite conform specificațiilor Rack. Acest lucru este util dacă implementați un server Rack (de exemplu, Puma) care va servi aplicația Rack și doriți să vă asigurați că respectați specificația Rack.
Alternativ, este folosit atunci când implementați o aplicație foarte simplă și doriți să vă asigurați că nu faceți greșeli simple legate de protocolul HTTP.
Implementarea
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 ...Utilizare în aplicația dvs
Să presupunem că construim un punct final foarte simplu. Uneori ar trebui să răspundă cu „Fără conținut”, dar am făcut o greșeală deliberată și vom trimite conținut în 50% din cazuri:

# 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 În astfel de cazuri, Rack::Lint va intercepta răspunsul, îl va verifica și va ridica o excepție:
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'Utilizare în Puma
În acest exemplu vedem cum Puma împachetează o aplicație foarte simplă lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } mai întâi într-un ServerLint (care moștenește de la Rack::Lint ) apoi în ErrorChecker .
scamele ridică excepții în cazul în care specificațiile nu sunt respectate. Verificatorul prinde excepțiile și returnează codul de eroare 500. Codul de test verifică dacă excepția nu a apărut:
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" endAșa se verifică că Puma este compatibil Rack.
RailsEventStore - Repository Lint
Rails Event Store este o bibliotecă pentru publicarea, consumarea, stocarea și preluarea evenimentelor. Acesta își propune să vă ajute în implementarea Arhitecturii Event-Driven pentru aplicația dumneavoastră Rails. Este o bibliotecă modulară construită cu componente mici, cum ar fi un depozit, mapper, dispecer, planificator, abonamente și serializator. Fiecare componentă poate avea o implementare interschimbabilă.
De exemplu, depozitul implicit folosește ActiveRecord și presupune un anumit aspect de tabel pentru stocarea evenimentelor. Cu toate acestea, implementarea dvs. poate folosi ROM sau poate funcționa în memorie fără a stoca evenimente, ceea ce este util pentru testare.
Dar cum poți ști dacă componenta pe care ai implementat-o se comportă în modul în care biblioteca se așteaptă? Folosind linter-ul furnizat, desigur. Și este imens. Acoperă aproximativ 80 de cazuri. Unele dintre ele sunt relativ simple:
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Și unele sunt puțin mai complexe și se referă la căi nefericite:
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) endLa aproape 1.400 de linii de cod Ruby, cred că este cel mai mare linter scris în Ruby. Dar dacă știți de unul mai mare, anunțați-mă. Partea interesantă este că este 100% despre semantică.
De asemenea, testează intens interfața, dar aș spune că este o idee ulterioară, având în vedere domeniul de aplicare al acestui articol.
Implementarea
Linter-ul de depozit este implementat folosind funcționalitatea Exemple partajate RSpec:
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 # ...Utilizare
Acest linter, similar celorlalte, se așteaptă să furnizați unele metode, cel mai important repository , care returnează implementarea pentru a fi verificată. Exemplele de testare sunt incluse folosind metoda RSpec include_examples încorporată:
RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } endÎncheierea
După cum puteți vedea, „ linter” are un sens puțin mai larg decât ceea ce avem de obicei în minte. De fiecare dată când implementați o bibliotecă care așteaptă niște colaboratori interschimbabili, vă încurajez să luați în considerare oferirea unui linter.
Chiar dacă singura clasă care trece astfel de teste la început va fi o clasă oferită și de biblioteca dvs., este un semn că dvs., ca inginer software, iei extensibilitatea în serios. De asemenea, vă va provoca să vă gândiți la interfața pentru fiecare componentă din codul dvs., nu accidental, ci conștient.
Resurse
- ActiveModel: faceți ca orice obiect Ruby să se simtă ca ActiveRecord
- RailsCast Despre Active Model
- Implementarea completă a scamelor de rack
- Utilizarea scamelor de rack în Puma
- Aflați mai multe despre Rails Event Store
