Cuando se le preguntó acerca de la inyección de dependencia en Scala, muchas respuestas apuntan al uso de Reader Monad, ya sea el de Scalaz o simplemente el suyo. Hay una serie de artículos muy claros que describen los conceptos básicos del enfoque (por ejemplo, la charla de Runar , el blog de Jason ), pero no pude encontrar un ejemplo más completo, y no veo las ventajas de ese enfoque sobre, por ejemplo, un más DI "manual" tradicional (consulte la guía que escribí ). Lo más probable es que me esté perdiendo algún punto importante, de ahí la pregunta.
Solo como ejemplo, imaginemos que tenemos estas clases:
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 = {}
}
Aquí estoy modelando cosas usando clases y parámetros de constructor, lo que juega muy bien con los enfoques DI "tradicionales", sin embargo, este diseño tiene un par de aspectos positivos:
- cada funcionalidad tiene dependencias claramente enumeradas. Suponemos que las dependencias son realmente necesarias para que la funcionalidad funcione correctamente
- las dependencias están ocultas entre funcionalidades, por ejemplo,
UserReminder
no tiene idea de queFindUsers
necesita un almacén de datos. Las funcionalidades pueden estar incluso en unidades de compilación separadas - estamos usando sólo Scala puro; las implementaciones pueden aprovechar clases inmutables, funciones de orden superior, los métodos de "lógica de negocios" pueden devolver valores envueltos en la
IO
mónada si queremos capturar los efectos, etc.
¿Cómo podría modelarse esto con la mónada Reader? Sería bueno conservar las características anteriores, de modo que quede claro qué tipo de dependencias necesita cada funcionalidad y ocultar las dependencias de una funcionalidad de otra. Tenga en cuenta que el uso de class
es es más un detalle de implementación; tal vez la solución "correcta" que usa la mónada Reader usaría otra cosa.
Encontré una pregunta algo relacionada que sugiere:
- usando un solo objeto de entorno con todas las dependencias
- usando entornos locales
- patrón de "parfait"
- mapas indexados por tipo
Sin embargo, aparte de ser (pero eso es subjetivo) un poco demasiado complejo como para algo tan simple, en todas estas soluciones, por ejemplo, el retainUsers
método (que llama emailInactive
, que llama inactive
para encontrar a los usuarios inactivos) necesitaría saber sobre la Datastore
dependencia, para ser capaz de llamar correctamente a las funciones anidadas, ¿o me equivoco?
¿En qué aspectos sería mejor usar Reader Monad para una "aplicación comercial" de este tipo que simplemente usar parámetros de constructor?
Respuestas:
Cómo modelar este ejemplo
No estoy seguro de si esto debería modelarse con el Reader, pero puede ser así:
Justo antes del comienzo, necesito informarle sobre pequeños ajustes de código de muestra que me parecieron beneficiosos para esta respuesta. El primer cambio tiene que ver con el
FindUsers.inactive
método. Dejo que regreseList[String]
para que la lista de direcciones se pueda usar en elUserReminder.emailInactive
método. También agregué implementaciones simples a los métodos. Finalmente, la muestra utilizará una siguiente versión enrollada a mano de Reader mónada: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) }
Paso de modelado 1. Codificar clases como funciones
Tal vez sea opcional, no estoy seguro, pero luego hace que la comprensión se vea mejor. Tenga en cuenta que la función resultante se curry. También toma los argumentos del constructor anterior como primer parámetro (lista de parámetros). De esa manera
class Foo(dep: Dep) { def bar(arg: Arg): Res = ??? } // usage: val result = new Foo(dependency).bar(arg)
se convierte en
object Foo { def bar: Dep => Arg => Res = ??? } // usage: val result = Foo.bar(dependency)(arg)
Tenga en cuenta que cada una de
Dep
,Arg
,Res
tipos pueden ser completamente arbitraria: una tupla, una función o un tipo simple.Aquí está el código de muestra después de los ajustes iniciales, transformado en funciones:
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() } }
Una cosa a tener en cuenta aquí es que las funciones particulares no dependen de todos los objetos, sino solo de las partes que se usan directamente. Donde en la versión OOP la
UserReminder.emailInactive()
instancia llamaríauserFinder.inactive()
aquí, solo llamainactive()
: una función que se le pasa en el primer parámetro.Tenga en cuenta que el código presenta las tres propiedades deseables de la pregunta:
retainUsers
El método no debería necesitar conocer la dependencia de DatastoreModelado del paso 2. Uso del Reader para componer funciones y ejecutarlas
Reader monad le permite componer funciones que dependen todas del mismo tipo. A menudo, este no es un caso. En nuestro ejemplo
FindUsers.inactive
depende deDatastore
yUserReminder.emailInactive
deEmailServer
. Para resolver ese problema, uno podría introducir un nuevo tipo (a menudo denominado Config) que contenga todas las dependencias, luego cambiar las funciones para que todas dependan de él y solo tomen de él los datos relevantes. Obviamente, eso es incorrecto desde la perspectiva de la administración de dependencias porque de esa manera hace que estas funciones también dependan de tipos que no deberían conocer en primer lugar.Afortunadamente, resulta que existe una forma de hacer que la función funcione
Config
incluso si acepta solo una parte de ella como parámetro. Es un método llamadolocal
, definido en Reader. Debe contar con una forma de extraer la parte relevante del archivoConfig
.Este conocimiento aplicado al ejemplo en cuestión se vería así:
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)() }
Ventajas sobre el uso de parámetros de constructor
Espero que al preparar esta respuesta haya hecho que sea más fácil juzgar por sí mismo en qué aspectos vencería a los constructores simples. Sin embargo, si tuviera que enumerarlos, aquí está mi lista. Descargo de responsabilidad: tengo experiencia en programación orientada a objetos y es posible que no aprecie completamente a Reader y Kleisli ya que no los uso.
local
llamadas encima. Este punto es en mi opinión más bien una cuestión de gustos, porque cuando usas constructores nadie te impide componer lo que quieras, a menos que alguien haga algo estúpido, como trabajar en constructor, lo cual se considera una mala práctica en OOP.sequence
,traverse
métodos implementados de forma gratuita.También me gustaría contar lo que no me gusta en Reader.
pure
,local
y la creación de propias clases config / tuplas usando para ello. Reader te obliga a agregar un código que no se trata del dominio del problema, por lo que introduce algo de ruido en el código. Por otro lado, una aplicación que usa constructores a menudo usa un patrón de fábrica, que también es externo al dominio del problema, por lo que esta debilidad no es tan grave.¿Qué pasa si no quiero convertir mis clases en objetos con funciones?
Usted quiere. Técnicamente puedes evitar eso, pero mira lo que sucedería si no convirtiera la
FindUsers
clase en objeto. La línea respectiva de para la comprensión se vería así:getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
que no es tan legible, ¿verdad? El punto es que Reader opera con funciones, por lo que si aún no las tiene, debe construirlas en línea, lo que a menudo no es tan bonito.
fuente
Datastore
yEmailServer
quedan como rasgos, y otros se convirtieron enobject
s? ¿Existe una diferencia fundamental en estos servicios / dependencias / (como los llame) que haga que se traten de manera diferente?EmailSender
en un objeto también, ¿verdad? Entonces no podría expresar la dependencia sin tener el tipo ...EmailSender
lo que dependería(String, String) => Unit
. Si eso es convincente o no es otra cuestión :) Para estar seguro, es más genérico al menos, ya que todo el mundo ya depende deFunction2
.(String, String) => Unit
para que transmita algún significado, aunque no con un alias de tipo, sino con algo que se verifica en tiempo de compilación;)Creo que la principal diferencia es que en su ejemplo está inyectando todas las dependencias cuando se crean instancias de objetos. La mónada Reader básicamente construye funciones cada vez más complejas para llamar dadas las dependencias, que luego son devueltas a las capas más altas. En este caso, la inyección ocurre cuando finalmente se llama a la función.
Una ventaja inmediata es la flexibilidad, especialmente si puede construir su mónada una vez y luego quiere usarla con diferentes dependencias inyectadas. Una desventaja es, como usted dice, potencialmente menos claridad. En ambos casos, la capa intermedia solo necesita conocer sus dependencias inmediatas, por lo que ambos funcionan como se anuncia para DI.
fuente
Config
que contiene una referenciaUserRepository
. Es cierto que no es directamente visible en la firma, pero yo diría que es aún peor, no tienes idea de qué dependencias usa tu código a primera vista. ¿No depender de aConfig
con todas las dependencias significa que cada método depende de todas ellas?config
y qué es "sólo una función". Probablemente también terminarías con muchas autodependencias. De todos modos, eso es más una cuestión de preferencia que una pregunta y respuesta :)