Les origines

L’entreprise pour laquelle je travaillais avait une API vieillissante en v1 qui était très mal conçue et lente dans son implémentation. Elle servait 4 plateformes frontend (un site web, deux applications mobiles et un outil d’administration). Étant dans un environnement de startup, notre équipe devait aller vite, mais c’était au détriment de la performance et de la maintenabilité. C’était la situation quand je suis arrivé.

Retourner des objets JSON était très fastidieux. On utilisait une méthode custom disponible dans chaque modèle qui construisait un Hash imbriqué, basé sur les attributs que vous lui donniez. C’est vite devenu un vrai bordel à gérer avec de mauvaises performances et des implications de sécurité préoccupantes. C’était difficile à maintenir, le nommage des variables et le comportement de la fameuse méthode étaient étranges, et donc nous avons entrepris de nous en débarrasser et sommes passés à une sorte de DSL de sérialiseur qui a été progressivement introduit pour remplacer l’usine à gaz.

Plus tard, nous avons décidé de refactorer notre API avec une nouvelle version pour nous débarrasser de cet encombrante dette technique. Quelques exigences ont rapidement émergé, parmi lesquelles :

  • L’API V2 devrait être facile à maintenir, avec des conventions de nommage claires et un namespacing
  • Puisqu’il s’agit d’une API interne, elle devrait être étroitement couplée à l’utilisation et à la plateforme prévues
  • L’API V2 devrait utiliser une sorte de DSL de vue pour générer les payloads JSON
  • L’API V2 devrait être aussi prévisible que possible en termes de typage et de formatage
  • L’API V2 devrait être super rapide par défaut. Ce devrait être la dernière chose que nous devons optimiser

J’ai donc examiné le DSL de sérialiseur que nous avions conçu pour l’API v1. La première chose que j’ai faite a été de le benchmarker contre d’autres solutions. J’ai donc retiré les fonctionnalités héritées / qui n’étaient plus nécessaires, l’ai déplacé vers une gem locale, et ai commencé à jouer avec quelques benchmarks.

Et c’était très rapide en effet ! Voici le résultat d’un benchmark que j’ai fait à l’époque :

Warming up --------------------------------------
# Omis pour la concision
Calculating -------------------------------------
 ams                    67.770  (± 1.9%) i/s -    657.000  in  10.042270s
jsonapi-rb            157.239  (± 2.3%) i/s -      1.521k in  10.010257s
barley                 96.909  (± 2.4%) i/s -    945.000  in  10.036316s
barley-cache            4.589k (± 2.8%) i/s -     44.620k in  10.010004s
ams          eager     68.237  (± 6.2%) i/s -    592.000  in  10.087602s
jsonapi-rb   eager    289.733  (± 3.7%) i/s -      2.805k in  10.033912s
barley       eager    406.732  (± 3.2%) i/s -      3.922k in  10.020076s
barley-cache eager    639.935  (± 2.4%) i/s -      6.300k in  10.053710s
with 95.0% confidence

Comparison:
barley-cache      :     4589.3 i/s
barley-cache eager:      639.9 i/s - 7.17x  (± 0.27) slower
barley       eager:      406.7 i/s - 11.29x  (± 0.49) slower
jsonapi-rb   eager:      289.7 i/s - 15.83x  (± 0.74) slower
jsonapi-rb        :      157.2 i/s - 29.17x  (± 1.08) slower
barley            :       96.9 i/s - 47.37x  (± 1.75) slower
ams          eager:       68.2 i/s - 67.25x  (± 4.69) slower
ams               :       67.8 i/s - 67.72x  (± 2.31) slower
with 95.0% confidence

Calculating -------------------------------------
# Omis pour la concision

Comparison:
barley-cache      :      46090 allocated
barley-cache eager:     216790 allocated - 4.70x more
barley       eager:     354326 allocated - 7.69x more
jsonapi-rb   eager:     694430 allocated - 15.07x more
jsonapi-rb        :     926082 allocated - 20.09x more
ams          eager:    1068038 allocated - 23.17x more
barley            :    1102354 allocated - 23.92x more
ams               :    1299674 allocated - 28.20x more

Ce qui est plutôt sympa 😎.

Je suis partageur

J’ai donc fait une gem de notre sérialiseur et l’ai publiée sur Github. Elle s’appelle Barley.

J’ai récemment rendu la gem encore plus rapide (à partir de la v0.9.0) et l’ai benchmarkée contre Alba, Blueprinter, Turbostreamer, Jserializer, FastSerializer, Representable, SimpleAMS, AMS, Rails JBuilder et Panko.

Voici les résultats de ces nouveaux benchmarks :

ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +YJIT +PRISM [x86_64-linux]
Warming up --------------------------------------
# Omis pour la concision

Calculating -------------------------------------
                alba    401.119 (± 1.7%) i/s    (2.49 ms/i) -      2.016k in   5.027350s
alba_with_transformation
                        256.123 (± 1.2%) i/s    (3.90 ms/i) -      1.300k in   5.076497s
         alba_inline     22.749 (± 8.8%) i/s   (43.96 ms/i) -    114.000 in   5.049973s
                 ams     16.923 (± 5.9%) i/s   (59.09 ms/i) -     85.000 in   5.029061s
              barley    427.045 (± 2.6%) i/s    (2.34 ms/i) -      2.162k in   5.066130s
        barley_cache    378.285 (± 2.4%) i/s    (2.64 ms/i) -      1.927k in   5.097462s
         blueprinter    120.704 (± 1.7%) i/s    (8.28 ms/i) -    612.000 in   5.071371s
     fast_serializer    142.347 (± 1.4%) i/s    (7.03 ms/i) -    720.000 in   5.058924s
         jserializer    252.455 (± 1.6%) i/s    (3.96 ms/i) -      1.274k in   5.047584s
               panko    510.262 (± 4.7%) i/s    (1.96 ms/i) -      2.585k in   5.077381s
               rails    107.589 (± 7.4%) i/s    (9.29 ms/i) -    546.000 in   5.104715s
       representable     50.264 (± 6.0%) i/s   (19.89 ms/i) -    252.000 in   5.027987s
          simple_ams     36.584 (± 5.5%) i/s   (27.33 ms/i) -    184.000 in   5.054347s
       turbostreamer    351.551 (± 1.7%) i/s    (2.84 ms/i) -      1.776k in   5.053401s

Comparison:
               panko:      510.3 i/s
              barley:      427.0 i/s - 1.19x  slower
                alba:      401.1 i/s - 1.27x  slower
        barley_cache:      378.3 i/s - 1.35x  slower
       turbostreamer:      351.6 i/s - 1.45x  slower
alba_with_transformation:      256.1 i/s - 1.99x  slower
         jserializer:      252.5 i/s - 2.02x  slower
     fast_serializer:      142.3 i/s - 3.58x  slower
         blueprinter:      120.7 i/s - 4.23x  slower
               rails:      107.6 i/s - 4.74x  slower
       representable:       50.3 i/s - 10.15x  slower
          simple_ams:       36.6 i/s - 13.95x  slower
         alba_inline:       22.7 i/s - 22.43x  slower
                 ams:       16.9 i/s - 30.15x  slower

Calculating -------------------------------------
# Omis pour la concision

Comparison:
               panko:     259178 allocated
              barley:     633521 allocated - 2.44x more
       turbostreamer:     641760 allocated - 2.48x more
alba_with_transformation:     818181 allocated - 3.16x more
                alba:     818241 allocated - 3.16x more
         jserializer:     822281 allocated - 3.17x more
        barley_cache:     849521 allocated - 3.28x more
     fast_serializer:    1470121 allocated - 5.67x more
         blueprinter:    2297921 allocated - 8.87x more
         alba_inline:    2736041 allocated - 10.56x more
               rails:    2757857 allocated - 10.64x more
                 ams:    4713401 allocated - 18.19x more
       representable:    5151321 allocated - 19.88x more
          simple_ams:    9017033 allocated - 34.79x more

Avec YJIT activé, Barley brille vraiment et est le premier sérialiseur Hash du groupe. Il y a plus de benchmarks à trouver dans le repository Barley.

Barley est très simple dans son implémentation et alloue très peu de mémoire. Je crois que c’est ce qui le rend si rapide. J’écrirai un autre article sur comment je l’ai optimisé dans la version 0.9.0.

Les fonctionnalités

Barley fournit :

  • un DSL clair pour définir votre sérialiseur
  • définition de sous-sérialiseur inline / block
  • russian-doll caching
  • type-check avec dry-types
  • gestion du contexte

Voici comment Barley fonctionne :

Dans le modèle que vous voulez sérialiser

D’abord, incluez le concern Barley::Serializable dans votre modèle.

# /app/models/user.rb
class User < ApplicationRecord
  include Barley::Serializable
end

Une fois que vous incluez ce module, Barley cherchera une classe UserSerializable dans votre codebase, vous devez donc en définir une, typiquement dans le répertoire app/serializable. Elle devrait hériter de Barley::Serializer

# /app/serializers/user_serializer.rb
class UserSerializer < Barley::Serializer
end

Une fois que cela est configuré, la méthode as_json sur votre modèle retournera ce que vous avez défini dans votre classe de sérialiseur.

Vous pouvez également choisir d’utiliser un sérialiseur différent pour rendre vos données. Vous pouvez utiliser la méthode serializer dans votre modèle. Cette méthode vous permet également de passer un argument cache.

# /app/models/user.rb
class User < ApplicationRecord
  include Barley::Serializable

  serializer MyCustomSerializer, cache: true
  # ou
  serializer MyCustomSerializer, cache: {expires_in: 1.hour}
end

Une autre façon d’y parvenir est via la méthode as_json :

User.first.as_json(serializer: MyCustomSerializer, cache: {expires_in: 1.hour})

Notez bien que Barley remplace la méthode as_json et ne lui fournit pas les arguments standards only, include ou except. C’est parce que le but de ce sérialiseur est précisément de faire ce travail. L’argument root fonctionne, cependant.

Définir le sérialiseur

Revenons à la classe de sérialiseur. Barley fournit un DSL simple qui vous permet de définir votre JSON de manière lisible et maintenable. Voici un exemple de définition qui couvre la plupart des cas d’utilisation :

# /app/serializers/user_serializer.rb
class UserSerializer < Barley::Serializer

  attributes id: Types::Strict::Integer, :name

  attribute :email
  attribute :value, type: Types::Coercible::Integer

  many :posts

  one :group, serializer: CustomGroupSerializer

  many :related_users, key: :friends, cache: true

  one :profile, cache: { expires_in: 1.day } do
    attributes :avatar, :social_url

    attribute :badges do
      object.badges.map(&:display_name)
    end
  end

end

Pour que la vérification de type fonctionne, vous devez définir un module Types comme ceci, quelque part dans votre codebase :

module Types
  include Dry.Types()
end

1. Attributs

Ils peuvent être définis comme un tableau avec la méthode plurielle attributes. Ce peut être un tableau de symboles, ou un tableau de hashes dans le pattern key: TypeDefinition, ou un mélange des deux.

Ils peuvent également être définis avec une méthode singulière attribute, qui accepte un argument de mot-clé type.

Si type est inclus dans la définition d’un attribut, il lèvera une erreur. Vous pouvez utiliser des types pour contraindre les types et définir des contraintes. Veuillez vous référer à la gem dry-types pour plus d’options.

Si vous donnez un bloc à la méthode attribute, vous pouvez également retourner tout ce que vous voulez. Vous avez l’objet object à portée de main qui fait référence au modèle sérialisé.

2. Associations

Elles sont définies avec les méthodes one ou many.

Cela utilisera le sérialiseur par défaut si aucun argument n’est donné. Vous pouvez lui fournir un serializer et une option cache.

Ces méthodes ne fournissent pas d’argument type, car cela n’a aucun sens de le faire.

Vous pouvez également décider de lui donner un bloc, qui vous permet de définir le sérialiseur de l’association inline.

Pour conclure

L’objectif de la gem Barley est de fournir un DSL simple pour définir vos payloads JSON avec une option de type-proof, et de les rendre de manière très rapide et efficace. Elle n’est probablement pas aussi riche en fonctionnalités que d’autres sérialiseurs disponibles pour la communauté Rails, mais c’est une option sans prise de tête, mais puissante que vous devriez considérer si vous recherchez la performance et la simplicité.

Je serais heureux de voir plus de projets commencer à l’adopter et j’invite la communauté à proposer des optimisations pour la rendre encore meilleure.