Praktické využití binární serializace v RunUO

Vítám vás u druhého dílu problematiky zabývající se binární serializací, resp. její praktické využití v emulátoru RunUO. Pokud jste nečetli předchozí díl nazvaný "Základy binární serializace", měli byste tak učinit, jinak hrozí, že následující řádky nepochopíte. Já vas varoval ;) Pokud navíc neumíte C# či podobný jazyk, ani se nepokoušejte tento článek louskat, ztratíte se v tom.

Tak to bychom měli trochu sarkasmu pro začátek. Teď se podiváme na kousek kódu z RunUO...

public override void Serialize( GenericWriter writer )
{
base.Serialize( writer );
writer.Write( (int) 0 ); // version
}
public override void Deserialize( GenericReader reader )
{
base.Deserialize( reader );
int version = reader.ReadInt();
}

Toto jsou dvě základní metody pro objekt, který chceme zaznamenat bez dalších parametrů. Najdete je ve vetšině skriptů, přesněji u těch tříd, jejichž instance se mají ukládata. Jak z jejich názvů vyplývá (alespoň mě), serializace je zápis a deserializace je čtení. Parametrem je odkaz na zapisovač, resp. čtečku, která se vytváří při spuštění Save, resp. Load.

Interní třída GenericWriter obsahuje několik metod na zápis dat, hlavní je metoda Write, kterou lze volat s různym datovým typem prvního parametru (pomocí overloadingu). Jsou zde základní datové typy pocházející z jádra dotNETu, a navíc jsou zde typy použité v RunUO, např. Mobile, Item, Map, Point3D, atd... Každá tato metoda obsahuje kód pro binární zápis daného datového typu, nebo spíše objektu, kterým se stanovuje určitý formát zapsaných dat.

Na druhé straně je třída GenericReader obsahující stejný počet metod, ale s různymi názvy vycházející s názvů datových typů. Zrcadlově jsou zde tedy metody jako ReadMobile, ReadItem, ReadMap, ReadPoint3D, atd...Tyto metody se starají o načtení uvedených datových typů přesně v takovém formátu, v jakém byly zapsané.

Pro názornou ukázku popíšu metody pro zápis a čtení datového typu Point3D. Ten představuje objekt obsahující jednu souřadnici ve světě identifikovanou třemi čísly X Y a Z. Tyto čísla jsou datového typu int, což je zkraceně datový typ Int32. Ten má pevně stanovenou délku na 4 Byte. Zavoláme tedy třikrát po sobě metodu Write, pokaždé s jedním s parametrů v uvedeném pořadí. Nyní máme tedy splněny dva předpoklady pro binární zápis, známe délku a pořadí dat. Při čtení jednoduše zavoláme třikrát po sobě metodu ReadInt() a tím ziskáme jednotlivé souřadnice zpět.

Na tomto jednoduchém principu funguje prakticky všechno. Funkce v jádru za vás obstarají jejich převedení do binárního tvaru. Jedinou vaší starostí je udržovat stejný postup při čtení jako při zápisu, včetně čtení těch správných datových typů, které jste zapsali.

Nyní k systému, jakým dochází k Save. Při spuštění je odstartována smyčka, která hledá ve světě všechny potomky třídy Mobile, takže všechny NPC včetně hráčské postavy (téměř to samé se provádí pro Item, Guild, Region a Account, ale to není podstatné). RunUO zjistí, že se ve hře nachází např. Eagle a spustí jeho metodu Serialize (u tohoto objektu je totožná jako výše uvedený kód).

Podle kódu výše tedy zjistí, že nejprve má spustit metodu Serialize u svého rodiče, což je BaseCreature. Ta, mj. obsahuje také volání na serializaci svého rodiče, což je Mobile. Tato třída je v jádru a již neobsahuje žádná další volání, pouze zapisuje data. Přesněji parametry související s touto třídou, které je třeba uchovat. Po zapsání parametrů třídy Mobile, se řízení vrací metodě Serialize u třídy BaseCreature.

Další přiklad si vezmeme z metody Serialize u třídy BaseCreature.

writer.Write( (bool) m_bControled );
writer.Write( (Mobile) m_ControlMaster );
writer.Write( (Mobile) m_ControlTarget );
writer.Write( (Point3D) m_ControlDest );
writer.Write( (int) m_ControlOrder );
writer.Write( (double) m_dMinTameSkill );

Zde vidite zápis různých parametrů této třídy s různými datovými typy. Rovnou se podíváme na čtení.

m_bControled = reader.ReadBool();
m_ControlMaster = reader.ReadMobile();
m_ControlTarget = reader.ReadMobile();
m_ControlDest = reader.ReadPoint3D();
m_ControlOrder = (OrderType) reader.ReadInt();
m_dMinTameSkill = reader.ReadDouble();

Porovnáním obou výpisu již musíte pochopit celý princip. Zápisem vznikne určitá řada Byte, která bude mít vždy stejnou délku, jen hodnota každého Byte bude jiná. Komu to ještě není v tomto bodě jasné, zkuste si přečíst článek znovu, pokud ani to nepomůže, vzdejte to :)

Nyní k hlavnímu problému a dá se řící nevýhodě. Pokud byste z výše uvedeného kódu pro čtení vyhodily první řádek, nastává problém, neboť při čtení metodou ReadMobile je místo toho nalezen typ Bool, což by normalně způsobilo kritický problém, místo 1 Byte pro Bool by byly přečteny 4 Byte pro Mobile. Takto by to pokračovalo dále celé posunuté o 3 Byte. Výsledek by byl velice nepříjemný.

Podobný problém může nastat, pokud přídate do deserializační metody čtení parametru, který nebyl zapsán. Třeba tedy odstraníme ze serializační metody opět první řádek. Následně při čtení se do proměnné m_bControled načte první Byte následující hodnoty. Takže opět dojde k poškození všech následujících hodnot.

Naštěstí má toto RunUO trochu ošetřené pomocí indexových souborů, které již vysvětlovat nebudu. Důležité pro nás je, že tam jsou a minimalizují tento problém, ovšem nedokáží ho plně opravit. Výsledkem je většinou ztráta daného objektu, nicmeně je to menší tragédie, než přijít o všechno.

S tímto úzce souvisí "verzování" serializačních metod, které jste mohli spatřit v prvním uvedeném kódu. Jednoduše řečeno, jde o zápsání verze serializační metody na začátek každého objektu. Nejedná se o žádnou globální verzi pro celý server, ale pouze o verzi metody daného objektu. Pro jednoduchost, jsou verze číslovány celými čísly počínaje 0.

Důvodem pro zavedení verzování je hlavně budoucí přidávání dalších parametrů. Pokud přidáme zápis dalšího parametru a přitom již máme někde vytvořený Save, nebude tento parametr v něm ještě obsažen a při čtení by došlo ke ztrátě objektu. Proto musíme deserializační metodě řici, kdy má nový parametr číst a kdy nikoliv. Při čtení se nejprve přečte číslo verze serializační metody s jakou byl daný objekt zapsán. Pomocí jednoduché konstrukce tak můžeme určit, že tento parametr se číst nebude a bude mu vnucena default hodnota.

public override void Serialize( GenericWriter writer )
{
base.Serialize( writer );
writer.Write( (int) 12 ); // version

// následují parametry obsažené v základní verzi 0
writer.Write( (int)m_CurrentAI );
writer.Write( (int)m_DefaultAI );
.....

// Version 1 (takto se označuje místo, kde začínají parametry přidané ve verzi 1)
writer.Write( (int) m_iRangeHome );
....

// takto pokračujeme až k verzi 12, která byla zapsána na začátku tohoto zápisu

// Version 12
writer.Write( (int)m_PosibleOrder );
}

Snad je to dost jasné. V tomto je potřeba si dát pozor abyste při změnách vždy změnili číslo verze na začátku, jinak opět při čtení dojde ke zmatkům. Pro úplnost tedy ještě deserializační metoda...

public override void Deserialize( GenericReader reader )
{
base.Deserialize( reader );
int version = reader.ReadInt(); // přečtení verze serializační metody

// naplnění proměných z verze 0
m_CurrentAI = (AIType)reader.ReadInt();
m_DefaultAI = (AIType)reader.ReadInt();
....

// naplnění proměnných z verze 1
if ( version >= 1 )
{
m_iRangeHome = reader.ReadInt();
....
}

// opět takto pokračujeme až do verze 12

if ( version >= 12 )
m_PosibleOrder = (OrderType)reader.ReadInt();
else
m_PosibleOrder = (OrderType)0x07FF;
}

Toto je jeden ze způsobů čtení záznamu s více verzema serializačních metod. Ješte to lze provést pomocí konstruktu switch...case, který je IMHO přehlednější, ale úkol splní stejně dobře oba dva. Zde je potřeba si dát pozor, aby všechny promenne meli definované defaultní hodnoty, nejlépe mimo serializační metody.

Uff, tak je to konečně za námi. Doufám, že pro vás byl tento dvoudílný seriál poučný. Pokud navrhnete téma, můžu se pokusit napsat další články. Provolávejme slávu RunUO, zvláště když teď bude OpenSource :)