In our cross-platform multi-app networked system developed mainly with Untiy engine we use custom binary serialization that simply writes primitives using BinaryWriter and then, using mirror layout, reads the same using BinaryReader. Note that we do not use BinaryFormatter and instead rely on manually specified binary layouts. It works very well for us as we have absolute control over what and how is serialized. We also go by primitive representations of objects rather then some kind of string formatting and then parsing to keep it compact. So for example we would write an integer directly rather than int.ToString(). In other words, compact over human-readable. Bare minimum to unambiguously deserialize something back to exactly what was serialized.
Recently I've bumped into NodaTime as a "better" (.NET's than DateTime, DateTimeOffset, TimeZoneInfo) solution for dealing with all things time. I especially like the strictness of types to force me think what exactly I'm talking about (clean universal time line vs zig-zag-y madness of time zones etc.).
I moved our date&time management to NodaTime but now I have troubles serializing the Noda types into binary stream as primitives. There seems to be no "standard" property accessing paradigm. Some types have getters, like Duration.TotalNanoseconds, some instead have a'la ToXyz() methods like Instant.ToUnixTicks(). By the way, this is something I suggest to simplify: getters for pure access of expectable bare-bones representation of the type and methods a'la ToXyz() for conversion calculation. This is missing, for example, for Instant type.
I did try the format/parse approach with Noda's format patters, but for some reason most provided format patterns are not roundtrip or even throw an exception that those patterns don't support parsing, only formatting:
binaryWriter.Write(ZonedDateTimePattern.ExtendedFormatOnlyIso.Format(value));
ZonedDateTimePattern.ExtendedFormatOnlyIso.Parse(binaryReader.ReadString()).Value;
What I want is something as simple as this:
binaryWriter.Write(instant.Nanoseconds);
instant = new Instant(binaryReader.ReadDouble());
binaryWriter.Write(duration.nanoseconds);
duration= new Duration(binaryReader.ReadDouble());
What one consistent, preferably non-formatting-parsing-roundtrip-based approach can I use to achieve compact binary serialization of all NodaTime types?
If that's not possible, what are recommended roundtrip patters for each NodaTime type?
PS: The text patterns in the code sample explicitly say they're not round-trip (format-only, no parsing) in their name. My bad.
We support binary serialization directly in NodaTime 1.x and 2.x, but that will be removed from 3.x.
If you want to write directly, I'd suggest creating extension methods to make this simple:
public static void WriteInstant(this BinaryWriter writer, Instant instant)
etc. That way you can write writer.WriteInstant(instant);
and isolate the precise format in a single place. Here's an untested implementation:
public static class BinaryWriterNodaTimeExtensions
{
public static void WriteInstant(this BinaryWriter writer, Instant instant) =>
writer.WriteDuration(instant - NodaConstants.UnixEpoch);
public static void WriteDuration(this BinaryWriter writer, Duration duration)
{
writer.Write(duration.Days);
writer.Write(duration.NanosecondOfDay);
// Alternative implementation if you don't need durations bigger than +/- 292 years
// writer.Write(duration.ToInt64Nanoseconds());
}
public static void WriteLocalDateTime(this BinaryWriter writer, LocalDateTime localDateTime)
{
writer.WriteLocalDate(localDateTime.Date);
writer.WriteLocalTime(localDateTime.TimeOfDay);
}
public static void WriteZonedDateTime(this BinaryWriter writer, ZonedDateTime zonedDateTime)
{
writer.WriteLocalDateTime(zonedDateTime.LocalDateTime);
// Note: no indication of the DateTimeZoneProvider. There's no standard way of representing
// that, but most applications would use the same one everywhere.
writer.Write(zonedDateTime.Zone.Id);
writer.WriteOffset(zonedDateTime.Offset);
}
public static void WriteLocalDate(this BinaryWriter writer, LocalDate localDate)
{
// Casting to byte to optimize for space, as requested in the question.
writer.Write((byte) localDate.Year);
writer.Write((byte) localDate.Month);
writer.Write((byte) localDate.Day);
// You could omit this if you'll only ever use the ISO calendar.
writer.Write(localDate.Calendar.Id);
}
public static void WriteLocalTime(this BinaryWriter writer, LocalTime localTime) =>
writer.Write(localTime.NanosecondOfDay);
public static void WriteOffset(this BinaryWriter writer, Offset offset) =>
writer.Write(offset.Seconds);
public static void WriteOffsetDateTime(this BinaryWriter writer, OffsetDateTime offsetDateTime)
{
writer.WriteLocalDateTime(offsetDateTime.LocalDateTime);
writer.WriteOffset(offsetDateTime.Offset);
}
}
That doesn't cover all the types, but it's probably the ones you're most likely to need. (Others can easily be created in a similar way.) The reader side would be similar but just in reverse. When reading the duration, you'd probably create two durations, one for days and one for nanosecond of day, and then add them together.
To comment on your API design note:
There seems to be no "standard" property accessing paradigm. Some types have getters, like Duration.TotalNanoseconds, some instead have a'la ToXyz() methods like Instant.ToUnixTicks(). By the way, this is something I suggest to simplify: getters for pure access of expectable bare-bones representation of the type and methods a'la ToXyz() for conversion calculation. This is missing, for example, for Instant type.
That's entirely deliberate. The total number of nanoseconds in a duration is inherent in it. The "number of ticks since the Unix epoch" of an Instant
(etc) is artificial due to the introduction of the Unix epoch into the matter. We happen to use the Unix epoch internally, but I didn't want that to spill out into the API design - so it's modeled as a method to give it a more explicit "conversion-like" feel. I will not be adding a Nanoseconds
property (or even UnixNanoseconds
) to Instant
.