Article écrit par François Vantomme
Les entrailles d’un framework cachent parfois des bouts de code fort intéressants ! C’est le cas de la méthode composed_of
du module ActiveRecord::Aggregations
qui par plusieurs aspects va nous intéresser aujourd’hui : elle nous permet d’introduire une notion importante d’architecture logiciel, les values objects ; et de revenir sur 10 ans de rebondissements autour de cette méthode ! Sortez les popcorns
Une vie mouvementée
Nous sommes en juin 2012, Rails arbore fièrement sa version 3.2 ! Et dans un post, faisant suite à une PR de Steve Klabnik, Rafael França nous explique pourquoi composed_of
sera prochainement déprécié, puis retiré à compter de la version 4.0 du framework.
Les raisons sont une complexité superflue pour une méthode rarement utilisée qu’on pourrait qualifier de cosmétique (nous y reviendrons), et de multiples bugs relatifs à cette méthode dans le framework à l’époque.
Seulement, tout ne se passa pas comme prévu, et deux mois plus tard, en août 2012…
We have decided to stop introducing API deprecations in all point releases going forward. From now on, it’ll only happen in majors/minors.
— @Rails, Twitter, 1er août 2012
La décision fut alors prise de réintroduire cette méthode, toujours présente à ce jour dans la version 7.0 de Rails ! Cette méthode et la documentation qui lui est associée reçoivent d’ailleurs toujours des améliorations, comme le montre cette PR de Neil Carvalho datant de septembre 2022.
Mais alors, à quoi peut bien servir cette méthode méconnue qui a bien failli disparaitre ?
Déclarez vos objets de valeur
La méthode composed_of
du module ActiveRecord::Aggregations
permet de manipuler des values objects, c’est-à-dire des objets ayant pour seule vocation que de véhiculer une valeur. Un value object a la particularité d’être identifiable par la valeur qu’il véhicule et non pas par un identifiant. En d’autres termes, deux values objects sont égaux s’ils représentent la même valeur. Autre condition nécessaire, un value object se doit d’être immuable. La notion de values objects est très présente dans la littérature portant sur le Domain Driven Design. Un excellent article de Victor Savkin fait d’ailleurs le lien entre Rails et DDD.
Notez également que Ruby 3.1 introduit une nouvelle classe de base appelée Data
destinée à représenter des values objects immuables. Ces objets peuvent être étendus avec des méthodes personnalisées lors de leur définition. Pour les plus curieuses et curieux d’entre vous, un article de Swaathi Kakarla en fait la présentation.
Un cas d’usage
Prenons un exemple qui parlera à tout le monde : la manipulation de valeurs monétaires. Il arrive assez fréquemment que l’on ait à manipuler des montants et des devises, que ce soit dans le cadre d’une application e-commerce, ou tout simplement l’établissement d’une facture. Dans ce cas, nous avons pris l’habitude de stocker en base, dans deux champs distincts mais étroitement liés, ce montant (appelons-le amount
) et la devise associée (nommons-la currency
).
Un value object nous permettra ici de manipuler ces deux informations au sein d’une même représentation. Nous pourrions imaginer la chose comme ceci, par exemple :
class Money attr_reader :amount, :currency def initialize(amount, currency = "EUR") @amount = amount @currency = currency end end
Nous avons là un objet Money
qui nous permet de manipuler des valeurs monétaires, et nous assure de toujours conserver ce lien entre montant et devise, l’un n’allant pas sans l’autre d’un point de vue fonctionnel. Seulement, il nous manque un petit quelque chose pour en faire un value object : nous avons besoin de définir l’égalité entre deux objets de cette classe !
class Money include Comparable # … def ==(other_money) amount == other_money.amount && currency == other_money.currency end end
Grace au module Comparable
que l’on vient d’inclure, et à la méthode ==
, nous voici en mesure de comparer deux objets de la classe Money
:
irb(main)> Money.new(5, "EUR") == Money.new(5, "EUR") => true irb(main)> Money.new(5, "EUR") != Money.new(5, "USD") => true
Mais un value object ne se limite pas forcément à l’encapsulation d’une ou plusieurs valeurs, il peut aussi présenter un ensemble de méthodes qui lui sont propres ! Ici, nous pourrions par exemple souhaiter convertir un montant dans une autre devise, ou encore comparer deux montants déclarés dans des devises différentes.
class Money EXCHANGE_RATES = { "EUR_TO_JPY" => 146 } # … def exchange_to(other_currency) exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor Money.new(exchanged_amount, other_currency) end def <=>(other_money) if currency == other_money.currency amount <=> other_money.amount else amount <=> other_money.exchange_to(currency).amount end end end
Notons que notre objet est immuable, la méthode exchange_to
retourne donc une nouvelle instance de notre classe Money
.
irb(main)> Money.new(5, "EUR") == Money.new(5, "EUR") => true irb(main)> Money.new(5, "EUR").exchange_to("JPY") => #<Money:0x00007faee7162f68 @amount=730, @currency="JPY"> irb(main)> Money.new(5, "EUR") == Money.new(730, "JPY") => true irb(main)> Money.new(5, "EUR") > Money.new(500, "JPY") => true
Et composed_of dans tout ça ?
La méthode de classe composed_of
appliquée sur un modèle ActiveRecord
nous permet de lier les attributs de celui-ci pour les manipuler sous la forme d’un value object. Voici un exemple d’utilisation de notre classe Money
:
# == Schema Information # # Table name: invoices # # id :integer not null, primary key # total_amount :decimal(, ) # total_currency :string class Invoice < ActiveRecord::Base composed_of :total, class_name: "Money", mapping: { total_amount: :amount, total_currency: :currency } end
Ainsi, nous pouvons directement utiliser une instance de la classe Money
à travers l’attribut total
, et ce en lecture comme en écriture !
irb(main)> invoice = Invoice.new(total: Money.new(5, "EUR")) => #<Invoice id: nil, total_amount: 0.5e1, total_currency: "EUR"> irb(main)> invoice.total => #<Money:0x00007f1d1006b038 @amount=5, @currency="EUR"> irb(main)> invoice.total = Money.new(500, "JPY") => #<Money:0x000055eca216b658 @amount=500, @currency="JPY"> irb(main)> invoice.total_amounnt => 0.5e3 irb(main)> invoice.total_currency => "JPY"
Très utile cette méthode, et cela clarifie par la même occasion notre intention ! Notre code s’en trouve plus explicite, et plus facile à comprendre et à maintenir. De plus, nous limitons les responsabilités de notre modèle en cloisonnant dans des values objects les méthodes qui leur sont propres.
Mais alors, pourquoi vouloir la supprimer de Rails ?
Valeur ajoutée & maintenabilité
Tout est dans la mesure. Cette méthode n’est au final qu’un sucre syntaxique, une fonctionnalité cosmétique, et celle-ci a un coût, notamment en termes de maintenabilité pour l’équipe de développement du framework. Ce coût est loin d’être négligeable, à en croire les multiples remontées de bugs qui lui sont imputées, et il convient dans ce cas de peser le pour et le contre afin de choisir entre conserver cette fonctionnalité ou la supprimer.
L’un des arguments de poids à l’encontre de cette méthode, est le fait de devoir lui passer des procs et des hashes pour obtenir magiquement un comportement qui pourrait être décrit de manière bien plus explicite avec un simple objet Ruby. Arrêtons-nous un moment pour prendre deux exemples.
Dans le cas le plus simple, celui d’un attribut unique, nous pourrions nous contenter d’un serializer. Admettons que dans notre exemple précédent, nous ayons choisi de faire fi de la devise. Nous pourrions ainsi écrire ceci :
class MoneySerializer def dump(money) money.amount end def load(amount) Money.new(amount) end end class Invoice < ActiveRecord::Base serialize :total_amount, MoneySerializer.new end
Autre approche, nous pourrions aussi faire appel à de simples accesseurs, comme ceci par exemple :
class Invoice < ActiveRecord::Base def total @total ||= Money.new(total_amount, total_currency) end def total=(money) self[:total_amount] = money.amount self[:total_currency] = money.currency @total = money end end
Ces deux exemples nous montrent à quel point il est facile d’obtenir le même résultat, sans la magie de composed_of
, mais surtout avec beaucoup plus de clarté, j’en veux pour preuve cet exemple tiré de la documentation d’ActiveRecord :
class NetworkResource < ActiveRecord::Base composed_of :cidr, class_name: 'NetAddr::CIDR', mapping: [ %w(network_address network), %w(cidr_range bits) ], allow_nil: true, constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") }, converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) } end
On comprend rapidement ici que maintenir ce code et le tester sera des plus pénibles !
Ceci étant, dans sa configuration la plus simple, ce petit sucre syntaxique reste attirant à l’œil et, sans convaincre celles et ceux fortement attachés aux principes du Domain Driven Design, devrait séduire les plus Rails-istes d’entre nous — Il suffit de ne pas être trop regardant de ce qu’il y a sous le capot 😉
Petit bonus
Puisque nous parlons d’ActiveRecord, qu’en est-il du requêtage de ces attributs ? Eh bien tout semble se passer le plus intuitivement du monde :
Invoice.where(total: Money.new(42, "EUR"))
Si vous avez choisi de vous passer de composed_of
, il s’agira simplement d’être explicite là aussi, à l’aide d’une méthode de classe par exemple :
def self.costing(money) where(total_amount: money.amount, total_currency: money.currency) end
Le coût d’un code explicite ne semble pas excessif. Surtout au regard des 768 lignes de code nécessaires à cette fonctionnalité cosmétique.
Du discernement
Cet exemple nous montre une nouvelle fois à quel point Rails n’est pas simple ! Il nous faut donc rester sur nos gardes, et prendre la mesure des choix techniques que nous faisons. Aussi insignifiants qu’ils puissent nous paraitre à première vue, leurs répercussions peuvent être considérables avec le temps, en particulier sur la maintenabilité, la pérennité et la testabilité de nos applications.