用于系统测试 DSL 的实用免费 monad:并发性和错误处理

Practical free monads for system-tests DSL: concurrency and error handling

我正在尝试编写用于在 Scala 中编写系统测试的 DSL。在此 DSL 中,我不想暴露某些操作可能异步发生的事实(例如,因为它们是使用被测 Web 服务实现的),或者可能发生错误(因为 Web 服务可能不可用) ,我们希望测试失败)。 不鼓励这种方法,但在用于编写测试的 DSL 上下文中我并不完全同意这种方法。我认为 DSL 会因这些方面的引入而受到不必要的污染。

要构建问题,请考虑以下 DSL:

type Elem = String

sealed trait TestF[A]
// Put an element into the bag.
case class Put[A](e: Elem, next: A) extends TestF[A]
// Count the number of elements equal to "e" in the bag.
case class Count[A](e: Elem, withCount: Int => A) extends TestF[A]

def put(e: Elem): Free[TestF, Unit] =
  Free.liftF(Put(e, ()))

def count(e: Elem): Free[TestF, Int] =
  Free.liftF(Count(e, identity))

def test0 = for {
  _ <- put("Apple")
  _ <- put("Orange")
  _ <- put("Pinneaple")
  nApples <- count("Apple")
  nPears <- count("Pear")
  nBananas <- count("Banana")
} yield List(("Apple", nApples), ("Pears", nPears), ("Bananas", nBananas))

现在假设我们想要实现一个解释器,它利用我们的被测服务来放置和计算商店中的元素。由于我们使用网络,我希望 put 操作异步进行。此外,鉴于可能发生网络错误或服务器错误,我希望程序在发生错误时立即停止。为了说明我想要实现的目标,here 是一个通过 monad 转换器(我无法翻译成 Scala)在 Haskell 中混合不同方面的示例。

所以我的问题是,您会使用哪个 monad M 作为满足上述要求的解释器:

def interp[A](cmd: TestF[A]): M[A]

如果 M 是一个单子变换器,您将如何使用您选择的 FP 库(Cats、Scalaz)来组合它们。

Task(scalaz 或更好的 fs2)应该满足所有要求,它不需要 monad-transformer,因为它内部已经有 EitherEither for fs2, \/ 对于鳞鳞鱼)。它还具有您需要的快速失败行为,与右偏 disjunction/xor.

相同

以下是我所知道的几个实现:

尽管没有 monad-transformer,使用时你仍然有点需要提升 Task:

  • 从值到 Task
  • EitherTask

但是,是的,它似乎确实比 monad 转换器更简单,尤其是在 monad 几乎不可组合的事实方面——为了定义 monad 转换器,除了是 monad 之外,你还必须知道关于你的类型的一些其他细节(通常它需要像 comonad 这样的东西来提取价值)。

出于广告目的,我还要补充一点,Task 表示堆栈安全的蹦床计算。

但是,有一些项目专注于扩展单子组合,例如 Emm-monad:https://github.com/djspiewak/emm, so you can compose monad transformers with Future/Task, Either, Option, List and so on and so forth. But, IMO, it's still limited in comparison with Applicative composition - cats provides universal Nested data type that allows to easily compose any Applicative, you can find some examples - the only disadvantage here is that it's hard to build a readable DSL using Applicative. Another alternative is so-called "Freer monad": https://github.com/m50d/paperdoll,它基本上提供了更好的组合,并允许将不同的效果层分离到不同的解释器中。

例如,由于没有 FutureT/TaskT 转换器,您无法构建像 type E = Option |: Task |: BaseOption 来自 Task)这样的效果这样 flatMap 需要从 Future/Task.

中提取值

作为结论,我可以说根据我的经验 Task 确实适用于基于 do-notation 的 DSL:我有一个复杂的类似外部规则的 DSL 用于异步计算,当我决定迁移时这一切到 Scala 嵌入式版本 Task 真的很有帮助——我从字面上将外部 DSL 转换为 Scala 的 for-comprehension。我们考虑的另一件事是有一些自定义类型,比如 ComputationRule 定义了一组类型 类 以及到 Task/Future 或我们需要的任何类型的转换,但是这是因为我们没有明确地使用 Free-monad。


你甚至可能不需要 Free-monad 假设你不需要切换解释器的能力(这可能适用于 just 系统测试)。在那种情况下,Task 可能是您唯一需要的东西 - 它是惰性的(与 Future 相比),真正的功能和堆栈安全:

 trait DSL {
   def put[E](e: E): Task[Unit]
   def count[E](e: E): Task[Int]
 }

 object Implementation1 extends DSL {

   ...implementation
 }

 object Implementation2 extends DSL {

   ...implementation
 }


//System-test script:

def test0(dsl: DSL) = {
  import dsl._
  for {
    _ <- put("Apple")
    _ <- put("Orange")
    _ <- put("Pinneaple")
    nApples <- count("Apple")
    nPears <- count("Pear")
    nBananas <- count("Banana")
  } yield List(("Apple", nApples), ("Pears", nPears), ("Bananas", nBananas))
 }

因此您可以通过在此处传递不同的 "interpreter" 来切换实现:

test0(Implementation1).unsafeRun
test0(Implementation2).unsafeRun

Differences/Disadvantages(与http://typelevel.org/cats/datatypes/freemonad.html相比):

  • 你坚持使用 Task 类型,所以你不能轻易地将它折叠成其他一些 monad。
  • 当你传递一个 DSL-trait 的实例(而不是自然转换)时,实现在运行时被解析,你可以使用 eta-expansion 轻松地抽象它:test0 _。 Java/Scala 自然支持多态方法(put、count),但多函数不支持,因此传递包含 T => Task[Unit]DSL 实例更容易(对于 put 操作)比使用自然变换 DSLEntry ~> Task.

  • 制作合成多态函数 DSLEntry[T] => Task[Unit]
  • 没有显式 AST 而不是自然转换中的模式匹配 - 我们在 DSL trait

    中使用静态分派(显式调用方法,这将 return 惰性计算)

实际上,您甚至可以在这里去掉 Task

 trait DSL[F[_]] {
   def put[E](e: E): F[Unit]
   def count[E](e: E): F[Int]
 }

 def test0[M[_]: Monad](dsl: DSL[M]) = {...}

所以在这里它甚至可能成为一个偏好问题,尤其是当您不编写开源库时。

综合起来:

import cats._
import cats.implicits._

trait DSL[F[_]] {
   def put[E](e: E): F[Unit]
   def count[E](e: E): F[Int]
 }

def test0[M[_]: Monad](dsl: DSL[M]) = {
    import dsl._
    for {
      _ <- put("Apple")
      _ <- put("Orange")
      _ <- put("Pinneaple")
      nApples <- count("Apple")
      nPears <- count("Pear")
      nBananas <- count("Banana")
    } yield List(("Apple", nApples), ("Pears", nPears), ("Bananas", nBananas))
 }

object IdDsl extends DSL[Id] {
   def put[E](e: E) = ()
   def count[E](e: E) = 5
}

请注意,猫有一个 Monad 定义为 Id,所以:

scala> test0(IdDsl)
res2: cats.Id[List[(String, Int)]] = List((Apple,5), (Pears,5), (Bananas,5))

很简单。当然,您可以根据需要选择 Task/Future/Option 或任意组合。事实上,您可以使用 Applicative 而不是 Monad:

def test0[F[_]: Applicative](dsl: DSL[F]) = 
  dsl.count("Apple") |@| dsl.count("Pinapple apple pen") map {_ + _ }

scala> test0(IdDsl)
res8: cats.Id[Int] = 10

|@| 是并行运算符,因此您可以使用 cats.Validated 而不是 Xor,请注意 |@| for Task 未执行(至少在较旧的 scalaz 版本)并行(并行运算符不等于并行计算)。您也可以结合使用两者:

import cats.syntax._

def test0[M[_]:Monad](d: DSL[M]) = {
    for {
      _ <- d.put("Apple")
      _ <- d.put("Orange")
      _ <- d.put("Pinneaple")
      sum <- d.count("Apple") |@| d.count("Pear") |@| d.count("Banana") map {_ + _ + _}
    } yield sum
 }

scala> test0(IdDsl)
res18: cats.Id[Int] = 15