Scala-way to handle conditions in for-comprehensions?

后端 未结 3 1781
天命终不由人
天命终不由人 2020-12-31 18:44

I am trying to create a neat construction with for-comprehension for business logic built on futures. Here is a sample which contains a working example based on Exception ha

3条回答
  •  梦毁少年i
    2020-12-31 19:15

    The central challenge is that for-comprehensions can only work on one monad at a time, in this case it being the Future monad and the only way to short-circuit a sequence of future calls is for the future to fail. This works because the subsequent calls in the for-comprehension are just map and flatmap calls, and the behavior of a map/flatmap on a failed Future is to return that future and not execute the provided body (i.e. the function being called).

    What you are trying to achieve is the short-cicuiting of a workflow based on some conditions and not do it by failing the future. This can be done by wrapping the result in another container, let's call it Result[A], which gives the comprehension a type of Future[Result[A]]. Result would either contain a result value, or be a terminating result. The challenge is how to:

    • provide subsequent function calls the value contained by a prior non-terminating Result
    • prevent the subsequent function call from being evaluated if the Result is terminating

    map/flatmap seem like the candidates for doing these types of compositions, except we will have to call them manually, since the only map/flatmap that the for-comprehension can evaluate is one that results in a Future[Result[A]].

    Result could be defined as:

    trait Result[+A] {
    
      // the intermediate Result
      def value: A
    
      // convert this result into a final result based on another result
      def given[B](other: Result[B]): Result[A] = other match {
        case x: Terminator => x
        case v => this
      }
    
      // replace the value of this result with the provided one
      def apply[B](v: B): Result[B]
    
      // replace the current result with one based on function call
      def flatMap[A2 >: A, B](f: A2 => Future[Result[B]]): Future[Result[B]]
    
      // create a new result using the value of both
      def combine[B](other: Result[B]): Result[(A, B)] = other match {
        case x: Terminator => x
        case b => Successful((value, b.value))
      }
    }
    

    For each call, the action is really a potential action, as calling it on or with a terminating result, will simply maintain the terminating result. Note that Terminator is a Result[Nothing] since it will never contain a value and any Result[+A] can be a Result[Nothing].

    The terminating result is defined as:

    sealed trait Terminator extends Result[Nothing] {
      val value = throw new IllegalStateException()
    
      // The terminator will always short-circuit and return itself as
      // the success rather than execute the provided block, thus
      // propagating the terminating result
      def flatMap[A2 >: Nothing, B](f: A2 => Future[Result[B]]): Future[Result[B]] =
        Future.successful(this)
    
      // if we apply just a value to a Terminator the result is always the Terminator
      def apply[B](v: B): Result[B] = this
    
      // this apply is a convenience function for returning this terminator
      // or a successful value if the input has some value
      def apply[A](opt: Option[A]) = opt match {
        case None => this
        case Some(v) => Successful[A](v)
      }
    
      // this apply is a convenience function for returning this terminator or
      // a UnitResult
      def apply(bool: Boolean): Result[Unit] = if (bool) UnitResult else this
    }
    

    The terminating result makes it possible to to short-circuit calls to functions that require a value [A] when we've already met our terminating condition.

    The non-terminating result is defined as:

    trait SuccessfulResult[+A] extends Result[A] {
    
      def apply[B](v: B): Result[B] = Successful(v)
    
      def flatMap[A2 >: A, B](f: A2 => Future[Result[B]]): Future[Result[B]] = f(value)
    }
    
    case class Successful[+A](value: A) extends SuccessfulResult[A]
    
    case object UnitResult extends SuccessfulResult[Unit] {
      val value = {}
    }
    

    The non-teminating result makes it possible to provide the contained value [A] to functions. For good measure, I've also predefined a UnitResult for functions that are purely side-effecting, like goodDao.removeGood.

    Now let's define your good, but terminating conditions:

    case object UserNotFound extends Terminator
    
    case object NotAuthenticated extends Terminator
    
    case object GoodNotFound extends Terminator
    
    case object NoOwnership extends Terminator
    

    Now we have the tools to create the the workflow you were looking for. Each for comprehention wants a function that returns a Future[Result[A]] on the right-hand side, producing a Result[A] on the left-hand side. The flatMap on Result[A] makes it possible to call (or short-circuit) a function that requires an [A] as input and we can then map its result to a new Result:

    def renderJson(data: Map[Any, Any]): JsResult = ???
    def renderError(message: String): JsResult = ???
    
    val resultFuture = for {
    
      // apply UserNotFound to the Option to conver it into Result[User] or UserNotFound
      userResult <- userDao.findUser(userId).map(UserNotFound(_))
    
      // apply NotAuthenticated to AuthResult.ok to create a UnitResult or NotAuthenticated
      authResult <- userResult.flatMap(user => userDao.authenticate(user).map(x => NotAuthenticated(x.ok)))
    
      goodResult <- authResult.flatMap(_ => goodDao.findGood(goodId).map(GoodNotFound(_)))
    
      // combine user and good, so we can feed it into checkOwnership
      comboResult = userResult.combine(goodResult)
    
      ownershipResult <- goodResult.flatMap { case (user, good) => goodDao.checkOwnership(user, good).map(NoOwnership(_))}
    
      // in order to call removeGood with a good value, we take the original
      // good result and potentially convert it to a Terminator based on
      // ownershipResult via .given
      _ <- goodResult.given(ownershipResult).flatMap(good => goodDao.removeGood(good).map(x => UnitResult))
    } yield {
    
      // ownership was the last result we cared about, so we apply the output
      // to it to create a Future[Result[JsResult]] or some Terminator
      ownershipResult(renderJson(Map(
        "success" -> true
      )))
    }
    
    // now we can map Result into its value or some other value based on the Terminator
    val jsFuture = resultFuture.map {
      case UserNotFound => renderError("User not found")
      case NotAuthenticated => renderError("User not authenticated")
      case GoodNotFound => renderError("Good not found")
      case NoOwnership => renderError("No ownership")
      case x => x.value
    }
    

    I know that's a whole lot of setup, but at least the Result type can be used for any Future for-comprehension that has terminating conditions.

提交回复
热议问题