Noda Time 1.2.0
Noda Time 1.2.0 finally came out last week, and since I promised I’d write a post about it, here’s a post about it — which I’ve also just partially self-plagiarised in order to make a post for the Noda Time blog, so apologies if you’ve read some of this already. I promise there’s new content below as well.
While the changes in Noda Time 1.1 were around making a Portable Class Library version and filling in the gaps from the first release, Noda Time 1.2 is all about serialization1 and text formatting.
On the serialization side, Noda Time now supports XML and binary serialization natively, and comes with an optional assembly (and NuGet package) to handle JSON serialization (using Json.NET).
On the text formatting side, Noda Time 1.2 now properly supports formatting
and parsing of the Duration
, OffsetDateTime
, and ZonedDateTime
types.
We also fixed a few bugs, and added a some more convenience methods —
Interval.Contains()
and ZonedDateTime.Calendar
, among others — in
response to requests we received from people using the
library2.
Finally, it apparently wouldn’t be a proper Noda Time major release without
fixing another spelling mistake in our API: we replaced Period.Millseconds
in 1.1, but managed not to spot that we’d also misspelled Era.AnnoMartyrm
,
the era used in the Coptic calendar. That’s fixed in 1.2, and I think
(hope) that we’re done now.
There’s more information about all of the above in the comprehensive
serialization section of the user guide, the pattern
documentation for the Duration
,
OffsetDateTime
, and
ZonedDateTime
types, and the 1.2.0 release notes.
You can pick up Noda Time 1.2.0 from the NuGet repository as usual, or from the links on the Noda Time home page.
That’s the summary, anyway. Below, I’m going to going into a bit more detail about XML and JSON serialization, and what kind of things you can do with the new text support.
XML serialization
Using XML serialization is pretty straightforward, and mostly works as you’d expect. Here’s a complete example demonstrating XML serialization of a Noda Time property:
using System;
using System.IO;
using System.Xml;
using System.Xml.Serialization;
using NodaTime;
public class Person
{
public string Name { get; set; }
public LocalDate BirthDate { get; set; }
}
static class Program
{
static void Main(string[] args)
{
var person = new Person {
Name = "David",
BirthDate = new LocalDate(1979, 3, 22)
};
var x = new XmlSerializer(person.GetType());
var namespaces = new XmlSerializerNamespaces(
new XmlQualifiedName[] { new XmlQualifiedName("", "urn:") } );
var output = new StringWriter();
x.Serialize(output, person, namespaces);
Console.WriteLine(output);
}
}
As you can see, there’s nothing special here, and the output is also as you’d expect:
<?xml version="1.0" encoding="utf-8"?>
<Person>
<Name>David</Name>
<BirthDate>1979-03-22</BirthDate>
</Person>
There are a couple of caveats to be aware of regarding XML serialization,
though, most notably that the Period
type requires special handling.
Period
is an immutable reference type, which XmlSerializer
doesn’t
really support, and so you’ll need to serialize via a proxy PeriodBuilder
property instead.
The other notable issue (which also applies to binary serialization) is that
.NET doesn’t provide any way to provide contextual configuration, and so
when deserializing a ZonedDateTime
, we need a way to find out which time
zone provider to use.
By default, we’ll use the TZDB provider, but if you’re using the BCL provider (or any custom provider), you’ll need to set a static property:
DateTimeZoneProviders.Serialization = DateTimeZoneProviders.Bcl;
The serialization section in the user guide has more details about both of these issues.
There are also two other limitations of XmlSerializer
that aren’t specific
to Noda Time, but are good to know about if you’re just getting started:
- In general, types that implement
IXmlSerializable
(as the Noda Time types do) can only be serialized as elements, and so annotating your properties with theXmlAttribute
attribute won’t work (it appears that .NET will throw an exception, while Mono will instead do something strange). - Surprisingly, value types that don’t implement
IXmlSerializable
are silently serialized as empty elements and deserialized to their default values. This is unlikely to be what you want, and it’s what will happen if you accidentally run using a pre-1.2 Noda Time assembly.
JSON serialization
Noda Time’s JSON serialization makes use of Json.NET, which means that
to use it, you’ll need to add references to both the Json.NET assembly
(Newtonsoft.Json.dll
) and the Noda Time support assembly
(NodaTime.Serialization.JsonNet.dll
).
The only setup you need to do in code is to inform Json.NET how to serialize
Noda Time’s types (and again, which time zone provider to use). This can
either be done by hand, or via a ConfigureForNodaTime
extension
method. Again, the user guide has
all the details.
Once that’s done, using the serializer is straightforward:
using System;
using System.IO;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.JsonNet;
internal class Person
{
public string Name { get; set; }
public LocalDate BirthDate { get; set; }
}
static class Program
{
static void Main(string[] args)
{
var person = new Person {
Name = "David",
BirthDate = new LocalDate(1979, 3, 22)
};
var json = new JsonSerializer();
json.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
var output = new StringWriter();
json.Serialize(output, person);
Console.WriteLine(output);
}
}
Output:
{"Name":"David","BirthDate":"1979-03-22"}
Unlike the .NET XML serializer, the Json.NET serializer is significantly more configurable. The Json.NET documentation is probably a good place to start if you’re interested in doing that.
Better text support
Noda Time 1.2 adds parsing and formatting for the Duration
,
OffsetDateTime
, and ZonedDateTime
types, which previously only had
placeholder ToString()
implementations. Given a series of assignments
like the following:
var paris = DateTimeZoneProviders.Tzdb["Europe/Paris"];
ZonedDateTime zdt = SystemClock.Instance.Now.InZone(paris);
OffsetDateTime odt = zdt.ToOffsetDateTime();
Duration duration = Duration.FromSeconds(12345);
the result of calling ToString()
on each of the zdt
, odt
, and
duration
variables would produce something like the following in 1.1:
Local: 26/11/2013 19:35:28 Offset: +01 Zone: Europe/Paris
2013-11-26T19:35:28.00081+01
Duration: 123450000000 ticks
In 1.2, these types use a standard pattern by default instead: the general
invariant pattern (‘G’), for ZonedDateTime
and OffsetDateTime
, and the
round-trip pattern (‘o’) for Duration
:
2013-11-26T19:35:28 Europe/Paris (+01)
2013-11-26T19:35:28+01
0:03:25:45
More usefully, we can now use custom patterns:
var pattern = ZonedDateTimePattern.CreateWithInvariantCulture(
"dd/MM/yyyy' 'HH:mm:ss' ('z')'", null);
Console.WriteLine(pattern.Format(zdt));
which will print “26/11/2013 19:35:28 (Europe/Paris)
”.
The null
above is an optional time zone provider. If not specified, as
shown above, the resulting pattern can only be use for formatting, and not
for parsing3. This is why the standard patterns are format-only:
they don’t have a time zone provider.
If you do specify a time zone provider, however, you can parse your custom format just fine:
var pattern = ZonedDateTimePattern.CreateWithInvariantCulture(
"dd/MM/yyyy' 'HH:mm:ss' ('z')'", DateTimeZoneProviders.Tzdb);
var zdt = pattern.Parse("26/11/2013 19:35:28 (Europe/Paris)").Value;
Console.WriteLine(zdt);
which prints “2013-11-26T19:35:28 Europe/Paris (+01)
”, as you would expect.
As well as formatting the time zone ID (the “z” specified in the format string above), you can also format the time zone abbreviation (using “x”), which given the above input would produce “CET”, for Central European Time.
Now, if you’ve seen Jon’s “Humanity: Epic fail” talk — or watched
his recent presentation at DevDay Kraków, which covers some of the
same content — then you’ll already know that time zone abbreviations
aren’t unique. For that reason, if you include a time zone abbreviation
when creating a ZonedDateTimePattern
, the pattern will also be
format-only.
In addition to the time zone identifiers, both ZonedDateTime
and
OffsetDateTime
patterns accept a format specifier for the offset in
effect. This uses a slightly unusual format, as Offset
can be formatted
independently: it’s “o<…>”, where the “…” is an Offset
pattern
specifier. For example:
var pattern = ZonedDateTimePattern.CreateWithInvariantCulture(
"dd/MM/yyyy' 'HH:mm:ss' ('z o<+HH:mm>')'", null);
Console.WriteLine(pattern.Format(zdt));
which will unsurprisingly print “26/11/2013 19:35:28 (Europe/Paris
+01:00)
”.
For OffsetDateTime
, the offset is a core part of the type, while for
ZonedDateTime
, it allows for the disambiguation of otherwise-ambiguous
local times (as typically seen during a daylight saving transition).
If the offset is not included, the default behaviour for ambiguous times is to consider the input invalid. However, this can also be customised by providing the pattern with a custom resolver.
Finally, to Duration
. Duration formatting is a bit more interesting,
because we allow you to choose the granularity of reporting. For our
duration above, of 12,345 seconds, the round-trip pattern shows the number
of days, hours, minutes, seconds, and milliseconds (if non-zero), as
“0:03:25:45
”.
We can also format just the hours and minutes:
var pattern = DurationPattern.CreateWithInvariantCulture("HH:mm");
var s = pattern.Format(duration);
Console.WriteLine(s);
which prints “03:25
” — or we can choose to format just the minutes and
seconds:
var pattern = DurationPattern.CreateWithInvariantCulture("M:ss");
var s = pattern.Format(duration);
Console.WriteLine(s);
which does not print “25:45
”, but instead prints “205:45
”, reporting
the total number of minutes and a ‘partial’ number of seconds. Had we
instead used “mm:ss
” as the pattern, we would indeed have seen the former
result; the case of the format specifier determines whether a total or
partial value is used.
Once again, there’s more information on all of the above in the relevant sections of the user guide.
-
or serialisation. I apologise in advance for the spelling, but the term turns up in code all the time (e.g.
ISerializable
), and I find it makes for awkward reading to mix and match the two. ↩ -
There’s definitely a balance to be had between the Pythonesque “only one way to do it” maxim and providing so many convenience methods that they cloud the basic concepts, and I think for 1.0 we definitely tended towards the former — which isn’t that bad: it’s easy to expand an API, but hard to reduce it. Some things that were a little awkward should be easier with 1.2, though. ↩
-
The error message you’ll see is “UnparsableValueException: This pattern is only capable of formatting, not parsing.” ↩