Leitor Monad para injeção de dependência: dependências múltiplas, chamadas aninhadas

87

Quando questionados sobre injeção de dependência no Scala, muitas respostas apontam para o uso do Reader Monad, seja o do Scalaz ou apenas o seu próprio. Há uma série de artigos muito claros que descrevem os fundamentos da abordagem (por exemplo, a palestra de Runar , o blog de Jason ), mas não consegui encontrar um exemplo mais completo e não consigo ver as vantagens dessa abordagem sobre, por exemplo, mais DI "manual" tradicional (veja o guia que escrevi ). Provavelmente estou perdendo algum ponto importante, daí a pergunta.

Apenas como exemplo, vamos imaginar que temos estas classes:

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

class FindUsers(datastore: Datastore) {
  def inactive(): Unit = ()
}

class UserReminder(findUser: FindUsers, emailServer: EmailServer) {
  def emailInactive(): Unit = ()
}

class CustomerRelations(userReminder: UserReminder) {
  def retainUsers(): Unit = {}
}

Aqui estou modelando coisas usando classes e parâmetros do construtor, o que funciona muito bem com as abordagens de DI "tradicionais", no entanto, esse design tem alguns lados bons:

  • cada funcionalidade tem dependências claramente enumeradas. Nós meio que presumimos que as dependências são realmente necessárias para que a funcionalidade funcione corretamente
  • as dependências ficam ocultas nas funcionalidades, por exemplo, o UserRemindernão tem ideia de que FindUsersprecisa de um armazenamento de dados. As funcionalidades podem estar mesmo em unidades de compilação separadas
  • estamos usando apenas Scala puro; as implementações podem alavancar classes imutáveis, funções de ordem superior, os métodos de "lógica de negócios" podem retornar valores embrulhados na IOmônada se quisermos capturar os efeitos etc.

Como isso poderia ser modelado com a mônada Reader? Seria bom manter as características acima, para que fique claro que tipo de dependências cada funcionalidade precisa e ocultar as dependências de uma funcionalidade da outra. Observe que o uso de classes é mais um detalhe de implementação; talvez a solução "correta" usando a mônada do Reader usaria outra coisa.

Eu encontrei uma questão um tanto relacionada que sugere:

  • usando um único objeto de ambiente com todas as dependências
  • usando ambientes locais
  • padrão "parfait"
  • mapas indexados por tipo

No entanto, além de ser (mas isso é subjetivo) um pouco complexo demais para uma coisa tão simples, em todas essas soluções, por exemplo, o retainUsersmétodo (que chama emailInactive, que chama inactivepara encontrar os usuários inativos) precisaria saber sobre a Datastoredependência, para ser capaz de chamar corretamente as funções aninhadas - ou estou errado?

Em quais aspectos usar o Reader Monad para tal "aplicativo de negócios" seria melhor do que apenas usar parâmetros do construtor?

adamw
fonte
1
A mônada do leitor não é uma bala de prata. Eu acho que, se você requer muitos níveis de dependências, seu design é muito bom.
ZhekaKozlov,
No entanto, é frequentemente descrito como uma alternativa à injeção de dependência; talvez devesse então ser descrito como um complemento? Às vezes, tenho a sensação de que o DI é descartado por "verdadeiros programadores funcionais", por isso estava me perguntando "o que seria" :) De qualquer forma, acho que ter vários níveis de dependências, ou melhor, vários serviços externos com os quais você precisa conversar é como todo "aplicativo de negócios" de médio-grande porte se parece (não é o caso para bibliotecas, com certeza)
adamw
2
Sempre pensei na mônada do Reader como algo local. Por exemplo, se você tiver algum módulo que se comunica apenas com um banco de dados, pode implementar este módulo no estilo monad Reader. No entanto, se seu aplicativo requer várias fontes de dados que devem ser combinadas, não acho que a mônada do Reader seja boa para isso.
ZhekaKozlov,
Ah, essa poderia ser uma boa diretriz de como combinar os dois conceitos. E então, de fato, parece que DI e RM se complementam. Eu acho que é na verdade bastante comum ter funções que operam em apenas uma dependência, e usar RM aqui ajudaria a esclarecer os limites de dependência / dados.
adamw

Respostas:

36

Como modelar este exemplo

Como isso poderia ser modelado com a mônada do Reader?

Não tenho certeza se isso deve ser modelado com o Reader, mas pode ser por:

  1. codificar as classes como funções que tornam o código mais agradável com o Reader
  2. compondo as funções com o Reader em um para compreensão e usando-o

Um pouco antes de começar, preciso falar sobre pequenos ajustes de código de amostra que achei benéficos para esta resposta. A primeira mudança é sobre o FindUsers.inactivemétodo. Deixo retornar List[String]para que a lista de endereços possa ser usada no UserReminder.emailInactivemétodo. Também adicionei implementações simples aos métodos. Por fim, o exemplo usará a seguinte versão enrolada à mão do Reader monad:

case class Reader[Conf, T](read: Conf => T) { self =>

  def map[U](convert: T => U): Reader[Conf, U] =
    Reader(self.read andThen convert)

  def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
    Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))

  def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
    Reader[BiggerConf, T](extractFrom andThen self.read)
}

object Reader {
  def pure[C, A](a: A): Reader[C, A] =
    Reader(_ => a)

  implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
    Reader(read)
}

Etapa de modelagem 1. Classificando classes como funções

Talvez seja opcional, não tenho certeza, mas depois faz com que o for compreensão pareça melhor. Observe que a função resultante é curry. Também leva os argumentos do construtor anteriores como primeiro parâmetro (lista de parâmetros). Dessa maneira

class Foo(dep: Dep) {
  def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)

torna-se

object Foo {
  def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)

Tenha em mente que cada um Dep, Arg, Restipos podem ser completamente arbitrária: a tupla, uma função ou um tipo simples.

Aqui está o código de amostra após os ajustes iniciais, transformado em funções:

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

object FindUsers {
  def inactive: Datastore => () => List[String] =
    dataStore => () => dataStore.runQuery("select inactive")
}

object UserReminder {
  def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
    emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}

object CustomerRelations {
  def retainUsers(emailInactive: () => Unit): () => Unit =
    () => {
      println("emailing inactive users")
      emailInactive()
    }
}

Uma coisa a notar aqui é que funções específicas não dependem dos objetos inteiros, mas apenas das partes diretamente usadas. Onde na versão OOP a UserReminder.emailInactive()instância chamaria userFinder.inactive()aqui, ela apenas chama inactive() - uma função passada a ele no primeiro parâmetro.

Observe que o código exibe as três propriedades desejáveis ​​da questão:

  1. é claro que tipo de dependências cada funcionalidade precisa
  2. esconde as dependências de uma funcionalidade de outra
  3. retainUsers método não precisa saber sobre a dependência do Datastore

Etapa de modelagem 2. Usando o Reader para compor funções e executá-las

A mônada do leitor permite apenas compor funções que dependam do mesmo tipo. Isso geralmente não é o caso. Em nosso exemplo FindUsers.inactivedepende de Datastoree UserReminder.emailInactivepara EmailServer. Para resolver esse problema, pode-se introduzir um novo tipo (freqüentemente referido como Config) que contém todas as dependências e, em seguida, alterar as funções para que todas dependam dele e retirar apenas os dados relevantes. Obviamente, isso está errado do ponto de vista do gerenciamento de dependência, porque dessa forma você torna essas funções também dependentes de tipos que elas não deveriam conhecer em primeiro lugar.

Felizmente, verifica-se que existe uma maneira de fazer a função funcionar, Configmesmo que ela aceite apenas uma parte dela como parâmetro. É um método chamado local, definido no Reader. Ele precisa ser fornecido com uma maneira de extrair a parte relevante do Config.

Esse conhecimento aplicado ao exemplo em questão seria assim:

object Main extends App {

  case class Config(dataStore: Datastore, emailServer: EmailServer)

  val config = Config(
    new Datastore { def runQuery(query: String) = List("[email protected]") },
    new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
  )

  import Reader._

  val reader = for {
    getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
    emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
    retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
  } yield retainUsers

  reader.read(config)()

}

Vantagens sobre o uso de parâmetros do construtor

Em quais aspectos usar o Reader Monad para tal "aplicativo de negócios" seria melhor do que apenas usar parâmetros do construtor?

Espero que, ao preparar esta resposta, tenha tornado mais fácil julgar por si mesmo em quais aspectos ela superaria os construtores simples. No entanto, se eu fosse enumerá-los, aqui está minha lista. Isenção de responsabilidade: Eu tenho experiência OOP e posso não apreciar o Reader e o Kleisli totalmente, pois não os uso.

  1. Uniformidade - não importa quão curto / longo seja o for compreensão, é apenas um Reader e você pode facilmente compor com outra instância, talvez apenas introduzindo mais um tipo de Config e polvilhando algumas localchamadas em cima dele. Este ponto é IMO mais uma questão de gosto, porque quando você usa construtores ninguém o impede de compor qualquer coisa que você queira, a menos que alguém faça algo estúpido, como trabalhar no construtor que é considerado uma prática ruim em OOP.
  2. Reader é uma mônada, por isso, recebe todos os benefícios relacionados a isso - sequence, traversemétodos implementados de forma gratuita.
  3. Em alguns casos, pode ser preferível construir o Reader apenas uma vez e usá-lo para uma ampla variedade de configurações. Com construtores, ninguém impede que você faça isso, você só precisa construir todo o gráfico do objeto novamente para cada Config recebido. Embora eu não tenha nenhum problema com isso (prefiro até mesmo fazer isso em cada solicitação de inscrição), não é uma ideia óbvia para muitas pessoas, por razões sobre as quais posso apenas especular.
  4. O Reader incentiva você a usar mais funções, que funcionarão melhor com aplicativos escritos predominantemente no estilo FP.
  5. O leitor separa as preocupações; você pode criar, interagir com tudo, definir lógica sem fornecer dependências. Na verdade, forneça mais tarde, separadamente. (Obrigado Ken Scrambler por este ponto). Isso costuma ser uma vantagem do Reader, mas também é possível com construtores simples.

Também gostaria de dizer o que não gosto no Reader.

  1. Marketing. Às vezes tenho a impressão de que o Reader é comercializado para todos os tipos de dependências, sem distinção se é um cookie de sessão ou um banco de dados. Para mim, não faz sentido usar o Reader para objetos praticamente constantes, como servidor de e-mail ou repositório deste exemplo. Para tais dependências, acho construtores simples e / ou funções parcialmente aplicadas muito melhores. Essencialmente, o Reader oferece flexibilidade para que você possa especificar suas dependências em cada chamada, mas se você realmente não precisar disso, você só paga seus impostos.
  2. Peso implícito - usar o Reader sem implícito tornaria o exemplo difícil de ler. Por outro lado, quando você oculta as partes barulhentas usando implícitos e comete alguns erros, o compilador às vezes deixa você difícil de decifrar mensagens.
  3. Cerimônia com pure, locale criando próprias classes de Config / usando tuplas para isso. O Reader força você a adicionar algum código que não seja sobre o domínio do problema, portanto, introduzindo algum ruído no código. Por outro lado, um aplicativo que usa construtores geralmente usa o padrão de fábrica, que também vem de fora do domínio do problema, então esse ponto fraco não é tão sério.

E se eu não quiser converter minhas classes em objetos com funções?

Você quer. Você pode evitar tecnicamente isso, mas veja o que aconteceria se eu não convertesse FindUsersclasse em objeto. A respectiva linha de compreensão seria semelhante a:

getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)

que não é tão legível, não é? A questão é que o Reader opera em funções, então, se você ainda não as tem, precisa construí-las embutidas, o que geralmente não é muito bonito.

Przemek Pokrywka
fonte
Obrigado pela resposta detalhada :) Um ponto que não está claro para mim é por que Datastoree EmailServersão deixados como traços e outros se tornaram objects? Existe uma diferença fundamental nesses serviços / dependências / (como você os chama) que faz com que sejam tratados de forma diferente?
adamw
Bem ... eu não consigo converter, por exemplo, EmailSenderpara um objeto também, certo? Eu não seria capaz de expressar a dependência sem ter o tipo ...
adamw
Ah, a dependência tomaria a forma de uma função com um tipo apropriado - então, em vez de usar nomes de tipo, tudo teria que ir para a assinatura da função (o nome sendo apenas acidental). Talvez, mas não estou convencido;)
adamw
Corrigir. Em vez de depender de EmailSendervocê dependeria (String, String) => Unit. Se isso é convincente ou não é outra questão :) Para ter certeza, é mais genérico pelo menos, já que todo mundo já depende Function2.
Przemek Pokrywka
Bem, você certamente gostaria de dar um nome (String, String) => Unit para que tenha algum significado, embora não com um alias de tipo, mas com algo que é verificado em tempo de compilação;)
adamw
3

Acho que a principal diferença é que em seu exemplo você está injetando todas as dependências quando os objetos são instanciados. A mônada do Reader basicamente constrói funções cada vez mais complexas para chamar dadas as dependências, que são então devolvidas às camadas superiores. Nesse caso, a injeção acontece quando a função é finalmente chamada.

Uma vantagem imediata é a flexibilidade, especialmente se você puder construir sua mônada uma vez e depois quiser usá-la com diferentes dependências injetadas. Uma desvantagem é, como você disse, potencialmente menos clareza. Em ambos os casos, a camada intermediária só precisa saber sobre suas dependências imediatas, de modo que ambas funcionam conforme anunciado para DI.

Daniel Langdon
fonte
Como a camada intermediária saberia apenas sobre suas dependências intermediárias, e não todas elas? Você poderia dar um exemplo de código mostrando como o exemplo poderia ser implementado usando o leitor monad?
adamw
Eu provavelmente não poderia explicar melhor do que o blog de Json (que você postou). Para citar lá "Ao contrário do exemplo implícito, não temos UserRepository em qualquer lugar nas assinaturas de userEmail e userInfo". Verifique esse exemplo com cuidado.
Daniel Langdon,
1
Bem, sim, mas isso pressupõe que a mônada de leitor que você está usando está parametrizada com a Configqual contém uma referência UserRepository. É verdade que não é diretamente visível na assinatura, mas eu diria que é ainda pior, você não tem ideia de quais dependências seu código está usando à primeira vista. Ser dependente de um Configcom todas as dependências não significa que cada método depende de todos eles?
adamw
Depende deles, mas não precisa saber disso. O mesmo que em seu exemplo com classes. Eu os vejo como bastante equivalentes :-)
Daniel Langdon
No exemplo com classes, você depende apenas do que realmente precisa, não de um objeto global com todas as dependências dentro. E você tem um problema de como decidir o que se passa nas "dependências" do global confige o que é "apenas uma função". Provavelmente, você também acabaria com muitas autodependências. De qualquer forma, isso é mais uma questão de preferência do que perguntas e respostas :)
adamw