Bağımlılık Ekleme için Reader Monad: çoklu bağımlılıklar, iç içe çağrılar


87

Scala'da Bağımlılık Enjeksiyonu sorulduğunda, pek çok cevap, ya Scalaz'dan gelen ya da sadece kendi başınıza yuvarlanan Reader Monad'ı kullanmaya işaret ediyor. Yaklaşımın temellerini açıklayan bir dizi çok net makale var (örneğin Runar'ın konuşması , Jason'ın blogu ), ancak daha eksiksiz bir örnek bulmayı başaramadım ve bu yaklaşımın örneğin daha fazlasına göre avantajlarını göremedim. geleneksel "manuel" DI ( yazdığım rehbere bakın ). Muhtemelen önemli bir noktayı kaçırıyorum, dolayısıyla soru.

Örnek olarak şu sınıflara sahip olduğumuzu düşünelim:

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

Burada, "geleneksel" DI yaklaşımlarıyla çok iyi oynayan sınıfları ve yapıcı parametrelerini kullanarak şeyleri modelliyorum, ancak bu tasarımın birkaç iyi yanı var:

  • her işlevselliğin açıkça numaralandırılmış bağımlılıkları vardır. İşlevselliğin düzgün çalışması için bağımlılıkların gerçekten gerekli olduğunu varsayıyoruz.
  • bağımlılıklar işlevsellikler arasında gizlidir, örneğin bir veri deposuna ihtiyaç duyan UserReminderhiçbir fikri yoktur FindUsers. İşlevler ayrı derleme birimlerinde bile olabilir
  • sadece saf Scala kullanıyoruz; uygulamalar değişmez sınıflardan, daha yüksek dereceden fonksiyonlardan yararlanabilir, "iş mantığı" yöntemleri, IOeğer etkileri yakalamak istiyorsak , monad içinde sarılmış değerleri döndürebilir .

Bu, Reader monad ile nasıl modellenebilir? Her bir işlevin ne tür bağımlılıklara ihtiyaç duyduğu ve bir işlevin bağımlılıklarını diğerinden gizlemesi için yukarıdaki özellikleri korumak iyi olacaktır. classEs kullanmanın daha çok bir uygulama ayrıntısı olduğunu unutmayın; belki Reader monad kullanan "doğru" çözüm başka bir şey kullanacaktır.

Aşağıdakilerden birini öneren biraz alakalı bir soru buldum :

  • tüm bağımlılıklarla tek bir ortam nesnesi kullanmak
  • yerel ortamları kullanmak
  • "parfe" kalıbı
  • tür indeksli haritalar

Bununla birlikte, bu kadar basit bir şey için biraz fazla karmaşık (ama bu öznel) bir yana, tüm bu çözümlerde, örneğin retainUsersyöntem ( etkin olmayan kullanıcıları bulmaya emailInactiveçağıran inactive) Datastorebağımlılık hakkında bilgi sahibi olmalıdır. iç içe geçmiş işlevleri düzgün bir şekilde çağırabilmeli - yoksa yanılıyor muyum?

Böyle bir "iş uygulaması" için Reader Monad'ı hangi yönlerden kullanmak, yalnızca yapıcı parametrelerini kullanmaktan daha iyi olabilir?


1
Reader monad sihirli bir değnek değildir. Bence, çok sayıda bağımlılık seviyesine ihtiyacınız varsa, tasarımınız oldukça iyidir.
ZhekaKozlov

Bununla birlikte, genellikle Bağımlılık Enjeksiyonuna bir alternatif olarak tanımlanır; belki o zaman bir tamamlayıcı olarak tanımlanmalıdır? Bazen DI'nın "gerçek işlevsel programcılar" tarafından reddedildiği hissine kapılıyorum, bu yüzden "yerine ne var" diye merak ediyordum :) Her iki durumda da, birden çok bağımlılık düzeyine sahip olduğunuzu veya daha çok konuşmanız gereken birden çok harici hizmetin olduğunu düşünüyorum. gibi her orta-büyük "iş başvurusu" görünüyor (değil kesin kütüphaneler için durum)
adamw

2
Reader monad'ı her zaman yerel bir şey olarak düşünmüşümdür. Örneğin, yalnızca bir DB ile konuşan bir modülünüz varsa, bu modülü Reader monad stilinde uygulayabilirsiniz. Ancak, uygulamanız bir araya getirilmesi gereken birçok farklı veri kaynağı gerektiriyorsa, Reader monad'ın bunun için iyi olduğunu düşünmüyorum.
ZhekaKozlov

Ah, bu iki kavramın nasıl birleştirileceği konusunda iyi bir kılavuz olabilir. Ve sonra gerçekten de görünen o ki DI ve RM birbirini tamamlıyor. Sanırım yalnızca bir bağımlılıkta çalışan işlevlere sahip olmak oldukça yaygındır ve burada RM'yi kullanmak bağımlılık / veri sınırlarını netleştirmeye yardımcı olacaktır.
adamw

Yanıtlar:


37

Bu örnek nasıl modellenir

Bu, Reader monad ile nasıl modellenebilir?

Bu olmadığından emin değilim gerektiğini Reader ile modellenebilir, henüz tarafından şunlar olabilir:

  1. sınıfları, kodun Reader ile daha güzel oynamasını sağlayan işlevler olarak kodlamak
  2. Okuyucu ile işlevleri anlamak ve kullanmak için a'da oluşturma

Başlamadan hemen önce size bu cevap için faydalı olduğunu düşündüğüm küçük örnek kod ayarlamalarından bahsetmem gerekiyor. İlk değişiklik FindUsers.inactiveyöntemle ilgilidir. Geri dönmesine izin verdim, List[String]böylece adres listesi UserReminder.emailInactiveyöntemde kullanılabilir . Yöntemlere basit uygulamalar da ekledim. Son olarak, örnek, Reader monad'ın aşağıdaki elle haddelenmiş bir sürümünü kullanacaktır:

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

Modelleme adımı 1. Sınıfları işlev olarak kodlama

Belki bu isteğe bağlıdır, emin değilim, ama daha sonra anlamanın daha iyi görünmesini sağlar. Elde edilen işlevin curried olduğuna dikkat edin. Ayrıca, önceki yapıcı bağımsız değişkenlerini ilk parametreleri (parametre listesi) olarak alır. Bu şekilde

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

olur

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

Her unutmayın Dep, Arg, Restipleri tamamen keyfi olabilir: bir demet, bir işlev veya basit bir türü.

İşte ilk ayarlamalardan sonra fonksiyonlara dönüştürülmüş örnek kod:

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

Burada dikkat edilmesi gereken bir nokta, belirli işlevlerin tüm nesnelere değil, yalnızca doğrudan kullanılan parçalara bağlı olmasıdır. OOP sürüm UserReminder.emailInactive()örneğinde userFinder.inactive()burada çağrı yapacağı yerde, sadece inactive() ilk parametrede kendisine iletilen bir işlevi çağırır .

Lütfen kodun soruda istenen üç özelliği gösterdiğini unutmayın:

  1. her bir işlevselliğin ne tür bağımlılıklara ihtiyaç duyduğu açıktır
  2. bir işlevin bağımlılıklarını diğerinden gizler
  3. retainUsers yöntemin Datastore bağımlılığı hakkında bilgi sahibi olması gerekmez

Modelleme adımı 2. Okuyucu'nun işlevleri oluşturmak ve çalıştırmak için kullanılması

Reader monad, yalnızca tümü aynı türe bağlı olan işlevleri oluşturmanıza izin verir. Bu genellikle bir durum değildir. Örneğimizde FindUsers.inactivebağlıdır Datastoreve UserReminder.emailInactiveüzerinde EmailServer. Bu sorunu çözmek için, tüm bağımlılıkları içeren yeni bir tür (genellikle Config olarak adlandırılır) tanıtılabilir, ardından işlevler değiştirilerek hepsi buna bağlı olacak ve ondan yalnızca ilgili veriler alınabilir. Bu, bağımlılık yönetimi açısından açıkça yanlıştır çünkü bu şekilde, bu işlevleri ilk etapta bilmemeleri gereken türlere de bağımlı hale getirirsiniz.

Neyse ki, işlevin Configyalnızca bir kısmını parametre olarak kabul etse bile işlevin çalışmasını sağlamanın bir yolu olduğu ortaya çıktı . localReader'da tanımlanan bir yöntemdir . İlgili parçayı .NET Framework'ten çıkarmanın bir yolu sağlanmalıdır Config.

Eldeki örneğe uygulanan bu bilgi şöyle görünecektir:

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

}

Yapıcı parametrelerini kullanmanın avantajları

Böyle bir "iş uygulaması" için Reader Monad'ı hangi yönlerden kullanmak, yalnızca yapıcı parametrelerini kullanmaktan daha iyi olabilir?

Umarım bu cevabı hazırlayarak, basit kurucuları hangi yönlerden yeneceğini kendiniz yargılamayı kolaylaştırmışımdır. Yine de bunları sıralayacak olursam, işte listem. Sorumluluk Reddi: OOP geçmişim var ve Reader ve Kleisli'yi kullanmadığım için tam olarak takdir etmeyebilirim.

  1. Tekdüzelik - kavrama için ne kadar kısa / uzun olursa olsun, bu sadece bir Okuyucu ve onu başka bir örnekle kolayca oluşturabilirsiniz, belki de yalnızca bir tane daha Yapılandırma türü sunabilir ve localüzerine bazı çağrıları serpebilirsiniz . Bu nokta, IMO'dan ziyade bir zevk meselesidir, çünkü kurucuları kullandığınızda kimse, OOP'de kötü bir uygulama olarak kabul edilen yapıcıda çalışmak gibi aptalca bir şey yapmadıkça, sevdiğiniz şeyleri bestelemenizi engellemez.
  2. O ile ilgili tüm avantajlarını alır böylece Okuyucu, bir monad olan - sequence, traverseyöntemler ücretsiz olarak uygulanmaktadır.
  3. Bazı durumlarda, Reader'ı yalnızca bir kez oluşturmayı ve onu çok çeşitli Yapılandırmalar için kullanmayı tercih edebilirsiniz. Yapıcılar ile kimse bunu yapmanızı engellemez, sadece gelen her Yapılandırma için tüm nesne grafiğini yeniden oluşturmanız gerekir. Bununla ilgili bir sorunum olmasa da (bunu her başvuru talebinde yapmayı tercih ediyorum), sadece hakkında spekülasyon yapabileceğim nedenlerden dolayı birçok kişi için açık bir fikir değil.
  4. Reader sizi, ağırlıklı olarak FP stilinde yazılmış uygulamalarla daha iyi oynayacak olan işlevleri daha fazla kullanmaya yönlendirir.
  5. Okuyucu endişeleri ayırır; bağımlılık sağlamadan oluşturabilir, her şeyle etkileşim kurabilir, mantığı tanımlayabilirsiniz. Aslında daha sonra ayrı ayrı tedarik edin. (Bu nokta için Ken Scrambler'a teşekkürler). Bu genellikle Reader'ın avantajı olarak duyulur, ancak bu düz kurucularla da mümkündür.

Reader'da neyi sevmediğimi de söylemek isterim.

  1. Pazarlama. Bazen, Reader'ın her tür bağımlılık için pazarlandığı izlenimine kapılıyorum, bu bir oturum çerezi mi yoksa bir veritabanı mı? Bana göre Reader'ı bu örnekteki e-posta sunucusu veya depo gibi pratik olarak sabit nesneler için kullanmanın pek bir anlamı yok. Bu tür bağımlılıklar için düz kurucular ve / veya kısmen uygulanan işlevleri çok daha iyi buluyorum. Esasen Reader size esneklik sağlar, böylece her aramada bağımlılıklarınızı belirleyebilirsiniz, ancak buna gerçekten ihtiyacınız yoksa, yalnızca vergisini ödersiniz.
  2. Örtülü ağırlık - Reader'ı dolaylı olarak kullanmak, örneğin okunmasını zorlaştırır. Öte yandan, gürültülü kısımları implicits kullanarak gizlediğinizde ve bazı hatalar yaptığınızda, derleyici bazen mesajları deşifre etmeniz zor olabilir.
  3. İle Töreni pure, localve kendi Yapılandırma sınıfları / bunun için dizilerini kullanarak oluşturma. Okuyucu sizi sorunlu etki alanıyla ilgili olmayan bazı kodlar eklemeye zorlar, bu nedenle kodda biraz gürültü ortaya çıkar. Öte yandan, kurucuları kullanan bir uygulama genellikle fabrika modelini kullanır, bu da sorunlu alanın dışından gelir, bu nedenle bu zayıflık o kadar da ciddi değildir.

Ya sınıflarımı işlevli nesnelere dönüştürmek istemiyorsam?

İstediğiniz. Teknik olarak bundan kaçınabilirsiniz, ancak FindUserssınıfı nesneye dönüştürmezsem ne olacağına bakın . İlgili anlama satırı şöyle görünecektir:

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

hangisi o kadar okunabilir değil, değil mi? Önemli olan şu ki, Reader'ın işlevler üzerinde çalışması, bu nedenle zaten bunlara sahip değilseniz, bunları satır içi olarak inşa etmeniz gerekir, bu genellikle o kadar da hoş değildir.


Ayrıntılı cevap için teşekkürler :) Benim için net olmayan bir nokta, neden Datastoreve EmailServerözellik olarak bırakıldı ve diğerleri objects oldu ? Bu hizmetlerde / bağımlılıklarda / (onları nasıl adlandırırsınız) farklı muamele görmelerine neden olan temel bir fark var mı?
adamw

Peki ... Örneğin EmailSenderbir nesneye de dönüştüremem , değil mi? O zaman bağımlılığı tür olmadan ifade edemezdim ...
edemezdim adamw

Ah, bağımlılık daha sonra uygun bir türe sahip bir işlev biçimini alacaktır - bu nedenle tür adlarını kullanmak yerine, her şeyin işlev imzasına gitmesi gerekir (ad sadece rastlantısaldır). Belki, ama ikna olmadım;)
adamw

Doğru. Bunun yerine bağlı olarak EmailSenderbağlıdır ediyorum size (String, String) => Unit. Bu ikna edici olsun ya da olmasın başka bir konu :) Kesin olmak gerekirse, en azından daha genel, çünkü zaten herkes buna bağlı Function2.
Przemek Pokrywka

Bir tür takma adla değil, derleme zamanında kontrol edilen bir şeyle olsa da, bir anlam ifade etmesi için kesinlikle isim vermek istersiniz (String, String) => Unit;)
adamw

3

Bence temel fark, örneğinizde nesneler somutlaştırıldığında tüm bağımlılıkları enjekte ediyor olmanızdır. Okuyucu monad, temelde, bağımlılıklar verildiğinde, daha sonra en yüksek katmanlara döndürülen çağırmak için gittikçe daha karmaşık işlevler oluşturur. Bu durumda, işlev nihayet çağrıldığında enjeksiyon gerçekleşir.

Acil bir avantaj, özellikle monad'ınızı bir kez oluşturabilir ve ardından farklı enjekte edilen bağımlılıklarla kullanmak istiyorsanız esnekliktir. Bir dezavantaj, sizin de söylediğiniz gibi, potansiyel olarak daha az netliktir. Her iki durumda da, ara katmanın yalnızca acil bağımlılıkları hakkında bilgi sahibi olması gerekir, bu nedenle her ikisi de DI için ilan edildiği gibi çalışır.


Ara katman nasıl olur da yalnızca ara bağımlılıklarını bilir ve hepsini değil? Okuyucu monad kullanılarak örneğin nasıl uygulanabileceğini gösteren bir kod örneği verebilir misiniz?
adamw

Muhtemelen bunu Json'un blogundan daha iyi açıklayabilirim (sizin gönderdiğiniz) Oradan alıntı yapmak için "Örneklerden farklı olarak, userEmail ve userInfo imzalarının hiçbir yerinde UserRepository'ye sahip değiliz". Bu örneği dikkatlice kontrol edin.
Daniel Langdon

1
Evet ama bu, kullandığınız okuyucu monadının Configbir referans içeren parametreleştirildiğini varsayar UserRepository. Yani doğru, doğrudan imzada görünmüyor, ama daha da kötüsü, kodunuzun ilk bakışta hangi bağımlılıkları kullandığı hakkında hiçbir fikriniz yok. Bir kaynağa bağlı kalmamak mu Configbağlıdır hepsi ile bağımlılıkları her yöntemin türlü anlamına tüm bunların?
adamw

Onlara bağlı, ama bunu bilmek zorunda değil. Sizin sınıflarınızdakiyle aynı. Onları oldukça eşdeğer görüyorum :-)
Daniel Langdon

Sınıflarla ilgili örnekte, içinde tüm bağımlılıkları olan global bir nesneye değil, yalnızca gerçekte neye ihtiyacınız olduğuna bağlıdır. Ve küreselin "bağımlılıkları" nın içine neyin gireceğine configve "sadece bir işlev" in ne olduğuna nasıl karar vereceğiniz konusunda bir problem yaşarsınız . Muhtemelen siz de birçok kendine bağımlılık yaşarsınız. Her neyse, bu bir Soru-
Cevaptan

1

Kabul edilen cevap, Reader Monad'ın nasıl çalıştığına dair harika bir açıklama sağlar.

Cats Library Reader kullanarak farklı bağımlılıkları olan herhangi iki işlevi oluşturmak için bir tarif eklemek istiyorum. Bu pasaj aynı zamanda Scastie'de de mevcuttur

Oluşturmak istediğimiz iki işlevi tanımlayalım: İşlevler, kabul edilen yanıtta tanımlananlara benzer.

  1. Fonksiyonların bağlı olduğu kaynakları tanımlayın
  case class DataStore()
  case class EmailServer()
  1. DataStoreBağımlılıkla ilk işlevi tanımlayın . DataStoreEtkin olmayan Kullanıcıların Listesini alır ve döndürür
  def f1(db:DataStore):List[String] = List("john@test.com", "james@test.com", "maria@test.com")
  1. EmailServerBağımlılıktan biri olarak başka bir işlevi tanımlayın
  def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =

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

Şimdi iki işlevi oluşturmanın tarifi

  1. İlk önce Reader'ı Cats Kitaplığından içe aktarın
  import cats.data.Reader
  1. İkinci işlevi, yalnızca bir bağımlılığa sahip olacak şekilde değiştirin.
  val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)

Şimdi bir kullanıcıyı e-postaya alan başka bir işlevi f2alıyor EmailServerve döndürüyorList

  1. CombinedConfigİki işlev için bağımlılıklar içeren bir sınıf oluşturun
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
  1. 2 işlevi kullanarak Okuyucular oluşturun
  val r1 = Reader(f1)
  val r2 = Reader(f2)
  1. Okuyucuları, birleştirilmiş yapılandırma ile çalışabilecek şekilde değiştirin
  val r1g = r1.local((c:CombinedConfig) => c.dataStore)
  val r2g = r2.local((c:CombinedConfig) => c.emailServer)
  1. Okuyucuları Oluşturun
  val composition = for {
    u <- r1g
    e <- r2g
  } yield e(u)
  1. Geçmek CombinedConfigve kompozisyon çağırmak
  val myConfig = CombinedConfig(DataStore(), EmailServer())

  println("Invoking Composition")
  composition.run(myConfig)
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.