Pembaca Monad untuk Injeksi Ketergantungan: beberapa ketergantungan, panggilan bertingkat

87

Ketika ditanya tentang Dependency Injection di Scala, cukup banyak jawaban yang mengarah pada penggunaan Reader Monad, baik yang dari Scalaz maupun yang bergulir sendiri. Ada sejumlah artikel yang sangat jelas yang menjelaskan dasar-dasar pendekatan tersebut (misalnya pembicaraan Runar , blog Jason ), tetapi saya tidak berhasil menemukan contoh yang lebih lengkap, dan saya gagal melihat keuntungan dari pendekatan itu dibandingkan mis. DI "manual" tradisional (lihat panduan yang saya tulis ). Kemungkinan besar saya kehilangan beberapa poin penting, maka pertanyaannya.

Sebagai contoh, mari kita bayangkan kita memiliki kelas-kelas ini:

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

Di sini saya memodelkan hal-hal menggunakan kelas dan parameter konstruktor, yang berfungsi sangat baik dengan pendekatan DI "tradisional", namun desain ini memiliki beberapa sisi baik:

  • setiap fungsi memiliki dependensi yang disebutkan dengan jelas. Kami berasumsi bahwa dependensi benar-benar diperlukan agar fungsionalitas berfungsi dengan baik
  • dependensi tersembunyi di seluruh fungsionalitas, misalnya UserRemindertidak memiliki gagasan yang FindUsersmembutuhkan datastore. Fungsinya bahkan bisa di unit kompilasi terpisah
  • kami hanya menggunakan Scala murni; implementasi dapat memanfaatkan kelas yang tidak dapat diubah, fungsi tingkat tinggi, metode "logika bisnis" dapat mengembalikan nilai yang dibungkus dalam IOmonad jika kita ingin menangkap efek, dll.

Bagaimana ini bisa dimodelkan dengan Reader monad? Sebaiknya pertahankan karakteristik di atas, sehingga jelas jenis dependensi yang dibutuhkan setiap fungsionalitas, dan menyembunyikan dependensi satu fungsionalitas dari fungsional lain. Perhatikan bahwa menggunakan classes lebih merupakan detail implementasi; mungkin solusi "benar" menggunakan Reader monad akan menggunakan sesuatu yang lain.

Saya menemukan pertanyaan yang agak terkait yang menyarankan:

  • menggunakan satu objek lingkungan dengan semua dependensi
  • menggunakan lingkungan lokal
  • pola "parfait"
  • peta jenis indeks

Namun, selain menjadi (tapi itu subjektif) agak terlalu rumit untuk hal yang sederhana, dalam semua solusi ini misalnya retainUsersmetode (yang memanggil emailInactive, yang memanggil inactiveuntuk menemukan pengguna yang tidak aktif) perlu mengetahui tentang Datastoreketergantungan, untuk dapat memanggil fungsi bersarang dengan benar - atau apakah saya salah?

Dalam aspek apa menggunakan Reader Monad untuk "aplikasi bisnis" seperti itu lebih baik daripada hanya menggunakan parameter konstruktor?

adamw
sumber
1
The Reader monad bukanlah peluru perak. Saya rasa, jika Anda membutuhkan banyak level dependensi, desain Anda cukup bagus.
ZhekaKozlov
Namun ini sering digambarkan sebagai alternatif untuk Injeksi Ketergantungan; mungkin itu harus digambarkan sebagai pelengkap? Terkadang saya merasa DI diberhentikan oleh "pemrogram fungsional sejati", oleh karena itu saya bertanya-tanya "apa yang sebaliknya" :) Bagaimanapun juga, menurut saya memiliki beberapa tingkat ketergantungan, atau lebih tepatnya beberapa layanan eksternal yang perlu Anda bicarakan adalah bagaimana tampaknya setiap "aplikasi bisnis" menengah-besar (tidak untuk perpustakaan pasti)
adamw
2
Saya selalu berpikir tentang Reader monad sebagai sesuatu yang lokal. Misalnya, jika Anda memiliki beberapa modul yang hanya berbicara ke DB, Anda dapat mengimplementasikan modul ini dalam gaya monad Reader. Namun, jika aplikasi Anda membutuhkan banyak sumber data yang berbeda yang harus digabungkan bersama, menurut saya Reader monad tidak bagus untuk itu.
ZhekaKozlov
Ah, itu bisa jadi pedoman bagus bagaimana menggabungkan kedua konsep tersebut. Dan memang tampaknya DI dan RM saling melengkapi. Saya rasa sebenarnya cukup umum untuk memiliki fungsi yang beroperasi hanya pada satu ketergantungan, dan menggunakan RM di sini akan membantu memperjelas batasan ketergantungan / data.
adamw

Jawaban:

37

Bagaimana memodelkan contoh ini

Bagaimana ini bisa dimodelkan dengan Reader monad?

Saya tidak yakin apakah ini harus dimodelkan dengan Pembaca, namun bisa juga dengan:

  1. mengkodekan kelas sebagai fungsi yang membuat permainan kode lebih bagus dengan Reader
  2. menyusun fungsi dengan Reader untuk pemahaman dan menggunakannya

Tepat sebelum memulai, saya perlu memberi tahu Anda tentang sedikit penyesuaian kode sampel yang menurut saya bermanfaat untuk jawaban ini. Perubahan pertama adalah tentang FindUsers.inactivemetode. Saya membiarkannya kembali List[String]sehingga daftar alamat dapat digunakan dalam UserReminder.emailInactivemetode. Saya juga menambahkan implementasi sederhana ke metode. Terakhir, contoh akan menggunakan versi linting manual dari Reader monad:

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

Langkah pemodelan 1. Mengkodekan kelas sebagai fungsi

Mungkin itu opsional, saya tidak yakin, tapi nanti itu membuat pemahaman for terlihat lebih baik. Perhatikan, fungsi yang dihasilkan adalah kari. Ini juga mengambil argumen konstruktor sebelumnya sebagai parameter pertama mereka (daftar parameter). Lewat situ

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

menjadi

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

Perlu diingat bahwa masing-masing Dep, Arg, Resjenis dapat benar-benar sewenang-wenang: tupel, fungsi atau tipe sederhana.

Berikut kode contoh setelah penyesuaian awal, diubah menjadi fungsi:

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

Satu hal yang perlu diperhatikan di sini adalah bahwa fungsi tertentu tidak bergantung pada keseluruhan objek, tetapi hanya pada bagian yang digunakan secara langsung. Di mana dalam versi OOP UserReminder.emailInactive()contoh akan memanggil di userFinder.inactive()sini itu hanya panggilan inactive() - sebuah fungsi yang diteruskan ke parameter pertama.

Harap dicatat, bahwa kode tersebut menunjukkan tiga properti yang diinginkan dari pertanyaan:

  1. jelas jenis dependensi yang dibutuhkan setiap fungsionalitas
  2. menyembunyikan ketergantungan dari satu fungsi dari yang lain
  3. retainUsers Metode seharusnya tidak perlu tahu tentang dependensi Datastore

Pemodelan langkah 2. Menggunakan Pembaca untuk membuat fungsi dan menjalankannya

Reader monad memungkinkan Anda hanya membuat fungsi yang semuanya bergantung pada jenis yang sama. Ini seringkali bukan kasus. Dalam contoh kita FindUsers.inactivebergantung pada Datastoredan UserReminder.emailInactiveterus EmailServer. Untuk mengatasi masalah itu, seseorang dapat memperkenalkan tipe baru (sering disebut sebagai Config) yang berisi semua dependensi, kemudian mengubah fungsinya sehingga semuanya bergantung padanya dan hanya mengambil data yang relevan darinya. Itu jelas salah dari perspektif manajemen ketergantungan karena dengan cara itu Anda membuat fungsi-fungsi ini juga bergantung pada jenis yang seharusnya tidak mereka ketahui sejak awal.

Untungnya, ternyata ada cara untuk membuat fungsi tersebut berfungsi Configmeskipun ia hanya menerima sebagian darinya sebagai parameter. Ini adalah metode yang disebut local, didefinisikan di Reader. Itu perlu disediakan dengan cara untuk mengekstrak bagian yang relevan dari Config.

Pengetahuan ini yang diterapkan pada contoh yang ada akan terlihat seperti ini:

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

}

Keuntungan menggunakan parameter konstruktor

Dalam aspek apa menggunakan Reader Monad untuk "aplikasi bisnis" seperti itu lebih baik daripada hanya menggunakan parameter konstruktor?

Saya berharap bahwa dengan mempersiapkan jawaban ini saya mempermudah untuk menilai sendiri dalam aspek apa itu mengalahkan konstruktor biasa. Namun jika saya harus menghitungnya, inilah daftar saya. Penafian: Saya memiliki latar belakang OOP dan saya mungkin tidak menghargai Reader dan Kleisli sepenuhnya karena saya tidak menggunakannya.

  1. Keseragaman - tidak peduli seberapa pendek / panjang pemahaman for, ini hanya Reader dan Anda dapat dengan mudah menyusunnya dengan instance lain, mungkin hanya memperkenalkan satu jenis Config lagi dan menambahkan beberapa localpanggilan di atasnya. Poin ini IMO lebih merupakan masalah selera, karena ketika Anda menggunakan konstruktor tidak ada yang mencegah Anda untuk membuat apa pun yang Anda suka, kecuali seseorang melakukan sesuatu yang bodoh, seperti melakukan pekerjaan di konstruktor yang dianggap praktik buruk di OOP.
  2. Pembaca adalah monad, jadi ia mendapat semua manfaat yang terkait dengan itu - sequence, traversemetode yang diterapkan secara gratis.
  3. Dalam beberapa kasus, Anda mungkin merasa lebih baik untuk membangun Pembaca hanya sekali dan menggunakannya untuk berbagai Konfigurasi. Dengan konstruktor tidak ada yang menghalangi Anda untuk melakukan itu, Anda hanya perlu membangun seluruh grafik objek lagi untuk setiap Config yang masuk. Meskipun saya tidak memiliki masalah dengan itu (saya bahkan lebih suka melakukan itu pada setiap permintaan untuk melamar), itu bukan ide yang jelas bagi banyak orang karena alasan yang mungkin hanya saya spekulasi.
  4. Pembaca mendorong Anda untuk menggunakan lebih banyak fungsi, yang akan bermain lebih baik dengan aplikasi yang ditulis dalam gaya FP.
  5. Pembaca memisahkan perhatian; Anda dapat membuat, berinteraksi dengan semuanya, mendefinisikan logika tanpa memberikan dependensi. Sebenarnya suplai nanti, secara terpisah. (Terima kasih Ken Scrambler untuk poin ini). Ini sering terdengar keuntungan dari Reader, tetapi itu juga mungkin dengan konstruktor biasa.

Saya juga ingin memberi tahu apa yang tidak saya sukai di Pustaka.

  1. Pemasaran. Terkadang saya mendapat kesan, bahwa Reader dipasarkan untuk semua jenis dependensi, tanpa perbedaan apakah itu cookie sesi atau database. Bagi saya, tidak ada gunanya menggunakan Pustaka untuk objek yang praktis konstan, seperti server email atau repositori dari contoh ini. Untuk dependensi seperti itu, saya menemukan konstruktor biasa dan / atau fungsi yang diterapkan sebagian jauh lebih baik. Pada dasarnya Reader memberi Anda fleksibilitas sehingga Anda dapat menentukan dependensi Anda di setiap panggilan, tetapi jika Anda tidak benar-benar membutuhkannya, Anda hanya membayar pajaknya.
  2. Bobot tersirat - menggunakan Pustaka tanpa implikasi akan membuat contoh sulit dibaca. Di sisi lain, ketika Anda menyembunyikan bagian yang berisik menggunakan implik dan membuat beberapa kesalahan, kompiler terkadang akan memberi Anda pesan yang sulit diuraikan.
  3. Upacara dengan pure, localdan membuat kelas Config sendiri / menggunakan tupel untuk itu. Pembaca memaksa Anda untuk menambahkan beberapa kode yang bukan tentang domain masalah, oleh karena itu menimbulkan beberapa gangguan dalam kode. Di sisi lain, aplikasi yang menggunakan konstruktor seringkali menggunakan pola pabrik, yang juga berasal dari luar domain masalah, sehingga kelemahan ini tidak terlalu serius.

Bagaimana jika saya tidak ingin mengonversi kelas saya menjadi objek dengan fungsi?

Kamu ingin. Anda secara teknis dapat menghindari itu, tetapi lihat saja apa yang akan terjadi jika saya tidak mengonversi FindUserskelas ke objek. Baris masing-masing untuk pemahaman akan terlihat seperti:

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

mana yang tidak bisa dibaca, bukan? Intinya adalah Pembaca beroperasi pada fungsi, jadi jika Anda belum memilikinya, Anda perlu membuatnya sebaris, yang seringkali tidak terlalu bagus.

Przemek Pokrywka
sumber
Terima kasih atas jawaban rinci :) Satu hal yang tidak jelas bagi saya, adalah mengapa Datastoredan EmailServerdibiarkan sebagai ciri, dan yang lain menjadi objects? Apakah ada perbedaan mendasar dalam layanan / ketergantungan / (bagaimanapun Anda menyebutnya) yang menyebabkan mereka diperlakukan berbeda?
adamw
Yah ... Saya tidak bisa mengubah misalnya EmailSendermenjadi sebuah objek juga, kan? Saya kemudian tidak akan dapat mengungkapkan ketergantungan tanpa memiliki tipe ...
adamw
Ah, ketergantungan kemudian akan mengambil bentuk fungsi dengan tipe yang sesuai - jadi alih-alih menggunakan nama tipe, semuanya harus masuk ke fungsi tanda tangan (namanya hanya insidental). Mungkin, tapi saya tidak yakin;)
adamw
Benar. Alih-alih bergantung pada EmailSenderAnda (String, String) => Unit. Apakah itu meyakinkan atau tidak adalah masalah lain :) Yang pasti, ini lebih umum setidaknya, karena semua orang sudah bergantung Function2.
Przemek Pokrywka
Anda pasti ingin memberi nama (String, String) => Unit sehingga itu menyampaikan beberapa makna, meskipun tidak dengan alias tipe tetapi dengan sesuatu yang diperiksa pada waktu kompilasi;)
adamw
3

Saya pikir perbedaan utamanya adalah dalam contoh Anda, Anda memasukkan semua dependensi saat objek dibuat instance-nya. Monad Reader pada dasarnya membangun fungsi yang lebih dan lebih kompleks untuk dipanggil mengingat dependensi, yang kemudian dikembalikan ke lapisan tertinggi. Dalam kasus ini, injeksi terjadi ketika fungsi tersebut akhirnya dipanggil.

Satu keuntungan langsung adalah fleksibilitas, terutama jika Anda dapat membuat monad sekali dan kemudian ingin menggunakannya dengan dependensi yang diinjeksi berbeda. Salah satu kelemahannya adalah, seperti yang Anda katakan, kemungkinan kurang jelas. Dalam kedua kasus tersebut, lapisan perantara hanya perlu mengetahui tentang dependensi langsungnya, sehingga keduanya berfungsi seperti yang diiklankan untuk DI.

Daniel Langdon
sumber
Bagaimana lapisan perantara mengetahui hanya tentang ketergantungan perantara mereka, dan tidak semuanya? Bisakah Anda memberikan contoh kode yang menunjukkan bagaimana contoh tersebut dapat diimplementasikan menggunakan reader monad?
adamw
Saya mungkin bisa menjelaskannya tidak lebih baik daripada blog Json (yang Anda posting) Untuk mengutip formulir di sana "Tidak seperti dalam contoh implikasinya, kami tidak memiliki UserRepository di mana pun dalam tanda tangan userEmail dan userInfo". Periksa contoh itu dengan cermat.
Daniel Langdon
1
Ya tapi ini mengasumsikan bahwa pembaca monad yang Anda gunakan adalah parameter Configyang berisi referensi ke UserRepository. Memang benar, itu tidak langsung terlihat di tanda tangan, tetapi saya akan mengatakan itu lebih buruk lagi, Anda tidak tahu benar-benar dependensi mana yang digunakan kode Anda pada pandangan pertama. Bukankah bergantung pada a Configdengan semua dependensi berarti setiap jenis metode bergantung pada semuanya ?
adamw
Itu memang bergantung pada mereka, tetapi tidak harus mengetahuinya. Sama seperti dalam contoh Anda dengan kelas. Saya melihat mereka cukup setara :-)
Daniel Langdon
Dalam contoh dengan kelas Anda hanya bergantung pada apa yang sebenarnya Anda butuhkan, bukan objek global dengan semua dependensi di dalamnya. Dan Anda mendapatkan masalah tentang bagaimana memutuskan apa yang ada di dalam "ketergantungan" global config, dan apa yang "hanya fungsi". Mungkin Anda akan berakhir dengan banyak ketergantungan pada diri sendiri juga. Bagaimanapun, itu lebih merupakan diskusi masalah preferensi daripada Q&A :)
adamw
1

Jawaban yang diterima memberikan penjelasan yang bagus tentang cara kerja Reader Monad.

Saya ingin menambahkan resep untuk membuat dua fungsi yang memiliki ketergantungan berbeda-beda menggunakan Cats Library Reader. Cuplikan ini juga tersedia di Scastie

Mari kita tentukan dua fungsi yang ingin kita buat: Fungsinya mirip dengan yang didefinisikan dalam jawaban yang diterima.

  1. Tentukan resource tempat fungsi bergantung
  case class DataStore()
  case class EmailServer()
  1. Tentukan fungsi pertama dengan DataStoreketergantungan. Dibutuhkan DataStoredan mengembalikan Daftar Pengguna yang tidak aktif
  def f1(db:DataStore):List[String] = List("[email protected]", "[email protected]", "[email protected]")
  1. Tentukan fungsi lain dengan EmailServersebagai salah satu ketergantungan
  def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =

    usersToEmail.foreach(user => println(s"emailing ${user} using server ${emailServer}"))

Sekarang resep untuk menyusun dua fungsi

  1. Pertama, impor Pembaca dari Perpustakaan Kucing
  import cats.data.Reader
  1. Ubah fungsi kedua sehingga hanya memiliki satu ketergantungan.
  val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)

Sekarang f2mengambil EmailServer, dan mengembalikan fungsi lain yang membawa Listpengguna ke email

  1. Buat CombinedConfigkelas yang berisi dependensi untuk dua fungsi
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
  1. Buat Pembaca menggunakan 2 fungsi
  val r1 = Reader(f1)
  val r2 = Reader(f2)
  1. Ubah Pembaca sehingga mereka dapat bekerja dengan konfigurasi gabungan
  val r1g = r1.local((c:CombinedConfig) => c.dataStore)
  val r2g = r2.local((c:CombinedConfig) => c.emailServer)
  1. Tulis Pembaca
  val composition = for {
    u <- r1g
    e <- r2g
  } yield e(u)
  1. Lewati CombinedConfigdan panggil komposisinya
  val myConfig = CombinedConfig(DataStore(), EmailServer())

  println("Invoking Composition")
  composition.run(myConfig)
Lincoln D
sumber