API in Rails:<br/>render :json => the simple way
Una soluzione minimale per generare JSON in un'API Rails, senza Jbuilder né ActiveModel::Serializers: solo moduli Ruby con metodi che restituiscono Hash. Massima flessibilità, performance bare metal.
Versione italiana dell’articolo originale del 1° giugno 2015; traduzione a cura del team.
Ultimamente il mio lavoro a Fancy Pixel si è concentrato sul backend di un prodotto che stiamo per lanciare e per cui abbiamo deciso di costruire un server JSON API-only. Queste API possono essere consumate da client/service di terze parti ma sono anche usate dal nostro frontend. In questo breve report vorrei condividere con te la semplice soluzione che stiamo usando per la generazione del JSON e che, secondo me, può essere un’alternativa rapida e indolore ai sistemi più comuni come Jbuilder o ActiveModel::Serializers.
Rails, uno dei miei work buddy preferiti, mi è capitato di usarlo diverse volte su progetti che includevano lo sviluppo di API. Per curiosità personale, negli anni ho avuto l’occasione di provare diverse soluzioni per la generazione del JSON e devo dire che a volte ho trovato qualche difficoltà con certe tecnologie: ottime nella maggior parte delle funzionalità, in qualche caso ti costringono a percorsi strani per ottenere il risultato desiderato. A dirla tutta, il motivo principale di questa sperimentazione continua è probabilmente la ricerca costante del miglior bilanciamento tra comfort/facilità d’uso/velocità di sviluppo e performance: dopo tutti questi test sono arrivato alla soluzione attuale che, pur non essendo nulla di nuovo, secondo me può dare a qualcuno un’idea alternativa che combina grande flessibilità e performance bare metal.
Per farla breve…
Non voglio dilungarmi con racconti epici o “disquisizioni sul sesso degli angeli”, quindi ho preparato una banale demo app che trovi sul nostro account GitHub, per mostrarti di cosa sto parlando.
Tiriamo su rapidamente l’applicazione:
git clone "https://github.com/FancyPixel/serializers_demo"
cd serializers_demo
bundle
rake db:create && rake db:migrate && rake db:seed
Per gli scopi di questo articolo ho deciso di usare rails-api (se non lo conosci ti consiglio di provarlo) al posto di Rails standard, semplicemente perché è quello che stiamo usando ora nel progetto che ho menzionato all’inizio. Ovviamente gli stessi concetti si applicano identicamente a Rails vanilla.
Apriamo insieme il codice e diamogli un’occhiata rapida: come vedi queste poche righe di codice non fanno altro che rispondere a tre route. Se guardi config/routes.rb troverai qualcosa tipo:
# config/routes.rb
Rails.application.routes.draw do
namespace :v1, defaults: { format: :json } do
get :jbuilder, to: 'comparison#jbuilder'
get :ams, to: 'comparison#ams'
get :simple, to: 'comparison#simple'
end
end
Come vedi ho impostato json come formato di default e ho definito un namespace così da replicare un tipico processo di versioning delle API.
Saltiamo all’unico controller esistente (comparison_controller) dove troviamo l’implementazione delle action chiamate dalle route. Ognuna di queste action fa esattamente la stessa cosa: carica un po’ di record dal DB e li renderizza come JSON, ma ciascuna lo fa a modo suo, usando rispettivamente jbuilder, ActiveModel::Serializers e “la mia soluzione” che chiamerò “simple”… che nome eh?
Non ci concentreremo sui primi due sistemi, perché con ogni probabilità tu padroneggi quelle tecnologie meglio di me, e poi non c’è nulla fuori standard nelle mie implementazioni: saltiamo invece a piè pari sull’action simple. Come le competitor non fa altro che renderizzare un po’ di JSON, ma stavolta viene chiamato l’helper method serialize_awesome_stuffs. Questo metodo è definito nel modulo V1::SimpleAwesomeStuffSerializer che il controller include. Trovi il modulo sotto app/serializers/v1: se apri il file noterai che è solo un plain modulo Ruby che definisce dei metodi.
# app/serializers/v1/simple_awesome_stuff_serializer.rb
module V1
module SimpleAwesomeStuffSerializer
def serialize_awesome_stuff(awesome_stuff = @awesome_stuff)
{
name: awesome_stuff.name,
some_attribute: awesome_stuff.some_attribute,
a_counter: awesome_stuff.a_counter
}
end
def serialize_awesome_stuffs(awesome_stuffs = @awesome_stuffs)
{
awesome_stuffs: awesome_stuffs.map do |awesome|
serialize_awesome_stuff awesome
end
}
end
end
end
Entrambi i metodi non fanno altro che restituire un Hash che definisce coppie chiave-valore che vogliamo restituire come JSON. In particolare serialize_awesome_stuffs crea un Array di Hash e internamente, giusto per fare un po’ di DRY, chiama serialize_awesome_stuff (singolare). Forse il naming complessivo non è il migliore al mondo, eh? Bonus: definire il parametro del metodo come awesome_stuffs = @awesome_stuffs rende il nostro codice più leggero e leggibile, perché se restiamo aderenti alle convenzioni di naming delle variabili, probabilmente il controller sta definendo qualcosa come @awesome_stuff (e in effetti è così) direttamente visibile e usabile dal modulo. Se ci viene un guizzo creativo e vogliamo usare nomi di variabili personali, non avremo nessun problema.
Prendi questo pezzo di codice come esempio:
# some_controller.rb
def some_action
@my_personal_awesome_stuffs = AwesomeStuff.all
render json: serialize_awesome_stuffs @my_personal_awesome_stuffs
end
e tutto funzionerà come previsto.
Step-by-step: complichiamo un po’ le cose
Alziamo un po’ la complessità del nostro esempio aggiungendo un model User e definendo una relazione one-to-many con il nostro AwesomeStuff:
rails g model user name:string
aggiungi la reference a User in AwesomeStuff:
rails g migration add_user_reference_to_awesome_stuff user:references
e migra tutto:
rake db:migrate
Ora definiamo le relazioni tra i model:
# app/models/awesome_stuff.rb
class AwesomeStuff < ActiveRecord::Base
belongs_to :user
end
# app/models/user.rb
class User < ActiveRecord::Base
has_many :awesome_stuffs
end
lancia la console Rails con rails c e inserisci un po’ di dati di test nel DB:
# Add five users
users = []
5.times {|n| users << User.create(name: "user_#{n}") }
# Randomly associate our awesome records™ to users
AwesomeStuff.all.each { |aww| aww.update user: users.sample }
# A rapid test confirms that...
User.first.awesome_stuffs
=> #<ActiveRecord::Associations::CollectionProxy [#<AwesomeStuff id: ... ... >]>
Ora che è tutto pronto, seguiamo alcuni degli step che faremmo di solito durante la creazione di un’API. Creiamo uno UserController tramite cui restituire al client anche gli awesome record™ associati all’utente.
Crea users_controller.rb sotto app/controllers/v1/ e aggiungi l’action index:
# app/controllers/v1/users_controller.rb
module V1
class UsersController < ApplicationController
include V1::UsersSerializer
def index
@users = User.all.includes(:awesome_stuffs)
render json: serialize_users
end
end
end
Come vedi ho già aggiunto qualcosa che ci servirà a breve, ovvero il modulo V1::UsersSerializer. Nota anche lo scoping (V1) dei nostri serializer: in questo modo possiamo seguire l’evoluzione delle versioni della nostra API senza problemi, ridefinendo eventualmente solo il behavior dei serializer che cambiano.
Non dimenticare di aggiungere le nuove route:
# config/routes.rb
Rails.application.routes.draw do
namespace :v1, defaults: { format: :json } do
get :jbuilder, to: 'comparison#jbuilder'
get :ams, to: 'comparison#ams'
get :simple, to: 'comparison#simple'
# Let's add the 'index' only
resources :users, only: :index
end
end
Cosa aggiungeremo al nostro UsersSerializer? Una prima idea potrebbe essere qualcosa tipo:
# app/serializers/v1/users_serializer.rb
module V1
module UsersSerializer
def serialize_users(users = @users)
{
users: users.map do |user|
{
id: user.id,
name: user.name,
awesome_stuffs: user.awesome_stuffs.map do |aww|
{
name: aww.name,
some_attribute: aww.some_attribute,
a_counter: aww.a_counter
}
end
}
end
}
end
end
end
Ok, ma qui c’è un sacco di code smell, giusto? Abbiamo già visto un po’ di questa roba, proviamo a riusarla:
# app/serializers/v1/users_serializer.rb
module V1
module UsersSerializer
include V1::SimpleAwesomeStuffSerializer
def serialize_users(users = @users)
{
users: users.map do |user|
{
id: user.id,
name: user.name,
awesome_stuffs: user.awesome_stuffs.map do |aww|
serialize_awesome_stuff aww
end
}
end
}
end
end
end
Molto bene, ma possiamo fare meglio. La nostra API avrà probabilmente una route per i dati di un singolo user, qualcosa come /v1/users/1, quindi possiamo portarci avanti e contemporaneamente fare DRY del codice attuale:
# app/serializers/v1/users_serializer.rb
module V1
module UsersSerializer
include V1::SimpleAwesomeStuffSerializer
def serialize_user(user = @user)
{
id: user.id,
name: user.name,
}
end
def serialize_users(users = @users)
{
users: users.map do |user|
serialize_user(user).merge(
{
awesome_stuffs: user.awesome_stuffs.map do |aww|
serialize_awesome_stuff aww
end
}
)
end
}
end
end
end
Ok, abbiamo “preso due piccioni con una fava”. (i piccioni stanno bene, tranquillo) Come avrai notato è possibile ottenere un ulteriore miglioramento:
# app/serializers/v1/users_serializer.rb
module V1
module UsersSerializer
include V1::SimpleAwesomeStuffSerializer
def serialize_user(user = @user)
{
id: user.id,
name: user.name
}
end
def serialize_users(users = @users)
{
users: users.map do |user|
serialize_user(user).merge(serialize_awesome_stuffs(user.awesome_stuffs))
end
}
end
end
end
Quello che hai visto è un semplice esempio di cosa si può fare con gli strumenti che abbiamo già sotto mano e vuole essere principalmente uno spunto per chi è costantemente alla ricerca della migliore performance e semplicità.
Siamo arrivati alla fine e spero di non averti annoiato troppo, ma se sei arrivato a questo punto probabilmente no :) Quello che hai visto oggi può piacere o meno, ma personalmente trovo che sia un sistema sicuramente performante che offre pura modularità, estendibilità e riuso del codice.
Sentiti libero di lasciare un commento, ci farebbe davvero piacere sentire il tuo feedback.