Deprecated: mysql_connect(): The mysql extension is deprecated and will be removed in the future: use mysqli or PDO instead in /home/www/novacisko.cz/subdomains/bredy/init.php on line 11

Warning: mysql_connect(): Headers and client library minor version mismatch. Headers:50562 Library:100020 in /home/www/novacisko.cz/subdomains/bredy/init.php on line 11

Warning: Cannot modify header information - headers already sent by (output started at /home/www/novacisko.cz/subdomains/bredy/init.php:11) in /home/www/novacisko.cz/subdomains/bredy/index.php on line 38
Serializace dat a objektů IV - Transport a struktura - Bredyho blog - Ondřej Novák
Bredyho blog - Ondřej Novák

Serializace dat a objektů IV - Transport a struktura

Dnešní díl o serializaci se pozastaví nad nejvhodnější implementací transportního modulu a pak se vrhneme na strukturu dat.


Transportní modul, primitivní varianta

V minulém díle jsme si napsali první, jednoduchý formátovač a parser. Jednalo se o binární formátovač/parser, který proměnné základního typu binárně ukládal do streamu, respektivě načítal ze streamu.

Mlčky jsem přitom předpokládal, že každý poznal standardní C++ stream, tedy iostream, nebo tedy jejich poloviny istream a ostream.

Tím by mohla kapitola o transportním modulu zase skončit. Nicméně bych rád ukázal, že použití iostreamu není až tak výhodné. Dopustím se teď subjektivního hodnocení, když napíšu, že používání STL streamů je vůbec ta nejhorší varianta transportního modulu.

Při psaní transportního modulu mysleme zejména na modularitu. Jednou věcí je vlastní transport dat, který je zpravidla jednoduchý. Transportní modul obdrží zpravidla vytvořený kanál, ať už je to TCP spojení, nebo otevřený soubor a nějakou metodu, zpravidla read/write. Zřídkakdy obdržíme objektový proud, který očekává čtení/zápis objektů. HW prostředky úrčené pro přenos nebo uložení dat umí mnohem lépe pracovat s bity a bajty. Předpokládejme tedy, že objekt otevřeného kanálu umí přenášet zejména bajty (v C++ je to unsigned char ale mnohem častěji to vidímě jako prosté void *).

Druhou věcí je formátovaný vstup a výstup, tedy jako vstupní data v různých typech převést na sérii bajtů, tak aby tomu

  1. druhá strana rozuměla
  2. vyhovělo našim požadavkům na objemu dat.
  3. vyhovělo našim požadavkům na čitelnost člověkem
  4. vyhovělo našim požadavkům na zabezpečení (ať již proti ztrátě vlastním přenosem, nebo proti nežádoucímu odposlechu, nebo dokonce modifikace)

Z hlediska naší aplikace je nejdůlěžitější bod 1, vše ostatní záleží na požadavcích, které na aplikaci klademe. I ten nejjednoduší formátovač (binární) je schopen splnit první bod, a tím vyhovět, pokud u ostatních bodu nemáme žádné požadavky.

Dobrá organizace propojení formátovače, a transportního modulu je (právě v kontrastu s STL v následující)

  1. Objekt zajišťující přenos
  2. Objekt zajišťující formát
  3. Řídící objekt.

Řídícím objektem je svým způsoben myšlen formátovač z předchozího dílu. Ten ovládá dva nástroje, objekt zajišťující přenos a objekt zajišťující formát. Ukažme si příklad deklaraci interfaců

class Concept_InputStream {
public:
void read(void *data, size_t countBytes);
};

class Concept_OutputStream {
public:
void write(const void *data, size_t countBytes);
};

template<typename Stream>
class TextFormatter {
public:
TextFormatter(Stream stream);
void writeInt(int val);
void writeChar(char val);
void writeFloat(float val);
void writeString(char *string);
...
};

template<typename Stream>
class TextParser {
public:
TextParser(Stream stream);
int readInt();
char readChar();
float readFloat();
char *readString();
...
};

V zásadě jsme si tedy připravili podporu pro formátovač a parser, zapisující data v textovém tvaru. Na rozdíl od STL streamu, zde se používá koncepce známá spíš v Javě. Vlastní objekt pro transport má skutečně na starost jen přenos dat a o jeho plnění se postará další třída. Toto oddělení umožňuje i prakticky "za běhu" měnit formátovače a parsery nad stejným přenosovým kanálem bez nutnosti ho ničit a znova vytvářet.

Řídící objekt pak může tyto interface použít snadno:


//předpokládejme...
File file;
TextFormatter<File &> fileWritter(file);

//pak implementace vypadá
void Formatter::operator()(int &x) {
fileWritter.writeInt(x);
}
void Formatter::operator()(float &x) {
fileWritter.writeFloat(x);
}
//ještě příklad pro reader
void Parser::operator()(int &x) {
x = fileReader.readInt();
}
void Parser::operator()(float &x) {
x = fileReader.readFloat();
}

Chyby a zotavení

Doposud jsme neřešili chyby, které při serializaci mohou nastat. Například když při deserializaci z TCP spojení dojde k přerušení spojení. Samotný serializátor nemá moc míst, kde by došlo k chybě. V zásadě tedy chyby mohou nastat

  1. v transportním modulu - chyby čtení / zápisu, timeout, rozpad spojení, chyba HW nebo média
  2. ve formátovacím modulu - chyby formátu, prohřešky proti formátu, narušení dat či bezpečnosti
  3. chyby v datech při deserializaci - nesmyslná data (mimo rozsah a podobně)

V zásadě všechny chyby by měly být řešeny strukturalizovaným systémem výjimek. Z toho důvodu se také požaduje, aby veškerý kód kolem serializátoru byl exception-safe a na místech, kde to není možné zajistit případně sekce try {...} catch, která provede zotavení z chyby a případně pošle výjimku výše.

Struktura dat a identifikace proměnných

Vzpomeňme si na první díl, zejména na rozšíření umožňující identifikaci proměnných. Doposud jsme nepředpokládali, že budeme proměnné serializované do proudu nazývat, i když původní návrh to deklaroval. Bude třeba tuto funkci nějak dodělat. Ale jak?

Na první pohled se zdá, že to znamená větší přestavbu celého konceptu, protože při jeho návrhu se s tím moc nepočítalo. Ale to vynechání v současném konceptu jsem provedl záměrně.

  • Jednak by to znepřehlednilo vlastní výklad
  • Objem práce na zprovoznění této funkce není až tak velký. V zásadě se nejedná o velký zásah do konceptu.

Řekli jsme si, že název proměnné budeme psát jako druhý parametr funktoru a že se bude jednat o obyčejný řetězec zakončený znakem NULL (prostě C řetězec)

a(proměnná, "název");

Rozhranní serializátoru tedy musíme obohatit o funktor se dvěma parametry


template<typename T>
void operator()(T &x, const char *name);

Jaké budou další změny?

Struktura a proměnné

Kde vlastně začíná proměnná a kde končí? Při rozkladu objektů na menší a menší díly může být proměnnou nejen jedna hodnota (třeba typu int), ale i celý objekt složený z mnoha proměnných. Dokonce všechna serializovaná data mohou být považována za jednu proměnnou.

Tímto způsobem skrytě vzniká struktura, která dobře definuje význam každé části dat. Jak už bylo naznačeno, že proměnná někde začíná a někde končí, nebude těžké tuhle myšlenku použít při implementaci proměnných do serializátoru. V zásadě půjde o tento algoritmus

  1. Ohlaš začátek proměnné
  2. Serializuj proměnnou
  3. Ohlaš konec proměnné

Začátek a konec proměnné samozřejmě ohlašujeme formátovači/parseru. Formátovač si pravděpodobně založí další sekci, parser se pokusu v aktualní sekci proměnnou nalézt (parser seriového proudu případně pouze ověří, že uvedená proměnná se skutečně očekává)

Díky ohlašování začátku a konce můžeme dobře generovat strukturu dat.

ohlaš začátek "enita"
ohlaš začátek "čtverec"
ohlaš začátek "barva"
ohlaš začátek "červená"
data: 255
ohkaš konec "červená"
ohlaš začátek "zelená"
data: 200
ohlaš konec "zelená"
ohlaš začátek "modrá"
data: 127
ohlaš konec "modrá"
ohlaš konec "barva"
ohlaš začátek "rozměr"
data: 12.5
ohkaš konec "rozměr"
ohlaš konec "čtverec"
ohlaš konec "enita"

Připomíná vám to něco?

<entity>
<rectangle>
<color>
<red>255</red>
<green>200</green>
<blue>127</blue>
</color>
<dimension>12.5</dimension>
</rectangle>
</entity>

Výsledkem ale může být i jiný formát

[entity::rectangle]
color.red=255
color.green=200
color.blue=127
dimension=12.5

To jak bude vypadat konečný formát rozhoduje formátovač, nikoliv objekt, který je serializován.

Implementace

Aby bylo možné výše uvedené implementovat, dopíšeme do konceptu formátovače dvě další funkce

bool openSection(const char *jmeno);
void closeSection(const char *jmeno);

Tyto funkce se nacházejí v třídě formátovače/parsera a odpovídají výše uvedenému ohlašování začátků a konců proměnných. Serializér nyní rozšíříme o funktor se dvěmi parametry

//v kontextu class Serializer

template<typename T>
bool operator()(T &x, const char *name) {
if (!formatter.openSection(name))
return false;
(*this)(x);
formatter.closeSection(name);
return true;
}
Proč vlastně funkce closeSection() předává jméno zavírané proměnné? Pokud se mají sekce uzavírat v opačném pořadí, než byly otevřené, je to zbytečné... A nebo se mohou uzavírat v jiném pořadí?

Serializátor musí garantovat že proměnné se budou zavírat v opačném pořadí než byly otevírány. Právě tato garance umožňuje, že formátovač se na ni může spolehnout a nemusí toto nějak speciálně řešit. V případě že tato vlastnost není zaručena, formátovač bude pravděpodobně generovat nevalidní výstup a parser nebude schopen data přečíst. Z toho důvodu se specifikuje i jméno proměnné při uzavírání sekce. Formátovač si tak nemusí pamatovat celou cestu aktuální proměnné. Tím se implementace zjednoduší

Funkce openSection (a následně vlastní funktor) vrací true pokud serializace proběhla, nebo false pokud neproběhla, protože proměnná nebyla nalezena. Nevzniká tedy výjimka, považuje se to za validní stav. Deserializace tedy může pokračovat dál a předpokládá se, že deserializovaná proměnná tedy obsahuje výchozí hodnotu. Při zápisu by funkce měla vždy vracet true, pouze v případě, kdy vynechání proměnné má nějaký smysl, například ten, že formát vynechání umožňuje bez ztráty informaci, funkce vráti false.

Směr serializace

Na konec se v dnešním článku podíváme na problém směru serializace. Doposud jsme předpokládali, že serializace může probíhat oběmi směry:

  • serializace - objekty jsou převáděny na proud dat
  • deserializace - z proudu dat se rekonstruují objekty.

Při implementaci serializace/deserializace na uživatelské typy se snažíme, aby proměnné uživatelského typu byla serializovatelná/deserializovatelná stejným serializačním předpisem, bez ohledu na směr serializace.

je tedy jedno, zda zápis

a(jmeno,"jmeno")

znamená, že se bude číst, nebo zapisovat do proměnné jmeno.

A zvláště právě při implementaci serializace těchto typů kolikrát potřebujeme vědět, zda serializátor zrovna serializuje nebo deserializuje. Typickým příkladem jsou kontejnery, které při čtení proud vznikají, zatímco při zápisu do proud se pouze jen procházejí.

Aby se daly ty dvě operace rozlišit, zavádí serializátor do konceptu další funkce a operátory

bool operator +() const;
bool storing() const;
Oba vrací true pokud serializátor zapisuje data do streamu.
bool operator -() const;
bool loading() const;
Oba vrací true pokud serializátor čte ze streamu.
V původním návrh bylo reading() a writting(), jenže to nejsou vhodně zvolené názvy. Například u názvu writting() není zřejmé, zda se zapisuje do objektů, nebo do streamu. Název storing() spíš představuje "ukládání" a tam je ten směr z objektů do proudu (a na disk) víc zřejmý.

Používání operátorů zjednodušuje koncept serializátoru, ale zhoršuje přehlednost, zvlášť pro programátory, kteří to vidí prvně.

if (+a) {
size_t sz = strlen(text) + 1;
a(sz);
...
} else if (-a) {
size_t sz;
a(sz);
delete [] text;
text = new char[sz]
...
}

je ekvivalentní porovnat s:

if (a.storing()) {
size_t sz = strlen(text) + 1;
a(sz);
...
} else if (a.loading()) {
size_t sz;
a(sz);
delete [] text;
text = new char[sz]
...
}

Oba směry by se měly testovat současně. I když by serializátor neměl nikdy vrátit na oba dotazy stejnou hodnotu, víc to zpřehlědňuje zápis kódu.

Implementace směru formátovačem

Protože o tom, zda se načítá nebo ukládá rozhoduje formátovač / parse, serializátor pouze přeposílá tyto dotazy do formátovače. Nicméně činí tak pouze přes jednu jedinou metodu a to isStoring(). Pokud metoda vrátí false, jedná se parser, pokud true, jedná se formátovač.

void Serializer<A,B>::operator+() {
return formatter.isStoring();
}
void Serializer<A,B>::operator-() {
return !formatter.isStoring();
}

Příště

V pátem díle seriálu o serializaci se dozvíme, že formátovač nemá povinnost vždy ukládat názvy proměnných. Ze základního konceptu serializátoru nám zbývá serializace proměnných s explicitně specifikovanou výchozí hodnotou. Nakonec si provedeme celkové shrnutí prvních pěti dílů.

Tím to ale nekončí, v šestém díle se zaměříme na zajišťování kompatibility uložených dat při rozšiřování formátu a přidávání proměnných do serializačního předpisu (zpětná kompatibilita). Těšit se můžete na řešení problémů se serializací polymofrních tříd (serializace potomka pokud máme k dispozici pouze ukazatel na předka) a s tím spojený problém deserializace (jak rekonstruovat potomka, když máme k dispozici pouze ukazatel na předka). Ze zajímavých témat bych ještě neopomenul serializaci grafů, zejména serializaci DAG (Directed Acyclic Graph), ale i serializaci obecných (takže i cyklických) grafů.

vytvořeno: 23.9.2008 11:36:43, změněno: 23.9.2008 11:36:43
Jsou informace v článku pro Vás užitečné?
  • (5)
  • (0)
  • (0)
  • (0)
  • (0)

Podobné články

Serializace dat a objektů

Následující článek je úvodem do další série o generickém programování (šablony), nyní se zaměříme na problém perzistentního ukládání dat nebo jejich transport, obecně o serializaci a deserializaci dat

Multiple Interface a Instance Factory

Používáme-li v C++ dědičnost a polymorfizmus, často řešíme problém, jak psát operace nad objekty, jejiž implementace se liší podle typu potomka, bez toho aniž bysme zasáhli do Base třídy.

Serializace dat a objektů II - Návrh serializátoru

Po teoretickém úvodu o serializaci se podíváme (ještě stále teoreticky) na vlastní serializátor. Zatím si uděláme takovou jednoduchou analýzu

Serializace dat a objektů III - Volba formátu

V našem seriálu o serializaci si tentokrát ukážeme, jakým způsobem budeme volit formát výsledného streamu. Nepůjde o konkrétní formáty, ale o způsoby, jak různé formáty implementovat

Úvod do obecného OOP 2. díl

V tomto díle prozkoumáme život objektů
Reklama: