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 :