Reader Monad para inyección de dependencia: dependencias múltiples, llamadas anidadas

87

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, UserReminderno tiene idea de que FindUsersnecesita 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 IOmó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 classes 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 retainUsersmétodo (que llama emailInactive, que llama inactivepara encontrar a los usuarios inactivos) necesitaría saber sobre la Datastoredependencia, 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?

Adamw
fuente
1
La mónada del lector no es una panacea. Creo que, si necesita muchos niveles de dependencias, su diseño es bastante bueno.
ZhekaKozlov
Sin embargo, a menudo se describe como una alternativa a la inyección de dependencia; ¿quizás debería describirse entonces como un complemento? A veces tengo la sensación de que DI es descartado por "verdaderos programadores funcionales", por lo que me preguntaba "qué en su lugar" :) De cualquier manera, creo que tener múltiples niveles de dependencias, o más bien múltiples servicios externos con los que necesitas hablar es cómo cada medianas y grandes aplicaciones de negocios "" Parece que (no es el caso para las bibliotecas de seguro)
adamw
2
Siempre he pensado en la mónada Reader como algo local. Por ejemplo, si tiene algún módulo que habla solo con una base de datos, puede implementar este módulo en el estilo de mónada de Reader. Sin embargo, si su aplicación requiere varias fuentes de datos que deben combinarse, no creo que la mónada Reader sea buena para eso.
ZhekaKozlov
Ah, esa podría ser una buena guía sobre cómo combinar los dos conceptos. Y entonces, de hecho, parecería que DI y RM se complementan. Supongo que, de hecho, es bastante común tener funciones que operen en una sola dependencia, y el uso de RM aquí ayudaría a aclarar los límites de dependencia / datos.
Adamw

Respuestas:

36

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 así:

  1. codificar las clases como funciones, lo que hace que el código funcione mejor con Reader
  2. componiendo las funciones con Reader en un para su comprensión y uso

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.inactivemétodo. Dejo que regrese List[String]para que la lista de direcciones se pueda usar en el UserReminder.emailInactivemé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, Restipos 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ía userFinder.inactive()aquí, solo llama inactive() : 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:

  1. está claro qué tipo de dependencias necesita cada funcionalidad
  2. oculta las dependencias de una funcionalidad de otra
  3. retainUsers El método no debería necesitar conocer la dependencia de Datastore

Modelado 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.inactivedepende de Datastorey UserReminder.emailInactivede EmailServer. 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 Configincluso si acepta solo una parte de ella como parámetro. Es un método llamado local, definido en Reader. Debe contar con una forma de extraer la parte relevante del archivo 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 utilizar Reader Monad para una "aplicación empresarial" de este tipo que simplemente utilizar 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.

  1. Uniformidad: no importa cuán corto / largo sea el para la comprensión, es solo un lector y puede componerlo fácilmente con otra instancia, quizás solo introduciendo un tipo de configuración más y agregando algunas localllamadas 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.
  2. Reader es una mónada, por lo que obtiene todos los beneficios relacionados con eso sequence, traversemétodos implementados de forma gratuita.
  3. En algunos casos, puede que sea preferible compilar 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 objetos de nuevo para cada configuración entrante. Si bien no tengo ningún problema con eso (incluso prefiero hacer eso en cada solicitud a la solicitud), no es una idea obvia para muchas personas por razones sobre las que solo puedo especular.
  4. Reader lo empuja a usar más funciones, lo que funcionará mejor con aplicaciones escritas predominantemente en estilo FP.
  5. El lector separa preocupaciones; puedes crear, interactuar con todo, definir la lógica sin proporcionar dependencias. En realidad, suministre más tarde, por separado. (Gracias Ken Scrambler por este punto). Esto a menudo se escucha como una ventaja de Reader, pero también es posible con constructores simples.

También me gustaría contar lo que no me gusta en Reader.

  1. Márketing. A veces tengo la impresión de que Reader se comercializa para todo tipo de dependencias, sin distinción de si es 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, encuentro mejores constructores simples y / o funciones parcialmente aplicadas. Esencialmente, Reader le brinda flexibilidad para que pueda especificar sus dependencias en cada llamada, pero si realmente no lo necesita, solo paga sus impuestos.
  2. Pesadez implícita: usar Reader sin implícitos haría que el ejemplo fuera difícil de leer. Por otro lado, cuando oculta las partes ruidosas usando implícitos y comete algún error, el compilador a veces le dará mensajes difíciles de descifrar.
  3. Ceremonia con pure, localy 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 FindUsersclase 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.

Przemek Pokrywka
fuente
Gracias por la respuesta detallada :) Un punto que no me queda claro, es por qué Datastorey EmailServerquedan como rasgos, y otros se convirtieron en objects? ¿Existe una diferencia fundamental en estos servicios / dependencias / (como los llame) que haga que se traten de manera diferente?
Adamw
Bueno ... no puedo convertir, por ejemplo, EmailSenderen un objeto también, ¿verdad? Entonces no podría expresar la dependencia sin tener el tipo ...
Adamw
Ah, la dependencia tomaría la forma de una función con un tipo apropiado, por lo que en lugar de usar nombres de tipo, todo tendría que ir a la firma de la función (el nombre es solo incidental). Tal vez, pero no estoy convencido;)
Adamw
Correcto. En lugar de depender de EmailSenderlo 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 de Function2.
Przemek Pokrywka
Bueno, ciertamente querrás nombrar (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;)
Adamw
3

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.

Daniel Langdon
fuente
¿Cómo sabría la capa intermedia solo sobre sus dependencias intermedias, y no todas? ¿Podría dar un ejemplo de código que muestre cómo se podría implementar el ejemplo usando la mónada del lector?
Adamw
Probablemente no podría explicarlo mejor que el blog de Json (que usted publicó). Para citar el formulario allí "A diferencia del ejemplo de implicits, no tenemos UserRepository en ninguna parte de las firmas de userEmail y userInfo". Revise ese ejemplo con cuidado.
Daniel Langdon
1
Bueno, sí, pero esto supone que la mónada lectora que estás usando está parametrizada con la Configque contiene una referencia UserRepository. 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 a Configcon todas las dependencias significa que cada método depende de todas ellas?
Adamw
Depende de ellos, pero no tiene por qué saberlo. Igual que en tu ejemplo con clases. Los veo como bastante equivalentes :-)
Daniel Langdon
En el ejemplo con clases, solo depende de lo que realmente necesita, no de un objeto global con todas las dependencias dentro. Y surge un problema sobre cómo decidir qué va dentro de las "dependencias" de lo global configy 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 :)
Adamw