Form-Object pour les applications Web

Publié le 19 juin 2013 par Nicolas Zermati | méthodologie

Cet article est publié sous licence CC BY-NC-SA

Une application Web peut se voir comme une application classique, sur laquelle on greffe une couche de communication et de présentation que sont HTTP et HTML. Ne pas tenir compte de l’aspect Web a de nombreux avantages : tests rapides, modularité accrue, etc. Comme François l’explique dans un autre de nos articles : cela permet aussi de découpler la logique métier plus facilement.

Dans cet article, je présente un motif de conception qui s’avère très utile pour connecter la logique métier avec la couche de communication. Ce motif, que je désigne ici par Form-Object, permet de convertir des données envoyées via HTTP afin de construire nos objets du domaine métier.

Les briques logicielles telles que Rails tentent de faciliter la vie des développeurs. Cependant, elles introduisent des raccourcis qui ont pour effet d’inciter à fusionner une partie de la couche de communication dans les objets du domaine métier, violant ainsi le SRP (en). C’est ce qui justifie l’emploi d’un tel motif de conception, et nous allons voir en détails pourquoi dans le reste de l’article…

Mise en situation

Nous allons partir sur un exemple classique mais indémodable : le blog !

Ce dernier est très simple : chaque billet se compose d’un contenu, d’un nom d’auteur et d’un avatar. Une première page web permet à un visiteur d’ajouter un billet en saisissant son pseudo, le contenu du billet et en selectionnant un avatar. La selection de l’avatar se fait soit via une liste déroulante soit via l’envoi d’un fichier. Une autre page affiche la liste des billets disponibles. Chaque élément contient le nom de l’auteur ainsi que les 30 premiers caractères du billet. Enfin une dernière page permet d’afficher le billet dans son intégralité. Sur cette page figure également le nom de l’auteur ainsi que son avatar.

Modélisation du métier

Inutile de vous dire qu’on va avoir le modèle Post suivant :

Post = Struct.new :author, :avatar, :content

En effet, ce modèle représente très exactement notre spécification, il est donc inutile d’aller chercher plus loin…

Notre moteur de blog va avoir la responsabilité d’archiver les fichiers d’avatars qui lui sont envoyés. Pour isoler cette fonctionnalitée, propre aux avatars, on va créer un modèle dédié et indépendant de Post :

class Avatar
  STORE_DIR = './avatars'

  attr_reader :filename

  def initialize(file, filename)
    file_content   = file.read
    file_extension = File.extname filename
    content_hash   = Digest::SHA2.hexdigest file_content
    @filename = content_hash + file_extension
    write!(file_content)
  end

  private

    def write!(file_content)
      filepath = File.join(STORE_DIR, filename)
      File.open(filepath, 'w') do |file|
        file.write(file_content)
      end
    end
end

Pour archiver les avatars, cette classe va utiliser une somme de contrôle et écrire le fichier dans un répertoire de stockage : STORE_DIR. Il n’est pas prévu qu’un visiteur utilise un avatar déjà enregistré puisqu’il pourra le selectionner dans une liste.

Vous pouvez déjà interagir avec ces deux modèles avec le script suivant :

# Create a fake file on the filesystem named /tmp/avatar.gif
filepath = '/tmp/avatar.gif'
system "wget -q -O #{filepath} http://placehold.it/100x100"

file   = File.new(filepath)
avatar = Avatar.new(file, filepath)
post   = Post.new('Nicolas', avatar, 'This is the content')
puts post.inspect

Le code des modèles uniquement se trouve sur le dépôt associé à l’article

Connexion avec notre application Web

À présent nous allons créer nos pages Web. Pour cela nous allons utiliser Sinatra de la manière la plus simple possible…

require 'sinatra'
require_relative 'models'

# Use in memory arrays instead of a persistence layer
$avatars = []
$posts   = []

set :public_folder, Avatar::STORE_DIR

# List all posts
get '/' do
  erb :index
end

# Display the for to add a new post
get '/new' do
  erb :new
end

# Display the full post
get '/view/:id' do
  @post = $posts[params[:id].to_i]
  erb :view
end

Vous pouvez voir que les billets et les avatars sont conservés dans deux variables globales. Les billets ne sont donc pas conservés d’une exécution à une autre. Faire ce choix évite de surcharger l’exemple avec une couche de persistance.

Vous remarquerez également que ces 3 pages sont toutes simples. Vous pouvez accéder à l’intégralité du code, vues comprises, sur notre dépôt Github.

Voilà à quoi ressemble un code, naïf, de l’ajout d’un billet :

post '/' do
  new_avatar      = params[:new_avatar]
  existing_avatar = params[:existing_avatar]

  if existing_avatar != ""
    avatar_index = existing_avatar.to_i
    avatar = $avatars[avatar_index]
  elsif new_avatar.kind_of?(Hash) && new_avatar[:tempfile] && new_avatar[:filename]
    avatar = Avatar.new new_avatar[:tempfile], new_avatar[:filename]
  end

  if avatar
    $avatars << avatar
    $posts   << Post.new(params[:author], avatar, params[:content])
    redirect to("/view/#{$posts.size - 1}")
  else
    redirect to('/new') # TODO: Add an error message
  end
end

Il y a ici plusieurs cas de figure à gérer :

  • il s’agit d’un nouvel avatar,
  • il s’agit d’un avatar existant,
  • aucun avatar n’est trouvé (params[:existing_avatar] ne donne aucun index valide),
  • une valeur inattendue est envoyée,
  • etc.

Le code ci-dessus ne gère pas tous ces cas et pourtant il commence à être difficile à lire.

Bilan

Cette approche plutôt naïve montre que même pour un domaine métier très simple, il est difficile de se contenter des cases modèles et controlleurs. Ce qui s’applique ici avec Sinatra s’applique aussi avec Rails puisque ce dernier introduit beaucoup plus de conventions.

Une approche « classique »

Tout le monde s’accordera à dire que l’action post '/' est trop lourde, j’en suis sûr. Pour corriger le tir, c’est à dire obtenir du beau code, Ruby et sa communauté créent régulièrement des extensions de syntaxe. Cette technique permet d’envelopper dans un code concis et explicite de nouvelles fonctionnalités. Pour mes exemples, je n’utiliserai pas ce type de techniques.

Examinons une des solutions à mon problème qu’est l’utilisation de validateurs. L’idée sous jacente est que le modèle doit être capable de contrôler sa propre intégrité. Le contrôle de cette intégrité peut se faire au travers d’une méthode que nous appellerons valid?. Ces méthodes sont ajoutées sous forme de patchs :

Avatar.class_eval do
  def initialize(file, filename)
    if file.respond_to?(:read) && filename.kind_of?(String)
      file_content   = file.read
      file_extension = File.extname filename
      content_hash   = Digest::SHA2.hexdigest file_content
      @filename = content_hash + file_extension
      write!(file_content)
    end
  end

  def valid?
    !!filename
  end
end

Post.class_eval do
  def valid?
    avatar && avatar.valid?
  end
end

De cette manière, nos objets métiers peuvent maintenant être valides ou bien invalides. Un Avatar est invalide lorsqu’on utilise des arguments inattendus et un Post est invalide lorsqu’il ne contient pas d’Avatar ou que ce dernier est invalide.

On va maintenant modifier la première condition de notre code afin d’obtenir systematiquement un objet Avatar pouvant être valide ou non. Voici le code résultant :

def avatar_from_params(params)
  if (id = params[:existing_avatar]) != ""
    $avatars[id.to_i]
  else
    file     = params[:new_avatar][:tempfile]
    filename = params[:new_avatar][:filename]
    Avatar.new(file, filename)
  end
rescue
  nil
end

# Submit a new post
post '/' do
  avatar = avatar_from_params(params)
  post   = Post.new(params[:author], avatar, params[:content])
  if post.valid?
    $avatars << avatar
    $posts   << post
    redirect to("/view/#{$posts.size - 1}")
  else
    redirect to('/new') # TODO: Add an error message
  end
end

Pour les habitués de Rails, vous remarquerez que l’action post '/' ressemble fortement à ce que Rails peut générer lors d’un scaffold. Toujours pour faire le parallèle avec Rails, la méthode avatar_from_params est souvent appellée grâce à un appel du type :

before_filter :avatar_from_params, only: [:create]

Concernant la validation, ce sont les ORMs qui se chargent la plupart du temps d’ajouter des fonctions de déclaration de validateurs. Ces fonctions sont souvent notées comme ceci :

# Classe Avatar
validate_presence_of :filename

# Class Post
validate_presence_of :avatar

Les ORM iront plus loins puisqu’il permettront de décrire des relations et ainsi ajouter une forme de validation implicite :

# Classe Avatar
has_many :posts

# Class Post
belongs_to :avatar

Cet exemple montre qu’on a plusieurs mécanismes à notre disposition permettant de déplacer de la complexité depuis le controlleur (post '/') vers les modèles. Ces mécanismes semblent élégants de par leur syntaxe mais favorisent la centralisation des responsabilités. Ici mes modèles purement métiers s’occupent à présent de la validation des informations saisies par l’utilisateur.

La validation a un autre inconvénient majeur : elle introduit la possibilité pour un objet d’être dans un état incohérent. Au delà d’une simple possibilité, on est incité à créer de tels objets. Avant l’introduction de la validation, je ne pouvais pas avoir un Avatar dépourvu de son attribut filename.

Le code résultant de l’utilisation de la validation est disponible sur le dépôt Github.

Introduction d’un Form-Object

Ce que j’appelle un Form-Object, c’est un objet qui va tenir le role d’interface entre les entrées utilisateur et nos modèles. C’est cet objet qui va avoir la charge de construire les objets de notre domaine métier et ce de manière à garder un système cohérent. Il est également en charge de valider les entrées utilisateur.

Voici le controlleur modifié :

post '/' do
  form_object = CreatePostForm.new(params)
  if form_object.success?
    $avatars << form_object.avatar
    $posts   << form_object.post
    redirect to("/view/#{$posts.size - 1}")
  else
    redirect to('/new') # TODO: Add an error message
  end
end

C’est tout ce qui sera dans le controlleur. On pourrait déporter d’avantages de choses dans le Form-Object comme par exemple l’ajout des nouveaux objets dans les listes $avatars et $posts.

Voici le code de notre Form-Object :

class CreatePostForm
  attr_reader :post, :avatar

  def initialize(form_params)
    @post    = @avatar = nil
    @params  = form_params
    @success = build_avatar! && build_post!
  end

  def success?
    !!@success
  end

private

  def build_avatar!
    @avatar = @params[:existing_avatar] != "" ?
      find_avatar(@params[:existing_avatar]) :
      create_new_avatar(@params[:new_avatar])
  rescue
    false
  end

  def build_post!
    author  = @params[:author]
    content = @params[:content]
    @post   = Post.new @params[:author], @avatar, @params[:content]
  end

  def find_avatar(id)
    $avatar[id.to_i]
  end

  def create_new_avatar(rack_file_upload)
    tempfile = rack_file_upload[:tempfile]
    filename = rack_file_upload[:filename]
    Avatar.new(tempfile, filename)
  end
end

L’objet est relativement gros par rapport aux solutions précédentes et il possède également plus de méthodes. Mais chacune d’entre elle est courte et explicite.

Ce code qui fait le pont entre le coeur de l’application et son interface n’a ni sa place dans les modèles ni dans les controlleurs. Grace à ce Form-Object mes modèles sont restés intacts et mon controlleur n’en a pas payé le prix. Il peut y avoir beaucoup de ces objets dans une application. Entre des dizaines de Form-Object, des controlleurs obèses ou des modèles aux multiples responsabilités, le choix est vite fait.

Le code ci dessus est disponible sur le dépôt Github.

Conclusion

Voilà, j’espère que cet exemple, bien que simpliste vous fera réfléchir sur comment séparer les responsabilités dans vos applications. Le champ d’application des Form-Object empiète sur beaucoup d’autres fonctionnalitées de Rails comme par exemple accept_nested_attributes. Malgré ça, le gain est important car le contrôle est total. On évite de se prendre les pieds dans le tapis que constitue l’amoncellement de fonctionnalitées aussi magiques que dangeureuses que proposes les frameworks. Essayez de mettre en pratique ces Form-Objects et vous serez séduit !

Attention, je ne dis pas que les facilitées offertes par les frameworks doivent toutes être boudées. Je vous incite par contre à la plus grande prudence lors de leur utilisation.

Liens

L’équipe Synbioz.

Libres d’être ensemble.