Microsoft имеет давнюю историю в запутывании разработчиков ошеломительным валом технологий доступа к данным. Бывали времена, когда казалось, будто каждый выпуск Windows, SQL Server или Visual Studio приводил к появлению нового API доступа к данным. Где-то по ходу дела — кажется, в 1996 году — Microsoft со свойственным ей энтузиазмом переводила разработчиков с ODBC на OLE DB.
ODBC (Open Database Connectivity) был старым стандартом доступа к системам управления базами данных (СУБД). OLE DB (Object Linking and Embedding Database) стал новой и универсальной утопией доступа к данным. Но это название совершенно не отражает суть данной технологии, о чем мы вскоре поговорим.
Я до сих пор помню статью Дона Бокса (Don Box) в его рубрике за июль 1999 года в «MSDN Magazine» (тогда этот журнал назывался «Microsoft Systems Journal»), где он описывал побудительные причины, которые привели к разработке OLE DB. В то время я считал, что эта технология гораздо сложнее ODBC, но, безусловно, намного более расширяемая. Название «OLE DB» вводит заблуждение потому, что не имеет ничего общего с OLE и не предназначена только для баз данных. Она действительно была рассчитана на универсальную модель доступа к любым данным — реляционным или каким-то другим, например к тексту, XML, базам данных, поисковым системам и др. OLE DB дебютировала, когда COM была последним писком моды на платформе Windows, поэтому ее API, тесно связанный с COM, и ее естественная расширяемость привлекала многих разработчиков.
Однако как API реляционной базы он так никогда и достиг производительности ODBC. В последующих технологиях доступа к данным, таких как в Microsoft .NET Framework, выбросили все, кроме средств доступа к данным, и мечта об универсальном доступе к данным начала угасать. Затем в августе 2011 года группа SQL Server, члены которой были самыми большими сторонниками OLE DB, сделала ошеломительное объявление о том, что «Microsoft поддерживает ODBC для доступа к реляционным данным из неуправляемого кода» (bit.ly/1dsYgTD). Они заявили, что рынок смещается от OLE DB к ODBC. Поэтому, занимаясь разработкой следующего поколения приложений на неуправляемом C++, мы с вами вновь возвращаемся к ODBC.
Хорошая новость в том, что ODBC — сравнительно простая технология. Кроме того, она работает чрезвычайно быстро. Часто заявлялось, что OLE DB превосходит ODBC по производительности, но это наблюдается лишь в крайне редких случаях. А плохая новость в том, что ODBC — это API в старом стиле C, о котором помнят лишь немногие разработчики (если вообще знали). К счастью, современный C++ приходит на помощь, резко упрощая программирование ODBC. Если вы хотите обращаться к базам данных в Windows Azure средствами C++, вам придется использовать ODBC. Давайте рассмотрим как.
Как и многие другие API в стиле C, ODBC смоделирован вокруг набора описателей (handles), представляющих объекты. Поэтому я вновь задействую свой верный шаблон класса unique_handle, о котором я писал и который я использовал во многих статьях из этой рубрики. Вы можете получить копию handle.h с dx.codeplex.com и следовать за мной. Однако ODBC-описатели слегка туповаты. ODBC API нужно сообщать тип каждого описателя, когда он используется, — как при создании, так и при освобождении описателя (и его нижележащего объекта).
Тип описателя выражается SQLSMALLINT, что представляет собой просто значение типа short int. Вместо определения traits-класса unique_handle для каждого типа объекта, определяемого ODBC, я намерен сделать шаблоном сам traits-класс. На рис. 1 показано, как это может выглядеть.
Рис. 1. Traits-класс для ODBC
template <SQLSMALLINT T>
struct sql_traits
{
using pointer = SQLHANDLE;
static auto invalid() noexcept -> pointer
{
return nullptr;
}
static auto close(pointer value) noexcept -> void
{
VERIFY_(SQL_SUCCESS, SQLFreeHandle(T, value));
}
};
Метод close в traits-классе на рис. 1 — то место, откуда вы можете начать следить за тем, как нужно сообщать ODBC тип каждого описателя при его использовании с некоторыми обобщенными ODBC-функциями. Так как я использую самую новую предварительную сборку компилятора Visual C++ (на момент написания этой статьи таковой была CTP-версия за ноябрь 2013 года), я могу заменить устаревшую спецификацию генерации исключения спецификатором noexcept, что позволяет компилятору генерировать в ряде случаев более оптимальный код. К сожалению, хотя этот компилятор также обеспечивает возможность логического распознавания возвращаемого типа для auto-функций, в нем есть ошибка, которая не дает этого делать для функций-членов шаблонов классов. Конечно, поскольку сам traits-класс является шаблоном класса, псевдоним шаблона (template alias) оказывается очень удобным:
template <SQLSMALLINT T>
using sql_handle = unique_handle<sql_traits<T>>;
Теперь я могу определить псевдонимы типа для различных ODBC-объектов, например для объектов environment и statement:
using environment = sql_handle<SQL_HANDLE_ENV>;
using statement = sql_handle<SQL_HANDLE_STMT>;
Я также определил один такой псевдоним для соединений, но буду использовать более специфическое имя:
using connection_handle = sql_handle<SQL_HANDLE_DBC>;
Причина этого в том, что соединения всегда требуют несколько больше работы для очистки. На самом деле объектам соединения нужен класс connection, который будет надежно обрабатывать подключения. Но сначала необходимо создать environment.
Обобщенная функция SQLAllocHandle создает различные объекты. Здесь вы снова видите разделение объекта (или, по крайней мере, описателя) и его типа. Вместо того чтобы повсеместно дублировать этот код, я вновь использую шаблон функции, чтобы собрать воедино информацию о типах. Вот шаблон функции для обобщенной ODBC-функции SQLAllocHandle:
template <typename T>
auto sql_allocate_handle(SQLSMALLINT const type,
SQLHANDLE input)
{
auto h = T {};
auto const r = SQLAllocHandle(type,
input,
h.get_address_of());
// TODO: check result here ...
return h;
}
Конечно, шаблон обобщен не более, чем сама ODBC-функция, но поддерживает обобщенность в дружественной к C++ манере. К обработке ошибок я скоро вернусь. Поскольку этот шаблон функции будет создавать описатель заданного типа и возвращать оболочку описателя, я могу просто использовать один из псевдонимов типа, определенных мной ранее. В случае environment я могу сделать так:
auto e = sql_allocate_handle<environment>(SQL_HANDLE_ENV, nullptr);
Второй параметр предоставляет необязательный входной (или родительский) описатель для какого-либо логического включения (containment). У объекта environment нет предка, но он действует как предок для объектов соединений. К сожалению, это требует немного больше усилий для создания environment. ODBC необходимо, чтобы я сообщил ей о том, какую именно версию ODBC я ожидаю. Для этого я задаю атрибут environment с функцией SQLSetEnvAttr. Вот как это может выглядеть при обертывании в какую-либо вспомогательную функцию:
auto create_environment()
{
auto e =
sql_allocate_handle<environment>(SQL_HANDLE_ENV, nullptr);
auto const r = SQLSetEnvAttr(e.get(),
SQL_ATTR_ODBC_VERSION,
reinterpret_cast<SQLPOINTER>(SQL_OV_ODBC3_80),
SQL_INTEGER);
// TODO: check result here ...
return e;
}
К этому моменту я готов создать соединение, что, к счастью, довольно легко:
auto create_connection(environment const & e)
{
return sql_allocate_handle<connection_handle>(
SQL_HANDLE_DBC, e.get());
}
Соединения создаются в контексте environment. Здесь вы видите, что я использую environment как предок соединения. Мне все равно нужно установить соединение, и это задача функции SQLDriverConnect, часть параметров которой можно игнорировать:
auto connect(connection_handle const & c,
wchar_t const * connection_string)
{
auto const r = SQLDriverConnect(c.get(), nullptr,
const_cast<wchar_t *>(connection_string),
SQL_NTS, nullptr, 0, nullptr,
SQL_DRIVER_NOPROMPT);
// TODO: check result here ...
}
То есть константа SQL_NTS просто сообщает функции, что предыдущая строка подключения завершается null. Вместо этого вы могли бы явным образом задавать длину строки. Конечная константа SQL_DRIVER_NOPROMPT указывает, надо ли запрашивать пользователя, если для установления соединения необходимо больше информации. В данном случае я отвечаю «нет» на все запросы.
Но, как упоминалось ранее, корректное закрытие соединения требует немного больше усилий. Беда в том, что, пока для освобождения описателя соединения используется функция SQLFreeHandle, предполагается, что соединение закрыто, а значит, открытое соединение не будет закрыто автоматически.
Поэтому мне нужен класс connection, который отслеживает соединения этого класса. Например:
class connection
{
connection_handle m_handle;
bool m_connected { false };
public:
connection(environment const & e) :
m_handle { create_connection(e) }
{}
connection(connection &&) = default;
// ...
};
Теперь я могу добавить в свой класс метод connect, используя ранее определенную функцию connect, которая не является членом класса, и соответственно обновлять состояние соединения:
auto connect(wchar_t const * connection_string)
{
ASSERT(!m_connected);
::connect(m_handle, connection_string);
m_connected = true;
}
Метод connect изначально предполагает, что соединение не открыто и отслеживает тот факт, что на завершающей стадии соединение открыто. Тогда деструктор класса connection может автоматически разорвать соединение:
~connection()
{
if (m_connected)
{
VERIFY_(SQL_SUCCESS, SQLDisconnect(m_handle.get()));
}
}
Это гарантирует, что соединение будет разорвано до вызова деструктора описателя-члена, чтобы освободить сам описатель соединения. Теперь можно создать ODBC-класс environment и корректно и эффективно устанавливать соединение с помощью всего нескольких строк кода:
auto main()
{
auto e = create_environment();
auto c = connection { e };
c.connect(L"Driver=SQL Server Native Client 11.0;Server=...");
}
А как насчет объектов statement? Здесь вновь пригодится шаблон функции sql_allocate_handle, и я просто добавлю в свой класс connection другой метод:
auto create_statement()
{
return sql_allocate_handle<statement>(SQL_HANDLE_STMT,
m_handle.get());
}
Объекты statement создаются в контексте connection. Здесь видно, что connection является предком для объекта statement. В своей функции main я могу довольно легко создать объект statement:
auto s = c.create_statement();
ODBC предоставляет сравнительно простую функцию для выполнения SQL-выражений, но для удобства я вновь обертываю ее:
auto execute(statement const & s,
wchar_t const * text)
{
auto const r = SQLExecDirect(s.get(),
const_cast<wchar_t *>(text),
SQL_NTS);
// TODO: check result here ...
}
ODBC — крайне старый API в стиле C, поэтому в нем не используется const — даже по условию для компиляторов C++. Здесь мне нужно уйти от этой проблемы, чтобы вызывающий код был защищен от отсутствия поддержки const. В своей функции main я могу выполнять SQL-выражения:
execute(s, L"create table Hens ( ... )");
Но что будет, если я выполню SQL-выражение, которое возвращает набор результатов? Например, такое выражение:
execute(s, L"select Name from Hens where Id = 123");
В этом случае выражение в конечном счете становится курсором, и мне нужно извлекать результаты, если таковые есть, по одному за раз. Это задача функции SQLFetch. Например, мне нужно узнать, существует ли объект hen с данным Id:
if (SQL_SUCCESS == SQLFetch(s.get()))
{
// ...
}
С другой стороны, я могу выполнить SQL-выражение, возвращающее несколько записей:
execute(s, L"select Id, Name from Hens order by Id desc");
В этом случае я просто вызываю функцию SQLFetch в цикле:
while (SQL_SUCCESS == SQLFetch(s.get()))
{
// ...
}
Получение значений индивидуальных полей — это как раз то, для чего предназначена функция SQLGetData. Это еще одна обобщенная функция, и вам нужно точно описать ожидаемую информацию, а также буфер, куда будет скопировано конечное значение. Извлечение значения фиксированного размера осуществляется относительно прямолинейно. На рис. 2 показана простая функция для получения SQL-значения типа int.
Рис. 2. Получение целого SQL-значения
auto get_int(statement const & s,
short const column)
{
auto value = int {};
auto const r = SQLGetData(s.get(),
column,
SQL_C_SLONG,
&value,
0,
nullptr);
// TODO: check result here ...
return value;
}
Первый параметр в SQLGetData — это описатель выражения (объекта statement), второй — индекс поля (с отсчетом от единицы), третий — ODBC-тип для SQL int, а четвертый — адрес буфера для приема значения. Предпоследний параметр игнорируется, так как это тип данных фиксированного размера. Для других типов данных он указывал бы размер буфера на входе. Последний параметр содержит реальную длину или размер данных, скопированных в буфер. Он тоже не используется для типов данных фиксированного размера, но его можно задействовать для возврата информации о состоянии, например было ли значение равно null. Извлечение строкового значения лишь немногим сложнее. На рис. 3 показан шаблон класса, который будет копировать значение в локальный массив.
Рис. 3. Получение строкового SQL-значения
template <unsigned Count>
auto get_string(statement const & s,
short const column,
wchar_t (&value)[Count])
{
auto const r = SQLGetData(s.get(),
column,
SQL_C_WCHAR,
value,
Count * sizeof(wchar_t),
nullptr);
sql_check(r, SQL_HANDLE_STMT, s.get());
}
Заметьте, что в этом случае мне нужно сообщить функции SQLGetData реальный размер буфера для приема значения и что делать это следует в байтах, а не в символах. Если бы я запросил имя конкретного объекта hen и поле Name вмещало бы максимум 100 символов, я мог бы использовать функцию get_string:
if (SQL_SUCCESS == SQLFetch(s.get()))
{
wchar_t name[101];
get_string(s, 1, name);
TRACE(L"Hen’s name is %s\n", name);
}
Наконец, я могу повторно использовать объект connection для выполнения нескольких объектов statement, но, поскольку объект statement представляет курсор, я должен убедиться в закрытии курсора перед выполнением любых последующих выражений (объектов statement):
VERIFY_(SQL_SUCCESS, SQLCloseCursor(s.get()));
По иронии судьбы, это не вопрос управления ресурсами. В отличие от проблем с открытыми соединениями функции SQLFreeHandle безразлично, есть ли в statement открытый курсор.
До сих пор я избегал говорить об обработке ошибок, потому что это сама по себе сложная тематика. ODBC-функции возвращают коды ошибок, и проверка значения этих кодов для определения успеха операции возлагается на вас. Обычно функции будут возвращать константу SQL_SUCCESS, указывающую на успех, но могут возвращать и константу SQL_SUCCESS_WITH_INFO. Последняя тоже подразумевает успех, но сообщает о наличии дополнительной диагностической информации, если вы хотите получить ее. Как правило, при возврате константы SQL_SUCCESS_WITH_INFO я извлекаю диагностическую информацию только в отладочных сборках. Тем самым я могу получить максимум сведений при разработке и не тратить время в производственной среде. Конечно, я всегда собираю эту информацию, когда возвращается код ошибки. Независимо от причины процесс получения диагностической информации всегда одинаков.
ODBC предоставляет диагностическую информацию как набор результатов, и вы можете извлекать по одной записи за раз с помощью функции SQLGetDiagRec и индекса записи с отсчетом от единицы. Просто убедитесь, что вы вызываете ее с описателем объекта, который привел к появлению ошибки с данным кодом.
В каждой такой записи есть три важных части информации: код ошибки, специфичный для источника данных или драйвера ODBC, краткий условный код состояния из пяти символов, определяющий класс ошибки, на который может ссылаться эта запись, и более длинное текстовое описание диагностической записи. При наличии необходимых буферов я могу просто вызывать функцию SQLGetDiagRec в цикле, чтобы получить все эти части, как показано на рис. 4.
Рис. 4. Получение диагностической информации об ошибке
auto native_error = long {};
wchar_t state[6];
wchar_t message[1024];
auto record = short {};
while (SQL_SUCCESS == SQLGetDiagRec(type,
handle,
++record,
state,
&native_error,
message,
_countof(message),
nullptr))
{
// ...
}
Windows Azure наряду с SQL Server предоставляет удивительно простой способ приступить к работе с размещенными в ней базами данных. Это особенно привлекательно, поскольку механизм баз данных SQL Server — тот, который известен разработчикам на C++ уже многие годы. В то время как OLE DB сдана в утиль, ODBC более чем хорошо подходит для этих задач и фактически проще и быстрее OLE DB во всех отношениях. Конечно, какую-то часть работы берет на себя сам C++.
Подробнее об использовании Visual C++ для доступа к базам данных в Windows Azure см. в моем курсе для Pluralsight «10 Practical Techniques to Power Your Visual C++ Apps» (bit.ly/1fgTifi). Я даю в нем пошаговые инструкции, необходимые для подготовки и использования серверов базы данных, а также для связывания полей, чтобы упростить процесс извлечения записей данных. Там же вы найдете массу другой полезной информации.