Quelle est la place de la persistance dans un langage purement fonctionnel?

18

Comment le modèle d'utilisation des gestionnaires de commandes pour gérer la persistance s'intègre-t-il dans un langage purement fonctionnel, où nous voulons rendre le code lié aux IO aussi fin que possible?


Lors de l'implémentation de la conception pilotée par domaine dans un langage orienté objet, il est courant d'utiliser le modèle de commande / gestionnaire pour exécuter des changements d'état. Dans cette conception, les gestionnaires de commandes sont placés au-dessus de vos objets de domaine et sont responsables de la logique ennuyeuse liée à la persistance, comme l'utilisation de référentiels et la publication d'événements de domaine. Les gestionnaires sont la face publique de votre modèle de domaine; Le code d'application comme l'interface utilisateur appelle les gestionnaires lorsqu'il doit changer l'état des objets de domaine.

Un croquis en C #:

public class DiscardDraftDocumentCommandHandler : CommandHandler<DiscardDraftDocument>
{
    IDraftDocumentRepository _repo;
    IEventPublisher _publisher;

    public DiscardDraftCommandHandler(IDraftDocumentRepository repo, IEventPublisher publisher)
    {
        _repo = repo;
        _publisher = publisher;
    }

    public override void Handle(DiscardDraftDocument command)
    {
        var document = _repo.Get(command.DocumentId);
        document.Discard(command.UserId);
        _publisher.Publish(document.NewEvents);
    }
}

L' documentobjet de domaine est responsable de la mise en œuvre des règles métier (comme "l'utilisateur doit avoir l'autorisation de supprimer le document" ou "vous ne pouvez pas supprimer un document qui a déjà été supprimé") et de générer les événements de domaine que nous devons publier ( document.NewEventsserait être un IEnumerable<Event>et contiendrait probablement un DocumentDiscardedévénement).

Ceci est une conception agréable - il est facile à étendre (vous pouvez ajouter de nouveaux cas d'utilisation sans changer votre modèle de domaine, en ajoutant de nouveaux gestionnaires de commandes) et est agnostique quant à la persistance des objets (vous pouvez facilement échanger un référentiel NHibernate pour un Mongo référentiel ou échanger un éditeur RabbitMQ contre un éditeur EventStore), ce qui facilite le test à l'aide de contrefaçons et de simulations. Il obéit également à la séparation modèle / vue - le gestionnaire de commandes n'a aucune idée s'il est utilisé par un travail par lots, une interface graphique ou une API REST.


Dans un langage purement fonctionnel comme Haskell, vous pouvez modéliser le gestionnaire de commandes à peu près comme ceci:

newtype CommandHandler = CommandHandler {handleCommand :: Command -> IO Result)
data Result a = Success a | Failure Reason
type Reason = String

discardDraftDocumentCommandHandler = CommandHandler handle
    where handle (DiscardDraftDocument documentID userID) = do
              document <- loadDocument documentID
              let result = discard document userID :: Result [Event]
              case result of
                   Success events -> publishEvents events >> return result
                   -- in an event-sourced model, there's no extra step to save the document
                   Failure _ -> return result
          handle _ = return $ Failure "I expected a DiscardDraftDocument command"

Voici la partie que j'ai du mal à comprendre. En règle générale, il y aura une sorte de code de «présentation» qui appelle le gestionnaire de commandes, comme une interface graphique ou une API REST. Alors maintenant, nous avons deux couches dans notre programme qui doivent faire des IO - le gestionnaire de commandes et la vue - qui est un gros non-non dans Haskell.

Autant que je sache, il y a deux forces opposées ici: l'une est la séparation modèle / vue et l'autre est la nécessité de maintenir le modèle. Il doit y avoir du code d'E / S pour conserver le modèle quelque part , mais la séparation modèle / vue indique que nous ne pouvons pas le mettre dans la couche de présentation avec tous les autres codes d'E / S.

Bien sûr, dans un langage "normal", les E / S peuvent (et se produisent) n'importe où. Une bonne conception dicte que les différents types d'E / S doivent être séparés, mais le compilateur ne l'applique pas.

Donc: comment réconcilier la séparation modèle / vue avec le désir de pousser le code IO jusqu'au bord du programme, alors que le modèle doit être persistant? Comment séparer les deux différents types d'E / S , mais toujours à l'écart de tout le code pur?


Mise à jour : la prime expire dans moins de 24 heures. Je ne pense pas que l'une des réponses actuelles ait répondu à ma question. @ Le commentaire de Flame de Ptharien acid-statesemble prometteur, mais ce n'est pas une réponse et il manque de détails. Je détesterais que ces points soient perdus!

Benjamin Hodgson
la source
1
Il serait peut-être utile d'examiner la conception de diverses bibliothèques de persistance dans Haskell; en particulier, acid-statesemble être proche de ce que vous décrivez .
Ptharien's Flame
1
acid-statesemble très bien, merci pour ce lien. En termes de conception d'API, il semble toujours être lié à IO; ma question est de savoir comment un cadre de persistance s'intègre dans une architecture plus large. Connaissez-vous des applications open source qui utilisent à acid-statecôté d'une couche de présentation et réussissent à garder les deux séparées?
Benjamin Hodgson
Les monades Queryet Updatesont assez éloignées IO, en fait. Je vais essayer de donner un exemple simple dans une réponse.
Flame de Ptharien le
Au risque d'être hors sujet, pour tous les lecteurs qui utilisent le modèle Command / Handler de cette façon, je recommande vraiment de vérifier Akka.NET. Le modèle d'acteur se sent bien ici. Il y a un grand cours pour cela sur Pluralsight. (Je jure que je suis juste un fanboy, pas un bot promotionnel.)
RJB

Réponses:

6

La manière générale de séparer les composants dans Haskell consiste à utiliser des piles de transformateurs monades. J'explique cela plus en détail ci-dessous.

Imaginez que nous construisons un système qui comprend plusieurs composants à grande échelle:

  • un composant qui parle avec le disque ou la base de données (sous-modèle)
  • un composant qui fait des transformations sur notre domaine (modèle)
  • un composant qui interagit avec l'utilisateur (voir)
  • un composant qui décrit la connexion entre la vue, le modèle et le sous-modèle (contrôleur)
  • un composant qui démarre l'ensemble du système (pilote)

Nous décidons que nous devons garder ces composants faiblement couplés afin de maintenir un bon style de code.

Par conséquent, nous codons chacun de nos composants de manière polymorphe, en utilisant les différentes classes MTL pour nous guider:

  • chaque fonction du sous-modèle est de type MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState est une pure représentation d'un instantané de l'état de notre base de données ou de notre stockage
  • chaque fonction du modèle est pure
  • chaque fonction de la vue est de type MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState est une pure représentation d'un instantané de l'état de notre interface utilisateur
  • chaque fonction du contrôleur est de type MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Notez que le contrôleur a accès à la fois à l'état de la vue et à l'état du sous-modèle
  • le pilote n'a qu'une seule définition, main :: IO ()qui fait le travail presque trivial de combiner les autres composants en un seul système
    • la vue et le sous-modèle devront être levés dans le même type d'état que le contrôleur utilisant zoomou un combinateur similaire
    • le modèle est pur et peut donc être utilisé sans restriction
    • à la fin, tout vit (un type compatible avec) StateT (DataState, UIState) IO, qui est ensuite exécuté avec le contenu réel de la base de données ou du stockage à produire IO.
Flamme de Ptharien
la source
1
C'est un excellent conseil, et c'est exactement ce que je cherchais. Merci!
Benjamin Hodgson
2
Je digère cette réponse. Pourriez-vous clarifier le rôle du «sous-modèle» dans cette architecture? Comment "parler avec le disque ou la base de données" sans effectuer d'E / S? Je suis particulièrement confus à propos de ce que vous entendez par " DataStateest une pure représentation d'un instantané de l'état de notre base de données ou stockage". Vraisemblablement, vous ne voulez pas charger toute la base de données en mémoire!
Benjamin Hodgson
1
J'aimerais absolument voir vos réflexions sur une implémentation C # de cette logique. Je suppose que je ne peux pas vous soudoyer avec une survote? ;-)
RJB
1
@RJB Malheureusement, vous devrez soudoyer l'équipe de développement C # pour autoriser des types supérieurs dans le langage, car sans eux, cette architecture tombe un peu à plat.
Ptharien's Flame
4

Alors: comment réconcilier la séparation modèle / vue avec le désir de pousser le code IO jusqu'au bord du programme, alors que le modèle doit être persistant?

Le modèle doit-il être conservé? Dans de nombreux programmes, l'enregistrement du modèle est nécessaire car l'état est imprévisible, toute opération peut muter le modèle de quelque manière que ce soit, donc la seule façon de connaître l'état du modèle est d'y accéder directement.

Si, dans votre scénario, la séquence d'événements (commandes qui ont été validées et acceptées) peut toujours générer l'état, alors ce sont les événements qui doivent être persistés, pas nécessairement l'état. L'état peut toujours être généré en rejouant les événements.

Cela dit, souvent, l'état est stocké, mais juste comme un instantané / cache pour éviter de rejouer les commandes, pas comme des données de programme essentielles.

Alors maintenant, nous avons deux couches dans notre programme qui doivent faire des IO - le gestionnaire de commandes et la vue - qui est un gros non-non dans Haskell.

Une fois la commande acceptée, l'événement est communiqué à deux destinations (le stockage des événements et le système de génération de rapports) mais sur la même couche du programme.

Voir aussi Dérivation de lecture avide de
sourçage d'événements

FMJaguar
la source
2
Je suis familier avec la recherche d'événements (je l'utilise dans mon exemple ci-dessus!), Et pour éviter de couper les cheveux, je dirais quand même que la recherche d'événements est une approche du problème de la persistance. Dans tous les cas, le sourcing d'événements n'élimine pas la nécessité de charger vos objets de domaine dans le gestionnaire de commandes . Le gestionnaire de commandes ne sait pas si les objets proviennent d'un flux d'événements, d'un ORM ou d'une procédure stockée - il les obtient simplement du référentiel.
Benjamin Hodgson
1
Votre compréhension semble coupler la vue et le gestionnaire de commandes pour créer plusieurs E / S. Ma compréhension est que le gestionnaire génère l'événement et n'a plus d'intérêt. La vue dans cette instance fonctionne comme un module distinct (même si techniquement dans la même application) et n'est pas couplée au gestionnaire de commandes.
FMJaguar
1
Je pense que nous pourrions parler à contre-courant. Quand je dis «vue», je parle de toute la couche de présentation, qui peut être une API REST ou un système de contrôleur de vue de modèle. (Je suis d'accord que la vue doit être découplée du modèle dans le modèle MVC.) Je veux dire essentiellement "quels que soient les appels dans le gestionnaire de commandes".
Benjamin Hodgson
2

Vous essayez de mettre de l'espace dans votre application intensive IO pour toutes les activités non-IO; malheureusement, les applications CRUD typiques comme vous en parlez ne font guère autre chose que IO.

Je pense que vous comprenez l'amende de séparation pertinente, mais lorsque vous essayez de placer le code d'E / S persistant à un certain nombre de couches du code de présentation, le fait général de la question se trouve dans votre contrôleur quelque part que vous devriez appeler votre couche de persistance, qui peut vous sembler trop proche de votre présentation - mais ce n'est qu'une coïncidence dans ce type d'application qui n'a pas grand-chose d'autre.

La présentation et la persistance constituent essentiellement l'intégralité du type d'application que je pense que vous décrivez ici.

Si vous pensez dans votre tête à une application similaire qui contenait beaucoup de logique métier complexe et de traitement de données, je pense que vous vous trouverez en mesure d'imaginer comment cela est bien séparé de l'IO de présentation et des choses d'E / S de persistance telles que il n'a besoin de rien savoir non plus. Le problème que vous avez en ce moment est juste un problème de perception causé par la recherche d'une solution à un problème dans un type d'application qui n'a pas ce problème pour commencer.

Jimmy Hoffa
la source
1
Vous dites que les systèmes CRUD peuvent coupler persistance et présentation. Cela me semble raisonnable; mais je n'ai pas mentionné CRUD. Je pose des questions spécifiques sur DDD, où vous avez des objets métier avec des interactions complexes, une couche de persistance (gestionnaires de commandes) et une couche de présentation en plus. Comment gardez-vous les deux couches d'E / S séparées tout en conservant une enveloppe IO fine ?
Benjamin Hodgson
1
NB, le domaine que j'ai décrit dans la question pourrait être très complexe. La suppression d'un brouillon est peut-être soumise à une vérification des autorisations impliquée, ou plusieurs versions du même brouillon peuvent devoir être traitées, ou des notifications doivent être envoyées, ou l'action doit être approuvée par un autre utilisateur, ou les brouillons passent par un certain nombre de étapes du cycle de vie avant la finalisation ...
Benjamin Hodgson
2
@BenjaminHodgson Je déconseille fortement de mélanger DDD ou d'autres méthodologies de conception intrinsèquement OO dans cette situation dans votre tête, cela ne fera que confondre. Alors que oui, vous pouvez créer des objets comme des bits et des bobbles en FP pur, les approches de conception basées sur eux ne devraient pas nécessairement être votre première portée. Dans le scénario que vous décrivez, j'envisagerais comme je le mentionne ci-dessus, un contrôleur qui communique entre les deux IO et le code pur: la présentation IO entre et est demandée au contrôleur, le contrôleur transmet les choses aux sections pures et aux sections de persistance.
Jimmy Hoffa
1
@BenjaminHodgson, vous pouvez imaginer une bulle où tout votre code pur vit, avec toutes les couches et fantaisie que vous voudrez dans la conception que vous appréciez. Le point d'entrée de cette bulle va être un petit morceau que j'appelle un "contrôleur" (peut-être incorrectement) qui fait la communication entre la présentation, la persistance et les morceaux purs. De cette façon, votre persévérance ne sait rien de la présentation ou de la pureté et vice versa - et cela garde vos trucs d'E / S dans cette fine couche au-dessus de la bulle de votre système pur.
Jimmy Hoffa
2
@BenjaminHodgson cette approche des "objets intelligents" dont vous parlez est intrinsèquement une mauvaise approche pour FP, le problème avec les objets intelligents dans FP est qu'ils se couplent beaucoup trop et se généralisent beaucoup trop peu. Vous vous retrouvez avec des données et des fonctionnalités qui leur sont liées, dans lesquelles FP préfère que vos données soient couplées de manière lâche à des fonctionnalités telles que vous pouvez implémenter vos fonctions pour qu'elles soient généralisées et qu'elles fonctionneront ensuite sur plusieurs types de données. Lisez ma réponse ici: programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa
1

Autant que je puisse comprendre votre question (ce que je ne peux pas, mais pensais que je mettrais mes 2 cents), puisque vous n'avez pas nécessairement accès aux objets eux-mêmes, vous devez avoir votre propre base de données d'objets qui s'auto expire avec le temps).

Idéalement, les objets eux-mêmes peuvent être améliorés pour stocker leur état. Ainsi, lorsqu'ils sont «contournés», les différents processeurs de commande sauront avec quoi ils travaillent.

Si ce n'est pas possible, (icky icky), la seule façon est d'avoir une clé de type DB commune, que vous pouvez utiliser pour stocker les informations dans un magasin qui est configuré pour être partagé entre différentes commandes - et, espérons-le, "ouvrez" l'interface et / ou le code afin que tout autre rédacteur de commandes adopte également votre interface pour la sauvegarde et le traitement des méta-informations.

Dans le domaine des serveurs de fichiers, samba a différentes façons de stocker des choses comme les listes d'accès et les flux de données alternatifs, en fonction de ce que le système d'exploitation hôte fournit. Idéalement, samba est hébergé sur un système de fichiers qui fournit des attributs étendus sur les fichiers. Exemple 'xfs' sur 'linux' - plus de commandes copient des attributs étendus avec un fichier (par défaut, la plupart des utilitaires sur linux "ont grandi" sans penser à des attributs étendus).

Une solution alternative - qui fonctionne pour plusieurs processus samba de différents utilisateurs opérant sur des fichiers communs (objets), est que si le système de fichiers ne prend pas en charge l'attachement de la ressource directement au fichier comme avec les attributs étendus, utilise un module qui implémente une couche de système de fichiers virtuel pour émuler des attributs étendus pour les processus samba. Seul samba le sait, mais il a l'avantage de fonctionner lorsque le format d'objet ne le prend pas en charge, mais fonctionne toujours avec divers utilisateurs de samba (cf. processeurs de commande) qui effectuent un certain travail sur le fichier en fonction de son état précédent. Il stockera les méta-informations dans une base de données commune pour le système de fichiers qui aide à contrôler la taille de la base de données (et ne

Il peut ne pas vous être utile si vous aviez besoin de plus d'informations spécifiques à l'implémentation avec laquelle vous travaillez, mais conceptuellement, la même théorie pourrait être appliquée aux deux ensembles de problèmes. Donc, si vous cherchiez des algorithmes et des méthodes pour faire ce que vous voulez, cela pourrait vous aider. Si vous aviez besoin de connaissances plus spécifiques dans un cadre spécifique, alors peut-être pas si utile ... ;-)

BTW - la raison pour laquelle je mentionne «expirant automatiquement» - est que ce n'est pas clair si vous savez quels objets sont là-bas et combien de temps ils persistent. Si vous n'avez aucun moyen direct de savoir quand un objet est supprimé, vous devez couper votre propre métaDB pour l'empêcher de se remplir d'anciennes ou anciennes métadonnées pour lesquelles les utilisateurs ont depuis longtemps supprimé les objets.

Si vous savez quand les objets sont arrivés à expiration / supprimés, vous êtes en avance sur le jeu et pouvez l'expirer en même temps de votre métaDB, mais il n'était pas clair si vous aviez cette option.

À votre santé!

Astara
la source
1
Pour moi, cela semble être une réponse à une question totalement différente. Je cherchais des conseils concernant l'architecture dans la programmation purement fonctionnelle, dans le contexte de la conception pilotée par domaine. Pourriez-vous clarifier vos points s'il vous plaît?
Benjamin Hodgson
Vous vous interrogez sur la persistance des données dans un paradigme de programmation purement fonctionnel. Citant Wikipedia: "Purement fonctionnel est un terme informatique utilisé pour décrire des algorithmes, des structures de données ou des langages de programmation qui excluent les modifications destructives (mises à jour) d'entités dans l'environnement d'exécution du programme." ==== Par définition, la persistance des données n'est pas pertinente et n'a aucune utilité pour quelque chose qui ne modifie aucune donnée. À strictement parler, il n'y a pas de réponse à votre question. J'essayais une interprétation plus lâche de ce que vous avez écrit.
Astara