Met serialization kun je heel eenvoudig een voorstelling of structuur van objecten converteren naar een stream van bytes. Deserialization is het omgekeerde proces waarmee we deze stream van bytes weer terug kunnen converteren naar de oorspronkelijke structuur van objecten. Serialization in .NET is te vergelijken met object streaming in Delphi (Win32). Streaming wordt in Delphi bijvoorbeeld gebruikt om alle componenten op een form te streamen naar een .dfm. Dit bestand wordt dan door de linker als resource meegelinked in de EXE, waarna de EXE deze resource weer gebruikt om tijdens runtime alle componenten te creëren, compleet met al zijn properties zoals je die design-time had ingesteld. In ASP.NET bijvoorbeeld wordt serialization gebruikt om de viewstate van een ASP pagina op te slaan in de pagina tussen verschillende page requests.
“Een mooi technisch voorbeeld, maar wat kan ik daar mee in de dagelijkse praktijk?” hoor ik je denken. Stel je eens voor dat de staat van een applicatie middels serialization eenvoudig kan worden opgeslagen op schijf. De volgende keer dat de applicatie gestart wordt kun je door deserialization deze staat van de applicatie weer herstellen.
Of je kunt een set van objecten, door deze te serializen, eenvoudig opslaan op het clipboard en ze dan in een andere applicatie weer gebruiken door daar de objecten middels deserialization weer te creëren.
Ook kun je van een set objecten tijdelijk een binaire kopie maken. De gebruiker kan verder in de applicatie en zo nodig aanpassingen maken en als hij deze ongedaan wil maken kun je de binaire kopie gebruiken om de voorgaande situatie weer te herstellen.
Serialization for dummies
De eenvoudigste manier van serialization in .NET is door gebruik te maken van het [Serializable] attribute.
type
[Serializable]
Article = class(TObject)
private
FTitle: string;
FDescription: string;
FBodyText: string;
procedure SetTitle(const Value: string);
procedure SetDescription(const Value: string);
procedure SetBodyText(const Value: string);
public
constructor Create;
property Title: string read FTitle
write SetTitle;
property Description: string read FDescription
write SetDescription;
property BodyText: string read FBodyText
write SetBodyText;
end;
De implementatie van deze class laten we gemakshalve achterwege, want die spreekt waarschijnlijk wel voor zich. Aan het einde van dit artikel vind je overigens instructies om de complete voorbeeldcode behorende bij dit artikel te downloaden vanaf de SDGN website.
Nu we de Article class serializable hebben gemaakt, is het tijd voor het echte werk. Serialization is echter heel eenvoudig. Wat we nodig hebben is een stream waar de binaire representatie van het object straks in moet komen te staan en een BinaryFormatter die we gaan gebruiken om het object te serializen en te deserializen met eerder genoemde stream. Een formatter is een klasse die de interface IFormatter implementeert. De IFormatter interface kent twee methodes; Serialize en Deserialize. Met Serialize schrijven we een object of een structuur van objecten naar een stream en met Deserialize lezen we de stream en herstellen we het object of een hele structuur van objecten. In dit artikel zullen we ons echter beperken tot één enkel object.
procedure TWinForm1.btnTest_Click(
sender: System.Object; e: System.EventArgs);
var
a1: Article;
a2: Article;
ms: MemoryStream;
bf: BinaryFormatter;
begin
ms := MemoryStream.Create;
bf := BinaryFormatter.Create;
a1 := Article.Create;
a1.Title := 'Title of article';
a1.Description := 'Description of article';
a1.BodyText := 'Add bodytext here...';
bf.Serialize(ms, a1);
ms.Position := 0;
a2 := bf.Deserialize(ms) as Article;
ShowArticle(a2);
end;
Bovenstaande implementatie van de method btnTest_Click is waarschijnlijk de meest eenvoudige implementatie van serialization. Maar aan de andere kant, echt veel ingewikkelder hoeft het ook niet te zijn.
Veel ingewikkelder hoeft het ook niet te zijn
Eerst creëren we een instance van het type MemoryStream en BinaryFormatter. Daarna creëren we een instance van het Article object en vullen we de properties met wat initiële waarden. Daarna gebruiken we de BinaryFormatter om het Article object te serializen naar de MemoryStream. We resetten de MemoryStream door Position op 0 te zetten en daarna kunnen we naar een tweede variabele het Article object deserializen. Met de method ShowArticle tonen we de inhoud van het deserialized Article object in een TextBox op ons form.
Door het Serializable attribute toe te voegen aan de type definitie van de class kunnen we dus op een eenvoudige manier een objecttype serializable maken. Alle data, private, protected en public, wordt dan door de formatter geserialized naar een stream.
In dit voorbeeld gebruikten we een BinaryFormatter om een object te serializen, maar het .NET framework kent nog een tweede formatter, de SoapFormatter. De SoapFormatter serializet het object niet naar een binair formaat maar naar een meer leesbaar XML formaat. Het binaire formaat is echter veel compacter en geniet daarom in de meeste gevallen de voorkeur.
Formatters hebben hele intelligente algoritmen. Zo zorgt een formatter er bijvoorbeeld voor dat een object uit een structuur van objecten slechts één keer in de stream voorkomt. Als bijvoorbeeld twee objecten in een structuur naar elkaar verwijzen, dan detecteert de formatter dit automatisch en serializet elk object maar één keer en voorkomt zo dat de formatter in een oneindige loop terecht komt. Het is mogelijk om een eigen formatter te schrijven maar dat valt buiten de scope van dit artikel.
Tijdens serialization van een structuur van objecten kan het voorkomen dat sommige objecttypen wel serializable zijn en andere weer niet. Formatters controleren niet of alle objecten in de structuur te serializen zijn. Dus het kan heel goed mogelijk zijn dat er al een aantal objecten geserialized is, voordat de formatter een object type tegenkomt dat hij niet kan serializen. Op dat moment zal een SerializationException optreden en wordt het proces afgebroken. Als dit gebeurt zal de bytestream corrupt zijn. Het resultaat van een formatter is alleen geschikt voor deserialization als het hele serialization proces foutloos is verlopen.
Het resultaat van een formatter is alleen geschikt voor deserialization als het hele serialization proces foutloos is verlopen
We hebben al gezien dat we een objecttype serializable kunnen maken door het serializable attribute toe te voegen aan de typedefinitie. Echter, het serialization attribute wordt niet automatisch overgenomen bij overerving. Bij iedere afgeleide van een class zul je opnieuw moeten aangeven dat het type serializable is. Verder is het omgekeerd zo dat als een base class niet serializable is, al zijn afgeleiden dat ook niet meer kunnen zijn. Dat is ook waarom System.Object serializable is. Als dit object niet serializable zou zijn zou geen enkel object in het .NET framework serializable zijn. Het is daarom aan te bevelen om zoveel mogelijk objecten serializable te maken.
Welke velden worden geserialized?
Tijdens serialization worden alle velden van een object, of ze nu private, public, protected of internal zijn, geserialized. We kunnen echter met een speciaal attribute aangeven dat sommige velden van een object niet geserialized moeten worden.
[Serializable]
Article = class(TObject)
private
FDescription: string;
FTitle: string;
[NonSerialized]
FBodyText: string;
In dit voorbeeld zorgen we ervoor dat de BodyText property niet meegenomen wordt tijdens serialization.
De ISerializable interface
Een andere mannier om nog meer controle te krijgen over het serializaton proces van een object is door gebruik te maken van de ISerializable interface.
type
[Serializable]
Article = class(TObject, ISerializable)
private
FDescription: string;
...
procedure SetBodyText(const Value: string);
constructor Create(info: SerializationInfo;
context: StreamingContext); overload;
public
constructor Create; overload;
procedure GetObjectData(
info: SerializationInfo;
context: StreamingContext);
property Title: string read FTitle ...
property Description: string read FDescr ...
property BodyText: string read FBodyText ... end;
Zoals je ziet is de implementatie van een ISerializable interface niet bijster ingewikkeld. Wat we moeten toevoegen is een public method GetObjectData en een tweede (private) constructor die tijdens deserialization gebruikt wordt om het object weer te creëren. Laten weer eerst eens kijken naar de GetObjectData method.
procedure Article.GetObjectData(
info: SerializationInfo;
context: StreamingContext);
begin
info.AddValue('Title', Title);
info.AddValue('Description', Description);
info.AddValue('BodyText', BodyText);
end;
Één van de parameters van de GetObjectData method is een SerializationInfo object. Dit object kunnen we gebruiken om het systeem te vertellen wat er zoal geserialized moet worden. In dit geval voegen we met info.AddValue drie items toe aan het SerializationInfo object.
Wat we dan verder nog nodig hebben om het verhaal compleet te maken is een speciale constructor voor de Article class. Omdat we hier een tweede constructor aan de article class toevoegen, moeten we aan beide constructors het overload keyword toevoegen waarmee we de compiler vertellen dat die dus meerdere overloaded constructors kan verwachten.
constructor Article.Create(
info: SerializationInfo;
context: StreamingContext);
begin
inherited Create;
Title := info.GetString('Title');
Description := info.GetString('Description');
BodyText := info.GetString('BodyText');
end;
In deze speciale constructor krijgen we weer het SerializationInfo object mee als parameter. Dit object kunnen we nu weer gebruiken om de status van het Article object te herstellen. Met de functie info.GetString lezen we alle waarden van het geserializede object weer uit de stream. In dit geval een beetje flauw, maar omdat we hier alles in code kunnen doen, zou je bepaalde properties van het object in bepaalde situaties wel of niet kunnen serializen.
Tijdens serialization wordt niet alleen de status van het object opgeslagen in de stream. Het eerste wat er in de stream terecht komt, is het exacte type van het object en informatie over de assembly waarin de class zich bevindt. Op deze manier weet het systeem precies welke class, in welke assembly, hij moet gebruiken om het object weer te creëren.
Versieverschillen
In een mogelijke applicatie gebruiken we serialization om objecten te serializen naar bijvoorbeeld een bestand op schijf. Het probleem dat zich nu voordoet is, dat als we de implementatie van de class aanpassen en er bijvoorbeeld een extra property aan toevoegen, we dit bestand niet zonder meer kunnen gebruiken om de serialized versie van het oude object te deserializen naar de nieuwe versie van dit object. Tijdens deserialization zullen we een Exception krijgen waarbij de formatter zal klagen over het ontbreken van de nieuwe property.
De System.Runtime.Serialization.SerializationBinder class maakt deserialization van objecten in een ander type heel eenvoudig. In het volgende voorbeeld zullen we een eigen SerializationBinder class definiëren waarmee we de formatter vertellen, dat hij een ander type object moet gebruiken om een bepaald type object te deserializen.
ArticleDeserializationBinder = class(
SerializationBinder)
public
function BindToType(assemblyName: string;
typeName: string): System.Type; override;
end;
De implementatie is dan als volgt:
function ArticleDeserializationBinder.BindToType(
assemblyName, typeName: string): System.Type;
begin
if typeName = 'TestClasses2.Article' then
begin
typeName := 'TestClasses2.ArticleVer2';
end;
Result := System.Type.GetType(typeName);
end;
Als we tijdens deserialization een object tegenkomen van het type 'TestClasses2.Article', gebruik dan het type 'TestClasses2.ArticleVer2' om het object te deserializen.
Nu hebben we de formatter dus duidelijk gemaakt hoe hij met de verschillende types van het object moet omgaan. Nu zitten we alleen nog met het probleem van de extra property die we in de nieuwe versie van het object geïntroduceerd hebben. Lossen we dit niet op, dan zal er nog steeds onherroepelijk een exception optreden.
Dit lossen we op door de speciale constructor van het nieuwe object aan te passen. In het nieuwe object zullen we een nieuwe property genaamd ‘Test’ van het type string introduceren. De nieuwe class voor dit object noemen we ArticleVer2.
[Serializable]
ArticleVer2 = class(Article, ISerializable)
private
FTest: string;
procedure SetTest(const Value: string);
constructor Create(info: SerializationInfo;
context: StreamingContext); overload;
public
constructor Create; overload;
procedure GetObjectData(info: SerializationInfo;
context: StreamingContext);
property Test: string read FTest write SetTest;
end;
De nieuwe class erft alle eigenschappen van Article en introduceert zijn eigen GetObjectData method die zorg zal dragen voor de serialization van de extra property.
In de nieuwe deserialization constructor voegen we de deserialization code van de nieuwe property toe.
constructor ArticleVer2.Create(
info: SerializationInfo;
context: StreamingContext);
begin
inherited Create;
FTitle := info.GetString('Title');
Description := info.GetString('Description');
BodyText := info.GetString('BodyText');
try
Test := info.GetString('Test');
except
Test := 'Test not found in SerializationInfo';
end;
end;
Om de aanroep info.GetString(‘Test’) zetten we een try except blok. Op het moment dat er voor Test geen item voorkomt in het SerializationInfo object, vangen we de exception af en schrijven we in dit voorbeeld een eigen waarde in de property om aan te geven dat de property tijdens deserialization niet gevonden kon worden.
Nu we alle classes hebben gemaakt, is het tijd voor een kleine testapplicatie. Voor deze testapplicatie maken we een WinForms applicatie met een simpel form met drie Buttons en een TextBox. Met de eerste button serializen we een Article object van het oorspronkelijke objecttype naar een filestream.
procedure TWinForm.btnSerialize1_Click(sender:
System.Object; e: System.EventArgs);
var
formatter: BinaryFormatter;
stream: FileStream;
begin
stream := FileStream.Create('test.bin',
FileMode.Create);
formatter := BinaryFormatter.Create;
formatter.Serialize(stream, FArticle1);
stream.Close();
end;
We creëren hier weer een stream, in dit geval een FileStream, omdat we de boel op gaan slaan op schijf.
Daarna creëren we een BinaryFormatter en met de Serialize method serializen we het article object FArticle1 naar een bestand op schijf. Om af te sluiten en het bestand daadwerkelijk op te slaan op schijf roepen we als laatste nog de Close method van de stream aan. Met de tweede button doen we exact hetzelfde maar dan serializen we FArticle2 naar de filestream, waarbij FArticle2 van het type ArticleVer2 is, het nieuwe type van het article object met de toegevoegde property test.
In de TWinForm_Load event handler creëren we een instance van beide article objecten. De TWinForm_Load event handler is het WinForms broertje van de OnCreate event handler die we kennen uit de VCL.
procedure TWinForm.TWinForm_Load(
sender: System.Object; e: System.EventArgs);
begin
FArticle1 := Article.Create;
FArticle1.Title := 'Article';
FArticle1.Description :=
'This is a test description';
FArticle2 := ArticleVer2.Create;
FArticle2.Title := 'ArticleVer2';
FArticle2.Description :=
'This is a test description';
FArticle2.Test := 'test';
end;
Als laatste hebben we nog de code nodig voor deserialization. Voor deserialization openen we het bestand met een FileStream. We registreren de deserialization binder bij de BinaryFormatter en met de Deserialize method van de BinaryFormatter deserializen we het object weer naar een Article object van type ArticleVer2.
procedure TWinForm.btnDeserialize_Click(sender:
System.Object; e: System.EventArgs);
var
formatter: BinaryFormatter;
stream: FileStream;
a: ArticleVer2;
begin
stream := FileStream.Create('test.bin',
FileMode.Open);
formatter := BinaryFormatter.Create;
formatter.Binder :=
ArticleDeserializationBinder.Create;
a := ArticleVer2(formatter.Deserialize(stream));
stream.Close;
DumpArticle(article);
end;
De testapplicatie is nu gereed. We starten de applicatie vanuit de Delphi IDE. Druk nu op de button Serialize1 om de eerste versie van het object te serializen naar het bestand.
Druk daarna op Deserialize en zie dat het oude objecttype zonder problemen deserialized wordt naar het nieuwe objecttype.
Met de Serialize 2 button serializen we een article object van het nieuwe object type naar het bestand, en het zal je niet verbazen dat ook dit object zonder problemen deserialized wordt.
In dit voorbeeld heb ik zowel de oude als de nieuwe versie van het object in de unit opgenomen. In een uiteindelijke applicatie is dit natuurlijk onzinnig en zul je kunnen volstaan met slechts de nieuwste versie van het object met wellicht zelfs dezelfde classname. Het enige waar je dan alleen rekening mee moet houden, is de versie van de uiteindelijke assembly waar het object zich in bevindt, maar dat is met een SerializationBinder ook allemaal op te lossen. En je moet er natuurlijk voor zorgen dat je in de speciale deserialization constructor van het nieuwe type de nieuwe properties voorziet van een try-finally-except blok, zodat deze geen Exceptions geeft wanneer het een oud object type betreft en hij properties niet kan vinden in de oorspronkelijke stream van het oude serialized object.
Voorbeeldcode
Alle voorbeeldcode behorende bij dit artikel is te downloaden vanaf de SDGN website: