Webes rendszerek esetén nem ritka, hogy ugyanahhoz az adathoz egyszerre több felhasználó fér hozzá és egyszerre módosítják azt. Ha nem teszünk semmit ellene, akkor a későbbi módosítás felülírja a korábbi módosítást, ami az esetek nagy részében nem egyezik a kívánatos eredménnyel. Alapvetően a következő lehetőségeink vannak:
- Optimista konkurenciakezelés: nem zároljuk az adatot, mert bízunk abban, hogy nem lesz ütközés. Ha mégis előfordul, hogy két felhasználó egyszerre ugyanazt az adatot módosítja, akkor a második módosítás adatbázisba írása előtt figyelmeztetjük a felhasználót, hogy már elavult adatot módosít.
- Pesszimista konkurenciakezelés: arra számítunk, hogy több felhasználó ugyanazt az adatot akarja módosítani, ezért amint az egyik felhasználó hozzáfér az adathoz zároljuk azt, és addig nem engedünk másik felhasználót hozzá, amíg az első módosítás be nem fejeződik.
Az optimista megoldás hátránya, hogy a felhasználó tipikusan későn értesül arról, hogy feleslegesen dolgozott, mégis sok adat és kevés felhasználó (például admin oldalak) esetén ez a célszerűbb megoldás, ugyanis nem kell zárakkal és azok feloldásával (pl. timeout) vesződnünk.
Az optimista megoldáshoz először is egy olyan mezőre van szükségünk az adatbázisban, ami a rekord módosításakor mindig frissül, azaz egy verzió vagy időbélyeg típusú mezőre. SQL Server esetén pont erre szolgál a timestamp típus, aminek a frissítéséről az SQL Server automatikusan gondoskodik. Tehát csak létre kell hoznunk az oszlopot, a többi megy magától:
VersionNumber timestamp NOT NULL
ORM használata esetén a modell frissítése után a VersionNumbernek byte[] típusú oszlopként kell megjelennie.
Ha egységesen akarunk eljárni és minden olyan táblában, ahol szükség van konkurenciakezelésre ugyanígy nevezzük az oszlopot, akkor célszerű kitalálnunk egy módszert, amivel hivatkozhatunk a “verziózott” táblákra. Ehhez készíthetünk öröklést az ORM modellben, vagy ha a Studio által generált modellt nem akarjuk bántani, akkor interfészt is bevezethetünk:
public interface IVersionedEntity
{
byte[] VersionNumber { get; set; }
}
Az interfészt a partial osztályoknak köszönhetően külön fájlban alkalmazhatjuk a modellben lévő entitásokra, nem kell belenyúlni a modellbe. Például ha a modellünkben van egy Partner nevű osztály, akkor így “származtathatjuk” az interfészünkből:
public partial class Partner : IVersionedEntity
{
}
Ezzel készen vagyunk az adatbázissal és az adatelérési réteggel.
Következő lépésként fogjuk meg a dolgot a másik végéről, induljunk el a GUI irányából és okosítsuk fel a ViewModelt. Itt a módosítással kapcsolatos VM-ekbe kell tennünk egy VersionNumber tulajdonságot, amit a módosítás előtt feltöltünk az adatbázisból, és az update előtt összevetünk az adatbázisban tárolt értékkel. Itt is választhatjuk az interfészes megoldást, de ha spórolni akarunk a kóddal, akkor alkothatunk ősosztályt is:
public class VersionedObject
{
[HiddenInput( DisplayValue = false )]
public byte[] VersionNumber { get; set; }
public string VersionNumberEncoded
{
get
{
return Convert.ToBase64String( this.VersionNumber );
}
}
}
A VersionNumberEncoded akkor lesz hasznos, ha az értéket például query stringben kell átadnunk, mert egyébként az MVC automatikusan gondoskodik a byte[] típusú érték Base64 kódolásáról, mielőtt a HiddenInput attribútum szerint kiírja egy rejtett mezőbe.
Ezek után a szerkesztéssel kapcsolatos viewmodeleket egyszerűen származtassuk ebből az ősosztályból:
public class EditPartnerVM : VersionedObject
{
[HiddenInput(DisplayValue = false)]
public int ID { get; set; }
public string DisplayName { get; set; }
}
A modellbe így bekerült értékünket ne felejtsük el megutaztatni a kliensre:
@Html.EditorFor( m => m.VersionNumber )
Miután a böngészőből visszajön az adat, a mentés előtt össze kell vetnünk az adatbázisban lévő értékkel. Ehhez készíthetünk például egy CheckConcurrency( VersionedObject viewModel, IVersionedEntity entity ) függvényt, amely összehasonlítja a két paraméter VersionNumber tulajdonságát, és eltérés esetén kivételt dob. Én készítettem egy saját ObjectMissingException osztályt, amit akkor használok, ha az adatbázis rekord null (azaz már törölték a rekordot), és egy ObjectChangedException osztályt, amit akkor, ha a két verziószám eltér egymástól.
Természetesen a felhasználóval valahogy illik tudatnunk, hogy a művelet kudarcba fulladt. Ezt tehetjük például egy saját exception filterrel, ami ezeknél a kivételeknél egy hibaüzenetet tartalmazó view-t jelenít meg:
[AttributeUsage( AttributeTargets.Class | AttributeTargets.Method,
Inherited = true, AllowMultiple = false )]
public sealed class HandleConcurrencyErrorAttribute :
FilterAttribute, IExceptionFilter
{
public void OnException( ExceptionContext filterContext )
{
if( filterContext.Exception.GetType() == typeof( ObjectChangedException ) )
{
filterContext.ExceptionHandled = true;
filterContext.Result = new ViewResult { ViewName = "_ObjectChanged" };
}
if( filterContext.Exception.GetType() == typeof( ObjectMissingException ) )
{
filterContext.ExceptionHandled = true;
filterContext.Result = new ViewResult { ViewName = "_ObjectMissing" };
}
}
}
Legvégül ne felejtsük el ráaggatni ezt a filtert azokra az actionökre, amik a módosítás POST-ját kezelik, hogy a kivétel biztosan ne szálljon egészen a felhasználóig:
[HttpPost]
[HandleConcurrencyError]
public virtual ActionResult EditPartner( EditPartnerVM model )
{ ...
Ha kevés paraméterünk van (például Ajaxos hívások esetén), ne felejtsük el átadni a VersionNumberEncoded értékét az actionnek.
Ti hogyan oldjátok meg ezt a problémát?
Technorati-címkék:
ASP.NET,
MVC,
SQL