Blog Arolla

Les type classes Scala : exemple sur une sérialisation MongoDB (1/2)

S’il y a un pattern que vous ne pouvez pas rater dans les librairies écrites en Scala ou dans les articles de blog consacrés à ce langage ce sont bien lestype classes”. Une type classe offre un moyen de définir un comportement commun à plusieurs types. Elle définit une interface commune, mais pas au sens de l’héritage rencontré dans la programmation orientée objet, aux types lui appartenant. Au même titre que l’héritage, les types classes permettent de définir des opérations “polymorphiques” c’est-à-dire des opérations qui marchent avec plusieurs types. Une type classe impose une contrainte qui doit être satisfaite par les types souhaitant utiliser son comportement. Afin de faciliter la compréhension de nos propos, voyons un exemple : la type classe Ordering définie par la librairie standard de Scala en triant le contenu d'une liste.

Trier les éléments d’une liste

Nous allons demander à Scala de nous trier une collection de nombres entiers. Lançons le REPL pour passer à l’action :

scala> val nums = List(2, 4, 9, 33, 9, 3, 11)
nums: List[Int] = List(2, 4, 9, 33, 9, 3, 11)

scala> val sortedNums = nums.sorted
sortedNums: List[Int] = List(2, 3, 4, 9, 9, 11, 33)

Nous avons créé une liste de nombres entiers puis avons demandé à Scala de la trier en invoquant la méthode sorted. Cette méthode nous retourne une liste triée. Plutôt facile ! Maintenant que se passe-t-il si nous souhaitons trier, non pas une liste d’entiers mais une liste d’objets quelconques ? Pour cela, imaginons que nous disposons d’une liste de joueurs constitués d’un nom et d’un score.

case class Player(name: String, score: Int)

Lançons le REPL et faisons comme plus haut :

scala> case class Player(name: String, score: Int)
defined class Player

scala> val players = List(Player("John Doe", 19), Player("Jane Doe", 34), Player("Toto Titi", 10))
players: List[Player] = List(Player(John Doe,19), Player(Jane Doe,34), Player(Toto Titi,10))
scala> players.sorted
<console>:11: error: No implicit Ordering defined for Player.
              players.sorted
                      ^

Cette fois-ci les choses ne se sont pas si bien passées ! Le message d’erreur explique qu’aucun “Ordering” n’est défini de manière implicite pour le type Player. Voici l’explication : la méthode sorted s’attend à trouver une instance de la type classe Ordering pour le type Player et nous obtenons une erreur puisqu’une telle instance n’existe pas. Définissons-en une en se basant sur le score des joueurs et réessayons de trier notre liste :

scala> implicit object personScoreOrdering extends Ordering[Player] {
     |   override def compare(p1: Player, p2: Player) = p1.score compare p2.score
     | }
defined module personScoreOrdering

scala> players.sorted
res0: List[Player] = List(Player(Toto Titi,10), Player(John Doe,19), Player(Jane Doe,34))

Cette fois-ci, la liste des joueurs est bien triée comme en atteste la sortie du REPL. Il a suffit de définir une instance de la type classe pour notre classe pour pouvoir bénéficier de l’opération de tri. En plus, nous n’avons apporté aucune modification à la classe Player pour y arriver. Après ces premiers pas, nous allons travailler sur un problème pour mieux comprendre des types classes : sérialiser des objets de notre application afin de permettre à MongoDB de les persister.

Les type classes et Mongo DB

Quand vous travaillez directement avec le driver Scala de Mongo DB sans passer par un framework de mapping objet/document comme Morphia ou Salat, vous avez besoin de transformer vos objets métier en DBObject afin de les persister. De même lorsque vous lisez un document MongoDB, vous obtenez un objet DBObject que vous transformez en un objet du domaine avant d’en faire quelque chose. Le listing suivant définit une interface à cet effet.

package fr.arolla.blog.mongo
import com.mongodb.casbah.Imports._

trait MongoDbModel[T] {
  def collectionName: String
  def write(t: T): Option[DBObject]
  def read(dbo: DBObject): Option[T]
}

Elle inclut aussi une fonction qui permet d’obtenir le nom de la collection dans laquelle sont stockés les documents correspondants à notre modèle. MongoDbModel est la type classe. Voyons comment elle est utilisée en définissant le code qui interagit avec Mongo pour stocker nos objets, les lister ou les supprimer :

package fr.arolla.blog.mongo
import com.mongodb.casbah.Imports._

trait MongoOperations {
  def save[T](t: T)(tc: MongoDbModel[T], coll: String => MongoCollection): Unit
  def findOne[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection): Option[T]
  def findById[T](id: ObjectId)(tc: MongoDbModel[T], coll: String => MongoCollection): Option[T]
  def find[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection): Seq[T]
  def delete[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection): Unit
  def count[T](tc: MongoDbModel[T], coll: String => MongoCollection): Long
}

Le trait MongoOperations définit les opérations CRUD avec la base. Attardons-nous un peu sur la signature des méthodes. Le paramètre tc de type MongoDbModel sert à convertir un objet métier t en une instance de DBObject qui est persisté par MongoDB ou de faire la transformation inverse. Le paramètre coll est une fonction qui retourne un objet de type MongoCollection en prenant son nom en paramètre. Par exemple, la méthode save utilise le paramètre tc pour convertir l’objet métier t en une instance de DBObject qui est persisté par MongoDB dans la collection renvoyée par la fonction coll. Implémentons les méthodes puisqu’un trait peut contenir des définitions :

package fr.arolla.blog.mongo
import com.mongodb.casbah.Imports._

trait MongoOperations {
  def save[T](t: T)(tc: MongoDbModel[T], coll: String => MongoCollection): Unit =
    tc.write(t) map { dbo => coll(tc.collectionName).insert(dbo) }

  def findOne[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection):
    Option[T] = coll(tc.collectionName).findOne(query).flatMap { tc.read _ }

  def findById[T](id: ObjectId)(tc: MongoDbModel[T], coll: String => MongoCollection):
    Option[T] = coll(tc.collectionName).findOneByID(id).flatMap { tc.read _ }

  def find[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection):
    Seq[T] = coll(tc.collectionName).find(query).map { tc.read _ }.flatten.toSeq

 def delete[T](query: DBObject)(tc: MongoDbModel[T], coll: String => MongoCollection):
    Unit = coll(tc.collectionName).remove(query)

 def count[T](tc: MongoDbModel[T], coll: String => MongoCollection): Long =
   coll(tc.collectionName).count
}

Définissons deux classes simples pour pouvoir tester notre code :

package fr.arolla.blog.domain
import org.bson.types.ObjectId

case class User(id: ObjectId, name: String)
case class Task(id: ObjectId, title: String, isDone: Boolean)

Pour pouvoir utiliser MongoOperations avec nos classes, il nous faut pour chacune une instance de la type classe. Nous en définissons une dans les objets compagnons ("companion objects") comme suit :

Companion object de User :

object User {
  implicit object UserMongoModel extends MongoDbModel[User] {
    def collectionName: String = "users"
    def write(user: User): Option[DBObject] = try {
      Some(DBObject("_id" -> user.id, "name" -> user.name))
    } catch {
      case _ => None
    }

    def read(dbo: DBObject): Option[User] = try {
      Some(User(dbo.as[ObjectId]("_id"), dbo.as[String]("name")))
    } catch {
      case _ => None
    }
   }
}

Companion object de Task :

object Task {
  implicit object TaskMongoModel extends MongoDbModel[Task] {
    def collectionName = "tasks"
    def write(task: Task): Option[DBObject] = try {
      Some(DBObject("_id" -> task.id, "title" -> task.title, "isDone" ->
      task.isDone))
    } catch {
      case _ => None
    }

    def read(dbo: DBObject): Option[Task] = try {
      Some(Task(dbo.as[ObjectId]("_id"), dbo.as[String]("title"),
        dbo.as[Boolean]("isDone")))
    } catch {
      case _ => None
    }
  }
}

Vous aurez besoin d’une installation de MongoDB pour pouvoir lancer les tests. Rendez-vous sur mongodb.org pour plus d’information sur l’installation.

Testons notre code dans le REPL :

scala> import fr.arolla.blog.mongo._
import fr.arolla.blog.mongo._
scala> object MongoService extends MongoOperations {}
defined module MongoService
scala> import com.mongodb.casbah.Imports._
import com.mongodb.casbah.Imports._
scala> def collFun = (name: String) => MongoConnection()("test")(name)
collFun: String => com.mongodb.casbah.MongoCollection
scala> import fr.arolla.blog.domain._
import fr.arolla.blog.domain._
scala> val user = User(new ObjectId, "Toto")
user: fr.arolla.blog.domain.User = User(5010035d44ae79f41b026ad3,Toto)
scala> import MongoService._
import MongoService._
scala> save(user)(User.UserMongoModel, collFun)

Nous avons défini d'abord un service qui implémente l'interface MongoOperations puis avons créé et persisté un utilisateur dans la base. Avant de tester les autres méthodes, persistons un autre type d'objet : une tâche.

scala> val task = Task(new ObjectId, "A first task", false)
task: fr.arolla.blog.domain.Task = Task(501005b544aed38335cfe824,A first task,false)
scala> save(task)(Task.TaskMongoModel, collFun)

Là aussi, tout à l'air de bien se passer. La méthode save a été capable de persister les deux types d'objet. Ceci dit une vérification s'impose ! Nous allons récupérer le contenu de la base avec la méthode findById.

scala>findOne[User](DBObject("_id" -> new ObjectId("5010035d44ae79f41b026ad3")))(User.UserMongoModel, collFun)
res2: Option[fr.arolla.blog.domain.User] = Some(User(5010035d44ae79f41b026ad3,Toto))

Nous arrivons bien à récupérer l'utilisateur précédemment stocké dans la base. Et si je fais la requête avec un mauvais id ?

scala>findOne[User](DBObject("_id" -> new ObjectId("5010035d44ae79f41b026ad4")))(User.UserMongoModel, collFun)
res3: Option[fr.arolla.blog.domain.User] = None

Dans ce cas, le service ne trouve rien car aucun objet ne correspond à cet identifiant que j’ai créé de toute pièce !

Tout va bien jusque-là. Nous avons réussi à définir un service qui fonctionne avec tout objet qui fournit une instance de la type classe. L’instance de la type classe permet d’adapter les types à notre interface. Cela doit rappeler le design pattern adaptateur à certains. Notre implémentation marche même avec les types que nous ne pouvons pas modifier. Il subsiste toutefois un problème : la lourdeur de notre API. A chaque appel, l'utilisateur doit fournir à la fois une instance de la type classe et la fonction qui renvoie la collection MongoDB dans laquelle l'objet est stocké.

Dans la deuxième partie de ce post, nous allons résoudre ce problème grâce à la possibilité de passer des paramètres de manière implicite.

Plus de publications

4 comments for “Les type classes Scala : exemple sur une sérialisation MongoDB (1/2)

  1. Sebastien Lorber
    1 août 2012 at 0 h 10 min

    Sympa,

    Par contre j’ai pas compris la différence entre ça et ce qu’on peut faire en Java, ni en quoi il n’y a pas d’héritage (du moins dans cet exemple?)

    C’est un peu comme la construction d’un abstract DAO.

    Au final quand on y pense ça ressemble aussi beaucoup à ce que propose la Session Hibernate non?
    On passe en paramètre le type de la classe attendue en sortie:
    session.load(User.class, new Long(1))

  2. Nouhoum
    1 août 2012 at 15 h 46 min

    @Sebastien
    En fait il y a une différence. Je pense que c’est plus facile de la voir en prenant l’exemple des interfaces Comparable et Comparator de Java. La classe Collections définit deux méthodes de tri. L’une utilise Comparable et oblige les objets de la collection à implémenter cette interface (notion d’d’héritage) et l’autre, plus flexible, utilise Comparator. Dans le denier cas tout ce que tu as à faire c’est de définir un objet Comparator pour le type des objets de la collection. J’en parle plus dans la deuxième de l’article.

    Dans ton exemple :

    <

    blockquote cite=””>

    session.load(User.class, new Long(1))

    <

    blockquote cite=”Sebastien Lorber”>
    tu passes à la méthode load() la classe de ton objet persisté et non un objet intermédiaire. Ici la classe User devrait pas être une Entity avec des annotations JPA ou un fichier .hbm ?

    Les types classes offrent plus de flexibilité et sont aussi présentes par Hibernate avec l’interface UserType.

  3. Jean-Claude
    9 octobre 2012 at 12 h 22 min

    Définir type class comme un pattern ??????
    Il faut un peu se désintoxiquer de l’OO.

    Ceci dit l’article est très clair, même s’il est un peu élaboré pour expliquer ce qu’est un type class. Ca change de ce que propose LYAHAGG : http://learnyouahaskell.com/types-and-typeclasses
    Merci.