Lezione Precedente | Elenco Lezioni |
Vogliamo adesso realizzare quella che è la parte "centrale" del nostro software, ovvero la procedura di prenotazione di un oggetto. Partendo dalla versione di Mancolista alla fine della scorsa lezione, l'idea è quella di modificare la pagina elenco in maniera tale che, per ogni oggetto:
Il link per prenotare richiama una pagina di prenotazione passando l'id dell'oggeto da prenotare. Non è necessario passare l'id dell'utente che ha effettuato la prenotazione in quanto questa si trova nella variabile di sessione $_SESSION['user_id'].
. Lo script di prenotazione esegue una semplice query UPDATE
aggiornando mettendo nel campo utente_id
corrispondente all'oggetto selezionato, l'user_id dell'utente loggato. Otteniamo i file elenco5.php e prenota.php.
Notare che abbiamo deciso di non realizzare una pagina di conferma della prenotazione, ma di saltare alla pagina elenco dopo aver effettuaro la prenotazione. Tuttavia, per confermare comunque all'utente che la prenotazione è avvenuta, ci siamo inventati una nuova soluzione. Abbiamo inserito in elenco5.php
un nuovo pezzo di codice che riportiamo qua sotto:
if (isset($_SESSION['flash'])) { echo "<p><strong>$_SESSION[flash]</strong></p>"; unset($_SESSION['flash']); }
Quello che fanno queste tre righe è semplicemente mandare in output la variabile $_SESSION['flash']
se è settata, e poi eliminarla. L'idea è che se uno script vuole tornare all'elenco degli oggetti ma nel contempo vuole visualizzare un messaggio all'utente, basta che setti la variabile $_SESSION['flash']
con il messaggio voluto, e poi faccia una redirect_browser
(ovviamente avremmo potuto usare una variabile di sessione qualsiasi, non per forza $_SESSION['flash']
).
La procedura di prenotazione così scritta non va bene perché non tiene conto del fatto che quando l'utente clicca su un oggetto, non è detto che l'oggetto sia ancora prenotabile. Lo era quando la pagina con l'elenco è stata visualizzata, ma nel frattempo qualcun altro può avere cliccato sullo stesso oggetto, prenotandolo. Se, come facciamo attualmente, non controlliamo nella pagina di prenotazione se l'oggetto è già prenotato, rischiamo di "rubare" la prenotazione a qualcun altro. Inoltre, il solito hacker potrebbe chiamare manualmente la pagina prenota.php
con l'id di un oggetto già prenotato o con un id inesistente. Raffiniamo allora le due pagina ottenendo elenco5.1.php e prenota.1.php. L'unica differenza di elenco5.1
è che il link di prenotazione punta a prenota.1.php
, mentre le modifiche a prenota.1.php
sono più sostanziose ma ovvie: adesso prima di prenotare veramente un oggetto controlliamo che non sia già stato prenotato.
Adesso sembrerebbe che le cose siano a posto.. ma purtroppo così non è. Può infatti succedere che due persone tentino di prenotare lo stesso oggetto contemporaneamente. Quello che succede è che partono due copie dello script prenota.1.php
(in termine tecnico, due processi) che vengono eseguite più o meno in parallelo. Il funzionamento del programma dipende dall'ordine con cui vengono eseguite le interrogazioni sul server SQL (possiamo pensare per semplicità che il server possa eseguire una sola interrogazione alla volta). Ovviamente la select dell'utente 1 è sempre eseguita prima dell'update dell'utente 1, e analogamente avviene per l'utente 2. Ma
questo lascia comunque aperte varie possibilità. Ad esempio:
Caso
1 |
Caso
2 |
Caso
3 |
select1 |
select1 |
select1 |
update1 |
select2 |
select2 |
select2 |
update1 |
update2 |
update2 |
update2 |
update1 |
Se siamo nella situazione non c'è nessun problema: il primo script trova l'oggetto libero e prenota, il secondo script controlla l'oggetto, lo trova occupato, e quindi informa l'utente due che l'oggetto è già stato prenotato (quindi, in realtà, il passo update2 non avviene). Ma cosa avviene negli altri casi? Le due select vengono eseguite prima degli update e produrranno esattamente lo steso risultato: l'oggetto è libero. Successivamente i due update modificheranno entrambi la tabella oggetti, e la seconda ad essere eseguità sarà quella vincente, perché sovrascriverà il dato precedente. Entrambi gli utenti saranno avvisati che la prenotazione ha avuto successo ma uno dei due non avrà veramente prenotato l'oggetto.
Ovviamente questo è un caso limite che è difficile far accadere, ma è possibile aiutare il fato aggiungendo artificialmente una pausa tra la select e l'update, utilizzando la funzione:
In questo modo è facile, usando contemporaneamente il sito con due browser, causare la successione degli eventi sfavorevole, semplicemente prenotando uno dopo l'altro lo stesso oggetto dalle due finestre. Notare che la situazione dannosa, prima o poi, si verificherebbe anche senza la pausa artificiale.. ma non abbiamo ore di tempo per tentare ripetutamente.
Nel compiere questi esperimenti sulla concorrenza è necessario richiedere in contemporanea l'esecuzione di più di una istanza della stesso script PHP. Anche se è possibile farlo semplicemente aprendo due finestre dallo stesso browser, è preferibile utilizzare un browser diverso per ogni istanza della pagina. Questo perchè alcuni browser, Firefox compreso, in varie situazioni si rifiutano di richiedere due copie della stessa pagina, e aspettano che una copia sia arrivata interamente prima di richiederne un'altra. È ovvio che un comportamento simile, che è ragionevole quando si tratta di un utilizzo normale del browser, rende difficile effettuare gli esperimenti che ci interessano. Utilizzando browser diversi, invece, il problema non sussiste.
Come fare per risolvere questo problema? Abbiamo in realtà varie possibilità. Il metodo più standard è quello di usare le transazioni, che sono una caratteristica avanzata di SQL. Purtroppo le transazioni sono una cosa abbastanza complessa, che non abbiamo tempo di trattare nel corso di laboratorio.
Altra soluzione può essere quella di modificare la query UPDATE in prenota.1.php
in modo che sia la query stessa ad accorgersi che qualcosa non va. Ad esempio, si può sostituire
$stmt=$conn->prepare("UPDATE oggetti SET utente_id=? WHERE id=?");
con
$stmt=$conn->prepare("UPDATE oggetti SET utente_id=? WHERE id=? AND utente_id is null");
In questo modo se una UPDATE in precedenza ha già modificato il campo utente_id
, la seconda UPDATE
non provocherà alcuna modifica. Il tutto funziona perché la UPDATE è una operazione atomica, nessun'altra operazione può avvenire, sulla stessa riga della tabella, mentre la UPDATE è in corso. Stessa proprietà di atomicità vale per INSERT e DELETE.
Ovviamente il programma deve potersi accorgere del fatto che l'UPDATE non ha in realtà aggiornato nulla, e generare un messaggio di errore di conseguenza. Per far ciò si può usare il metodo rowCount
dell'oggetto PDOStatemente
:
int PDOStatement::rowCount(void)
: restituisce il numero di righe che sono state coinvolte nell'esecuzione dell'interrogazione.Se il numero righe coinvolte dall'operazione UPDATE
modificata è zero, vuol dire che qualcuno ha già prenotato l'oggetto. Questo consente tra l'altro di semplificare la procedura eliminando la query SELECT e lasciando solo la UPDATE. Apportando le opportune modifiche, otteniamo elenco5.2.php e prenota.2.php.
I comandi UPDATE
, INSERT
, DELETE
e simili non sono vere operazioni atomiche, ma lo sono riga per riga. Ovvero, in una update multipla, l'aggiornamento di ogni singola riga è una operazione atomica, ma tra l'aggiornamento di due righe è possibile che siano eseguite delle operazioni diverse provenienti da un'altra connessione.
La soluzione vista sopra è abbastanza efficiente ma un po' troppo ad-hoc. Bisogna adattare le query di volta in volta al caso specifico, e non sempre questo è possibile. Una soluzione più generica, ma disponibile solo su MySQL, è quella che ora vedremo dei blocchi di tabella.
Come avviene per i sistemi operativi, il MySQL ha delle primitive che consentono di bloccare le risorse (in questo caso le tabelle) ed assegnarne l'uso esclusivo ad una specifica connessione. Il comando SQL che fa al caso
nostro è LOCK TABLES.
La sintassi del comando è la seguente:
LOCK TABLES <nome_tabella>
[READ | WRITE] [, <nome_tabella> [READ | WRITE]]....
Ad esempio,
LOCK TABLES oggetti WRITE,
utenti READ
blocca la tabella oggetti per la scrittura e la tabella utenti per
la lettura. Ma cosa vuol dire che queste tabelle sono bloccate?
Una tabella bloccata con la clausola
READ può essere letta da
altre connessioni ma non può essere modificata. Una tabella bloccata
con la clausola WRITE non può essere nè letta nè
scritta da altre connessioni. Qualora una connessione tenta di effettuare
una operazione non consentita su una tabella bloccata, essa si
sospende, in attesa che il blocco venga rilasciato e che quindi la
query in sospeso possa essere ripresa.
In questo modo, finchè il LOCK è attivo, possiamo essere
sicuri che nulla di strano può accadere alle nostre tabelle. Per
rilasciare il lock quando abbiamo finito con le manipolazioni, si
può usare il comando SQL:
UNLOCK TABLES
che rilascia tutti i lock acquisiti.
Gli script corrispondenti sono elenco5-lock.php e prenota-lock.php. Anche a prenota-lock.php
aggiungiamo la pausa di 10 secondi tra SELECT e UPDATE come fatto per prenota.1.php
, non riusciamo comunque a causare una doppia prenotazione.
LOCK TABLES
Il comando LOCK TABLES
potrebbe generare un errore quando inviato da uno script PHP: dipende dalla versione di PHP che avete nella vostra macchina. Per evitare queso problema, bisogna configurare un parametro della libreria PDO con il comando:
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES,true);
Se vi ricordate, setAttribute
lo usiamo già per indicare alla libreria PDO di lanciare le opportune eccezioni quando si verifica un errore. Per far ciò, modificavamo l'attributo PDO::ATTR_ERRMODE
, adesso invece modifichiamo anche PDO::ATTR_EMULATE_PREPARES
.
contatore
, creata e inzializzata con i seguenti comandi:
create table contatore ( val int ); insert into contatore values (0);e lo script conteggia.php che aggiunge un numero specificato dall'utente al valore corrente del contatore. Verificare che lo script può generare dei risultati errati quando ne vengono eseguite più copie concorrenti. Modificare lo script in modo che non si verifichino problemi nel caso di più esecuzioni concorrenti, sia modificando le query opportunamente, sia ricorrendo ai blocchi di tabella.
Risolvere l'esercizio 1 usando gli advisory lock invece dei mandatory lock.
Lezione Precedente | Elenco Lezioni |