scala: how to handle validations in a functional w

2019-03-20 05:56发布

问题:

I'm developing a method that is supposed to persist an object, if it passes a list of conditions.

If any (or many) condition fail (or any other kind of error appears), a list with the errors should be returned, if everything goes well, a saved entity should be returned.

I was thinking about something like this (it's pseudocode, of course):

request.body.asJson.map { json =>
  json.asOpt[Wine].map { wine =>
    wine.save.map { wine => 
      Ok(toJson(wine.update).toString)
    }.getOrElse  { errors => BadRequest(toJson(errors))}
  }.getOrElse    { BadRequest(toJson(Error("Invalid Wine entity")))}
}.getOrElse      { BadRequest(toJson(Error("Expecting JSON data")))}

That is, I'd like to treat it like an Option[T], that if any validation fails, instead of returning None it gives me the list of errors...

The idea is to return an array of JSON errors...

So the question would be, is this the right way to handle these kind of situation? And what would be the way to accomplish it in Scala?

--

Oops, just posted the question and discovered Either

http://www.scala-lang.org/api/current/scala/Either.html

Anyway, I'd like to know what you think about the chosen approach, and if there's any other better alternative to handle it.

回答1:

Using scalaz you have Validation[E, A], which is like Either[E, A] but has the property that if E is a semigroup (meaning things that can be concatenated, like lists) than multiple validated results can be combined in a way that keeps all the errors that occured.

Using Scala 2.10-M6 and Scalaz 7.0.0-M2 for example, where Scalaz has a custom Either[L, R] named \/[L, R] which is right-biased by default:

import scalaz._, Scalaz._

implicit class EitherPimp[E, A](val e: E \/ A) extends AnyVal {
  def vnel: ValidationNEL[E, A] = e.validation.toValidationNEL
}

def parseInt(userInput: String): Throwable \/ Int = ???
def fetchTemperature: Throwable \/ Int = ???
def fetchTweets(count: Int): Throwable \/ List[String] = ???

val res = (fetchTemperature.vnel |@| fetchTweets(5).vnel) { case (temp, tweets) =>
  s"In $temp degrees people tweet ${tweets.size}"
}

Here result is a Validation[NonEmptyList[Throwable], String], either containing all the errors occured (temp sensor error and/or twitter error or none) or the successful message. You can then switch back to \/ for convenience.

Note: The difference between Either and Validation is mainly that with Validation you can accumulate errors, but cannot flatMap to lose the accumulated errors, while with Either you can't (easily) accumulate but can flatMap (or in a for-comprehension) and possibly lose all but the first error message.

About error hierarchies

I think this might be of interest for you. Regardless of using scalaz/Either/\//Validation, I experienced that getting started was easy but going forward needs some additional work. The problem is, how do you collect errors from multiple erring functions in a meaningful way? Sure, you can just use Throwable or List[String] everywhere and have an easy time, but doesn't sound too much usable or interpretable. Imagine getting a list of errors like "child age missing" :: "IO error reading file" :: "division by zero".

So my choice is to create error hierarchies (using ADT-s), just like as one would wrap checked exceptions of Java into hierarchies. For example:

object errors {

  object gamestart {
    sealed trait Error
    case class ResourceError(e: errors.resource.Error) extends Error
    case class WordSourceError(e: errors.wordsource.Error) extends Error
  }

  object resource {
    case class Error(e: GdxRuntimeException)
  }

  object wordsource {
    case class Error(e: /*Ugly*/ Any)
  }

}

Then when using result of erring functions with different error types, I join them under a relevant parent error type.

for {
  wordSource <-
    errors.gamestart.WordSourceError <-:
    errors.wordsource.Error <-:
    wordSourceCreator.doCreateWordSource(mtRandom).catchLeft.unsafePerformIO.toEither

  resources <-
    errors.gamestart.ResourceError <-:
    GameViewResources(layout)

} yield ...

Here f <-: e maps the function f on the left of e: \/ since \/ is a Bifunctor. For se: scala.Either you might have se.left.map(f).

This may be further improved by providing shapeless HListIsos to be able to draw nice error trees.

Revisions

Updated: (e: \/).vnel lifts the failure side into a NonEmptyList so if we have a failure we have at least one error (was: or none).



回答2:

If you have Option values, and you want to turn them into success/failure values, you can turn an Option into an Either using the toLeft or toRight method.

Usually a Right represents success, so use o.toRight("error message") to turn Some(value) into Right(value) and None into Left("error message").

Unfortunately Scala doesn't recognise this right-bias by default, so you have to jump through a hoop (by calling the .right method) in order to neatly compose your Eithers in a for-comprehension.

def requestBodyAsJson: Option[String] = Some("""{"foo":"bar"}""")

def jsonToWine(json: String): Option[Wine] = sys.error("TODO")

val wineOrError: Either[String, Wine] = for {
  body <- requestBodyAsJson.toRight("Expecting JSON Data").right
  wine <- jsonToWine(body).toRight("Invalid Wine entity").right
} yield wine


回答3:

If you need an empty value, instead of using Either[A,Option[B]] you can use lift Box, which can have three values:

  • Full (there is a valid result)
  • Empty (no result, but no error either)
  • Failure (an error happened)

Box are more flexible than Either thanks to a rich API. Of course, although they were created for Lift, you can use them in any other framework.



回答4:

well, this is my attemp using Either

def save() = CORSAction { request =>
  request.body.asJson.map { json =>
    json.asOpt[Wine].map { wine =>
      wine.save.fold(
        errors => JsonBadRequest(errors),
        wine => Ok(toJson(wine).toString)
      )
    }.getOrElse     (JsonBadRequest("Invalid Wine entity"))
  }.getOrElse       (JsonBadRequest("Expecting JSON data"))
}

And wine.save is like the following:

def save(wine: Wine): Either[List[Error],Wine] = {

  val errors = validate(wine)
  if (errors.length > 0) {
    Left(errors)
  } else {
    DB.withConnection { implicit connection =>
      val newId = SQL("""
        insert into wine (
          name, year, grapes, country, region, description, picture
        ) values (
          {name}, {year}, {grapes}, {country}, {region}, {description}, {picture}
        )"""
      ).on(
        'name -> wine.name, 'year -> wine.year, 'grapes -> wine.grapes,
        'country -> wine.country, 'region -> wine.region, 'description -> wine.description,
        'picture -> wine.picture
      ).executeInsert()

      val newWine = for {
        id <- newId;
        wine <- findById(id)
      } yield wine

      newWine.map { wine =>
        Right(wine)
      }.getOrElse {
        Left(List(ValidationError("Could not create wine")))
      }
    }
  }
}

Validate checks several preconditions. I still have to add a try/catch to catch any db error

I'm still looking for a way to improve the whole thing, it feels much to verbose to my taste...