Работающий программист

Noda Time

Тэд Ньюард

 

Ted NewardНикогда не проводили много времени, размышляя о времени?

В самом начале своей карьеры я работал над системой, которая потом была развернута в нескольких центрах обработки звонков. Отслеживание, «когда» происходило какое-то событие, было особенно важным (это была медицинская система для центра обработки звонков от медсестер), и поэтому, не раздумывая слишком долго, мы послушно записывали время события в строку базы данных и оставляли его как есть. Правда, как мы обнаружили позднее, когда система была развернута в четырех центрах обработки звонков, каждый из которых находился в разных часовых поясах США, журналы времени оказывались не совсем правильными из-за того, что мы не догадались включить смещения часовых поясов.

Время в программных системах всегда кажется чем-то простым и прямолинейным, пока вдруг все не ломается.

Идя в ногу с тематикой моих двух последних статей (все мои статьи можно найти по ссылке bit.ly/ghMsco), еще раз подчеркну, что сообщество .NET выигрывает от работы, проделанной сообществом Java; в данном случае речь идет о пакете Noda Time, портированном в Microsoft .NET Framework проекте «Joda Time» на Java, который был разработан на замену Java-класса Date (ужасно корявому блоку кода, датировка которого уходит во времена Java 1.0). Джон Скит (Jon Skeet), автор Noda Time, основывал его на алгоритмах и концепциях Joda Time, но создал его с нуля в виде .NET-библиотеки.

И на этом преамбулу можно закончить: выполните команду «Install-Package NodaTime» (обратите внимание на отсутствие пробела между словами «Noda» и «Time») и давайте рассмотрим кое-какой код.

«Личное» время

Первым делом нужно осознать, что при всем уважении к теориям Эйнштейна нам вовсе не требуется приближаться к скорости света, чтобы понять, что время относительно. Если сейчас в Сиэтле 7 часов после полудня (p.m.) (для европейцев это 19:00), то это 7 p.m. для всех нас в Сиэтле, но 8 p.m. для моих родителей в Солт-Лейк-Сити, 9 p.m. для моего знакомого менеджера по туризму в Далласе и 10 p.m. для моего собутыльника в Бостоне. Мы все понимаем это — вот в чем магия часовых поясов. Но мой компьютер, как таковой, на самом деле ничего не знает о часовых поясах — он сообщает то время, на которое был настроен, в данном случае — 7 p.m., несмотря на тот факт, что это абсолютно одинаковый момент во времени для всех нас во всем мире. Иначе говоря, относительно не само время, а наше представление о нем. В Noda Time это представление отражается глобальным временем, указывая момент на универсальном графике, с которым все согласны. То, что мы считаем локальным временем, т. е. временем со связанным часовым поясом, в Noda Time называют зональным временем (zoned time), а локальным временем в Noda Time считают местное время без связанного часового пояса (подробности — позже).

Например, Instant (мгновение) в Noda Time относится к точке на графике глобального времени с началом координат в полночь первого января 1970 года по всемирному скоординированному времени (Coordinated Universal Time, UTC). (В этой дате нет ничего особенного, просто по соглашению оно считается началом эпохи Unix-систем, которые ведут отсчет с той даты, и тем самым служит хорошей эталонной точкой начала координат — такой же, как и любая другая дата.) Это дает нам текущий Instant (конечно, предполагая, что вы ссылаетесь на пространство имен Noda Time — выражением «using NodaTime»):

var now = SystemClock.Instance.Now;

Чтобы получить время, относительное для Сиэтла, нам требуется ZonedDateTime. Это, по сути, Instant, но с включенной информацией по часовому поясу, поэтому он идентифицируется как относительный «Сиэтлу девятого января 2013 года» (дата важна, так как нам нужно знать, находимся ли мы в условиях летнего времени [daylight saving time, DST] или нет). Мы получаем ZonedDateTime через конструктор, передавая Instant и зону DateTime. Instant у нас есть, но мы должны получить зону DateTime для Сиэтла в DST. Для этого нам нужен IDateTime zoneProvider. (Причина такой косвенности весьма тонка, но связана с тем фактом, что .NET Framework использует представление часовых поясов, отличное от таковых на любой другой платформе программирования; Internet Assigned Names Authority (IANA) применяет формат, подобный «America/Los_Angeles».) Noda Time предлагает два встроенных провайдера (один — IANA-версии, а другой — стандартной версии для библиотеки базовых классов .NET [base class library, BCL]) через статические свойства класса DateTime zoneProviders:

var seattleTZ = dtzi["America/Vancouver"];
var dtzi = DateTime zoneProviders.Tzdb;

Версия базы данных часовых поясов (time zone database, TZDB) — это IANA-версия, и поэтому получение часового пояса, представляющего Сиэтле, сводится к выбору этого пояса (а он, согласно IANA, выглядит как «America/Los_Angeles» или, если вы хотите что-то поближе, как «America/Vancouver»):

var seattleNow = new ZonedDateTime(now, seattleTZ);

И если вывести это на экран, мы получим представление в виде «Local: 1/9/2013 7:54:16 PM Offset: –08 Zone: America/Vancouver». Заметили элемент «Offset» в этом представлении? Он важен, так как хранит данные, основанные на том, какой сейчас день года (и в какой стране вы находитесь, и каким календарем вы пользуетесь, и…); в общем, смещение от UTC будет меняться. Для тех из нас, кто находится в Сиэтле, DST означает прибавку или потерю одного часа от местного времени, поэтому важно отметить, что такое смещение от UTC. По сути, Noda Time отслеживает это раздельно, потому что при разборе такой даты, как «2012-06-26T20:41:00+01:00», мы знаем, что опережаем UTC на один час, но не знаем, чем это вызвано — действием летнего времени в данном конкретном часовом поясе или по какой-то другой причине.

Все еще думаете, что время — штука простая?

«Наше» время

Теперь предположим, что мне нужно выяснить, сколько времени осталось до некоей важной даты в моей жизни, например до 25-летнего юбилея свадьбы, который будет 16 января 2018 г. (Отслеживание таких вещей достаточно важно — это вам подтвердит, как я полагаю, любой супруг, — и мне надо знать, сколько времени у меня осталось до покупки по-настоящему дорогого подарка.) И здесь Noda Time показывает себя с самой лучшей стороны, потому что умеет следить за любыми мелочами за вас.

Сначала я должен сконструировать LocalDateTime (или LocalDate, если меня не интересует время, или LocalTime, если меня не интересует дата). LocalDateTime (или LocalDate, или LocalTime) — относительная позиция на временном графике, в которой точно не известно, чему она относительна. Иначе говоря, это точка во времени с неизвестным часовым поясом.

(Несмотря на незнание часового пояса, это все же полезный фрагмент информации. Рассматривайте это так: если вы и я вместе работаем в одном офисе и мы хотим встретиться сегодня позже, я скажу: «Давайте встретимся в 4 p.m.». Так как мы оба находимся в одном часовом поясе, для однозначного определения времени нам не требуется никакой дополнительной информации.)

Поэтому, поскольку мне известна точка во времени, которая меня интересует, я могу легко сконструировать:

var twentyFifth = new LocalDate(2018, 1, 16);

А ввиду того, что я спрашиваю только о разнице между двумя датами, не заботясь о часовых поясах, мне требуется лишь элемент LocalDate элемента LocalDateTime из ZonedDateTime:

var today = seattleNow.LocalDateTime.Date;

Но здесь мы запрашиваем новый вид единицы времени: период между двумя значениями времени. (В BCL это Duration.) Noda Time представляет это другой конструкцией — Period, и, как любая должным образом представленная единица измерения, она требует наличия соответствующей единицы. Например, ответ «47» бесполезен без сопутствующей единицы вроде «47 дней», «47 часов» или «47 лет». Period предоставляет удобный метод Between для вычисления числа некий конкретных единиц времени между двумя LocalDate:

var period = Period.Between(today, twentyFifth, PeriodUnits.Days);
testContextInstance.WriteLine("Only {0} more days to shop!", period.Days);

Это сообщает мне точное число дней, но мы обычно не считаем дни для столь больших промежутков времени (1833 на момент написания этой статьи). Мы предпочитаем время в более управляемом виде, таком как «годы, месяцы, дни», что мы опять же можем попросить Noda Time взять на себя. Мы можем попросить получать Period, который содержит разбивку по годам, месяцам и дням, объединив флаги PeriodUnits логической операцией OR:

Period.Between(today, twentyFifth,
  PeriodUnits.Years | PeriodUnits.Months | PeriodUnits.Days)

Или, поскольку это довольно частый запрос, мы можем попросить давать нам Period, который содержит разбивку по годам, месяцам и дням, используя заранее сконструированный флаг с тем же именем:

Period.Between(today, twentyFifth, PeriodUnits.YearMonthDay)

(Очевидно, что у меня еще есть время, что хорошо, так как я не имею ни малейшего представления, что же мне ей подарить.)

Тестирование времени

Постоянные читатели моей рубрики знают, что я люблю писать исследовательские тесты (exploration tests) при изучении какой-либо новой библиотеки, и эта статья не будет исключением. Однако написать тесты на основе времени невозможно — особенно потому, что время имеет раздражающую привычку непрерывно идти вперед. Каждая уходящая миллисекунда отбрасывает любой ожидаемый результат, и это затрудняет (если делает вообще невозможным) написание тестов, выдающих предсказуемые результаты, которые мы могли бы проверить.

По этой причине все, что выдает значения времени (например, часы), реализует интерфейс IClock, включая SystemClock, использовавшийся мной ранее, чтобы получить Instant — «прямо сейчас» (статическое свойство Now). Если мы, например, создаем реализацию с интерфейсом IClock и передаем значение константы обратно для свойства Now (фактически это единственный член, требующий интерфейса IClock), то, поскольку остальная часть библиотеки Noda Time в основном использует Instant, чтобы распознавать этот момент во времени, мы, по сути, создаем полностью тестируемую среду, которая позволит полностью проверить тестируемую сущность. Таким образом, я могу слегка изменить свой прежний код и создать набор исследовательских тестов, показанных на рис. 1.

Рис. 1. Создание исследовательских тестов

[TestClass]
public class UnitTest1
{
  // SystemClock.Instance.Now был 13578106905161124, когда
  // я запускал его, поэтому сымитируем часы, которые
  // возвращают этот момент во времени как Now
  public class MockClock : IClock
  {
    public Instant Now
    {
      get { return new Instant(13578106905161124); }
    }
  }
  [TestMethod]
  public void TestMethod1()
  {
    IClock clock = new MockClock();// был SystemClock.Instance;
    var now = clock.Now;
    Assert.AreEqual(13578106905161124, now.Ticks);
    var dtzi = DateTime zoneProviders.Tzdb;
    var seattleTZ = dtzi["America/Vancouver"];
    Assert.AreEqual("America/Vancouver", seattleTZ.Id);
    var seattleNow = new ZonedDateTime(now, seattleTZ);
    Assert.AreEqual(1, seattleNow.Hour);
    Assert.AreEqual(38, seattleNow.Minute);
    var today = seattleNow.LocalDateTime.Date;
    var twentyFifth = new LocalDate(2018, 1, 16);
    var period = Period.Between(today, twentyFifth, PeriodUnits.Days);
    Assert.AreEqual(1832, period.Days);
  }
}

При использовании Noda Time вместо встроенных .NET-типов времени код становится гораздо более тестируемым простой заменой IClock, использовавшегося для получения Instant «прямо сейчас», на нечто контролируемое и известное.

Но постойте-ка…

В Noda Time скрыто куда больше, чем я показал здесь. Так, вы можете сравнительно легко добавлять единицы времени (дни, месяцы и т. д.) к данному времени, используя методы Plus и Minus (для которых есть перегруженные операторы, если они вам удобнее), а также класс FakeClock, предназначенный специально для тестирования кода, связанного со временем, и позволяющий, в том числе, программным способом дискретно «прокручивать» время, что упрощает тестирование кода, чувствительного к истекшему времени (например, экземпляры Windows Workflow, которые, как предполагается, должны срабатывать по истечении некоего периода времени в бездействии).

На более глубоком концептуальном уровне Noda Time также демонстрирует, как система типов в языке программирования может помочь различать слегка отличающиеся виды значений в рамках предметной области: разделяя разные виды времени (например, мгновения, местное время и даты, зональные даты и время) на дискретные и взаимозависимые типы, она помогает программисту соблюдать четкость и точность в том, как именно его код должен работать. Это может оказаться особенно важным, например, для того, чтобы отличать в коде дату рождения от дня рождения: первая отражает момент во вселенском времени, когда на свет рождается человек, а вторая является циклической датой, когда мы празднуем тот момент. (С практической точки зрения, с первой связан конкретный год, а со второй — нет.)

Скит ясно дал понять, что он никоим образом не считает свою библиотеку «законченной» и что планирует ее дальнейшее расширение и совершенствование. К счастью, Noda Time доступна для использования уже сегодня, и разработчикам следует немедленно загрузить ее через NuGet, посмотреть и примериться, где и как ее можно задействовать в их предметной области. В конце концов, время бесценно.

Удачи в кодировании!


Тэд Ньюард (Ted Neward) — консультант по архитектуре в компании Neudesic LLC. Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области C#. С ним можно связаться по адресу ted@tedneward.com, если вы заинтересованы в сотрудничестве с его группой. Также читайте его блог blogs.tedneward.com и заметки на Twitter.com/tedneward.

Выражаю благодарность за рецензирование статьи эксперту Джону Скиту (Jon Skeet).