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


Friday, January 29, 2010

CollabNet Subversion + SSL (Windows)

Хоть блог про какой-то непонятный .net, первый пост будет о svn.

Исходные данные: установленный и работающий под Windows CollabNet Subversion.
Задача: включить ssl и авторизацию с помощью клиентских сертификатов.

В интернете масса информации, как это делается, но каждый раз, когда я возвращаюсь к этому вопросу, ни один найденный FAQ не работает, а я уже все и позабыл, да и какой-то текстовый файлик, в котором я когда-то делал пометки как-чего, потерялся.
Так что напишу всё тут.
Кстати, есть один очень хороший FAQ, вот тут, но под линух, и вот если выполнить всё по пунктам - не работает.

В инструкции считается, что CollabNet Subversion установлен в директорию c:\Program Files\CollabNet\Subversion Server\

1. Открываем коммандную строку и переходим в директорию c:\Program Files\CollabNet\Subversion Server\httpd\conf
2. Создаём собственный доверенный сертификат - Certificate Authority

Выполняем
"c:\Program Files\CollabNet\Subversion
Server\httpd\bin\openssl.exe" req -new -newkey rsa:1024 -nodes -k
eyout ca.key -x509 -days 1000
-subj/C=RU/ST=Msk/L=Msk/CN=bla/emailAddress=yourmail@gmail.com
-out ca.crt -config openssl.cnf

Если в результате получили ошибку следующего вида
error on line -1 of c:\Program Files\CollabNet Subversion
Server\httpd\conf\open
ssl.cnf 2828:error:02001003:system library:fopen:No suchprocess:.\crypto\bio\bss_file.c
:126:fopen('c:\Program Files\CollabNet SubversionServer\httpd\conf\openssl.cnf','rb')
2828:error:2006D080:BIO routines:BIO_new_file:no such file:.\crypto\bio\bss_file.c:129:
2828:error:0E078072:configuration file routines:DEF_LOAD:no such file:.\crypto\conf\conf_def.c:197:
Играйтесь с дирректориями, причина - процедура не может найти файл openssl.conf
После выполнения команды в папке c:\Program Files\CollabNet\Subversion Server\httpd\conf появится 2 файла: ca.key и ca.crt
3. Генерируем клиентские сертификаты
3.1 Создаём запрос на создание клиентского сертификата.
Выполняем
"c:\Program Files\CollabNet\Subversion Server\httpd\bin\openssl.exe" req -new -newkey rsa:1024 -nodes -k eyout client01.key -subj /C=RU/ST=Msk/L=Msk/O=Inc/OU=Web/CN=grendaizer/emailAddress=semkaa@gmail.com -out client01.csr -config openssl.cnf

Если в результате получили ошибку следующего вида
Loading 'screen' into random state - done
Generating a 1024 bit RSA private key
.++++++
.++++++
writing new private key to 'client01.
unable to find 'distinguished_name' in config
problems making Certificate Request
3700:error:0E06D06C:configuration file
routines:NCONF_get_string:no value:.\cryp
to\conf\conf_lib.c:329:group=req name=distinguished_name
Опять играйтесь с дирректориями, процедура не может найти файл openssl.conf
После выполнения команды в папке c:\Program Files\CollabNet\Subversion Server\httpd\conf появится 2 файла: client01.key и client01.csr

3.2 Подписываем запрос, созданный в пункте 3.1
3.2.1 Выполняем группу комманд для создания директорий и файлов, описанных в графе [CA] конфигурационного файла openssl.cnf
mkdir demoCA
mkdir demoCA\certs
mkdir demoCA\crl
mkdir demoCA\newcerts
copy con demoCA\index.txt /Y - нажмите Ctrl+Z, нажмите Enter
copy con demoCA\serial /Y - нажмите Ctrl+Z, нажмите Enter
copy con demoCA\crlnumber /Y - нажмите Ctrl+Z, нажмите Enter
echo 01 > demoCA\serial

3.2.2 Правим файл openssl.cnf
В разделе [ CA_default ]
certificate = ca.crt
private_key = ca.key
В разделе [ policy_match ]
organizationName = optional

3.2.3 Выполняем
"c:\Program Files\CollabNet\Subversion Server\httpd\bin\openssl.exe"
ca -config openssl.cnf -in client01
.csr -out client01.crt -batch
Возможные ошибки:
1. wrong number of fields on line 1 (looking for field 6, got 1, '' left) - файл index.txt не пустой
После выполнения команды появится файл client01.crt.
Это клиентский сертификат, по умолчанию он действителен год от даты создания.
Мне сейчас лень искать параметры, отвечающие за период валидности сертификати, да и когда его срок истечет, по идее, ничего с доступом не случиться (кстати, а как сделать так, чтобы доступ закрылся?).
Ну и если будет желание перееиздать клиентский сертификат через год, тут надо будет почитать как работать с базой данных сертификатов (в openssl.exe я нашёл только параметр -updatedb).

4. Созданный сертификат надо передать клиенту.
Можно или обычными методами, или же упаковать его, защитив паролем (меняйте параметр pass)
"c:\Program Files\CollabNet\Subversion Server\httpd\bin\openssl.exe" pkcs12 -export -in client01.crt -in
key client01.key -certfile ca.crt -out client01.p12 -passout pass:111111

В результате будет создан файл client01.p12 который необходимо передать клиенту.

5. Конфигурация CollabNET Subversion для работы с ssl.
5.1 Правим файл httpd.conf
1. Убрать символ # в строке #LoadModule ssl_module modules/mod_ssl.so
2. В строке ServerName localhost:80 меняем порт на 443 - ServerName localhost:443
3. Убрать символ # в строке #Include conf/extra/httpd-ssl.conf
4. В директиву добавляем следующие строки:
SSLCACertificateFile "c:\Program Files\CollabNet\Subversion Server\httpd\conf\ca.crt"
SSLVerifyClient require
SSLRequireSSL
5.2 Редактируем файл httpd-ssl.conf (папка extra)
В директиве VirtualHost, если необходимо, правим значения следующих параметров:
1. ServerName
2. ServerAdmin
3. ErrorLog
4. TransferLog
5. DocumentRoot - выставить аналогично файлу httpd.conf
Выставить значения следующих параметров:
1. SSLCertificateFile - указываем путь к ca.crt ("c:\Program Files\CollabNet\Subversion Server\httpd\conf\ca.crt")
2. SSLCertificateKeyFile - указываем путь к ca.key ("c:\Program Files\CollabNet\Subversion Server\httpd\conf\ca.key")
3. CustomLog - выставить реальный путь, а то при запуске сервиса будет ругаться
4. SSLVerifyClient require - разкомментарить

Запускаем сервис CollabNetSubversionApache.