Андрей Майоров
Введение
Не будет большим преувеличением сказать, что львиная доля разрабатываемых сейчас программ использует в своей работе базы данных. Большинство из них работает с одной базой, более редкие представители – с двумя, тремя, четырьмя и так далее, вплоть до полной неопределенности в количестве баз на этапе разработки.
Примеры приложений, работающих с одной БД, знакомы каждому. С двумя базами может работать, например, программа, синхронизирующая данные между ними. При этом базы могут иметь разную структуру и управляться разными СУБД. С заранее неизвестным количеством баз может работать, например, приложение, собирающее статистику из множества источников.
В рамках этой статьи нас не очень интересует, что именно приложение делает с базой данных. Важен сам факт подключения для выполнения каких бы то ни было операций. Рассмотрим традиционный для ADO.NET сценарий работы с базой.
Во-первых, мы должны знать строку подключения (connection string), во-вторых, создать объект подключения соответствующего типа, затем проинициализировать его этой строкой, открыть и начать использовать. По завершению использования объект подключения следует уничтожить. Выглядит это примерно так:
string conString = "...";
SqlConnection con = new SqlConnection();
con.ConnectionString = conString;
using( con ){
con.Open();
...
} // В этой точке подключение будет автоматически закрыто и уничтожено
|
Код прост и логичен, но в реальном приложении с ним могут возникнуть проблемы:
Почти всегда разработка приложения ведется не с теми строками подключения, которые будут использоваться при эксплуатации. Соответственно, мы не можем жестко зашить строку подключения в код, а должны как-то ввести ее во время работы приложения.
В ряде приложений разработчик не знает конкретного типа объекта подключения и работает с базовым интерфейсом IDbConnection. Для таких случаев код типа «new SqlConnection» не годится.
В то же время разработчик обычно четко представляет, какое подключение ему нужно открыть, и может логически обозначить его «база А» или «база Б», что бы это ни означало в среде конечного пользователя. В случае приложения с одной базой, можно подключаться и к некоторой базе по умолчанию, никак ее не именуя.
Очевидно, что удобным решением могло бы стать использование некоторого механизма, позволяющего получать объект подключения к базе данных по его логическому имени. Конечному пользователю этот механизм должен предоставлять возможность быстро и просто ассоциировать логическое название с реальной строкой подключения. Например, это можно делать в файле конфигурации. Подобный механизм мы и назовем «менеджером подключений к базам данных».
Основная функция менеджера
Основная функция менеджера – по заданному логическому имени вернуть объект подключения нужного типа, проинициализированный нужной строкой подключения.
В использовании это может выглядеть так:
SqlConnection c1 = (SqlConnection)dbmgr["beta"];
IDbConnection c2 = dbmgr.Default;
|
Приведение типа в первой строке обусловлено тем, что наше приложение может работать с базами разных типов, и, следовательно, менеджер не может возвращать объект подключения какого-то определенного типа. Так как любой объект подключения должен реализовывать интерфейс IDbConnection, менеджеру наиболее логично давать доступ к объектам именно через этот интерфейс.
Очевидно, что эта функция менеджера примерно соответствует шаблону (паттерну) проектирования Factory Method.
Перечисление подключений
Мы уже говорили о приложениях с неопределенным на этапе разработки количеством подключений. В практике применения менеджера подключений это может выглядеть, например, так: программа должна последовательно получить информацию из каждой БД, зарегистрированной в менеджере. При этом на момент написания программы мы не знаем, какие базы, и в каком количестве будут нужны конечному пользователю.
Очевидно, что в этом случае логические имена баз нас не очень-то интересуют. Гораздо больше нам нужна возможность перебора всех баз в менеджере. Например:
foreach( IDbConnection con in dbmgr ) {
// Получаем информацию
…
}
|
Для того чтобы эта языковая конструкция работала, да и вообще для перебора всех имеющихся подключений, наш класс DbManager должен реализовывать интерфейс IEnumerable.
Конфигурирование менеджера
Настройка менеджера заключается в установке соответствия между логическим именем подключения и информацией, достаточной для создания объекта подключения. Несложные размышления показывают, что достаточно знать тип объекта подключения, строку подключения, а также является ли данное подключение подключением по умолчанию.
Как уже указывалось, проще всего эту настройку делать через конфигурационный файл приложения. При этом и у разработчика есть единое место для описания подключений, и конечный пользователь может быстро адаптировать приложение к своим условиям. Нужно отметить, впрочем, что при этом подходе конечный пользователь должен знать стандартный формат строк подключения ADO.NET.
Примерный код, инструктирующий менеджера произвести чтение настроечных данных, даже если он уже был сконфигурирован:
dbmgr.Configure( true ); // forceReload = true |
При этом формат секции конфигурационного файла может быть таким:
<Database>
<connection name="alfa"
connectionString="..."
default="true" />
<connection name="beta"
connectionString="..."
type="OleDbConnection" />
</Database>
|
Здесь декларируется, что приложение использует две базы данных. Первая из них называется alfa, обслуживается объектом типа SqlConnection (ибо ничего другого не указано), и является подключением по умолчанию. Вторая носит логическое имя beta и обслуживается объектом типа OleDbConnection. Безусловно, для обеих баз указаны и корректные строки подключений.
Имея простой и удобный способ описания подключений через конфигурационный файл, мы, тем не менее, не должны забывать, что бывают ситуации, когда все это должно быть сделано программным путем. Например, так:
Configuration config = new Configuration();
// Настраиваемобъект config
…
// Назначаемконфигурациюменеджеру
DbManager.Configure( config );
|
В данном случае объект типа Configuration предоставляет нам те же возможности настройки, что и файл конфигурации.
Очень тяжело представить приложение, в котором существовало бы несколько отдельных наборов подключений к базам данных. Я говорю, например, о ситуации, когда в двух разных местах приложения мы используем два разных подключения с именем beta. Какие выводы из этого следуют?
Во-первых, это значит, что все экземпляры менеджера подключений, используемые в приложении, должны быть сконфигурированы одинаково. Соответственно, методы Configure(…) мы смело можем делать статическими.
Во-вторых, напрашивается вывод, что мы вполне можем обойтись одним экземпляром менеджера на все приложение. В некоторых случаях, о которых мы поговорим позже, нам понадобится большее, но все же ограниченное количество экземпляров. Из этого следует, что экземпляр менеджера мы должны получать не при помощи оператора new, а посредством некоего статического метода класса. Пример:
DbManager dbmgr = DbManager.Get(); |
Подобный подход напоминает о паттерне проектирования Singleton, но, в отличие от классической трактовки, у нас может быть не один экземпляр, а несколько. Впрочем, об этом мы еще поговорим.
Структура класса
Продумав сценарий использования менеджера, мы можем спроектировать структуру класса. Вот она:
public class DbManager : IEnumerable
{
public static DbManager Get() {...}
public IDbConnection this[string name]
{
get {...}
}
public IDbConnection Default
{
get {...}
}
public static void Configure( bool forceReload ) {...}
public static void Configure( Configuration config ) {...}
public IEnumerator GetEnumerator() {...}
// Непубличныеметодыичленыкласса
...
}
|
Краткое описание методов:
Get – возвращает менеджер подключений. Если экземпляра менеджера еще нет, создается новый.
this[ string ] – возвращает объект подключения по данному логическому имени. В том случае, если имя не указано (равно null), возвращается объект подключения по умолчанию.
Default – возвращает объект подключения по умолчанию.
Configure( bool ) – читает настроечную информацию из конфигурационного файла. Если мы пытаемся работать с еще не сконфигурированным менеджером, он должен автоматически вызвать этот метод.
Configure( Configuration ) – настраивает менеджер в соответствии с данным конфигурационным объектом.
GetEnumerator – позволяет пробежаться по всем подключениям менеджера циклом foreach.
Варианты работы с базой
Мы уже рассматривали кусок типового кода, работающего с базой. Более полный фрагмент выглядит так: мы сначала создаем подключение (например, SqlConnection), потом создаем команду (SqlCommand), добавляем к команде параметры, ассоциируем ее с подключением, открываем подключение, выполняем команду, закрываем подключение:
SqlConnection con = new SqlConnection();
con.ConnectionString = "...";
SqlCommand cmd = new SqlCommand();
cmd.CommandText = "...";
cmd.Connection = con;
cmd.Parameters.Add( new SqlParameter( ... ) );
using( con ){
con.Open();
cmd.Execute();
...
}
|
Мы делаем это при каждом обращении к базе, так что возникает вопрос: а не будет ли быстрее заранее создать и сохранить подключение и команду, а потом только использовать их? С точки зрения элементарной логики кажется очевидным, что должно быть быстрее. С другой стороны, известно, что создание объектов в .NET Framework происходит очень быстро, так что выигрыш вряд ли будет большим.
Проведем тест. В одном прогоне мы будем каждый раз создавать подключение и команду, а в другом – использовать готовые объекты. Команде определим три параметра. В двух прогонах по 100 000 итераций удалось выяснить следующее:
Первый подход, при котором все создается заново, примерно на 5 процентов медленнее второго.
В абсолютном исчислении это замедление составляет всего 0.08 миллисекунды на каждую итерацию, т.е. очень мало. Если учесть, что само обращение к базе выполняется на несколько порядков медленнее создания любого объекта, то выигрыш получается и вовсе умозрительный.
Какие выводы? Во-первых, логика восторжествовала – не создавать объекты оказалось быстрее, чем создавать. Во-вторых, это совершенно не важно. Разница в скорости между пересозданием объектов и использованием готовых настолько мала, что разработчик может смело выбирать тот или иной подход, руководствуясь только своим личным пониманием прекрасного.
Говоря же о практической экономии, можно сделать такую оценку: если у нас есть некая динамическая web-страница, которая делает одно обращение к базе, а к ней самой обращаются 10 раз в секунду, то сохранение объектов поможет нам выиграть целую секунду за полчаса.
Естественно, между этими двумя полярными вариантами есть большое число промежуточных состояний. Например, можно каждый раз создавать объект подключения, но хранить готовые команды. Этот подход, вероятно, весьма органично сочетается с визуальным дизайнером компонентов из Visual Studio. Набросав на компонент команды, мы получаем код для их инициализации, который выполняется при создании экземпляра компонента. Очевидно, что извлекать этот код из метода InitializeComponent неразумно, лучше просто назначить нужной команде тот объект подключения, который мы собираемся открывать в данный момент.
Повторное использование подключений
В то время как с повторным использованием командных объектов (SqlCommand, OleDbCommand и т.п.) все, в общем, понятно, вопрос повторного использования объекта подключения остается открытым. Нужно ли это кому-нибудь, а если нужно, то зачем?
Под «повторным» мы здесь понимаем такое использование, когда один и тот же объект подключения используется снова и снова во всех частях приложения, где нужен доступ к соответствующей базе данных. При этом мы сознаем, что все стандартные для ASP.NET объекты подключения не являются безопасными для многопоточного использования (non thread safe), поэтому для начала будем считать, что наше приложение имеет только один поток.
Какие проблемы могут возникнуть при подобном использовании объекта подключения? Во-первых, каждый раз, открывая подключение к базе данных, можно обнаружить, что оно уже было открыто раньше. Попытка открытия уже открытого подключения вызывает ошибку. Во-вторых, открытый объект DataReader блокирует свое подключение, так что до его закрытия выполнить еще какую-либо команду невозможно. Это может создать проблему в методе, вызванном во время чтения данных из базы.
Первую проблему обойти несложно. Достаточно проверять состояние подключения перед открытием, и пропускать этот шаг, если оно уже открыто. Здесь важно заметить, что метод, открывший подключение, обязательно должен его закрыть, так что, если проверка показала, что подключение нужно открывать, это значит и то, что его нужно закрыть после использования.
Обойти вторую проблему в том месте, где она дала о себе знать, невозможно. Действительно, данные уже читаются, подключение уже заблокировано, и сделать мы с этим ничего не можем. Единственное решение здесь – так проектировать блоки чтения, чтобы они даже потенциально не могли никого блокировать. Например, можно сначала прочитать все данные в массив, а уже потом проводить их дальнейшую обработку.
Основные проблемы ясны и достаточно серьезны. Какие преимущества могут быть у данного подхода? Перевешивают ли они недостатки?
Во-первых, мы можем существенно ускорить работу в тех приложениях, где свежеоткрытое подключение нужно специально готовить. Например, приложение может использовать application roles. Для входа в роль MS SQL Server требует выполнения хранимой процедуры sp_setapprole:
EXEC sp_setapprole 'SalesApprole', 'AsDeFXX' |
Очевидно, что если обработка запроса состоит, к примеру, из пяти обращений к базе, то гораздо быстрее будет открыть подключение и выполнить эту команду один раз, нежели все пять. Сама операция открытия подключения требует очень мало времени – на это есть connection pooling. Лишнее же обращение к базе – это серьезный удар по быстродействию.
Естественно, я говорю не о простейшем случае, когда все пять обращений к базе находятся в одном методе. В конце концов, мы живем во времена победившего объектно-ориентированного подхода, так что «макаронный» код почти почил в бозе. Все эти обращения совершаются разными компонентами, обслуживающими запрос. Как быть в этом случае? Предлагается открыть подключение и выполнить эту команду в начале обработки запроса, а затем передать объект подключения в дальнейшее использование.
Представляется, что это преимущество выглядит достаточно серьезным (конечно, для определенного класса приложений). Кстати, здесь стоит обратить внимание еще на одну особенность: войдя в роль и рассчитывая на автоматический выход из нее по закрытию подключения, можно получить неприятный сюрприз в том случае, если подключение на самом деле закрыто не будет. Последующие обращения к БД, возможно, будут выполняться с несоответствующими правами. С другой стороны, это может случиться только в приложении с весьма специфической архитектурой.
Во-вторых, можно представить себе приложение, открывающее чересчур много подключений. Большущая вложенность вызовов. Может быть, даже рекурсия. Все методы открывают подключения и, не закрывая, вызывают другие методы. В таком (совершенно гипотетическом) приложении можно столкнуться с тем, что свободные подключения закончатся, и в какой-то момент времени мы не сможем открыть подключение к базе. Использование одного объекта подключения могло бы нас здесь спасти.
Впрочем, подобная проблема выглядит совершенно надуманной. Если она и имеет где-то место, то это, скорее, ошибка в проектировании приложения, и решать ее нужно другими способами.
Режимы функционирования менеджера
Вернемся к менеджеру подключений. Очевидно, что он мог бы функционировать в двух режимах: либо каждый раз создавать новый объект подключения, либо возвращать уже готовый экземпляр, соответствующий заданному логическому имени.
На практике менеджер, осуществляющий кэширование объектов подключения, должен успешно проходить вот такой тест:
DbManager.Mode = DbManagerMode.CacheConnections;
DbManager dbmgr =DbManager.Get();
IDbConnection c1 = dbmgr["beta"];
IDbConnection c2 = dbmgr["beta"];
Assert.IsTrue( c1 == c2 ); // Менеджер возвращает один и тот же экземпляр
|
А «простой» менеджер, т.е. не осуществляющий кэширование, такой:
DbManager.Mode = DbManagerMode.DoNotCacheConnections;
DbManager dbmgr =DbManager.Get();
IDbConnection c1 = dbmgr["beta"];
IDbConnection c2 = dbmgr["beta"];
Assert.IsTrue( c1 != c2 ); // Менеджер возвращает разные экземпляры
|
Подобная функциональность позволила бы разработчику легко выбирать между двумя описанными выше вариантами работы с подключениями, и даже без больших сложностей перейти с одного подхода на другой в уже частично написанном приложении.
Многопоточность
Ранее мы рассматривали приложение, в котором есть только один поток (thread). Какие сложности могут встретиться, если потоков будет несколько?
По сути, сложность здесь только одна – один объект подключения можно одновременно использовать только в одном потоке. Если для каждого обращения создается новый объект подключения, то нас эта проблема совершенно не касается. Соответственно, менеджер подключений, работающий в этом режиме, очень прост в реализации. Метод, возвращающий объект подключения по имени, может выглядеть так:
public override IDbConnection this[String name]
{
get
{
ConnectionInfo info = (ConnectionInfo)_config.Connections[name];
if( info != null )
{
return CreateConnection( info );
}
return null;
}
}
|
Несколько более сложно выглядят действия при кэшировании объектов подключения. С одной стороны, нужно организовать некий словарь готовых объектов, из которого выдаются объекты по запросу. С другой стороны, мы должны сделать так, чтобы каждый поток работал со своим экземпляром данного подключения. Пытаться реализовать такую функциональность в одном объекте, обслуживающем сразу все потоки, может быть слишком сложно. Поэтому предлагается сделать так, чтобы каждый экземпляр менеджера обслуживал только один поток. Очевидно, что в этом случае создается некоторое неопределенное количество экземпляров, не большее, чем общее количество потоков в приложении. Неопределенность обусловлена тем, что для потоков, в которых менеджер не требуется, создавать экземпляр не нужно.
Таким образом, нужно, во-первых, написать такой метод Get, который бы возвращал экземпляр, приписанный к вызывающему потоку, и, во-вторых, сделать словарь готовых объектов. Приблизительно так может выглядеть этот фрагмент кода:
[ThreadStatic] private static DbManager _instance;
private ListDictionary _connections = new ListDictionary();
internal static new DbManager Get()
{
// Если экземпляр уже есть, вернуть его
if( _instance != null ) return _instance;
// Создатьновыйэкземпляр
_instance = new CachingDbManager();
_instance.Init();
return _instance;
}
public override IDbConnection this[String name]
{
get
{
// Пытаемся взять готовый объект из словаря
IDbConnection result = (IDbConnection)_connections[name];
if( result == null )
{
// Ищем описание подключения в конфигурации
ConnectionInfo info = (ConnectionInfo)_config.Connections[name];
if( info != null )
result = CreateConnection( info );
}
return result;
}
}
|
Менеджер и ASP.NET
Как известно, приложения ASP.NET весьма активно используют многопоточность. В то же время делают они это настолько неявно, что этот факт легко оставить без внимания и получить неожиданные ошибки.
Вспомним, в общих чертах, структуру обычного приложения ASP.NET. В домене приложения (AppDomain) есть несколько экземпляров класса HttpApplication. Каждый из этих экземпляров обладает набором сопутствующих ему модулей (HttpModule). Набор модулей у каждого приложения одинаков, да и сами приложения, по идее, не должны ничем отличаться. Далее, домен приложения имеет набор рабочих потоков (working threads), готовых обслуживать пользовательские запросы. Со всей очевидностью, потоков существует по крайне мере столько же, сколько объектов Http-приложения.
При обслуживании запроса ASP.NET неким псевдослучайным образом выбирает рабочий поток и объект приложения, с которым этот поток будет работать. В связи с этим, определенный объект приложения в разных запросах будет, скорее всего, работать с разными потоками.
Предположим теперь, что в нашем приложении мы создали командный объект (SqlCommand) и сохранили его для дальнейшего использования. Команда связана с определенным объектом подключения, а именно, с тем объектом, который был возвращен менеджером подключений в момент создания и первого выполнения команды. Не будем, однако, забывать, что данный объект HttpApplication при обслуживании следующего (в его хронологии) запроса, скорее всего, будет работать уже с другим рабочим потоком, а поэтому менеджер подключений вернет не то подключение, с которым связана наша команда. Хуже того, с возвращенным подключением, вероятно, будет связана аналогичная команда в другом объекте приложения.
Выход из описанной ситуации достаточно прост. Необходимо сделать такой модуль (HttpModule), который в начале обработки запроса будет связывать менеджер подключений, приписанный к данному объекту приложения, с потоком, который сейчас работает с этим приложением и со всеми подчиненными ему объектами. Это устранит все проблемы такого рода и позволит опять забыть про реальное положение дел с потоками в ASP.NET.
Код модуля предельно прост:
public class AspAdapter : IHttpModule
{
private HttpApplication application;
private DbManager manager;
public void Init(System.Web.HttpApplication context)
{
application = context;
manager = DbManager.Get();
application.BeginRequest += new EventHandler( OnBeginRequest );
}
protected void OnBeginRequest( object sender, EventArgs e )
{
manager.Init();
}
public void Dispose()
{
application.BeginRequest -= new EventHandler( OnBeginRequest );
}
}
|
В последних примерах можно заметить ранее не упоминавшийся метод Init. Он служит для привязки данного экземпляра менеджера к вызывающему потоку.
Пожалуйста, закрывайте двери!
Общеизвестно, что при использовании пула подключений (connection pool) основополагающий принцип работы с подключениями гласит: открывай поздно, закрывай рано. Иными словами, открывать нужно перед самым использованием, а закрывать сразу после оного. При этом нужно помнить, что в блоке использования подключения к базе может произойти какая-нибудь ошибка (exception), которая помешает закрыть подключение.
Мы уже рассматривали обычный для ADO.NET способ работы с объектом подключения, примерно такой:
using( connection ) {
connection.Open();
// Активнее используем подключение! Иначе, зачем открывали?!
...
}
|
Он прост и удачен, если не жалко уничтожить объект подключения в конце блока, т.е. если каждый раз создается новый объект подключения. Если же хочется использовать один объект, код становится гораздо менее красивым:
bool wasOpened = false;
if( connection.State == ConnectionState.Closed )
{
connection.Open();
wasOpened = true;
}
try {
// Используемподключение
…
}
finally
{
if( wasOpened ) connection.Close();
}
|
Помимо общей сложности, он еще и затрудняет возможность переключения между двумя режимами работы менеджера подключений. Мы же не хотим, перекинув флаг, еще и править все блоки работы с базой.
Используем для решения тот же механизм детерминированной деструкции – интерфейс IDisposable, который столь упрощает использование подключения по стандартной схеме. Нам достаточно создать класс, который при конструировании открывал бы подключение, если это необходимо, а при уничтожении – закрывал бы, если открывал его сам. При этом мы можем сделать класс достаточно умным, чтобы он понимал, в каком режиме работает менеджер подключений. Если менеджер не кэширует объекты, наш сервисный класс будет удалять их в конце блока using.
В использовании это будет выглядеть примерно так:
using( new DbOpen( connection )) {
// Используем подключение, раз открывали!
…
}
|
Отметим также, что при конструировании экземпляра класса подключение открывается автоматически. Таким образом, еще одна строка в стандартном сценарии становится ненужной.
Реализация
Данный подход уже реализован и неоднократно прошел полевые испытания. Более подробно о готовом модуле можно узнать на http://www.byte-force.com/russian/products/tech/lsddatabase.html.
Ссылки
E. Gamma, et al., Design Pattern: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1995
ПРИМЕЧАНИЕ
От редакции
Многие участники редакционной коллегии, имеющие опыт работы с базами данных, неоднозначно расценивает данную статью. С одной стороны, идея инкапсуляции работы с подключениями, позволяющая получать подключения по логическим именам, хороша. Она упрощает код, тем самым снижая вероятность появления ошибок. С другой стороны, создание самодельного пула, а также реализация закрытия соединения и многопоточной работы, является успешным решением собственноручно созданной проблемы. Более того, так как кэш может возвращать один и тот же экземпляр подключения при разных вызовах, в программе может возникнуть ошибка из-за случайного (неявного) использования одного подключения в разных алгоритмах. То есть велика вероятность того, что программист в двух алгоритмах попытается создать две независимых транзакции, но поскольку соединение физически одно, это ему не удастся. Как, собственно, заметил сам автор, выигрыша в скорости такое решение не дает, и смысл кэширования просто непонятен.
Таким образом, мы рекомендуем использовать на практике идею инкапсуляции работы с подключениями, но не кэширование подключений. Однако изучение этой части статьи интересно, так как в ней используются методы оптимизации, вполне применимые в других случаях.
|
|