Résolution des problèmes de type de données dans Ruby ou Fiabilisation des données

Dans cet article, je voudrais parler des problèmes avec les types de données dans Ruby, quels problèmes j'ai rencontrés, comment ils peuvent être résolus et comment s'assurer que les données avec lesquelles nous travaillons peuvent être fiables.

image

Vous devez d'abord décider quels types de données sont. Je vois une définition très réussie du terme, qui peut être trouvée dans le HaskellWiki .
Les types sont la façon dont vous décrivez les données avec lesquelles votre programme va travailler.
Mais qu'est-ce qui ne va pas avec les types de données dans Ruby? Pour décrire le problème de manière globale, je voudrais souligner plusieurs raisons.

Raison 1. Problèmes de Ruby lui-même


Comme vous le savez, Ruby utilise un typage dynamique strict avec prise en charge de ce qu'on appelle. typage de canard . Qu'est-ce que cela signifie?

Un typage fort nécessite une conversion explicite et ne produit pas cette conversion à lui seul, comme c'est le cas, par exemple, en JavaScript. Par conséquent, la liste de codes suivante dans Ruby échouera:

1 + '1' - 1 #=> TypeError (String can't be coerced into Integer) 

En typage dynamique, la vérification de type a lieu lors de l'exécution, ce qui nous permet de ne pas spécifier les types de variables et d'utiliser la même variable pour stocker des valeurs de différents types:

 x = 123 x = "123" x = [1, 2, 3] 

La déclaration suivante est généralement donnée comme explication du terme «typage du canard»: s'il ressemble à un canard, nage comme un canard et quacks comme un canard, alors il s'agit très probablement d'un canard. C'est-à-dire le typage canard, s'appuyant sur le comportement des objets, nous offre une flexibilité supplémentaire dans l'écriture de nos systèmes. Par exemple, dans l'exemple ci-dessous, la valeur pour nous n'est pas le type de l'argument de collection , mais sa capacité à répondre aux messages blank? et map :

 def process(collection) return if collection.blank? collection.map { |item| do_something_with(item) } end 

La possibilité de créer de tels «canards» est un outil très puissant. Cependant, comme tout autre outil puissant, il nécessite une grande prudence lors de son utilisation. Cela peut être vérifié par les recherches de Rollbar , où ils ont analysé plus de 1000 applications ferroviaires et identifié les erreurs les plus courantes. Et 2 des 10 erreurs les plus courantes sont liées précisément au fait que l'objet ne peut pas répondre à un message spécifique. Et par conséquent, vérifier le comportement de l'objet que la saisie de canard nous donne dans de nombreux cas peut ne pas être suffisant.

Nous pouvons observer comment la vérification de type est ajoutée aux langages dynamiques sous une forme ou une autre:

  • TypeScript apporte la vérification de type aux développeurs JavaScript
  • Des indications de type ont été ajoutées dans Python 3
  • Dialyzer fait un bon travail de vérification de type pour Erlang / Elixir
  • Steep et Sorbet ajoutent une vérification de type dans Ruby 2.x

Cependant, avant de parler d'un autre outil pour travailler plus efficacement avec les types dans Ruby, examinons deux autres problèmes pour lesquels je voudrais trouver une solution.

Raison 2. Le problème général des développeurs dans divers langages de programmation


Rappelons la définition des types de données que j'ai donnée au tout début de l'article:
Les types sont la façon dont vous décrivez les données avec lesquelles votre programme va travailler.
C'est-à-dire les types sont conçus pour nous aider à décrire les données de notre domaine dans lequel nos systèmes fonctionnent. Cependant, souvent au lieu de fonctionner avec les types de données que nous avons créés à partir de notre domaine, nous utilisons des types primitifs, tels que des nombres, des chaînes, des tableaux, etc., qui ne disent rien sur notre domaine. Ce problème est généralement classé comme obsession primitive (obsession des primitives).

Voici un exemple typique d'obsession primitive:

 price = 9.99 # vs Money = Struct.new(:amount_cents, :currency) price = Money.new(9_99, 'USD') 

Au lieu de décrire le type de données pour travailler avec de l'argent, des nombres réguliers sont souvent utilisés. Et ce nombre, comme tout autre type primitif, ne dit rien sur notre sujet. À mon avis, c'est le plus gros problème de l'utilisation de primitives au lieu de créer votre propre système de types, où ces types décrivent des données de notre domaine. Nous rejetons nous-mêmes les avantages que nous pouvons obtenir avec l'utilisation de types.

Je parlerai de ces avantages juste après avoir couvert un autre problème que notre framework Ruby on Rails préféré nous a appris, grâce auquel, j'en suis sûr, la plupart de ceux qui sont ici sont venus à Ruby.

Raison 3. Le problème que le framework Ruby on Rails nous a habitué à


Ruby on Rails, ou plutôt le framework ORM ActiveRecord intégré, nous a appris que les objets qui sont dans un état invalide sont normaux. À mon avis, c'est loin d'être la meilleure idée. Et je vais essayer de l'expliquer.

Prenez cet exemple:

 class App < ApplicationRecord validates :platform, presence: true end app = App.new app.valid? # => false 

Il n'est pas difficile de comprendre que l'objet app aura un état invalide: la validation du modèle App nécessite que les objets de ce modèle aient un attribut de platform , et notre objet a cet attribut vide.

Essayons maintenant de passer cet objet dans un état non valide à un service qui attend un objet App comme argument et effectue certaines actions en fonction de l'attribut de platform - platform de cet objet:

 class DoSomethingWithAppPlatform # @param [App] app # # @return [void] def call(app) # do something with app.platform end end DoSomethingWithAppPlatform.new.call(app) 

Dans ce cas, même la vérification de type passerait. Cependant, puisque cet attribut est vide pour l'objet, il n'est pas clair comment le service gérera ce cas. Dans tous les cas, ayant la capacité de créer des objets dans un état invalide, nous nous condamnons à la nécessité de traiter en permanence les cas où un état invalide s'est infiltré dans notre système.

Mais réfléchissons à un problème plus profond. En général, pourquoi vérifions-nous la validité des données? En règle générale, pour vous assurer qu'un état non valide ne s'infiltre pas dans nos systèmes. S'il est si important de garantir qu'un état non valide n'est pas autorisé, alors pourquoi autorisons-nous la création d'objets avec un état non valide? Surtout lorsque nous avons affaire à des objets aussi importants que le modèle ActiveRecord, qui fait souvent référence à la logique métier racine. À mon avis, cela semble être une très mauvaise idée.

Ainsi, résumant tout ce qui précède, nous obtenons les problèmes suivants lors de l'utilisation des données dans Ruby / Rails:

  • le langage lui-même a un mécanisme pour vérifier le comportement, mais pas les données
  • nous, comme les développeurs dans d'autres langues, avons tendance à utiliser des types de données primitifs au lieu de créer un système de types pour notre domaine
  • Rails nous a habitués au fait que la présence d'objets dans un état invalide est normale, bien qu'une telle solution semble être une assez mauvaise idée

Comment résoudre ces problèmes?


Je voudrais considérer l'une des solutions aux problèmes décrits ci-dessus, en utilisant un exemple d'implémentation de fonctionnalités réelles dans Appodeal. Dans le processus de collecte de statistiques sur les statistiques des utilisateurs actifs quotidiens (ci-après DAU) pour les applications qui utilisent Appodeal pour monétiser, nous sommes arrivés à environ la structure de données suivante que nous devons collecter:

 DailyActiveUsersData = Struct.new( :app_id, :country_id, :user_id, :ad_type, :platform_id, :ad_id, :first_request_date, keyword_init: true ) 

Cette structure a tous les mêmes problèmes que j'ai décrits ci-dessus:

  • toute vérification de type est complètement absente, ce qui ne permet pas de savoir quelles valeurs les attributs de cette structure peuvent prendre
  • il n'y a pas de description des données utilisées dans cette structure, et au lieu des types spécifiques à notre domaine, des primitives sont utilisées
  • la structure peut exister dans un état non valide

Pour résoudre ces problèmes, nous avons décidé d'utiliser les bibliothèques dry-types et dry-struct . dry-types est un système de type simple et extensible pour Ruby, utile pour la coulée, l'application de diverses contraintes, la définition de structures complexes, etc. dry-struct est une bibliothèque construite au-dessus de dry-types qui fournit une DSL pratique pour définir des structures typées / cours.

Pour décrire les données de notre domaine utilisé dans la structure de collecte des DAU, le système de type suivant a été créé:

 module Types include Dry::Types.module AdTypeId = Types::Strict::Integer.enum(AD_TYPES.invert) EntityId = Types::Strict::Integer.constrained(gt: 0) PlatformId = Types::Strict::Integer.enum(PLATFORMS.invert) Uuid = Types::Strict::String.constrained(format: UUID_REGEX) Zero = Types.Constant(0) end 

Nous avons maintenant reçu une description des données qui sont utilisées dans notre système et que nous pouvons utiliser dans la structure. Comme vous pouvez le voir, les types EntityId et Uuid ont certaines limitations, et les types énumérables AdTypeId et PlatformId ne peuvent avoir que des valeurs d'un ensemble spécifique. Comment travailler avec ces types? Considérez le PlatformId comme exemple:

 #     enumerable- PLATFORMS = { 'android' => 1, 'fire_os' => 2, 'ios' => 3 }.freeze #       , #     Types::PlatformId[1] == Types::PlatformId['android'] #    ,    #   ,     Types::PlatformId['fire_os'] # => 2 #     ,   Types::PlatformId['windows'] # => Dry::Types::ConstraintError 

Donc, en utilisant les types eux-mêmes compris. Appliquons-les maintenant à notre structure. En conséquence, nous avons obtenu ceci:

 class DailyActiveUsersData < Dry::Struct attribute :app_id, Types::EntityId attribute :country_id, Types::EntityId attribute :user_id, Types::EntityId attribute :ad_type, (Types::AdTypeId ǀ Types::Zero) attribute :platform_id, Types::PlarformId attribute :ad_id, Types::Uuid attribute :first_request_date, Types::Strict::Date end 

Que voyons-nous maintenant dans la structure de données de DAU? En utilisant dry-types et dry-struct nous nous sommes débarrassés des problèmes liés au manque de vérification des types de données et au manque de description des données. Maintenant, toute personne, après avoir examiné cette structure et la description des types qui y sont utilisés, peut comprendre quelles valeurs chaque attribut peut prendre.

En ce qui concerne le problème avec les objets dans un état invalide, dry-struct nous sauve dry-struct de ceci: si nous essayons d'initialiser la structure avec des valeurs invalides, nous obtiendrons une erreur. Et pour les cas où l'exactitude des données est essentielle (et dans le cas de la collecte DAU, c'est le cas avec nous), à mon avis, obtenir une exception est beaucoup mieux que d'essayer de traiter des données invalides plus tard. De plus, si le processus de test est bien établi pour vous (et c'est exactement le cas avec nous), alors avec une forte probabilité, le code générant de telles erreurs n'atteindra tout simplement pas l'environnement de production.

Et en plus de l'impossibilité d'initialiser des objets dans un état non valide, dry-struct ne permet pas non plus de changer d'objets après l'initialisation. Grâce à ces deux facteurs, nous obtenons la garantie que les objets de ces structures seront dans un état valide et vous pouvez compter en toute sécurité sur ces données n'importe où dans votre système.

Résumé


Dans cet article, j'ai essayé de décrire les problèmes que vous pouvez rencontrer en travaillant avec des données dans Ruby, ainsi que de parler des outils que nous utilisons pour résoudre ces problèmes. Et grâce à l'implémentation de ces outils, j'ai cessé de m'inquiéter de l'exactitude des données avec lesquelles nous travaillons. N'est-ce pas parfait? N'est-ce pas le but d'un instrument - de faciliter notre vie dans certains aspects de celui-ci? Et à mon avis, les dry-types dry-struct parfaitement leur travail!

Source: https://habr.com/ru/post/fr433180/


All Articles