Leonardo Brito

Supposons que vous ayez une application Rails (juste à titre d'exemple – cet article s'applique de la même manière aux applications Ruby pures) et que vous souhaitez créer une API JSON. Assez simple, il suffit de mettre en place un api/v1/my_resourcespoint de terminaison et rédiger un sérialiseur pour MyResource, puis servez-le simplement via un contrôleur.

Vous faites peut-être partie de ces fêtards. Construisons une vraie application simple avec un Party modèle comme exemple:

$ rails new faster-json-api --database=postgresql 
$ cd faster-json-api
$ rails g migration CreateParties name:string description:string starts_at:datetime ends_at:datetime

Assez simple: un nom, une description, les heures de début et de fin.

Maintenant sur l'API. Gardons les choses super simples: il n'y aura qu'un seul point de terminaison, api/v1/parties, qui répond par un JSON de toutes les parties de la base de données.

Un sérialiseur basé sur Ruby typique pourrait ressembler à ceci:

module PartySerializer
def ruby_to_json
all.to_json(only: %i(name description starts_at ends_at))
end
end

Quelques notes: on pourrait (devrait, en fait) utiliser pluck dans l'exemple ci-dessus, mais nous allons aller avec le :only option dans to_json juste pour mettre en évidence les différences entre faire ce genre de traitement côté Ruby et le faire côté base de données. Notez également qu'ActiveRecord possède son propre assistant de sérialisation qui peut être utilisé pour obtenir les mêmes résultats que ci-dessus.

D'accord, alors nous pouvons utiliser PartySerializer ainsi:

class Api::PartiesController < ApplicationController
def index
render json: Party.extend(PartySerializer).ruby_to_json
end
end

C'est suffisant. Faisons un test rapide avec curl pour voir ce que nous obtenons (une fois que nous avons rempli la base de données avec les données de départ et configuré le point de terminaison dans config / routes.rb):

$ curl localhost:3000/api/parties | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 144 0 144 0 0 662 0 --:--:-- --:--:-- --:--:-- 663
(
{
"name": "My awesome party",
"description": "Incredible awesomeness",
"starts_at": "2022-01-01T23:00:00.000Z",
"ends_at": "2022-01-02T02:00:00.000Z"
}
)

(jq est une jolie imprimante JSON pour bash, soit dit en passant)

Semble fonctionner très bien. Vous pouvez même choisir d'abandonner le formatage JSON basé sur le noyau Ruby et d'utiliser un bijou ultra-rapide pour accélérer les choses.

Que vous utilisiez une gemme pour sérialiser le modèle en JSON ou non, voici une ventilation approximative du fonctionnement de cette approche de sérialisation basée sur Ruby. Encore une fois, nous utilisons Rails comme exemple, mais quelque chose de très similaire se produira quel que soit le cadre (ou son absence) que vous utilisez:

  1. ActiveRecord traduit votre requête en SQL. Dans notre exemple, c'était une requête assez simple: Party.all, ce qui se traduit par SELECT * FROM parties;
  2. ActiveRecord interroge la base de données avec cette instruction SQL et attend la réponse;
  3. La base de données exécute la requête et répond avec quelque chose;
  4. ActiveRecord reçoit ces données et crée ActiveRecord::Base modèles (dans notre exemple, il construit Party des modèles);
  5. PartySerializer#ruby_to_json appels #to_json pour chacun des Party les modèles sont revenus à l'étape précédente.
  6. Enfin, le contrôleur renvoie cette chaîne JSON à celui qui l'a demandée.

Ouf! On dirait beaucoup de travail. Et si nous pouvions ignorer complètement ces étapes intermédiaires et laisser la base de données faire tout le travail pour nous? Voici à quoi cela ressemblerait:

  1. Nous fournissons une instruction SQL à ActiveRecord::connection.exec_query;
  2. La base de données exécute la requête et répond avec JSON;
  3. Le contrôleur renvoie cette chaîne JSON au client.

Semble beaucoup plus simple, non?

Analysons les différences entre ces listes. Dans la deuxième liste, nous exécutons une instruction SQL dès le début, en ignorant essentiellement l'étape 1 de la première liste. Il y a donc un peu moins de temps pour traduire la syntaxe ActiveRecord en SQL.

Mais la plus grande différence ici est que nous avons ignoré les étapes 4 et 5 de la liste précédente, qui chargent les résultats de la requête dans la mémoire et construisent des modèles ActiveRecord. En fin de compte, nous livrons JSON, donc il n'y a vraiment pas besoin de le faire (sauf si nous vraiment besoin quelque chose du monde Ruby: par exemple si nous faisions une sorte d'ETL où le Transformer étape a été effectuée dans le processus Ruby).

Comme vous pouvez le deviner, la magie opère aux étapes 1 et 2: nous devons créer une requête SQL que le SGBDR exécutera et répondre avec un JSON. Postgres a des fonctions JSON intégrées assez impressionnantes et elles sont tout simplement parfaites pour ce cas d'utilisation.

json_build_object à la rescousse

json_build_object prend une liste de clés et de valeurs et renvoie un objet JSON. Dans notre cas, les clés sont les clés JSON dans la réponse API et les valeurs sont leurs noms de colonnes respectifs dans la base de données. Nous pouvons donc nous attendre à ceci:

json_build_object(
‘id’, id,
‘name’, name,
‘description’, description,
‘starts_at’, starts_at,
‘ends_at’, ends_at
)

Pour renvoyer des objets comme ceux-ci:

{
"name": "My awesome party",
"description": "Incredible awesomeness",
"starts_at": "2022-01-01T23:00:00.000Z",
"ends_at": "2022-01-02T02:00:00.000Z"
}

Mais nous voulons retourner un liste d'objets, droite? C'est le signal pourjson_agg.

json_agg agrège les valeurs sous forme de tableau JSON. Ces valeurs sont, dans notre cas, les objets JSON que nous venons de construire. Alors collons les deux ensemble.

json_agg(
json_build_object(
'id', id,
'name', name,
'description', description,
'starts_at', starts_at,
'ends_at', ends_at
)
)

Ces expressions doivent bien sûr être sélectionnées. Voici la dernière requête:

SELECT
json_agg(
json_build_object(
'id', id,
'name', name,
'description', description,
'starts_at', starts_at,
'ends_at', ends_at
)
)
FROM parties

Cela produit exactement la même sortie que notre JSONification basée sur Ruby.

Nous devons UNIR nos forces

Les exemples que nous avons utilisés sont assez agréables, mais ils semblent terriblement simples: ils sont entièrement plats, c'est-à-dire qu'il n'y a pas du tout d'imbrication.

Si vous voulez imbriquer des valeurs qui font déjà partie du modèle, c'est assez simple: utilisez simplement json_build_objectencore. Donc quelque chose comme:

SELECT
json_agg(
json_build_object(
'id', id,
'name', name,
'description', description,
'dates', json_build_object(
'starts_at', starts_at,
'ends_at', ends_at
)

)
)
FROM parties

Produirait ce que vous attendez:

(
{
"id": 1,
"name": "My awesome party",
"description": "Incredible awesomeness",
"dates": {
"starts_at": "2022-01-01T23:00:00",
"ends_at": "2022-01-02T02:00:00"
}

)

Mais qu'en est-il si vous souhaitez imbriquer une autre table dans ce JSON? Supposons que nos Parties ont également de nombreux concours, chacun avec un nom et une description (les deux sont des chaînes), et nous voulons que l'API les renvoie sous forme de tableau JSON dans le Party JSON. Nous voulons donc que le résultat final soit le suivant:

(
{
"id": 1,
"name": "My awesome party",
"description": "Incredible awesomeness",
"dates": {
"starts_at": "2022-01-01T23:00:00",
"ends_at": "2022-01-02T02:00:00"
},
"sweepstakes": (
{
"name": "My awesome Sweepstake",
"description": "Pure incredibleness"
},
{
"name": "My second awesome Sweepstake",
"description": "Purer incredibleness"
}
)

}
)

Le fait est que vous ne pouvez pas imbriquer des opérateurs d'agrégation tels que json_agg dans Postgres, vous ne pouvez donc pas simplement faire un JOIN sur le concours, puis les agréger dans l'agrégation de parties en cours.

Ce que nous devons faire, c'est utiliser json_agg à l'intérieur de JOIN, puis placez ces valeurs dans l'agrégation Party:

SELECT
json_agg(
json_build_object(
'id', parties.id,
'name', parties.name,
'description', parties.description,
'dates', json_build_object(
'starts_at', starts_at,
'ends_at', ends_at
),
'sweepstakes', s.json_agg
)
)
FROM parties
LEFT JOIN (
SELECT
party_id,
json_agg(
json_build_object(
'name', name,
'description', description
)
)
FROM sweepstakes
GROUP BY party_id
) s ON s.party_id = parties.id

Je sais, je sais: cela semble terriblement compliqué par rapport à un simple ActiveRecord joins. Mais cela en vaudra la peine, je le promets.

Donnez-moi des chiffres!

Vous m'avez promis plus de performances, donnez-moi quelques chiffres! Nous allons faire un benchmarking maintenant en utilisant cet exemple d'application.

Nous devons d'abord comprendre quelques éléments sur le module de référence de Ruby. En ce qui nous concerne, deux chiffres importants sont en action ici: total et réel. Le premier est le temps CPU total que votre code a pris, et le second est le temps «d'horloge murale», c'est-à-dire le temps réellement passé pendant l'exécution.

Étant donné que dans notre optimisation, nous déchargeons essentiellement le travail du processus de Ruby vers le processus de Postgres, alors nous devrions nous attendre à ce que l'approche Ruby ait un temps CPU plus grand par rapport au temps réel, tandis que l'approche Postgres devrait avoir moins de temps CPU par rapport au temps réel (Postgres utilise bien sûr des cycles CPU, mais ils ne sont pas pris en compte dans le benchmark de Ruby car ils font partie d'un autre processus). En d'autres termes, l'approche Ruby devrait consacrer la majeure partie du temps réel aux opérations du processeur, tandis que l'approche Postgres devrait consacrer la majeure partie du temps réel aux opérations d'E / S.

Cela dit, voici un exemple de sortie de référence mesurant les deux approches (Ruby pur x Postgres) avec 100 Parties dans notre base de données:

            user     system      total        real
ruby 0.128114 0.000183 0.128297 ( 0.129308)
postgres 0.000642 0.000046 0.000688 ( 0.015426)

Comme nous nous y attendions, l'approche Postgres consomme considérablement (~ 200x) moins de temps CPU par rapport à l'approche Ruby. Comme prévu, le temps IO (real — total) est beaucoup plus pertinent dans l'approche Postgres que dans celle Ruby: ~ 95% du temps réel a été passé avec IO, contre ~ 0,7% avec l'approche Ruby.

L'optimisation en valait-elle la peine? Cela semble définitivement le cas: l'échantillon indique quelque chose de l'ordre d'une amélioration de 10 fois le temps réel passé.

Bien, mais une référence avec seulement 100 éléments dans la base de données semble trop petite pour avoir de l'importance. Essayons de mesurer le même code de 1k à 10k éléments: