Tuesday, March 15, 2011

WCF Ria Services и DateTimeOffset

Задача: в распределенном мульти региональном приложении выдавать клиенту даты в часовом поясе клиента; реализовать хранение дат на стороне БД в UTC, реализовать удобную схему для использования дат в бизнес логике приложения на стороне сервера без дополнительных конвертаций и т.д.

Инструменты: MSSQLServer 2008, WCF Ria Services  SP1, Silverlight (опционально).

Решение: в базе данных хранить все даты в формате DateTimeOffset со смещением 0. На клиенте только в случае отображения дат переводить даты в местное время – в остальном всегда использовать DateTimeOffcet в UTC.

Проблема: WCF Ria Services на текущий момент не поддерживает тип DateTimeOffset.

Необходимо найти workaround, чтобы:
  1. В базе данных всё равно использовать DateTimeOffset. 
  2. На клиенте так же использовать DateTimeOffset – это обеспечит безболезненную миграцию в будущем на версию RiaServices, которая будет поддерживать этот тип.
На текущий момент я просто не смог найти вариантов, каким образом можно сделать не слишком сложное решение задачи (не шаманя), без использования DateTimeOffset. Если использовать любой из оставшихся типов – надо следить за свойством Kind и вмешиваться чуть ли не в каждое действие, при работе с датой, начиная от получения даты с sql сервера (посредством linq to sql или entity framework), заканчивая перебросом даты клиенту и отображением – и везде, на каждом таком шаге присутствуют свои нюансы. То ORM или LinqToSql модель выставляет дате Kind в Unspecified, хотя мы приняли тот факт, что в БД храним даты в часовом поясе сервера (или в том же UTC) – тут нужно править генерированный код, и выставлять Kind вручную. То при сериализации и десериализации от сервера к клиенту, при использовании разных сериализаторов, поведение меняется и т.д. Не буду описывать всех подробностей – их достаточно много, и у меня не получилось придумать комплексное решение задачи без использования типа DateTimeOffset, учитывая все нюансы.

Итак, к делу.
Архитектура решения представлена на рисунке.

  1. В базе данных все даты хранятся, используя тип DateTimeOffset со смещением 0.
  2. На стороне сервера в LinqToSql модели (думаю что Entity Framework тоже будет работать) так же есть поле типа DateTimeOffset. Для данного поля необходимо отключить Optimistic concurrency checking.
  3. На стороне сервера, в LinqToSql модели прописываем дополнительное поле TransferDateTime - будет использоваться для транспортировки даты между сервером и клиентом. Данное поле берёт значения из поля DateTimeOffsetDB используя необходимые конвертации.
  4. В в классах метаданных используем атрибут [Exclude] для поля DateTimeOffset, чтобы не было попыток со стороны Ria Services к генерации proxy классов с не поддерживаемым типом поля.
  5. Создаём shared файл класса, который содержит в себе поля DateTimeOffsetDB и TransferDateTime. Прописываем поле DateTimeOffsetShared. Данное поле берёт значения из поля TransferDateTime, используя необходимые конвертации.
  6. Со стороны клиента и пользовательского интерфейса используем только поле DateTimeOffsetShared. Это позволит в будущем безболезненно мигрировать на новую версию Ria Services.
  7. Со стороны сервера, в процедурах бизнес логики и т.п. так же необходимо использовать поле DateTimeOffsetShared.
Представленное решение наклыдывает только 2 ограничения:
  1. Для поля с датой DateTimeOffset необходимо отключить Optimistic concurrency checking.
  2. Есть дефект конвертации 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