Como a persistência se encaixa em uma linguagem puramente funcional?

18

Como o padrão de usar manipuladores de comando para lidar com persistência se encaixa em uma linguagem puramente funcional, na qual queremos tornar o código relacionado à IO o mais fino possível?


Ao implementar o Design Orientado a Domínio em uma linguagem orientada a objetos, é comum usar o padrão Comando / Manipulador para executar alterações de estado. Nesse design, os manipuladores de comando ficam sobre os objetos do domínio e são responsáveis ​​pela lógica chata relacionada à persistência, como usar repositórios e publicar eventos do domínio. Os manipuladores são a face pública do seu modelo de domínio; código de aplicativo como a interface do usuário chama os manipuladores quando ele precisa alterar o estado dos objetos de domínio.

Um esboço em 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);
    }
}

O documentobjeto de domínio é responsável por implementar as regras de negócios (como "o usuário deve ter permissão para descartar o documento" ou "você não pode descartar um documento que já foi descartado") e por gerar os eventos de domínio que precisamos publicar ( document.NewEventsseria ser um IEnumerable<Event>e provavelmente conteria um DocumentDiscardedevento).

Esse é um bom design - é fácil de estender (você pode adicionar novos casos de uso sem alterar o modelo de domínio, adicionando novos manipuladores de comando) e é independente da maneira como os objetos são persistidos (você pode facilmente trocar um repositório NHibernate por um Mongo repositório ou troque um editor RabbitMQ por um editor EventStore), o que facilita o teste usando falsificações e zombarias. Ele também obedece à separação de modelo / exibição - o manipulador de comandos não tem idéia se está sendo usado por um trabalho em lotes, uma GUI ou uma API REST.


Em uma linguagem puramente funcional como Haskell, você pode modelar o manipulador de comandos aproximadamente assim:

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"

Aqui está a parte que estou lutando para entender. Normalmente, haverá algum tipo de código de 'apresentação' que chama o manipulador de comandos, como uma GUI ou uma API REST. Portanto, agora temos duas camadas em nosso programa que precisam fazer IO - o manipulador de comandos e a exibição - o que é um grande não-não no Haskell.

Até onde eu entendi, existem duas forças opostas aqui: uma é a separação modelo / vista e a outra é a necessidade de persistir no modelo. É necessário haver código de IO para manter o modelo em algum lugar , mas a separação de modelo / exibição diz que não podemos colocá-lo na camada de apresentação com todos os outros códigos de IO.

Obviamente, em um idioma "normal", o IO pode (e acontece) em qualquer lugar. Um bom design determina que os diferentes tipos de E / S sejam mantidos separados, mas o compilador não o impõe.

Então: como reconciliar o modelo / visualizar a separação com o desejo de levar o código de E / S até a borda do programa, quando o modelo precisa ser persistido? Como mantemos os dois tipos diferentes de E / S separados , mas ainda longe de todo o código puro?


Atualização : A recompensa expira em menos de 24 horas. Não acho que nenhuma das respostas atuais tenha respondido à minha pergunta. O comentário de @ Ptharien's Flame sobre acid-stateparece promissor, mas não é uma resposta e está faltando detalhes. Eu odiaria que esses pontos fossem desperdiçados!

Benjamin Hodgson
fonte
1
Talvez seja útil examinar o design de várias bibliotecas de persistência em Haskell; em particular, acid-stateparece estar perto do que você está descrevendo .
Chama de Ptharien 01/03/14
1
acid-stateparece ótimo, obrigado por esse link. Em termos de design da API, ainda parece estar vinculado IO; minha pergunta é sobre como uma estrutura de persistência se encaixa em uma arquitetura maior. Você conhece algum aplicativo de código aberto usado acid-statejunto com uma camada de apresentação e consegue manter os dois separados?
Benjamin Hodgson
As mônadas Querye Updatesão bastante distantes IO, na verdade. Vou tentar dar um exemplo simples em uma resposta.
Chama de Ptharien
Correndo o risco de ficar fora do tópico, para qualquer leitor que esteja usando o padrão Comando / Manipulador dessa maneira, eu realmente recomendo verificar o Akka.NET. O modelo do ator parece um bom ajuste aqui. Existe um ótimo caminho para isso no Pluralsight. (Eu juro que eu sou apenas um fanboy, não um bot promocional.)
RJB

Respostas:

6

A maneira geral de separar componentes no Haskell é através de pilhas de transformadores de mônada. Eu explico isso com mais detalhes abaixo.

Imagine que estamos construindo um sistema que possui vários componentes de grande escala:

  • um componente que fala com o disco ou banco de dados (submodelo)
  • um componente que faz transformações em nosso domínio (modelo)
  • um componente que interage com o usuário (visualização)
  • um componente que descreve a conexão entre visualização, modelo e submodelo (controlador)
  • um componente que inicia o sistema inteiro (driver)

Decidimos que precisamos manter esses componentes fracamente acoplados para manter um bom estilo de código.

Portanto, codificamos cada um de nossos componentes polimorficamente, usando as várias classes MTL para nos guiar:

  • todas as funções no submodelo são do tipo MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState é uma representação pura de um instantâneo do estado do nosso banco de dados ou armazenamento
  • todas as funções do modelo são puras
  • todas as funções na visualização são do tipo MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState é uma representação pura de um instantâneo do estado da nossa interface de usuário
  • todas as funções no controlador são do tipo MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Observe que o controlador tem acesso ao estado da visualização e ao estado do submodelo
  • o driver tem apenas uma definição, main :: IO ()que faz o trabalho quase trivial de combinar os outros componentes em um sistema
    • a visualização e o submodelo precisarão ser elevados para o mesmo tipo de estado que o controlador usando zoomou um combinador semelhante
    • o modelo é puro e, portanto, pode ser usado sem restrições
    • no final, tudo vive (um tipo compatível com) StateT (DataState, UIState) IO, que é executado com o conteúdo real do banco de dados ou armazenamento a ser produzido IO.
Chama de Ptharien
fonte
1
Este é um conselho excelente e exatamente o que eu estava procurando. Obrigado!
Benjamin Hodgson
2
Estou digerindo esta resposta. Por favor, você poderia esclarecer o papel do 'submodelo' nessa arquitetura? Como ele "conversa com o disco ou banco de dados" sem executar E / S? Estou particularmente confuso sobre o que você quer dizer com " DataStateé uma representação pura de um instantâneo do estado do nosso banco de dados ou armazenamento". Presumivelmente, você não pretende carregar todo o banco de dados na memória!
Benjamin Hodgson
1
Eu adoraria ver seus pensamentos sobre uma implementação em C # dessa lógica. Não suponha que eu possa subornar você com um voto positivo? ;-)
RJB
1
@RJB Infelizmente, você teria que subornar a equipe de desenvolvimento de C # para permitir tipos mais altos na linguagem, porque sem eles essa arquitetura fica um pouco plana.
Chama de Ptharien
4

Então: como reconciliar o modelo / visualizar a separação com o desejo de levar o código de E / S até a borda do programa, quando o modelo precisa ser persistido?

O modelo deve ser persistido? Em muitos programas, é necessário salvar o modelo porque o estado é imprevisível, qualquer operação pode alterar o modelo de qualquer forma, portanto, a única maneira de conhecer o estado do modelo é acessá-lo diretamente.

Se, no seu cenário, a sequência de eventos (comandos que foram validados e aceitos) sempre pode gerar o estado, são os eventos que precisam ser persistidos, não necessariamente o estado. O estado sempre pode ser gerado reproduzindo os eventos.

Dito isto, muitas vezes o estado é armazenado, mas apenas como um instantâneo / cache para evitar a repetição dos comandos, não como dados essenciais do programa.

Portanto, agora temos duas camadas em nosso programa que precisam fazer IO - o manipulador de comandos e a exibição - o que é um grande não-não no Haskell.

Depois que o comando é aceito, o evento é comunicado a dois destinos (o armazenamento de eventos e o sistema de relatórios), mas na mesma camada do programa.

Consulte também Derivação de leitura ansiosa de
fornecimento de eventos

FMJaguar
fonte
2
Estou familiarizado com a fonte de eventos (estou usando-a no meu exemplo acima!) E, para evitar a divisão dos cabelos, ainda diria que a fonte de eventos é uma abordagem para o problema da persistência. De qualquer forma, a fonte de eventos não evita a necessidade de carregar seus objetos de domínio no manipulador de comandos . O manipulador de comando não sabe se os objetos vieram de um fluxo de eventos, um ORM ou um procedimento armazenado - apenas o obtém do repositório.
Benjamin Hodgson
1
Seu entendimento parece unir a exibição e o manipulador de comandos para criar várias E / S. Meu entendimento é que o manipulador gera o evento e não tem mais interesse. A visualização nesta instância funciona como um módulo separado (mesmo que tecnicamente no mesmo aplicativo) e não seja acoplada ao manipulador de comandos.
precisa saber é o seguinte
1
Acho que podemos estar conversando com propósitos diferentes. Quando digo 'view', estou falando de toda a camada de apresentação, que pode ser uma API REST ou um sistema de model-view-controller. (Concordo que a visualização deve ser dissociada do modelo no padrão MVC.) Basicamente, quero dizer "o que quer que chame o manipulador de comandos".
Benjamin Hodgson
2

Você está tentando colocar espaço em seu aplicativo intensivo de E / S para todas as atividades que não sejam de E / S; infelizmente, aplicativos CRUD típicos como o que você fala fazem pouco além de IO.

Eu acho que você entende bem a separação relevante, mas onde você está tentando colocar o código de persistência IO em um número de camadas distante do código de apresentação, o fato geral da questão está no seu controlador em algum lugar em que você deveria estar chamando o seu camada de persistência, que pode parecer muito próxima da sua apresentação para você - mas isso é apenas uma coincidência nesse tipo de aplicativo.

Apresentação e persistência compõem basicamente o tipo de aplicativo que você está descrevendo aqui.

Se você pensa em um aplicativo semelhante com muita lógica de negócios e processamento de dados complexos, acho que conseguirá imaginar como isso é bem separado das coisas de IO de apresentação e IO de persistência, de modo que também não precisa saber nada. O problema que você tem agora é apenas um perceptivo causado pela tentativa de encontrar uma solução para um problema em um tipo de aplicativo que não possui esse problema para começar.

Jimmy Hoffa
fonte
1
Você está dizendo que não há problema em sistemas CRUD unirem persistência e apresentação. Isso me parece razoável; no entanto, eu não mencionei CRUD. Estou perguntando especificamente sobre DDD, onde você tem objetos de negócios como interações complexas, uma camada de persistência (manipuladores de comando) e uma camada de apresentação. Como você mantém as duas camadas de E / S separadas enquanto mantém um invólucro de E / S fino ?
Benjamin Hodgson
1
NB, o domínio que descrevi na pergunta pode ser muito complexo. Talvez o descarte de um documento de rascunho esteja sujeito a algumas verificações de permissões envolvidas, ou várias versões do mesmo rascunho precisem ser tratadas, ou seja necessário enviar notificações, ou a ação precise de aprovação de outro usuário, ou os rascunhos passam por várias estágios do ciclo de vida antes da finalização ...
Benjamin Hodgson
2
@BenjaminHodgson Eu recomendaria fortemente que não misturasse DDD ou outras metodologias de design OO inerentes a essa situação em sua mente, isso só vai confundir. Embora sim, você pode criar objetos como bits e bobbles em FP puro, as abordagens de design baseadas neles não devem necessariamente ser o seu primeiro alcance. No cenário que você descreve, eu imaginaria, como mencionei acima, um controlador que se comunica entre os dois IO e o código puro: o IO da apresentação entra e é solicitado ao controlador, o controlador passa as coisas para as seções puras e para as seções de persistência.
Jimmy Hoffa
1
@BenjaminHodgson, você pode imaginar uma bolha onde vive todo o seu código puro, com todas as camadas e fantasias que você deseja em qualquer design que você aprecie. O ponto de entrada para essa bolha será um pequeno pedaço que estou chamando de "controlador" (talvez incorretamente), que faz a comunicação entre a apresentação, persistência e peças puras. Dessa maneira, sua persistência não sabe nada de apresentação ou pura e vice-versa - e isso mantém seu material de IO nessa fina camada acima da bolha do seu sistema puro.
Jimmy Hoffa
2
@BenjaminHodgson: essa abordagem de "objetos inteligentes" de que você fala é inerentemente uma abordagem ruim para o FP, o problema com objetos inteligentes no FP é que eles acoplam demais e generalizam muito pouco. Você acaba com dados e funcionalidades vinculados a ele, em que o FP prefere que seus dados tenham um acoplamento fraco à funcionalidade, de modo que você possa implementar suas funções para serem generalizadas e, em seguida, elas trabalharão em vários tipos de dados. Ter uma leitura da minha resposta aqui: programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa
1

O mais próximo que eu puder entender sua pergunta (o que talvez não seja, mas pensei em gastar 2 centavos), já que você não necessariamente tem acesso aos objetos em si, precisa ter seu próprio banco de dados de objetos que expira com o tempo).

Idealmente, os próprios objetos podem ser aprimorados para armazenar seu estado; assim, quando eles são "repassados", diferentes processadores de comando saberão com o que estão trabalhando.

Se isso não for possível (icky icky), a única maneira é ter uma chave comum do tipo DB, que você pode usar para armazenar as informações em uma loja configurada para ser compartilhável entre diferentes comandos - e, esperançosamente, "abra" a interface e / ou o código para que outros escritores de comando também adotem sua interface para salvar e processar meta-informações.

Na área de servidores de arquivos, o samba tem maneiras diferentes de armazenar itens como listas de acesso e fluxos de dados alternativos, dependendo do que o sistema operacional host fornece. Idealmente, o samba está sendo hospedado em um sistema de arquivos e fornece atributos estendidos nos arquivos. Exemplo 'xfs' no 'linux' - mais comandos estão copiando atributos estendidos junto com um arquivo (por padrão, a maioria dos utilitários no linux "cresceu" sem os atributos estendidos).

Uma solução alternativa - que funciona para vários processos samba de diferentes usuários que operam em arquivos (objetos) comuns, é que, se o sistema de arquivos não suportar a conexão direta do recurso ao arquivo, como nos atributos estendidos, está usando um módulo que implementa uma camada de sistema de arquivos virtual para emular atributos estendidos para processos de samba. Somente o samba sabe disso, mas tem a vantagem de trabalhar quando o formato do objeto não o suporta, mas ainda trabalha com diversos usuários do samba (cf. processadores de comando) que trabalham no arquivo com base em seu estado anterior. Ele armazenará as meta informações em um banco de dados comum para o sistema de arquivos, o que ajuda a controlar o tamanho do banco de dados (e não

Pode não ser útil se você precisar de mais informações específicas para a implementação com a qual está trabalhando, mas conceitualmente, a mesma teoria pode ser aplicada aos dois conjuntos de problemas. Portanto, se você estava procurando algoritmos e métodos para fazer o que deseja, isso pode ajudar. Se você precisava de conhecimento mais específico em alguma estrutura específica, talvez não seja tão útil ... ;-)

Entre - a razão pela qual menciono 'auto-expiração' - é que não está claro se você sabe quais objetos estão lá fora e por quanto tempo eles persistem. Se você não tem uma maneira direta de saber quando um objeto é excluído, é necessário aparar seu próprio metaDB para impedir que ele seja preenchido com meta-informações antigas ou antigas, para as quais os usuários há muito excluíram os objetos.

Se você souber quando os objetos expiraram / foram excluídos, estará à frente do jogo e poderá expirá-lo do seu metaDB ao mesmo tempo, mas não ficou claro se você tem essa opção.

Felicidades!

Astara
fonte
1
Para mim, isso parece uma resposta para uma pergunta totalmente diferente. Eu estava procurando conselhos sobre arquitetura em programação puramente funcional, no contexto de design controlado por domínio. Você poderia esclarecer seus pontos, por favor?
Benjamin Hodgson
Você está perguntando sobre a persistência de dados em um paradigma de programação puramente funcional. Citando a Wikipedia: "Puramente funcional é um termo usado na computação para descrever algoritmos, estruturas de dados ou linguagens de programação que excluem modificações destrutivas (atualizações) de entidades no ambiente de execução do programa". ==== Por definição, a persistência de dados é irrelevante e não tem utilidade para algo que não modifica dados. A rigor, não há resposta para sua pergunta. Eu estava tentando uma interpretação mais solta do que você escreveu.
Astara