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
UserReminder
tidak memiliki gagasan yangFindUsers
membutuhkan 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
IO
monad 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 class
es 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 retainUsers
metode (yang memanggil emailInactive
, yang memanggil inactive
untuk menemukan pengguna yang tidak aktif) perlu mengetahui tentang Datastore
ketergantungan, 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?
Jawaban:
Bagaimana memodelkan contoh ini
Saya tidak yakin apakah ini harus dimodelkan dengan Pembaca, namun bisa juga dengan:
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.inactive
metode. Saya membiarkannya kembaliList[String]
sehingga daftar alamat dapat digunakan dalamUserReminder.emailInactive
metode. 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
,Res
jenis 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 diuserFinder.inactive()
sini itu hanya panggilaninactive()
- sebuah fungsi yang diteruskan ke parameter pertama.Harap dicatat, bahwa kode tersebut menunjukkan tiga properti yang diinginkan dari pertanyaan:
retainUsers
Metode seharusnya tidak perlu tahu tentang dependensi DatastorePemodelan 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.inactive
bergantung padaDatastore
danUserReminder.emailInactive
terusEmailServer
. 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
Config
meskipun ia hanya menerima sebagian darinya sebagai parameter. Ini adalah metode yang disebutlocal
, didefinisikan di Reader. Itu perlu disediakan dengan cara untuk mengekstrak bagian yang relevan dariConfig
.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
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.
local
panggilan 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.sequence
,traverse
metode yang diterapkan secara gratis.Saya juga ingin memberi tahu apa yang tidak saya sukai di Pustaka.
pure
,local
dan 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
FindUsers
kelas 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.
sumber
Datastore
danEmailServer
dibiarkan sebagai ciri, dan yang lain menjadiobject
s? Apakah ada perbedaan mendasar dalam layanan / ketergantungan / (bagaimanapun Anda menyebutnya) yang menyebabkan mereka diperlakukan berbeda?EmailSender
menjadi sebuah objek juga, kan? Saya kemudian tidak akan dapat mengungkapkan ketergantungan tanpa memiliki tipe ...EmailSender
Anda(String, String) => Unit
. Apakah itu meyakinkan atau tidak adalah masalah lain :) Yang pasti, ini lebih umum setidaknya, karena semua orang sudah bergantungFunction2
.(String, String) => Unit
sehingga itu menyampaikan beberapa makna, meskipun tidak dengan alias tipe tetapi dengan sesuatu yang diperiksa pada waktu kompilasi;)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.
sumber
Config
yang berisi referensi keUserRepository
. 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 aConfig
dengan semua dependensi berarti setiap jenis metode bergantung pada semuanya ?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 :)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.
case class DataStore() case class EmailServer()
DataStore
ketergantungan. DibutuhkanDataStore
dan mengembalikan Daftar Pengguna yang tidak aktifdef f1(db:DataStore):List[String] = List("[email protected]", "[email protected]", "[email protected]")
EmailServer
sebagai salah satu ketergantungandef f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit = usersToEmail.foreach(user => println(s"emailing ${user} using server ${emailServer}"))
Sekarang resep untuk menyusun dua fungsi
import cats.data.Reader
val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)
Sekarang
f2
mengambilEmailServer
, dan mengembalikan fungsi lain yang membawaList
pengguna ke emailCombinedConfig
kelas yang berisi dependensi untuk dua fungsicase class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
val r1 = Reader(f1) val r2 = Reader(f2)
val r1g = r1.local((c:CombinedConfig) => c.dataStore) val r2g = r2.local((c:CombinedConfig) => c.emailServer)
val composition = for { u <- r1g e <- r2g } yield e(u)
CombinedConfig
dan panggil komposisinyaval myConfig = CombinedConfig(DataStore(), EmailServer()) println("Invoking Composition") composition.run(myConfig)
sumber