Hello les petits Biscuits !
Bienvenue sur la 34ème édition de Ruby Biscuit.
Vous êtes maintenant 585 abonnés 🥳
Maintenant Ruby biscuit, c’est aussi votre meilleur allié pour recruter des devs Ruby !
Si vous n’avez pas encore rejoint le club, RDV sur https://recrutement.rubybiscuit.fr
Bonne lecture
Active Storage Versus Shrine: le match
Ce mois-ci, nous abordons la gestion des fichiers dans Ruby on Rails, une fonctionnalité indispensable pour que vos utilisateurs puissent téléverser (uploader) des photos, des vidéos ou des documents PDF. Découvrez deux gems populaires utilisées dans la communauté: ActiveStorage et Shrine.
Partie Théorique
Avant de nous lancer, faisons un bref rappel sur ce que font ces gems :
Télécharger des fichiers depuis un navigateur vers un service de stockage de fichiers
Faire le lien entre ces fichiers et vos instances Active Record
Que ce soit pour Shrine ou Active Storage, l'installation se fait très facilement. Pour les 2 gems, vous trouverez la plupart des réponses à vos questions dans la documentation et en particulier comment les installer.
TL;DR
Les différences entre Shrine et Active Storage sont généralement mineures, mais votre choix dépendra surtout de votre usage :
Si vous gérez principalement un seul fichier par modèle, Shrine offre une approche plus flexible et explicite
Si vous devez gérer plusieurs fichiers par modèle ou que vous souhaitez une solution prête à l’emploi et intégrée à Rails, Active Storage sera souvent plus pratique.
Base de données
Pour commencer, je voudrais attaquer la comparaison par le prisme de la base de données. Quelles sont les différences d'architecture et qu'est-ce que ça implique sur les migrations par exemple ?
Avec Shrine, c'est simple, chaque nouveau fichier nécessite une migration sur le modèle.
class AddCoverPictureToProjects < ActiveRecord::Migration
def change
add_column :projects, :cover_picture_data, :text
end
end
Ensuite on va mettre à jour notre modèle
class Project < ApplicationRecord
include ImageUploader::Attachment(:cover_picture)
end
Pour Active Storage, c'est encore plus simple, on va créer 2 tables au début et puis c'est tout:
active_storage_blobs
active_storage_attachments
Ensuite, il suffira de mettre à jour son modèle à chaque fois qu'on veut lier un nouveau fichier à un modèle.
class Project < ApplicationRecord
has_one_attached :cover_picture
end
Sous cette simplicité apparente se cache une réalité bien plus complexe. Par exemple, quand on appelle la photo dans la vue, voici ce que fait AST sous le capot :
Il cherche dans la table
active_storage_attachments
record_type: "Project"
record_id: 1
name : "cover_picture"
Ensuite il va chercher dans la table
active_storage_blobs
Enfin il génère l'URL
Vous voyez venir le problème. En appelant une image (ou un document) dans un index, on obtient non pas une N+1 mais bel et bien une 2N + 1 ! Alors que sur Shrine, il n'y a aucune requête SQL supplémentaire.
Pour la petite histoire, j'ai déjà travaillé sur une page d'accueil avec 160 requêtes SQL pour aller récupérer et afficher des images ! Un simple includes a résolu le problème
Avec notre exemple d'aujourd'hui, et une syntaxe plus moderne, ça donnerait ça dans le controller.
@projects = Project.with_attached_cover_picture
Un des avantages avec l'architecture d'AST, c'est qu'on peut facilement ajouter plusieurs fichiers dans notre modèle. Par exemple pour ajouter des photos à un projet :
class Project < ApplicationRecord
has_many_attached :photos
end
Alors que dans Shrine, ça passe forcément par créer un nouveau modèle.
class Photo < ApplicationRecord
belongs_to :project
include ImageUploader::Attachment(:file)
end
class Project < ApplicationRecord
has_many :photos
end
Les formulaires
Quand un utilisateur a plusieurs photos à ajouter sur un projet dans un formulaire, ce qui est pratique de pouvoir les sélectionner toutes ensemble.
Pour AST, il suffit simplement d'ajouter ça à votre formulaire dans la vue
<%= form.file_field :photos, multiple: true %>
Ensuite dans le controller
class ProjectsController < ApplicationController
def create
@project = Project.new(project_params)
if @project.save
redirect_to project_path(@project)
else
render :new
end
end
private
def project_params
params.require(:project).permit(photos: [])
end
end
Ce fut ma plus grosse frustration en passant de AST à Shrine que de ne pas pouvoir faire ça aussi simplement .
Pour avoir l'équivalent, j'ai dû rajouter quelques lignes de code dans le modèle
class Photo < ApplicationRecord
belongs_to :project
include ImageUploader::Attachment(:file)
end
class Project < ApplicationRecord
has_many :photos
accepts_nested_attributes_for :photos
attr_accessor :new_photos
def new_photos=(files)
files.each do |file|
photos.build(file: file)
end
end
end
Et maintenant dans ma vue je peux utiliser ça
<%= f.file_field :new_photos, multiple: true %>
Dans le controller il faut juste mettre à jour cette ligne
params.require(:project).permit(new_photos: [])
Mon plus gros bug (à cause du polymorphisme)
La flexibilité qu'amène le polymorphisme sur AST vient avec son lot de piège. Ça a causé une des plus grosses frayeurs de code.
Après avoir changé le nom d'un modèle pour une refacto, je pousse mon code en production, et là surprise, tous les fichiers avaient disparu !
L'explication peut paraitre simple à posteriori mais sur le moment je n'y avais pas pensé. Je vous explique : dans la table active_storage_attachments
la colonne record_type
qui stocke le nom du modèle doit être mise à jour en même temps que le changement de nom du modèle.
Il n y'a aucune vérification, ni de la base de données, ni de l'application, que le record (combinaison record_id et record_type) existe bel et bien. C'est uniquement sur vos petites épaules que reposent la concordance des noms. Alors que sur Shrine, il n'y a besoin de rien faire.
Tests
Une autre erreur qui m'est arrivé sur AST, c'est d'oublier de supprimer les fichiers après les tests. On se retrouve en local avec une taille projet qui augmente de manière exponentielle et fait saturer le disque dur. En faisant une analyse rapide (avec la commande $ du
), je me suis vite rendu compte que mon dossier tmp/storage faisait plusieurs giga octet.
Pourtant, c'est bien marqué dans la documentation !!! (RTFM)
Voici la solution pour Rspec
config.after(:suite) do
path = "#{Rails.root}/tmp/storage"
if Dir.exist?(path)
FileUtils.remove_dir(path)
end
end
J'ai une petite préférence pour ce que fait Shrine qui permet de mettre les fichiers dans la RAM. C'est plus rapide et plus simple à gérer 😃
Shrine.storages[:store] = Shrine::Storage::Memory.new
Les variantes (AST) ou dérivatives (Shrine)
Ce sont des images modifiées à une taille spécifique pour avoir des images plus légères ou qui s'insèrent facilement dans du code html. Les 2 utilisent image_magick ou vips. Attention pour les 2 gems aussi, la génération des images se fait côté back-end ce qui peut considérablement ralentir votre serveur. L'avantage de AST c'est que si vous définissez vos variantes dans le modèle, elles sont créées dans un background Job.
Conclusion : Comparaison n'est pas raison
Comme souvent en informatique, quand on veut comparer, la réponse va être : "ça dépend du contexte". Pour une application de logistique, qui doit récupérer beaucoup de photos par commande pour vérifier le bon déroulement d'une livraison (mon précédent job), AST sera plus adapté. En revanche, pour une application qui a une image par projet et quelques PDF à sauvegarder, Shrine convient très bien.
Il faut juste avoir conscience qu'avec AST, il faudra faire attention aux N+1 au risque de ralentir votre application. Sur Shrine, il est plus rare de voir des requêtes SQL incontrollées.
Enfin AST étant nativement dans Rails, il est plus rassurant sur le long-terme mais pas forcément plus simple à maintenir.
— Alexandre
Une de mes frustrations perso c'est qu'il n'y aie pas de comportement natif pour prévisualiser et supprimer facilement les fichiers uploadés via un champ file_field. Et aucune gem toute prête pour ça non plus. C’est pourtant un besoin hyper courant 🤷♂️