Backend orthodoxe



Le backend moderne est diversifié, mais obéit toujours à des règles tacites. Beaucoup d'entre nous qui développons des applications serveur sont confrontés à des approches généralement acceptées, telles que l'architecture propre, SOLID, l'ignorance de la persistance, l'injection de dépendances et autres. Beaucoup d'attributs du développement de serveurs sont tellement galvaudés qu'ils ne posent aucune question et sont utilisés sans réfléchir. Ils en parlent beaucoup, mais ne l'utilisent jamais. Le sens du reste est soit mal interprété, soit déformé. L'article explique comment construire une architecture backend simple et complètement typique, qui non seulement peut suivre les préceptes des théoriciens de la programmation célèbres sans aucun dommage, mais peut également les améliorer dans une certaine mesure.

Dédié à tous ceux qui ne pensent pas à la programmation sans beauté et n'acceptent pas la beauté au milieu de l'absurdité.

Modèle de domaine


La modélisation est le point de départ du développement logiciel dans un monde idéal. Mais nous ne sommes pas tous parfaits, nous en parlons beaucoup, mais nous faisons tout comme d'habitude. La raison en est souvent l'imperfection des outils existants. Et pour être honnête, notre paresse et notre peur de prendre la responsabilité de s'éloigner des "meilleures pratiques". Dans un monde imparfait, le développement de logiciels commence, au mieux, par l'échafaudage, et au pire, rien n'est fait avec l'optimisation des performances. Néanmoins, je voudrais écarter les exemples concrets d'architectes «exceptionnels» et spéculer sur des choses plus ordinaires.

Nous avons donc une tâche technique, et même une conception d'interface utilisateur (ou non, si l'interface utilisateur n'est pas fournie). L'étape suivante consiste à refléter les exigences du modèle de domaine. Pour commencer, vous pouvez esquisser un diagramme des objets du modèle pour plus de clarté:



Ensuite, en règle générale, nous commençons à projeter le modèle sur les moyens de sa mise en œuvre - un langage de programmation, un convertisseur objet-relationnel (Object-Relational Mapper, ORM), ou sur une sorte de cadre complexe comme ASP.NET MVC ou Ruby on Rails, en d'autres termes - commencez à écrire du code. Dans ce cas, nous suivons le chemin du cadre, qui je pense n'est pas correct dans le cadre d'un développement basé sur le modèle, aussi pratique que cela puisse paraître au départ. Ici, vous faites une hypothèse énorme, qui annule par la suite les avantages du développement basé sur un domaine. En tant qu'option plus libre, non limitée par la portée de n'importe quel outil, je suggérerais de m'attarder sur l'utilisation des seuls outils syntaxiques d'un langage de programmation pour construire un modèle objet d'un domaine. Dans mon travail, j'utilise plusieurs langages de programmation - C #, JavaScript, Ruby. Le destin a décrété que les écosystèmes Java et C # sont mon inspiration, JS est mon principal revenu et Ruby est le langage que j'aime. Par conséquent, je continuerai à montrer des exemples simples en Ruby: je suis convaincu que cela ne causera pas de problèmes aux développeurs dans d'autres langues. Donc, portez le modèle sur la classe Invoice dans Ruby:

class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end end 

C'est-à-dire nous avons une classe dont le constructeur accepte un hachage d'attributs, des dépendances d'objet et initialise ses champs, et une méthode de paiement qui peut changer l'état de l'objet. Tout est très simple. Maintenant, nous ne pensons pas à comment et où nous allons afficher et stocker cet objet. Il existe simplement, nous pouvons le créer, changer son état, interagir avec d'autres objets. Veuillez noter que le code ne contient aucun artefact étranger comme BaseEntity et autres déchets qui ne sont pas liés au modèle. C'est très important. Soit dit en passant, à ce stade, nous pouvons déjà commencer le développement par le biais de tests (TDD), en utilisant des objets de remplacement au lieu de dépendances comme payment_service:

 RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new({...}) @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end end 

ou même jouer avec le modèle dans l'interpréteur (irb pour Ruby), qui peut bien être, bien que pas très convivial, l'interface utilisateur:

 irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) irb > invoice.pay 

Pourquoi est-il si important d'éviter les «artefacts étrangers» à ce stade? Le fait est que le modèle ne devrait pas avoir la moindre idée de la façon dont il sera enregistré ou s'il sera enregistré du tout. Au final, pour certains systèmes, le stockage d'objets directement en mémoire peut être tout à fait approprié. Au moment de la modélisation, il faut complètement abstraire de ce détail. Cette approche est appelée Ignorance de la persistance. Il convient de souligner que nous n'ignorons pas les problèmes de travail avec le référentiel, qu'il s'agisse d'une base de données relationnelle ou de toute autre base de données, nous négligeons uniquement les détails de l'interaction avec lui au stade de la modélisation. L'ignorance de la persistance signifie l'élimination intentionnelle des mécanismes pour travailler avec l'état du modèle, ainsi que toutes sortes de métadonnées liées à ce processus, du modèle lui-même. Exemples:

 #  class User < Entity #     table :users #     # mapping  field :name, type: 'String' #   def save ... end end user = User.load(id) #     user.save #     

 #  class User #   ,      attr_accessor :name, :lastname end user = repo.load(id) #     repo.save(user) #     

Cette approche est également due à des raisons fondamentales - respect du principe de la responsabilité unique (principe de responsabilité unique, S dans SOLID). Si le modèle, en plus de sa composante fonctionnelle, décrit les paramètres de conservation de l'état et traite également de sa conservation et de son chargement, alors il a évidemment trop de responsabilités. Le résultat et non le dernier avantage de Persistence Ignorance est la possibilité de remplacer l'outil de stockage et même le type de stockage lui-même au cours du processus de développement.

Model-View-Controller


Le concept MVC est si populaire dans l'environnement de développement de diverses applications, pas seulement de serveur, dans différentes langues et plates-formes que nous ne pensons plus à ce qu'il est et pourquoi il est nécessaire du tout. J'ai le plus de questions de cette abréviation est appelée «contrôleur». Du point de vue de l'organisation de la structure du code, il est bon de regrouper les actions sur le modèle. Mais le contrôleur ne doit pas du tout être une classe, il doit plutôt être un module qui inclut des méthodes pour accéder au modèle. Non seulement cela, devrait-il avoir un endroit où être? En tant que développeur qui a suivi le chemin de .NET -> Ruby -> Node.js, j'ai simplement été touché par les contrôleurs JS (ES5) qui implémentent dans le cadre d'express.js. Ayant la capacité de résoudre la tâche assignée aux contrôleurs dans un style plus fonctionnel, les développeurs, comme ensorcelés, écrivent encore et encore le «contrôleur» magique. Pourquoi un contrôleur typique est-il mauvais?

Un contrôleur typique est un ensemble de méthodes qui ne sont pas étroitement liées les unes aux autres, unies par une seule - une certaine essence du modèle; et parfois pas un seul, pire. Chaque méthode individuelle peut nécessiter des dépendances différentes. Pour l'avenir, je note que je suis partisan de la pratique de l'inversion de dépendance (Dependency Inversion, D in SOLID). Par conséquent, j'ai besoin d'initialiser ces dépendances quelque part à l'extérieur et de les transmettre au constructeur du contrôleur. Par exemple, lors de la création d'un nouveau compte, je dois envoyer des notifications au comptable, pour lequel j'ai besoin d'un service de notification, et dans d'autres méthodes, je n'en ai pas besoin:

 class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end end 

Ici, l'idée demande à être divisée en méthodes pour travailler avec le modèle en classes distinctes, et pourquoi pas?

 class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end end 

Eh bien, au lieu du contrôleur, il existe maintenant un ensemble de «fonctions» pour accéder au modèle, qui, soit dit en passant, peut également être structuré à l'aide de répertoires de système de fichiers, par exemple. Maintenant, vous devez "ouvrir" ces méthodes à l'extérieur, c'est-à-dire organiser quelque chose comme un routeur. En tant que personne tentée par toutes sortes de DSL (Domain-Specific Language), je préférerais avoir une description plus visuelle des instructions pour une application Web que des astuces en Ruby ou un autre langage à usage général pour spécifier des itinéraires:

 `HTTP GET /invoices -> return all invoices` `HTTP POST /invoices -> create new invoice` 

ou au moins

 `HTTP GET /invoices -> ./invoices/list_invoices` `HTTP POST /invoices -> ./invoices/create` 

Ceci est très similaire à un routeur typique, à la seule différence qu'il n'interagit pas avec les contrôleurs, mais directement avec les actions sur le modèle. Il est clair que si nous voulons envoyer et recevoir du JSON, nous devons nous occuper de la sérialisation et de la désérialisation des objets et bien plus encore. D'une manière ou d'une autre, nous pouvons nous débarrasser des contrôleurs, transférer une partie de leur responsabilité à la structure de répertoires et au routeur plus avancé.

Injection de dépendance


J'ai délibérément écrit un «routeur plus avancé». Pour que le routeur puisse vraiment permettre le flux d'actions sur le modèle en utilisant le mécanisme d'injection de dépendance au niveau déclaratif, cela devrait probablement être assez complexe à l'intérieur. Le schéma général de son travail devrait ressembler à ceci:



Comme vous pouvez le voir, tout mon routeur est criblé d'injection de dépendance à l'aide d'un conteneur IoC. Pourquoi est-ce même nécessaire? Le concept d '«injection de dépendance» remonte à la technique de l'inversion de dépendance, qui est conçue pour réduire la connectivité des objets en déplaçant l'initialisation des dépendances hors du champ de leur utilisation. Un exemple:

 class Repository; end #  (   ) class A def initialize @repo = Repository.new end end #  (   ) class A def initialize(repo) @repo = repo end end 

Cette approche aide grandement ceux qui utilisent le développement piloté par les tests. Dans l'exemple ci-dessus, nous pouvons facilement mettre un stub dans le constructeur au lieu de l'objet de référentiel réel correspondant à son interface, sans «pirater» le modèle d'objet. Ce n'est pas le seul bonus DI: lorsqu'elle est appliquée correctement, cette approche apportera beaucoup de magie agréable à votre application, mais d'abord. L'injection de dépendance est une approche qui vous permet d'intégrer la technique d'inversion de dépendance dans une solution architecturale complète. L'outil d'implémentation est généralement un conteneur IoC- (Inversion of Control). Il y a des tonnes de conteneurs IoC vraiment cool dans le monde Java et .NET, il y en a des dizaines. Dans JS et Ruby, malheureusement, il n'y a pas d'options appropriées pour moi. En particulier, j'ai examiné le conteneur sec (conteneur sec ). Voici à quoi ressemblerait ma classe en l'utilisant:

 class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end end 

Au lieu de l'utilisation mince du constructeur, nous chargeons la classe en introduisant nos propres dépendances, ce qui au départ nous éloigne d'un modèle propre et indépendant. Eh bien, quelque chose, et le modèle ne devrait pas du tout connaître l'IoC! Cela est vrai pour des actions comme CreateInvoice. Pour le cas donné, dans mes tests, je suis déjà obligé d'utiliser l'IoC comme quelque chose d'inaliénable. C'est totalement faux. La plupart des objets d'application ne devraient pas connaître l'existence d'IoC. Après avoir beaucoup cherché et réfléchi, j'ai esquissé mon IoC , ce qui ne serait pas si intrusif.

Enregistrement et chargement d'un modèle


L'ignorance de persistance nécessite un transformateur d'objet discret. Dans cet article, je veux dire travailler avec une base de données relationnelle, les points principaux seront vrais pour les autres types de stockages. Un convertisseur objet-relationnel - ORM (Object Relational Mapper) est utilisé comme un convertisseur similaire pour les bases de données relationnelles. Dans le monde de .NET et Java, il existe une abondance d'outils ORM vraiment puissants. Tous ont certains ou d'autres défauts mineurs auxquels vous pouvez fermer les yeux. Il n'y a pas de bonnes solutions dans JS et Ruby. Tous, d'une manière ou d'une autre, lient rigidement le modèle au cadre et forcent la déclaration d'éléments étrangers, sans parler de l'inapplicabilité de l'ignorance de la persistance. Comme dans le cas de l'IoC, j'ai pensé à implémenter ORM par moi-même, c'est la situation à Ruby. Je n'ai pas tout fait à partir de zéro, mais j'ai pris comme base une simple suite ORM, qui fournit des outils discrets pour travailler avec différents SGBD relationnels. Tout d'abord, je m'intéressais à la possibilité d'exécuter des requêtes sous forme de SQL standard, en recevant un tableau de chaînes (objets de hachage) en sortie. Il ne restait plus qu'à implémenter votre mappeur et à fournir une ignorance de la persistance. Comme je l'ai déjà mentionné, je ne voudrais pas mélanger les champs de mappage dans le modèle de domaine, donc j'implémente Mapper pour qu'il utilise un fichier de configuration distinct au format type:

 entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription end 

L'ignorance de la persistance est assez simple à implémenter en utilisant un objet externe du type Repository:

 repository.save(user) 

Mais nous irons plus loin et appliquerons le modèle d'unité de travail. Pour ce faire, vous devez mettre en évidence le concept d'une session. Une session est un objet qui existe dans le temps, au cours duquel un ensemble d'actions est effectué sur le modèle, qui sont une seule opération logique. Au cours d'une session, le chargement et la modification des objets du modèle peuvent se produire. À la fin de la session, l'état transactionnel du modèle est enregistré.
Exemple d'unité de travail:

 user = session.load(User, id: 1) plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan) session.attach(subscription) invoice = Invoice.new(subscription) session.attach(invoice) # ... # -       if Date.today.yday == 1 subscription.comment = 'New year offer' invoice.amount /= 2 end session.flush 

En conséquence, 2 instructions seront exécutées dans la base de données au lieu de 4, et les deux seront exécutées dans la même transaction.

Et puis souvenez-vous soudain des référentiels! Ici, il y a un sentiment de déjà-vu, comme avec les contrôleurs: le référentiel n'est-il pas la même entité rudimentaire? Pour l'avenir, je répondrai - oui, ça l'est. Le principal objectif du référentiel est d'empêcher la couche de logique métier d'interagir avec le stockage réel. Par exemple, dans le contexte des bases de données relationnelles, cela signifie écrire des requêtes SQL directement dans le code logique métier. Sans aucun doute, c'est une décision très raisonnable. Mais revenons au moment où nous nous sommes débarrassés du contrôleur. Du point de vue de la POO, le référentiel est essentiellement le même contrôleur - le même ensemble de méthodes, non seulement pour le traitement des demandes, mais pour travailler avec le référentiel. Le référentiel peut également être divisé en actions. Selon toutes les indications, ces actions ne différeront en aucune façon de ce que nous avons proposé à la place du contrôleur. Autrement dit, nous pouvons refuser le référentiel et le contrôleur en faveur d'une seule action unifiée!

 class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end end 

Vous avez probablement remarqué que j'utilise SQL au lieu d'une sorte de syntaxe d'objet. C'est une question de goût. Je préfère SQL car c'est un langage de requête, une sorte de DSL pour travailler avec des données. Il est clair qu'il est toujours plus facile d'écrire Plan.load (id) que le SQL correspondant, mais c'est pour les cas triviaux. Lorsqu'il s'agit de choses légèrement plus complexes, SQL devient un outil très apprécié. Parfois, vous maudissez un autre ORM en essayant de le faire fonctionner comme du SQL pur, que «j'écrirais en quelques minutes». Pour ceux qui sont dans le doute, je suggère de consulter la documentation MongoDB , où les explications sont données sous une forme similaire à SQL, ce qui est très drôle! Par conséquent, l'interface pour les requêtes dans ORM JetSet , que j'ai écrite pour mes besoins, est SQL avec des imprégnations minimales telles que «AS ENTITY». Soit dit en passant, dans la plupart des cas, je n'utilise pas d'objets de modèle, de divers DTO, etc. pour afficher des données tabulaires - j'écris simplement une requête SQL, j'obtiens un tableau d'objets de hachage et je l'affiche en vue. D'une manière ou d'une autre, peu de personnes parviennent à «faire défiler» les données volumineuses en projetant des tables associées sur un modèle. En pratique, la projection plate (vue) est plus susceptible d'être utilisée, et les produits très matures arrivent à l'étape d'optimisation lorsque des solutions plus complexes comme CQRS (Command and Query Responsibility Segregation) commencent à être utilisées.

Tout mettre ensemble


Donc ce que nous avons:

  • nous avons compris comment charger et enregistrer le modèle, nous avons également conçu une architecture approximative de l'outil de livraison Web du modèle, un certain routeur;
  • nous sommes arrivés à la conclusion que toute logique qui ne fait pas partie du domaine peut être extraite en actions (actions) au lieu de contrôleurs et de référentiels;
  • Les actions doivent prendre en charge l'injection de dépendance
  • outil décent d'injection de dépendance mis en œuvre;
  • L'ORM nécessaire est implémenté.

Il ne reste plus qu'à implémenter le même «routeur». Puisque nous nous sommes débarrassés des référentiels et des contrôleurs au profit des actions, il est évident que pour une demande, nous devrons effectuer plusieurs actions. Les actions sont autonomes et nous ne pouvons pas investir les uns dans les autres. Par conséquent, dans le cadre du framework Dandy, j'ai implémenté un routeur qui vous permet de créer des chaînes d'actions. Exemple de configuration (attention / plans):

 :receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors 

«GET / auth / plans» affiche tous les plans d'abonnement disponibles et «met en évidence» l'actuel. Les événements suivants se produisent:

  1. ": avant -> common / open_db_session" - ouverture d'une session JetSet
  2. / auth ": before -> current_user @ users / load_current_user" - charge l'utilisateur actuel (par jetons). Le résultat est enregistré dans le conteneur IoC en tant que current_user (current_user @ instruction).
  3. / auth / plans "current_plan @ plans / load_current_plan" - charge le plan actuel. Pour cela, la valeur @current_user est extraite du conteneur. Le résultat est enregistré dans le conteneur IoC en tant que current_plan (current_plan @ instruction):

     class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end end 

  4. "Plans @ plans / load_plans" - chargement d'une liste de tous les plans disponibles. Le résultat est enregistré dans le conteneur IoC en tant que plans (l'instruction plans @).
  5. ": respond <- plans / list" - ViewBuilder enregistré, par exemple JBuilder, dessine la vue 'plans / list' de type:

     json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id end 


En tant que @plans et @current_plan, les valeurs enregistrées dans les étapes précédentes sont extraites du conteneur. Dans le constructeur Action, en général, vous pouvez «commander» tout ce dont vous avez besoin, ou plutôt tout ce qui est enregistré dans le conteneur. Un lecteur attentif aura très probablement une question, mais existe-t-il une isolation de ces variables en mode «multi-utilisateurs»? Oui. Le fait est que le conteneur Hypo IoC a la capacité de définir la durée de vie des objets et, en outre, de le lier à la durée de vie des autres objets. Dans Dandy, des variables telles que @plans, @current_plan, @current_user sont liées à l'objet de demande et seront détruites au moment où la demande est terminée. Soit dit en passant, la session JetSet est également liée à la demande - une réinitialisation de son état sera également effectuée lorsque la demande Dandy sera terminée. C'est-à-dire Chaque demande a son propre contexte isolé. Hypo gouverne tout le cycle de vie de Dandy, peu importe à quel point ce jeu de mots est amusant dans la traduction littérale des noms.

Conclusions


Dans le cadre de l'architecture donnée, j'utilise le modèle objet pour décrire le sujet; J'utilise des pratiques appropriées comme l'injection de dépendance; Je peux même utiliser l'héritage. Mais, en même temps, toutes ces Actions sont essentiellement des fonctions ordinaires qui peuvent être enchaînées ensemble à un niveau déclaratif. Nous avons obtenu le backend souhaité dans un style fonctionnel, mais avec tous les avantages de l'approche objet, lorsque vous ne rencontrez pas de problèmes avec les abstractions et testez votre code. En utilisant le routeur DSL Dandy comme exemple, nous sommes libres de créer les langues nécessaires pour décrire les itinéraires et plus encore.

Conclusion


Dans le cadre de cet article, j'ai mené une sorte d'excursion sur les aspects fondamentaux de la création d'un backend tel que je le vois. Je le répète, l'article est superficiel, il n'a pas abordé de nombreux sujets importants, comme par exemple l'optimisation des performances. J'ai essayé de me concentrer uniquement sur les choses qui peuvent vraiment être utiles à la communauté en tant que matière à réflexion, et non de verser de nouveau de vide en vide, qu'est-ce que SOLID, TDD, à quoi ressemble le schéma MVC, etc. Des définitions strictes de ces termes et d'autres termes utilisés par un lecteur curieux peuvent être facilement trouvées dans le vaste réseau, sans parler des collègues de la boutique, pour qui ces abréviations font partie du discours de tous les jours. Et enfin, j'insiste, essayez de ne pas vous concentrer sur les outils dont j'avais besoin pour mettre en œuvre les problèmes posés.Ce n'est qu'une démonstration de la validité des pensées, pas de leur essence. Si cet article vous intéresse, j'écrirai un document séparé sur ces bibliothèques.

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


All Articles