If debugging is the process of removing bugs, then programming must be the process of putting them in.

Edsger W. Dijkstra

Ceux qui me connaissent savent que je suis un grand fan des langages typés, de programmation fonctionnelle et de Scala. Pourquoi ? Tout simplement parce qu’ils permettent de coder de manière concise tout en évitant un grand nombre de bugs que nous, développeurs distraits, avons l’habitude de laisser derrière nous

Static typing

Habituellement je fais références aux Option, Future, Try, Either… mais la semaine dernière j’ai eu quelques soucis dans mon code qui m’ont fait réfléchir et m’ont décidés à pousser encore un peu plus dans ce sens. Voici un exemple :

def details(eventId: String, exponentId: String) = SecuredAction.async { implicit req =>
  withEvent(exponentId) { event =>
    withExponent(eventId) { exponent =>
      AttendeeRepository.findByUuids(exponent.info.team).map { team =>
        Ok(backend.views.html.Events.Exponents.details(exponent, team, event))
      }
    }
  }
}

Évidemment, vous avez tout de suite vu le bug ! Non ? Pourtant, il est tellement évident que même un non-codeur devrait l’identifier immédiatement… C’est bon ? Vous l’avez ? Pas si facile en effet, et encore, c’est vraiment les conditions idéales : je vous dit qu’il y a un bug, il y a très peu de lignes, le code est très clair, l’erreur est évidente… Imaginez que ce soit sur du code bien plus complexe, en plein milieu de votre application, dans une partie du site rarement utilisée / difficilement accessible et que le bug soit bien moins évident (ex : AttendeeRepository => UserRepository)… Évidemment c’est ce que j’ai eu il y a quelques jours et ça m’a motivé pour trouver un solution afin que ces erreurs soient détectées dès la compilation !

Si on prends un peu de recul, le problème est évident : on utilise le même type pour des données de nature différentes. C’est quand même ballot d’utiliser le même type (String) pour : tous les IDs, emails, noms (utilisateur, société…), adresses, urls, commentaires… Du coup, impossible de se reposer sur le compilateur ou tout autre outil pour s’assurer qu’on n’a pas fait de boulette. En même temps, comme ce sont des types primitifs, c’est logique de les utiliser un peu partout. Mais les effets peuvent être ravageurs et difficiles à identifier. Par exemple :

  • Se tromper dans ses IDs (comme dans l’exemple) => j’utilise des UUIDs donc j’avais un NotFound alors que je n’aurais pas du (pas toujours évident à voir) mais si vous utilisez des IDs qui se suivent (auto-increment), vous récupérerez juste les mauvais objets (très difficile à identifier !)
  • Inverser deux champs dans une fonction (ex : Nom / Prénom d’un utilisateur) => en fonction de où et quand l’utilisateur a été créé/modifié, il aura ce problème qui n’est pas évident à identifier et impossible à corriger après coup

Dynamic typing

Bref, je crois que le problème est assez clair et j’espère que vous êtes aussi convaincus que moi que corriger ça fera drastiquement diminuer le nombre de bugs / temps de debug dans votre application.

La seule question qui reste est : comment le faire ? Et surtout sans alourdir/complexifier votre code !

Play Framework & Scala for the win !

Étant loin d’être un expert, je vous présente simplement la solution que j’ai mise en place sur le backend de SalooN. Il y a potentiellement mieux (je suis preneur si c’est le cas).

On veut donc typer nos propriétés pour réduire au maximum les bugs possibles et améliorer la compréhension de notre code (f: FullName => Slug est quand même plus clair que f: String => String). Pour l’instant concentrons nous uniquement sur les IDs (ce sont eux les plus utilisés après tout).

Heureusement pour nous, Scala a des Value Classes qui vont nous permettre de faire exactement ce que l’on souhaite. Ce sont des classes particulières qui ne sont pas allouées à l’exécution, ce qui est parfait pour typer nos String. Pour cela, elles ne doivent prendre qu’un seul argument et hériter de AnyVal. A noter que j’aurais aussi pu utiliser Unboxed Tagged Types mais c’est une autre histoire…

Donc voici notre nouveau type :

case class EventId(id: String) extends AnyVal

Il suffit maintenant de remplacer Event(uuid: String, ...) par Event(uuid: EventId, ...), de répercuter ce changement partout dans le code et voilà ! Simple non ?

Oui… Et non

Play framework possède un certain nombre de fonctionnalités avec des bindings vers les objets communs (String, Int, Long, Date…) mais nous venons justement de transformer un objet commun (String) en une classe custom que Play framework ne sait pas utiliser directement. Il va donc falloir définir nous même ces bindings pour que tout fonctionne au mieux (ce qui est assez rare habituellement, en tous cas, c’était une première pour moi !). Et au lieu de se plaindre de devoir bosser un peu plus, prenons-le comme une opportunité d’en apprendre d’avantage sur notre framework préféré

Tout d’abord les routes. En effet, les IDs étant généralement utilisés comme paramètre dans les URLs, on va enfin pouvoir mieux les typer :

GET  /backend/events/:eventId        backend.controllers.Events.details(eventId: String)

Ressemblera maintenant à :

GET  /backend/events/:eventId        backend.controllers.Events.details(eventId: EventId)

=> not found: type EventId

Oooups ! Notre type, EventId, n’est pas importé par défaut dans le fichier de routes… Pour ça, il faut simplement ajouter la dépendance dans notre build.sbt :

play.PlayImport.PlayKeys.routesImport += "common.models.event.EventId"

On recharge la configuration Play et :

No URL path binder found for type common.models.event.EventId.
Try to implement an implicit PathBindable for this type.

Re-Oooups ! Le problème ici, c’est que Play ne sait pas transformer la String de l’URL en EventId pour le transmettre ensuite à l’Action correspondante… Il y a tous les bindings nécessaires pour les types habituels (String, Long, Int et même Option et RegEx) mais il ne peut pas gérer notre type custom. On va donc devoir écrire notre binding propre avec un PathBindable. Ce sera d’ailleurs l’occasion de vérifier que le format de l’ID est correct (de la forme e3485ee0-7591-42fa-8c2e-110b203b06ab) :

object EventId {
  private def build(str: String): Option[EventId] = 
    Try(java.util.UUID.fromString(str)).toOption.map(uuid => EventId(uuid.toString))
  implicit val pathBinder = new PathBindable[EventId] {
    def bind(key: String, value: String): Either[String, EventId] = 
      build(value).toRight("Wrong format")
    def unbind(key: String, value: EventId): String = value.id
  }
}

Mettre le binding dans l’objet compagnon de l’EventId permet de l’importer en même temps que le type. Il est donc maintenant accessible dans le fichier de routes.

Et voilà… Maintenant vous avez des routes correctement typées avec la vérification du format des IDs !!! Et sans alourdir le code de l’application (EventId à la place de String). Mais il est probable que votre projet ne compile toujours pas

Si comme moi vous utilisez MongoDB ou que vous exposez une API et que vous sérialisez en JSON votre objet Event vous devriez voir un joli :

No implicit format for common.models.event.EventId available.

Et oui, il faut écrire des Reads et Writes… Et malheureusement l’habituel implicit val format = Json.format[EventId] ne vous aidera pas cette fois-ci

En effet, même si on utilise une Value Classe qui sera traitée uniquement comme une String à l’exécution, ça n’en reste pas moins une classe. Du coup, la macro la sérialisera sous la forme {id: "0000"} au lieu de simplement "0000". On va donc devoir passer par des Reads/Writes custom. Ce sera d’ailleurs l’occasion d’y ajouter une validation du format comme pour les routes :

object EventId {
  private def build(str: String): Option[EventId] = 
    Try(java.util.UUID.fromString(str)).toOption.map(uuid => EventId(uuid.toString))
  implicit val pathBinder = ...
  implicit val jsonFormat = Format(new Reads[EventId] {
    def reads(json: JsValue): JsResult[EventId] = 
      json.validate[String].map(id => build(id)).filter(_.isDefined).map(_.get)
  }, new Writes[EventId] {
    def writes(value: EventId): JsValue = JsString(value.id)
  })
}

Les sérialisations devraient maintenant bien se passer et vérifier le format des IDs.

Petite anecdote : dans l’objet Session (les talks en gros), je stocke la liste des IDs des speakers. La vérification du format m’a fait me rendre compte que sur un événement particulier les sessions sans speaker avaient une liste avec une String vide (List("")) au lieu d’une liste vide (List()). Aucun bug n’était visible dans l’application mais j’aurais pu avoir des statistiques fausses (si je compte le nombre de speakers en comptant les IDs) ou d’autres joyeusetés !!! Merci les types !!!

Always fail

Prochaine étape : les formulaires. Si vous utilisez les formulaires Play vous devriez avoir quelque chose du genre :

val eventForm: Form[Event] = Form(mapping(
  "uuid" -> nonEmptyText,
  "name" -> nonEmptyText,
  ...
  "description" -> optional(text))(Event.apply)(Event.unapply))

Oui ? Dans ce cas vous devriez aussi avoir un joli :

type mismatch;
 found   : (common.models.event.EventId, String, ..., String) => common.models.event.Event
 required: (String, String, ..., String) => ?

Encore une fois, le mapping nonEmptyString est fait pour les String et pas pour les EventId. Et devinez quoi, Play n’a pas de binding pour EventId build-in… On va donc devoir faire notre Formatter spécifique :

object EventId {
  private def build(str: String): Option[EventId] = 
    Try(java.util.UUID.fromString(str)).toOption.map(uuid => EventId(uuid.toString))
  implicit val pathBinder = ...
  implicit val jsonFormat = ...
  implicit val formMapping = new Formatter[EventId] {
    def bind(key: String, data: Map[String, String]): Either[Seq[FormError], EventId] = 
      data.get(key).flatMap(build).toRight(Seq(FormError(key, "error.wrongFormat", Nil)))
    def unbind(key: String, value: EventId): Map[String, String] = Map(key -> value.id)
  }
}

Et l’utiliser dans tous nos formulaires :

val eventForm: Form[Event] = Form(mapping(
  "uuid" -> of[EventId],
  "name" -> nonEmptyText,
  ...
  "description" -> optional(text))(Event.apply)(Event.unapply))

Notre fonction build() vérifie encore que l’ID a un format correct \o/

C’est pas beau tout ça ? On vient d’écrire en quelques lignes les adaptateurs nécessaires pour que Play se trouve aussi à l’aise avec des EventId qu’avec une simple String ! Plus aucun soucis pour utiliser des types custom de partout, améliorer la cohérence et la compréhension de notre code et surtout diminuer drastiquement la probabilité de bugs dûs à des erreurs d’inattention ou de copier/coller.

Autre petite anecdote : dans le processus, j’ai trouvé 2 autres endroits où je m’étais emmêlé les pinceaux avec les IDs. Donc soit je suis vraiment pas doué (ce qui est bien possible !). Soit vous feriez bien de checker vos app

Is this real life ?

Theory land Comme souvent, les articles de blog montrent tous les avantages mais sans nécessairement évoquer d’éventuels subtilités de mise en oeuvre ou passages un peu moins friendly. Comme je viens de migrer tout le backend de SalooN, voici quelques problèmes supplémentaires que j’ai dû adresser.

Lorsque vous concaténez votre EventId avec une autre String, la fonction toString() sera automatiquement appelée. Or, si vous ne la redéfinissez pas (override def toString: String = this.id), la sortie sera différente par rapport à votre code d’avant : EventId(0000) au lieu de 0000.

Puisqu’on on parle d’IDs, il faut les générer. Avant j’avais un objet Repository avec une méthode Repository.generateUuid(): String qui fonctionnait pour tout. Maintenant vous pouvez générer votre ID directement depuis son objet : EventId.generate(): EventId. C’est quand même mieux !

Parfois, c’était quand même bien pratique de pouvoir “mélanger” plusieurs IDs ensemble. Par exemple, sur SalooN les utilisateurs peuvent mettre en favoris des sessions, des exposants, d’autres participants… J’ai donc une def favorite(itemType: String, itemId: String) qui me permet d’enregistrer des favoris pour n’importe quel type d’objet. Maintenant que chaque ID a un type incompatible avec les autres (et c’est bien l’objectif de tout ça) comment implémenter une telle fonction ? Bien sûr, on pourrait ressortir l’ID de sa classe et le passer en tant que String. Mais on a quand même pas fait tout ça pour ça !!! J’ai donc choisi de créer un ID générique GenericId de la même manière et d’implémenter des conversions implicites dans le sens spécifique => générique :

object GenericId {
  ...
  implicit def fromEventId(eventId: EventId): GenericId = GenericId(eventId.id)
  implicit def fromAttendeeId(attendeeId: AttendeeId): GenericId = GenericId(attendeeId.id)
  implicit def fromExponentId(exponentId: ExponentId): GenericId = GenericId(exponentId.id)
  implicit def fromSessionId(sessionId: SessionId): GenericId = GenericId(sessionId.id)
}

Attention à ne surtout pas implémenter d’autres conversions implicites. Il faut que les types incompatibles le restent !!!

Toutes les autres conversions doivent être explicites et faites avec beaucoup de précautions.

J’ai donc maintenant un def favorite(itemType: String, itemId: GenericId) qui indique clairement que cette fonction prend différents types d’ID. On pourrait pousser le vice en créant un ItemId qui serait compatible (conversions implicites) qu’avec les ID acceptables. À voir à l’usage… Mais je n’y suis pas encore

Take Away

Un des objectifs est de répéter cette opération au moins pour tous vos IDs, voire pour un certain nombre de champs assez génériques (Email, FirstName, LastName, FullName, Avatar…) afin de garantir une vraie cohérence dans le code. Mais si il faut tout redéfinir à chaque fois, c’est un peu pénible et surtout risqué en terme de maintenance (DRY, Don’t Repeat Yourself !)

Voici donc une classe générique pour avoir un minimum de boilerplate :

trait tString extends Any {
  def unwrap: String
  override def toString: String = this.unwrap
}
trait tStringHelper[T <: tString] {
  def build(str: String): Option[T]
  protected val buildErrKey = "error.wrongFormat"
  protected val buildErrMsg = "Wrong format"
  implicit def pathBinder = new PathBindable[T] {
    override def bind(key: String, value: String): Either[String, T] = build(value).toRight(buildErrMsg)
    override def unbind(key: String, value: T): String = value.unwrap
  }
  implicit def jsonFormat = Format(new Reads[T] {
    override def reads(json: JsValue): JsResult[T] = json.validate[String].map(id => build(id)).filter(_.isDefined).map(_.get)
  }, new Writes[T] {
    override def writes(value: T): JsValue = JsString(value.unwrap)
  })
  implicit def formMapping = new Formatter[T] {
    override def bind(key: String, data: Map[String, String]): Either[Seq[FormError], T] = data.get(key).flatMap(build).toRight(Seq(FormError(key, buildErrKey, Nil)))
    override def unbind(key: String, value: T): Map[String, String] = Map(key -> value.unwrap)
  }
}

Avec ça, pour créer un nouveau type il faut simplement quelques lignes :

case class Email(value: String) extends AnyVal with tString {
  def unwrap: String = this.value
}
object Email extends tStringHelper[Email] {
  def build(str: String): Option[Email] = Some(Email(str))
}

Pour les IDs, ils sont un peu plus spécifiques du coup j’utilise un trait UUID qui définit leur comportement additionnel ainsi qu’un ID générique. Ce qui donne :

trait UUID extends Any with tString {
  def toGenericId: GenericId = GenericId(this.unwrap)
}
object UUID {
  def generate(): String = java.util.UUID.randomUUID().toString()
  def toUUID(str: String): Option[String] = 
    Try(java.util.UUID.fromString(str)).toOption.map(_.toString)
}
case class GenericId(id: String) extends AnyVal with tString with UUID {
  def unwrap: String = this.id
 
  def toEventId: EventId = EventId(this.id)
  def toAttendeeId: AttendeeId = AttendeeId(this.id)
  def toExponentId: ExponentId = ExponentId(this.id)
  def toSessionId: SessionId = SessionId(this.id)
}
object GenericId extends tStringHelper[GenericId] {
  def generate(): GenericId = GenericId(UUID.generate())
  def build(str: String): Option[GenericId] = UUID.toUUID(str).map(id => GenericId(id))
 
  implicit def fromUUID(uuid: UUID): GenericId = GenericId(uuid.unwrap)
  implicit def fromEventId(id: EventId): GenericId = GenericId(id.unwrap)
  implicit def fromAttendeeId(id: AttendeeId): GenericId = GenericId(id.unwrap)
  implicit def fromExponentId(id: ExponentId): GenericId = GenericId(id.unwrap)
  implicit def fromSessionId(id: SessionId): GenericId = GenericId(id.unwrap)
}
case class EventId(val id: String) extends AnyVal with tString with UUID {
  def unwrap: String = this.id
}
object EventId extends tStringHelper[EventId] {
  def generate(): EventId = EventId(UUID.generate())
  def build(str: String): Option[EventId] = UUID.toUUID(str).map(id => EventId(id))
}

Voilà voilà, vous avez tout maintenant !!!

Comme je le disais en préambule, ce n’est pas forcément la technique ultime mais simplement ce que j’ai mis en place dans mon application. Si vous voyez des moyens de faire mieux, je suis preneur

Comme piste d’amélioration, la fonction build() pourrait renvoyer un Either au lieu d’une Option et on pourrait ajouter des messages d’erreur custom pour les différents mappers… À voir à l’usage…

Bilan

Côté bilan, il faudra voir un peu plus sur le long terme. Mais pour avoir refactoré l’ensemble de mon application, je trouve que l’ensemble du code est bien plus clair (le type portant déjà une information sur l’objet, on peut en ajouter d’autres dans le nom sans avoir des noms à rallonge) et je me sens bien plus serein avec une codebase mieux typée (déjà 8 bugs corrigés uniquement grâce à ça !).

Côté maintenabilité, avec seulement 3 classes génériques (tString, UUID, GenericId), un nouveau type se définit en quelques lignes et intègre une validation.

Côté utilisation je ne vois aucun inconvénient sachant que les transformations “logiques” peuvent être définies comme implicites, que celles “risquées” sont simples mais doivent rester explicites et qu’il est possible de revenir à une simple String avec le .unwrap si besoin (compatibilité autres libs/parties du code).

J’espère que cet article vous aura été bénéfique et que vous y avez appris quelque chose. En tous cas, moi, je suis très content d’avoir eu l’occasion d’avancer un peu plus sur mes connaissances de Play framework (qui est très puissant !) et de pouvoir toujours plus typer mes applications

Baby coder