#189 Embedded Association
- Download:
- source codeProject Files in Zip (106 KB)
- mp4Full Size H.264 Video (18.2 MB)
- m4vSmaller H.264 Video (13.5 MB)
- webmFull Size VP8 Video (38.8 MB)
- ogvFull Size Theora Video (25.1 MB)
Nel precedente episodio abbiamo creato un sistema di autenticazione basato sui ruoli. Nella pagina di registrazione dell’applicazione una serie di checkbox permette all’utente di assegnarsi uno o più ruoli:
L’applicazione ha un modello Role
in cui sono definiti i ruoli e una relazione molti-a-molti fra il Role
e lo User
per mezzo di un modello Assignment
. Se guardiamo sul database vedremo i tre ruoli esistenti:
>> Role.all +----+-----------+-------------------------+-------------------------+ | id | name | created_at | updated_at | +----+-----------+-------------------------+-------------------------+ | 1 | Admin | 2009-11-16 21:22:59 UTC | 2009-11-16 21:22:59 UTC | | 2 | Moderator | 2009-11-16 21:23:06 UTC | 2009-11-16 21:23:06 UTC | | 3 | Author | 2009-11-16 21:23:16 UTC | 2009-11-16 21:23:16 UTC | +----+-----------+-------------------------+-------------------------+ 3 rows in set
Il problema principale con questa impostazione delle cose è che c’è un forte accoppiamento fra i ruoli sul database e il codice nel file authorization_rules
che definisce i permessi per tale ruolo:
role :author do includes :guest has_permission_on :articles, :to => [:new, :create] has_permission_on :articles, :to => [:edit, :update] do if_attribute :user => is { user } end end
Con ruoli e permessi definiti in questo modo non possiamo apportare modifiche alla tabella dei ruoli senza anche necessariamente cambiare il codice Ruby che definisce ciascun ruolo e di conseguenza i benefici di mantenere i ruoli sul database sono persi. Questo episodio vi mostrerà come rimuovere questo accoppiamento definendo i ruoli esclusivamente sul codice, anzichè su di una tabella sul database.
Dal momento che non abbiamo più un modello Role
, la prima modifica che facciamo è al modello User
, da dove rimuoviamo le associazioni con assignments e roles, cancellando le seguenti due linee:
has_many :assignments has_many :roles, :through => :assignments
Fatto ciò, dobbiamo creare i ruoli da qualche altra parte. Poichè i ruoli sono associati agli utenti, li definiamo come costanti nel modello User
:
class User < ActiveRecord::Base acts_as_authentic has_many :articles has_many :comments ROLES = %w[admin moderator author] def role_symbols roles.map do |role| role.name.underscore.to_sym end end end
Ora dovremo modificare il codice solamente nel momento in cui si dovranno cambiare i ruoli. Ma la domanda rimane: come associamo un utente ai propri ruoli? Dal momento che non abbiamo più una tabella dei ruoli, dovremo in qualche maniera cablare questa associazione nel modello User
.
Di seguito vi mostreremo due modi per farlo, a seconda del tipo di associazione con cui si sta lavorando.
Cablare una relazione uno-a-molti
Al momento la nostra applicazione ha una relazione molti-a-molti fra gli utenti e i ruoli. Cambiamo questa relazione in una uno-a-molti in modo che ogni utente abbia solo un ruolo associato. Tale ruolo dovrà essere salvato nella tabella users
, ma siccome stiamo memorizzando un unico valore, il ruolo può essere memorizzato in un campo di tipo stringa. Generiamo il campo ruolo con la seguente migration:
script/generate migration add_role_to_users role:string
Poi migriamo il database per aggiungere il nuovo campo alla tabella users:
rake db:migrate
Il prossimo passo è modificare la form di registrazione per sostituire i checkbox dei ruoli con una tendina. Per fare ciò, sostituiamo il codice nel template /app/views/users/new.html.erb
:
<%= form.label :roles %> <% for role in Role.all %> <%= check_box_tag "user[role_ids][]", role.id, @user.roles.include?(role) %> <%=h role.name %><br /> <% end %>
con questo:
<%= form.label :role %> <%= form.collection_select :role, User::ROLES, :to_s, :humanize %>
Usiamo collection_select
per generare la tendina. Di solito questo metodo lo si usa con un modello, ma funziona bene anche in questo scenario. Quando è usato con un modello, gli passiamo una collezione di oggetti di modello e le proprietà che dovrebbero essere utilizzate per impostare il valore e il testo di ciascuna opzione. Anzichè quello, passiamo l’array di nomi di ruoli che abbiamo definito nel modello User
. Impostiamo il valore per di ogni opzione, chiamando il metodo :to_s
su ciascun ruolo ed il testo da visualizzare chiamando :humanize
su ciascun ruolo per rendere la descrizione carina da leggere. Come risultato, avremo una tendina che permetterà agli utenti in fase di registrazione di poter scegliere il proprio unico ruolo:
Siccome stiamo usando declarative authorization nella nostra applicazione, dobbiamo anche fare una modifica al metodo role_symbols
nella classe User
, cambiandolo affinchè restituisca il ruolo utente convertito in simbolo:
def role_symbols [role.to_sym] end
Ora, in fase di creazione di nuovi utenti, il loro ruolo verrà salvato nel campo role
della tabella users
:
>> User.last.role => "moderator"
Cablare una relazione molti-a-molti
Abbiamo cablato con successo una relazione uno-a-molti all’interno di un singolo modello, ma come potremmo fare per mantenere la possibilità di fare associare più di un ruolo alla utenza in creazione, senza dover ripristinare il modello (e la tabella) Role
? Questo è un po’ più complicato da perseguire, poichè dobbiamo comprimere molti valori all’interno di una singola colonna della tabella users.
Una soluzione potrebbe essere quella di creare una colonna di testo denominata roles
nella tabella users
e usare il metodo di Rails serialize
per salvare i ruoli dell’utente in tale colonna. Questo causerebbe il richiamo del metodo to_yaml
sui ruoli prima di salvare il valore del campo nel database. Come approccio funziona, ma rende difficili le successive operazioni di manipolazione dei dati, come ad esempio il recupero degli utenti in base al ruolo, per cui decidiamo di intraprendere una strada diversa.
Anzichè serializzare i ruoli, useremo una bitmask. In questo modo possiamo salvare più valori in un singolo campo intero e continuare ad avere la possibilità di recuperare gli utenti in base al loro ruolo.
La prima cosa da fare è aggiungere una nuova colonna chiamata roles_mask
alla tabella users
per poter salvare il valore della bitmask:
script/generate migration add_roles_mask_to_users roles_mask:integer
Poi lanciamo la migrazione:
rake db:migrate
Nel modello User
dovremo gestire la roles_mask
implementando i metodi getter e setter per poter convertire facilmente la bitmask da e verso un array di ruoli:
def roles=(roles) self.roles_mask = (roles & ROLES).map { |r| 2**ROLES.index(r) }.sum end def roles ROLES.reject { |r| ((roles_mask || 0) & 2**ROLES.index(r)).zero? } end
Il metodo setter accetta un array di ruoli e li converte in una bitmask di interi che viene assegnata all’attributo roles_mask
. Il getter itera su ciascun ruolo e restituisce un array di ruoli il cui bit sia impostato nella maschera. Ci sono dei plugin disponibili per rendere più semplice l’utilizzo delle bitmask, ma siccome abbiamo un solo campo che ne fa uso e occorrevano davvero poche righe di codice per gestire la nostra bitmask, abbiamo preferito non appoggiarci a uno di essi.
Dobbiamo cambiare nuovamente il metodo role_symbols
per farlo andare con i nostri nuovi ruoli. Prendiamo l’array di ruoli e convertiamo dunque ciascun ruolo in un simbolo:
def role_symbols roles.map(&:to_sym) end
L’ultima modifica da fare è di cambiare nuovamente la vista per sostituire la tendina con i checkbox, in modo tale che l’utente possa scegliere una serie di ruoli all’atto della registrazione:
<%= form.label :roles %> <% for role in User::ROLES %> <%= check_box_tag "user[roles][]", role, @user.roles.include?(role) %> <%= h role.humanize %> <% end %> <%= hidden_field_tag "user[roles][]"%>
La tecnica qui mostrata per creare i checkbox per una relazione molti-a-molti è simile a quella già vista nell’episodio 17 [guardalo, leggilo]. Le parentesi quadre vuote in user[roles][]
indicano che i valori dei checkbox spuntati saranno passati come array al modello User
, dove il metodo setter che abbiamo appena scritto li convertirà ad un valore di bitmask. L’ultimo parametro passato a check_box_tag
spunta il checkbox se l’utente ha quel ruolo. Il campo nascosto assicura che sia passato un array vuoto se non è spuntato alcun checkbox.
Tornando ora alla maschera di registrazione, ora, si rivedono i checkbox. Registriamo un nuovo utente ed assegnamogli due ruoli:
Se diamo un’occhiata all’utente appena creato da console, possiamo vedere i suoi ruoli e i valori assegnati alla roles_mask
:
>> User.last.roles => ["admin", "author"] >> User.last.roles_mask => 5
Ogni ha un valore doppio rispetto al ruolo a lui successivo, con il ruolo di valore più basso pari a 1. Per questo, il valore 5 per un utente deriva da (1*1) + (2*0) + (4*1)
.
Avendo salvato il ruoli in un’unica colonna, come possiamo trovare tutti gli utenti che hanno un certo ruolo? Poniamo di voler trovare tutti gli utenti con privilegi di moderatore. Possiamo fare ciò che vogliamo, aggiungendo un named scope al nostro modello User
:
named_scope :with_role, lambda { |role| {:conditions => "roles_mask & #{2**ROLES.index(role.to_s)} > 0 "} }
Questo named scope accetta un ruolo per argomento e compie su di esso un’operazione bit a bit per capire se l’utente appartiene a quel ruolo. Possiamo controllare il funzionamento da console, cercando tutti gli utenti con ruolo amministrativo:
>> User.with_role("admin") +----+----------+-------------+-------------+-------------+-------------+------------+ | id | username | email | crypted_... | password... | persiste... | roles_mask | +----+----------+-------------+-------------+-------------+-------------+------------+ | 6 | paul | paul@tes... | cffada11... | FDGoNtM1... | 35a7d8c8... | 5 | +----+----------+-------------+-------------+-------------+-------------+------------+ 1 row in set
Questa query restituisce solo l’utente più recente, dal momento che è lui l’unico utente che abbiamo aggiunto da quando abbiamo creato il campo roles_mask
.
Se avete bisogno di aggiungere ruoli aggiuntivi all’applicazione in seguito, c’è un’insidia potenziale a cui dovrete prestare attenzione. Dal momento che la bitmask si basa sulla posizione dei ruoli nell’array ROLES
, è indispensabile che ogni nuovo ruolo sia aggiunto in fondo alla lista. Se per sbaglio non si facesse in questo modo, gli utenti già registrati si vedrebbero cambiati i propri ruoli:
ROLES = %w[admin moderator author editor]
Aggiunta di ruoli alla lista.
E’ tutto per questo episodio. Il bitmasking è una potente tecnica per incapsulare relazioni molti-a-molti in un singolo campo intero di un modello. Detto ciò ha senso applicare questo metodo solo nei casi in cui si ha una lista di record che non appartengono ad alcuna tabella del database. La nostra lista di ruoli è così strettamente legata al codice che non ha alcun senso memorizzarla al di fuori del codice stesso, dal momento che non possiamo cambiare un ruolo senza dover cambiare il codice che definisce i permessi per quel ruolo. Se vi trovate in una situazione in cui potete aggiungere o cambiare uno di questi record senza la conseguente necessità di dover fare modifiche al codice, allora l’approccio tradizionale con relazione molti-a-molti sul database rimane la soluzione migliore.