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
UserReminder
não tem ideia de queFindUsers
precisa 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
IO
mô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 class
es é 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 retainUsers
método (que chama emailInactive
, que chama inactive
para encontrar os usuários inativos) precisaria saber sobre a Datastore
dependê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?
fonte
Respostas:
Como modelar este exemplo
Não tenho certeza se isso deve ser modelado com o Reader, mas pode ser por:
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.inactive
método. Deixo retornarList[String]
para que a lista de endereços possa ser usada noUserReminder.emailInactive
mé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: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
torna-se
Tenha em mente que cada um
Dep
,Arg
,Res
tipos 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:
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 chamariauserFinder.inactive()
aqui, ela apenas chamainactive()
- 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:
retainUsers
método não precisa saber sobre a dependência do DatastoreEtapa 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.inactive
depende deDatastore
eUserReminder.emailInactive
paraEmailServer
. 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,
Config
mesmo que ela aceite apenas uma parte dela como parâmetro. É um método chamadolocal
, definido no Reader. Ele precisa ser fornecido com uma maneira de extrair a parte relevante doConfig
.Esse conhecimento aplicado ao exemplo em questão seria assim:
Vantagens sobre o uso de 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.
local
chamadas 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.sequence
,traverse
métodos implementados de forma gratuita.Também gostaria de dizer o que não gosto no Reader.
pure
,local
e 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
FindUsers
classe em objeto. A respectiva linha de compreensão seria semelhante a: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.
fonte
Datastore
eEmailServer
são deixados como traços e outros se tornaramobject
s? Existe uma diferença fundamental nesses serviços / dependências / (como você os chama) que faz com que sejam tratados de forma diferente?EmailSender
para um objeto também, certo? Eu não seria capaz de expressar a dependência sem ter o tipo ...EmailSender
você 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á dependeFunction2
.(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;)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.
fonte
Config
qual contém uma referênciaUserRepository
. É 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 umConfig
com todas as dependências não significa que cada método depende de todos eles?config
e 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 :)