web-dev-qa-db-fra.com

CQRS Event Sourcing: valider l'unicité du nom d'utilisateur

Prenons un exemple simple "Enregistrement de compte", voici le flux:

  • Site Web de visite de l'utilisateur
  • Cliquez sur le bouton "S'inscrire" et remplissez le formulaire, cliquez sur le bouton "Enregistrer"
  • Contrôleur MVC: valider l'unicité du nom d'utilisateur en lisant dans ReadModel
  • RegisterCommand: Validez à nouveau l'unicité de UserName (voici la question)

Bien sûr, nous pouvons valider l'unicité de UserName en lisant dans ReadModel dans le contrôleur MVC pour améliorer les performances et l'expérience utilisateur. Cependant, nous devons encore valider l'unicité dans RegisterCommand , et évidemment, nous ne devons PAS accéder à ReadModel dans les commandes.

Si nous n'utilisons pas Event Sourcing, nous pouvons interroger le modèle de domaine, ce n'est donc pas un problème. Mais si nous utilisons Event Sourcing, nous ne pouvons pas interroger le modèle de domaine, donc comment pouvons-nous valider l'unicité de UserName dans RegisterCommand?

Remarque: La classe utilisateur a une propriété Id et UserName n'est pas la propriété clé de la classe User. Nous ne pouvons obtenir l'objet de domaine que par identifiant lors de l'utilisation du sourcing d'événements.

BTW: Dans l'exigence, si le nom d'utilisateur entré est déjà pris, le site Web devrait afficher le message d'erreur "Désolé, le nom d'utilisateur XXX n'est pas disponible" pour le visiteur. Il n'est pas acceptable d'afficher un message, par exemple, "Nous créons votre compte, veuillez patienter, nous vous enverrons le résultat de l'inscription par e-mail plus tard", au visiteur.

Des idées? Merci beaucoup!

[MISE À JOUR]

Un exemple plus complexe:

Exigence:

Lors de la commande, le système doit vérifier l'historique des commandes du client, s'il est un client précieux (si le client a passé au moins 10 commandes par mois au cours de la dernière année, il est précieux), nous faisons 10% de réduction sur la commande.

Implémentation:

Nous créons PlaceOrderCommand, et dans la commande, nous devons interroger l'historique des commandes pour voir si le client est précieux. Mais comment pouvons-nous faire cela? Nous ne devons pas accéder à ReadModel dans la commande! Comme Mikael dit , nous pouvons utiliser des commandes de compensation dans l'exemple d'enregistrement de compte, mais si nous utilisons également cela dans cet exemple de commande, ce serait trop complexe et le code pourrait être trop difficile à maintenir.

71
Mouhong Lin

Si vous validez le nom d'utilisateur à l'aide du modèle de lecture avant d'envoyer la commande, nous parlons d'une fenêtre de conditions de concurrence critique de quelques centaines de millisecondes où une véritable situation de concurrence peut se produire, qui dans mon système n'est pas gérée. Il est tout simplement trop improbable de se produire par rapport au coût de son traitement.

Cependant, si vous sentez que vous devez le gérer pour une raison quelconque ou si vous sentez simplement que vous voulez savoir comment maîtriser un tel cas, voici une façon:

Vous ne devez pas accéder au modèle de lecture à partir du gestionnaire de commandes ni du domaine lors de l'utilisation du sourcing d'événements. Toutefois, vous pouvez utiliser un service de domaine qui écouterait l'événement UserRegistered dans lequel vous accédez à nouveau au modèle de lecture et vérifiez si le nom d'utilisateur n'est toujours pas un doublon. Bien sûr, vous devez utiliser le UserGuid ici ainsi que votre modèle de lecture peut avoir été mis à jour avec l'utilisateur que vous venez de créer. Si un doublon est trouvé, vous avez la possibilité d'envoyer des commandes de compensation telles que la modification du nom d'utilisateur et la notification à l'utilisateur que le nom d'utilisateur a été pris.

C'est une approche du problème.

Comme vous pouvez probablement le voir, il n'est pas possible de le faire de manière synchrone requête-réponse. Pour résoudre cela, nous utilisons SignalR pour mettre à jour l'interface utilisateur chaque fois qu'il y a quelque chose que nous voulons pousser vers le client (s'ils sont toujours connectés, c'est-à-dire). Ce que nous faisons, c'est que nous laissons le client Web s'abonner à des événements qui contiennent des informations utiles pour que le client puisse les voir immédiatement.

Mise à jour

Pour le cas le plus complexe:

Je dirais que le placement de commande est moins complexe, car vous pouvez utiliser le modèle de lecture pour savoir si le client est précieux avant d'envoyer la commande. En fait, vous pouvez demander cela lorsque vous chargez le formulaire de commande, car vous voulez probablement montrer au client qu'il obtiendra les 10% de réduction avant de passer la commande. Ajoutez simplement une remise à la PlaceOrderCommand et peut-être une raison de la remise, afin que vous puissiez suivre pourquoi vous réduisez les bénéfices.

Mais là encore, si vous avez vraiment besoin de calculer la remise après que la commande a été passée pour une raison quelconque, utilisez à nouveau un service de domaine qui écouterait OrderPlacedEvent et la commande "compensating" dans ce cas serait probablement un DiscountOrderCommand ou quelque chose. Cette commande affecterait la racine Order Aggregate et les informations pourraient être propagées à vos modèles de lecture.

Pour le cas du nom d'utilisateur en double:

Vous pouvez envoyer un ChangeUsernameCommand comme commande de compensation à partir du service de domaine. Ou même quelque chose de plus spécifique, qui décrirait la raison pour laquelle le nom d'utilisateur a changé, ce qui pourrait également entraîner la création d'un événement auquel le client Web pourrait s'abonner afin que vous puissiez laisser l'utilisateur voir que le nom d'utilisateur était un doublon.

Dans le contexte du service de domaine, je dirais que vous avez également la possibilité d'utiliser d'autres moyens pour informer l'utilisateur, comme l'envoi d'un e-mail qui pourrait être utile car vous ne pouvez pas savoir si l'utilisateur est toujours connecté. Peut-être que cette fonctionnalité de notification pourrait être déclenchée par le même événement auquel le client Web est abonné.

En ce qui concerne SignalR, j'utilise un concentrateur SignalR auquel les utilisateurs se connectent lorsqu'ils chargent un certain formulaire. J'utilise la fonctionnalité Groupe SignalR qui me permet de créer un groupe dont je nomme la valeur du Guid que j'envoie dans la commande. Cela pourrait être le userGuid dans votre cas. Ensuite, j'ai Eventhandler qui souscrit à des événements qui pourraient être utiles pour le client et lorsqu'un événement arrive, je peux invoquer une fonction javascript sur tous les clients du groupe SignalR (qui dans ce cas ne serait que le seul client créant le nom d'utilisateur en double dans votre Cas). Je sais que cela semble complexe, mais ce n'est vraiment pas le cas. J'ai tout installé en un après-midi. Il existe d'excellents documents et exemples sur la page SignalR Github.

36
Mikael Östberg

Je pense que vous n'avez pas encore changé de mentalité vers cohérence éventuelle et la nature de la recherche d'événements. J'ai eu le même problème. Plus précisément, j'ai refusé d'accepter que vous fassiez confiance aux commandes du client qui, à l'aide de votre exemple, disent "Passez cette commande avec 10% de réduction" sans que le domaine ne valide que la remise doit être effectuée. Une chose qui m'a vraiment frappé chez moi était quelque chose que Udi lui-même m'a dit (vérifiez les commentaires de la réponse acceptée).

Fondamentalement, j'ai réalisé qu'il n'y avait aucune raison de ne pas faire confiance au client; tout le côté lecture a été produit à partir du modèle de domaine, il n'y a donc aucune raison de ne pas accepter les commandes. Tout ce qui, dans le côté lecture, indique que le client a droit à une remise a été ajouté par le domaine.

BTW: Dans l'exigence, si le nom d'utilisateur entré est déjà pris, le site Web devrait afficher le message d'erreur "Désolé, le nom d'utilisateur XXX n'est pas disponible" pour le visiteur. Il n'est pas acceptable d'afficher un message, par exemple, "Nous créons votre compte, veuillez patienter, nous vous enverrons le résultat de l'inscription par e-mail plus tard", au visiteur.

Si vous allez adopter le sourcing d'événements et la cohérence éventuelle, vous devrez accepter que parfois il ne sera pas possible d'afficher des messages d'erreur instantanément après avoir soumis une commande. Avec l'exemple de nom d'utilisateur unique, les chances que cela se produise sont si minces (étant donné que vous vérifiez le côté lecture avant d'envoyer la commande), cela ne vaut pas la peine de s'inquiéter trop, mais une notification ultérieure devrait être envoyée pour ce scénario, ou peut-être demander les pour un nom d'utilisateur différent lors de leur prochaine connexion. La grande chose au sujet de ces scénarios est que cela vous fait réfléchir sur la valeur commerciale et ce qui est vraiment important.

MISE À JOUR: octobre 2015

Je voulais juste ajouter qu'en réalité, lorsque des sites Web destinés au public sont concernés - indiquer qu'un e-mail est déjà pris est en fait contraire aux meilleures pratiques de sécurité. Au lieu de cela, l'enregistrement devrait avoir réussi à informer l'utilisateur qu'un e-mail de vérification a été envoyé, mais dans le cas où le nom d'utilisateur existe, l'e-mail doit l'en informer et l'inviter à se connecter ou à réinitialiser son mot de passe. Bien que cela ne fonctionne que lorsque vous utilisez des adresses e-mail comme nom d'utilisateur, ce qui, à mon avis, est conseillé pour cette raison.

23
David Masters

Il n'y a rien de mal à créer des modèles de lecture immédiatement cohérents (par exemple, pas sur un réseau distribué) qui sont mis à jour dans la même transaction que la commande.

La cohérence des modèles de lecture sur un réseau distribué permet de prendre en charge la mise à l'échelle du modèle de lecture pour les systèmes de lecture lourds. Mais il n'y a rien à dire que vous ne pouvez pas avoir un modèle de lecture spécifique au domaine qui soit immédiatement cohérent.

Le modèle de lecture immédiatement cohérent n'est utilisé que pour vérifier et recevoir des données avant d'émettre une commande (c'est vraiment un service pour la commande), vous ne devez jamais l'utiliser pour afficher directement les données lues à un utilisateur (c'est-à-dire à partir d'une demande Web GET ou similaire ). Utilisez pour cela des modèles de lecture éventuellement cohérents et évolutifs.

11
Gaz_Edge

Comme beaucoup d'autres lors de la mise en œuvre d'un système basé sur des événements, nous avons rencontré le problème d'unicité.

Au début, j'étais partisan de laisser le client accéder au côté requête avant d'envoyer une commande afin de savoir si un nom d'utilisateur est unique ou non. Mais je me suis rendu compte qu'avoir un back-end qui n'a aucune validation sur l'unicité est une mauvaise idée. Pourquoi imposer quoi que ce soit quand il est possible de publier une commande qui corromprait le système? Un back-end devrait valider toutes ses entrées, sinon vous êtes ouvert pour des données incohérentes.

Nous avons créé un index du côté des commandes. Par exemple, dans le cas simple d'un nom d'utilisateur qui doit être unique, créez simplement un UserIndex avec un champ de nom d'utilisateur. Maintenant, le côté commande peut vérifier si un nom d'utilisateur est déjà dans le système ou non. Une fois la commande exécutée, il est sûr de stocker le nouveau nom d'utilisateur dans l'index.

Quelque chose comme ça pourrait également fonctionner pour le problème de remise de commande.

Les avantages sont que votre back-end de commande valide correctement toutes les entrées afin qu'aucune donnée incohérente ne puisse être stockée.

Un inconvénient peut être que vous avez besoin d'une requête supplémentaire pour chaque contrainte d'unicité et que vous appliquez une complexité supplémentaire.

6
Jonas Geiregat

Je pense que dans de tels cas, nous pouvons utiliser un mécanisme comme le "verrouillage consultatif avec expiration".

Exemple d'exécution:

  • Vérifier que le nom d'utilisateur existe ou non dans le modèle de lecture cohérent éventuellement
  • S'il n'existe pas; en utilisant un redis-couchbase comme le stockage de valeurs-clés ou le cache; essayez de pousser le nom d'utilisateur comme champ clé avec une certaine expiration.
  • En cas de succès; puis augmentez userRegisteredEvent.
  • Si l'un des noms d'utilisateur existe dans le modèle de lecture ou dans la mémoire cache, informez le visiteur que le nom d'utilisateur a été pris.

Même vous pouvez utiliser une base de données SQL; insérer le nom d'utilisateur comme clé primaire d'une table de verrouillage; puis un travail planifié peut gérer les expirations.

5
Safak Ulusoy

Concernant l'unicité, j'ai implémenté ce qui suit:

  • Une première commande comme "StartUserRegistration". UserAggregate serait créé, peu importe si l'utilisateur est unique ou non, mais avec le statut RegistrationRequested.

  • Sur "UserRegistrationStarted", un message asynchrone serait envoyé à un service sans état "UsernamesRegistry". serait quelque chose comme "RegisterName".

  • Le service essaierait de mettre à jour (pas de requêtes, "ne demande pas") une table qui inclurait une contrainte unique.

  • En cas de succès, le service répondrait avec un autre message (de manière asynchrone), avec une sorte d'autorisation "UsernameRegistration", indiquant que le nom d'utilisateur a été enregistré avec succès. Vous pouvez inclure certains requestId pour garder une trace en cas de compétence simultanée (peu probable).

  • L'émetteur du message ci-dessus dispose désormais d'une autorisation confirmant que le nom a été enregistré par lui-même. Sinon, marquez comme jeté.

Emballer:

  • Cette approche n'implique aucune requête.

  • L'inscription des utilisateurs sera toujours créée sans validation.

  • Le processus de confirmation impliquerait deux messages asynchrones et une insertion de base de données. La table ne fait pas partie d'un modèle de lecture, mais d'un service.

  • Enfin, une commande asynchrone pour confirmer que l'utilisateur est valide.

  • À ce stade, un dénormalisateur peut réagir à un événement UserRegistrationConfirmed et créer un modèle de lecture pour l'utilisateur.

3
Daniel Vasquez

Avez-vous envisagé d'utiliser un cache "fonctionnel" comme une sorte de RSVP? C'est difficile à expliquer car cela fonctionne dans un peu de cycle, mais en gros, lorsqu'un nouveau nom d'utilisateur est "revendiqué" (c'est-à-dire que la commande a été émise pour le créer), vous placez le nom d'utilisateur dans le cache avec une courte expiration ( suffisamment longtemps pour prendre en compte une autre demande passant par la file d'attente et dénormalisée dans le modèle de lecture). S'il s'agit d'une seule instance de service, alors en mémoire fonctionnerait probablement, sinon centralisez-la avec Redis ou quelque chose.

Ensuite, pendant que le prochain utilisateur remplit le formulaire (en supposant qu'il existe un frontal), vous vérifiez de manière asynchrone le modèle de lecture pour la disponibilité du nom d'utilisateur et alertez l'utilisateur s'il est déjà pris. Lorsque la commande est soumise, vous vérifiez le cache (pas le modèle de lecture) afin de valider la demande avant d'accepter la commande (avant de retourner 202); si le nom est dans le cache, n'acceptez pas la commande, sinon, vous l'ajoutez au cache; si l'ajout échoue (clé en double parce qu'un autre processus vous y bat), alors supposez que le nom est pris - puis répondez au client de manière appropriée. Entre les deux choses, je ne pense pas qu'il y aura beaucoup de possibilité de collision.

S'il n'y a pas de frontal, vous pouvez ignorer la recherche asynchrone ou au moins demander à votre API de fournir le point de terminaison pour la rechercher. De toute façon, vous ne devriez pas autoriser le client à parler directement au modèle de commande, et placer une API devant lui vous permettrait d'avoir l'API pour agir en tant que médiateur entre la commande et les hôtes de lecture.

2
Sinaesthetic

Il me semble que peut-être l'ensemble est faux ici.

En termes généraux, si vous devez garantir que la valeur Z appartenant à Y est unique dans l'ensemble X, utilisez X comme agrégat. Après tout, X est l'endroit où l'invariant existe réellement (un seul Z peut être dans X).

En d'autres termes, votre invariant est qu'un nom d'utilisateur ne peut apparaître qu'une seule fois dans la portée de tous les utilisateurs de votre application (ou pourrait être une portée différente, comme dans une organisation, etc.) Si vous avez un ensemble "ApplicationUsers" et envoyez la commande "RegisterUser" à cela, alors vous devriez être en mesure d'avoir ce dont vous avez besoin afin de vous assurer que la commande est valide avant de stocker l'événement "UserRegistered". (Et, bien sûr, vous pouvez ensuite utiliser cet événement pour créer les projections dont vous avez besoin pour effectuer des opérations telles que l'authentification de l'utilisateur sans avoir à charger l'intégralité de l'agrégat "ApplicationUsers".

1
John Wilger