Up-to-date Version of this question: Binary protocol variability V3.0
Summary of the problem: Parsing an incoming stream of events from a binary communication protocol, if we have some variations in devices to support and would not like to have one huge switch to include everything.
So, we use it this way:
static void Main(string[] args)
{
using (var inputStream = FakeInputDataStream())
using (var binaryReader = new BinaryReader(inputStream))
using (var tokenizer = new DeviceTokenizer(binaryReader).AsLoggable())
using (var reader = new EventReader(tokenizer))
foreach (dynamic e in reader.ReadAll())
Handle(e);
}
static void Handle(SessionStart e) =>
Console.WriteLine("SessionStart, Hardware Version = " + e.Hardware);
static void Handle(EnvironmentalReport e) =>
Console.WriteLine("EnvironmentalReport, Temperature = " + e.Temperature);
Solution looks like this:
Company.Hardware
Base event class:
public abstract class Event
{
}
And EventReader
:
public class EventReader : IDisposable
{
public EventReader(ITokenizer tokenizer)
{
Tokenizer = tokenizer;
}
public void Dispose() => Tokenizer.Dispose();
public IEnumerable<Event> ReadAll()
{
Event e = null;
while (TryRead(out e))
yield return e;
}
bool TryRead(out Event e)
{
try
{
var type = Tokenizer.Read<Type>("discriminator");
e = (Event)Activator.CreateInstance(type, Tokenizer);
return true;
}
catch (ObjectDisposedException)
{
e = null;
return false;
}
}
ITokenizer Tokenizer { get; }
}
Where:
public interface ITokenizer : IDisposable
{
T Read<T>(string token);
}
With base implementation:
public abstract class Tokenizer : ITokenizer,
IRead<Type>
{
protected Tokenizer(BinaryReader reader)
{
Reader = reader;
}
public void Dispose() => Reader.Dispose();
public T Read<T>(string token)
{
if (this is IRead<T>)
return (this as IRead<T>).Read();
else
throw new NotSupportedException();
}
Type IRead<Type>.Read() => Protocol[Reader.ReadInt32()];
protected virtual Protocol Protocol => Protocol.Undefined;
protected BinaryReader Reader { get; }
}
public interface IRead<T>
{
T Read();
}
The idea is to allow processing of additional tokens by implementing IRead<T>
on the subtype.
Protocol
class here maps discriminators to Types
and uses System.Collections.Immutable
from NuGet:
public class Protocol
{
public static readonly Protocol Undefined =
new Protocol(ImmutableDictionary<int, Type>.Empty);
Protocol(IImmutableDictionary<int, Type> types)
{
Types = types;
}
public Protocol Support<TEvent>(int discriminator)
where TEvent : Event =>
new Protocol(Types.Add(discriminator, typeof(TEvent)));
public Type this[int discriminator]
{
get
{
if (Types.ContainsKey(discriminator))
return Types[discriminator];
else
throw new NotSupportedException();
}
}
IImmutableDictionary<int, Type> Types { get; }
}
As for the logging:
public interface ILog : IDisposable
{
void Write(string line);
}
With one implementation:
class TextLog : ILog
{
public TextLog(TextWriter writer)
{
Writer = writer;
}
public void Dispose() =>
Writer.Dispose();
public void Write(string line) =>
Writer.WriteLine(line);
TextWriter Writer { get; }
}
And consumer:
class LoggingTokenizer : ITokenizer
{
public LoggingTokenizer(ITokenizer parent, ILog log)
{
Parent = parent;
Log = log;
}
public void Dispose()
{
Parent.Dispose();
Log.Dispose();
}
public T Read<T>(string name)
{
var value = Parent.Read<T>(name);
Log.Write($"{name}={value}");
return value;
}
ITokenizer Parent { get; }
ILog Log { get; }
}
We would use it through:
public static class TokenizerLogging
{
public static ITokenizer AsLoggable(this ITokenizer tokenizer) =>
tokenizer.AsLoggable(Console.Out);
public static ITokenizer AsLoggable(this ITokenizer tokenizer, TextWriter writer) =>
tokenizer.AsLoggable(new TextLog(writer));
public static ITokenizer AsLoggable(this ITokenizer tokenizer, ILog log) =>
new LoggingTokenizer(tokenizer, log);
}
Company.Hardware.Controller
Let’s say we have an updated tokenizer:
public class ControllerTokenizer : Tokenizer,
IRead<DateTime>, IRead<Version>
{
public ControllerTokenizer(BinaryReader reader)
: base(reader)
{
}
protected override Protocol Protocol => base.Protocol
.Support<SessionStart>(1);
DateTime IRead<DateTime>.Read() => new DateTime(Reader.ReadInt32());
Version IRead<Version>.Read() => new Version(Reader.ReadInt32(), Reader.ReadInt32());
}
And an event:
public class SessionStart : Event
{
public SessionStart(ITokenizer tokenizer)
: this(
tokenizer.Read<Version>("hardware-version"),
tokenizer.Read<Version>("firmware-version"),
tokenizer.Read<DateTime>("clock-time"))
{
}
public SessionStart(Version hardware, Version firmware, DateTime clock)
{
Hardware = hardware;
Firmware = firmware;
Clock = clock;
}
public Version Hardware { get; }
public Version Firmware { get; }
public DateTime Clock { get; }
}
Company.Hardware.Controller.Device
EnvironmentalReport
event:
public class EnvironmentalReport : Event
{
public EnvironmentalReport(ITokenizer tokenizer)
:this(
tokenizer.Read<float>("temperature"),
tokenizer.Read<float>("humidity"))
{
}
public EnvironmentalReport(float temperature, float humidity)
{
Temperature = temperature;
Humidity = humidity;
}
// actually, I use non-primitive types
public float Temperature { get; }
public float Humidity { get; }
}
Which requires:
public class DeviceTokenizer : ControllerTokenizer,
IRead<float>
{
public DeviceTokenizer(BinaryReader reader)
: base(reader)
{
}
protected override Protocol Protocol => base.Protocol
.Support<EnvironmentalReport>(2);
float IRead<float>.Read() => Reader.ReadSingle();
}
P.S. Parameter validation is omitted for brevity.
Big/Little endian handling:
I am planning to define this helper class:
public static class BigEndian
{
public static int ReadInt32BE(this BinaryReader reader) =>
BitConverter.ToInt32(reader.ReadValue(4), 0);
public static uint ReadUInt32BE(this BinaryReader reader) =>
BitConverter.ToUInt32(reader.ReadValue(4), 0);
public static short ReadInt16BE(this BinaryReader reader) =>
BitConverter.ToInt16(reader.ReadValue(2), 0);
public static ushort ReadUInt16BE(this BinaryReader reader) =>
BitConverter.ToUInt16(reader.ReadValue(2), 0);
public static long ReadInt64BE(this BinaryReader reader) =>
BitConverter.ToInt64(reader.ReadValue(8), 0);
static byte[] ReadValue(this BinaryReader reader, int size)
{
var bytes = reader.ReadBytes(size);
if (BitConverter.IsLittleEndian)
Array.Reverse(bytes);
return bytes;
}
}
And at the controller level of tokenizer inheritance it will look like the following:
// big-endian controller
public abstract class FreescaleTokenizer : Tokenizer,
IRead<Type>, IRead<int>, IRead<uint>, IRead<ErrorCode>
{
protected FreescaleTokenizer(BinaryReader reader)
: base(reader)
{
}
Type IRead<Type>.Read() => Protocol[Reader.ReadInt32BE()];
uint IRead<uint>.Read() => Reader.ReadUInt32BE();
int IRead<int>.Read() => Reader.ReadInt32BE();
ErrorCode IRead<ErrorCode>.Read() => (ErrorCode)Reader.ReadInt32BE();
}
public enum ErrorCode: int
{
OK = 0,
FailedGoingCanada = 1
}
Are there any potential troubles?