Reader Monad per Dependency Injection: più dipendenze, chiamate nidificate


87

Alla domanda su Dependency Injection in Scala, molte risposte indicano l'utilizzo della Reader Monad, quella di Scalaz o semplicemente la tua. Ci sono una serie di articoli molto chiari che descrivono le basi dell'approccio (ad esempio il discorso di Runar , il blog di Jason ), ma non sono riuscito a trovare un esempio più completo, e non riesco a vedere i vantaggi di quell'approccio rispetto ad es. DI "manuale" tradizionale (vedi la guida che ho scritto ). Molto probabilmente mi sto perdendo un punto importante, da cui la domanda.

A titolo di esempio, immaginiamo di avere queste classi:

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 = {}
}

Qui sto modellando le cose usando classi e parametri del costruttore, che gioca molto bene con gli approcci DI "tradizionali", tuttavia questo design ha un paio di lati positivi:

  • ogni funzionalità ha dipendenze chiaramente enumerate. In un certo senso presumiamo che le dipendenze siano davvero necessarie affinché la funzionalità funzioni correttamente
  • le dipendenze sono nascoste tra le funzionalità, ad esempio UserRemindernon ha idea che abbia FindUsersbisogno di un datastore. Le funzionalità possono essere anche in unità di compilazione separate
  • stiamo usando solo Scala pura; le implementazioni possono sfruttare classi immutabili, funzioni di ordine superiore, i metodi di "logica di business" possono restituire valori racchiusi nella IOmonade se vogliamo catturare gli effetti ecc.

Come potrebbe essere modellato con la monade Reader? Sarebbe bene mantenere le caratteristiche di cui sopra, in modo che sia chiaro quale tipo di dipendenze necessita ciascuna funzionalità e nascondere le dipendenze di una funzionalità da un'altra. Nota che l'uso di classes è più un dettaglio di implementazione; forse la soluzione "corretta" utilizzando la monade Reader userebbe qualcos'altro.

Ho trovato una domanda in qualche modo correlata che suggerisce:

  • utilizzando un unico oggetto ambiente con tutte le dipendenze
  • utilizzando ambienti locali
  • modello "parfait"
  • mappe indicizzate per tipo

Tuttavia, a parte essere (ma questo è soggettivo) un po 'troppo complesso come per una cosa così semplice, in tutte queste soluzioni ad esempio il retainUsersmetodo (che chiama emailInactive, che chiama inactiveper trovare gli utenti inattivi) dovrebbe conoscere la Datastoredipendenza, per essere in grado di chiamare correttamente le funzioni annidate - o mi sbaglio?

In quali aspetti sarebbe meglio utilizzare Reader Monad per una simile "applicazione aziendale" rispetto al semplice utilizzo dei parametri del costruttore?


1
La monade Lettore non è una pallottola d'argento. Penso che se hai bisogno di molti livelli di dipendenze, il tuo design è abbastanza buono.
ZhekaKozlov

Tuttavia è spesso descritto come un'alternativa all'inserimento delle dipendenze; forse dovrebbe quindi essere descritto come un complemento? A volte ho la sensazione che la DI sia ignorata dai "veri programmatori funzionali", quindi mi chiedevo "cosa invece" :) Ad ogni modo, penso che avere più livelli di dipendenza, o piuttosto più servizi esterni con cui devi parlare sia come ogni "applicazione aziendale" di dimensioni medio-grandi assomiglia (non è sicuramente il caso delle biblioteche)
adamw

2
Sono sempre stato considerato la monade Reader come qualcosa di locale. Ad esempio, se hai un modulo che parla solo a un DB, puoi implementare questo modulo nello stile monade Reader. Tuttavia, se la tua applicazione richiede molte fonti di dati diverse che dovrebbero essere combinate insieme, non penso che la monade Reader sia buona per questo.
ZhekaKozlov

Ah, potrebbe essere una buona linea guida su come combinare i due concetti. E poi in effetti sembrerebbe che DI e RM si completino a vicenda. Immagino che sia in effetti abbastanza comune avere funzioni che operano su una sola dipendenza, e l'uso di RM qui aiuterebbe a chiarire i confini di dipendenza / dati.
adamw

Risposte:


36

Come modellare questo esempio

Come potrebbe essere modellato con la monade Reader?

Non sono sicuro che questo debba essere modellato con il Reader, ma può essere da:

  1. codificare le classi come funzioni che rendono il codice più piacevole con Reader
  2. comporre le funzioni con Reader in un per la comprensione e il suo utilizzo

Appena prima dell'inizio ho bisogno di parlarti di piccoli aggiustamenti del codice di esempio che ho ritenuto vantaggioso per questa risposta. Il primo cambiamento riguarda il FindUsers.inactivemetodo. Lascio che ritorni in List[String]modo che l'elenco di indirizzi possa essere utilizzato nel UserReminder.emailInactivemetodo. Ho anche aggiunto semplici implementazioni ai metodi. Infine, l'esempio utilizzerà una seguente versione manuale della monade Reader:

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)
}

Fase di modellazione 1. Codifica delle classi come funzioni

Forse è facoltativo, non ne sono sicuro, ma in seguito migliora l'aspetto della comprensione. Nota, quella funzione risultante è curry. Accetta anche gli argomenti precedenti del costruttore come primo parametro (elenco di parametri). Quel modo

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

diventa

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

Tenere presente che ciascuna Dep, Arg, Restipi possono essere del tutto arbitraria: una tupla, una funzione o un tipo semplice.

Ecco il codice di esempio dopo le regolazioni iniziali, trasformato in funzioni:

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 da notare qui è che particolari funzioni non dipendono dall'intero oggetto, ma solo dalle parti utilizzate direttamente. Dove nella versione OOP l' UserReminder.emailInactive()istanza chiamerebbe userFinder.inactive()qui, chiama semplicemente inactive() - una funzione passata ad essa nel primo parametro.

Tieni presente che il codice mostra le tre proprietà desiderabili dalla domanda:

  1. è chiaro che tipo di dipendenze necessita ogni funzionalità
  2. nasconde le dipendenze di una funzionalità da un'altra
  3. retainUsers non dovrebbe essere necessario conoscere la dipendenza Datastore

Fase di modellazione 2. Utilizzo del lettore per comporre funzioni ed eseguirle

Reader monad ti consente di comporre solo funzioni che dipendono tutte dallo stesso tipo. Questo spesso non è un caso. Nel nostro esempio FindUsers.inactivedipende da Datastoree UserReminder.emailInactiveda EmailServer. Per risolvere questo problema si potrebbe introdurre un nuovo tipo (spesso indicato come Config) che contiene tutte le dipendenze, quindi modificare le funzioni in modo che dipendano tutte da esso e trarre da esso solo i dati rilevanti. Questo ovviamente è sbagliato dal punto di vista della gestione delle dipendenze perché in questo modo si rendono queste funzioni anche dipendenti da tipi che non dovrebbero conoscere in primo luogo.

Fortunatamente si scopre che esiste un modo per far funzionare la funzione Configanche se accetta solo una parte di essa come parametro. È un metodo chiamato local, definito in Reader. Deve essere fornito un modo per estrarre la parte rilevante dal file Config.

Questa conoscenza applicata all'esempio in esame sarebbe simile a quella:

object Main extends App {

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

  val config = Config(
    new Datastore { def runQuery(query: String) = List("john.doe@fizzbuzz.com") },
    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)()

}

Vantaggi rispetto all'utilizzo dei parametri del costruttore

In quali aspetti sarebbe meglio utilizzare Reader Monad per una simile "applicazione aziendale" rispetto al semplice utilizzo dei parametri del costruttore?

Spero che preparando questa risposta sia stato più facile giudicare da soli in quali aspetti avrebbe battuto i semplici costruttori. Tuttavia, se dovessi elencarli, ecco la mia lista. Dichiarazione di non responsabilità: ho un background OOP e potrei non apprezzare completamente Reader e Kleisli poiché non li uso.

  1. Uniformità - non importa quanto sia breve / lunga la comprensione, è solo un Reader e puoi comporlo facilmente con un'altra istanza, magari introducendo solo un altro tipo di configurazione e aggiungendovi localsopra alcune chiamate. Questo punto è IMO piuttosto una questione di gusti, perché quando usi i costruttori nessuno ti impedisce di comporre le cose che ti piacciono, a meno che qualcuno non faccia qualcosa di stupido, come lavorare in un costruttore che è considerato una cattiva pratica in OOP.
  2. Reader è una monade, quindi ottiene tutti i vantaggi relativi a questo sequence, traversemetodi implementati gratuitamente.
  3. In alcuni casi potresti trovare preferibile costruire il Reader solo una volta e usarlo per un'ampia gamma di configurazioni. Con i costruttori nessuno ti impedisce di farlo, devi solo ricostruire l'intero oggetto grafico di nuovo per ogni configurazione in arrivo. Anche se non ho problemi con questo (preferisco persino farlo su ogni richiesta all'applicazione), non è un'idea ovvia per molte persone per ragioni su cui posso solo speculare.
  4. Reader ti spinge a utilizzare maggiormente le funzioni, che giocheranno meglio con applicazioni scritte prevalentemente in stile FP.
  5. Il lettore separa le preoccupazioni; puoi creare, interagire con tutto, definire la logica senza fornire dipendenze. Effettivamente fornire in seguito, separatamente. (Grazie Ken Scrambler per questo punto). Questo è spesso sentito trarre vantaggio da Reader, ma è anche possibile con semplici costruttori.

Vorrei anche dire cosa non mi piace in Reader.

  1. Marketing. A volte ho l'impressione che Reader sia commercializzato per tutti i tipi di dipendenze, senza distinzione se si tratta di un cookie di sessione o di un database. Per me ha poco senso usare Reader per oggetti praticamente costanti, come il server di posta elettronica o il repository di questo esempio. Per tali dipendenze trovo che i semplici costruttori e / o le funzioni parzialmente applicate siano molto migliori. Essenzialmente Reader ti offre flessibilità in modo da poter specificare le tue dipendenze ad ogni chiamata, ma se non ne hai davvero bisogno, paghi solo le sue tasse.
  2. Pesantezza implicita: l'utilizzo di Reader senza impliciti renderebbe l'esempio difficile da leggere. D'altra parte, quando nascondi le parti rumorose usando gli impliciti e commetti qualche errore, il compilatore a volte ti darà dei messaggi difficili da decifrare.
  3. Cerimonia con pure, locale la creazione di classi Config / usando tuple per questo. Reader ti obbliga ad aggiungere del codice che non riguarda il dominio del problema, introducendo quindi un po 'di rumore nel codice. D'altra parte, un'applicazione che utilizza i costruttori spesso utilizza il modello di fabbrica, che è anche al di fuori del dominio del problema, quindi questa debolezza non è così grave.

E se non volessi convertire le mie classi in oggetti con funzioni?

Tu vuoi. Tecnicamente puoi evitarlo, ma guarda cosa succederebbe se non convertissi la FindUsersclasse in oggetto. La rispettiva riga di per la comprensione sarebbe simile a:

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

che non è leggibile, vero? Il punto è che Reader opera sulle funzioni, quindi se non le hai già, devi costruirle in linea, il che spesso non è così carino.


Grazie per la risposta dettagliata :) Un punto che non mi è chiaro, è perché Datastoree EmailServersono lasciati come tratti, e altri sono diventati objects? C'è una differenza fondamentale in questi servizi / dipendenze / (come li chiami tu) che fa sì che vengano trattati in modo diverso?
adamw

Beh ... non posso convertire ad esempio anche EmailSenderin un oggetto, giusto? Non sarei quindi in grado di esprimere la dipendenza senza avere il tipo ...
adamw

Ah, la dipendenza prenderebbe quindi la forma di una funzione con un tipo appropriato, quindi invece di usare i nomi dei tipi, tutto dovrebbe andare nella firma della funzione (il nome è solo casuale). Forse, ma non sono convinto;)
adamw

Corretta. Invece di dipendere da EmailSendercui dipenderesti (String, String) => Unit. Che sia convincente o meno è un altro problema :) Per essere certi, è almeno più generico, poiché tutti già dipendono da Function2.
Przemek Pokrywka

Beh, sicuramente vorresti dare un nome in (String, String) => Unit modo che trasmetta un significato, anche se non con un alias di tipo ma con qualcosa che viene controllato in fase di compilazione;)
adamw

3

Penso che la differenza principale sia che nel tuo esempio stai iniettando tutte le dipendenze quando gli oggetti vengono istanziati. La monade Reader fondamentalmente costruisce funzioni sempre più complesse da chiamare date le dipendenze, che vengono poi restituite ai livelli più alti. In questo caso, l'iniezione avviene quando la funzione viene finalmente chiamata.

Un vantaggio immediato è la flessibilità, specialmente se puoi costruire la tua monade una volta e poi vuoi usarla con diverse dipendenze iniettate. Uno svantaggio è, come dici tu, potenzialmente meno chiarezza. In entrambi i casi, il livello intermedio deve solo conoscere le loro dipendenze immediate, quindi funzionano entrambi come pubblicizzato per DI.


Come farebbe il livello intermedio a conoscere solo le proprie dipendenze intermedie e non tutte? Potresti fornire un esempio di codice che mostri come l'esempio potrebbe essere implementato utilizzando la monade del lettore?
adamw

Probabilmente potrei spiegarlo non meglio del blog di Json (che hai pubblicato) Per citare il modulo "A differenza dell'esempio implicito, non abbiamo UserRepository da nessuna parte nelle firme di userEmail e userInfo". Controlla attentamente quell'esempio.
Daniel Langdon

1
Ebbene sì, ma questo presuppone che la monade del lettore che stai utilizzando sia parametrizzata con la Configquale contiene un riferimento UserRepository. Quindi è vero, non è direttamente visibile nella firma, ma direi che è anche peggio, non hai idea di quali dipendenze il tuo codice stia usando a prima vista. Essere dipendenti da a Configcon tutte le dipendenze non significa che ogni metodo dipende da tutti loro?
adamw

Dipende da loro, ma non deve saperlo. Come nel tuo esempio con le classi. Li vedo abbastanza equivalenti :-)
Daniel Langdon

Nell'esempio con le classi dipendi solo da ciò di cui hai effettivamente bisogno, non da un oggetto globale con tutte le dipendenze all'interno. E si pone un problema su come decidere cosa va dentro le "dipendenze" del globale confige cosa è "solo una funzione". Probabilmente ti ritroveresti anche con molte dipendenze personali. Ad ogni modo, è più una questione di preferenza che una
domanda
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.