Come si inserisce la persistenza in un linguaggio puramente funzionale?


18

In che modo il modello di utilizzo dei gestori dei comandi per gestire la persistenza si adatta a un linguaggio puramente funzionale, dove vogliamo rendere il codice relativo all'IO il più sottile possibile?


Quando si implementa la progettazione guidata dal dominio in un linguaggio orientato agli oggetti, è comune utilizzare il modello Command / Handler per eseguire i cambiamenti di stato. In questo progetto, i gestori dei comandi si trovano in cima agli oggetti del dominio e sono responsabili della noiosa logica relativa alla persistenza come l'utilizzo di repository e la pubblicazione di eventi di dominio. I gestori sono il volto pubblico del tuo modello di dominio; il codice dell'applicazione come l'interfaccia utente chiama i gestori quando deve cambiare lo stato degli oggetti di dominio.

Uno schizzo in 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' documentoggetto di dominio è responsabile per l'attuazione delle regole di business (come "l'utente deve avere il permesso di scartare il documento" o "non si può eliminare un documento che è già stato scartato") e per la generazione degli eventi dominio abbiamo bisogno di pubblicare ( document.NewEventssarebbe essere un IEnumerable<Event>e conterrebbe probabilmente un DocumentDiscardedevento).

Questo è un bel design - è facile da estendere (puoi aggiungere nuovi casi d'uso senza cambiare il tuo modello di dominio, aggiungendo nuovi gestori di comandi) ed è agnostico sul modo in cui gli oggetti sono persistenti (puoi facilmente scambiare un repository NHibernate per un Mongo repository o scambia un editore RabbitMQ con un editore EventStore) che semplifica il test usando falsi e beffe. Rispetta anche la separazione modello / vista: il gestore comandi non ha idea se viene utilizzato da un processo batch, una GUI o un'API REST.


In un linguaggio puramente funzionale come Haskell, potresti modellare il gestore dei comandi più o meno in questo modo:

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"

Ecco la parte che faccio fatica a capire. In genere, ci sarà una sorta di codice 'presentazione' che chiama nel gestore dei comandi, come una GUI o un'API REST. Quindi ora abbiamo due livelli nel nostro programma che devono fare IO - il gestore dei comandi e la vista - che è un grande no-no in Haskell.

Per quanto ne so, ci sono due forze opposte qui: una è la separazione modello / vista e l'altra è la necessità di persistere nel modello. È necessario un codice IO per rendere persistente il modello da qualche parte , ma la separazione modello / vista afferma che non possiamo inserirlo nel livello di presentazione con tutto l'altro codice IO.

Naturalmente, in un linguaggio "normale", l'IO può (e fa) accadere ovunque. Una buona progettazione impone che i diversi tipi di I / O siano tenuti separati, ma il compilatore non lo impone.

Quindi: come conciliare la separazione modello / vista con il desiderio di spingere il codice IO fino al limite del programma, quando il modello deve essere persistito? Come manteniamo separati i due diversi tipi di IO , ma ancora lontani da tutto il codice puro?


Aggiornamento : la taglia scade in meno di 24 ore. Non credo che nessuna delle risposte attuali abbia risposto alla mia domanda. Il commento di @ Ptharien Flame acid-statesembra promettente, ma non è una risposta e manca di dettagli. Odio che questi punti vadano sprecati!


1
Forse sarebbe utile esaminare la progettazione di varie librerie di persistenza in Haskell; in particolare, acid-statesembra essere vicino a quello che stai descrivendo .
Ptharien's Flame,

1
acid-statesembra abbastanza bello, grazie per quel link. In termini di progettazione API sembra essere ancora legato IO; la mia domanda è su come un framework di persistenza si adatta a un'architettura più ampia. Sei a conoscenza di applicazioni open source che utilizzano acid-stateinsieme a un livello di presentazione e riescono a mantenere separati i due?
Benjamin Hodgson,

Le monadi Querye Updatesono abbastanza lontane da IO, in realtà. Proverò a dare un semplice esempio in una risposta.
Ptharien's Flame

A rischio di essere fuori tema, per tutti i lettori che usano il modello Command / Handler in questo modo, consiglio vivamente di dare un'occhiata a Akka.NET. Il modello dell'attore sembra una buona scelta qui. C'è un grande corso per questo su Pluralsight. (Giuro che sono solo un fanboy, non un bot promozionale.)
RJB

Risposte:


6

Il modo generale di separare i componenti in Haskell è attraverso pile di trasformatori monade. Lo spiego più in dettaglio di seguito.

Immagina di costruire un sistema con diversi componenti su larga scala:

  • un componente che comunica con il disco o il database (sottomodella)
  • un componente che esegue trasformazioni nel nostro dominio (modello)
  • un componente che interagisce con l'utente (visualizza)
  • un componente che descrive la connessione tra vista, modello e sottomodello (controller)
  • un componente che avvia l'intero sistema (driver)

Decidiamo che dobbiamo mantenere questi componenti liberamente accoppiati per mantenere un buon stile di codice.

Pertanto codifichiamo polimorficamente ciascuno dei nostri componenti, utilizzando le varie classi MTL per guidarci:

  • ogni funzione nel sottomodel è di tipo MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState è una pura rappresentazione di un'istantanea dello stato del nostro database o archivio
  • ogni funzione nel modello è pura
  • ogni funzione nella vista è di tipo MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState è una pura rappresentazione di un'istantanea dello stato della nostra interfaccia utente
  • ogni funzione nel controller è di tipo MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Si noti che il controller ha accesso sia allo stato della vista sia allo stato del modello secondario
  • il driver ha solo una definizione, main :: IO ()che fa il lavoro quasi banale di combinare gli altri componenti in un sistema
    • la vista e il sottomodello dovranno essere portati nello stesso tipo di stato del controller zoomo di un combinatore simile
    • il modello è puro e quindi può essere utilizzato senza restrizioni
    • alla fine, tutto vive (un tipo compatibile con) StateT (DataState, UIState) IO, che viene quindi eseguito con i contenuti effettivi del database o dell'archivio da produrre IO.

1
Questo è un consiglio eccellente, ed è esattamente quello che stavo cercando. Grazie!
Benjamin Hodgson,

2
Sto digerendo questa risposta. Per favore, potresti chiarire il ruolo del "sottomodello" in questa architettura? In che modo "parla con il disco o il database" senza eseguire IO? Sono particolarmente confuso su ciò che intendi con " DataStateè una pura rappresentazione di un'istantanea dello stato del nostro database o della nostra memoria". Presumibilmente non intendi caricare l'intero database in memoria!
Benjamin Hodgson,

1
Mi piacerebbe assolutamente vedere i tuoi pensieri su un'implementazione C # di questa logica. Non pensi di poterti corrompere con un voto? ;-)
RJB

1
@RJB Sfortunatamente, dovresti corrompere il team di sviluppo di C # per consentire tipi più alti nella lingua, perché senza di loro questa architettura è un po 'piatta.
Ptharien's Flame,

4

Quindi: come conciliare la separazione modello / vista con il desiderio di spingere il codice IO fino al limite del programma, quando il modello deve essere persistito?

Il modello deve essere persistito? In molti programmi, è necessario salvare il modello perché lo stato è imprevedibile, qualsiasi operazione potrebbe mutare il modello in alcun modo, quindi l'unico modo per conoscere lo stato del modello è accedervi direttamente.

Se, nel tuo scenario, la sequenza di eventi (comandi che sono stati validati e accettati) può sempre generare lo stato, allora sono gli eventi che devono essere mantenuti, non necessariamente lo stato. Lo stato può sempre essere generato riproducendo gli eventi.

Detto questo, spesso lo stato viene archiviato, ma proprio come un'istantanea / cache per evitare di riprodurre i comandi, non come dati essenziali del programma.

Quindi ora abbiamo due livelli nel nostro programma che devono fare IO - il gestore dei comandi e la vista - che è un grande no-no in Haskell.

Dopo che il comando è stato accettato, l'evento viene comunicato a due destinazioni (l'archiviazione dell'evento e il sistema di reportistica) ma allo stesso livello del programma.

Vedere anche Derivazione di lettura desiderosa di
sourcing di eventi


2
Ho familiarità con il sourcing di eventi (lo sto usando nel mio esempio sopra!), E per evitare di spaccare i capelli direi ancora che il sourcing di eventi è un approccio al problema della persistenza. In ogni caso, il sourcing di eventi non elimina la necessità di caricare gli oggetti del dominio nel gestore comandi . Il gestore dei comandi non sa se gli oggetti provengono da un flusso di eventi, da un ORM o da una procedura memorizzata, ma semplicemente li ottiene dal repository.
Benjamin Hodgson

1
La tua comprensione sembra accoppiare la vista e il gestore comandi per creare IO multipli. La mia comprensione è che il gestore genera l'evento e non ha più interesse. La vista in questa istanza funziona come un modulo separato (anche se tecnicamente nella stessa applicazione) e non è accoppiata al gestore comandi.
FMJaguar

1
Penso che potremmo parlare a scopi trasversali. Quando dico "view", sto parlando dell'intero livello di presentazione, che può essere un'API REST o un sistema di controller della vista modello. (Concordo sul fatto che la vista debba essere disaccoppiata dal modello nel modello MVC.) In pratica intendo "qualunque cosa si chiami nel gestore dei comandi".
Benjamin Hodgson

2

Stai cercando di mettere spazio nella tua applicazione IO intensiva per tutte le attività non IO; purtroppo le tipiche app CRUD di cui parli fanno ben poco meno di IO.

Penso che tu capisca bene la separazione pertinente, ma dove stai cercando di posizionare il codice IO di persistenza ad un certo numero di strati di distanza dal codice di presentazione, il fatto generale della questione è nel tuo controller da qualche parte che dovresti chiamare al tuo livello di persistenza, che potrebbe sembrare troppo vicino alla tua presentazione, ma questa è solo una coincidenza in quel tipo di app ha poco altro.

La presentazione e la persistenza costituiscono sostanzialmente l'insieme del tipo di app che penso tu stia descrivendo qui.

Se pensi nella tua testa a un'applicazione simile che conteneva molte logiche aziendali complesse ed elaborazione dei dati, penso che ti troverai in grado di immaginare come ciò sia ben separato dall'IO di presentazione e da cose di IO di persistenza tali che non ha bisogno di sapere nulla di entrambi. Il problema che hai in questo momento è solo uno percettivo causato dal tentativo di vedere una soluzione a un problema in un tipo di applicazione che non ha quel problema per cominciare.


1
Stai dicendo che va bene per i sistemi CRUD accoppiare persistenza e presentazione. Questo mi sembra ragionevole; tuttavia non ho menzionato CRUD. Sto specificatamente chiedendo informazioni su DDD, dove si trovano oggetti business con interazioni complesse, un livello di persistenza (gestori di comandi) e un livello di presentazione. Come si mantengono separati i due layer IO mantenendo un IO wrapper sottile ?
Benjamin Hodgson,

1
NB, il dominio che ho descritto nella domanda potrebbe essere molto complesso. Forse l'eliminazione di una bozza di documento è soggetta al controllo di alcune autorizzazioni coinvolte, oppure potrebbe essere necessario gestire più versioni della stessa bozza, oppure è necessario inviare notifiche o l'azione deve essere approvata da un altro utente o le bozze passano attraverso una serie di fasi del ciclo di vita prima della finalizzazione ...
Benjamin Hodgson

2
@BenjaminHodgson Consiglierei vivamente di non mescolare DDD o altre metodologie di progettazione intrinsecamente OO in questa situazione nella tua testa, ti confonderà. Mentre sì, puoi creare oggetti come bit e bobine in puro FP, gli approcci di progettazione basati su di essi non dovrebbero necessariamente essere la tua prima portata. Nello scenario che descriveresti immaginerei, come ho detto sopra, un controller che comunica tra i due IO e il codice puro: la presentazione IO entra e viene richiesta dal controller, il controller passa le cose alle sezioni pure e alle sezioni di persistenza.
Jimmy Hoffa,

1
@BenjaminHodgson puoi immaginare una bolla in cui vive tutto il tuo codice puro, con tutti gli strati e la fantasia che potresti desiderare in qualsiasi disegno apprezzi. Il punto di ingresso per questa bolla sarà un piccolo pezzo che sto chiamando un "controller" (forse in modo errato) che fa la comunicazione tra la presentazione, la persistenza e i pezzi puri. In questo modo la tua persistenza non sa nulla di presentazione o pura e viceversa - e questo mantiene le tue cose IO in questo sottile strato sopra la bolla del tuo sistema puro.
Jimmy Hoffa,

2
@BenjaminHodgson questo approccio di "oggetti intelligenti" di cui parli è intrinsecamente un cattivo approccio per FP, il problema con gli oggetti intelligenti in FP è che si accoppiano troppo e generalizzano troppo poco. Finisci con i dati e le funzionalità ad esso collegate, in cui FP preferisce che i tuoi dati abbiano un accoppiamento libero con la funzionalità in modo tale che tu possa implementare le tue funzioni per essere generalizzate e quindi funzioneranno su più tipi di dati. Leggi qui la mia risposta: programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa

1

Per quanto riesco a capire la tua domanda (che non posso, ma ho pensato di aggiungere 2 centesimi), dato che non hai necessariamente accesso agli oggetti stessi, devi avere il tuo database di oggetti che si auto- scade nel tempo).

Idealmente gli oggetti stessi possono essere migliorati per memorizzare il loro stato in modo che quando vengono "passati", diversi processori di comandi sapranno con cosa stanno lavorando.

Se ciò non è possibile (icky icky), l'unico modo è avere una chiave simile a un DB comune, che puoi usare per archiviare le informazioni in un negozio che è configurato per essere condivisibile tra comandi diversi e, si spera, "apri" l'interfaccia e / o il codice in modo che qualsiasi altro autore di comandi adotti anche la tua interfaccia sul salvataggio e l'elaborazione delle meta-informazioni.

Nell'area dei file server samba ha diversi modi di memorizzare cose come elenchi di accesso e flussi di dati alternativi, a seconda di ciò che fornisce il sistema operativo host. Idealmente, samba è ospitato su un file system fornisce attributi estesi sui file. Esempio 'xfs' su 'linux' - più comandi stanno copiando gli attributi estesi insieme a un file (per impostazione predefinita, la maggior parte dei programmi di utilità su Linux "sono cresciuti" senza pensare come attributi estesi).

Una soluzione alternativa - che funziona per più processi samba di diversi utenti che operano su file (oggetti) comuni, è che se il file system non supporta il collegamento diretto della risorsa al file come con gli attributi estesi, sta usando un modulo che implementa un livello di file system virtuale per emulare attributi estesi per i processi di samba. Solo samba lo sa, ma ha il vantaggio di funzionare quando il formato dell'oggetto non lo supporta, ma funziona ancora con diversi utenti di samba (vedi processori di comando) che fanno un po 'di lavoro sul file in base al suo stato precedente. Memorizzerà le meta informazioni in un database comune per il file system che aiuta a controllare le dimensioni del database (e non

Potrebbe non esserti utile se avessi bisogno di maggiori informazioni specifiche sull'implementazione con cui stai lavorando, ma concettualmente, la stessa teoria potrebbe essere applicata ad entrambi i set di problemi. Quindi, se stavi cercando algoritmi e metodi per fare ciò che desideri, ciò potrebbe aiutarti. Se avessi bisogno di conoscenze più specifiche in un determinato contesto, forse non è così utile ... ;-)

A proposito, il motivo per cui menziono 'auto-scadenza' - è che non è chiaro se sai quali oggetti sono là fuori e per quanto tempo persistono. Se non hai modo diretto di sapere quando un oggetto viene eliminato, dovresti tagliare il tuo metaDB per evitare che si riempia di meta info vecchie o antiche per le quali gli utenti hanno da tempo cancellato gli oggetti.

Se sai quando gli oggetti sono scaduti / eliminati, allora sei in vantaggio sul gioco e puoi espellerlo dal tuo metaDB allo stesso tempo, ma non era chiaro se avessi quell'opzione.

Saluti!


1
Per me, questa sembra una risposta a una domanda totalmente diversa. Stavo cercando consigli sull'architettura nella programmazione puramente funzionale, nel contesto della progettazione guidata dal dominio. Potresti chiarire i tuoi punti per favore?
Benjamin Hodgson,

Stai chiedendo informazioni sulla persistenza dei dati in un paradigma di programmazione puramente funzionale. Citando Wikipedia: "Puramente funzionale è un termine nell'informatica utilizzato per descrivere algoritmi, strutture di dati o linguaggi di programmazione che escludono modifiche distruttive (aggiornamenti) di entità nell'ambiente in esecuzione del programma." ==== Per definizione, la persistenza dei dati è irrilevante e non serve a nulla che non modifica alcun dato. A rigor di termini non c'è risposta alla tua domanda. Stavo tentando un'interpretazione più libera di ciò che hai scritto.
Astara,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.