Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Некоторые разработчики пишут код, требующий особого обращения с датой и временем — но совершенно не задумываются над этим. А другие разработчики тоже пишут такой код и очень переживают, потому что это действительно непросто.
На конференции DotNext Джон Скит поговорил о сложностях, связанных с написанием такого кода. Почему даже подход «просто храните всё в UTC» может приводить к ошибке, и пользователь может опоздать на встречу, правильно сохранённую в UTC?
А также говорил о Noda Time — созданном им инструменте для работы с датой и временем в .NET. Но доклад будет полезен, даже если вы не можете или не хотите использовать Noda Time.
Если хотите, можете посмотреть видео доклада в оригинале — запись под катом. А ещё мы подготовили для вас расшифровку. Далее — повествование от лица спикера.
Для кого и о чём мой доклад
Хотелось бы начать не совсем с даты и времени. Я стал начинать выступления подобным образом около двух лет назад на Карибской конференции, где собирался представить доклад без презентации. Организаторы мне тогда предложили просто вывести на экран мою фотографию, на что я ответил, что это ужасная идея. Людям пришлось бы терпеть меня в двойном объеме. И я попросил поставить на задний фон фразу «Будьте добрее». Я не особо раздумывал над этим решением, но с тех пор пожелание «Будьте добрее» стало одной из главных тем в моих докладах. Если это хоть чуточку поможет технической индустрии стать добрее, для меня, да и для мира в целом, наверное, это будет куда важнее того, что я смогу научить вас особенностям даты и времени, версий и прочих вещей в C#. Мне думается, что миру необходима доброта, и я уверен, что вы и без этого все очень добрые. Тем не менее, такое вот простое напоминание может повлиять на ваше решение в будущем, когда вы окажетесь в трудной ситуации и от вас потребуется проявить немного доброты.
Итак, сегодня мы поговорим о дате и времени. Почти у всех нас есть код, использующий дату и время. Я не видел ваш код, может быть, он хороший или даже отличный, но, думается мне, что у нас всех есть баги в этом коде. Эти баги могут проявляться только в течение нескольких часов в год, что значительно осложняет их поиск. Основной смысл моего доклада заключается в том, что работать с датой и временем сложно, но вполне возможно, и быть при этом уверенным в своей работе. Также важно использовать правильные инструменты, и мне хочется думать, что для .NET таким инструментом является библиотека Noda Time, которая была создана в 2008 году.
Другой причиной является то, что ещё в 2008 я выступал с докладом на StackOverflow DevDays на тему того бардака, который люди устроили с основными типами данных: строками, числами и датой и временем. Тогда я и начал говорить, что нужно использовать правильные инструменты для работы. Все это было ещё до Java 8 и прочего. В то время я сказал: «Для Java используйте Joda-Time, а для .NET используйте… а там ничего нет». И я чувствовал себя ужасно из-за того, что мне пришлось так сказать, потому что существуют проблемы с DateTime и DateTimeOffset, к которым я ещё вернусь. Мне было не по себе, что я не смог указать на правильный инструмент для .NET, и по своей неопытности я решил, что нужно попытаться как-то решить эту проблему на максимум моих возможностей.
Я не стану заявлять, что Noda Time — это лучший API, который подойдет абсолютно всем, и в нём нет багов, но я очень горд этим кодом и думаю, что в него вложил больше всего любви, скажем так. Я просто скажу, что это лучше, чем использовать DateTime и DateTimeOffset. Большая часть доклада будет посвящена причинам для этого.
Доклад будет разбит на две части. В первой мы поговорим об основных концептах, которые стоит учитывать при написании кода, использующего дату и время. Вы можете писать такой код и без Noda Time, я уверен, что у вас имеются собственные библиотеки для этого, или вам по каким-то причинам нужно пользоваться встроенной библиотекой .NET, или вы не можете использовать библиотеки с открытым исходным кодом и так далее. Тем не менее, вы сможете работать с этими концептами, возможно, потребуется их немного переработать, и если вы будете думать о дате и времени в таком ключе, то вполне преуспеете в работе над кодом.
Концепты
Я постараюсь поговорить обо всех концептах, но сначала рассмотрим самые важные из них. В Noda Time существует очень много концептов и типов, о которых я точно не смогу рассказать за один доклад. Я затрону только самые важные, а когда вы разберетесь с этими фундаментальными компонентами, всё остальное уложится у вас в голове довольно просто. Во второй части я расскажу о хороших и плохих идеях, о том, что стоит делать, что не стоит, что можно попробовать и так далее.
Момент времени
Я считаю моментом нечто, насчёт чего абсолютно все в мире, во всей Вселенной могут сойтись во мнении. И каким бы образом мы его ни представляли (а представление и концепт — это разные вещи), если я щёлкну пальцами во время телефонного разговора и если у нас связь полностью исключает какую-либо задержку и мы не берем в расчёт относительность и прочие сложные вещи, то мы можем согласиться, в какой момент времени я щёлкнул пальцами — вне зависимости от часового пояса или системы календаря.
Это очень важно, например, для временных меток в базе данных и в системе логирования, где такая метка является одинаковым для всех моментом времени. Обычно момент времени представляется как некоторая эпоха или стартовая точка, и эпохой в .NET является DateTime.MinValue со значением 00:00, 1 января 1-го года нашей эры, это без часового пояса. Чуть позднее я объясню, что не так с DateTime. Эпохой в UNIX является 00:00 UTC, 1 января 1970 года нашей эры по григорианскому календарю. Мы все находимся в этой эпохе вне зависимости от того, представляете ли вы ее как полночь 1970 года или, допустим, вы находитесь на западном побережье США, и ваш часовой пояс UTC −7, и вы решите, что, скажем, эпоха начинается 31 декабря 1969 в 17:00 по западному побережью. И поскольку данные варианты представляют один и тот же момент времени, если кто-то щёлкает пальцами в эпоху UNIX, то другой человек на западном побережье услышит этот щелчок, если я не ошибаюсь, в пять часов вечера и скажет, что это тот же момент, это UNIX-эпоха. Представлением любого момента является соглашение об использовании некой эпохи, которая не является частью значения, она фиксированная, и мы просто спрашиваем, сколько секунд или миллисекунд, или наносекунд прошло с этой эпохи.
И даже на этом основном концепте у нас возникают проблемы с секундами координации. Я бы с радостью сказал, что у меня нет времени вдаваться в детали о секундах координации, но, честно сказать, я попросту их не знаю. Это довольно сложная вещь. Я знаю, что есть различные версии измерений времени: UT0, UT1, UT2, TAI и так далее.
Хорошая новость в том, что, скорее всего, вам это и не нужно. А если всё же нужно, то, во-первых, мне очень жаль, что вам приходится с этим работать, и у вас наверняка есть сложности при работе с датой и временем. Во-вторых, когда речь заходит о дате и времени, у вас наверняка имеется гораздо больше источников информации, чем у меня, и это хорошо, поэтому продолжайте работать с секундами координации. Остальные же просто могут продолжать притворяться, что мы живем в мире без секунд координации, где понятно время, когда мы говорим, что пройдет 3 миллиона миллисекунд или иное количество с эпохи UNIX.
Календарь
Следующий концепт — календарь или система календаря. Это способ разбиения моментов времени в некоторой степени в связке с часовыми поясами на дни, недели, месяцы, года. Почти все календарные системы, которые мне известны, содержат в себе концепты месяцев и дней в этих месяцах. Также существуют другие календари с эрами.
В григорианском календаре есть разделение на нашу эру и до нашей эры, но есть и более любопытные. Так, например, в японском календаре у каждого императора своя эра, что немного усложняет прогнозирование, ведь если мы не знаем, кто будет императором через 10 лет, то и предсказать мы ничего не сможем в плане дат и времени в пределах этого календаря.
Итак, календарь — это способ разбиения времени на дни, месяцы и годы. Григорианский календарь имеет 12 месяцев в году, каждый из которых содержит 28–31 день, с правилами, знакомыми практически всем нам. Не стоит думать, что это единственная существующая система календаря. Помимо него есть исламские календари, еврейские с разными способами выражения месяца, чисел, а также интересными неожиданностями: должен ли день начинаться в полночь, как мы привыкли считать, или с заходом солнца, как считает несколько строгий наблюдательный еврейский календарь?
И тут я хотел бы акцентировать, что всё это только человеческие понятия. Компьютерам нет дела до самих календарей, которые полезны только для межчеловеческих взаимодействий. В то же время концепт момента времени универсален, и компьютер будет знать о моменте времени без отсылки на какие-либо культурные явления. Люди во всём мире могут договориться о моменте времени, но могут использовать для этого разные календари.
Местные дата и время
Ранее мы сказали, что календарь — это способ разбиения времени на дни, недели, месяцы и так далее. Местные дата и время берут начало отсюда же. Допустим, мои местные дата и время по григорианскому календарю сейчас 15:29, 15 июня 2020 года (и оно верно только для меня). А у моих знакомых в России сейчас, если они в Москве или Санкт-Петербурге, 17:29 того же числа, месяца и года. Мы говорим об одном и том же моменте времени, но, если нам известно местное время, но неизвестен часовой пояс, то никакой речи об одном и том же моменте времени уже не идёт. В некоторых случаях это совершенно нормально, и нам даже не нужно знать этого, а в других, наоборот, нам эта информация нужна. Следует помнить, что местные дата и время состоят из даты и из времени, это две раздельные сущности.
Так вот, если нам необходимо представить какую-то дату в .NET, мы используем System.DateTime. Если же нам необходимо представить какую-то дату и время в .NET, мы опять используем System.DateTime. Мы знаем, что текущее время отличается от даты. Несколько удручает, что у нас всего одна структура для этого. Дальше ещё хуже, когда получается, что System.DateTime может быть в рамках нашего местного времени, в каком-то другом часовом поясе, который мы не сообщаем, или в UTC. Такая запутанность уменьшает уверенность в работе кода.
Проблема также кроется в том, что с этой библиотекой довольно просто работать, и мы можем быть в какой-то степени уверены, что код работает на нашей машине. Но будет ли он корректно работать на чужой машине? Эта проблема довольно размыто оформлена и нечётко определена. Аналогично можно сказать и про мою библиотеку Noda Time, которая не учитывает секунды координации и в которой я размыто определяю значение некоторых вещей.
Однако это сделано намеренно и не особо влияет на работу многих людей, в то время как невозможность представить дату или время суток немного раздражает в .NET, в котором практически нет того, что в действительности является временем суток. Да, мы можем посмотреть в System.DateTime и найти .TimeOfDay, возвращающий отрезок времени. Может показаться, что этого хватит, ведь это количество времени, прошедшего с полуночи.
Вот только этого может быть недостаточно, потому что, допустим, сейчас 08:00, и может выйти так, что прошло 7, 8 или 9 часов с полуночи в зависимости от того, переходили ли в этом часовом поясе на летнее или стандартное время, или имело место иное изменение времени. И на деле выходит, что .TimeOfDay не представляет количество времени, прошедшего с полуночи.
Именно поэтому в Noda Time есть LocalTime, который является временем суток, составленным из местных даты и времени. При проектировании времени суток в Noda Time я не учитывал календарь, но учитываю его в LocalDate, который не просто знает, какой используется календарь, а также текущие день, месяц, год, но и может сказать, какая дата будет завтра. Это возможно, только если нам известен используемый календарь.
В качестве простого примера сравним юлианский и григорианский календари, которые различаются только в високосных годах. Предположим, что сейчас 2100 год, который не является високосным по григорианскому календарю, но является таковым по юлианскому, и допустим, говорим, что сейчас 28 февраля. Мы сможем сказать, какой завтра день, только если нам известен используемый календарь.
Часовой пояс
Перейдём ко всеми «горячо любимой» теме, часовым поясам. Большинство людей понимают, что это сложная тема. А те, кто не понимают, либо делают неправильно, потому что им все равно, либо они считают, что это слишком сложно и что они не смогут понять. Всем им я хочу сказать, что понять, что происходит с часовыми поясами, можно, но в процессе объяснения я могу слегка отпугнуть вас тем, как всё меняется.
Итак, часовой пояс — это регион планеты, где местное время (читай время на чьих-то часах) одинаковое во всём этом регионе. Существует изменение часового пояса, который не изменяет один часовой пояс на другой, но изменяет время на часах, поскольку произошло изменение часового пояса. То есть произошло смещение по отношению к UTC, где UTC — в какой-то степени произвольная линия на песке, с которой все могут согласиться. Иначе говоря, у нас имеется точка отсчёта. Например, текущее смещение в Великобритании UTC+1, а в Москве UTC+3. Можно это посчитать и выяснить, что Москва на два часа впереди Великобритании.
Таким образом, часовой пояс — это регион на Земле, где на часах у людей отображено одно и то же время, будь то будущее или прошлое. Даже это вводит некоторую неопределённость, поскольку я сказал про будущее, но мы-то не знаем, что произойдёт в будущем. В настоящий момент часовой пояс «Европа/Лондон» охватывает и Уэльс. И, возможно, Уэльс станет независимой страной через 10 лет и решит, что будет использовать другой часовой пояс. Великобритания по-прежнему будет переходить на летнее время, а Уэльс решит не делать этого.
Имейте в виду, что это просто случайный пример. В тот момент часовой пояс Великобритании и Уэльса должны будут отличаться, даже если в настоящий момент они одинаковые. Поскольку мы не умеем точно предсказывать будущее, мы имеем регион, где ожидаем, что время останется без изменений в будущем и таковым было в прошлом.
Но даже это «в прошлом» зависит от того, о насколько далёком прошлом мы говорим. Например, наше приложение работает с относительно недавними событиями. Предположим, в 1950-х было время, когда Великобритания и Уэльс использовали разные часовые пояса. Будет ли это иметь значение? Если наше приложение не интересуют 1950-е, то оно по-прежнему может считать, что Уэльс и Великобритания используют один часовой пояс. Но если это какое-то историческое приложение про весь XX век, тогда лучше бы представить Великобританию и Уэльс использующими разные часовые пояса.
Так, под часовым поясом мы понимаем некоторую область в мире, где часы жителей той области показывают одно и то же время для периода истории, в котором мы заинтересованы, включая будущее время, которое мы можем предсказать с определённой точностью. Уже звучит немного пространно. Часовой пояс по сути предсказывает, какой момент времени должен отображаться на часах в том или ином часовом поясе.
Примерно так это объясняется простыми словами. Если же вам нужно что-то более абстрактное, то это функция от момента времени к местным дате и времени, и это не биекция, если вы математик, потому что она не идёт в обратную сторону. Сейчас объясню почему.
Большинство часовых поясов имеют стандартное время и летнее время, когда весной переводят стрелки на час вперёд, а осенью — на час назад. В Великобритании, например, весной, где-то в конце марта, если внимательно наблюдать за часами в час ночи, они будут показывать 00:59:58, 00:59:59, а затем 02:00:00, пропустив целый час между ними. Осенью, где-то в октябре, произойдёт обратный процесс: мы увидим 01:59:58, 01:59:59, и снова 01:00:00, тем самым мы перейдём на час назад.
Допустим, всё чётко определено, когда мы пытаемся перейти от момента времени к местным дате и времени, которые также определены для любого часового пояса. Но когда мы переходим обратно — например пользователь сообщил, что он что-то сделал в 01:30 ночи такого-то числа, а нам известен его часовой пояс, то нам придётся учитывать три различных варианта.
Первый и самый распространённый — когда 01:30 такого-то числа соответствует только один момент времени, и так для всего, кроме двух исключений, существующих во многих часовых поясах.
Но может быть, пользователь солгал или недопонял по той или иной причине, потому что 01:30 было пропущено, но нам пользователь сказал, что бодрствовал в 01:30, когда на самом деле часы перескочили с 01:00 на 02:00 ввиду перехода на летнее время. Мы не знаем, какое именно время имелось в виду.
Ещё у нас есть неоднозначный вариант, когда пользователь сообщает, что посмотрел на часы в 01:30 ночи, мы в свою очередь можем спросить: «А это был первый раз, когда часы показывали 01:30, или второй?» Потому что эти две вещи произошли с разницей в час между ними, пользователь мог посмотреть на часы, увидеть 01:30, затем 01:40, а после этого внезапно 01:10, что звучит очень дико, пока мы не поймём, что был переход на стандартное время. Поясню, что мы все ещё в том же часовом поясе: меняется смещение по отношению к UTC. В общем и целом, вот что представляет собой часовой пояс.
Я бы хотел ещё поговорить о процессе определения часовых поясов. Есть два основных источника: база данных часовых поясов Windows, которая используется непосредственно в ОС Windows, и база данных Олсона или база данных часовых поясов (tzdb или IANA), которая используется во всём остальном. Часовые пояса в ней представлены в унифицированном виде («Европа/Париж», «Европа/Лондон»). Для каждого часового пояса эта БД сообщает, что например, пояс «Европа/Гернси» связан с «Европа/Лондон», то есть там также есть условные названия.
У каждого пояса есть определённые правила, которые можно передавать, но об этом нам знать не нужно. Эти правила просто сообщают, например, в 1935 у нас произошло вот это, в Великобритании было что-то странное между 1961 и 1968, а теперь у нас вот такая структура и так далее. И этот набор правил может сообщать в меру возможностей этой базы данных, сколько времени было на часах в тот или иной момент времени.
Но все меняется, и это касается не только перемен в разных часовых поясах, могут изменяться и сами правила. Речь идет не про изменение, когда мы просто переводим стрелку часов вперёд или назад, а изменяется прогноз того, какое время будут показывать ваши часы в определённый момент времени. Это связано с изменением законодательства.
Например, это происходит сейчас в ЕС, а из-за выхода Великобритании из состава Европейского союза мы точно не можем сказать, будет ли она следовать за ЕС в том плане, что в течение нескольких лет ЕС, скорее всего, прекратит переход на летнее время. Каждый часовой пояс ЕС просто будет иметь смещение по UTC, пока через, скажем, сто лет ЕС не решит вернуться к практике перехода на летнее время.
Чтобы это случилось, должен быть принят закон, и именно такой закон меняет правила часовых поясов. Вы не находитесь в другом часовом поясе, но можете думать в таком ключе. Именно такая возможность предоставляется в Noda Time, где есть, скажем версия базы данных часовых поясов под названием «2020a», которую мы можем использовать в одном месте, и есть «2020b». По этим базам мы можем предсказать, какое время будет на часах у людей в определённый момент времени в каждой версии этих двух баз данных. Я очень хотел объяснить, что прогнозы часовых поясов могут меняться без перехода в другие часовые пояса, равно как и ваше смещение по UTC может меняться без изменения часового пояса.
Длительность и период
Поговорим о длительности и периодах. Это два тесно связанных концепта. Тут я немного схитрил, поскольку кратко затронул длительность ранее, когда говорил, что представление момента времени — это обычно какая-то зафиксированная эпоха, с которой все согласны, а затем, что это количество секунд, миллисекунд или чего-то ещё, прошедших с одного момента времени до момента, который мы пытаемся описать. И этот момент, то есть, количество секунд или иной использованной единицы, и есть длительность, и она одинакова, когда бы она ни использовалась.
Так, разница между 14:35 UTC и 14:40 UTC равна разнице между 14:50 и 14:55. Эти разницы равны длительности в 5 минут. Также стоит заметить, что длительность не имеет никакого отношения к календарям, в отличие от периодов, поскольку это может быть год, месяц или даже пять секунд. Последнее это и длительность и период, поскольку это фиксированный отрезок времени, который не меняется. В то же время, если я скажу что-то в духе: «Встретимся через 2 месяца» — в зависимости от используемого календаря и текущей даты это может быть 61 день или 58 дней и так далее.
Важно различать эти два концепта. Структура TimeSpan в .NET абсолютно идентична концепции длительности, но в ней полностью отсутствует эквивалент для периода. Да, мы можем использовать DateTime.AddMonths, но у нас не получится сказать, что вот здесь у нас есть период, который будет передаваться, и равен он месяцу и трём дням.
Проблемы и мифы
Вторая часть будет сосредоточена на вещах, которые делать не следует, которые вы, возможно, делаете и которые вы могли бы делать лучше. Здесь я постараюсь быть кратким, поскольку об этом я могу говорить очень долго, что было бы не очень практично, на мой взгляд.
DateTime.Now не должно существовать
Итак, для начала в .NET у нас имеется DateTime.Now. Здесь бы я ещё хотел сделать небольшое отступление и сказать: «Не доверяйте Twitter-пользователю @blowdart свой код в вопросах даты и времени. Этого человека зовут Барри Дорранс — мой хороший друг, работающий в Microsoft, у нас есть своего рода традиция подшучивать над кодом друг друга. Я решил не нарушать традицию, потому что у него есть несколько примеров кода с использованием DateTime.Now.
Вернемся к DateTime.Now, которое является свойством, статично и конвертируется из системных часов, содержит информацию о UTC (своего рода нативное представление) и конвертирует его в местный часовой пояс. Если вы встретите такой код на стороне сервера, кричите в ужасе и сразу же исправляйте, потому что, когда наш код запущен на сервере, нам нет дела до того, в каком часовом поясе находится этот сервер. Мы можем обратиться к нему из любого часового пояса, запустить свой сайт или что угодно, и никто не должен почувствовать разницы: никого не интересует, в каком часовом поясе оперирует сервер.
Но история с пользователем полностью противоположна: это могут быть несколько часовых поясов с множеством пользователей, которые будут взаимодействовать друг с другом. Допустим, нам нужно знать эту информацию, и мы хотим использовать часовой пояс системы, что вполне рабочий вариант. Я, например, пишу несколько клиентских приложений в WPF (Windows Presentation Foundation), и когда ведется логирование, мне нужно, чтобы оно было записано с используемым системой часовым поясом, ведь именно это и ожидает увидеть пользователь.
Иначе говоря, если пользователь видит на своих часах 16:04 и появляется какая-то запись в логе, он ожидает увидеть именно 16:04, а не, скажем, 15:04. Для этого мы запоминаем фактический момент времени, и если мы записываем лог в память, чтобы потом кто бы то ни было мог отправить нам его по электронной почте, мы получим его со временем в UTC, ввиду чего не будет проблем с переводом времени назад или вперёд, а для пользователей выводится часовой пояс их системы.
Но это должен быть осознанный выбор, и требуется чётко указать, что в этот момент я хочу использовать локальный часовой пояс системы. В идеале это следует делать с тестированием, что приводит нас ко второй проблеме DateTime.Now: статичность.
Даже если наш код использует DateTime.UtcNow, что уже лучше обычной DateTime.Now в большинстве случаев, как мы протестируем, что код корректно работает, когда часовой пояс переходит вперёд или назад на час, когда меняется смещение по UTC внутри часового пояса? Это весьма сложно сделать.
В данном случае, даже если мы все ещё работаем с System.DateTime, можно ввести идею сервиса, сообщающего текущий момент времени. Я обычно называю его IClock с методом GetCurrentTimestamp или GetCurrentInstant. Он относительно простой в исполнении. Мы используем его как интерфейс, применяем простое внедрение зависимостей и простую реализацию системных часов, реализующих метод GetCurrentInstant из IClock, который возвращает значение DateTime.UtcNow.
В этом случае факт статичности уже не важен, поскольку остальной код работает с IClock. Затем можем написать свою собственную реализацию, которая будет не настоящими часами и сообщать не фактический момент времени. Мы отвечаем ей, что всё нормально, это и есть текущее время. Далее, можно дописать, скажем, перевод часов на полчаса вперёд, и тогда каждый раз, когда программа будет что-то спрашивать, она будет видеть, что часы идут вперёд. Это, наверное, мой основной совет по тому, как добавить возможность тестирования таких штук через интерфейс, сообщающий текущее время.
Следующая проблема DateTime.Now заключается в том, что это свойство. И как правило, когда мы вызываем свойство несколько раз, оно возвращает одинаковое значение. Было бы полностью бессмысленно, делай DateTime.Now так всегда. Это должен быть метод. Я допустил подобную ошибку в первой версии Noda Time, когда сделал IClock.UtcNow свойством.
Я исправил эту ошибку во второй версии, это была критическая ошибка, за которую я извинился перед пользователями. Я не извинялся за то, что разбил их между первой и второй версией, поскольку это, как мне кажется, было верным решением ввиду малого количества пользователей на тот момент. Теперь же у большего количества людей будет хорошая база кода, которая вызывает метод при необходимости рассчитать что-то. На этом закончим с нахождением текущих даты и времени.
Нам также может потребоваться внедрить стандартный часовой пояс системы, что сложно сделать даже с Noda Time. Как правило, это может быть при запуске системы. Определяется текущий часовой пояс, в Noda Time, например, поддерживается несколько провайдеров часового пояса, и все они могут определить текущий часовой пояс в провайдере. Даже если используется Windows и если необходимо, чтобы данные tzdb (IANA) были совместимы с другими системами, мы просто запрашиваем текущий часовой пояс в провайдере tzdb, и он возвращает нам нужный ответ.
Думайте!
Моя следующая рекомендация — думайте. На самом деле она не настолько высокомерна, как может показаться, но подкрепляет главную тему моего доклада: «Дата и время — вещь сложная, но посильная». Если вы просто сделаете шаг назад и подумаете, то сможете быть более успешным и писать код, в работе которого будете уверены.
Для этого, разумеется, потребуются определенные усилия. И для начала попробуйте выделить утро или другую часть дня, или, если вы на работе, попробуйте сказать начальнику: «Знаете, у меня есть некоторые проблемы с кодом в плане даты и времени, поэтому я не буду их решать утром этой пятницы, а постараюсь лучше понять эту тему, потому что я посмотрел выступление Джона Скита, в котором он о многом не смог рассказать, но я могу изучить эту тему подробнее, ведь есть много отличных источников».
Прочитайте руководство пользователя Noda Time, это уже будет хорошим подспорьем, подумайте и убедитесь, что действительно понимаете, что такое часовой пояс, что такое календарь, что из этого должен учитывать ваш код, а затем поймите, что всё это значит для вашей кодовой базы. Всё это своего рода подготовка для вас лично.
Когда бы вы ни работали с датой и временем, продумывайте детали. Например, какую структуру лучше использовать здесь? Пользователь сообщает, на какое время он хочет что-то запланировать? Если да, это время в часовом поясе, местное время или просто время суток, когда вы, например, заводите будильник на 7 утра на мобильном устройстве. В таком случае вы просто передаете команду срабатывать всегда, когда время 7 утра. Это просто локальное время, к нему не привязана какая-то дата.
Не стоит всегда обращаться к System.DateTime, просто потому что это ваш единственный инструмент. Также есть DateTimeOffset, который зачастую является верным инструментом. Уделяйте время на обдумывание правильного выбора типов данных, написание документации и комментариев. Я слышал про своего рода мантру, что если в коде реализации есть комментарии, это значит, что код можно упростить. Это далеко не всегда так. Бывают случаи, когда комментарии объясняют, почему это сделано, но не объясняют, что делает код, это и так должно быть очевидно. Они сообщают, почему код делает так, например, потому что есть требование использовать ту или иную вещь.
Просто объясняйте ход своих мыслей, поскольку при изменении каких-то требований впоследствии, вы сможете понять, почему приняли такое решение, и, возможно, вам следует адаптировать свой код ввиду изменений требований. Вместо того, чтобы тратить время на понимание, почему код написан таким образом, окажите себе услугу и опишите ход своих мыслей, укажите причины вашего решения. Если вам не нравятся комментарии в коде, укажите это где-нибудь в проектной документации. Я лично предпочитаю, чтобы такие вещи были на виду в коде.
Неоднозначность
Неоднозначность — та ещё заноза. Я приведу два разных примера. Первый — System.DateTime, по которому я уже проехался, ведь если я сообщаю дату и время, допустим, 16:13, мы не знаем, это время по UTC, часовой пояс системы или какой-то иной необозначенный часовой пояс. Эту информацию можно получить при помощи DateTime.Kind, но фактически мы не знаем этого. И если мы пишем код, использующий DateTime, нам либо потребуется вводить правило, что принимаются значения DateTime только в UTC (я сам делал это множество раз), либо мы предполагаем, что настораживает, или же у нас есть код, который каким-то образом работает. Но так мы не можем полагаться на систему типов для принятия верного решения, и это удручает.
Также нельзя выразить некоторые тонкости касательно часового пояса, когда мне нужна только дата, и метод возвращает System.DateTime, в котором значение всегда равно полуночи, потому что он просто является представлением даты. Но при этом вызывной код должен либо напоминать читающему об этом, либо это должно быть задокументировано в каком-то виде, будь то соответствующие наименование переменных или что-то другое. Поэтому мне очень нравятся структуры в Noda Time, которые чётко показывают, что вы хотите, потому что выбрана нужная для этого структура.
Второй пример неоднозначности кроется в часовых поясах. Если я спрошу своих американских коллег в Сиэтле или Маунтин-Вью, какой у них часовой пояс, некоторые из них ответят PDT (тихоокеанское летнее время). Если же я спрошу их зимой, они ответят PST (тихоокеанское стандартное время).
Эти два названия не являются часовыми поясами. В рамках tzdb они находятся в поясе «Америка/Лос-Анджелес». Тем не менее, люди используют такие сокращения, и даже без однозначности они досаждают, потому что мы по сути не знаем их значения. Они представляют не часовой пояс, а часть имени и текущее смещение по UTC. Может быть несколько часовых поясов, где используются подобные сокращения, но они могут различаться по тому, когда эти часовые пояса переходят от одного к другому.
Не могу с точностью сказать, есть ли такое в тихоокеанском регионе, но подобное определённо можно встретить. Например, если вы в настоящий момент в центральном стандартном времени (CST), вы можете быть в часовом поясе, где не переходят на центральное летнее время (CDT). Другая причина их неоднозначности кроется в том, что иногда одна и та же аббревиатура, например, CST, может использоваться для двух разных времён: центральное летнее время (Central Summer Time) и центральное стандартное время (Central Standard Time), или центральное летнее время другого часового пояса. Пожалуйста, просто не пользуйтесь ими.
Ограничение области применения
Я уже упоминал секунды координации, которые вам, скорее всего не нужно иметь в виду, и надеюсь, что вам не нужно учитывать любые другие календари помимо григорианского. Если же вы пишете какое-то приложение-календарь, такие вещи нужно учитывать. Например, у меня есть несколько друзей еврейского происхождения, которые хотели бы составлять планы с использованием еврейского календаря. При поддержке других календарей нужно понимать, что такое високосные годы и прочее. И опять же повторюсь, лучше использовать правильные инструменты, и, как мне кажется, Noda Time предлагает хороший инструментарий для этого.
Даже если мы можем сделать подобное с System.DateTime, обозначив год, месяц, день и календарь, она просто конвертирует всё в григорианский календарь, ввиду чего теряется информация об исходном календаре. Смысл заключается в том, чтобы ограничивать область применения: если нам нет нужды учитывать различные календари — не учитывайте; если нам не нужно учитывать секунды координации — не учитывайте, и так далее.
«Просто сохраните в UTC, и все будет в порядке» — миф!
Я говорил, что лучше избегать использования аббревиатур часовых поясов. Многие люди скажут, что часовые пояса — это просто, нужно лишь перевести то, что сообщил пользователь, в UTC, сохранить, и не будет больше проблем с часовыми поясами. Иногда это действительно так. Например, источником времени выступают системные часы, потому что мы не прогнозируем вещи в будущем, а просто записываем временные отметки, когда что-то происходит (временная отметка в базе данных, например). В таких случаях хранить время по UTC — рабочий вариант. Но, допустим, пользователь хочет что-то запланировать в будущем. С этим могут возникнуть большие проблемы.
Потеря информации с UTC
- Пользователь в 2018: «Запланировать встречу на 09:00 1-го декабря 2019 года в Париже»
- Система: 2019-12-01T09:00 Paris => 2019-1201T08:00Z
- Правительство Франции в конце 2018: «Постоянное летнее время (UTC+2) с октября 2019» (возможно)
- Пользователь: «Покажи мои встречи»
- Система: 2019-12-01T08:00Z => 2019-12-01T10:00 Paris
Это довольно старый мой пример. Итак, допустим, пользователь хочет сегодня запланировать встречу 1 декабря на 9 утра, а вместо 2019, скажем 2022 год, то есть, два с половиной года спустя. Система, если она работает по принципу хранения времени UTC, узнает, какой момент времени будет соответствовать указанному времени 1 декабря 2022 года. Мы имеем пояс «Европа/Париж» (UTC+1), а система запишет 08:00 по UTC.
Далее, я ожидаю, что в ближайшие несколько лет Франция вместе с ЕС сообщит о переходе на постоянное летнее время (UTC+2). Теперь правила для этого часового пояса изменились, и если мы проведём те же расчеты для поиска момента времени, который будет соответствовать 9 утра 1-го декабря 2022 года, получим те же самые 08:00 по UTC. Ожидается, что при планировании встречи на 09:00 будет храниться один и тот же момент времени, но при хранении времени по UTC, храниться будут разные вещи, потому что изменился контекст.
А после никто не знает, что возникла проблема, пока пользователь не спросит, какие у него встречи запланированы на сегодня. Система извлечет из памяти 08:00 по UTC, переведёт их в часовой пояс Парижа и выведет 10:00. И когда пользователь придет на встречу, он встретит уже уходящего коллегу по бизнесу или инвестора, с которым была встреча, с вопросом, почему он пришел в 10 утра, когда встреча была назначена на 9 утра. Таким образом, мы потеряли информацию, забыв про часовой пояс, в котором были люди. У меня есть целый пост в блоге о том, как с этим работать.
Заключение
Просто помните, что вы всё можете сделать правильно и быть уверенным в работе своего кода, если приложите достаточно усилий на этапе продумывания.
После этого сделайте так, чтобы тот, кто читал ваш код впоследствии, мог понять ход ваших мыслей, а не раздумывать над ним. Возможно, ему придётся подумать, если вдруг изменились какие-то требования, и он сразу это заметит, и учесть эти изменения.
Внедряйте часы, и неважно, называете ли вы их IClock, создаете свои собственные, или же публикуете пакет NuGet, в котором только эти часы — при желании можно использовать библиотеку Noda Time, в которой они уже реализованы.
Не стоит использовать DateTime.Now или DateTime.Utc.
Обдумывайте контекст (что вашему приложению нужно учитывать) и избавляйтесь от всего ненужного. Не нужно усложнять себе работу.
В начале я сказал, что попрощаюсь с вами так же, как и поздоровался, поэтому будьте добрее к людям, которые не согласны с вами. Если вы в чем-то не согласны со мной, это не делает меня или вас плохим человеком, мы по-прежнему можем быть доброжелательны по отношению друг к другу. И даже если вы ничего не усвоили из моего доклада, кроме просьбы быть добрее, меня это устраивает.
Если вы не только стали добрее после прочтения поста, но вам понравился и сам доклад, загляните на сайт с программой нового сезона DotNext. Конференция пройдёт 2–5 декабря: поговорим о производительности, архитектуре, best practices и внутреннем устройстве платформы .NET.
А пока скажем кое-что о внешнем устройстве другой платформы: нашей конференционной онлайн-платформы. В этот раз мы добавили «игровой режим»: можно будет перемещаться по виртуальной 2D-площадке, и если подходишь там к другим участникам, автоматически включается видеосвязь (помните, в древние времена, когда конференции проходили в офлайне, там тоже можно было подойти к кому-то и поговорить?) Так что, даже если вы уже были на июньском DotNext или других онлайн-конференциях, новый DotNext будет для вас отличаться.
Перевёл Даниил Литвинов