Silverlight 2: Doing Data Part VII: Custom Binary Serialization

Since Silverlight has no concept of "ADO.NET", DataReaders, DataSets or SqlConnections, it needs to work with data "over the wire" via WCF and ASMX webservices, usually in the form of typed Generic Lists or ObservableCollections. This is all XML.

"Large refactorings shouldn’t sneak up on you." -- Jeremy D. Miller

The standard serializers (DataContractSerializer, XmlSerializer) provided with Silverlight create XML for over - the - wire transport. The only exception is the DataContractJSONSerializer, which produces or consumes a more compact JSON-formatted string that can be Eval-ed as legal Javascript. But in the full .NET Framework, we have the BinaryFormatter, a complex set of classes and structs (66 of them, in all) that can take any Serializable object and serialize it into a compact byte array that is normally much smaller  in size.

When you are working with Silverlight data going over the wire, one of the biggest impediments is the time it takes for a verbose string of XML to go over and often for a similarly large one to come back in the response.  Binary Serialization allows us to transmit a very compact byte array back and forth over the wire, which increases both the speed and the scalability of an application.  The few milliseconds it takes to perform the binary serialization / deserialization on a collection of "Customers" for example, is tiny compared to the bandwidth savings of being able to transmit -- and receive back -- a much smaller payload. This means our app may be able to handle more connected clients and respond faster under load.

The CustomBinarySerializer classes I've created here are based on some work by Greg Young, an MVP with whom I have corresponded over the last year or two on this and similar topics. Greg is a real "thinker" and has produced a class that ports easily to the Silverlight Framework subset.

We start out with an interface that our classes must implement, in order to be "Custom Serializable":

public interface ICustomBinarySerializable
    {
        void WriteDataTo(BinaryWriter _Writer);
        void SetDataFrom(BinaryReader _Reader);
    }
Then, you implement this interface on your class, like this:
[Serializable]
    public class Customer : ICustomBinarySerializable
    {
        private String _lastname;
        private String _firstname;
        private String _address;
        private String _city;
        private string _region;
        private string _postalCode;
        private string _homePhone;

        public Customer()
        {
        }
        public Customer(String lastName, String firstName, String address,  string city, string region, string postalCode, string homePhone)
        {
            _lastname = lastName;
            _firstname = firstName;
            _address = address;
            _city = city;
            _region = region;
            _postalCode = postalCode;
            _homePhone = homePhone;
        }

        public String LastName
        {
            get { return _lastname; }
            set { _lastname = value; }
        }
        public String FirstName
        {
            get { return _firstname; }
            set { _firstname = value; }
        }
        public String Address
        {
            get { return _address; }
            set { _address = value; }
        }

        public String City
        {
            get { return _city; }
            set { _city = value; }
        }
        
        public String Region
        {
            get { return _region; }
            set { _region = value; }
        }

        public String PostalCode
        {
            get { return _postalCode; }
            set { _postalCode = value; }
        }


        public String HomePhone
        {
            get { return _homePhone; }
            set { _homePhone = value; }
        }

        public void WriteDataTo(BinaryWriter _Writer)
        {
            _Writer.Write((string)_lastname);
            _Writer.Write((string)_firstname);
            _Writer.Write((string)_address);
            _Writer.Write((string)_region);
            _Writer.Write((string)_postalCode);
            _Writer.Write((string) _homePhone);
        }

        public void SetDataFrom(BinaryReader _Reader)
        {
            _lastname = _Reader.ReadString();
            _firstname = _Reader.ReadString();
            _address = _Reader.ReadString();
            _region = _Reader.ReadString();
            _postalCode = _Reader.ReadString();
            _homePhone = _Reader.ReadString();
        }
    }
We can also create a "CustomerList" class on which we can implement the interface:
[Serializable]
    public class CustomerList: ICustomBinarySerializable
    {
        public List Customers;

        public CustomerList()
        {
            Customers = new List();
        }

        public void WriteDataTo(BinaryWriter _Writer)
        {
            foreach(Customer c in this.Customers )
            {
                _Writer.Write(c.LastName);
                _Writer.Write(c.FirstName);
                _Writer.Write(c.Address);
                _Writer.Write(c.City );
                _Writer.Write(c.Region);
                _Writer.Write(c.PostalCode);
                _Writer.Write(c.HomePhone);

            }
        }

        public void SetDataFrom(BinaryReader _Reader)
        {
            while (_Reader.BaseStream.Position < _Reader.BaseStream.Length )
            {
                Customer c = new Customer();
                c.LastName = _Reader.ReadString();
                c.FirstName = _Reader.ReadString();
                c.Address = _Reader.ReadString();
                c.City = _Reader.ReadString();
                c.Region = _Reader.ReadString();
                c.PostalCode = _Reader.ReadString();
                c.HomePhone = _Reader.ReadString();
                this.Customers.Add(c);
            }
        }
    }
Note that when deserializing, we don't know "how many" customer objects we've got, so we can simply continue to loop reading as long as the Reader's BaseStream position is less than its length.

We then have a CustomBinaryFormatter class, whose semantics should be familiar to any developer who has worked with custom Serialization and Serialization Surrogate classes:
using System;
using System.IO;
using System.Runtime.Serialization;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CustomBinarySerializer;

namespace CustomBinarySerializer
{
   public class CustomBinaryFormatter
   {
       private readonly MemoryStream m_WriteStream;
       private readonly MemoryStream m_ReadStream;
       private readonly BinaryWriter m_Writer;
       private readonly BinaryReader m_Reader;
       private readonly Dictionary m_ByType = new Dictionary();
       private readonly Dictionary m_ById = new Dictionary();
       private readonly byte[] m_LengthBuffer = new byte[4];
       private readonly byte[] m_CopyBuffer;
     
       public CustomBinaryFormatter()
       {
           m_CopyBuffer = new byte[20000];
           m_WriteStream = new MemoryStream(10000);
           m_ReadStream = new MemoryStream(10000);
           m_Writer = new BinaryWriter(m_WriteStream);
           m_Reader = new BinaryReader(m_ReadStream);
       }

       public void Register(int _TypeId) where T:ICustomBinarySerializable
       {
           m_ById.Add(_TypeId, typeof(T));
           m_ByType.Add(typeof (T), _TypeId);
       }

       public object Deserialize(Stream serializationStream)
       {
           if(serializationStream.Read(m_LengthBuffer, 0, 4) != 4)
               throw new SerializationException("Could not read length from the stream.");
           IntToBytes length = new IntToBytes(m_LengthBuffer[0], m_LengthBuffer[1], m_LengthBuffer[2], m_LengthBuffer[3]);
           //TODO make this support partial reads from stream
           if(serializationStream.Read(m_CopyBuffer, 0, length.i32) != length.i32) 
               throw new SerializationException("Could not read " + length.ToString() + " bytes from the stream.");
           m_ReadStream.Seek(0L, SeekOrigin.Begin);
           m_ReadStream.Write(m_CopyBuffer, 0, length.i32);
           m_ReadStream.Seek(0L, SeekOrigin.Begin);
           int typeid = m_Reader.ReadInt32();
           Type t;
           if(!m_ById.TryGetValue(typeid, out t))
               throw new SerializationException("TypeId " + typeid.ToString( ) + " is not a registerred type id");
           object obj = Activator.CreateInstance(t);
           ICustomBinarySerializable deserialize = (ICustomBinarySerializable) obj;
           deserialize.SetDataFrom(m_Reader);
           if(m_ReadStream.Position != length.i32) 
               throw new SerializationException("object of type " + t + " did not read its entire buffer during deserialization. This is most likely an inbalance between the writes and the reads of the object.");
           return deserialize;
       }

       public void Serialize(Stream serializationStream, object graph)
       {
           int key;
           if (!m_ByType.TryGetValue(graph.GetType(), out key))
               throw new SerializationException(graph.GetType() + " has not been registered with the serializer");
           ICustomBinarySerializable c = (ICustomBinarySerializable) graph; //this will always work due to generic constraint on the Register
           m_WriteStream.Seek(0L, SeekOrigin.Begin);
           m_Writer.Write((int) key);
           c.WriteDataTo(m_Writer);
           IntToBytes length = new IntToBytes((int) m_WriteStream.Position);
           serializationStream.WriteByte(length.b0);
           serializationStream.WriteByte(length.b1);
           serializationStream.WriteByte(length.b2);
           serializationStream.WriteByte(length.b3);
           serializationStream.Write(m_WriteStream.GetBuffer(), 0, (int) m_WriteStream.Position);
       }
   }
}
The "IntToBytes" struct call basically takes care of writing the length of the serialized stream which follows into the first four bytes - a very common construct in creating streams of bytes that are "self - describing".

To use the formatter, we first register our type, and then serialize our object into a MemoryStream. Here is a complete code snippet showing end-to-end operations:
private void PopulateCustomers()
        {
            // create a new CustomerList collection class
            CustomerList custs = new CustomerList();
            // Create and add three new Customer objects...
            Customer c = new Customer("doodad", "daddeo", "23 street", "Orlando", "FL", "32801", "4071235645");
            Customer c2 = new Customer("doodad2", "daddeo2", "24 street", "Orlando", "FL", "32802", "4071275645");
            Customer c3 = new Customer("doodad3", "daddeo3", "27 street", "Orlando", "FL", "32803", "4071235649");
            // add our objects to the Customers List in CustomerList...
            custs.Customers.Add(c);
            custs.Customers.Add(c2);
            custs.Customers.Add(c3);
            // Register the CustomerList type with the CustomBinaryFormatter
            CustomBinarySerializer.CustomBinaryFormatter f = new CustomBinaryFormatter();
            f.Register<CustomerList>(1);
            // Create a new stream to store our stuff
            MemoryStream ms = new MemoryStream();
            // Serialize the customer list (only takes up 168 bytes for three items!)
            f.Serialize(ms, custs);
            // rewind to beginning of stream!
            ms.Seek(0, 0);
            // Deserialize it into a new CustomerList object
            CustomerList cl2 = (CustomerList)f.Deserialize(ms);
            // Bind the grid--
            Grid1.ItemsSource = cl2.Customers;
        }

A CustomerList object containing three "customers" serializes down to just 168 bytes! According to Greg's tests, serializing with the custom serializer is 1612% faster than the BinaryFormatter,  and on deserializing it is 1418% faster. On Silverlight you probably would not see quite these large numbers.

To test this over the wire, I added a WCF Service to my Silverlight application. The service has copies of the Customer and CustomerList classes, plus a full-framework version of the CustomBinarySerializer library.  We accept a Northwind database Employees table query where the user has supplied the "Where" clause of the SQL in their WCF method call.

We create a SqlDataAdapter and get a DataSet comprising the results of the SQL query. We then iterate over the DataRows, creating and adding new Customer objects to the CustomerList. Finally, we serialize the CustomerList and send the resultant byte array back to the Silverlight app, which deserializes and populates a DataGrid with the resulting CustomerList's Customers field (a Generic List of type Customer):

[OperationContract]
        public byte[] GetEmployees( string whereClause)
        {
            byte[] b = null;
            string cnString =ConfigurationManager.ConnectionStrings["connectionString"].ConnectionString;
            if(String.IsNullOrEmpty( whereClause))
                whereClause = "1=1";
            string strSQL = "SELECT * FROM EMPLOYEES WHERE " + whereClause;
            SqlDataAdapter da = new SqlDataAdapter(strSQL, cnString);
            DataSet ds = new DataSet();
            da.Fill(ds);
            DataTable dtEmps = ds.Tables[0];

            CustomerList cl = new CustomerList();

            foreach(DataRow row in dtEmps.Rows)
            {
                string lastName = row["LastName"] != System.DBNull.Value ? (string) row["LastName"] : "";
                string firstName = row["FirstName"] != System.DBNull.Value ? (string)row["FirstName"] : "";
                string address = row["Address"] != System.DBNull.Value ? (string)row["Address"] : "";
                string city = row["City"] != System.DBNull.Value ? (string)row["City"] : "";
                string region = row["Region"] != System.DBNull.Value ? (string)row["Region"] : "";
                string postalCode = row["PostalCode"] != System.DBNull.Value ? (string)row["PostalCode"] : "";
                string  homePhone = row["HomePhone"] != System.DBNull.Value ? (string)row["HomePhone"] : "";
                Customer c = new Customer( lastName, firstName,address,city,region,postalCode,homePhone);
                cl.Customers.Add(c);
            }

            CustomBinarySerializer.CustomBinaryFormatter f = new CustomBinaryFormatter();
            f.Register<CustomerList>(1);
            MemoryStream ms = new MemoryStream();
            // Serialize the customer list (only takes up 168 bytes for three items!)
            f.Serialize(ms, cl);
            // rewind to beginning of stream!
            ms.Seek(0, 0);
            b = ms.ToArray();
            return b;
        }
For larger payloads, it may even make sense to apply Zip compression before sending the bytes over the wire. To add this, see my previous article here.  In the downloadable sample, I'm just getting all Customers, and I've provided some simple timings to show the speed of both "full" (XML) and "Binary". The timings won't be of much use though, unless you deploy the service to a remote machine so that the real payload speed "over the wire" can be measured.

You can download the complete solution here (updated for Silverlight 2 RTM) for your coding pleasure.
By Peter Bromberg   Popularity  (11351 Views)