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…
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.
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
À 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 :
params[:existing_avatar]
ne donne aucun index valide),Le code ci-dessus ne gère pas tous ces cas et pourtant il commence à être difficile à lire.
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.
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.
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.
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.
L’équipe Synbioz.
Libres d’être ensemble.