Как стать автором
Обновить
1262.84
OTUS
Цифровые навыки от ведущих экспертов

Даты, время и часовые пояса: улучшения в .NET 6

Время на прочтение15 мин
Количество просмотров17K

Материал переведен. Ссылка на оригинал

В этой статье я расскажу о грядущих улучшениях в .NET 6, затрагивающих даты, время и часовые пояса. Все, о чем здесь говорится, вы можете опробовать сами — эти возможности будут доступны, начиная с версии .NET 6 Preview 4.

Мы рассмотрим следующие темы:

Узнать больше подробностей вы можете из статьи на GitHub: dotnet/runtime#45318.

Знакомство с типами DateOnly и TimeOnly

Если вы работали с датой и временем в .NET, вы наверняка использовали типы DateTime, DateTimeOffset, TimeSpan и TimeZoneInfo. В следующем выпуске вводятся два дополнительных типа: DateOnly и TimeOnly. Оба они относятся к пространству имен System и встроены в .NET, как и другие типы даты и времени.

Тип DateOnly

Тип DateOnly — это структура, представляющая только дату: год, месяц и день. Вот небольшой пример:

// Создание и свойства
DateOnly d1 = new DateOnly(2021, 5, 31);
Console.WriteLine(d1.Year);      // 2021
Console.WriteLine(d1.Month);     // 5
Console.WriteLine(d1.Day);       // 31
Console.WriteLine(d1.DayOfWeek); // Monday

// Манипуляции
DateOnly d2 = d1.AddMonths(1);  // Можно прибавлять дни, месяцы или годы. Для вычитания используйте отрицательные значения.
Console.WriteLine(d2);     // Результат: "6/30/2021" (обратите внимание: без указания времени)

// С помощью свойства DayNumber можно вычислить разницу между двумя датами в сутках
int days = d2.DayNumber - d1.DayNumber;
Console.WriteLine($"{d2} наступит через {days} суток после {d1}");

// Парсинг и токены форматирования строк работают работают ожидаемым образом
DateOnly d3 = DateOnly.ParseExact("31 Dec 1980", "dd MMM yyyy", CultureInfo.InvariantCulture);  // Пользовательский формат
Console.WriteLine(d3.ToString("o", CultureInfo.InvariantCulture));   // Результат: "1980-12-31" (формат ISO 8601)

// Можно сочетать с TimeOnly, чтобы получить дату и время (DateTime)
DateTime dt = d3.ToDateTime(new TimeOnly(0, 0));
Console.WriteLine(dt);       // Результат: "12/31/1980 12:00:00 AM"

// Если нужна текущая дата (в местном часовом поясе)
DateOnly today = DateOnly.FromDateTime(DateTime.Today);

Тип DateOnly идеально подходит для работы с датами рождения, юбилеев, найма и другими датами, обычно не привязанными к конкретному времени. Можно сказать, что тип DateOnly отражает конкретный день целиком (от начала и до конца) — одну клеточку в календаре. Раньше вы могли использовать для этого тип DateTime , указав в качестве времени полночь (00:00:00.0000000). Этот вариант по-прежнему работает, но применение DateOnly дает ряд преимуществ, включая следующие:

  • Безопасность типа DateOnly выше безопасности DateTime, используемого для представления только даты. Это важно при использовании API, поскольку не каждое действие, применимое к определенному моменту конкретного дня, имеет смысл для даты как единого целого. Например, метод TimeZoneInfo.ConvertTime позволяет конвертировать DateTime из одного часового пояса в другой. Передавать ему дату целиком не имеет смысла, поскольку можно конвертировать только конкретный момент времени этого дня. Такие бессмысленные операции могут иметь место, если использовать DateTime. Это может привести к ошибке, например к сдвигу дня рождения на сутки вперед или назад. А с DateOnly ни один API конвертации часовых поясов попросту не будет работать, что исключает его ошибочное использование.

  • Тип DateTime также содержит свойство Kind типа DateTimeKind, которое может принимать значения Local, Utc или Unspecified. Оно влияет на поведение API конвертации, включая форматирование и парсинг строк. Тип DateOnly не имеет этого свойства — оно по сути всегда считается неуказанным (Unspecified).

  • При сериализации DateOnly достаточно указать год, месяц и день. Благодаря этому данные будут храниться в чистом виде, без множества нулей в конце. Кроме того, любому пользователю вашего API будет видно, что это значение представляет целую дату, а не ее полночь.

  • При взаимодействии с базой данных, например с SQL Server, целые даты практически всегда хранятся как тип данных date. Прежде API для хранения и извлечения таких данных были в значительной мере привязаны к типу DateTime. При сохранении время обрезалось, что потенциально могло привести к потере данных. При извлечении время заполнялось нулями — дату без указания времени было не отличить от полуночи. Работая с типом DateOnly, мы имеем более точный эквивалент типа date в базе данных. Учтите, что пока не все поставщики данных поддерживают новый тип, но, по крайней мере, теперь это возможно.

Тип DateOnly имеет диапазон от 0001-01-01 до 9999-12-31, как и DateTime. Мы также предусмотрели в конструкторе поддержку любых календарей, поддерживаемых .NET. Но, подобно DateTime, объект DateOnly всегда отражает значения пролептического григорианского календаря, независимо от использованного при его создании календаря. Если передать в конструктор календарь, он будет использоваться только для интерпретации года, месяца и дня, переданных в тот же конструктор. Пример:

Calendar hebrewCalendar = new HebrewCalendar();
DateOnly d4 = new DateOnly(5781, 9, 16, hebrewCalendar);                   // 16 сивана 5781 г.
Console.WriteLine(d4.ToString("d MMMM yyyy", CultureInfo.InvariantCulture)); // 27 мая 2021 г.
Подробные сведения см. в руководстве по работе с календарями.

Подробные сведения см. в руководстве по работе с календарями.

Тип TimeOnly

Мы также ввели новый тип TimeOnly. Это структура, которая представляет только время — конкретный момент в сутках. Можно сказать, что DateOnly является одной половиной DateTime, а TimeOnly — второй. Вот небольшой пример:

// Создание и свойства
TimeOnly t1 = new TimeOnly(16, 30);
Console.WriteLine(t1.Hour);      // 16
Console.WriteLine(t1.Minute);    // 30
Console.WriteLine(t1.Second);    // 0

// Можно прибавлять часы, минуты или период времени TimeSpan (или использовать отрицательные значения для вычитания).
TimeOnly t2 = t1.AddHours(10);
Console.WriteLine(t2);     // Результат: "2:30 AM" (обратите внимание, что даты нет, а время уже перешло через полночь)

// При необходимости можно вычислить, сколько прошло суток за указанный период (= сколько раз часы показывали полночь).
TimeOnly t3 = t2.AddMinutes(5000, out int wrappedDays);
Console.WriteLine($"{t3}, {wrappedDays} суток спустя");  // Результат: "1:50 PM, 3 суток спустя"

// Значения времени можно вычитать, чтобы узнать временной промежуток между ними.
// Операция должна выглядеть как "конечное время - начальное время". Порядок важен, так как это циклические часы.  Пример:
TimeOnly t4 = new TimeOnly(2, 0);  // 2:00
TimeOnly t5 = new TimeOnly(21, 0); // 21:00
TimeSpan x = t5 - t4;
TimeSpan y = t4 - t5;
Console.WriteLine($"{t5} наступит через {x.TotalHours} ч. после {t4}"); // 19 часов
Console.WriteLine($"{t4} наступит через {x.TotalHours} ч. после {t5}"); // 5 часов

// Парсинг и токены форматирования строк работают работают ожидаемым образом
TimeOnly t6 = TimeOnly.ParseExact("5:00 pm", "h:mm tt", CultureInfo.InvariantCulture);  // Пользовательский формат
Console.WriteLine(t6.ToString("T", CultureInfo.InvariantCulture));   // Результат: "17:00:00" (длинный формат времени)

// Можно преобразовать TimeOnly в TimeSpan для использования с прежними API
TimeSpan ts = t6.ToTimeSpan();
Console.WriteLine(ts);      // "17:00:00"

// Или скомбинировать с DateOnly, чтобы получить DateTime
DateTime dt = new DateOnly(1970, 1, 1).ToDateTime(t6);
Console.WriteLine(dt);       // Результат: "1/1/1970 5:00:00 PM"

// Если требуется текущее время (в местном часовом поясе)
TimeOnly now = TimeOnly.FromDateTime(DateTime.Now);

// Можно определить, находится ли время между двумя другими значениями времени
if (now.IsBetween(t1, t2))
    Console.WriteLine($"{now} находится между {t1} и {t2}");
else
    Console.WriteLine($"{now} НЕ находится между {t1} и {t2}");

Тип TimeOnly идеально подходит для таких сценариев, как время регулярных встреч, время ежедневного будильника или начало и конец рабочего дня в каждый день недели. Так как тип TimeOnly не привязан к конкретной дате, его можно воспринимать как обычные стрелочные часы (правда, не 12-, а 24-часовые). Раньше существовало два распространенных способа представления таких значений: с помощью типов TimeSpan или DateTime. Оба варианта продолжают работать, но использование TimeOnly дает следующие преимущества:

  • Тип TimeSpan преимущественно описывает прошедшее время, подобно секундомеру. Его верхняя граница превышает 29 000 лет, а значения могут быть и отрицательными, обозначая обратный отсчет времени. В свою очередь, тип TimeOnly обозначает момент в сутках, поэтому имеет интервал от 00:00:00.0000000 до 23:59:59.9999999 и допускает только положительные значения. Если использовать для указания момента в сутках тип TimeSpan, есть риск выхода за пределы допустимого диапазона. TimeOnly исключает этот риск.

  • Момент в сутках можно представить через тип DateTime, но тогда нам придется указать какую-то произвольную дату. Зачастую берется значение DateTime.MinValue (0001-01-01), но такой подход порой приводит к выходу за пределы диапазона при вычитании времени. Если указать другую дату, требуется запомнить ее, а затем отбросить, что может привести к проблемам при сериализации.

  • Тип TimeOnly, напротив, непосредственно предназначен для указания момента в сутках, и в таких ситуациях он намного безопаснее, чем DateTime или TimeSpan, — точно так же, как DateOnly более безопасен, чем DateTime, для указания дат без конкретного времени.

  • Одна из распространенных операций с временем — прибавление или вычитание разных истекших периодов. В отличие от TimeSpan, TimeOnly корректно отрабатывает такие операции при переходе через полночь. Например, если восьмичасовая смена сотрудника начинается в 18:00, TimeOnly позволит правильно прибавить эти восемь часов к времени начала смены и получить 02:00; кроме того, этот тип имеет метод InBetween, позволяющий легко определить для любого указанного времени, пришлось ли оно на рабочую смену сотрудника.

Почему имена типов кончаются на Only?

Придумывать имена всегда непросто, и этот случай — не исключение. Мы долго обсуждали разные варианты имен, но в итоге приняли имена DateOnly и TimeOnly, так как они соответствуют ряду условий и ограничений:

  • Они не используют зарезервированные ключевые слова языка .NET. Date было бы идеальным вариантом, но это ключевое слово в языке VB.NET и тип данных, являющийся псевдонимом System.DateTime, поэтому это имя было отвергнуто.

  • Их легко найти в документации и с помощью IntelliSense, введя их начало — Date или Time. Мы сочли это важным, поскольку множество разработчиков .NET-приложений привыкло к типам DateTime и TimeSpan. В других платформах аналогичная функциональность обозначается префиксами, например Java-классы LocalDate и LocalTime из пакета java.time или же типы PlainDate и PlainTime в готовящемся к выпуску предложении Temporal для JavaScript. Но в этих примерах все типы дат и времени сгруппированы в отдельное пространство имен, в то время как в .NET типы даты и времени находятся в общем пространстве имен System.

  • Их трудно спутать с существующими возможностями API. В частности, оба типа DateTime и DateTimeOffset имеют свойства с именами Date (возвращает DateTime) и TimeOfDay (возвращает TimeSpan). Мы сочли, что если бы тип назывался TimeOfDay вместо TimeOnly, было бы нелогично, что свойство DateTime.TimeOfDay возвращает тип TimeSpan, а не TimeOfDay. Если бы мы делали все с нуля, мы выбрали бы эти имена в качестве имен свойств и имен типов, которые они возвращают, но такое радикальное изменение сейчас просто невозможно.

  • Они легко запоминаются и имеют интуитивно понятный смысл. Ведь имена DateOnly и TimeOnly явно говорят о том, что эти типы должны использоваться для указания «только даты» и «только времени». Более того, при их комбинировании образуется тип DateTime, поэтому, давая им такие имена, мы логически связываем их друг с другом.

А как насчет Noda Time?

Многие просили нас использовать Noda Time вместо введения этих двух типов. Действительно, Noda Time — отличный образец высококачественной библиотеки .NET с открытым исходным кодом, разработанной сообществом, и вы, конечно же, можете и дальше ею пользоваться. Однако мы решили, что реализовывать подобие API Noda внутри .NET неоправданно. В конечном счете было решено, что лучше дополнить существующие типы, заполнив пробелы, чем перерабатывать их и заменять другими. Ведь уже существует множество .NET-приложений, использующих имеющиеся типы DateTime, DateTimeOffset, TimeSpan и TimeZoneInfo. Типы DateOnly и TimeOnly станут естественным и удобным дополнением к ним.

Также замечу, что предложение о поддержке взаимозаменяемости DateOnly и TimeOnly с эквивалентными типами Noda Time (LocalDate и LocalTime) уже внесено на рассмотрение.

API конвертации часовых поясов

Для начала — небольшая историческая справка. Существуют два основных набора данных о часовых поясах:

  • Набор часовых поясов Microsoft, поставляемый с Windows.

    • Пример идентификатора: "AUS Eastern Standard Time"

  • Набор часовых поясов, используемый остальными платформами и поддерживаемый IANA.

    • Пример идентификатора: "Australia/Sydney"

Под остальными платформами я подразумеваю не только Linux и macOS, но и Java, Python, Perl, Ruby, Go, JavaScript и многие другие.

Поддержка часовых поясов в .NET обеспечивается классом TimeZoneInfo. Этот класс изначально был создан для платформы .NET Framework 3.5, работающей только в операционных системах Windows. Поэтому он получал данные о часовых поясах из Windows. Вскоре это стало проблемой для тех, кто хотел использовать информацию о часовых поясах в данных, передаваемых между системами. С выходом .NET Core проблема обострилась, поскольку данные о часовых поясах Windows просто недоступны в других системах, таких как Linux и macOS.

Ранее метод TimeZoneInfo.FindSystemTimeZoneById искал часовые пояса, доступные в операционной системе. То есть часовые пояса Windows в системах Windows и часовые пояса IANA во всех остальных. Это неудобно, особенно если код и данные должны быть кросс-платформенными. До настоящего времени эту проблему обходили путем ручного преобразования одного набора часовых поясов в другой, предпочтительно на основе отображений, заданных в общем репозитории языковых данных Unicode (Unicode CLDR). Эти отображения доступны также в таких библиотеках, как ICU и TimeZoneConverter, которую часто используют разработчики .NET-приложений. Все эти методы работают и сейчас, но теперь появился и новый, более простой и удобный способ.

Начиная с этого выпуска, метод TimeZoneInfo.FindSystemTimeZoneById автоматически преобразует входные данные в другой формат, если запрошенный часовой пояс не найден в системе. Другими словами, вы можете использовать идентификаторы часовых поясов как IANA, так и Windows, в любой операционной системе, где есть данные о часовых поясах*. Метод использует те же отображения CLDR, но получает их через средства глобализации .NET на основе ICU, поэтому отдельная библиотека не требуется.

Краткий пример:

// Оба идентификатора теперь работают в любых поддерживаемых ОС, где доступны данные часовых поясов и ICU.
TimeZoneInfo tzi1 = TimeZoneInfo.FindSystemTimeZoneById("AUS Eastern Standard Time");
TimeZoneInfo tzi2 = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");

В UNIX часовые пояса Windows фактически не устанавливаются как часть ОС, но их идентификаторы распознаются благодаря преобразованиям и данным, предоставляемым ICU. Вы можете установить в систему библиотеку libicu или встроить данные ICU в свое .NET-приложение.

* Имейте в виду, что некоторые Docker-образы .NET, например для Alpine Linux, не содержат предустановленный пакет tzdata, но вы можете легко добавить его.

В этом выпуске мы также добавили в класс TimeZoneInfo новые методы с именами TryConvertIanaIdToWindowsId и TryConvertWindowsIdToIanaId — они используются при необходимости ручного преобразования одного формата часового пояса в другой.

Примеры использования:

// Преобразование из формата IANA в Windows
string ianaId1 = "America/Los_Angeles";
if (!TimeZoneInfo.TryConvertIanaIdToWindowsId(ianaId1, out string winId1))
    throw new TimeZoneNotFoundException($"Часовой пояс Windows для "{ianaId1}" не найден.");
Console.WriteLine($"{ianaId1} => {winId1}");  // "America/Los_Angeles => Pacific Standard Time"

// Преобразование из формата Windows в IANA при неизвестном регионе
string winId2 = "Eastern Standard Time";
if (!TimeZoneInfo.TryConvertWindowsIdToIanaId(winId2, out string ianaId2))
    throw new TimeZoneNotFoundException($"Часовой пояс IANA для "{winId2}" не найден.");
Console.WriteLine($"{winId2} => {ianaId2}");  // "Eastern Standard Time => America/New_York"

// Преобразование из формата Windows в IANA при известном регионе
string winId3 = "Eastern Standard Time";
string region = "CA"; // Канада
if (!TimeZoneInfo.TryConvertWindowsIdToIanaId(winId3, region, out string ianaId3))
    throw new TimeZoneNotFoundException($"Часовой пояс IANA для "{winId3}" в регионе "{region}" не найден.");
Console.WriteLine($"{winId3} + {region} => {ianaId3}");  // "Eastern Standard Time + CA => America/Toronto"

Кроме того, мы добавили в класс TimeZoneInfo свойство экземпляра с именем HasIanaId — оно возвращает true, если свойство Id является идентификатором часового пояса IANA. Это позволяет определить, требуется ли преобразование. Допустим, у вас есть объекты TimeZoneInfo, полученные из смешанного набора идентификаторов Windows или IANA, а вам требуется только идентификатор часового пояса IANA для вызова внешнего API-интерфейса. Вы можете определить следующий вспомогательный метод:

static string GetIanaTimeZoneId(TimeZoneInfo tzi)
{
    if (tzi.HasIanaId)
        return tzi.Id;  // Преобразование не требуется

    if (TimeZoneInfo.TryConvertWindowsIdToIanaId(tzi.Id, out string ianaId))
        return ianaId;  // Использовать преобразованный идентификатор

    throw new TimeZoneNotFoundException($"Часовой пояс IANA для "{tzi.Id}" не найден.");
}

Если же вам, наоборот, нужен идентификатор часового пояса Windows — например, функция AT TIME ZONE в SQL Server всегда нуждается в идентификаторе часового пояса из Windows, даже если SQL Server работает в Linux, — вы можете определить следующий вспомогательный метод:

static string GetWindowsTimeZoneId(TimeZoneInfo tzi)
{
    if (!tzi.HasIanaId)
        return tzi.Id;  // Преобразование не требуется

    if (TimeZoneInfo.TryConvertIanaIdToWindowsId(tzi.Id, out string winId))
        return winId;   // Использовать преобразованный идентификатор

    throw new TimeZoneNotFoundException($"Часовой пояс Windows для "{tzi.Id}" не найден.");
}

Отображаемые имена часовых поясов в Linux и macOS

Другая частая операция с часовыми поясами — формирование списка поясов, например, чтобы пользователь мог выбрать нужный. В Windows для этого всегда использовался метод TimeZoneInfo.GetSystemTimeZones. Он возвращает набор объектов TimeZoneInfo только для чтения, на основе которого можно составить список, используя свойства Id и DisplayName каждого объекта.

В Windows отображаемые имена часовых поясов в приложениях .NET заполняются на основании файлов ресурсов, связанных с текущим языком интерфейса ОС. В Linux и macOS для этого используются данные глобализации ICU. В целом этом неплохой подход, но нужно убедиться, что все значения DisplayName в списке уникальны, иначе его нельзя использовать. Например, 13 разных часовых поясов возвращают одинаковое отображаемое имя "(UTC-07:00) Mountain Standard Time", в результате чего пользователь не сможет выбрать свой часовой пояс. Часовой пояс America/Denver представляет большинство зон Mountain Time в США, но в штате Аризона, где переход на летнее время отсутствует, используется пояс America/Phoenix.

В новом выпуске мы предусмотрели дополнительные алгоритмы, выбирающие в ICU более подходящие значения для отображаемых имен, что позволяет составлять более осмысленные списки. Например, часовой пояс America/Denver теперь отображается как "(UTC-07:00) Mountain Time (Denver)", а America/Phoenix принимает вид "(UTC-07:00) Mountain Time (Phoenix)". Посмотреть остальные изменения в списке можно в разделах Before («До») и After («После») в этой статье на GitHub.

Стоит отметить, что список часовых поясов и их отображаемые имена в Windows в основном не изменились. Но мы исправили небольшой баг — в коде было жестко фиксировано английское отображаемое имя для часовых поясов UTC ("Coordinated Universal Time"), что приводило к проблемам в других языках. Теперь оно корректно отображается во всех операционных системах на том же языке, что и все остальные отображаемые имена часовых поясов.

Улучшения в классе TimeZoneInfo.AdjustmentRule

Последнее рассматриваемое улучшение затрагивает чуть реже используемую, но все же важную возможность. Класс TimeZoneInfo.AdjustmentRule используется в .NET как часть представления часовых поясов в памяти. Каждый класс TimeZoneInfo может иметь одно или несколько правил корректировки либо вовсе их не иметь. Эти правила описывают, как смещение часового пояса относительно UTC корректируется с течением времени, что обеспечивает корректное преобразование любого момента времени. Такая корректировка — очень сложная работа, выходящая за рамки настоящей статьи. Тем не менее я постараюсь описать некоторые сделанные нами улучшения.

Изначально при создании класса TimeZoneInfo подразумевалось, что смещение BaseUtcOffset будет фиксированным и что все правила корректировки будут управлять только переходами на летнее и зимнее время. К сожалению, такой подход не учел, что часовые пояса меняют свое стандартное смещение в разное время, например совсем недавно на территории Юкон (Канада) решили отказаться от переходов между UTC-8 и UTC-7 — теперь там круглый год действует UTC-7. Чтобы адаптироваться к подобным изменениям, в .NET (уже давным-давно) появилось внутреннее свойство класса TimeZoneInfo.AdjustmentRule с именем BaseUtcOffsetDelta. Это свойство используется для отслеживания изменений TimeZoneInfo.BaseUtcOffset при переключении с одного правила корректировки на другое.

Но так как в ряде случаев нужен доступ ко всем необработанным данным и нет смысла прятать какую-либо их часть от разработчиков, в новом выпуске свойство BaseUtcOffsetDelta класса TimeZoneInfo.AdjustmentRule стало публичным. Более того, мы также дополнили метод CreateAdjustmentRule возможностью принимать параметр baseUtcOffsetDelta (разумеется, это вовсе не означает, что мы решили, будто большинству разработчиков потребуется или захочется создавать свои часовые пояса или правила корректировки).

Два других небольших изменения касаются извлечения правил корректировки из данных IANA в операционных системах, отличающихся от Windows. Внешне они ни на что не влияют, но обеспечивают корректную работу в ряде пограничных случаев.

Если вы уже немного запутались во всем вышесказанном, не переживайте — вы не одиноки. К счастью, вся необходимая логика для корректного использования данных уже встроена в различные методы TimeZoneInfo, такие как GetUtcOffset и ConvertTime. Скорее всего, непосредственно использовать правила корректировки вам не понадобится.

Заключение

Как видите, работать с датами, временем и часовыми поясами в .NET 6 будет значительно удобнее. Я с интересом буду следить за тем, как новые типы DateOnly и TimeOnly приживутся в экосистеме .NET, особенно за их использованием при сериализации и работе с базами данных. В целом же я рад видеть, что локализация и удобство использования .NET продолжают улучшаться — даже в такой не самой популярной области, как часовые пояса!

Не забудьте поделиться своим мнением об этих новых возможностях. Спасибо за внимание!

Примечание: Примеры кода приведены для американских языковых настроек, и в русской или иных версиях выводимый текст (например, формат дат и времени) может отличаться.

Материал переведен. Ссылка на оригинал


Материал подготовлен в рамках курса "C# Developer. Professional". Если вас интересует развитие в C# разработке с нуля до Pro, предлагаем узнать про специализацию.

Также приглашаем всех желающих на открытый урок «Управление конфигурациями микросервисов». На занятии обсудим один из подходов, используемых в реальных high-load проектах. РЕГИСТРАЦИЯ

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии5

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS