Отправляет email-рассылки с помощью сервиса Sendsay

.NET: Записки программиста

  Все выпуски  

В поисках баланса или Вновь на Дальнем рубеже ...




.NET: Записки программиста или хлопок одной ладони


И снова, доброй ночи!

Когда-то я наивно думал, что в следующем выпуске смогу поздравить вас с Рождеством. А сейчас, осталось совсем чуть-чуть и в самый раз будет поздравлять с не менее важным (а по сути, очень похожим) событием из жизни того же самого персонажа ...

Почему прошло так много времени? Трудный вопрос ... Иногда была работа, такая интересная, что не хотелось отвлекаться, иногда - настолько скучная, что после этого не хотелось заниматься чем бы то ни было другим. Но сейчас, я надеюсь, мне удастся свести время, затрачиваемое на скучные вещи к минимуму, посвятив его как работе интересной, так и интересной не-работе ... Так что - show must go on!

Вначале, я хотел посвятить этот выпуск подборке событий из жизни полезных библиотек, утилит или просто интересных примеров программирования, с которыми сталкивался в последнее время, но, увлекшись одной такой библиотекой, решил, что она (а скорее сама тема), заслуживает более подробного внимания. Так что встречайте:

Выпуск тринадцатый: В поисках баланса или Вновь на Дальнем рубеже ...


Монгольский конный лучник (Keshik) в оличие от рыцаря имеет защиту 2 вместо 3, стоит 60 щитков,
а не 70 и не требует железа, что весьма важно порой. Т.е. это юнит похуже рыцаря, но и за меньшую цену
 Недостаток в защите весьма легко исправить против медленных юнитов. Т.к. конный лучник имеет 2 хода,
им всегда можно атаковать медленный юнит. Однако сражаться против быстрых юнитов ими не очень
эффективно, особенно против китайских всадников. Имея 3 хода, против 2-х китайские всадники всегда
смогут атаковать конных лучников, в полной мере задействовав их слабость в защите.
(Civilization III)


Сегодня мы опять перенесемся на Дальние рубежи программ, которые мы проектируем. Тем кто не читал (или успел позабыть) одинадцатый выпуск этой рассылки "На дальнем рубеже ..." я вкратце напомню, о чем идеть речь ...

Какое бы приложение, работающее с базой данных, мы не проектировали, в нем всегда можно выделить Database Layer (который содержит хранилище данных) и уровень, который обращается к этому хранилищу. В сложных приложениях это может быть Data Access Component Layer, расположенный на выделенном application server, в случае попроще с хранилищем данных работают объекты уровня Business Components \ Busines Objects. В самом простом варианте объекты, обращающиеся к Database Layer определены прямо в code-behind classes ASP.NET приложения.

Как правило, для доступа к хранилищу данных используется ADO.NET. Независимо от сложности архитектуры или типа приложения (Descktop \ Web) удобно использовать промежуточный, так называемый Data Access Helper Layer, располагаемый между Database Layer и остальными уровнями. Он предназначен для повышения уровня абстракции (еще бы, где вы видели уровни архитектуры, которые его понижают? :) и инкапсулирования таких объектов ADO.NET как DbConnection, DataAdapter, DbCommand, DbParameter (а вернее их наследников для разных реализаций баз данных, например SqlCommand для MS SQL Server). Программист имеет возможность работать с более высокоуровневыми методами типа "ExecuteDataSet" или "ExecuteScalar" получая в качестве результата контейнеры с данными (DataSet\DataTable, DataReader) или скалярные значения. Именно такой библиотекой и является Microsoft Data Access application block (DAAB), описанный в одинадцатом выпуске.

Хорошо, скажите вы, уровней становится больше, абстракция повышается, библиотеки для этого есть, а в чем собственно проблема? Дело в том, что любое решение как правило является компромиссом между такими показателями как "надежность", "производительность", "понятность кода" и некоторых других. И Microsoft Data Access application block вовсе не исключение.

Чтобы проще было понять, что мы приобретаем и чем нам приходится расплачиваться, давайте разберем ситуацию на небольшом примере ...

Воспользуемся базой данных "Northwind", которая идет в стандартной инсталляции MS SQL Server 2000. Предположим, что нам нужно вызвать хранимую процедуру "Customers_Update", обновляющую данные о заказчике.

Если бы мы работали с ADO.NET напрямую, то должны бы были написать примерно такой код:

using (SqlConnection connection = new SqlConnection("connection string"))
{
  connection.Open();

  SqlCommand command = new SqlCommand();
  command.Connection = connection;
  command.CommandType = CommandType.StoredProcedure;
  command.CommandText = "Customers_Update";

  SqlParameter sqlParameter;

  sqlParameter = new SqlParameter("@OriginalCustomerID", SqlDbType.NChar);
  sqlParameter.Size = 5;
  sqlParameter.Value = "AFAIC";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@CustomerId", SqlDbType.NChar);
  sqlParameter.Size = 5;
  sqlParameter.Value = "AFAIC";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@CompanyName", SqlDbType.NVarChar);
  sqlParameter.Size = 40;
  sqlParameter.Value = "New company name";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@ContactName", SqlDbType.NVarChar);
  sqlParameter.Size = 30;
  sqlParameter.Value = "New contact name";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@ContactTitle", SqlDbType.NVarChar);
  sqlParameter.Size = 30;
  sqlParameter.Value = "New contact title";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@Address", SqlDbType.NVarChar);
  sqlParameter.Size = 60;
  sqlParameter.Value = "New address";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@City", SqlDbType.NVarChar);
  sqlParameter.Size = 15;
  sqlParameter.Value = "New city";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@Region", SqlDbType.NVarChar);
  sqlParameter.Size = 15;
  sqlParameter.Value = "New Region";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@PostalCode", SqlDbType.NVarChar);
  sqlParameter.Size = 10;
  sqlParameter.Value = "New postal code";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@Country", SqlDbType.NVarChar);
  sqlParameter.Size = 15;
  sqlParameter.Value = "New country";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@Phone", SqlDbType.NVarChar);
  sqlParameter.Size = 24;
  sqlParameter.Value = "New phone";
  command.Parameters.Add(sqlParameter);

  sqlParameter = new SqlParameter("@Fax", SqlDbType.NVarChar);
  sqlParameter.Size = 24;
  sqlParameter.Value = "New fax";
  command.Parameters.Add(sqlParameter);

  command.Connection.Open();
  command.ExecuteNonQuery();
}

Не очень впечатляет, не правда ли? В конце концов, если бы Господь хотел, чтобы мы писали код именно таким образом,  он бы создал большие специальные кнопки "Сopy" и "Paste", разместив их в центре клавиатуры. Посмотрим, что предлагает нам Microsoft Data Access application block:

Database database = DatabaseFactory.CreateDatabase();
database.ExecuteNonQuery("Customers_Update", "ALFKI", "ALFKI", "Company name", "Contact name", "Contact title", "Address", "City", "Region", "PostCode", "Country", "Phone", "Fax");

Комментарии излишни. Излишни? Как люди недоверчивые, давайте все таки посмотрим, за счет чего более чем 60 строк кода превратились всего лишь в две. Итак:

  • Скорость написания кода - комментировать думаю не нужно.

  • Понятность кода - опять таки с явным отрывом лидирует второй вариант. Он работает с более высокоуровневыми понятиями (объект "Database", метод "ExecuteNonQuery") и намного короче - одного взгляда достаточно, чтобы понять, о чем идет речь. Правда понятнее он с одной небольшой натяжкой - в первом варианте мы видим названия параметров, которые скрыты от нас во втором варианте. Но тем не менее в общем второй вариант намного лучше.

  • Производительность - незначительно выше в первом варианте. Дело в том, что поскольку у второго варианта нет описания параметров хранимой процедуры, он вынужден определять их сам, обращаясь к метаданным, которые предоставляет ADO.NET. Это достаточно длительная операция, но полученные данные кешируются, так что время тратится только при первом обращении к процедуре, потом коллекция объектов DbParameter берется прямо из кеша. Так что, как правило, разницу в производительности во внимание можно не принимать.

  • Ошибкоустойчивость - вот, этот показатель и есть той самой ложкой дегтя, которая портит весь праздник. Обратите внимание, ошибись мы в любом параметре в первом случае - и при вызове хранимой процедуры будет выброшено исключение. Тип параметра, ошибка всего в одну букву в его имени - все это обнаружится при первом же обращении. Теперь попробуем таким же образом вывести из себя систему, используя Data Access application block. Меняем местами параметры "Company name" и "Fax" - система невозмутимо записывает неправильные данные в таблицу. Меняем код хранимой процедуры, изменяя порядок аргументов - тот же результат. Отчаявшись, передаем вместо параметра "CompanyName" значение другого типа, скажем число 356 631. Система заботливо приводит это число к строковому типу и сохраняет строку "356 631" в качестве названия компании.

Собственно в этом и заключался главный недостаток использования DAAB: он может привести к появлению ошибок, которые очень трудно обнаружить даже при тщательном тестировании. Все работает, исключений нет, просто система настойчиво посылает электронные письма по телефонному номеру, а вместо названия фирмы использует дату обновления записи.

Такая ситуация сохранялась до недавнего времени, пока группой разработчиков Microsoft в феврале этого года не было предложено еще одно решение - Stored Procedure Object Interface Layer (SPOIL).

Основная идея этой небольшой библиотеки (4-е .cs файла) заключается в следующем: для каждой вызываемой хранимой процедуры разработчик создает метод, принимающий тот же набор параметров, что и сама процедура. Кроме того, дополнительная информация о методе и параметрах указывается при помощи custom attributes. Когда идет обращение к SPOIL, описание параметров метода и их имена добываются через reflection и на основании этой информации и формируется коллекция параметров для хранимой процедуры.

Так, в нашем случае, разработчик реализовал бы следующий метод:

[SqlCommandMethod(CommandType.StoredProcedure, "Customers_Update")]
public void CustomersUpdate(
  [SqlParameter(5)] string CustomerId,
  [SqlParameter(5)] string OriginalCustomerID,
  [SqlParameter(40)] string CompanyName,
  [SqlParameter(30)] string ContactName,
  [SqlParameter(30)] string ContactTitle,
  [SqlParameter(60)] string Address,
  [SqlParameter(15)] string City,
  [SqlParameter(15)] string Region,
  [SqlParameter(10)] string PostalCode,
  [SqlParameter(15)] string Country,
  [SqlParameter(24)] string Phone,
  [SqlParameter(24)] string Fax)
{
  using (SqlConnection sqlConn = new SqlConnection("connection string"))
  {
    sqlConn.Open();

    SqlCommandGenerator.ExecuteCommand(
      sqlConn,
      this.GetType().GetMethod("CustomersUpdate"),
      CustomerId,
      OriginalCustomerID,
      CompanyName,
      ContactName,
      ContactTitle,
      Address,
      City,
      Region,
      PostalCode,
      Country,
      Phone,
      Fax);
  }
}

Аттрибут "SqlCommandMethod" указывает, что вызывается хранимая процедура с именем "Customers_Update". Аттрибуты "SqlParameter" уточняют тип параметров, а их названия берутся из имен входных аттрибутов нашего метода ("CustomersUpdate"). Если у нас есть параметр, который не нужно передавать в хранимую процедуру, его нужно пометить аттрибутом "NonCommandParameter". Обратите внимание, вторым параметром в вызове SqlCommandGenerator.ExecuteCommand() передаются метаданные о методе, полученные при помощи reflection. На их основании в методе "ExecuteCommand" и будет сформирована коллекция параметров для вызова хранимой процедуры.

Давайте теперь посмотрим что же дает нам этот подход:

  • Скорость написания кода - хуже чем во втором варианте, но все же лучше чем в первом (плюс тут нет copy\paste операций при которых и совершается львиная доля ошибок)

  • Понятность кода - если правильно к этому подойти и собрать подобные методы в специальные классы (например CustomerDACL для хранимых процедур, относящихся к сущности Customer), то в коде будут встречаться вызовы типа
CustomerDACL.Update("ALFKI", "ALFKI", "Company name", "Contact name", "Contact title", "Address", "City", "Region", "PostCode", "Country", "Phone", "Fax");

которые выглядят так же наглядно, как и во втором варианте.

  • Ошибкоустойчивость - такая же, как и в первом случае, то есть - высокая. При несовпадении имен или типов параметров будут выбрасываться исключения.

  • Производительность - ага, скажите вы, а вот опять, та самая ложка дегтя! Все знают, что работа через reflection очень медленная, так что несмотря на большую надежность этот подход более медленный, что явно не оправдывается в большинстве web-приложений.

Так же подумал и я и, для очистки совести, создал тестовое web-приложение, которое вызывало хранимые процедуры как при помощи DAAB так и при помощи SPOIL. Тестировалось три варианта:

  • вызов метода без параметров - "Categories_Get_List"

  • вызов метода с одним параметром - "Customers_GetByCustomerID"

  • вызов метода с 12-ю параметроми - уже знакомый нам "Customers_Update"

Для тестирования я использовал Visual Studio 2005, создав в ней нагрузочный тест (load test), который имитировал одновременную работу 10-и пользователей, обращающихся к тестовому web-приложению по внутренней сети. Сравнительные результаты приведены в следующей таблице:

Тип обращения 0 параметров 1 параметр 12 параметров
DAAB 118  491  489 
SPOIL 118  498  428 

Что же, вполне предсказуемые результаты ... Разницы в скорости при работе с нулем и одним параметром практически никакой, ну а 17% потери производительности в случае с 12-ю параметрами - плата за работу через reflection. И я уже совсем собирался вздохнуть и произнести что-то банальное типа "за все хорошее приходится чем-то платить", как мне в голову пришла одна мысль! SPOIL - маленькая и простая библиотека, действительно простая. А вдруг она не кеширует полученные коллекции параметров?!! Я быстро просмотрел код - так и есть, каждый вызов приводил к формированию коллекции параметров заново. Остается только удивиться, что разница в производительности была такой маленькой!

Воспрянув духом я выковырял механизм кеширования из MS Data Access application block и прикрутил ее к SPOIL. Сам механизм достаточно простой, заглянув в два файла ("ParameterCache.cs" и "CachingMechanism.cs"), вы быстро во всем разберетесь - клонированная коллекция параметров сохраняется в Hashtable по ключу, который формируется на основании "Connection string" и имени вызываемой хранимой процедуры.

Новое тестирование было более обнадеживающим:

Тип обращения 0 параметров 1 параметр 12 параметров
DAAB 118  491  489 
SPOIL 118  498  428 
SPOIL with caching 119  500  525 

Как видите, разницы в случае с нулем и одним параметром почти нет (что вполне логично, так как ускорять там было практически нечего), зато в последнем варианте производительность превзошла DAAB блок, работающий без всякой reflection. То, что производительность SPOIL не только сравнялась, но и превзошла DAAB, можно объяснить тем, что код SPOIL расчитан именно на SQL Server, а DAAB слишком универсален и меньшая скорость работы - плата за то, что вы теоретически можете переключаться на использование другого типа хранилища данных (например Oracle) вобще не меняя и перекомпилируя код.

Прим:
Кстати говоря, достоинства SPOIL этим не исчерпываются. Эта библиотека содержит еще несколько возможностей, повышающих уровень абстракции, например позволяет получать результат не в виде нетипизированного контейнера (типа DataTable или DataReader), а в виде коллекции типизированных объектов (например struct Customer { ... }), заполняя их при помощи той же reflection.

Что ж, мы лишний раз убедились, что идеальные решения встречаются крайне редко (крайне - потому что нужно же все таки верить в идеальную архитектуру, ведь и святой Грааль придумали не просто так :). Зато, можно взять конкретное решение и оценить - вот в этом случае этот параметр более важен, этот менее, то что мы теряем тут, вполне окупается вот этим вот преимуществом и т.д. И поэтому, какие бы библиотеки и шаблоны решений не создавались, какие бы оболочки для визуального программирования не разрабатывались, какие бы ни делались универсальные компоненты - Архитекторов всегда будут ценить, потому что именно они с умным видом смогут сказать "Для вашего племени охота будет удачной, если каждый воин привяжет к запястью перо белой совы, пойманной девственницой в полночь на опушке леса" - и охота будет удачной! :)

Удачи вам, счастливо и - до следующей встречи!



В избранное