Linters mis en œuvre par les bibliothèques Ruby
Publié: 2022-03-11Lorsque vous entendez le mot « linter » ou « lint », vous avez probablement déjà certaines attentes sur le fonctionnement d'un tel outil ou sur ce qu'il devrait faire.
Vous pensez peut-être à Rubocop, que maintient un développeur Toptal, ou à JSLint, ESLint, ou quelque chose de moins connu ou de moins populaire.
Cet article vous présentera un autre type de linters. Ils ne vérifient pas la syntaxe du code ni ne vérifient l'arbre de syntaxe abstraite, mais ils vérifient le code. Ils vérifient si une implémentation adhère à une certaine interface, non seulement lexicalement (en termes de typage de canard et d'interfaces classiques) mais parfois aussi sémantiquement.
Pour se familiariser avec eux, analysons quelques exemples pratiques. Si vous n'êtes pas un fervent professionnel de Rails, vous voudrez peut-être lire ceci en premier.
Commençons avec un Lint de base.
ActiveModel ::Lint ::Tests
Le comportement de ce Lint est expliqué en détail dans la documentation officielle de Rails :
« Vous pouvez tester si un objet est conforme à l'API Active Model en incluant ActiveModel::Lint::Tests dans votre TestCase . Il inclura des tests qui vous indiqueront si votre objet est entièrement conforme ou, si ce n'est pas le cas, quels aspects de l'API ne sont pas implémentés. Notez qu'un objet n'est pas obligé d'implémenter toutes les API pour fonctionner avec Action Pack. Ce module vise uniquement à fournir des conseils au cas où vous voudriez que toutes les fonctionnalités soient prêtes à l'emploi.
Donc, si vous implémentez une classe et que vous souhaitez l'utiliser avec les fonctionnalités Rails existantes telles que redirect_to, form_for , vous devez implémenter quelques méthodes. Cette fonctionnalité n'est pas limitée aux objets ActiveRecord . Cela peut aussi fonctionner avec vos objets, mais ils doivent apprendre à charlataniser correctement.
Mise en œuvre
La mise en œuvre est relativement simple. C'est un module qui est créé pour être inclus dans des cas de test. Les méthodes qui commencent par test_ seront implémentées par votre framework. Il est prévu que la variable d'instance @model soit configurée par l'utilisateur avant le 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 endUsage
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
Les sérialiseurs de modèles actifs ne sont pas nouveaux, mais nous pouvons continuer à apprendre d'eux. Vous incluez ActiveModel::Serializer::Lint::Tests pour vérifier si un objet est conforme à l'API Active Model Serializers. Si ce n'est pas le cas, les tests indiqueront les pièces manquantes.
Cependant, dans la documentation, vous trouverez un avertissement important indiquant qu'il ne vérifie pas la sémantique :
« Ces tests ne tentent pas de déterminer l'exactitude sémantique des valeurs renvoyées. Par exemple, vous pouvez implémenter serializable_hash pour toujours renvoyer {} , et les tests réussiraient. C'est à vous de vous assurer que les valeurs sont sémantiquement significatives.
En d'autres termes, nous ne vérifions que la forme de l'interface. Voyons maintenant comment il est mis en œuvre.
Mise en œuvre
Ceci est très similaire à ce que nous avons vu il y a un instant avec l'implémentation de ActiveModel::Lint::Tests , mais un peu plus strict dans certains cas car il vérifie l'arité ou les classes des valeurs renvoyées :
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 ...Usage
Voici un exemple de la façon dont ActiveModelSerializers utilise le lint en l'incluant dans son cas de test :
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
Les exemples précédents ne se souciaient pas de la sémantique .
Cependant, Rack::Lint est une bête complètement différente. C'est le middleware Rack dans lequel vous pouvez envelopper votre application. Le middleware joue le rôle d'un linter dans ce cas. Le linter vérifiera si les demandes et les réponses sont construites conformément à la spécification Rack. Ceci est utile si vous implémentez un serveur Rack (c'est-à-dire Puma) qui servira l'application Rack et que vous voulez vous assurer que vous suivez la spécification Rack.
Alternativement, il est utilisé lorsque vous implémentez une application très simple et que vous voulez vous assurer que vous ne faites pas d'erreurs simples liées au protocole HTTP.
Mise en œuvre
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 ...Utilisation dans votre application
Disons que nous construisons un point de terminaison très simple. Parfois, il devrait répondre par "Pas de contenu", mais nous avons fait une erreur délibérée et nous enverrons du contenu dans 50 % des cas :

# 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 Dans de tels cas, Rack::Lint interceptera la réponse, la vérifiera et déclenchera une exception :
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'Utilisation dans Puma
Dans cet exemple, nous voyons comment Puma enveloppe une application très simple lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } d'abord dans un ServerLint (qui hérite de Rack::Lint ) puis dans ErrorChecker .
Le lint lève des exceptions au cas où la spécification n'est pas suivie. Le vérificateur intercepte les exceptions et renvoie le code d'erreur 500. Le code de test vérifie que l'exception ne s'est pas produite :
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" endC'est ainsi que Puma est certifié compatible Rack.
RailsEventStore - Référentiel Lint
Rails Event Store est une bibliothèque pour publier, consommer, stocker et récupérer des événements. Il vise à vous aider à mettre en œuvre l'architecture pilotée par les événements pour votre application Rails. Il s'agit d'une bibliothèque modulaire construite avec de petits composants tels qu'un référentiel, un mappeur, un répartiteur, un planificateur, des abonnements et un sérialiseur. Chaque composant peut avoir une implémentation interchangeable.
Par exemple, le référentiel par défaut utilise ActiveRecord et suppose une certaine disposition de table pour stocker les événements. Cependant, votre implémentation peut utiliser la ROM ou fonctionner en mémoire sans stocker d'événements, ce qui est utile pour les tests.
Mais comment savoir si le composant que vous avez implémenté se comporte d'une manière attendue par la bibliothèque ? En utilisant le linter fourni, bien sûr. Et c'est immense. Il couvre environ 80 cas. Certains d'entre eux sont relativement simples :
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 endEt certains sont un peu plus complexes et concernent des chemins malheureux :
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) endAvec près de 1 400 lignes de code Ruby, je pense que c'est le plus gros linter écrit en Ruby. Mais si vous en connaissez un plus gros, faites-le moi savoir. La partie intéressante est qu'il s'agit à 100% de sémantique.
Il teste également fortement l'interface, mais je dirais que c'est une réflexion après coup compte tenu de la portée de cet article.
Mise en œuvre
Le référentiel linter est implémenté à l'aide de la fonctionnalité d'exemples partagés 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 # ...Usage
Ce linter, similaire aux autres, attend de vous que vous fournissiez certaines méthodes, le plus important étant le repository , qui renvoie l'implémentation à vérifier. Les exemples de test sont inclus à l'aide de la méthode intégrée RSpec include_examples :
RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } endEmballer
Comme vous pouvez le voir, « linter » a un sens légèrement plus large que ce que nous avons habituellement à l'esprit. Chaque fois que vous implémentez une bibliothèque qui attend des collaborateurs interchangeables, je vous encourage à envisager de fournir un linter.
Même si la seule classe qui passe de tels tests au début sera une classe également fournie par votre bibliothèque, c'est un signe que vous, en tant qu'ingénieur logiciel, prenez l'extensibilité au sérieux. Cela vous mettra également au défi de réfléchir à l'interface de chaque composant de votre code, non pas accidentellement mais consciemment.
Ressources
- ActiveModel : faites en sorte que n'importe quel objet Ruby ressemble à ActiveRecord
- RailsCast À propos du modèle actif
- Mise en œuvre complète de la charpie du rack
- Utilisation de la charpie du rack dans Puma
- En savoir plus sur Rails Event Store
