scala - Reader Monad para inyección de dependencias: dependencias múltiples, llamadas anidadas
dependency-injection scalaz (2)
Cómo modelar este ejemplo
¿Cómo podría modelarse esto con la mónada Reader?
No estoy seguro de si esto debería modelarse con el Reader, pero puede ser por:
- codificar las clases como funciones que hacen que el código se reproduzca mejor con Reader
- componiendo las funciones con Reader para comprenderlas y usarlas
Justo antes del comienzo, necesito informarle sobre pequeños ajustes de código de muestra que me pareció beneficioso para esta respuesta.
El primer cambio es sobre el método
FindUsers.inactive
.
Dejé que devuelva
List[String]
para que la lista de direcciones se pueda usar en el método
UserReminder.emailInactive
.
También he agregado implementaciones simples a los métodos.
Finalmente, la muestra usará la 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. Codificación de clases como funciones
Tal vez eso sea opcional, no estoy seguro, pero luego hace que la comprensión se vea mejor. Tenga en cuenta que la función resultante es curry. También toma los argumentos del constructor anterior como su 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
object Foo {
def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
Tenga en cuenta que cada uno de los tipos
Dep
,
Arg
,
Res
puede ser completamente arbitrario: 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 los objetos completos, sino solo de las partes utilizadas directamente.
Donde en la versión OOP, la instancia
UserReminder.emailInactive()
llamaría a
userFinder.inactive()
aquí solo llama a
inactive()
, una función que se le pasa en el primer parámetro.
Tenga en cuenta que el código exhibe las tres propiedades deseables de la pregunta:
- está claro qué tipo de dependencias necesita cada funcionalidad
- oculta las dependencias de una funcionalidad de otra
-
retainUsers
métodoretainUsers
no debería necesitar saber sobre la dependencia del almacén de datos
Paso de modelado 2. Uso del lector para componer funciones y ejecutarlas
Reader monad solo le permite componer funciones que dependen del mismo tipo.
Esto a menudo no es un caso.
En nuestro ejemplo
FindUsers.inactive
depende de
Datastore
y
UserReminder.emailInactive
en
EmailServer
.
Para resolver ese problema, se podría introducir un nuevo tipo (a menudo denominado Config) que contiene todas las dependencias, luego cambiar las funciones para que todas dependan de él y solo tomar de él los datos relevantes.
Obviamente, eso está mal desde la perspectiva de la gestión de dependencias porque de esa manera usted hace que estas funciones también dependan de tipos que no deberían conocer en primer lugar.
Afortunadamente, resulta que existe una manera de hacer que la función funcione con
Config
incluso si solo acepta una parte de ella como parámetro.
Es un método llamado
local
, definido en Reader.
Debe proporcionarse una forma de extraer la parte relevante de la
Config
.
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
¿En qué aspectos sería mejor usar el Reader Monad para una "aplicación comercial" como simplemente usar parámetros de constructor?
Espero que al preparar esta respuesta, sea más fácil juzgar por ti mismo en qué aspectos superaría a los constructores simples. Sin embargo, si tuviera que enumerarlos, aquí está mi lista. Descargo de responsabilidad: Tengo antecedentes de OOP y es posible que no aprecie completamente a Reader y Kleisli ya que no los uso.
-
Uniformidad: no importa cuán corto / largo sea la comprensión, es solo un lector y puede componerlo fácilmente con otra instancia, tal vez solo introduciendo un tipo de configuración más y rociando algunas llamadas
local
sobre él. Este punto es IMO 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 hacer un trabajo en constructor, lo que se considera una mala práctica en OOP. -
Reader es una mónada, por lo que obtiene todos los beneficios relacionados con eso:
sequence
, métodos detraverse
implementados de forma gratuita. - En algunos casos, es preferible construir el Reader solo una vez y usarlo para una amplia gama de configuraciones. Con los constructores, nadie le impide hacer eso, solo necesita construir todo el gráfico de objeto nuevamente para cada Config entrante. Si bien no tengo ningún problema con eso (incluso prefiero hacerlo en cada solicitud de solicitud), no es una idea obvia para muchas personas por razones de las que solo puedo especular.
- Reader lo empuja a usar más funciones, que jugarán mejor con aplicaciones escritas predominantemente en estilo FP.
- El lector separa las preocupaciones; Puede crear, interactuar con todo, definir la lógica sin proporcionar dependencias. Realmente suministro más tarde, por separado. (Gracias Ken Scrambler por este punto). Esto a menudo se escucha ventaja de Reader, pero eso también es posible con constructores simples.
También me gustaría decir lo que no me gusta en Reader.
- Márketing. A veces me da la impresión de que Reader se comercializa para todo tipo de dependencias, sin distinción si se trata de una cookie de sesión o una base de datos. Para mí, tiene poco sentido usar Reader para objetos prácticamente constantes, como el servidor de correo electrónico o el repositorio de este ejemplo. Para tales dependencias, creo que los constructores simples y / o las funciones parcialmente aplicadas son mucho mejores. Esencialmente, Reader le brinda flexibilidad para que pueda especificar sus dependencias en cada llamada, pero si realmente no lo necesita, solo paga su impuesto.
- Pesadez implícita: el uso de Reader sin implicidades dificultaría la lectura del ejemplo. Por otro lado, cuando ocultas las partes ruidosas usando implicits y cometes algún error, el compilador a veces te dará mensajes difíciles de descifrar.
-
Ceremonia con clases
pure
,local
y de creación de configuraciones propias / uso de tuplas para eso. Reader lo obliga a agregar un código que no se trata del dominio del problema, por lo tanto, 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 está fuera del dominio del problema, por lo que esta debilidad no es tan grave.
¿Qué sucede si no quiero convertir mis clases en objetos con funciones?
Usted quiere.
Técnicamente
puedes
evitar eso, pero solo mira lo que sucedería si no convirtiera la clase
FindUsers
en objeto.
La línea respectiva de comprensión se vería así:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
lo cual no es tan legible, ¿verdad? El punto es que Reader funciona con funciones, por lo que si aún no las tiene, debe construirlas en línea, lo que a menudo no es tan bonito.
Cuando se les preguntó acerca de la inyección de dependencia en Scala, muchas respuestas apuntan al uso de Reader Monad, ya sea la de Scalaz o simplemente la suya. 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, más DI "manual" tradicional (vea la guía que escribí ). Lo más probable es que me falte 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, que juega muy bien con los enfoques DI "tradicionales", sin embargo, este diseño tiene un par de buenos lados:
- cada funcionalidad tiene dependencias claramente enumeradas. Asumimos que las dependencias son realmente necesarias para que la funcionalidad funcione correctamente
-
las dependencias están ocultas en las funcionalidades, por ejemplo,
UserReminder
no tiene idea de queFindUsers
necesita un almacén de datos. Las funcionalidades pueden ser incluso en unidades de compilación separadas -
estamos usando solo Scala puro;
las implementaciones pueden aprovechar clases inmutables, funciones de orden superior, los métodos de "lógica empresarial" pueden devolver valores envueltos en la mónada
IO
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 la
class
es es más un detalle de implementación;
tal vez la solución "correcta" usando la mónada Reader usaría otra cosa.
Encontré una pregunta algo relacionada que sugiere:
- utilizando un único objeto de entorno con todas las dependencias
- utilizando entornos locales
- patrón "parfait"
- mapas indexados por tipo
Sin embargo, aparte de ser (pero eso es subjetivo) un poco complejo como para una cosa tan simple, en todas estas soluciones, por ejemplo, el método
retainUsers
(que llama a
emailInactive
, que llama a
inactive
para encontrar a los usuarios inactivos) necesitaría saber sobre Dependencia del
Datastore
, para poder invocar correctamente las funciones anidadas, ¿o me equivoco?
¿En qué aspectos sería mejor usar el Reader Monad para una "aplicación comercial" como simplemente usar parámetros de constructor?
Creo que la principal diferencia es que en su ejemplo está inyectando todas las dependencias cuando se instancian los objetos. La mónada Reader básicamente construye funciones cada vez más complejas para llamar dadas las dependencias, que luego se devuelven 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 desea usarla con diferentes dependencias inyectadas. Una desventaja es, como usted dice, potencialmente menos claridad. En ambos casos, la capa intermedia solo necesita saber acerca de sus dependencias inmediatas, por lo que ambos funcionan como se anuncia para DI.