Welcome to CSharp Labs

Reading and Writing Binary Data with IBinaryFile

Thursday, June 13, 2013

Most applications require saving user data or settings and many developers decide to use one of .NET's serialization formatters to handle the task. However, if you want to design exactly how data is stored for the utmost performance, minimalist data footprint and support versioning, encryption, compression and complex data types, the only solution is to write binary data.

To get started writing binary files, I created the IBinaryFile interface, which provides properties determining the file layout. The actual reading and writing to a stream is done through extension methods.

How it Works

The IBinaryFile interface provides several properties which determine how data should be interpreted with methods to handle reading and writing data:

    /// <summary>
    /// Provides binary file reading and writing capability for classes.
    /// </summary>
    public interface IBinaryFile
    {
        #region Properties
        /// <summary>
        /// Gets the binary header.
        /// </summary>
        byte[] MagicNumber { get; }

        /// <summary>
        /// Gets the file version.
        /// </summary>
        int? Version { get; }

        /// <summary>
        /// Gets the minimum file version that can be loaded.
        /// </summary>
        int? MinimumVersion { get; }

        /// <summary>
        /// Gets the maximum file version that can be loaded.
        /// </summary>
        int? MaximumVersion { get; }
        #endregion

        #region Methods
        /// <summary>
        /// Handles reading binary data with a file version to create an object.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryReader"/> to read data from.</param>
        /// <param name="version">The <see cref="BinaryFile"/> file version read from the underlying <see cref="Stream"/> or null if <see cref="BinaryFile.Version"/> is not implemented.</param>
        void Read(BinaryReader reader, int? version);

        /// <summary>
        /// Handles writing binary data.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryWriter"/> to write data to.</param>
        void Write(BinaryWriter writer);
        #endregion
    }

All defined properties can return null, indicating they are not included in the binary data.

Reading & Writing Data

I wanted to keep the IBinaryFile an interface and use static generic methods to create instances. Therefore, I created the IBinaryFileExtensions static class with Stream<T>.Read and IBinaryFile.Write extension methods to handle the heavy lifting:

    /// <summary>
    /// Defines extension methods to read and write <see cref="IBinaryFile"/> data.
    /// </summary>
    public static class IBinaryFileExtensions
    {
        /// <summary>
        /// Creates an object instance by reading from a <see cref="Stream"/>.
        /// </summary>
        /// <param name="stream">The <see cref="Stream"/> to read binary data from.</param>
        /// <returns>An object instance.</returns>
        /// <exception cref="InvalidFileType">The header does not match expected header.</exception>
        /// <exception cref="InvalidFileVersion">The file version is out of the range of possible versions.</exception>
        /// <exception cref="ArgumentNullException">The stream is null.</exception>
        /// <exception cref="NotSupportedException">The stream does not support reading.</exception>
        public static T Read<T>(this Stream stream) where T : class, IBinaryFile, new()
        {
            if (stream == null)
                throw new ArgumentNullException("stream");
            if (!stream.CanRead)
                throw new NotSupportedException("The stream does not support reading.");

            T obj = null;

            try
            {
                obj = new T(); //create object instance using parameterless constructor

                //initialize reader, set to leave stream open
                using (BinaryReader reader = new BinaryReader(input: stream, encoding: System.Text.Encoding.UTF8, leaveOpen: true))
                {
                    byte[] expectedMagicNumber = obj.MagicNumber; //get the expected header

                    if (expectedMagicNumber != null) //detect if a header should be read
                    {
                        byte[] actualMagicNumber = reader.ReadBytes(count: expectedMagicNumber.Length); //read the actual header

                        if (!actualMagicNumber.SequenceEqual(second: expectedMagicNumber)) //compare header
                            throw new InvalidFileType(expectedMagicNumber: expectedMagicNumber, actualMagicNumber: actualMagicNumber); //throw if invalid
                    }

                    if (obj.Version.HasValue) //detect if a version should be read
                    {
                        int version = reader.ReadInt32(); //read file version

                        //compare version:
                        if ((obj.MinimumVersion.HasValue && obj.MinimumVersion.Value > version) ||
                            (obj.MaximumVersion.HasValue && obj.MaximumVersion.Value < version))
                            throw new InvalidFileVersion<int>(version: version); //throw if invalid version

                        obj.Read(reader: reader, version: version); //read the file
                    }
                    else
                        obj.Read(reader: reader, version: null); //read the file
                }
            }
            catch //catch errors
            {
                //if IDisposable is implemented, cast and dispose reference
                IDisposable obj_cleanUp = obj as IDisposable;

                if (obj_cleanUp != null)
                    obj_cleanUp.Dispose();

                throw; //rethrow exception
            }

            return obj; //return instance
        }

        /// <summary>
        /// Writes the object data to the specified <see cref="Stream"/>.
        /// </summary>
        /// <param name="stream">The <see cref="Stream"/> to write data to.</param>
        /// <exception cref="ArgumentNullException">The stream is null.</exception>
        /// <exception cref="NotSupportedException">The stream does not support writing.</exception>
        public static void Write<T>(this T obj, Stream stream) where T : class, IBinaryFile, new()
        {
            if (stream == null)
                throw new ArgumentNullException(paramName: "stream");
            if (!stream.CanWrite)
                throw new NotSupportedException(message: "The stream does not support writing.");

            //initializes the writer, leaves the stream open after disposing
            using (BinaryWriter writer = new BinaryWriter(output: stream, encoding: System.Text.Encoding.UTF8, leaveOpen: true))
            {
                byte[] actualHeader = obj.MagicNumber; //get the header

                if (actualHeader != null) //determine if a header exists
                    writer.Write(buffer: actualHeader); //write the header

                if (obj.Version.HasValue) //determine if a version exists
                    writer.Write(value: obj.Version.Value); //write the version

                obj.Write(writer: writer); //write data
            }
        }
    }

The IBinaryFile new() constraint enables the IBinaryFileExtensions.Read method to create an instance of the generic type to access defined binary structural properties, validate data and call the IBinaryFile.Read method. Inversely, the IBinaryFileExtensions.Write method outputs binary data and calls the IBinaryFile.Write method.

Using

Create or use a class which contains data to save and implement the IBinaryFile interface. I strongly recommend returning data for all properties rather than null to properly distinguish the file type and provide strong versioning support. Here is an example implementation which contains application startup configuration settings:

    /// <summary>
    /// Provides application startup configuration settings.
    /// </summary>
    public class ApplicationStartupConfiguration : IBinaryFile
    {
        #region Application Startup Properties
        /// <summary>
        /// Gets or sets a the name the application is registered to.
        /// </summary>
        public string RegisteredName
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the last time the application was updated.
        /// </summary>
        public DateTime? LastUpdated
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets if the Welcome screen should be displayed.
        /// </summary>
        public bool DisplayWelcome
        {
            get;
            set;
        }
        #endregion

        #region IBinaryFile Implemented Properties
        /// <summary>
        /// Gets the file header.
        /// </summary>
        public byte[] MagicNumber
        {
            //create a unique byte combination to define file type
            get { return new byte[] { 0, 0, 255, 0 }; }
        }

        /// <summary>
        /// Gets the file version.
        /// </summary>
        public int? Version
        {
            get { return 1; }
        }

        /// <summary>
        /// Gets the minimum file version that can be read.
        /// </summary>
        public int? MinimumVersion
        {
            get { return 1; }
        }

        /// <summary>
        /// Gets the maximum file version that can be read.
        /// </summary>
        public int? MaximumVersion
        {
            get { return 1; }
        }
        #endregion

        #region IBinaryFile Implemented Methods
        /// <summary>
        /// Handles writing binary data.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryWriter"/> to write data to.</param>
        public void Write(BinaryWriter writer)
        {
            if (WriteConditional(RegisteredName != null, writer)) //writes a Boolean value to the stream which determines if RegisteredName is written
                writer.Write(RegisteredName); //writes the RegisteredName to the stream

            if (WriteConditional(LastUpdated.HasValue, writer)) //writes a Boolean value to the stream which determines if LastUpdated is written.
                writer.Write(LastUpdated.Value.ToBinary()); //serializes the LastUpdated property to a signed 64 bit integer and writes to the stream.

            writer.Write(DisplayWelcome); //writes the Boolean DisplayWelcome value to the stream
        }

        /// <summary>
        /// Writes the value to the writer.
        /// </summary>
        /// <param name="value">A value to write and return.</param>
        /// <param name="writer">The <see cref="BinaryWriter"/> to write a value to.</param>
        /// <returns>The value wrote to the writer.</returns>
        private bool WriteConditional(bool value, BinaryWriter writer)
        {
            //this helper function writes a Boolean value and returns the value written
            writer.Write(value);
            return value;
        }

        /// <summary>
        /// Handles reading binary data with a file version to create an object.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryReader"/> to read data from.</param>
        /// <param name="version">The <see cref="BinaryFile"/> file version read from the underlying <see cref="Stream"/> or null if <see cref="BinaryFile.Version"/> is not implemented.</param>
        public void Read(BinaryReader reader, int? version)
        {
            if (reader.ReadBoolean()) //read if RegisteredName is stored in the stream
                RegisteredName = reader.ReadString(); //reads RegisteredName value

            if (reader.ReadBoolean()) //read if LastUpdated is stored
                LastUpdated = DateTime.FromBinary(reader.ReadInt64()); //read a 64 bit integer and deserializes to a DateTime structure

            DisplayWelcome = reader.ReadBoolean(); //reads DisplayWelcome value
        }
        #endregion
    }
Versioning

Now suppose we need to include additional properties or make changes in the ApplicationStartupConfiguration class. We can update the class to support reading a new property (DisplayNotifyIcon) by incrementing the Version and MaximumVersion properties, then amending the Read method to support both file versions:

    /// <summary>
    /// Provides application startup configuration settings.
    /// </summary>
    public class ApplicationStartupConfiguration : IBinaryFile
    {
        #region Application Startup Properties
        /// <summary>
        /// Determines if the application displays a notify icon on startup.
        /// </summary>
        /// <remarks>Property added in version 2.</remarks>
        public bool DisplayNotifyIcon
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets a the name the application is registered to.
        /// </summary>
        public string RegisteredName
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the last time the application was updated.
        /// </summary>
        public DateTime? LastUpdated
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets if the Welcome screen should be displayed.
        /// </summary>
        public bool DisplayWelcome
        {
            get;
            set;
        }
        #endregion

        #region IBinaryFile Implemented Properties
        /// <summary>
        /// Gets the file header.
        /// </summary>
        public byte[] MagicNumber
        {
            //create a unique byte combination to define file type
            get { return new byte[] { 0, 0, 255, 0 }; }
        }

        /// <summary>
        /// Gets the file version.
        /// </summary>
        public int? Version
        {
            get { return 2; }
        }

        /// <summary>
        /// Gets the minimum file version that can be read.
        /// </summary>
        public int? MinimumVersion
        {
            get { return 1; }
        }

        /// <summary>
        /// Gets the maximum file version that can be read.
        /// </summary>
        public int? MaximumVersion
        {
            get { return 2; }
        }
        #endregion

        #region IBinaryFile Implemented Methods
        /// <summary>
        /// Handles writing binary data.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryWriter"/> to write data to.</param>
        public void Write(BinaryWriter writer)
        {
            if (WriteConditional(RegisteredName != null, writer)) //writes a Boolean value to the stream which determines if RegisteredName is written
                writer.Write(RegisteredName); //writes the RegisteredName to the stream

            if (WriteConditional(LastUpdated.HasValue, writer)) //writes a Boolean value to the stream which determines if LastUpdated is written.
                writer.Write(LastUpdated.Value.ToBinary()); //serializes the LastUpdated property to a signed 64 bit integer and writes to the stream.

            writer.Write(DisplayWelcome); //writes the Boolean DisplayWelcome value to the stream

            writer.Write(DisplayNotifyIcon); //added for version >=2
        }

        /// <summary>
        /// Writes the value to the writer.
        /// </summary>
        /// <param name="value">A value to write and return.</param>
        /// <param name="writer">The <see cref="BinaryWriter"/> to write a value to.</param>
        /// <returns>The value wrote to the writer.</returns>
        private bool WriteConditional(bool value, BinaryWriter writer)
        {
            //this helper function writes a Boolean value and returns the value written
            writer.Write(value);
            return value;
        }

        /// <summary>
        /// Handles reading binary data with a file version to create an object.
        /// </summary>
        /// <param name="reader">The <see cref="BinaryReader"/> to read data from.</param>
        /// <param name="version">The <see cref="BinaryFile"/> file version read from the underlying <see cref="Stream"/> or null if <see cref="BinaryFile.Version"/> is not implemented.</param>
        public void Read(BinaryReader reader, int? version)
        {
            if (reader.ReadBoolean()) //read if RegisteredName is stored in the stream
                RegisteredName = reader.ReadString(); //reads RegisteredName value

            if (reader.ReadBoolean()) //read if LastUpdated is stored
                LastUpdated = DateTime.FromBinary(reader.ReadInt64()); //read a 64 bit integer and deserializes to a DateTime structure

            DisplayWelcome = reader.ReadBoolean(); //reads DisplayWelcome value

            if (version.Value >= 2) //version >=2 includes DisplayNotifyIcon value
                DisplayNotifyIcon = reader.ReadBoolean(); //reads DisplayNotifyIcon value
            else
                DisplayNotifyIcon = false; //provide a default value to migrate
        }
        #endregion
    }

When the IBinaryFile<T>.Read method is called, the version parameter indicates the actual file's version to allow conditional binary reading. This pattern smoothly migrates data to the latest version. To read or write ApplicationStartupConfiguration data, the extension methods can be called in the following manner:

        /// <summary>
        /// Reads a file and returns a new <see cref="ApplicationStartupConfiguration"/> instance.
        /// </summary>
        /// <param name="path">The file to open and read.</param>
        /// <returns>An <see cref="ApplicationStartupConfiguration"/> instance with data read from a file.</returns>
        public ApplicationStartupConfiguration ReadFile(string path)
        {
            using (Stream stream = File.Open(path: path, mode: FileMode.Open, access: FileAccess.Read, share: FileShare.None))
                return stream.Read<ApplicationStartupConfiguration>();
        }

        /// <summary>
        /// Writes the data to a file.
        /// </summary>
        /// <param name="data">The data to write.</param>
        /// <param name="path">The file to create and write to.</param>
        public void WriteFile(ApplicationStartupConfiguration data, string path)
        {
            using (Stream stream = File.Open(path: path, mode: FileMode.Create, access: FileAccess.Write, share: FileShare.None))
                data.Write(stream);
        }
Source Code

Download IBinaryFile and Supporting Classes

Comments