On my current project we have quite a lot of stuff happening at configurable intervals. Some time ago someone thought it would be better to configure these intervals using human readable and very flexible values, so for instance the following values are all valid and parseable: “1.Hour”, “500.Milliseconds”, “30.Days”. The parsing is done through TimeSpanParser:
public static class TimeSpanParser { private static readonly IEnumerable<Parser> Parsers = new[] { new Parser { Pattern = @"^\d+\.[mM]illiseconds?$", Parse = @int => TimeSpan.FromMilliseconds(@int) }, new Parser { Pattern = @"^\d+\.[sS]econds?$", Parse = @int => TimeSpan.FromSeconds(@int) }, new Parser { Pattern = @"^\d+\.[mM]inutes?$", Parse = @int => TimeSpan.FromMinutes(@int) }, new Parser { Pattern = @"^\d+\.[hH]ours?$", Parse = @int => TimeSpan.FromHours(@int) }, new Parser { Pattern = @"^\d+\.[dD]ays?$", Parse = @int => TimeSpan.FromDays(@int) }, new Parser { Pattern = @"^\d+\.[wW]eeks?$", Parse = @int => TimeSpan.FromDays(@int*7) } }; public static TimeSpan ToTimeSpan(this string timeSpan) { var parser = Parsers.FirstOrDefault(p => Regex.IsMatch(timeSpan, p.Pattern)); if (parser != default(Parser)) { return parser.Parse(timeSpan.IntPart()); } TimeSpan parsed; if (TimeSpan.TryParse(timeSpan, CultureInfo.InvariantCulture, out parsed)) { return parsed; } throw new NotSupportedException($@"The timespan string '{timeSpan}' is not a supported format. Supported formats are default TimeSpan formats and the following custom formats: {Parsers.Select(p => p.Pattern).Aggregate((first, second) => $"{first}, {second}")}"); } public static TimeSpan? TryParseToTimeSpan(this string timeSpan) { try { return timeSpan.ToTimeSpan(); } catch (NotSupportedException) { return null; } } public static TimeSpan TryParseToTimeSpan(this string timeSpan, TimeSpan defaultValue) { try { return timeSpan.ToTimeSpan(); } catch (NotSupportedException) { return defaultValue; } } private static int IntPart(this string timespan) { return int.Parse(timespan.Split('.').First()); } private class Parser { public string Pattern { get; set; } public Func<int, TimeSpan> Parse { get; set; } } }
We even have extension methods on int to give a shorthand for specifying default values like this: 30.Minutes()
var someDefault = 30.Minutes(); var someOtherDefault = 24.Hours();
Here is an example extension method:
public static class TimespanExtensions { public static TimeSpan Days(this int days) { return TimeSpan.FromDays(days); } ... }
While upgrading the solution from .nat 4.6.1 to .net core 2 / .net standard 2 and rejigging the entire configuration setup from relying on ConfigurationManager / AppSettings to use the new Microsoft.Extensions.Configuration I wanted to be able to keep the human-readable Timespan specifications in our config files but have them automagically being read into our config POCOs as TimeSpan objects. After some digging and googling I found this approach which I have never even heard about before, live and learn! It all boils down to being able to decorate the default TypeConverter for TimeSpans (System.ComponentModel.TimeSpanConverter) with my own implementation. Here’s how you do it:
First make the decorator:
public class TimeSpanConverterCustom : TimeSpanConverter { public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) { if (value is string strValue) { var ret = strValue.TryParseToTimeSpan(); if (ret.HasValue) return ret.Value; } return base.ConvertFrom(context, culture, value); } }
Then make some code to replace a default TypeConverter with a custom one
public static class CustomTypeConverters { public static TypeDescriptionProvider Register<T, TC>() where TC : TypeConverter { Attribute[] attr = new Attribute[1]; TypeConverterAttribute vConv = new TypeConverterAttribute(typeof(TC)); attr[0] = vConv; return TypeDescriptor.AddAttributes(typeof(T), attr); } }
Finally register the the custom decorator on application bootstrapping:
CustomTypeConverters.Register<TimeSpan, TimeSpanConverterCustom>(); //be able to read "30.Days" as TimeSpan
Change the configuration POCOs to expect a TimeSpan input
public class ApplicationConfig { public TimeSpan SomeInterval { get; set; } = 24.Hours(); ... }
and specify the config in some appsettings.root.json
{ "SomeInterval":"36.Hours", ... }