Serialisierung von Objekten in .NET

Veröffentlicht: 15. Dez 2001 | Aktualisiert: 14. Jun 2004
Von Marcus Heege

Wie in jeder Remoting Architektur ist die Serialisierung von Objekten ein besonders wichtiger Aspekt. In diesem Artikel erfahren Sie, wie das .NET Objektmodell die Serialisierung von Objekten ermöglicht und wie Sie diese Möglichkeiten in Ihrer Anwendung sinnvoll einsetzen können

* * *

Auf dieser Seite

Persistenz in COM Persistenz in COM
Serialisierung mit Format Serialisierung mit Format
Alles nur geklaut? Alles nur geklaut?
Serialisierbare Objekte in .NET Serialisierbare Objekte in .NET
Custom Serialization Custom Serialization
Formatter Formatter
Streams Streams
Objektidentitäten Objektidentitäten
Probleme bei der Deserialisierung Probleme bei der Deserialisierung
Fazit Fazit

Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:

Bild01



Serialisierung ist die Fähigkeit, den Status von Objekten in eine Folge von Bytes zu abzubilden. Dies kann zum Beispiel dazu verwendet werden, um Objekte über die Lebensdauer einer laufenden Anwendung hinaus zu erhalten. Dabei wird die Bytefolge gespeichert und beim nächsten Start der Anwendung aus der Bytefolge eine neue Instanz mit dem gleichen Zustand erzeugt.

Jedes Objektmodell, das Remote-Aufrufe unterstützen will ist auf Möglichkeiten der Serialisierung von Objekten angewiesen. Ohne einen Weg, Daten zu serialisieren kann der Client keine Informationen erhalten, wie der Proxy auszusehen hat und es können keine Parameter eines Methodenaufrufs in andere Adressräume übertragen werden.

Im Gegensatz zu dem Objektmodell von C++ bietet das .NET Objektmodell einen eigenen Mechanismus zur Serialisierung von Objekthierarchien an. Dieser Mechanismus ist mehr als ein Nice-to-have-Feature, er ist integraler Bestandteil von .NET.

Auch COM bietet ein Objektmodell, das Remote Aufrufe unterstützt. Die Serialisierung in COM und .NET ist jedoch sehr unterschiedlich.

Persistenz in COM

In der COM-Welt gibt es zwei Parteien, die für die Persistenz von Objekten zuständig sind: das Objekt und das Medium. COM-Objekte, die serialisiert werden können, unterstützen das Interface IPersistStream. Das Medium, in das serialisiert wird, muss die Schnittstelle IStream anbieten. Es gibt auch noch andere Paare, die für Persistenz verwendet werden können, darunter IPersistStorage / IStorage und IPersistPropertyBag / IpropertyBag. Auf diese will ich hier aber nicht eingehen. Die Schnittstelle IPersistStream, die vom Objekt implementiert werden muss, enthält unter anderem zwei Methoden Load und Save. Die Methode Save erhält eine IStream-Referenz als Argument, mit deren Hilfe der Status des Objektes gespeichert werden kann. Wie im Bild B1 zu erkennen ist, kann dazu über die IStream-Referenz die Methode Write aufgerufen werden.

Die IPersistStream-Implementierung legt also fest, welcher Status serialisiert werden soll, und in welchem Format dies erfolgt. Wenn ein Objekt deserialisiert werden soll, wird eine neue Instanz des Objektes erzeugt. Um den Zustand der Instanz wieder herzustellen wird über IPersistStream die Methode Load aufgerufen. Auch diese erhält eine IStream Referenz als Parameter. Load kann über diese IStream Referenz die Methode Read aufrufen, um die serialisierten Daten auszulesen und den Status der Instanz entsprechend zu setzen.

Dieser Mechanismus entkoppelt das physische Speichern im Speichermedium vom logischen Speichervorgang, sprich dem Festlegen der zu speichernden Zustandsdaten des Objekts. Dadurch wird ein hohes Maß an Flexibilität erreicht: Wenn Ihr Objekt IPersistStream implementiert kann es mit jeder Implementierung von IStream verwendet werden. Kurz: Serialisierbare Objekte können überall gespeichert werden. Wenn Sie sich die Mühe machen und IStream selbst für ein Medium zu implementieren - zum Beispiel für einen FTP Server, können Sie jedes Objekt, das IPersistStream implementiert, auf Ihrem FTP Server speichern und von dort wieder herunterladen.

Serialisierung mit Format

Die Serialisierung in .NET baut dieses Verfahren der Entkopplung aus und führt noch eine weitere Abstraktion ein: den Formatter. Damit gibt es drei Parteien, die bei der Serialisierung eine Rolle spielen:

  • Der Stream legt (wie in COM) fest, wo die Daten gespeichert werden.

  • Das Objekt legt fest, welche Daten gespeichert werden.

  • Der Formatter legt fest, wie (in welchem Format) die Daten gespeichert werden.

Das Resultat ist Flexibilität pur: Wenn Sie ein serialisierbares Objekt haben, können Sie dieses nicht nur in einen beliebigen Stream - also auf ein beliebiges Medium - ablegen, sie können es auch mit einem beliebigen Formatter - also in einem beliebigen Format - speichern. Mit einem eigenen Formatter, der die Daten zum Beispiel verschlüsselt formatiert, können Sie jedes beliebige serialisierbare Objekt in jeden beliebigen Stream speichern. Auch eine eigene Implementierung eines Streams kann sich lohnen - wie das FTP-Beispiel zeigt.

Alles nur geklaut?

Diese Abstraktion ist jedoch keine neue Erfindung. Java bietet sie ebenfalls. Allen die jetzt denken, die .NET-Plattform sei nur eine Kopie von Java, sei hier gesagt, dass auch Java keinen Urheberanspruch darauf anmelden kann. Es gibt eine C++ Klassenbibliothek, die viel älter ist als Java und dieses Verfahren ebenfalls verwendet. Obwohl diese Klassenbibliothek schon von vielen als fett, legacy und mit noch schlimmeren Begriffen bedacht worden ist, gibt es unzählige, die täglich damit arbeiten - Paul DiLascia schreibt immer noch regelmäßig Artikel darüber. Für die Leute, die immer noch raten: Gemeint ist die MFC.

Klassen die von CObject abgeleitet sind, können die virtuelle Funktion Serialize überschreiben und ihre Member in ein Archiv (Klasse CArchive) ablegen. Das Archiv legt fest, in welchem Format die Daten serialisiert werden und ein CFile Objekt kann die Daten danach speichern. Leider sind die Methoden der Klasse CArchive nicht virtuell, so dass es Ihnen schwer fallen wird, eine eigene CArchive-Implementierung zu realisieren. Dennoch gilt: Ersetzen Sie Archiv durch Formatter und Datei durch Stream und Sie sind im Modell, das in Java und in .NET verwendet wird.

Serialisierbare Objekte in .NET

Nun ist es an der Zeit, serialisierbare Objekte in .NET unter die Lupe zu nehmen. Es gibt zwei Möglichkeiten, um serialisierbare .NET Objekte zu erzeugen: Mit Attributen (Basic Serialization) oder durch die Implementierung der Schnittstelle ISerializable (Custom Serialization). Fangen wir mit Basic Serialization an.

Nahezu alle Bestandteile eines Assemblies können durch Attribute näher beschrieben werden: Klassen, Methoden, Eigenschaften, Delegates und einige andere Dinge. Diese Attribute ähneln den Attributen, die wir aus IDL Dateien bereits kennen. In C# sieht Code mit Attributen folgendermaßen aus:

[Serializable] 
class MySerializableClass 
{ 
private String s; 
private double d; 
[NotSerialized] 
private long l; 
... 
}

Diese Attribute werden als Bestandteil der Metadaten in Ihr Assembly eingebrannt und können von jedem, der Ihr Assembly verwendet, über die Reflection-Architektur ausgelesen werden:

... 
using System.Reflection; 
Type t = typeof(MySerializableClass); 
if ((t.Attributes & TypeAttributes.Serializable) != 0) 
MessageBox.Show("Serializable attribute set"); 
...

Wie Sie schon vermuten, bedeutet das Attribut [Serializable], dass Instanzen der Klasse serialisiert werden können. Innerhalb Ihrer Klasse können Sie Mitgliedsvariablen mit dem Attribut [NotSerialized] versehen. Damit wird festgelegt, dass dieses Attribut nicht serialisiert wird. Derzeit (Visual Studio Beta 1) ist die Syntax für die Attribute [Serializable] und [NotSerialized] jedoch in einigen Sprachen noch nicht eindeutig festgelegt. Insbesondere das Attribut [NotSerialized] funktioniert derzeit noch nicht so, wie man es erwartet. Es scheint einfach ignoriert zu werden. Bei der Basic Serialization werden die Metadaten der Klasse ausgelesen, um die zu serialisierenden Mitgliedsvariablen zu ermitteln. Diese Metadaten sind für alle Klassen eines Assemblies vorhanden - natürlich auch für Klassen, die das Attribut [Serializable] nicht haben. Meine Versuche haben in der Tat ergeben, dass es nicht einmal notwendig ist, das Attribut [Serializable] zu verwenden. Klassen, die dieses Attribut nicht besitzen können ebenfalls serialisiert werden. Dies hängt damit zusammen, dass die mitgelieferten Formatter das Attribut [Serializable] gar nicht erst überprüfen - warum auch.

Custom Serialization

Wenn Sie auf die Daten, die serialisiert werden sollen, mehr Einfluss nehmen wollen, als dies durch die beiden Attribute möglich ist, müssen Sie Custom Serialization verwenden. Dies kann dann sinnvoll sein, wenn Sie eine redundante Mitgliedsvariable in Ihrer Klasse definieren. Nehmen wir an, Sie definieren eine Klasse Konto, in der Sie den Anfangsbestand, den aktuellen Bestand und die Liste aller Buchungen verwalten. Die Mitgliedsvariable für den aktuellen Bestand ist redundant, denn sie lässt sich aus dem Anfangsbestand und der Summe aller Buchungen berechnen. Wenn Sie Custom Serialisation verwenden wollen, müssen Sie in Ihrer Klasse die Schnittstelle ISerializable implementieren. Dieses Interface enthält nur eine Methode: GetObjectData.

interface ISerializable 
{ 
    void GetObjectData(SerializationInfo si,  
                       StreamingContext ctx); 
}

Die Methode GetObjectData wird vom Formatter aufgerufen, wenn ein Objekt serialisiert werden soll. Als Parameter wird eine Referenz auf eine Instanz von SerializationInfo übergeben. Dieses Objekt ähnelt der Property Bag, die Visual Basic-Programmierer zur Serialisierung von ActiveX Controls verwenden. Es verwaltet intern eine Abbildung von Strings auf eine Object-Referenz, also auf einen Wert beliebigen Typs. Mit der Methode AddValue können Werte eingetragen werden. Für die Deserialisierung ist keine Methode im Interface ISerializable definiert. Stattdessen müssen Sie einen weiteren Konstruktor der folgenden Form in Ihrer Klasse definieren:

private KlassenName(SerializationInfo si,  
                    StreamingContext ctx);

Ich werde diesen Konstruktor im Folgenden Deserialisierungskonstruktor nennen. Sie müssen den Deserialisierungskonstruktor nicht als privat definieren, aber sie können dies tun. Private Konstruktoren sind in der Regel nur dann sinnvoll, wenn es in der Klasse Methoden gibt, die eine Instanz erzeugen. Dies ist hier jedoch nicht der Fall. Der Deserialisierungskonstruktor wird vom .NET System - genauer gesagt von einer Klasse namens ObjectManager - aufgerufen und das System muss sich um public, protected oder private nicht kümmern. Den Parameter si des Deserialisierungskonstruktors können Sie verwenden, um den Status des Objektes auszulesen und damit die Mitgliedsvariablen zu initialisieren. Dazu können Sie die Methode GetValue aufrufen.

Diese Methode hat zwei Nachteile. Sie müssen als Parameter den Typ angeben, den Sie deserialisieren wollen und nach dem Aufruf dürfen Sie den Rückgabewert auch noch in den entsprechenden Typ konvertieren. Zum Glück gibt es für alle elementaren Datentypen entsprechende Methoden, die Ihnen die Typangabe und die Konvertierung ersparen. Statt

d = (double)si.GetValue("d", typeof(double));

können sie also auch schreiben

d = si.GetDouble("d");

Der Code in Listing L1 zeigt, wie Sie Custom Serialization implementieren können.

L1 Implementierung von Custom Serialization

using System; 
using System.Runtime.Serialization; 
pubic class MySerializableClass2 : ISerializable 
{ 
// state 
private String s; 
private double d; 
// other members, constructors, methods 
... 
// deserialization 
private MySerializableClass2(SerializationInfo si,  
                             StreamingContext ctx) 
{ 
s = (String)si.GetValue("s", typeof(String)); 
d = si.GetDouble("d"); 
} 
// serialization 
public void GetObjectData(SerializationInfo si,  
                          StreamingContext ctx) 
{ 
si.AddValue("s", s); 
si.AddValue("d", d); 
} 
}

Die Methode GetObjectData und der Deserialisierungskonstruktor erhalten noch einen weitern Parameter: den StreamingContext. Über diesen Parameter können Sie herausfinden, warum das Objekt serialisiert bzw. deserialisiert werden soll. Mögliche Alternativen sind das Clonen von Objekten, das Serialisieren in eine Datei, Remote-Aufrufe über Prozessgrenzen und Remote-Aufrufe über Maschinengrenzen. Insofern können Sie diesen StreamingContext mit dem MarshalContext (enum MSHCTX) vergleichen, der in der COM Welt an CoMarshalInterface übergeben wird.

Diesen Parameter sollten Sie in jedem Fall dann zu Rate ziehen, wenn Sie Informationen serialisieren wollen, die nur zur Laufzeit der Anwendung Bedeutung haben. Es macht keinen Sinn, solche Informationen in eine Datei zu schreiben, denn die Datei existiert auch dann noch, wenn die Anwendung bereits beendet wurde. Der StreamingContext enthält noch einen weiteren Parameter: den Kontext. Darauf werde ich in einem Folgeartikel im Zusammenhang mit kontextgebundenen Objekten noch genauer eingehen.

Formatter

Ein Formatter ist eine .NET-Klasse, die IFormatter implementiert:

interface IFormatter 
{ 
SerializationBinder Binder {get; set;} 
StreamingContext Context {get; set;} 
ISurrogateSelector SurrogateSelector {get; set;} 
void Serialize(Stream serializationStream, Object graph); 
Object Deserialize(Stream SerializationStream); 
}

Sie können dem Formatter mit der Eigenschaft Binder einen sogenannten SerializationBinder unterjubeln. Ein SerializationBinder ist eine Klasse, die festlegt, von welchem Typ die Klasse ist, die beim Deserialisieren erzeugt wird. Es ist also möglich, eine Klasse in der Version A zu serialisieren, und Sie als Version B zu deserialisieren. Außerdem können Sie über die Eigenschaft SurrogateSelector festlegen, wie Klassen zu serialieren sind, die das Interface ISerializable nicht unterstützen. Damit lässt sich also auch die Serialisierung getrennt vom Objekt implementieren. Die Standard-Implementierung des SurrogateSelectors realisiert die Basic Serialization.

In den meisten Fällen müssen Sie IFormatter nicht selbst implementieren, denn es gibt bereits zwei Formatter, mit denen Sie arbeiten können: den BinaryFormatter und den SoapFormatter. Der BinaryFormatter speichert die Daten ihres Objektes in einem binären Format ab. Über den SoapFormatter sollte ich wohl ein paar Worte verlieren.

Jeder von Ihnen wird den Begriff SOAP schon einmal gehört haben, aber nicht im Zusammenhang mit Serialisierung, sondern als HTTP/XML-basierendes Protokoll für Prozeduraufrufe über das Internet. Wie ich bereits erwähnt habe, muss jede Infrastruktur, die Prozeduraufrufe über Prozessgrenzen hinweg definiert, Serialisierung unterstützen - zum Beispiel, um die Parameter eines Aufrufs vom Stack des Aufrufers zum Stack des Aufgerufenen zu transportieren. So definiert auch SOAP genaue Regeln, in welchem XML-Format Parameter zu übertragen sind. Diese Regeln zur Serialisierung von Parametern werden vom SoapFormatter verwendet, um Objekte zu serialisieren. Wenn Sie den SoapFormatter verwenden, werden alle Daten ihres Objektes im Format eines SOAP-Datenpakets (Envelope) abgespeichert. Im Listing L2 sehen Sie, wie eine Instanz von MySerializableClass1 im SOAP-Format aussieht. Das SOAP-Format bringt Ihnen den Vorteil, dass Sie die serialisierten Daten mit einem Text- oder XML-Editor sehr einfach ansehen und bearbeiten können.

Der Preis dafür ist natürlich Speicherkapazität. Ein exakter Faktor lässt sich hier nicht angeben. Je größer das serialisierte Objekt ist, desto weniger fällt der Overhead des SOAP Protokolls ins Gewicht. Aber selbst bei großen Objekten ist häufig ein Verhältnis von 1:2 zwischen dem BinaryFormatter und dem SoapFormatter einzukalkulieren. Sowohl der BinaryFormatter, als auch der SoapFormatter können verwendet werden, um Methodenaufrufe über Prozessgrenzen hinweg durchzuführen. Auch darüber werde ich ihnen in meinem nächsten Artikel mehr erzählen.

L2 MySerializableClass1 in einem SOAP-Datenpaket

<SOAP-ENV:Envelope xmlns:xsi="http://www.w3.org/1999/ 
     XMLSchema-instance" xmlns:x 
sd="http://www.w3.org/1999/XMLSchema"  
     xmlns:SOAP-ENV="http://schemas.xmlsoap.org 
/soap/envelope/" SOAP-ENV:encodingStyle= 
     "http://schemas.xmlsoap.org/soap/encoding/" xmlns:a1="
http://schemas.microsoft.com/urt/NSAssem/Serial1/Serial1"> 
<SOAP-ENV:Body> 
<a1:MySerializableClass1 id="ref-1"> 
<s id="ref-3">SerializableTest1</s> 
<l>42</l> 
<d>3.1419267</d> 
</a1:MySerializableClass1> 
</SOAP-ENV:Body> 
</SOAP-ENV:Envelope>

Streams

Auch für Streams werden Ihnen vom .NET Framework-SDK unterschiedliche Alternativen angeboten. Der FileStream speichert Daten in eine Datei, mit dem MemoryStream können Sie Objekte in den RAM-Speicher ablegen. Der folgende Code zeigt Ihnen, wie das Objekt, der Formatter und der Stream bei der Serialisierung eingesetzt werden können:

using System.Runtime.Serialization; 
using System.Runtime.Serialization.Formatters.Soap; 
Object[] rgo =  
{ 
    new MySerializableClass1(),  
    new MySerializableClass2()  
}; 
MemoryStream strm = new MemoryStream(); 
IFormatter frm = new SoapFormatter(); 
frm.Serialize(strm, rgo);

Objektidentitäten

Wenn Sie sich den Code genauer ansehen, werden Sie erkennen, dass ein Array von Objekten serialisiert wird. Auch das ist möglich, denn ein Array ist in .NET ebenfalls ein Objekt. Als nächstes stellt sich die Frage, was passiert, wenn wir das folgende Array serialisieren:

MySerializableClass1 o = new MySerializableClass1(); 
Object[] rgo = {o, o};

Wird die Instanz von o jetzt zweimal in dem Stream abgelegt? Wenn dies so wäre, würden wir beim Deserialisieren zwei Instanzen von MySerializableClass1 erhalten. Vor dem Serialisieren könnten wir die beiden Array-Elemente auf Identität prüfen und würden true erhalten, nach dem Deserialisieren hätten wir zwei unterschiedliche Identitäten. Vor dem Serialisieren würde jede Änderung in einem Element im über das andere Element sichtbar sein, nach dem Deserialisieren nicht mehr. Schlimmer noch: Wenn Sie ein Objekt A serialisieren, welches eine Referenz auf Objekt B enthält, und Objekt B eine Referenz auf Objekt A verwaltet, würden wir bei der Serialisierung in einer unendlichen Rekursion landen.

Am Konjunktiv, den ich in den letzten Sätzen verwendet habe, können Sie erkennen, dass der Serialisierungsmechanismus von .NET dieses Problem berücksichtigt hat. Beim Serialisieren einer Objektreferenz wird die Identität des Objektes ermittelt und es wird geprüft, ob das Objekt bereits serialisiert wurde. Wenn das Objekt noch nicht serialisiert wurde, wird sowohl die Objektidentität als auch das Objekt selbst im Stream mit abgelegt. Ist das Objekt bereits serialisiert worden, wird nur die Objektidentität serialisiert.

Probleme bei der Deserialisierung

Die Reihenfolge, in der Objekte deserialisiert werden, ist dem Formatter überlassen. Wenn Sie Custom Serialization implementieren, sollten Sie dies stets im Hinterkopf haben, denn sonst können Sie beim Deserialisieren Probleme bekommen. Wenn Ihr Objekt Referenzen auf andere Objekte enthält, kann beim Deserialisieren nicht davon ausgegangen werden, dass die referenzierten Objekte bereits deserialisiert sind.

Nehmen wir noch einmal das Beispiel mit den zirkulären Referenzen: Objekt A referenziert Objekt B und Objekt B referenziert Objekt A. Beim Serialisieren werden die Objekte A und B nur einmal im Stream gespeichert. Wenn Objekt A zuerst deserialisiert wird, darf dabei nicht auf Objekt B zugegriffen werden, denn Objekt B ist noch in einem undefinierten Status. Wenn Objekt B zuerst deserialisiert wird, sind Aufrufe in das Objekt A tabu. In einem Deserialisierungskonstruktor dürfen Sie über Referenzen des Objektes also keine Methoden aufrufen oder Eigenschaften ansprechen.

Das Beispiel mag zwar etwas wirklichkeitsfremd klingen, aber bei einigen serialisierbaren Klassen des .NET Framework SDKs ist dieses Problem aufgetreten. Auch unser Beispiel mit dem Konto zeigt, dass dieses Problem in der Praxis durchaus vorkommen kann. Nehmen wir noch einmal an, sie haben eine Klasse Konto, in der sie den Anfangsbestand, den aktuellen Bestand und ein Array von Referenzen auf Objekte vom Typ Buchung speichern. Wie bereits erwähnt ist der aktuelle Bestand redundant. Sie könnten nun Custom Serialization implementieren und in GetObjectData nur den Anfangsbestand und das Array mit den Buchungen serialisieren. Beim Deserialisieren ist es nun notwendig, den aktuellen Bestand neu zu berechnen. Leider können Sie im Deserialisierungskonstruktor nicht davon ausgehen, dass die Buchungen bereits Deserialisiert sind. Möglicherweise wurde das Konto vor den Buchungen deserialisiert und die Buchungen sind noch in einem undefinierten Zustand. Hier schafft das Interface IDeserializationEventListener Abhilfe:

interface IDeserializationEventListener 
{ 
    void OnDeserialization(Object sender,  
                           DeserializationEvent dsEvent); 
}

Jedes Objekt, das deserialisiert wird, wird nach diesem Interface gefragt. Das System merkt sich alle Objekte, die IDeserializationEventListener unterstützen und ruft nach dem vollständigen Deserialisieren über all diese Objekte die Methode OnDeserialization auf. Wenn Ihr Objekt neben ISerializable also auch IDeserializationEventListener unterstützt, wird nach dem Deserialisieren aller Objekte OnDeserialization aufgerufen. In Ihrer Implementierung dieser Methode können Sie nun den aktuellen Bestand ermitteln.

Fazit

.NET bietet einen Mechanismus zur Serialisierung an, der äußerst flexibel ist und seinem Pendant in COM einiges voraus hat. Mit der Trennung von Objekt, Formatter und Stream können Sie nahezu alle Probleme lösen, die mit der Speicherung von Objekten zusammenhängen. Wenn Sie mit den vorhandenen Streams und Formattern doch einmal an die Grenzen des Möglichen stoßen sollten, ist es mit Erträglichem Aufwand möglich, eine eigene Lösung zu implementieren, ohne dafür Ihre Objekthierarchie ändern zu müssen. Mit dem SerializationBinder können Sie auch exotische Fälle der Serialisierung auf eine elegante Art und Weise in den Griff bekommen. Ein selbst geschriebener SurrogateSelector kann Ihnen in einigen Fällen sogar die Implementierung des Interfaces ISerializable in Ihren Klassen ersparen. In meinem nächsten Artikel werde ich Ihnen unter anderem zeigen, wie dieser Serialisierungsmechanismus in .NET für Remote Aufrufe verwendet wird.


Anzeigen: