Scala中的方法参数验证,使用for comprehension和monads

35

我正在尝试验证一个方法的参数是否为空,但我找不到解决方案...

有人可以告诉我如何做吗?

我尝试了类似于这样的东西:

  def buildNormalCategory(user: User, parent: Category, name: String, description: String): Either[Error,Category] = {
    val errors: Option[String] = for {
      _ <- Option(user).toRight("User is mandatory for a normal category").right
      _ <- Option(parent).toRight("Parent category is mandatory for a normal category").right
      _ <- Option(name).toRight("Name is mandatory for a normal category").right
      errors : Option[String] <- Option(description).toRight("Description is mandatory for a normal category").left.toOption
    } yield errors
    errors match {
      case Some(errorString) => Left( Error(Error.FORBIDDEN,errorString) )
      case None =>  Right( buildTrashCategory(user) )
    }
  }

2
更符合惯用语法的做法是编写一个包装器,围绕返回可能为空引用的任何库进行编写。否则,人们可以想象在几乎每个方法中都会重复这种验证。 - Ben James
我同意@BenJames的观点。在Scala中完全避免使用null,而是在适当的情况下使用Option[..]非常有用。 - Petr
@BenJames 我的值来自于Play2的JSON解析器:JsValue。没错,看起来我可能可以做出更优雅的解决方案,但我正在学习Scala,仍然对答案感兴趣 ;) - Sebastien Lorber
@PetrPudlák 我完全同意,但如果我使用选项进行工作,这意味着我的方法签名将不得不公开选项...这不是一个问题吗?因为所有这些值都不是可选的。 - Sebastien Lorber
1
函数操作简单值可以被“提升”以操作Option。如果您感兴趣,可以查找如何使用Scalaz库来实现此操作。否则,只需在for-comprehension中应用该方法即可。 - Ben James
4个回答

88
如果你愿意使用Scalaz,它有一些方便的工具可以使这种任务更加方便,包括一个新的Validation类和一些有用的右偏类型类实例,适用于普通的scala.Either。我将在这里举个例子。

使用Validation累积错误信息

首先,我们需要导入Scalaz(请注意,我们必须隐藏scalaz.Category以避免名称冲突):

import scalaz.{ Category => _, _ }
import syntax.apply._, syntax.std.option._, syntax.validation._

我用Scalaz 7作为这个例子的实现。如果你要使用6,你需要做一些微小的更改。
我假设我们有这个简化模型:
case class User(name: String)
case class Category(user: User, parent: Category, name: String, desc: String)

接下来我将定义以下验证方法,如果您采用不涉及检查 null 值的方法,您可以轻松地进行调整:
def nonNull[A](a: A, msg: String): ValidationNel[String, A] =
   Option(a).toSuccess(msg).toValidationNel

“Nel”部分代表“非空列表”,而“ValidationNel[String, A]”基本上与“Either[List[String], A]”相同。
现在我们使用此方法来检查我们的参数:
def buildCategory(user: User, parent: Category, name: String, desc: String) = (
  nonNull(user,   "User is mandatory for a normal category")            |@|
  nonNull(parent, "Parent category is mandatory for a normal category") |@|
  nonNull(name,   "Name is mandatory for a normal category")            |@|
  nonNull(desc,   "Description is mandatory for a normal category")
)(Category.apply)

请注意,Validation[Whatever, _] 不是一个单子(例如,在这里讨论的原因),但 ValidationNel[String, _] 是一个应用函子,并且我们在这里使用了这个事实,当我们“提升” Category.apply 到它中。有关应用函子的更多信息,请参见下面的附录。
现在,如果我们写出类似这样的东西:
val result: ValidationNel[String, Category] = 
  buildCategory(User("mary"), null, null, "Some category.")

我们将会在累积的错误中得到一个失败:
Failure(
 NonEmptyList(
   Parent category is mandatory for a normal category,
   Name is mandatory for a normal category
  )
)

如果所有的参数都检查通过,我们将得到一个带有类别值的成功(Success)对象。
使用Either快速失败
使用应用函子进行验证的好处之一是可以轻松地更改处理错误的方法。如果你想在第一个错误出现时失败而不是累积它们,你可以基本上只需要更改你的nonNull方法。
我们确实需要稍微不同的导入集合:
import scalaz.{ Category => _, _ }
import syntax.apply._, std.either._

“但是,不需要更改上面的大小写类。以下是我们的新验证方法:”
def nonNull[A](a: A, msg: String): Either[String, A] = Option(a).toRight(msg)

几乎与上面的代码相同,只是我们使用Either而不是ValidationNEL,并且Scalaz为Either提供的默认应用函子实例不会累积错误。
这就是我们需要做的一切,以获得所需的快速失败行为 - 我们的buildCategory方法不需要进行任何更改。现在如果我们编写以下内容:
val result: Either[String, Category] =
  buildCategory(User("mary"), null, null, "Some category.")

结果将只包含第一个错误:
Left(Parent category is mandatory for a normal category)

恰好我们想要的。
附录:应用函子的快速介绍
假设我们有一个带有单个参数的方法:
def incremented(i: Int): Int = i + 1

假设我们想将这种方法应用于一些 `x: Option[Int]` 并获得一个 `Option[Int]` 返回。由于 `Option` 是一个函子并且提供了 `map` 方法,这使得这个过程变得容易:
val xi = x map incremented

我们已经将“incremented”提升到了“Option”函子中;也就是说,我们基本上将一个将“Int”映射到“Int”的函数转换为一个将“Option[Int]”映射到“Option[Int]”的函数(尽管语法有些混乱——在像Haskell这样的语言中,“lifting”隐喻更清晰)。
现在假设我们想以类似的方式将以下“add”方法应用于“x”和“y”。
def add(i: Int, j: Int): Int = i + j

val x: Option[Int] = users.find(_.name == "John").map(_.age)
val y: Option[Int] = users.find(_.name == "Mary").map(_.age) // Or whatever.

事实上,Option 是一个函子并不足够。然而,它是一个单子,我们可以使用 flatMap 来得到我们想要的结果:
val xy: Option[Int] = x.flatMap(xv => y.map(add(xv, _)))

或者,等价地:

val xy: Option[Int] = for { xv <- x; yv <- y } yield add(xv, yv)

在某种程度上,然而,Option 的单子性对于这个操作来说有些过度。有一个更简单的抽象——称为 应用函子——它介于函子和单子之间,并提供了我们需要的所有机制。
请注意,在形式上它是介于两者之间的:每个单子都是一个应用函子,每个应用函子都是一个函子,但不是每个应用函子都是一个单子,等等。
Scalaz 为 Option 提供了一个应用函子实例,因此我们可以编写以下代码:
import scalaz._, std.option._, syntax.apply._

val xy = (x |@| y)(add)

语法有点奇怪,但概念并不比上面的函数对象或单子例子更复杂——我们只是将 add 提升到了应用函子中。如果我们有一个带有三个参数的方法 f,我们可以这样写:
val xyz = (x |@| y |@| z)(f)

等等。

那么为什么要使用适用函子呢,既然我们已经有了单子呢?首先,对于我们想要使用的某些抽象,根本不可能提供单子实例——Validation就是一个完美的例子。

其次(并且相关),使用能够完成任务的最不强大的抽象只是一种可靠的开发实践。原则上,这可能允许进行否则不可能的优化,但更重要的是使我们编写的代码更具可重用性。


1
谢谢。顺便问一下,在你的代码中 |@| 是什么?是 Scalaz 添加到 Either 的函数吗? - Sebastien Lorber
1
@SebastienLorber:请参见附录。 - Travis Brown
@Travis Brown 有没有简单的方法可以防止客户端直接使用 case class?例如,我想防止使用 Category(myUser, myParent, myName, myDesc)。事实上,我想在对象创建之前强制进行验证,从而迫使他始终使用 buildCategory 方法。 - Mik378
1
在这种情况下,我会将Category作为一个密封特质,并使用一个私有的case类来扩展它(并且buildCategory显式地被定义为ValidationNEL[Error, Category])。这样你就可以在验证对象内部获得一个case类的便利性,但是你可以控制人们如何创建Category的新实例。 - Travis Brown
@Travis Brown,trait和private case class都扩展了它,你的解决方案必须同时称之为Category吗?我会将sealed trait重命名为CategoryBuilder,不是吗?否则,在签名中:ValidationNEL [Error,Category]Category将指向trait,而不是case class。 - Mik378
显示剩余4条评论

9
我完全支持Ben James对于为产生null的api创建包装器的建议。但是,当编写该包装器时,您仍会遇到相同的问题。因此,以下是我的建议。
为什么要使用单子和for推导式?在我看来这是过度复杂化了。以下是您可以实现它的方法:
def buildNormalCategory
  ( user: User, parent: Category, name: String, description: String )
  : Either[ Error, Category ] 
  = Either.cond( 
      !Seq(user, parent, name, description).contains(null), 
      buildTrashCategory(user),
      Error(Error.FORBIDDEN, "null detected")
    )

如果你坚持希望错误消息存储参数的名称,那么你可以按照以下方式进行操作,这将需要更多的样板代码:

def buildNormalCategory
  ( user: User, parent: Category, name: String, description: String )
  : Either[ Error, Category ] 
  = {
    val nullParams
      = Seq("user" -> user, "parent" -> parent, 
            "name" -> name, "description" -> description)
          .collect{ case (n, null) => n }

    Either.cond( 
      nullParams.isEmpty, 
      buildTrashCategory(user),
      Error(
        Error.FORBIDDEN, 
        "Null provided for the following parameters: " + 
        nullParams.mkString(", ")
      )
    )
  }

2
当你进行这种验证时,想要累积错误并不罕见(这样你就可以告诉用户他们做错了什么),而这种简化的方法并不能很好地支持这一点。 - Travis Brown
@TravisBrown 有道理。我已经更新了答案来涵盖这一点。 - Nikita Volkov
实际上,我希望避免错误的累积,并且只返回第一个遇到的错误,而不对其他参数进行检查。 - Sebastien Lorber
@SebastienLorber,只需将“collect”更改为“collectFirst”,您就可以解决问题了。 - Nikita Volkov

5
如果您喜欢@Travis Brown答案中的applicative functor方法,但不喜欢Scalaz语法或者其他原因不想使用Scalaz,那么这里有一个简单的库,可以丰富标准库Either类以作为applicative functor验证:https://github.com/youdevise/eithervalidation 例如:
import com.youdevise.eithervalidation.EitherValidation.Implicits._    

def buildNormalCategory(user: User, parent: Category, name: String, description: String): Either[List[Error], Category] = {     
  val validUser = Option(user).toRight(List("User is mandatory for a normal category"))
  val validParent = Option(parent).toRight(List("Parent category is mandatory for a normal category"))
  val validName = Option(name).toRight(List("Name is mandatory for a normal category"))
  Right(Category)(validUser, validParent, validName).
    left.map(_.map(errorString => Error(Error.FORBIDDEN, errorString)))
}

换言之,如果所有的 Eithers 都是 Rights,则此函数将返回包含您的类别的 Right,否则将返回包含所有错误列表的 Left,如果其中一个或多个是 Left。请注意,这种语法在某种程度上更加 Scala,而不太 Haskell,并且库更小 ;)

0
假设您已经完成了以下的快速而肮脏的东西:
object Validation {
  var errors = List[String]()  

  implicit class Either2[X] (x: Either[String,X]){

def fmap[Y](f: X => Y) = {
  errors = List[String]()  
  //println(s"errors are $errors")
  x match {
    case Left(s) => {errors = s :: errors ; Left(errors)}
    case Right(x) => Right(f(x))
  }
}    
def fapply[Y](f: Either[List[String],X=>Y]) = {
  x match { 
    case Left(s) => {errors = s :: errors ; Left(errors)}
    case Right(v) => {
      if (f.isLeft) Left(errors) else Right(f.right.get(v))
    }
  }
}
}}

考虑一个返回 Either 的验证函数:
  def whenNone (value: Option[String],msg:String): Either[String,String] = 
      if (value isEmpty) Left(msg) else Right(value.get)

一个返回元组的柯里化构造函数:

  val me = ((user:String,parent:String,name:String)=> (user,parent,name)) curried

您可以使用以下方式进行验证:
   whenNone(None,"bad user") 
   .fapply(
   whenNone(Some("parent"), "bad parent") 
   .fapply(
   whenNone(None,"bad name") 
   .fmap(me )
   ))

没什么大不了的。


抱歉,由于需要实现副作用,所以必须明确设置和重新初始化可变变量,但肯定有更好的编码方式。 - wiki1000

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接