¿Cómo encaja la persistencia en un lenguaje puramente funcional?

18

¿Cómo encaja el patrón de usar controladores de comandos para lidiar con la persistencia en un lenguaje puramente funcional, donde queremos hacer que el código relacionado con IO sea lo más delgado posible?


Al implementar el diseño controlado por dominio en un lenguaje orientado a objetos, es común usar el patrón de comando / controlador para ejecutar cambios de estado. En este diseño, los controladores de comandos se ubican encima de los objetos de su dominio y son responsables de la aburrida lógica relacionada con la persistencia, como el uso de repositorios y la publicación de eventos de dominio. Los controladores son la cara pública de su modelo de dominio; El código de la aplicación, como la IU, llama a los controladores cuando necesita cambiar el estado de los objetos de dominio.

Un boceto 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);
    }
}

El documentobjeto de dominio es responsable de la aplicación de las reglas de negocio (como "el usuario debe tener permiso para descartar el documento" o "no se puede descartar un documento que ya ha sido descartada") y para generar los eventos de dominio que necesitamos para publicar ( document.NewEventslo haría ser un IEnumerable<Event>y probablemente contendría un DocumentDiscardedevento).

Este es un diseño agradable: es fácil de extender (puede agregar nuevos casos de uso sin cambiar su modelo de dominio, agregando nuevos controladores de comandos) y es independiente de cómo persisten los objetos (puede cambiar fácilmente un repositorio de NHibernate por un Mongo repositorio, o cambiar un editor RabbitMQ por un editor EventStore), lo que facilita la prueba con falsificaciones y simulacros. También obedece a la separación modelo / vista: el controlador de comandos no tiene idea de si lo está utilizando un trabajo por lotes, una GUI o una API REST.


En un lenguaje puramente funcional como Haskell, puede modelar el controlador de comandos más o menos así:

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"

Aquí está la parte que me cuesta entender. Por lo general, habrá algún tipo de código de 'presentación' que llama al controlador de comandos, como una GUI o una API REST. Así que ahora tenemos dos capas en nuestro programa que necesitan hacer IO: el controlador de comandos y la vista, que es un gran no-no en Haskell.

Hasta donde puedo entender, hay dos fuerzas opuestas aquí: una es la separación modelo / vista y la otra es la necesidad de persistir en el modelo. Es necesario que haya un código IO para mantener el modelo en alguna parte , pero la separación modelo / vista dice que no podemos ponerlo en la capa de presentación con el resto del código IO.

Por supuesto, en un lenguaje "normal", IO puede (y ocurre) en cualquier lugar. Un buen diseño dicta que los diferentes tipos de IO se mantengan separados, pero el compilador no lo exige.

Entonces: ¿cómo conciliamos la separación modelo / vista con el deseo de llevar el código IO al borde del programa, cuando el modelo necesita persistir? ¿Cómo mantenemos los dos tipos diferentes de IO separados , pero aún lejos de todo el código puro?


Actualización : la recompensa caduca en menos de 24 horas. No creo que ninguna de las respuestas actuales haya abordado mi pregunta en absoluto. El comentario de @Ptharien's Flame sobre acid-stateparece prometedor, pero no es una respuesta y carece de detalles. ¡Odiaría que estos puntos se desperdicien!

Benjamin Hodgson
fuente
1
Quizás sería útil mirar el diseño de varias bibliotecas de persistencia en Haskell; en particular, acid-stateparece estar cerca de lo que estás describiendo .
Ptharien's Flame
1
acid-statese ve muy bien, gracias por ese enlace. En términos de diseño de API, todavía parece estar vinculado IO; Mi pregunta es acerca de cómo un marco de persistencia encaja en una arquitectura más grande. ¿Conoces las aplicaciones de código abierto que se usan acid-statejunto con una capa de presentación y logran mantener las dos separadas?
Benjamin Hodgson
Las mónadas Queryy Updateestán bastante lejos de IO, en realidad. Trataré de dar un ejemplo simple en una respuesta.
Ptharien's Flame
A riesgo de estar fuera de tema, para cualquier lector que esté usando el patrón Command / Handler de esta manera, realmente recomiendo revisar Akka.NET. El modelo de actor se siente como un buen ajuste aquí. Hay un gran curso para ello en Pluralsight. (Juro que solo soy un fanboy, no un bot promocional)
RJB

Respuestas:

6

La forma general de separar componentes en Haskell es a través de pilas de transformadores de mónada. Explico esto con más detalle a continuación.

Imagine que estamos construyendo un sistema que tiene varios componentes a gran escala:

  • Un componente que habla con el disco o la base de datos (submodelo)
  • un componente que realiza transformaciones en nuestro dominio (modelo)
  • Un componente que interactúa con el usuario (ver)
  • Un componente que describe la conexión entre la vista, el modelo y el submodelo (controlador)
  • un componente que inicia todo el sistema (controlador)

Decidimos que necesitamos mantener estos componentes débilmente acoplados para mantener un buen estilo de código.

Por lo tanto, codificamos cada uno de nuestros componentes polimórficamente, utilizando las diversas clases de MTL para guiarnos:

  • cada función en el submodelo es de tipo MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState es una representación pura de una instantánea del estado de nuestra base de datos o almacenamiento
  • cada función en el modelo es pura
  • cada función en la vista es de tipo MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState es una representación pura de una instantánea del estado de nuestra interfaz de usuario
  • cada función en el controlador es de tipo MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Observe que el controlador tiene acceso tanto al estado de la vista como al estado del submodelo
  • el controlador solo tiene una definición, main :: IO ()que hace el trabajo casi trivial de combinar los otros componentes en un sistema
    • la vista y el submodelo deberán elevarse al mismo tipo de estado que el controlador que usa zoomo un combinador similar
    • el modelo es puro, por lo que puede usarse sin restricciones
    • al final, todo vive (un tipo compatible con) StateT (DataState, UIState) IO, que luego se ejecuta con el contenido real de la base de datos o el almacenamiento para producir IO.
Llama de Ptharien
fuente
1
Este es un excelente consejo, y exactamente lo que estaba buscando. ¡Gracias!
Benjamin Hodgson
2
Estoy digiriendo esta respuesta. ¿Podría aclarar el papel del 'submodelo' en esta arquitectura? ¿Cómo "habla con el disco o la base de datos" sin realizar IO? Estoy particularmente confundido acerca de lo que quiere decir con " DataStatees una representación pura de una instantánea del estado de nuestra base de datos o almacenamiento". ¡Presumiblemente no quiere cargar toda la base de datos en la memoria!
Benjamin Hodgson
1
Me encantaría ver sus pensamientos sobre una implementación de C # de esta lógica. ¿No crees que puedo sobornarte con un voto a favor? ;-)
RJB
1
@RJB Desafortunadamente, tendrías que sobornar al equipo de desarrollo de C # para permitir tipos más altos en el lenguaje, porque sin ellos esta arquitectura cae un poco plana.
Llama de Ptharien
4

Entonces: ¿cómo conciliamos la separación modelo / vista con el deseo de llevar el código IO al borde del programa, cuando el modelo necesita persistir?

¿Debería persistir el modelo? En muchos programas, es necesario guardar el modelo porque el estado es impredecible, cualquier operación podría mutar el modelo de cualquier manera, por lo que la única forma de conocer el estado del modelo es acceder a él directamente.

Si, en su escenario, la secuencia de eventos (comandos que han sido validados y aceptados) siempre puede generar el estado, entonces son los eventos los que deben persistir, no necesariamente el estado. El estado siempre se puede generar al reproducir los eventos.

Dicho esto, muchas veces el estado se almacena, pero solo como una instantánea / caché para evitar reproducir los comandos, no como datos esenciales del programa.

Así que ahora tenemos dos capas en nuestro programa que necesitan hacer IO: el controlador de comandos y la vista, que es un gran no-no en Haskell.

Una vez aceptado el comando, el evento se comunica a dos destinos (el almacenamiento de eventos y el sistema de informes) pero en la misma capa del programa.

Consulte también
Obtención de eventos
Ansioso Leer derivación

FMJaguar
fuente
2
Estoy familiarizado con el abastecimiento de eventos (¡lo estoy usando en mi ejemplo anterior!), Y para evitar dividir los pelos, aún diría que el abastecimiento de eventos es un enfoque para el problema de la persistencia. En cualquier caso, el abastecimiento de eventos no elimina la necesidad de cargar sus objetos de dominio en el controlador de comandos . El controlador de comandos no sabe si los objetos provienen de una secuencia de eventos, un ORM o un procedimiento almacenado; simplemente lo obtiene del repositorio.
Benjamin Hodgson
1
Su comprensión parece unir la vista y el controlador de comandos para crear múltiples E / S. Tengo entendido que el controlador genera el evento y no tiene más interés. La vista en este caso funciona como un módulo separado (incluso si técnicamente está en la misma aplicación) y no está acoplada al controlador de comandos.
FMJaguar
1
Creo que podríamos estar hablando con propósitos cruzados. Cuando digo 'ver' estoy hablando de la capa de presentación completa, que puede ser una API REST, o un sistema modelo-vista-controlador. (Estoy de acuerdo en que la vista se debe desacoplar del modelo en el patrón MVC). Básicamente me refiero a "lo que sea que se llame al controlador de comandos".
Benjamin Hodgson
2

Estás tratando de poner espacio en tu aplicación intensiva de IO para todas las actividades que no son de IO; desafortunadamente, las aplicaciones CRUD típicas como las que hablas hacen poco más que IO.

Creo que entiende bien la separación relevante, pero cuando intenta colocar el código IO de persistencia en varias capas del código de presentación, el hecho general del asunto está en su controlador en algún lugar al que debería llamar. capa de persistencia, que puede parecer demasiado cercana a su presentación, pero eso es solo una coincidencia en que ese tipo de aplicación tiene poco más.

La presentación y la persistencia conforman básicamente la totalidad del tipo de aplicación que creo que estás describiendo aquí.

Si piensa en su mente acerca de una aplicación similar que tenía mucha lógica de negocios compleja y procesamiento de datos, creo que podrá imaginar cómo se separa muy bien del IO de presentación y el IO de persistencia de tal manera que no necesita saber nada tampoco. El problema que tiene en este momento es solo uno perceptivo causado por tratar de ver una solución a un problema en un tipo de aplicación que no tiene ese problema para comenzar.

Jimmy Hoffa
fuente
1
Estás diciendo que está bien que los sistemas CRUD combinen persistencia y presentación. Esto me parece razonable; Sin embargo, no mencioné CRUD. Le pregunto específicamente sobre DDD, donde tiene objetos de negocios con interacciones complejas, una capa de persistencia (manejadores de comandos) y una capa de presentación además de eso. ¿Cómo se mantienen separadas las dos capas de IO mientras se mantiene un envoltorio de IO delgado ?
Benjamin Hodgson
1
NB, el dominio que describí en la pregunta podría ser muy complejo. Quizás descartar un documento borrador está sujeto a alguna verificación de permisos involucrada, o es posible que se deban tratar varias versiones del mismo borrador, o se deban enviar notificaciones, o la acción necesite la aprobación de otro usuario, o los borradores pasen por varios etapas del ciclo de vida antes de la finalización ...
Benjamin Hodgson
2
@BenjaminHodgson Recomiendo encarecidamente que no mezcle DDD u otras metodologías de diseño inherentemente OO en esta situación en su cabeza, solo va a confundir. Si bien sí, puede crear objetos como bits y bobbles en FP puro, los enfoques de diseño basados ​​en ellos no necesariamente deben ser su primer alcance. En el escenario que describe, imagino como mencioné anteriormente, un controlador que se comunica entre los dos IO y el código puro: la presentación IO entra y se solicita al controlador, el controlador pasa las cosas a las secciones puras y a las secciones de persistencia.
Jimmy Hoffa
1
@BenjaminHodgson, puedes imaginar una burbuja donde vive todo tu código puro, con todas las capas y fantasía que quieras en cualquier diseño que aprecies. El punto de entrada para esta burbuja será una pequeña pieza a la que llamo un "controlador" (quizás incorrectamente) que hace la comunicación entre la presentación, la persistencia y las piezas puras. De esta manera, su persistencia no sabe nada de presentación o pura y viceversa, y esto mantiene sus cosas de E / S en esta capa delgada sobre la burbuja de su sistema puro.
Jimmy Hoffa
2
@BenjaminHodgson, este enfoque de "objetos inteligentes" del que habla es intrínsecamente un mal enfoque para FP, el problema con los objetos inteligentes en FP es que se acoplan demasiado y generalizan muy poco. Terminas con datos y funcionalidades vinculados a ellos, en los que FP prefiere que tus datos tengan un acoplamiento suelto con la funcionalidad de modo que puedas implementar tus funciones para generalizarlas y luego trabajarán en múltiples tipos de datos. Lea mi respuesta aquí: programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa
1

Tan cerca como puedo entender su pregunta (que no puedo, pero pensé en tirar mis 2 centavos), ya que no necesariamente tiene acceso a los objetos en sí, necesita tener su propia base de datos de objetos que caduca con el tiempo).

Idealmente, los objetos mismos pueden mejorarse para almacenar su estado, de modo que cuando se "pasen", los diferentes procesadores de comandos sabrán con qué están trabajando.

Si eso no es posible, (icky icky), la única forma es tener una clave común similar a DB, que pueda usar para almacenar la información en una tienda que esté configurada para ser compartida entre diferentes comandos, y con suerte, "abra" la interfaz y / o el código para que cualquier otro escritor de comandos también adopte su interfaz para guardar y procesar metainformación.

En el área de servidores de archivos, samba tiene diferentes formas de almacenar cosas como listas de acceso y flujos de datos alternativos, dependiendo de lo que proporcione el sistema operativo host. Idealmente, samba se aloja en un sistema de archivos que proporciona atributos extendidos en los archivos. Ejemplo 'xfs' en 'linux': más comandos están copiando atributos extendidos junto con un archivo (por defecto, la mayoría de las utilidades en linux "crecieron" sin pensar en atributos extendidos).

Una solución alternativa, que funciona para múltiples procesos de samba de diferentes usuarios que operan en archivos comunes (objetos), es que si el sistema de archivos no admite adjuntar el recurso directamente al archivo como con atributos extendidos, está utilizando un módulo que implementa una capa de sistema de archivos virtual para emular atributos extendidos para procesos de samba. Solo samba lo sabe, pero tiene la ventaja de funcionar cuando el formato de objeto no lo admite, pero aún funciona con diversos usuarios de samba (cf. procesadores de comandos) que trabajan en el archivo en función de su estado anterior. Almacenará la metainformación en una base de datos común para el sistema de archivos que ayuda a controlar el tamaño de la base de datos (y no

Puede que no sea útil para usted si necesita más información específica para la implementación con la que está trabajando, pero conceptualmente, la misma teoría podría aplicarse a ambos conjuntos de problemas. Entonces, si estaba buscando algoritmos y métodos para hacer lo que desea, eso podría ayudar. Si necesitabas un conocimiento más específico en algún marco específico, entonces tal vez no sea tan útil ... ;-)

Por cierto, la razón por la que menciono 'autoexpiración' - es que no está claro si sabes qué objetos hay y cuánto tiempo persisten. Si no tiene una forma directa de saber cuándo se elimina un objeto, tendría que recortar su propia metaDB para evitar que se llene con metainformación antigua o antigua que los usuarios han eliminado hace mucho tiempo los objetos.

Si sabe cuándo caducan / eliminan los objetos, entonces está por delante del juego y puede expirarlo de su metaDB al mismo tiempo, pero no estaba claro si tenía esa opción.

¡Salud!

Astara
fuente
1
Para mí, esto parece una respuesta a una pregunta totalmente diferente. Estaba buscando consejos sobre arquitectura en programación puramente funcional, en el contexto del diseño basado en dominios. ¿Podría aclarar sus puntos por favor?
Benjamin Hodgson
Usted pregunta sobre la persistencia de datos en un paradigma de programación puramente funcional. Citando Wikipedia: "Puramente funcional es un término en informática utilizado para describir algoritmos, estructuras de datos o lenguajes de programación que excluyen modificaciones destructivas (actualizaciones) de entidades en el entorno de ejecución del programa". ==== Por definición, la persistencia de datos es irrelevante y no sirve para algo que no modifica datos. Estrictamente hablando, no hay respuesta para su pregunta. Intentaba una interpretación más flexible de lo que escribiste.
Astara