【问题标题】:How to implement if-else logic with Cats IO monad?如何使用 Cats IO monad 实现 if-else 逻辑?
【发布时间】:2019-07-01 04:36:25
【问题描述】:

用 Cats IO monad 实现 if-else 逻辑的正确方法是什么?

这是一个用伪代码描述的用户注册流程的基本示例:

registerUser(username, email, password) = {
  if (findUser(username) == 1) "username is already in use"
  else if (findUser(email) == 1) "email is already in use"
  else saveUser(username, email, password)
}

如何在 Scala Cats IO monad 方面实现相同的逻辑?

  def createUser(username: Username, email: Email, password: Password): IO[Unit]
  def getUserByUsername(username: Username): IO[Option[User]]
  def getUserByEmail(email: Email): IO[Option[User]]

【问题讨论】:

  • “伪代码”中的那些字符串是什么?他们应该提出错误,还是返回Strings?
  • 在scala方面也可能是Left[String]NonEmptyList[String]
  • 它可能是任何东西,你可以使用Either并返回Left/Right,或者你可以使用Validated并返回一个有错误的列表,或者你可以在IO 本身。由您决定。要求所有三个变体都太宽泛了。此外,标题表明每个if-else 的两个分支中都应该有一些 IO 操作。我认为您应该重新提出您的问题并限制其范围。 registerUser 的预期类型是什么?
  • registerUser 应该有以下类型IO[Either[NonEmptyList[String], UserId]]

标签: scala scala-cats


【解决方案1】:

由于您想要NonEmptyList 的错误,看来您必须将getUserByUsernamegetUserByEmail 的结果与Validated 结合起来,然后才将其转换为Either。在这个Either 上,您可以在两个分支中调用带有一些IOs 的fold。把它合并到一个for-comprehension 太别扭了,所以我把它分成了两种方法:

import cats.data.Validated.condNel
import cats.data.NonEmptyList
import cats.syntax.apply._
import cats.syntax.either._
import cats.effect._

case class User(name: String)

trait CreateUserOnlyIfNoCollision {

  type Username = String
  type Email = String
  type Password = String
  type ErrorMsg = String 
  type UserId = Long

  def createUser(username: Username, email: Email, password: Password): IO[UserId]
  def getUserByUsername(username: Username): IO[Option[User]]
  def getUserByEmail(email: Email): IO[Option[User]]

  /** Attempts to get user both by name and by email,
    * returns `()` if nothing is found, otherwise
    * returns a list of error messages that tell whether
    * name and/or address are already in use.
    */
  def checkUnused(username: Username, email: Email)
  : IO[Either[NonEmptyList[String], Unit]] = {
    for {
      o1 <- getUserByUsername(username)
      o2 <- getUserByEmail(email)
    } yield {
      (
        condNel(o1.isEmpty, (), "username is already in use"),
        condNel(o2.isEmpty, (), "email is already in use")
      ).mapN((_, _) => ()).toEither
    }
  }

  /** Attempts to register a user.
    * 
    * Returns a new `UserId` in case of success, or 
    * a list of errors if the name and/or address are already in use.
    */
  def registerUser(username: Username, email: Email, password: Password)
  : IO[Either[NonEmptyList[String], UserId]] = {
    for {
      e <- checkUnused(username, email)
      res <- e.fold(
        errors => IO.pure(errors.asLeft),
        _ => createUser(username, email, password).map(_.asRight)
      )
    } yield res
  }
}

可能是这样的吗?

或者EitherT:

  def registerUser(username: Username, email: Email, password: Password)
  : IO[Either[Nel[String], UserId]] = {
    (for {
      e <- EitherT(checkUnused(username, email))
      res <- EitherT.liftF[IO, Nel[String], UserId](
        createUser(username, email, password)
      )
    } yield res).value
  }

或:

  def registerUser(username: Username, email: Email, password: Password)
  : IO[Either[Nel[String], UserId]] = {
    (for { 
      e <- EitherT(checkUnused(username, email))
      res <- EitherT(
        createUser(username, email, password).map(_.asRight[Nel[String]])
      )
    } yield res).value
  }

【讨论】:

    【解决方案2】:

    考虑以下示例

    object So56824136 extends App {
      type Error = String
      type UserId = String
      type Username = String
      type Email = String
      type Password = String
      case class User(name: String)
    
      def createUser(username: Username, email: Email, password: Password): IO[Option[UserId]] = IO { Some("100000001")}
      def getUserByUsername(username: Username): IO[Option[User]] = IO { Some(User("picard"))}
      def getUserByEmail(email: Email): IO[Option[User]] = IO { Some(User("picard"))}
    
      def userDoesNotAlreadyExists(username: Username, email: Email, password: Password): IO[Either[Error, Unit]] =
        (for {
          _ <- OptionT(getUserByUsername(username))
          _ <- OptionT(getUserByEmail(username))
        } yield "User already exists").toLeft().value
    
      def registerUser(username: Username, email: Email, password: Password) : IO[Either[Error, UserId]] =
        (for {
          _ <- EitherT(userDoesNotAlreadyExists(username, email, password))
          id <- OptionT(createUser(username, email, password)).toRight("Failed to create user")
        } yield id).value
    
      registerUser("john_doe", "john@example.com", "1111111")
        .unsafeRunSync() match { case v => println(v) }
    }
    

    哪个输出

    Left(User already exists)
    

    注意我已经将createUser的返回类型更改为IO[Option[UserId]],并且不会根据电子邮件或用户名区分已经存在的用户,而是将它们都视为简单的用户已经存在的错误,因此我只使用String 在左边而不是 NonEmptyList

    【讨论】:

    • 如果userDoesNotAlreadyExist 在列表中最多返回一个String,那么最多一个元素的NonEmptyList[String]String 完全相同,因此应该从签名,以免引起错误的印象,即该方法可能会返回更详细的错误消息列表。
    【解决方案3】:

    根据 Andrey 的回答,我为此用例开发了自己的解决方案。

        case class User(name: String)
    
        type Username = String
        type Email = String
        type Password = String
        type ErrorMsg = String
        type UserId = Long
    
        def createUser(username: Username, email: Email, password: Password): IO[UserId] = ???
        def getUserByUsername(username: Username): IO[Option[User]] = ???
        def getUserByEmail(email: Email): IO[Option[User]] = ???
    
        def isExist(condition: Boolean)(msg: String): IO[Unit] =
          if (condition) IO.raiseError(new RuntimeException(msg)) else IO.unit
    
        def program(username: Username, email: Email, password: Password): IO[Either[String, UserId]] = (for {
          resA <- getUserByUsername(username)
          _ <- isExist(resA.isDefined)("username is already in use")
          resB <- getUserByEmail(email)
          _ <- isExist(resB.isDefined)("email is already in use")
          userId <- createUser(username, email, password)
        } yield {
          userId.asRight[String]
        }).recoverWith {
          case e: RuntimeException => IO.pure(e.getMessage.asLeft[UserId])
        }
    

    首先我介绍了一个辅助函数isExist(condition: Boolean)(msg: String): IO[Unit]。它的目的只是为了检查用户名或电子邮件(或其他任何东西)存在的事实。除此之外,它通过抛出带有适当消息的RuntimeException 立即终止程序执行流程,该消息可用于稍后的描述性响应。

    【讨论】:

    • 请注意,在此实现中,各种网络错误、无法反序列化某些消息、无法访问数据库等都与无效的用户输入混合在一起。根据用例,这可能不是您真正想要的:如果选择的用户名已经存在,您当然想向用户报告,但您可能希望记录访问数据库的失败,并向用户显示更容易理解的错误消息。
    • 那么,不是将这些异常表示为RuntimeExceptions,而是表示为与域相关的一些自定义异常(例如EntityAlreadyExistException)有意义吗?
    • 这类似于对控制流使用异常。检查具有给定用户名的用户是否已经存在并不像“异常控制流”,所以我建议将其建模为 Either[Error, ?]ValidatedNel[Error, ?] 或类似的东西,并将 IO.raiseError 保留为“实际异常”(网络故障等)。
    • @AndreyTyukin 在这种情况下,“异常控制流”的唯一积极特征是当“电子邮件已在使用”或“用户名已在使用”时会立即中断。除了抛出异常,还有什么技术可以防止冗余操作吗?
    • Either / EitherTOption / OptionT 都提供快速失败的行为,即它们在第一次出现问题后中断计算。 EitherLeft 中提供了“有意义的错误消息”,Option 只是以 None 失败。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-03-13
    • 2017-06-08
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多