Streams, Formatters en Serialization in.net (Tutorial gebaseerd op tutorials van Richard Grimes, het MSDN en anderen) In deze tutorial ga ik in op het gebruik van Streams, Formatters en Serialization. We starten met de abstracte klasse Stream, waarop alle concrete streams gebaseerd zijn, maar ik ga eerst over enkele technieken en wat theorie voor ik het daadwerkelijk over enkele concrete stream klassen ga hebben. 1. De namespace System.IO en de abstracte klasse Stream. Om gebruik te maken van streams moeten we een referentie toevoegen naar de namespace System.IO. using System.IO; Deze namespace bevat onder andere de abstracte klasse Stream, dewelke de mogelijkheden definieert die een concrete stream klasse nodig heeft. De Stream klasse heeft properties die bepalen wat je kan doen met een bepaalde stream, informatie omtrent de stream (zoals lengte, huidige positie in de stream, etc.) en methoden om bytes of arrays van bytes te lezen en te schrijven. Volgend voorbeeld demonstreert het gebruik van een Stream object. We verkrijgen een stream (als voorbeeld) via de methoden GetOutputStream en GetInputStream om respectievelijk te schrijven naar en te lezen van een stream. //Bytes schrijven naar een stream Stream output = GetOutputStream(); byte[ ] outbuf = new byte[5]{20,21,22,23,24}; output.write(outbuf, 0, outbuf.length); output.close(); //Bytes lezen van een stream Stream input = GetInputStream(); byte[ ] inbuf = new byte[(int)instr.length]; input.read(inbuf, 0, inbuf.length); input.close(); In bovenstaande code kunnen de streams output en input om het even welk type stream zijn, bvb. een FileStream, een NetworkStream of een ander type stream. (vervolgt)
NOOT (belangrijk!!!): Omdat streams meestal gebufferd werken (bvb. FileStream), is het noodzakelijk de stream te flushen na gebruik. Dat kan via de methode Flush van een stream, maar er wordt ook automatisch een flush uitgevoerd bij het afsluiten van je stream via de methode Close. Het is dus belangrijk dat je na gebruik van een stream de methode Close aanroept (of Flush wanneer je de stream nog wenst te gebruiken). Als je dit niet doet, zal de buffer pas geflushed worden wanneer de garbage collector het object als verwijderbaar detecteert, en verwijdert uit het geheugen, maar dat kan om het even wanneer zijn, en is dus zeker geen good practice. Ook zal het bestand in gebruik blijven totdat de stream gesloten wordt (Close), waardoor het niet toegankelijk is voor verder gebruik (Tenzij anders ingesteld, bij de parameters voor je stream).
2. Bytes? Chars? Strings? Tekst schrijven naar een stream. Bytes, chars en strings zijn natuurlijk niet gelijk aan elkaar (anders zouden deze 3 verschillende datatypes niet bestaan natuurlijk). Byte Æ 1 byte (duh -). Char Æ 2 unicode bytes. StringÆ Is een object (reference type), bevat o.a. een serie unicode characters. Streams schrijven gegevens typisch weg als bytes. We moeten dus een manier hebben om onze chars te converteren naar bytes en onze strings te converteren naar een byte array. Het mag voor zich spreken dat het.net framework deze mogelijkheid aanbiedt. Er is een ASCII Encoding klasse beschikbaar via de System.Text namespace. Using System.Text;... string str = "Hello world"; byte[ ] b = new byte[str.length]; Encoding.ASCII.GetBytes(str.ToCharArray(), 0, str.length, b, 0); Een string object heeft de methode ToCharArray, dewelke een char array teruggeeft met de unicode chars van de string. Een string heeft ook een property Length, waarmee we weten uit hoeveel characters de string bestaat. Via de Encoding.ASCII klasse uit de System.Text namespace kunnen we de character array van een string omzetten naar een byte array, die we dan wel kunnen wegschrijven naar een stream. (Zie MSDN voor de methoden en overloads) Ook het omgekeerde is mogelijk natuurlijk (een byte array omzetten naar een string): byte[ ] buf = new byte[11]{72,101,108,108,111,32,119,111,114,108,100}; string str; str = System.Text.Encoding.ASCII.GetString(buf); String str zal de tekst Hello world bevatten.
3. Readers en Writers Misschien vinden jullie het al wat omslachtig om eerst conversies te moeten doen vooraleer je de gegevens kan wegschrijven Wel, dat is ook zo, maar je moet er rekening mee houden dat ik tot nu toe het lezen en schrijven van gegevens naar een stream, geïnstancieerd van de abstracte klasse Stream demonstreerde. De ontwerpers van het.net framework zorgden er echter wel voor dat er enkele andere klassen beschikbaar zijn, die de mogelijkheid geven om wel rechtstreeks strings en chars (en andere data types) weg te schrijven naar een stream. Deze klassen breiden als het ware de mogelijkheden van het werken met een stream uit. Zo zijn er de klassen BinaryReader, BinaryWriter, StreamReader en StreamWriter. Typisch nemen deze klassen een stream als parameter voor hun constructor, maar je kan bij de StreamReader en StreamWriter klassen ook een bestandspath opgeven als parameter aan de constructor, waarbij dan automatisch de stream gecreëerd wordt (BinaryReader en BinaryWriter nemen enkel een stream als parameter, geen filepath). Omdat ik het pas later in deze tutorial heb over de concrete stream klassen, geef ik hier enkel een codevoorbeeld omtrent de StreamReader en StreamWriter klassen. //Schrijven naar bestand StreamWriter sw = new StreamWriter("test.txt", false, Encoding.ASCII); sw.writeline("hello world"); sw.close(); //Terug lezen van bestand StreamReader sr = new StreamReader("test.txt", Encoding.ASCII); string input = sr.readline(); sr.close(); //Resultaat in messagebox MessageBox.Show( input ); Er zijn verscheidene constructors voor deze 2 klassen, één voor ieder zijn noden -. Bovenstaande demonstratiecode is heel simpel, en zou voor zich moeten spreken. De parameter false voor de StreamWriter constructor zet append op false voor dit object. Er wordt een string geschreven naar het bestand test.txt via een instantie van de klasse StreamWriter, en deze wordt terug uitgelezen via een instantie van de klasse StreamReader. Daarna wordt de gelezen string in een MessageBox weergegeven.
Noot: Via StreamWriter kan je naast strings ook value types (int, double, decimal, ) wegschrijven naar een stream (en via StreamReader terug uitlezen). Kijk hiervoor naar de overloaded methoden Write en Read van deze klassen.
4. Formatters Wat hebben we tot nu toe gezien? We kunnen reeds simpele gegevens (hoofdzakelijk strings en value types) wegschrijven naar en lezen van een stream. Dit is heel bruikbaar voor simpele testjes, of zelfs voor het werken met een log bestand, maar in de praktijk gebeurt het meer dat er objecten worden weggeschreven naar een stream. Eventueel zou je dit kunnen omzeilen via de methode ToString van een object, en bij het terug inlezen van een object zou je een constructor die deze string kan parsen kunnen gebruiken, maar er is een efficiëntere methode. Wat we gaan doen, wordt Object Serialization genoemd (I/O met objecten). Dit gebeurt in samenwerking met een Formatter. Object Serialization serialiseert een object naar een bytestream, en we leerden reeds dat streams gebaseerd zijn op het lezen en schrijven van bytes. Om een klasse serialiseerbaar te maken, moet je de statement [Serializable] voor de klassedefinitie plaatsen. (Als je in de MSDN informatie naleest omtrent verschillende klassen, is meestal ook vermeld of je een bepaalde klasse kan serialiseren). Een voorbeeldklasse Point : [Serializable] public class Point { private double xval; private double yval; [NonSerialized] private double len = 0; public Point(int x, int y) { xval = x; yval = y; } public double x{get{return xval;}} public double y{get{return xval;}} public double Length { get { if (len == 0) len = Math.Sqrt(x*x + y*y); return len; } } }
Door de statement Serializable weet de compiler dat desbetreffende klasse serialiseerbaar is. Let op het field [NonSerialized] private double len = 0;, wegens het statement NonSerialized zal dit field niet opgenomen worden in het geserializeerd object. Deze nonserialized fields gaan een waarde null meekrijgen bij het serializeren, afhankelijk van hun datatype, bijvoorbeeld 0 voor een integer, en null voor een reference. Laat ons nu een demonstreren hoe een object geserializeerd wordt naar een stream (we maken gebruik van bovenstaande klasse Point, stm is de stream voor volgend voorbeeld) : Point p1 = new Point(1, 2); Point p2 = new Point(3, 4); Point p3 = new Point(5, 6); BinaryFormatter bf = new BinaryFormatter(); bf.serialize(stm, p1); bf.serialize(stm, p2); bf.serialize(stm, p3); str.close(); Je kan dus gebruik maken van een BinaryFormatter object, en via zijn methode Serialize kan je je object serializeren op een stream. Omgekeerd is even simpel, je deserializeert de objecten van een stream: Point p4, p5, p6; p4 = (Point)bf.Deserialize(str); p5 = (Point)bf.Deserialize(str); p6 = (Point)bf.Deserialize(str); De methode deserialize creëert een instantie van het geserialiseerde object, waarbij alle geserialiseerde fields geïnitialiseerd worden naar de opgeslagen waarde. De nonserialised fields gaan op hun null waarde ingesteld worden. Noot: De BinaryFormatter krijgt volle toegang tot private fields, waardoor je je daar geen zorgen om hoeft te maken - Fantastisch niet!? Noot: Hoe kan deserialize nu weten welk type klasse hij moet instanciëren? Er wordt naast de objectgegevens ook wat informatie omtrent de klasse van het object weggeschreven. Dit wordt bepaald door de klasse SerializationInfo.
5. Streams Eindelijk ga ik het hebben over streams. Vergis je echter niet, de technieken beschreven bij de vorige hoofdstukjes van deze tutorial zijn heel belangrijk. Je dient die te kennen om correct te werken met streams. De Stream klasse is abstract, nu gaan we het hebben over concrete stream klassen, die afgeleid zijn van de Stream klasse. De manier waarop je een stream aanmaakt verschilt van stream klasse tot stream klasse. Met volgende code kan je bijvoorbeeld een FileStream verkrijgen: File.OpenRead("sourcefile.txt"); Noot: OpenRead is een statische methode van de klasse File, die beschikbaar is van de System.IO namespace. Deze klasse File bevat nog meer van deze heel interessante mogelijkheden, zoals File.Exissts om te checken of een bestand bestaat, enzovoort... Wederom: Zie de MSDN voor meer informatie. In samenwerking met de eerder besproken StreamReader klasse kunnen we als volgt werken: StreamReader input; input = new StreamReader( File.OpenRead("test.txt") ); En om te schrijven naar een bestand : StreamWriter write; write = new StreamWriter( File.OpenWrite("destfile.txt") ); Oefening: Nu zou je het vorige voorbeeld, waarbij we Hello World schrijven naar bestand en nadien terug uitlezen met weergave in een messagebox, zelf moeten kunnen aanpassen zodat die werkt met een FileStream. (vervolgt)
Je weet nu dat sommige methoden je een stream kunnen teruggeven (als return value). Je kan streams natuurlijk ook zelf definiëren. In volgend voorbeeld definiëren we zelf een FileStream om te lezen uit een bestand: FileStream f = new FileStream( test.txt, FileMode.Open, FileAccess.Read, FileShare.Read); Voor de verschillende overloads van de constructors van de stream klassen kijk je best in de MSDN. Verschillende types streams: Ik ga nog even heel kort in op enkele van de belangrijkste stream klassen: FileStream : Gebufferde stream om te lezen van en schrijven naar bestanden. NetworkStream : Ongebufferde stream om gegevens te verzenden tussen meerdere computers op via een netwerk. MemoryStream : Gegevens kunnen opgeslagen worden in het computergeheugen. BufferedStream : Hiermee kan je ongebufferde streams gebufferd laten werken. PS: In de volgende tutorial, namelijk networkstreams, gaan we een pong spelletje maken voor 2 spelers in een netwerk. Hierbij gaan we object serialization gebruiken op een NetworkStream om de positie van de tegenspeler (en het balletje) op het speelveld te communiceren tussen de 2 applicaties. Veel plezier. Kris.