Choisir une alternative Tech Stack - Les hauts et les bas

Publié: 2022-03-11

Si une application Web est suffisamment grande et ancienne, il peut arriver un moment où vous devez la décomposer en parties plus petites et isolées et en extraire des services, dont certains seront plus indépendants que d'autres. Certaines des raisons qui pourraient inciter à une telle décision incluent : la réduction du temps d'exécution des tests, la possibilité de déployer différentes parties de l'application indépendamment ou l'application de limites entre les sous-systèmes. L'extraction de service nécessite que les ingénieurs logiciels prennent de nombreuses décisions vitales, et l'une d'elles est la pile technologique à utiliser pour le nouveau service.

Dans cet article, nous partageons une histoire sur l'extraction d'un nouveau service à partir d'une application monolithique - la plate-forme Toptal . Nous expliquons quelle pile technique nous avons choisie et pourquoi, et décrivons quelques problèmes que nous avons rencontrés lors de la mise en œuvre du service.

Le service Toptal's Chronicles est une application qui gère toutes les actions de l'utilisateur effectuées sur la plate-forme Toptal. Les actions sont essentiellement des entrées de journal. Lorsqu'un utilisateur fait quelque chose (par exemple, publie un article de blog, approuve un travail, etc.), une nouvelle entrée de journal est créée.

Bien qu'extrait de notre Plateforme, il n'en dépend fondamentalement pas et peut être utilisé avec n'importe quelle autre application. C'est pourquoi nous publions un compte rendu détaillé du processus et discutons d'un certain nombre de défis que notre équipe d'ingénieurs a dû surmonter lors de la transition vers la nouvelle pile.

Plusieurs raisons expliquent notre décision d'extraire le service et d'améliorer la pile :

  • Nous voulions que d'autres services puissent enregistrer des événements qui pourraient être affichés et utilisés ailleurs.
  • La taille des tables de base de données stockant les enregistrements d'historique a augmenté rapidement et de manière non linéaire, entraînant des coûts d'exploitation élevés.
  • Nous avons estimé que l'implémentation existante était grevée par une dette technique.

Tableau des actions - tables de base de données

À première vue, cela semblait être une initiative simple. Cependant, traiter avec des piles technologiques alternatives a tendance à créer des inconvénients inattendus, et c'est ce que l'article d'aujourd'hui vise à résoudre.

Présentation de l'architecture

L'application Chronicles se compose de trois parties qui peuvent être plus ou moins indépendantes et sont exécutées dans des conteneurs Docker distincts.

  • Le consommateur Kafka est un consommateur Kafka très léger basé sur Karafka de messages de création d'entrée. Il met en file d'attente tous les messages reçus sur Sidekiq.
  • Le travailleur Sidekiq est un travailleur qui traite les messages Kafka et crée des entrées dans la table de la base de données.
  • Points de terminaison GraphQL :
    • Le point de terminaison public expose l'API de recherche d'entrée, qui est utilisée pour diverses fonctions de la plate-forme (par exemple, pour afficher des info-bulles de commentaires sur les boutons de filtrage ou afficher l'historique des modifications de travail).
    • Le point de terminaison interne offre la possibilité de créer des règles de balise et des modèles à partir de migrations de données.

Les chroniques permettent de se connecter à deux bases de données différentes :

  • Sa propre base de données (où nous stockons les règles et les modèles de balises)
  • La base de données de la plate-forme (où nous stockons les actions effectuées par les utilisateurs et leurs balises et marquages)

Lors du processus d'extraction de l'application, nous avons migré les données de la base de données de la plate-forme et fermé la connexion à la plate-forme.

Plan initial

Au départ, nous avons décidé d'utiliser Hanami et tout l'écosystème qu'il fournit par défaut (un modèle hanami soutenu par ROM.rb, dry-rb, hanami-newrelic, etc.). Suivre une manière « standard » de faire les choses nous a promis une faible friction, une grande vitesse de mise en œuvre et une très bonne « googleabilité » de tous les problèmes auxquels nous pourrions être confrontés. De plus, l'écosystème hanami est mature et populaire, et la bibliothèque est soigneusement entretenue par des membres respectés de la communauté Ruby.

De plus, une grande partie du système avait déjà été implémentée du côté de la plate-forme (par exemple, le point de terminaison GraphQL Entry Search et l'opération CreateEntry), nous avons donc prévu de copier une grande partie du code de la plate-forme vers Chronicles tel quel, sans apporter de modifications. C'était aussi l'une des principales raisons pour lesquelles nous n'avons pas opté pour Elixir, car Elixir ne le permettait pas.

Nous avons décidé de ne pas faire Rails parce que c'était exagéré pour un si petit projet, en particulier des choses comme ActiveSupport, qui ne fourniraient pas beaucoup d'avantages tangibles pour nos besoins.

Quand le plan va au sud

Bien que nous ayons fait de notre mieux pour nous en tenir au plan, il a rapidement déraillé pour un certain nombre de raisons. L'un était notre manque d'expérience avec la pile choisie, suivi de véritables problèmes avec la pile elle-même, puis il y avait notre configuration non standard (deux bases de données). Au final, nous avons décidé de nous débarrasser du hanami-model , puis de Hanami lui-même, en le remplaçant par Sinatra.

Nous avons choisi Sinatra parce que c'est une bibliothèque activement entretenue créée il y a 12 ans, et comme c'est l'une des bibliothèques les plus populaires, tous les membres de l'équipe avaient une grande expérience pratique avec elle.

Dépendances incompatibles

L'extraction des Chroniques a commencé en juin 2019, et à l'époque, Hanami n'était pas compatible avec les dernières versions des gemmes dry-rb. À savoir, la dernière version de Hanami à l'époque (1.3.1) ne supportait que la validation sèche 0.12, et nous voulions la validation sèche 1.0.0. Nous avions prévu d'utiliser des contrats de validation sèche qui n'ont été introduits que dans la version 1.0.0.

De plus, Kafka 1.2 est incompatible avec les gemmes sèches, nous en utilisions donc la version de dépôt. À l'heure actuelle, nous utilisons 1.3.0.rc1, qui dépend des dernières gemmes sèches.

Dépendances inutiles

De plus, la gemme Hanami comprenait trop de dépendances que nous n'avions pas l'intention d'utiliser, telles que hanami-cli , hanami-assets , hanami-mailer , hanami-view et même hanami-controller . De plus, en regardant le fichier readme du modèle hanami, il est devenu clair qu'il ne prend en charge qu'une seule base de données par défaut. D'autre part, ROM.rb, sur lequel le hanami-model est basé, prend en charge les configurations multi-bases de données prêtes à l'emploi.

Dans l'ensemble, Hanami en général et le hanami-model en particulier ressemblaient à un niveau d'abstraction inutile.

Donc, 10 jours après avoir fait le premier PR significatif pour Chronicles, nous avons complètement remplacé hanami par Sinatra. Nous aurions également pu utiliser Rack pur car nous n'avons pas besoin d'un routage complexe (nous avons quatre points de terminaison "statiques" - deux points de terminaison GraphQL, le point de terminaison /ping et l'interface Web sidekiq), mais nous avons décidé de ne pas aller trop loin. Sinatra nous convenait parfaitement. Si vous souhaitez en savoir plus, consultez notre tutoriel Sinatra et Sequel.

Incompréhension du schéma sec et de la validation sèche

Il nous a fallu du temps et beaucoup d'essais et d'erreurs pour comprendre comment «cuisiner» correctement la validation à sec.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

Dans l'extrait ci-dessus, le paramètre url est défini de plusieurs manières légèrement différentes. Certaines définitions sont équivalentes, d'autres n'ont aucun sens. Au début, nous ne pouvions pas vraiment faire la différence entre toutes ces définitions car nous ne les comprenions pas complètement. Du coup, la première version de nos contrats était assez brouillonne. Avec le temps, nous avons appris à lire et à rédiger correctement les contrats DRY, et maintenant ils ont l'air cohérents et élégants - en fait, non seulement élégants, ils sont tout simplement beaux. Nous validons même la configuration des applications avec les contrats.

Problèmes avec ROM.rb et Sequel

ROM.rb et Sequel diffèrent d'ActiveRecord, pas de surprise. Notre idée initiale de pouvoir copier et coller la plupart du code de Platform a échoué. Le problème est que la partie plate-forme était très lourde en RA, donc presque tout a dû être réécrit en ROM/Sequel. Nous avons réussi à ne copier que de petites portions de code indépendantes du framework. En cours de route, nous avons rencontré quelques problèmes frustrants et quelques bugs.

Filtrage par sous-requête

Par exemple, il m'a fallu plusieurs heures pour comprendre comment créer une sous-requête dans ROM.rb/Sequel. C'est quelque chose que j'écrirais sans même me réveiller dans Rails : scope.where(sequence_code: subquery ). Dans Sequel, cependant, cela ne s'est pas avéré si facile.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

Ainsi, au lieu d'une simple ligne comme base_query.where(sequence_code: bild_subquery(params)) , nous devons avoir une douzaine de lignes avec du code non trivial, des fragments SQL bruts et un commentaire multiligne expliquant ce qui a causé ce cas malheureux de gonfler.

Associations avec des champs de jointure non triviaux

La relation entry (table performed_actions ) a un champ d' id primaire. Cependant, pour joindre les tables *taggings , il utilise la colonne sequence_code . Dans ActiveRecord, cela s'exprime assez simplement :

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

Il est également possible d'écrire la même chose en ROM.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

Il y avait cependant un petit problème. Il se compilerait très bien mais échouerait à l'exécution lorsque vous essayez réellement de l'utiliser.

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

Nous avons de la chance que les types d'id et de sequence_code soient différents, donc PG génère une erreur de type. Si les types étaient les mêmes, qui sait combien d'heures je passerais à déboguer cela.

Ainsi, entries.join(:access_taggings) ne fonctionne pas. Et si nous spécifions explicitement la condition de jointure ? Comme dans entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , comme le suggère la documentation officielle.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

Maintenant, il pense que :access_taggings est un nom de table pour une raison quelconque. Très bien, échangeons-le avec le nom réel de la table.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

Enfin, il a renvoyé quelque chose et n'a pas échoué, bien qu'il se soit retrouvé avec une abstraction qui fuit. Le nom de la table ne doit pas fuir vers le code de l'application.

Interpolation des paramètres SQL

Il existe une fonctionnalité dans la recherche de chroniques qui permet aux utilisateurs de rechercher par charge utile. La requête ressemble à ceci : {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , où path est toujours un tableau de chaînes, et value est une valeur JSON valide.

Dans ActiveRecord, cela ressemble à ceci :

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

Dans Sequel, je n'ai pas réussi à interpoler correctement :path , donc j'ai dû recourir à ça :

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

Heureusement, le path ici est correctement validé afin qu'il ne contienne que des caractères alphanumériques, mais ce code a toujours l'air drôle.

La magie silencieuse de l'usine ROM

Nous avons utilisé la gemme rom-factory pour simplifier la création de nos modèles dans les tests. Plusieurs fois, cependant, le code n'a pas fonctionné comme prévu. Pouvez-vous deviner ce qui ne va pas avec ce test?

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

Non, l'attente n'est pas défaillante, l'attente est bonne.

Le problème est que la deuxième ligne échoue avec une erreur de validation de contrainte unique. La raison en est que l' action n'est pas l'attribut du modèle Action . Le vrai nom est action_name , donc la bonne façon de créer des actions devrait ressembler à ceci :

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

Comme l'attribut mal typé a été ignoré, il revient à celui par défaut spécifié dans la fabrique ( action_name { 'created' } ), et nous avons une violation de contrainte unique car nous essayons de créer deux actions identiques. Nous avons dû faire face à ce problème plusieurs fois, ce qui s'est avéré éprouvant.

Heureusement, il a été corrigé en 0.9.0. Dependabot nous a automatiquement envoyé une demande d'extraction avec la mise à jour de la bibliothèque, que nous avons fusionnée après avoir corrigé quelques attributs mal typés que nous avions lors de nos tests.

Ergonomie générale

Ceci dit tout :

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

Et la différence est encore plus grande dans des exemples plus compliqués.

Les bonnes pièces

Ce n'était pas que de la douleur, de la sueur et des larmes. Il y avait beaucoup, beaucoup de bonnes choses au cours de notre voyage, et elles l'emportent de loin sur les aspects négatifs de la nouvelle pile. Si cela n'avait pas été le cas, nous ne l'aurions pas fait en premier lieu.

Vitesse d'essai

Il faut 5 à 10 secondes pour exécuter l'ensemble de la suite de tests localement, et autant pour RuboCop. Le temps de CI est beaucoup plus long (3-4 minutes), mais c'est moins un problème car nous pouvons tout exécuter localement de toute façon, grâce à quoi, tout échec sur CI est beaucoup moins probable.

La gemme de garde est redevenue utilisable. Imaginez que vous puissiez écrire du code et exécuter des tests sur chaque sauvegarde, vous donnant un retour très rapide. C'est très difficile à imaginer lorsque l'on travaille avec la plateforme.

Temps de déploiement

Le temps de déploiement de l'application Chronicles extraite n'est que de deux minutes. Pas rapide comme l'éclair, mais toujours pas mal. Nous déployons très souvent, donc même des améliorations mineures peuvent générer des économies substantielles.

Performances des applications

La partie la plus gourmande en performances de Chronicles est la recherche d'entrées. Pour l'instant, il y a environ 20 emplacements dans le back-end de la plate-forme qui récupèrent les entrées d'historique de Chronicles. Cela signifie que le temps de réponse de Chronicles contribue au budget de 60 secondes de la plate-forme pour le temps de réponse, donc Chronicles doit être rapide, ce qui est le cas.

Malgré la taille énorme du journal des actions (30 millions de lignes, et en augmentation), le temps de réponse moyen est inférieur à 100 ms. Jetez un oeil à ce beau tableau:

Tableau des performances des applications

En moyenne, 80 à 90 % du temps de l'application est passé dans la base de données. C'est à quoi devrait ressembler un tableau de performances approprié.

Nous avons encore des requêtes lentes qui peuvent prendre des dizaines de secondes, mais nous avons déjà un plan pour les éliminer, permettant à l'application extraite de devenir encore plus rapide.

Structure

Pour nos besoins, la validation sèche est un outil très puissant et flexible. Nous transmettons toutes les entrées du monde extérieur par le biais de contrats, et cela nous donne l'assurance que les paramètres d'entrée sont toujours bien formés et de types bien définis.

Il n'est plus nécessaire d'appeler .to_s.to_sym.to_i dans le code de l'application, car toutes les données sont nettoyées et transtypées aux frontières de l'application. Dans un sens, cela apporte de forts types de santé mentale au monde Ruby dynamique. Je ne peux pas le recommander assez.

Derniers mots

Le choix d'une pile non standard n'était pas aussi simple qu'il y paraissait au départ. Nous avons pris en compte de nombreux aspects lors de la sélection du framework et des bibliothèques à utiliser pour le nouveau service : la pile technologique actuelle de l'application monolithique, la familiarité de l'équipe avec la nouvelle pile, la maintenance de la pile choisie, etc.

Même si nous avons essayé de prendre des décisions très prudentes et calculées dès le début - nous avons choisi d'utiliser la pile Hanami standard - nous avons dû reconsidérer notre pile en cours de route en raison d'exigences techniques non standard du projet. Nous nous sommes retrouvés avec Sinatra et une pile basée sur DRY.

Est-ce que nous choisirions à nouveau Hanami si nous devions extraire une nouvelle application ? Probablement oui. Nous en savons maintenant plus sur la bibliothèque et ses avantages et inconvénients, ce qui nous permet de prendre des décisions plus éclairées dès le début de tout nouveau projet. Cependant, nous envisagerions également sérieusement d'utiliser une application simple Sinatra/DRY.rb.

Dans l'ensemble, le temps investi dans l'apprentissage de nouveaux frameworks, paradigmes ou langages de programmation nous donne une nouvelle perspective sur notre pile technologique actuelle. Il est toujours bon de savoir ce qui existe pour enrichir sa boîte à outils. Chaque outil a son propre cas d'utilisation unique. Par conséquent, mieux les connaître signifie en avoir plus à votre disposition et les adapter mieux à votre application.