Reader Monad cho Dependency Injection: nhiều phụ thuộc, lệnh gọi lồng nhau


87

Khi được hỏi về Dependency Injection trong Scala, khá nhiều câu trả lời chỉ ra rằng bạn nên sử dụng Reader Monad, hoặc là từ Scalaz hoặc chỉ sử dụng của riêng bạn. Có một số bài viết rất rõ ràng mô tả những điều cơ bản của cách tiếp cận (ví dụ như bài nói chuyện của Runar , blog của Jason ), nhưng tôi không tìm được ví dụ đầy đủ hơn và tôi không thấy được lợi thế của cách tiếp cận đó so với DI "thủ công" truyền thống (xem hướng dẫn tôi đã viết ). Hầu hết có lẽ tôi đang thiếu một số điểm quan trọng, do đó câu hỏi.

Chỉ là một ví dụ, hãy tưởng tượng chúng ta có các lớp sau:

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

Ở đây tôi đang mô hình hóa mọi thứ bằng cách sử dụng các lớp và tham số hàm tạo, hoạt động rất độc đáo với các cách tiếp cận DI "truyền thống", tuy nhiên thiết kế này có một vài mặt tốt:

  • mỗi chức năng có các phụ thuộc được liệt kê rõ ràng. Chúng tôi giả định rằng các phần phụ thuộc thực sự cần thiết để chức năng hoạt động bình thường
  • các phụ thuộc được ẩn trên các chức năng, ví dụ như UserReminderkhông có ý tưởng FindUserscần một kho dữ liệu. Các chức năng thậm chí có thể nằm trong các đơn vị biên dịch riêng biệt
  • chúng tôi chỉ đang sử dụng Scala thuần túy; các triển khai có thể tận dụng các lớp bất biến, các hàm bậc cao hơn, các phương thức "logic nghiệp vụ" có thể trả về các giá trị được bao bọc trong IOđơn nguyên nếu chúng ta muốn nắm bắt các hiệu ứng, v.v.

Làm thế nào điều này có thể được mô hình hóa với đơn nguyên Reader? Sẽ rất tốt nếu giữ lại các đặc điểm ở trên, để có thể rõ ràng loại phụ thuộc nào mà mỗi chức năng cần và ẩn các phụ thuộc của chức năng này với chức năng khác. Lưu ý rằng việc sử dụng classes là một chi tiết triển khai; có thể giải pháp "đúng" bằng cách sử dụng đơn nguyên Reader sẽ sử dụng cách khác.

Tôi đã tìm thấy một câu hỏi hơi liên quan gợi ý:

  • sử dụng một đối tượng môi trường duy nhất với tất cả các phụ thuộc
  • sử dụng môi trường cục bộ
  • mẫu "parfait"
  • loại bản đồ được lập chỉ mục

Tuy nhiên, ngoài việc (nhưng đó là chủ quan) hơi quá phức tạp đối với một điều đơn giản như vậy, trong tất cả các giải pháp này, ví dụ: retainUsersphương thức (lệnh gọi emailInactive, lệnh gọi inactiveđể tìm người dùng không hoạt động) sẽ cần biết về sự Datastorephụ thuộc, để có thể gọi đúng các hàm lồng nhau - hay tôi nhầm?

Ở những khía cạnh nào việc sử dụng Reader Monad cho một "ứng dụng kinh doanh" như vậy sẽ tốt hơn so với việc chỉ sử dụng các tham số của hàm tạo?


1
Đơn nguyên Reader không phải là một viên đạn bạc. Tôi nghĩ, nếu bạn yêu cầu nhiều mức độ phụ thuộc, thiết kế của bạn khá tốt.
ZhekaKozlov

Tuy nhiên, nó thường được mô tả là một giải pháp thay thế cho Dependency Injection; có lẽ sau đó nó nên được mô tả như một phần bổ sung? Đôi khi tôi có cảm giác rằng DI bị "các nhà lập trình chức năng thực thụ" gạt bỏ, do đó tôi đã tự hỏi "cái gì thay thế" :) Dù bằng cách nào, tôi nghĩ rằng có nhiều cấp độ phụ thuộc, hoặc đúng hơn là nhiều dịch vụ bên ngoài mà bạn cần phải nói chuyện là như thế nào mọi "ứng dụng kinh doanh" vừa và lớn đều trông như thế nào (chắc chắn không phải trường hợp cho các thư viện)
adamw

2
Tôi đã luôn nghĩ về đơn nguyên Reader như một cái gì đó địa phương. Ví dụ: nếu bạn có một số mô-đun chỉ nói chuyện với DB, bạn có thể triển khai mô-đun này theo kiểu đơn nguyên Reader. Tuy nhiên, nếu ứng dụng của bạn yêu cầu nhiều nguồn dữ liệu khác nhau nên được kết hợp với nhau, tôi không nghĩ rằng đơn nguyên Reader là tốt cho điều đó.
ZhekaKozlov

À, đó có thể là một hướng dẫn tốt về cách kết hợp hai khái niệm. Và thực sự thì dường như DI và RM bổ sung cho nhau. Tôi đoán rằng thực tế là khá phổ biến khi có các hàm chỉ hoạt động trên một phụ thuộc và sử dụng RM ở đây sẽ giúp làm rõ ranh giới phụ thuộc / dữ liệu.
adamw

Câu trả lời:


36

Làm thế nào để mô hình hóa ví dụ này

Làm thế nào điều này có thể được mô hình hóa với đơn nguyên Reader?

Tôi không chắc liệu điều này có nên được lập mô hình với Reader hay không, nhưng nó có thể được thực hiện bằng cách:

  1. mã hóa các lớp dưới dạng các hàm làm cho mã chơi đẹp hơn với Reader
  2. soạn các chức năng với Reader để hiểu và sử dụng nó

Ngay trước khi bắt đầu, tôi cần cho bạn biết về những điều chỉnh mã mẫu nhỏ mà tôi cảm thấy có lợi cho câu trả lời này. Thay đổi đầu tiên là về FindUsers.inactivephương pháp. Tôi để nó trả về List[String]để danh sách các địa chỉ có thể được sử dụng trong UserReminder.emailInactivephương thức. Tôi cũng đã thêm các triển khai đơn giản vào các phương thức. Cuối cùng, mẫu sẽ sử dụng phiên bản Reader monad được cuộn thủ công sau:

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

Bước mô hình hóa 1. Mã hóa các lớp dưới dạng hàm

Có lẽ đó là tùy chọn, tôi không chắc, nhưng sau này nó làm cho việc hiểu rõ hơn. Lưu ý, chức năng kết quả đó bị hạn chế. Nó cũng lấy (các) đối số của hàm tạo trước đây làm tham số đầu tiên của chúng (danh sách tham số). Đường đó

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

trở thành

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

Hãy ghi nhớ rằng mỗi người Dep, Arg, Resloại hoàn toàn có thể tùy ý: một tuple, một chức năng hoặc một loại đơn giản.

Đây là mã mẫu sau khi điều chỉnh ban đầu, được chuyển đổi thành các hàm:

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

Một điều cần lưu ý ở đây là các chức năng cụ thể không phụ thuộc vào toàn bộ đối tượng, mà chỉ phụ thuộc vào các bộ phận được sử dụng trực tiếp. Trường hợp trong phiên bản OOP UserReminder.emailInactive()sẽ gọi userFinder.inactive()ở đây nó chỉ gọi inactive() - một hàm được truyền cho nó trong tham số đầu tiên.

Xin lưu ý rằng mã hiển thị ba thuộc tính mong muốn từ câu hỏi:

  1. rõ ràng mỗi chức năng cần loại phụ thuộc nào
  2. ẩn sự phụ thuộc của một chức năng này với một chức năng khác
  3. retainUsers phương thức không cần biết về sự phụ thuộc vào Kho dữ liệu

Bước lập mô hình 2. Sử dụng Trình đọc để soạn các hàm và chạy chúng

Đơn nguyên trình đọc cho phép bạn chỉ soạn các hàm phụ thuộc vào cùng một loại. Đây thường không phải là một trường hợp. Trong ví dụ của chúng tôi FindUsers.inactivephụ thuộc vào DatastoreUserReminder.emailInactivevào EmailServer. Để giải quyết vấn đề đó, người ta có thể giới thiệu một kiểu mới (thường được gọi là Cấu hình) chứa tất cả các phụ thuộc, sau đó thay đổi các chức năng để tất cả chúng phụ thuộc vào nó và chỉ lấy từ nó dữ liệu có liên quan. Điều đó rõ ràng là sai từ quan điểm quản lý phụ thuộc bởi vì theo cách đó bạn làm cho các chức năng này cũng phụ thuộc vào các loại mà ngay từ đầu chúng không nên biết.

May mắn thay, hóa ra có một cách để làm cho hàm hoạt động Configngay cả khi nó chỉ chấp nhận một số phần của nó làm tham số. Đó là một phương thức được gọi local, được định nghĩa trong Reader. Nó cần được cung cấp một cách để trích xuất phần có liên quan từ Config.

Kiến thức này được áp dụng cho ví dụ dưới đây sẽ trông giống như sau:

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

}

Ưu điểm khi sử dụng các tham số của hàm tạo

Ở những khía cạnh nào việc sử dụng Reader Monad cho một "ứng dụng kinh doanh" như vậy sẽ tốt hơn so với việc chỉ sử dụng các tham số của hàm tạo?

Tôi hy vọng rằng bằng cách chuẩn bị câu trả lời này, tôi sẽ dễ dàng tự đánh giá hơn về những khía cạnh nào mà nó sẽ đánh bại các nhà xây dựng đơn giản. Tuy nhiên, nếu tôi phải liệt kê những thứ này, đây là danh sách của tôi. Tuyên bố từ chối trách nhiệm: Tôi có nền tảng OOP và tôi có thể không đánh giá cao Reader và Kleisli hoàn toàn vì tôi không sử dụng chúng.

  1. Tính đồng nhất - không có vấn đề gì về độ ngắn / dài để hiểu, nó chỉ là một Trình đọc và bạn có thể dễ dàng soạn nó với một phiên bản khác, có lẽ chỉ giới thiệu thêm một loại Cấu hình và rắc một số locallệnh lên trên nó. Điểm này là IMO đúng hơn là một vấn đề của sở thích, bởi vì khi bạn sử dụng các hàm tạo, không ai ngăn cản bạn soạn bất cứ thứ gì bạn thích, trừ khi ai đó làm điều gì đó ngu ngốc, như làm công việc trong hàm tạo được coi là một hành vi xấu trong OOP.
  2. Reader là một đơn nguyên, vì vậy nó nhận được tất cả các lợi ích liên quan đến điều đó - sequence, traversecác phương pháp được triển khai miễn phí.
  3. Trong một số trường hợp, bạn có thể thấy chỉ nên xây dựng Trình đọc một lần và sử dụng nó cho nhiều loại Cấu hình. Với các hàm tạo không ai ngăn cản bạn làm điều đó, bạn chỉ cần xây dựng lại toàn bộ đồ thị đối tượng cho mỗi Cấu hình đến. Mặc dù tôi không gặp vấn đề gì với điều đó (tôi thậm chí thích làm điều đó với mọi yêu cầu nộp đơn), nhưng đó không phải là một ý tưởng rõ ràng đối với nhiều người vì những lý do tôi chỉ có thể suy đoán.
  4. Reader thúc đẩy bạn sử dụng các chức năng nhiều hơn, điều này sẽ chơi tốt hơn với ứng dụng được viết theo phong cách FP chủ yếu.
  5. Người đọc phân tách mối quan tâm; bạn có thể tạo, tương tác với mọi thứ, xác định logic mà không cần cung cấp phụ thuộc. Thực tế cung cấp sau này, riêng biệt. (Cảm ơn Ken Scrambler về điểm này). Đây là ưu điểm thường thấy của Reader, nhưng điều đó cũng có thể xảy ra với các hàm tạo đơn giản.

Tôi cũng muốn nói những điều tôi không thích trong Reader.

  1. Tiếp thị. Đôi khi tôi nhận được ấn tượng rằng Reader được tiếp thị cho tất cả các loại phụ thuộc, không phân biệt đó là cookie phiên hay cơ sở dữ liệu. Đối với tôi, có rất ít ý nghĩa khi sử dụng Reader cho các đối tượng thực tế không đổi, như máy chủ email hoặc kho lưu trữ từ ví dụ này. Đối với những phụ thuộc như vậy, tôi thấy các hàm tạo đơn giản và / hoặc các hàm được áp dụng một phần tốt hơn. Về cơ bản, Reader cung cấp cho bạn sự linh hoạt để bạn có thể chỉ định phụ thuộc của mình trong mỗi cuộc gọi, nhưng nếu bạn không thực sự cần điều đó, bạn chỉ phải trả thuế của nó.
  2. Độ nặng tiềm ẩn - sử dụng Reader mà không có ẩn ý sẽ khiến ví dụ khó đọc. Mặt khác, khi bạn ẩn các phần ồn ào bằng cách sử dụng hàm ý và mắc một số lỗi, trình biên dịch đôi khi sẽ cung cấp cho bạn các thông báo khó giải mã.
  3. Lễ với pure, localvà tạo ra các lớp học Config riêng / sử dụng các bộ cho điều đó. Reader buộc bạn phải thêm một số mã không liên quan đến miền có vấn đề, do đó sẽ tạo ra một số nhiễu trong mã. Mặt khác, một ứng dụng sử dụng hàm tạo thường sử dụng mô hình nhà máy, cũng là từ bên ngoài miền vấn đề, vì vậy điểm yếu này không nghiêm trọng lắm.

Điều gì sẽ xảy ra nếu tôi không muốn chuyển đổi các lớp của mình thành các đối tượng có hàm?

Bạn muốn. Về mặt kỹ thuật, bạn có thể tránh điều đó, nhưng hãy nhìn xem điều gì sẽ xảy ra nếu tôi không chuyển đổi FindUserslớp thành đối tượng. Dòng tương ứng để hiểu sẽ giống như sau:

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

cái nào không đọc được phải không? Vấn đề là Reader hoạt động trên các chức năng, vì vậy nếu bạn chưa có chúng, bạn cần phải xây dựng chúng nội tuyến, điều này thường không đẹp cho lắm.


Cảm ơn vì câu trả lời chi tiết :) Một điểm mà tôi không rõ ràng, là tại sao DatastoreEmailServerđược để lại dưới dạng đặc điểm, và những điểm khác trở thành objects? Có sự khác biệt cơ bản trong các dịch vụ / phụ thuộc / (theo cách bạn gọi chúng) này khiến chúng được đối xử khác nhau không?
adamw

Chà ... tôi cũng không thể chuyển đổi ví dụ EmailSenderthành một đối tượng, phải không? Tôi sẽ không sau đó có thể để diễn tả sự phụ thuộc mà không cần phải loại ...
adamw

À, phần phụ thuộc sau đó sẽ có dạng một hàm với kiểu thích hợp - vì vậy thay vì sử dụng tên kiểu, mọi thứ sẽ phải chuyển vào chữ ký hàm (tên chỉ là ngẫu nhiên). Có lẽ, nhưng tôi không thuyết phục;)
adamw

Chính xác. Thay vì phụ thuộc vào EmailSenderbạn sẽ phụ thuộc vào (String, String) => Unit. Cho dù điều đó có thuyết phục hay không là một vấn đề khác :) Để chắc chắn, nó ít nhất là chung chung hơn, vì mọi người đã phụ thuộc vào Function2.
Przemek Pokrywka

Vâng, bạn chắc chắn sẽ muốn đặt tên (String, String) => Unit để nó truyền tải một số ý nghĩa, mặc dù không phải với một loại bí danh nhưng với một cái gì đó của kiểm tra tại thời gian biên dịch;)
adamw

3

Tôi nghĩ sự khác biệt chính là trong ví dụ của bạn, bạn đang tiêm tất cả các phụ thuộc khi các đối tượng được khởi tạo. Đơn nguyên Reader về cơ bản xây dựng một hàm ngày càng phức tạp hơn để gọi các hàm phụ thuộc, sau đó chúng được trả về các lớp cao nhất. Trong trường hợp này, việc tiêm xảy ra khi hàm cuối cùng được gọi.

Một lợi thế ngay lập tức là tính linh hoạt, đặc biệt nếu bạn có thể xây dựng đơn nguyên của mình một lần và sau đó muốn sử dụng nó với các phụ thuộc được chèn khác nhau. Một bất lợi là, như bạn nói, có khả năng kém rõ ràng hơn. Trong cả hai trường hợp, lớp trung gian chỉ cần biết về các phụ thuộc tức thời của chúng, vì vậy cả hai đều hoạt động như quảng cáo cho DI.


Làm thế nào mà lớp trung gian chỉ biết về các phụ thuộc trung gian của chúng chứ không phải tất cả chúng? Bạn có thể đưa ra một ví dụ mã cho thấy ví dụ có thể được triển khai như thế nào bằng cách sử dụng đơn nguyên của trình đọc không?
adamw

Tôi có lẽ không thể giải thích nó tốt hơn blog của Json (mà bạn đã đăng) Để trích dẫn biểu mẫu ở đó "Không giống như trong ví dụ implicits, chúng tôi không có UserRepository ở bất kỳ đâu trong chữ ký của userEmail và userInfo". Kiểm tra ví dụ đó một cách cẩn thận.
Daniel Langdon

1
Vâng, nhưng điều này giả định rằng đơn nguyên độc giả mà bạn đang sử dụng được tham số có Configchứa tham chiếu đến UserRepository. Vì vậy, sự thật là nó không hiển thị trực tiếp trong chữ ký, nhưng tôi muốn nói rằng điều đó thậm chí còn tệ hơn, bạn không biết thực sự mã của bạn đang sử dụng phụ thuộc nào ngay từ cái nhìn đầu tiên. Không phụ thuộc vào a Configvới tất cả các phụ thuộc có nghĩa là mỗi loại phương thức phụ thuộc vào tất cả chúng?
adamw

Nó phụ thuộc vào họ, nhưng nó không cần phải biết điều đó. Tương tự như trong ví dụ của bạn với các lớp. Tôi thấy chúng như khá tương đương :-)
Daniel Langdon

Trong ví dụ với các lớp, bạn chỉ phụ thuộc vào những gì bạn thực sự cần, không phải là một đối tượng toàn cục với tất cả các phụ thuộc bên trong. Và bạn gặp một vấn đề về cách quyết định cái gì nằm bên trong "các phụ thuộc" của toàn cục configvà cái gì "chỉ là một hàm". Có lẽ bạn cũng sẽ có rất nhiều sự phụ thuộc vào bản thân. Dù sao, đó là hơn một cuộc thảo luận vấn đề của sở thích hơn là một Q & A :)
adamw
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.