Un joueur est ajouté à notre application.
{ "name": "Batman", "level": 99, "hp": 85 }
Le JSON est parsé et les erreurs de structure gérées.
case class Player(name: String, level: Int, hp: Int)
On veut valider ces données avec des critères métier.
// On veut implémenter cette fonction def validate(player: Player): ValidPlayer = ??? case class ValidPlayer(name: String, level: Int, hp: Int) // ^ Même structure que Player
case class Player(name: String, level: Int, hp: Int)
Pour qu'un joueur soit valide (et devienne un ValidPlayer), il doit respecter ces 3 critères :
Longueur de nom supérieur à 3 caractères :
def validateName(name: String): Boolean = name.size >= 3
Niveau strictement positif :
def validateLevel(level: Int): Boolean = level > 0
Moins de points de vie que 95 + niveau * 5 :
def validateHp(level: Int, hp: Int): Boolean = hp <= 95 + level * 5
validate peut échouer à produire une valeur de type ValidPlayer.
def validate(player: Player): ValidPlayer = ???
Comment le gérer ?
Deux points de vue :
Si validate échoue, on renvoie null.
def validate(player: Player): ValidPlayer = { if ( /* ... */ ) { ValidPlayer( /* ... */ ) } else null }
val player = Player( /* ... */ ) val validPlayer = validate(player) if (validPlayer != null) { // On peut utiliser validPlayer }
Que ce passe-t-il si on oublie le test et que la validation a échoué ?
if (validPlayer != null) { /* ... */ }
java.lang.NullPointerException
Billion Dollar Mistake
De manière générale :
Ne jamais utiliser null.
def validate(player: Player): ValidPlayer = { if (!validateName(player.name)) { throw new RuntimeException("Invalid name") } if (!validateLevel(player.level)) { throw new RuntimeException("Invalid level") } if (!validateHp(player.level, player.hp)) { throw new RuntimeException("Invalid HP") } ValidPlayer(player.name, player.level, player.hp) }
try { validate(p) } catch { case e: RuntimeException => /* Réparer ou propager l'erreur */ }
Pas de checked exceptions en Scala.
Pokemon Driven Development: Gotta catch 'em all!
Pas très adapté pour gérer des erreurs métier prévisibles...
Une erreur métier est un résultat comme un autre.
Comment peut-on représenter ces erreurs métier en Scala ?
case object NameTooShort case object InvalidLevel case class TooManyHp(current: Int, max: Int)
Algébrique ?
abstract sealed trait VE // ^^ Pour ValidationError // ^^^ Très important !
case object NameTooShort extends VE case object InvalidLevel extends VE case class TooManyHp(current: Int, max: Int) extends VE
Uniquement 3 manières de créer une valeur de type VE :
val e1: VE = NameTooShort val e2: VE = InvalidLevel val e3: VE = TooManyHp(current, max)
scalacOptions ++= Seq( "-unchecked", "-deprecation", "-feature", "-Xfuture", "-Xlint", "-Xfatal-warnings" )
def validate(player: Player): Option[ValidPlayer] = { if ( /* ... */ ) { Some(ValidPlayer( /* ... */ )) } else None }
Suffisant si on n'a pas besoin d'information sur l'erreur.
val player = Player( /* ... */ ) val validPlayer = validate(player) // Option[ValidPlayer]
// Modifier sans traiter l'erreur : val playerName = validPlayer.map(p => p.name) // ^ Option[String] // Accéder à la valeur : validPlayer match { case None => // On gère l'erreur case Some(p) => // On peut utiliser p } // Fournir une valeur par défaut : playerName.getOrElse(Player( /* ... */ ))
import scala.util.Either
def validate(player: Player): Either[VE, ValidPlayer] = { if (!validateName(player.name)) { Left(NameTooShort) } else if (!validateLevel(player.level)) { Left(InvalidLevel) } else if (!validateHp(player.level, player.hp)) { Left(TooManyHp(player.hp, 95 + player.level * 5)) } else { Right(ValidPlayer(player.name, player.level, player.hp)) } }
val player = Player( /* ... */ ) val validPlayer = validate(player) // Either[VE, ValidPlayer]
// Modifier sans traiter l'erreur : val playerName = validPlayer.right.map(p => p.name) // ^ Either[VE, String] // ^^^^^^ Pas dingue :/ validPlayer match { case Right(p) => // On peut utiliser p case Left(NameTooShort) => // On gère l'erreur case Left(InvalidLevel) => // On gère l'erreur case Left(TooManyHp(current, max)) => // On gère l'erreur // Warning du compilateur si on oublie un cas \o/ }
Similaire à Either :
An extension to the core Scala library for functional programming.
https://github.com/scalaz/scalaz
libraryDependencies += "org.scalaz" %% "scalaz-core" % "7.2.7"
import scalaz.{ \/, -\/, \/- }
def validate(player: Player): VE \/ ValidPlayer = { if (!validateName(player.name)) { -\/(NameTooShort) } else if (!validateLevel(player.level)) { -\/(InvalidLevel) } else if (!validateHp(player.level, player.hp)) { -\/(TooManyHp(player.hp, 95 + player.level * 5)) } else { \/-(ValidPlayer(player.name, player.level, player.hp)) } }
Similaire à Either, mais part du principe que la valeur intéressante est à droite (right-biased).
eitherVal.left.map(/* ... */) eitherVal.right.map(/* ... */) disjunctionVal.leftMap(/* ... */) disjunctionVal.map(/* ... */)
A singly-linked list that is guaranteed to be non-empty.
List(xs: A*) List() // Compile List(1, 2, 3) // Compile
scalaz.NonEmptyList(h: A, t: A*) scalaz.NonEmptyList() // Erreur scalaz.NonEmptyList(1, 2, 3) // Compile
import scalaz.{ NonEmptyList, ValidationNel, Success, Failure } import scalaz.syntax.applicative._ import scalaz.syntax.validation._
def validate(p: Player): ValidationNel[VE, ValidPlayer] = { val vName = if (validateName(p.name)) Success(p.name) else Failure[NonEmptyList[VE]](NonEmptyList(NameTooShort)) val vLevel = if (validateLevel(p.level)) Success(p.level) else Failure[NonEmptyList[VE]](NonEmptyList(InvalidLevel)) val vHp = if (validateHp(p.level, p.hp)) Success(p.hp) else { val e = TooManyHp(p.hp, 95 + p.level * 5) Failure[NonEmptyList[VE]](NonEmptyList(e)) } /* ... */ }
def validate(p: Player): ValidationNel[VE, ValidPlayer] = { val vName = /* ... */ val vLevel = /* ... */ val vHp = /* ... */ (vName |@| vLevel |@| vHp) { (n, l, h) => ValidPlayer(n, l, h) } }
Permet d'accumuler les erreurs lorsqu'on fait des validations indépendantes.
Rapture is a family of Scala libraries providing beautiful idiomatic and typesafe Scala APIs for common programming tasks, like working with I/O, cryptography and JSON & XML processing.
libraryDependencies += "com.propensive" %% "rapture-core" % "2.0.0-M7"
import rapture.core._
On wrap notre fonction validate qui renvoie un Either.
def validate(player: Player)(implicit mode: Mode[_]): mode.Wrap[ValidPlayer, VE] = { mode.wrapEither(validateEither(player)) // ^^^^^^^^^^^^^^ // def validateEither(p: Player): Either[VE, ValidPlayer] }
On importe un mode à l'endroit de l'appel.
Par exemple, returnOption :
def validateOption(player: Player): Option[ValidPlayer] = { import modes.returnOption._ validate(player) }
Ou returnTry :
def validateTry(player: Player): Try[ValidPlayer] = { import modes.returnTry._ validate(player) }
Quelques modes actuellement disponibles :
modes.throwExceptions._ // default modes.returnEither._ //missing? modes.returnOption._ modes.returnTry._ modes.returnFuture._ modes.timeExecution._ modes.keepCalmAndCarryOn._ modes.explicit._
Orignal, mais pas prêt pour la production.
Pour gérer les erreurs métier :
Utiliser correctement ces types pour gérer les erreurs permet :