Задача: в распределенном мульти региональном приложении выдавать клиенту даты в часовом поясе клиента; реализовать хранение дат на стороне БД в UTC, реализовать удобную схему для использования дат в бизнес логике приложения на стороне сервера без дополнительных конвертаций и т.д.
Инструменты: MSSQLServer 2008, WCF Ria Services SP1, Silverlight (опционально).
Решение: в базе данных хранить все даты в формате DateTimeOffset со смещением 0. На клиенте только в случае отображения дат переводить даты в местное время – в остальном всегда использовать DateTimeOffcet в UTC.
Проблема: WCF Ria Services на текущий момент не поддерживает тип DateTimeOffset.
Необходимо найти workaround, чтобы:
Инструменты: MSSQLServer 2008, WCF Ria Services SP1, Silverlight (опционально).
Решение: в базе данных хранить все даты в формате DateTimeOffset со смещением 0. На клиенте только в случае отображения дат переводить даты в местное время – в остальном всегда использовать DateTimeOffcet в UTC.
Проблема: WCF Ria Services на текущий момент не поддерживает тип DateTimeOffset.
Необходимо найти workaround, чтобы:
- В базе данных всё равно использовать DateTimeOffset.
- На клиенте так же использовать DateTimeOffset – это обеспечит безболезненную миграцию в будущем на версию RiaServices, которая будет поддерживать этот тип.
На текущий момент я просто не смог найти вариантов, каким образом можно сделать не слишком сложное решение задачи (не шаманя), без использования DateTimeOffset. Если использовать любой из оставшихся типов – надо следить за свойством Kind и вмешиваться чуть ли не в каждое действие, при работе с датой, начиная от получения даты с sql сервера (посредством linq to sql или entity framework), заканчивая перебросом даты клиенту и отображением – и везде, на каждом таком шаге присутствуют свои нюансы. То ORM или LinqToSql модель выставляет дате Kind в Unspecified, хотя мы приняли тот факт, что в БД храним даты в часовом поясе сервера (или в том же UTC) – тут нужно править генерированный код, и выставлять Kind вручную. То при сериализации и десериализации от сервера к клиенту, при использовании разных сериализаторов, поведение меняется и т.д. Не буду описывать всех подробностей – их достаточно много, и у меня не получилось придумать комплексное решение задачи без использования типа DateTimeOffset, учитывая все нюансы.
Итак, к делу.
Архитектура решения представлена на рисунке.
Итак, к делу.
Архитектура решения представлена на рисунке.
- В базе данных все даты хранятся, используя тип DateTimeOffset со смещением 0.
- На стороне сервера в LinqToSql модели (думаю что Entity Framework тоже будет работать) так же есть поле типа DateTimeOffset. Для данного поля необходимо отключить Optimistic concurrency checking.
- На стороне сервера, в LinqToSql модели прописываем дополнительное поле TransferDateTime - будет использоваться для транспортировки даты между сервером и клиентом. Данное поле берёт значения из поля DateTimeOffsetDB используя необходимые конвертации.
- В в классах метаданных используем атрибут [Exclude] для поля DateTimeOffset, чтобы не было попыток со стороны Ria Services к генерации proxy классов с не поддерживаемым типом поля.
- Создаём shared файл класса, который содержит в себе поля DateTimeOffsetDB и TransferDateTime. Прописываем поле DateTimeOffsetShared. Данное поле берёт значения из поля TransferDateTime, используя необходимые конвертации.
- Со стороны клиента и пользовательского интерфейса используем только поле DateTimeOffsetShared. Это позволит в будущем безболезненно мигрировать на новую версию Ria Services.
- Со стороны сервера, в процедурах бизнес логики и т.п. так же необходимо использовать поле DateTimeOffsetShared.
Представленное решение наклыдывает только 2 ограничения:
Пройдёмся по коду
1. Тестовая табличка
В силу того, что мы определили для себя, что в поле DateTimeOffset будем хранить только значения со смещением 0, то не лишним будет написать констреинт - в текущем примере такого констреинта я не делал.
Так же рассмотрим 2 типа дат: дата со временем и просто дата, без времени (например дата начала проекта и т.п.). Такие даты нужно будет по разному обрабатывать при отображении на экранных формах.
2. LinqToSql модель
3. Domain Model
В описании метаданных доменной модели (DomainService1.metadata.cs) необходимо сделать следующее:
Теперь на клиенте доступно 2 поля: DateTimeOffset_Shared и DateOffset_Shared типа DateTimeOffset с которыми можно работать. Когда RiaServices начнёт поддерживать тип DateTimeOffset можно будет достаточно легко отказаться от текущей логики (удалить промежуточные DateTime поля, удалить shared файл, немного поправить метаданные в доменной модели и исправить ошибки компиляции удалением постфикса _Shared).
4. Отображение дат на экранных формах
Для того, что корректно отображать даты, необходимо использовать конвертации при привязках (bindings) в двух разрезах: приведение в даты и времени к локальной дате и времени, форматирование текстовки даты и времени в соответствии с настройками культур компьютера клиента.
Примеры конвертеров в тестовом проекте находятся в папке Converters. У некоторых имена убийственно длинные, но здесь я придерживаюсь принципа "лучше длинное понятное имя, чем короткая, расшифрованная в комментариях аббревиатура".
5. Валидация
- Для поля с датой DateTimeOffset необходимо отключить Optimistic concurrency checking.
- Есть дефект конвертации DateTime в DateTimeOffset. При конвертации к типу DateTimeOffset необходимо явно указывать смещение.
Пройдёмся по коду
Демо проект можно скачать тут. (Спасибо MisterGoodcat за DateTimePicker контрол.)
Для того, чтобы после создания базы данных (см. пункт 1 ниже) все сразу заработало, в web.config поменяйте DateTimeTestConnectionString.
1. Тестовая табличка
В силу того, что мы определили для себя, что в поле DateTimeOffset будем хранить только значения со смещением 0, то не лишним будет написать констреинт - в текущем примере такого констреинта я не делал.
Так же рассмотрим 2 типа дат: дата со временем и просто дата, без времени (например дата начала проекта и т.п.). Такие даты нужно будет по разному обрабатывать при отображении на экранных формах.
CREATE TABLE [dbo].[TestTable]( [DateTimeKey] [uniqueidentifier] NOT NULL, [DateTimeOffset_DB] [datetimeoffset](7) NOT NULL, [DateOffset_DB] [datetimeoffset](7) NOT NULL, CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED ( [DateTimeKey] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY]
2. LinqToSql модель
- Для полей DateTimeOffset_DB и DateOffset_DB необходимо выставить свойство UpdateCheck в Never. Делаем это для того, чтобы не было ругани со стороны Ria Services. Пример возможной ошибки следующий:
Member 'RiaServices.v1RTM.DateTimeOffset.Web.TestTable.DateOffset_DB' is an optimistic concurrency check member, but is either excluded or non-serializable.
- В code behind модели (DataClasses1.cs) создаём 2 новых поля типа DateTime. Эти поля будут выполнять функцию транспортировки значений через RiaServices.
TransferDateTime
private DateTime _transferDateTime; public DateTime TransferDateTime { get { return _transferDateTime; } set { DateTime dt = value; if (dt.Kind == DateTimeKind.Unspecified) { dt = DateTime.SpecifyKind(dt, DateTimeKind.Local); } if (dt.Kind == DateTimeKind.Utc) { dt = dt.ToLocalTime(); } _transferDateTime = dt; this.DateTimeOffset_DB = new System.DateTimeOffset(dt.ToUniversalTime()); } }
TransferDate
private DateTime _transferDate; public DateTime TransferDate { get { _transferDate = DateTime.SpecifyKind(_transferDate, DateTimeKind.Utc); return _transferDate; } set { DateTime dt = value; dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); _transferDate = dt; this.DateOffset_DB = new System.DateTimeOffset(dt, new TimeSpan(0)); } }
Обратите внимание на разницу сохранения значений в свойства this.DateTimeOffset_DB и this.DateOffset_DB соответственно. В первом случае, так как имеем дело с датой и временем, при конвертировании DateTime в DateTimeOffset выполняем ToUniversalTime(), чтобы всегда получать время со смешением 0. Во втором случае, в силу того, что работаем только с датой, выставляем смещение вручную - здесь никаких конвертаций к локальным значением даты и делаем. - Содаём shared файл (Classes.shared.cs) в котором прописываем 2 поля: DateTimeOffset_Shared и DateOffset_Shared. Данные поля будут использоваться для работы с датами на стороне клиента, а так же на стороне сервера. Информацию о датах эти поля берут из полей типа DateTime, описанных в предыдущем пункте. Конвертация аналогичная - дату-время берём в локальном виде, просто дату не переводим, а берём как есть со смещением 0.
DateTimeOffset_Shared
[Required] [CustomValidation(typeof(RiaServices.v1RTM.DateTimeOffset.Web.CustomValidators), "IsDateValid")] public System.DateTimeOffset DateTimeOffset_Shared { get { return new System.DateTimeOffset(TransferDateTime.ToUniversalTime()); } set { if (TransferDateTime != value.LocalDateTime) { //this.OnDateTimeKeyChanging(value); # if (!SILVERLIGHT) this.SendPropertyChanging(); # else this.ValidateProperty("DateTimeOffset_Shared", value); # endif this.TransferDateTime = value.LocalDateTime; # if (!SILVERLIGHT) this.SendPropertyChanged("DateTimeOffset_Shared"); # else this.RaisePropertyChanged("DateTimeOffset_Shared"); # endif //this.OnDateTimeKeyChanged(); } } }
DateOffset_Shared
[Required] public System.DateTimeOffset DateOffset_Shared { get { return new System.DateTimeOffset(TransferDate, new TimeSpan(0)); } set { if (TransferDate.Date != value.Date) { //this.OnDateTimeKeyChanging(value); # if (!SILVERLIGHT) this.SendPropertyChanging(); # else this.ValidateProperty("DateOffset_Shared", value); # endif this.TransferDate = value.Date; # if (!SILVERLIGHT) this.SendPropertyChanged("DateOffset_Shared"); # else this.RaisePropertyChanged("DateOffset_Shared"); # endif //this.OnDateTimeKeyChanged(); } } }
Обратите внимание, что именно в shared файле можно для данных свойств указать DataAnnotations атрибуты. Про валидацию речь пойдёт в конце поста.
3. Domain Model
В описании метаданных доменной модели (DomainService1.metadata.cs) необходимо сделать следующее:
internal sealed class TestTableMetadata { // Metadata classes are not meant to be instantiated. private TestTableMetadata() { } public Guid DateTimeKey { get; set; } [Exclude] public DateTimeOffset DateTimeOffset_DB { get; set; } [Exclude] public DateTimeOffset DateTimeOffset_Shared { get; set; } public DateTime TransferDateTime { get; set; } [Exclude] public DateTimeOffset DateOffset_DB { get; set; } [Exclude] public DateTimeOffset DateOffset_Shared { get; set; } public DateTime TransferDate { get; set; } }Поля типа DateTimeOffset метим [Exclude] атрибутом, так как этот тип не поддерживается RiaServices. Следовательно данные поля не будут участвовать в генерации proxy классов клиента.
Теперь на клиенте доступно 2 поля: DateTimeOffset_Shared и DateOffset_Shared типа DateTimeOffset с которыми можно работать. Когда RiaServices начнёт поддерживать тип DateTimeOffset можно будет достаточно легко отказаться от текущей логики (удалить промежуточные DateTime поля, удалить shared файл, немного поправить метаданные в доменной модели и исправить ошибки компиляции удалением постфикса _Shared).
4. Отображение дат на экранных формах
Для того, что корректно отображать даты, необходимо использовать конвертации при привязках (bindings) в двух разрезах: приведение в даты и времени к локальной дате и времени, форматирование текстовки даты и времени в соответствии с настройками культур компьютера клиента.
Примеры конвертеров в тестовом проекте находятся в папке Converters. У некоторых имена убийственно длинные, но здесь я придерживаюсь принципа "лучше длинное понятное имя, чем короткая, расшифрованная в комментариях аббревиатура".
- DateTimeOffsetToLocalDateTimeStringCultureFormatConverter - конвертация DateTiteOffset значения (с датой и временем) к локальному часовому поясу в строку с форматом, определённым текущей культурой клиента. Используется при отображении в гридах, текст боксах и т.д.
- DateTimeOffsetToUtcDateStringCultureFormatConverter - конвертирует дату (без времени) из DateTimeOffset в строку с форматом, определённым текущей культурой клиента.
- DateTimeOffsetToLocalDateTimeConverter - конвертирует дату и время из DateTimeOffset в локальный DateTime. Используется для биндинга к контролам вида DateTimePicker и т.п., которые требуют объект, а не строку. Формат отображения прописывается с помощью свойств таких контролов.
- DateTimeOffsetToUtcDateConverter - аналогичный предыдущему, только без времени.
5. Валидация
В представленной схеме так же работает валидация. Атрибуты для валидации необходимо указывать в shared файле, где прописываются DateTimeOffset поля. В тестовом проекте в shared файле для поля DateTimeOffset_Shared указан атрибут, с реализованным механизмом проверки в файле CustomValidators.shared.cs