【问题标题】:How can I better collect multiple validation errors when attempting to instantiate a case class尝试实例化案例类时如何更好地收集多个验证错误
【发布时间】:2014-06-12 17:50:39
【问题描述】:

总结:
我希望在实例化 Scala 案例类之前有验证选项,而不是必须使用 requires/IllegalArgumentException 机制。在尝试预先验证用于 Scala 案例类实例化的参数时,有没有办法避免类似 Java 的样板代码?

详情:
来自 Java,我现在已经使用了足够多的 Scala 案例类来彻底享受和欣赏它消除了多少样板。但是,我现在遇到了一个问题,这似乎是我的 Scala 代码在案例类周围大量膨胀的样板。

请为我的典型 Scala 案例类考虑以下代码(直接从 IntelliJ 的 Scala 工作表复制 - 注意:案例类 Surface1 之上的所有内容都是为了在呈现 Surface2 时减少代码噪音):

val LONGITUDE_MAX = 180.0d
val LATITUDE_MAX = 90.0d
def isLongitudeValid(longitude: Double) = (-LONGITUDE_MAX <= longitude) && (longitude <= LONGITUDE_MAX)
def isLatitudeValid(latitude: Double) = (-LATITUDE_MAX <= latitude) && (latitude <= LATITUDE_MAX)
object SurfaceType extends Enumeration {
  type SurfaceType = Value
  val SEA, LAND, ICE = Value
}
def isSurfaceTypeCorrect(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value) = true //fabricated validation assuming longitude and latitude are validated
//
//#1. Simple case class use
case class Surface1(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) {
  require(isLongitudeValid(longitude), s"longitude [$longitude] must be greater than or equal to -$LONGITUDE_MAX and less than or equal to $LONGITUDE_MAX")
  require(isLatitudeValid(latitude), s"latitude [$latitude] must be greater than or equal to -$LATITUDE_MAX and less than or equal to $LATITUDE_MAX")
  require(isSurfaceTypeCorrect(longitude, latitude, surfaceType), s"for longitude[$longitude] and latitude[$latitude], surfaceType [$surfaceType] must be the correct")
}

因此,鉴于以上 Surface1 的情况,以下是一些示例用法:

val surface1a = Surface1()
val surface1b = Surface1(0.0d, 0.0d, SurfaceType.SEA)
val surface1c = Surface1(-180.0d, -90.0d, SurfaceType.ICE)
val surface1d = Surface1(-180.1d, -90.1d, SurfaceType.ICE) //generates an IllegalArgumentException

前三个将生成正确的实例。最后一个将生成 IllegalArgumentException...但仅适用于经度。纬度也是错误的。但是对于标准的 Scala 案例类模型,不会评估对表面类型的纬度验证。至少 latitude 的验证也已执行并收到异常列表将非常有帮助。

所以,经过无数次切线,这就是我能够在我可以做任何事情的地方进行预实例化验证的结果;继续使用标准的 requires/exception 模型进行实例化(应用),返回一个选项(获取)或返回一个(创建):

//#2. Explicit validators for case class use
type CaseClassValidationException = IllegalArgumentException
object Surface2 {
  def validate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[List[CaseClassValidationException]] = {
    val errorsA =
      List(
          if (!isLongitudeValid(longitude)) Some(new CaseClassValidationException(s"longitude [$longitude] must be greater than or equal to -$LONGITUDE_MAX and less than or equal to $LONGITUDE_MAX")) else None
        , if (!isLatitudeValid(latitude)) Some(new CaseClassValidationException(s"latitude [$latitude] must be greater than or equal to -$LATITUDE_MAX and less than or equal to $LATITUDE_MAX")) else None
      )
    val errorsB =
      if (errorsA.isEmpty) //these checks depend upon the errorsA checks
        List(
            if (!isSurfaceTypeCorrect(longitude, latitude, surfaceType))
              Some(new CaseClassValidationException(s"for longitude[$longitude] and latitude[$latitude], surfaceType [$surfaceType] must be the correct"))
            else None
        )
      else Nil
    val errors = (errorsA ::: errorsB).flatten
    if (!errors.isEmpty) Some(errors)
    else None
  }
  def get(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Option[Surface2] = {
    create(longitude, latitude, surfaceType) match {
      case Right(surface2) => Some(surface2)
      case Left(errors) => None
    }
  }
  def create(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Either[List[CaseClassValidationException], Surface2] = {
    validate(longitude, latitude, surfaceType) match {
      case Some(errors) => Left(errors)
      case None => Right(new Surface2(longitude, latitude, surfaceType, false))
    }
  }
}
case class Surface2 private (longitude: Double, latitude: Double, surfaceType: SurfaceType.Value, executeValidate: Boolean) {
  def this(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) = this(longitude, latitude, surfaceType, true)
  if (executeValidate) require(Surface2.validate(longitude, latitude, surfaceType).isEmpty, "failed validate")
}

因此,鉴于以上 Surface2 的情况,以下是一些示例用法:

val surface2a = new Surface2()
val surface2b = new Surface2(0.0d, 0.0d, SurfaceType.SEA)
val surface2c = new Surface2(-180.0d, -90.0d, SurfaceType.ICE)
val surface2d = new Surface2(-180.1d, -90.1d, SurfaceType.ICE) //generates an IllegalArgumentException
val surface2option1a = Surface2.get()
val surface2option1b = Surface2.get(0.0d, 0.0d, SurfaceType.SEA)
val surface2option1c = Surface2.get(-180.0d, -90.0d, SurfaceType.ICE)
val surface2option1d = Surface2.get(-180.1d, -90.1d, SurfaceType.ICE) //None
val surface2either1a = Surface2.create()
val surface2either1b = Surface2.create(0.0d, 0.0d, SurfaceType.SEA)
val surface2either1c = Surface2.create(-180.0d, -90.0d, SurfaceType.ICE)
val surface2either1d = Surface2.create(-180.1d, -90.1d, SurfaceType.ICE) //Left[...]

前四个产生与 Surface1 相同的结果。但是,剩下的 8 个只会在 validate 方法返回 None 时实例化。所以,我已经达到了我想要的效果,但代价是代码气味。以下是我在解决方案中遇到的问题:

  1. 额外的 executeValidate 参数导致实例化空间使用效率低下
  2. 公开的实现细节 - executeValidate 参数
  3. 更大的代码表面(即样板)增加了引入错误/错误的可能性

我有几十个案例类将在我当前的项目中使用。当我定义它时,必须将它添加到每个案例类中确实感觉非常沉重。当然,我忽略了一些东西,我可以在没有列出不想要的效果的情况下大大简化产生所需效果的过程。

如果您能提供任何指导,我们将不胜感激。

2014 年 6 月 13 日更新:
看来(不使用 Scalaz)没有真正的方法可以显着减少样板。也就是说,感谢 NikitaVolkov 和他与我的偏好保持一致,即尽可能避免使用异常,我意识到我不需要案例类本身中的“应用”方法(这就是强制执行效率低下的原因)私有案例类构造函数)。这是对案例类本身的重大简化;即完全删除抛出异常的应用方法。

下面是最新版本,Surface3,它实现了我想要的所有效果,虽然更短更简单,但在样板方面仍然很长:

object SurfaceType extends Enumeration {
  type SurfaceType = Value
  val SEA, LAND, ICE = Value
}
type CaseClassValidationException = IllegalArgumentException
object Surface3 {
  def longitudeValidate(longitude: Double): Option[CaseClassValidationException] = {
    val longitudeBound = 180.0d
    if (!((-longitudeBound <= longitude) && (longitude <= longitudeBound)))
      Some(new CaseClassValidationException(s"longitude [$longitude] must be greater than or equal to -$longitudeBound and less than or equal to $longitudeBound"))
    else None
  }
  def latitudeValidate(latitude: Double): Option[CaseClassValidationException] = {
    val latitudeBound = 90.0d
    if (!((-latitudeBound <= latitude) && (latitude <= latitudeBound)))
      Some(new CaseClassValidationException(s"latitude [$latitude] must be greater than or equal to -$latitudeBound and less than or equal to $latitudeBound"))
    else None
  }
  def surfaceTypeValidate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[CaseClassValidationException] = None //fabricated validation assuming longitude and latitude are validated
  private def fullValidate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[List[CaseClassValidationException]] = {
    val errors1 =
      List(
          longitudeValidate(longitude)
        , latitudeValidate(latitude)
      )
    val errors2 =
      if (errors1.isEmpty)
        List(
           surfaceTypeValidate(longitude, latitude, surfaceType)
        )
      else Nil
    val errorsFinal = (errors1 ::: errors2).flatten
    if (errorsFinal.nonEmpty) Some(errorsFinal)
    else None
  }
  def createEither(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Either[List[CaseClassValidationException], Surface3] =
    fullValidate(longitude, latitude, surfaceType) match {
      case Some(errors) => Left(errors)
      case None => Right(new Surface3(longitude, latitude, surfaceType))
    }
  def createOption(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Option[Surface3] =
    createEither(longitude, latitude, surfaceType) match {
      case Right(surface3) => Some(surface3)
      case Left(_) => None
    }
}
case class Surface3 private (longitude: Double, latitude: Double, surfaceType: SurfaceType.Value)
//
val surface3option1a = Surface3.createOption()
val surface3option1b = Surface3.createOption(0.0d, 0.0d, SurfaceType.SEA)
val surface3option1c = Surface3.createOption(-180.0d, -90.0d, SurfaceType.ICE)
val surface3option1d = Surface3.createOption(-180.1d, -90.1d, SurfaceType.ICE) //None
val surface3either1a = Surface3.createEither()
val surface3either1b = Surface3.createEither(0.0d, 0.0d, SurfaceType.SEA)
val surface3either1c = Surface3.createEither(-180.0d, -90.0d, SurfaceType.ICE)
val surface3either1d = Surface3.createEither(-180.1d, -90.1d, SurfaceType.ICE) //Left[...]

【问题讨论】:

  • 如果你不介意有额外的依赖,你应该看看 Scalaz 的 ValidationNel。看看这个post 了解更多信息。
  • 现在,我宁愿找出没有 Scalaz 的最小样板解决方案。不过,Tysvm 的建议。

标签: scala validation installation case-class


【解决方案1】:

永远不要用异常表达逻辑。这可能是 Java 中的标准做法,但它仍然是一种反模式。这同样适用于require,因为它只是throw 的包装。

验证是一个完美的逻辑操作,其结果可以很容易地用以下任何标准类型表示:BooleanOptionEither。最后一个可用于传递有关验证失败的特定信息,例如带有描述消息的String。更重要的是,OptionEither 类型允许您通过包装数据来编码数据的有效性。

您的代码中还有一些其他问题。您引入常量并通过在多个地方冗余地依赖它们来耦合您的代码。另一件事是,案例类实际上应该只是数据,而无需像您介绍的那样大惊小怪。

以下是解决问题的方法:

case class Surface1(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA)

type ValidSurface1 = Either[String, Surface1]
object ValidSurface1 {
  def validateLongitude(longitude: Double) = {
    val max = 180
    if( longitude.abs =< max ) Right(longitude) 
    else Left(s"longitude [$longitude] must have an absolute greater than or equal to $max")
  }
  def validateLatitude(latitude: Double): Either[String, Double] = 
    sys.error("TODO: same as validateLongitude")
  def validateSurfaceType(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Either[String, Double] = 
    sys.error("TODO: validation assuming longitude and latitude are validated")

  def apply(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) = 
    for {
      _ <- validateLongitude(longitude)
      _ <- validateLatitude(latitude)
      _ <- validateSurfaceType(longitude, latitude, surfaceType)
    }
    yield Surface1(longitude, latitude, surfaceType)
}

您现在可以通过调用ValidSurface1(...) 来构造经过验证的表面。

如果您不了解 apply 方法中发生的情况,我们将揭露 Either 是一个 monad 的事实,并且“for-yield”表示法是为它们设计的。您可以在网络上轻松找到大量有关 Scala 中 monad 的阅读材料。

【讨论】:

  • @wingedsubmariner 哦.. 否则没有更好的方法来实现这一点,然后使用应用函子,因此,Scalaz。 Here is 提供详细信息的答案。
  • 如果我正确阅读了这个解决方案,它允许用任何值构造案例类,无论是否有效,验证是实例化后的可选操作。我的要求之一是防止案例类的无效实例被实例化。 IOW,如果存在案例类的实例,则可以假定它是有效的。据我了解,这是 ADT(抽象数据类型)的好处之一。
  • @chaotic3quilibrium 您可以将案例类的默认构造函数设为私有,并将此答案中的apply 方法提供给它的伴生对象。这样,您将确保除了通过验证之外没有其他方法可以实例化该类。这就是抽象数据类型的意义所在。
  • @NikitaVolkov 我正在尝试遵循您的代码。但是,我遇到了返回 Either[String, Double] 的 validateSurfaceType 函数。那是对的吗?如果是这样,我不明白如何编写该函数的主体。
  • @chaotic3quilibrium 哦,对,这是我的错字。只需使用 Either[String, SurfaceType.Value] 代替。是的,只需传递 surfaceType 值。
【解决方案2】:

这是一个不需要更改伴生对象或重复重复构造函数参数的解决方案。它提供类似于require 的DSL,但允许收集多个错误。它使用异常,并且可能与样板文件一样小:

class ValidationException(val errors: Seq[String], message: String) extends Exception(message)

class Validator {
  var errors = Vector[String]()
  def done() = {
    if (!errors.isEmpty) {
      val message = "Multiple validation errors:\n" + errors.mkString("\n")
      throw new ValidationException(errors, message)
    }
  }
  def require(b: Boolean, s: String) = {
    if (!b)
      errors :+= s
  }
  def isEmpty = errors.isEmpty
}

trait ValidatedClass {
  def validate(v: Validator): Unit

  {
    val v = new Validator
    validate(v)
    v.done()
  }
}

case class Surface2 (longitude: Double = 0d, latitude: Double = 0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) extends ValidatedClass {
  import Surface2._

  def validate(v: Validator) = {
    v.require(isLongitudeValid, s"longitude [$longitude] must be greater than or equal to -$LONGITUDE_MAX and less than or equal to $LONGITUDE_MAX")
    v.require(isLatitudeValid, s"latitude [$latitude] must be greater than or equal to -$LATITUDE_MAX and less than or equal to $LATITUDE_MAX")
    if (v.isEmpty)
      v.require(isSurfaceTypeCorrect, s"for longitude[$longitude] and latitude[$latitude], surfaceType [$surfaceType] must be the correct")
  }

  // These access the constructor parameters directly and return Boolean
  def isLongitudeValid = ???
  def isLatitudeValid = ???
  def isSurfaceTypeCorrect = ???
}

我非常喜欢例外。它们默认提供失败,无需样板即可编写,并且不会尝试使用.get 来假装错误不存在。 Either 是在 Haskell 中复活的检查异常的噩梦。 Scala 的Try 可以提供两全其美的效果,但如果需要,我会将其留给客户端代码以包装在Try() 中。

【讨论】:

  • 如果我准确地阅读了您的解决方案,它需要我实例化案例类,然后检查有效性。如果是这样,这不是我要找的。我想要的效果是首先防止案例类的无效实例被实例化。 IOW,如果存在案例类的实例,则可以假定它是有效的。这就是我对 ADT(抽象数据类型)部分价值的理解。
  • @chaotic3quilibrium 否,此方案在实例化案例类时检查有效性,如果无效,构造函数将抛出异常。你不需要手动调用validate,它会被自动调用——这是由ValidatedClass主体中的块完成的。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2017-10-05
  • 1970-01-01
  • 2013-10-06
  • 2013-10-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多