Сообщений 22    Оценка 190        Оценить  
Система Orphus

Extensible Storage Engine

Краткий обзор

Автор: Artour A. Bakiev
Quest Software, Inc.

Источник: RSDN Magazine #1-2007
Опубликовано: 25.04.2007
Исправлено: 26.06.2007
Версия текста: 1.1
Введение
Типичные сценарии сохранения состояния
Extensible Storage Engine
Предназначение
Где может применяться
Для кого предназначена технология
Требования времени исполнения [Run-Time Requirements]
Общее впечатление
Начало работы
Работа с данными
Как устроена база
Индексация Unicode-строк
Заключение
Ссылки

Исходные коды к статье

Введение

В этой статье я попытаюсь дать обзор технологии “Extensible Storage Engine”. Хотя технология вышла в свет немногим более года назад, новой её можно назвать только условно. Она использовалась при создании таких продуктов, как Active Directory и Exchange 2000, но до недавнего времени не была известна широкому кругу разработчиков. В узком же кругу тех, кто с ней сталкивался, она была известна под именем “JET Blue”.

Примерно в конце 2005 года корпорация Microsoft решила опубликовать “JET Blue”, дав ей более привлекательное с коммерческой точки зрения название “Extensible Storage Engine”, и справедливо решив, что технология достаточно обкатана и может оказаться полезной многим.

Дальше, в меру возможностей, я опишу назначение технологии, её слабые и сильные стороны, а также возможные области применения. Но прежде давайте посмотрим, какие бывают типичные сценарии сохранения состояния.

Типичные сценарии сохранения состояния

Многим приложениям между перезапусками требуется сохранять своё состояние в каком-нибудь постоянном хранилище. Хранилище подбирает (обычно) разработчик (если нет специальных маркетинговых требований) с учётом нужд конкретного приложения.

Одному приложению достаточно сохранить настройки пользователя, другому необходимо сохранять результаты своей работы, третьему – промежуточную информацию. В зависимости от этого в качестве хранилища программист может выбрать файл, реестр, СУБД, Active Directory и т.д., а возможно, и какую-нибудь комбинацию этих вариантов.

Зачастую при выборе хранилища в первую очередь приходится руководствоваться объёмом сохраняемых данных. Помимо этого, важной характеристикой может оказаться скорость доступа и/или поиска, а также удобство работы с выбранным хранилищем.

Давайте рассмотрим эти критерии подробнее.

Предположим, что я хочу создать ещё один (новый и улучшенный) “Notepad”. “Notepad” является обычным desktop–приложением, и у меня не должно возникнуть проблем при выборе хранилища – файлы будут сохраняться там, где укажет пользователь, а настройки – в реестре.

Если же я захочу создать поисковую систему, которая затмит собой Google, мне нужно будет подумать о том, где сохранять собранные URL’ы. Для их хранения, в качестве back-end’а, я выберу хорошо зарекомендовавшую себя (а возможно, наиболее доступную мне) СУБД. Это решение можно назвать классическим для серверного приложения, и оно не вызовет сколько-нибудь серьёзных размышлений с моей стороны.

А теперь представим, что я задумал конкурировать с такими популярными почтовыми клиентами, как “The Bat” и “Eudora”. Рано или поздно передо мной встанет вопрос о том, где программа будет хранить письма. Сохранять ли их по отдельности: один файл – одно письмо? Или, может быть, сохранять их все в одном плоском файле? Ни тот, ни другой вариант не представляются оптимальными. И вот почему.

При использовании схемы «один файл – одно письмо» много времени будет теряться на открытие файлов, а значит, и на “full text search”, а также и на сортировку, и группировку. Используя схему «всё в одном файле», я экономлю время на открытии файла, но зато теряю его, занимаясь поиском начала каждого письма. Хотя реализация “full text search” и становится проще, поскольку начало каждого письма так или иначе вычисляемо, но сортировка и группировка, как кажется, потребуют нетривиальных алгоритмов. Да и скорость поиска по ключевым полям (from, to, subject) будет оставлять желать лучшего.

Если мой почтовый клиент не будет выполнять поиск, группировку и сортировку хотя бы с той же скоростью, что и другие почтовые клиенты, у меня мало шансов составить им конкуренцию. Похоже, что решение в лоб (использовать для хранения файл(ы)) не годится. Что бы мне здесь подошло, так это небольшая база данных. Но без сервера и без сложного процесса инсталляции. Иными словами, мне нужна реляционная база данных, невидимая для пользователя.

Прежде чем предлагать какие бы то ни было решения, давайте взглянем на небольшую таблицу. В ней отражены наиболее интересные характеристики таких хранилищ, как реестр, плоский файл, Active Directory и СУБД:

РеестрПлоский файлActive DirectoryСУБД
Возможность хранить большие объёмы данныхОтсутствуетПрисутствуетПрисутствует (1)Присутствует
Скорость поиска-НизкаяВысокаяВысокая
Хранилище работает как внешний, по отношению к приложению, сервисНетНетДаДа/Нет4
Необходимо производить инсталляцию хранилищаНетНетНет (2)Да/Нет4
Права, необходимые для записи в хранилищеПрава на модификацию реестраПрава на модификацию файла(ов)Права на запись в Active DirectoryПрава на запись в таблицы базы данных
Хранилище поддерживает транзакцииНетНетНетДа
Сложность использования хранилища (3)НизкаяНизкаяСредняяСредняя

1 – Active Directory действительно способна хранить большой объём информации. Но Microsoft не рекомендует приложениям использовать Active Directory для этих целей. В качестве замены Microsoft рекомендует использовать ADAM.

2 – Конечно, при условии, что приложение разворачивается в домене.

3 – Сложность использования – достаточно субъективная характеристика. Тем менее, как кажется, что большинство разработчиков уверенней себя чувствуют при работе с реестром или файловой системой, нежели с Active Directory или СУБД.

4 – Большинство СУБД реализуется в виде отдельного процесса ОС (или даже множества процессов). Однако существуют встраиваемые СУБД, которые также не требуют отдельной установки и доступны в виде DLL или в виде статических библиотек С. Например, такую возможность предоставляет opensource-версия Intrebase – Firebird. – прим.ред.

Приведу небольшие комментарии к характеристикам, приведенным в таблице.

Для нашего «нетривиального» случая из таблицы хорошо видно, что основным минусом при использовании реестра и/или плоского файла является низкая скорость (или отсутствие – в случае реестра) поиска. При работе с реестром к этому минусу добавляется ещё и невозможность хранения больших объёмов данных.

Active Directory (если пренебречь рекомендациями Microsoft) может обеспечить высокую скорость поиска, но требует наличия соответствующего окружения и прав. Преимущества же СУБД (или ADAM’а) могут быть сведены на нет необходимостью установки самой СУБД (или ADAM’а). Помимо этого, как Active Directory, так и СУБД, и ADAM в процессе работы приложения могут оказаться недоступными. Для некоторых приложений это неприемлемо. Также необходимо отметить, что СУБД является единственным хранилищем из перечисленных, поддерживающим транзакционность.

Нельзя сказать, что наш «нетривиальный» случай слишком специфичен и оторван от жизни. Корпорация Microsoft имеет немало продуктов, которым, в силу своей архитектуры, необходимо использовать возможности реляционного хранилища данных, без использования самой СУБД. Вот наиболее известные из них:

Для решения подобных задач в Microsoft используется собственный движок, который позволяет заменить реляционную базу. Этот движок называется JET Blue. JET – аббревиатура "Joint Engine Technology". Blue – цвет футболки менеджера данного проекта (шутка). На самом деле, я не знаю, что означает слово Blue в названии библиотеки, но оно позволяет отличать этот движок от другого движка, который используется таким продуктом, как Microsoft Office Access и носит имя JET Red. Кроме приставки JET, между этими двумя движками нет ничего общего. Реализация JET Blue в корне отличается от реализации JET Red, и по всем ключевым параметрам превосходит последнюю.

После этого небольшого вступления можно перейти к самому предмету. Посмотрим, что умеет эта технология.

Extensible Storage Engine

Как было уже упомянуто выше, не так давно корпорация Microsoft опубликовала закрытый до той поры интерфейс JET Blue под именем “Extensible Storage Engine” (ESE). Необходимые для работы с этим интерфейсом заголовочный и lib-файлы входят в поставку "Windows Server 2003 SP1 Platform SDK". Функции для работы с данным интерфейсом реализованы в единственном бинарном файле (esent.dll). Доступен данный интерфейс на платформе Windows 2000 и выше.

Посмотрим, что об этом пишет сам производитель. Ниже с небольшими купюрами цитируется вводная статья из MSDN (http://msdn2.microsoft.com/en-us/library/ms684493.aspx), посвящённая данной технологии

Начало цитаты.

Предназначение

"Extensible Storage Engine" (ESE) – это передовая технология индексированного и последовательного доступа ["indexed and sequential access method" (ISAM)]. Назначение данной технологии – позволить приложениям сохранять и запрашивать данные из таблиц с использованием индексированной и последовательной курсорной навигации [indexed or sequential cursor navigation]. ESE поддерживает денормализованные схемы [denormalized schemas], включая широкие таблицы [wide tables] с многочисленными разреженными колонками [numerous sparse columns], колонками, содержащими множественные значения [multi-valued columns], а также разреженными и частыми индексами [sparse and rich indexes]. ESE позволяет приложениям использовать согласованное состояние данных [consistent data state], используя транзакционные механизмы обновления данных, а также их поиска. Предоставлен механизм восстановления после аварийного завершения, так что данные остаются согласованными даже в случае полного отказа операционной системы. ESE предоставляет ACID [Atomic Consistent Isolated Durable] (Атомарный Согласованный Изолированный Долговечный) механизм транзакций для данных и схемы, посредством предварительной записи в лог [write ahead log] и модели изолированных моментальных снимков [snapshot isolation model]. Транзакции в ESE поддерживают высокую степень параллельности, что делает ESE подходящим для серверных приложений. ESE разумно кэширует данные для того, чтобы обеспечить высокую эффективность доступа к ним. […]

Где может применяться

"Extensible Storage Engine" предназначен для использования приложениями, которым необходимо быстрое и/или легко структурированное хранилище данных, если непосредственная работа с файлом или с реестром не удовлетворяет требованиям приложения относительно индексирования данных или их размера.

ESE используется приложениями, которые никогда не сохраняют данные, размер которых превышает 1 Mb. Также он использовался в приложениях с хранилищами, размер которых в экстремальных случаях превышал 1 Tb, и в общем случае составлял 50 Gb.

Для кого предназначена технология

Технология предназначена для разработчиков, знакомых с C/C++, и некоторыми основными концепциями баз данных (таблицы, колонки, индексы, восстановление, транзакции). [...]

Требования времени исполнения [Run-Time Requirements]

"Extensible Storage Engine" является компонентом Windows, который входит во все версии Windows, начиная с Windows 2000 (на данный момент – Windows 2000, Windows XP и Windows Server 2003). Не все возможности или функции API доступны во всех версиях Windows, хотя набор возможностей является неизменным внутри одной версии Windows.

ESE предоставляет механизм хранения в режиме пользователя [user-mode], который управляет данными, размещёнными в плоском бинарном файле, доступ к которому осуществляется посредством Win32 API для работы с файловой системой. "Extensible Storage Engine" доступен через dll, которая загружается непосредственно в процесс приложения. Никакого метода удалённого доступа не требуется и не предоставляется механизмом ESE. Хотя "Extensible Storage Engine" не имеет методов удалённого или межпроцессорного доступа, файлы базы данных могут быть использоваться удалённо (посредством совместного использования файлов через Win32 API), хотя такой подход и не рекомендуется.

ПРИМЕЧАНИЕ

Возможности, которые поддерживает "Extensible Storage Engine" в Windows XP 64-Bit Edition, идентичны возможностям, поддерживаемым в Windows Server 2003.

Конец цитаты.

Много специальных технических терминов. Но, в общем и целом, звучит неплохо. :) Попробуем разделить полученную информацию по категориям. Для этого расширим нашу сравнительную таблицу, добавив в неё ESE.

РеестрПлоский файлESEActive DirectoryСУБД
Возможность хранить большие объёмы данныхОтсутствуетПрисутствуетПрисутствуетПрисутствует 1 Присутствует
Скорость поиска-НизкаяВысокаяВысокаяВысокая
Хранилище работает как внешний, по отношению к приложению, сервисНетНетНетДаДа/Нет4
Необходимо производить инсталляцию хранилищаНетНетНетНет2 Да/Нет4
Права, необходимые для записи в хранилищеПрава на модификацию реестраПрава на модификацию файла(ов)Права на модификацию файла(ов)Права на запись в Active DirectoryПрава на запись в таблицы базы данных
Хранилище поддерживает транзакцииНетНетДаНетДа
Сложность использования хранилища3 НизкаяНизкаяВысокаяСредняяСредняя

1 – см. первый комментарий к предыдущей таблице.

2 – см. второй комментарий к предыдущей таблице.

3 – см. третий комментарий к предыдущей таблице.

4 – см. четвертый комментарий к предыдущей таблице.

Видно, что с точки зрения создаваемого мною почтового клиента, JET Blue является идеальным вариантом. Я могу хранить большие объёмы данных, быть независимым от внешних служб (сервисов), осуществлять быстрый поиск и сортировку, а также поддерживать транзакционность. Что ещё нужно почтовому клиенту от хорошего back-end’а?

Таким образом, при использовании библиотеки можно получить удобство работы с полновесной СУБД. Тем не менее, необходимо отметить два момента, которые могут оказаться важными для разработчиков:

  1. Не следует обольщаться – ESE не является СУБД. В ней отсутствуют такие привычные услуги настоящих СУБД, как внешние ключи, триггеры, сохранённые процедуры, пользовательские типы, разграничение прав доступа, и ещё многое, многое другое. (По нашему мнению, это нормальная СУБД, но с весьма ограниченными возможностями. На заре СУБД-строения на РС они все были такими. – прим.ред.)
  2. Использование ESE увеличивает стоимость разработки. Обычные затраты, связанные с изучением новой технологии, будут больше, поскольку существующая на данный момент документация в MSDN достаточно скудна и ограничена разделом «Extensible Storage Engine Reference», описывающим функции и структуры данных. Примеры использования данного API в MSDN, к сожалению, отсутствуют.

Общее впечатление

В предыдущем разделе уже говорилось о том, что все функции ESE реализованы в user-mode, в единственном бинарном файле (esent.dll). Dll загружается непосредственно в адресное пространство клиентского приложения. Эта легковесность вкупе с достаточно мощными возможностями движка делает честь его проектировщикам.

Прототипы всех функций содержатся в файле esent.h. Там же находится список всех сообщений об ошибках. Описание функций вызывает некоторое удивление, но особых проблем не создаёт. Дело в том, что функции, работающие со строками, в качестве параметров принимают указатели на char. Аналогов этих функций, принимающих указатели на wchar_t, не предусмотрено.

Больших проблем это не создаёт потому, что “char”-функциями являются только те (функции), который управляют наименованием и местоположением бинарных файлов, а также функции, которые управляют именованием таблиц и колонок в таблицах. Unicode-приложениям необходимо преобразовывать путь до файла из ANSI в Unicode с помощью вызова функции, указывая текущую кодировку [code page] операционной системы (т.е. параметр CodePage функции WideCharToMultibyte должен принимать значение CP_ACP). (т.е. фактически возможны проблемы с путями к файлам на машинах, где используются каталоги с именами, которые нельзя отобразить в 8-битной кодировке. – прим.ред.)

При сохранении/чтении данных (в частности, при сохранении/чтении unicode-строк) используется указатель на буфер типа void и размер этого буфера в байтах – т.е. сохранение и чтение Unicode строк не вызывает проблем.

Интересно посмотреть, как движок обеспечивает индексацию Unicode-строк. Чтобы поддерживать индексы в колонках, содержащих Unicode-строки, ESE производит нормализацию Unicode-строки в этих колонках. Не следует пугаться термина «нормализация». «Нормализация» Unicode-строки – это операция получения ключа, соответствующего данной строке. Ключ, в свою очередь, также является строкой. Но, в отличие от оригинальной строки, он структурирован и имеет (обычно) длину, которая меньше длины оригинальной строки. В дальнейшем для определения порядка строк, а также для поиска, уже могут использоваться не оригинальные строки, а сгенерированные для них ключи.

Нормализация производится с помощью функции LCMapString, которая вычисляет ключ (точный термин – «ключ сортировки»), используя в качестве входных параметров входную строку и локаль [locale identifier]. Описанный способ создания индексов приводит к интересному побочному эффекту, который заставляет разработчиков быть внимательными при создании таблиц, использующих индексы, которые содержат Unicode-строки. Ниже об этом будет сказано подробнее.

К особенностям, на которые следует обратить внимание, чтобы составить общее впечатление, можно отнести способ реализации транзакционных механизмов. Транзакционные механизмы выполнены с использованием техники snapshot’ов (моментальных снимков) и отличаются от классических изоляционных моделей ANSI SQL. В двух словах описать принцип этой техники можно следующим образом – на момент начала транзакции состояние базы «замораживается», и никакие изменения, сделанные в одной транзакции, не могут быть видны в другой. Snapshot, а также отличия этой техники от классических изоляционных моделей ANSI SQL достаточно хорошо и подробно описаны в статье «A Critique of ANSI SQL Isolation Levels» (H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O'Neil, and P. O'Neil). Мы же вернёмся к обсуждению транзакций чуть позже.

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

Начало работы

Начнём с чего-нибудь простого – например, с создания базы и таблиц. Но прежде нужно инициализировать подсистему ESE.

Инициализация

Перед созданием базы (а точнее – перед началом работы с ESE) необходимо проинициализировать подсистему ESE. Инициализация подсистемы ESE в процессе производится в два этапа:

  1. На первом этапе создаётся экземпляр движка (с помощью семейства функций JetCreateInstance/JetCreateInstance2).
  2. На втором этапе происходит инициализация созданного экземпляра (с помощью семейства функций JetInit/JetInit2/JetInit3).

Создание происходит с помощью одного из следующих вызовов: JetCreateInstance либо JetCreateInstance2.

JET_ERR JET_API JetCreateInstance(
  JET_INSTANCE    *pinstance,
  const char    *szInstanceName);

JET_ERR JET_API JetCreateInstance2(
  JET_INSTANCE    *pinstance,
  const char    *szInstanceName,
  const char    *szDisplayName,
  JET_GRBIT     grbit);

Каждая из этих функций принимает указатель на переменную типа JET_INSTANCE и, как видно из названия, конструирует экземпляр движка и возвращает указатель на него. Остальные параметры двух этих функций рассматриваться не будут – они достаточно подробно и хорошо описаны в MSDN, и не дают ничего нового с точки зрения получения общего впечатления о технологии. Также не будем затрагивать вопрос о различиях между этими функциями (оно не существенно и также описано в MSDN). Подобным образом будем поступать и дальше.

ПРИМЕЧАНИЕ

Нужно заметить, что библиотека пестрит функциями, имена которых заканчиваются цифрой 2 или 3. Функции, скорее всего, были созданы в процессе развития библиотеки – функции с цифрами в имени расширяют возможности функций без цифр или функций с меньшей цифрой в имени функции. Далее, для простоты, мы обычно будем рассматривать функцию с самым коротким прототипом.

Вызов функции инициализации JetInit переводит движок в состояние готовности. Эта функция также принимает указатель на переменную типа JET_INSTANCE.

JET_ERR JET_API JetInit(
  JET_INSTANCE *pinstance
);

Переменная должна содержать значение, возвращённое функцией JetCreateInstance (JetCreateInstance2), либо 0 (к этому значению мы вернёмся).

Если объединить оба вызова, код инициализации подсистемы ESE будет выглядеть так:

  try
  {
    JET_INSTANCE instance = JET_instanceNil;
    JET_ERR err = ::JetCreateInstance(
      &instance,                                // возвращаемое значение
      "{0A9A6617-8AE9-4c5e-AF28-01D5D4820C23}"  // уникальное имя экземпляра ESE
    );

    if(JET_errSuccess != err)
      throw CError(err);

    err = ::JetInit(
      &instance                                 // экземпляр ESE
    );

    if(JET_errSuccess != err)
      throw CError(err);
  }
  catch(const CException& e)
  {
    ::JetTerm(0);
  }
ПРИМЕЧАНИЕ

В целях уменьшения объёма кода в примерах, для сигнализации об ошибке я буду использовать генерацию исключения CException (мой собственный, неописанный здесь класс). При перехвате исключения (конечно же) необходимо закрывать все открытые дескрипторы [handles]. Для этих целей служат функции JetCloseTable, JetEndSession, JetTerm. В дальнейшем вызовы этих функций я буду опускать.

Данный код будет работать на операционных системах Windows XP и Windows 2003, и не будет работать под Windows 2000. Дело в том, что в этой версии операционной системы библиотека esent.dll не содержит функций JetCreateInstance и JetCreateInstance2. Это означает, что на операционной системе Windows 2000 позволено работать с единственным («нулевым») экземпляром движка ESE. И потому приложение, которое исполняется на Windows 2000, должно передавать в функцию JetInit (JetInit2 и JetInit3 на Windows 2000 также отсутствуют) нулевое значение параметра pinstance.

Таким образом, код инициализации подсистемы ESE на платформе Windows 2000 будет выглядеть следующим образом:

  JET_ERR err = ::JetInit(
    0                      // 0 - это экземпляр ESE по умолчанию
  );

  if(JET_errSuccess != err)
    throw CError(err);

Заметно проще, не так ли? :)

ПРИМЕЧАНИЕ

Необходимо отметить, что приведённый код будет работать как на платформе Windows 2000, так и на платформах Windows XP и Windows 2003. Передавая 0 в функцию JetInit в качестве указателя на экземпляр (в случае Windows XP или Windows 2003), мы сообщаем ESE, что собираемся работать в режиме совместимости с Windows 2000 [legacy mode (Windows 2000 compatibility mode)] – в режиме, в котором поддерживается единственный экземпляр движка на процесс.

Следующий шаг после инициализации движка - создание сессии.

Создание сессии

Сессия используется как контекст, в котором выполняются все операции с базой данных. Если провести аналогию с СУБД, то сессии в ESE соответствует установленное соединение с сервером СУБД.

Код создания сессии незамысловат – сессия создается с помощью вызова функции JetBeginSession.

JET_ERR JET_API JetBeginSession(
  JET_INSTANCE  instance,
  JET_SESID     *psesid,
  const char    *szUserName,
  const char    *szPassword
);

В качестве входного параметра функция принимает созданную и инициализированную переменную типа JET_INSTANCE (0, в случае Windows 2000). В качестве результата возвращается идентификатор сессии.

  JET_SESID sessionID = JET_sesidNil;
  err = ::JetBeginSession(
    0,                     // 0 в качестве экземпляра ESE
    &sessionID,            // возвращаемое значение
    0,                     // зарезервировано
    0                      // зарезервировано
  );
  if(JET_errSuccess != err)
    throw CError(err);

Поскольку каждая сессия служит контекстом, в котором выполняются все операции с базой, постольку отдельная сессия контролирует границы транзакции – транзакция может начаться и закончиться только в рамках одной сессии. Приложение может создавать более чем одну сессию для работы с базой, тем самым, возможно, повышая свою производительность.

На этом подготовительная работа по инициализации движка и создании сессии закончена.

Создание базы данных

Для создания базы служит функция JetCreateDatabase. Функция принимает идентификатор сессии и имя файла базы данных (имя может содержать полный путь до файла). Функция возвращает идентификатор созданной базы.

  JET_DBID dbID = JET_dbidNil;
  err = ::JetCreateDatabase(
    sessionID,              // идентификатор сессии
    "test.db",              // имя файла
    0,                      // зарезервировано
    &dbID,                  // возвращаемое значение
    0                       // нулевой флаг – просто создать базу
 );

  if(JET_errSuccess != err)
    throw CError(err);

Каждый экземпляр ESE внутри процесса может создать (и использовать) до семи баз.

Создание таблиц

Прототип функции, создающий таблицу, выглядит следующим образом:

JET_ERR JET_API JetCreateTableColumnIndex(
  JET_SESID      sesid,
  JET_DBID       dbid,
  JET_TABLECREATE  *ptablecreate
);

Вся информация, необходимая для создания таблицы, передаётся с помощью структуры JET_TABLECREATE, чьё описание, вместе с комментариями из заголовочного файла, приведено ниже:

typedef struct tagJET_TABLECREATE
{
  // Размер структуры (для возможного расширения структуры в будущем)
  unsigned long     cbStruct;      
  // Имя создаваемой таблицы.
  char        *szTableName
  // Имя таблицы, используемой в качестве шаблона
  char        *szTemplateTableName;   
  // количество страниц, изначально выделенных под создаваемую таблицу
  unsigned long     ulPages;       
  // плотность таблицы
  unsigned long     ulDensity;   
  // набор описаний колонок
  JET_COLUMNCREATE  *rgcolumncreate;
  // число создаваемых колонок
  unsigned long     cColumns;     
  // первичный ключ и список индексов
  JET_INDEXCREATE   *rgindexcreate;
  // число создаваемых индексов
  unsigned long     cIndexes;
  JET_GRBIT       grbit;
   // возвращаемый tableid 
  JET_TABLEID     tableid;
  // Число созданных объектов (колонок+таблиц+индексов)
  unsigned long     cCreated;
} JET_TABLECREATE;

Возвращаемых значений – два. Первое возвращаемое значение – это идентификатор курсора созданной таблицы (tableid).

ПРИМЕЧАНИЕ

Пусть вас не сбивает с толку имя переменной – tableid, и её тип – JET_TABLEID. На самом деле это не идентификатор таблицы, как можно было бы подумать по названию и типу, а именно идентификатор курсора, который может быть получен после создания или после открытия таблицы. Описывая операции модификации, удаления и чтения данных, MSDN, ссылаясь на переменную tableid, использует именно этот термин – «курсор».

Второе возвращаемое значение – это количество созданных в базе объектов (cCreated). В число этих объектов включаются таблицы, колонки, а также индексы. В случае ошибки значение этого поля не определено. Поле кажется бесполезным, я пытался придумать случай, когда это поле может оказаться полезным, но не смог.

Информация о колонках, которые должны присутствовать в таблице, передаётся с помощью структуры JET_COLUMNCREATE. Её я тоже приведу с комментариями из заголовочного файла:

typedef struct tag_JET_COLUMNCREATE
{
  // размер структуры 
  unsigned long  cbStruct;
  // имя колонки
  char       *szColumnName;
  // тип колонки
  JET_COLTYP     coltyp;
  // максимальная длина колонки (для бинарных и текстовых колонок)
  unsigned long  cbMax;
  // флаги, определяющие свойства колонки
  JET_GRBIT    grbit;
  // значение по умолчанию (если не задано, то NULL)
  void       *pvDefault;
  // длина значения по умолчанию
  unsigned long  cbDefault;
  // кодировка (для текстовых колонок)
  unsigned long  cp;
  // возвращаемый id колонки
  JET_COLUMNID   columnid;
  // возвращаемый код ошибки
  JET_ERR      err;
} JET_COLUMNCREATE;

В этой структуре, опять же, ничего необычного. Можно задать:

Добавлю несколько слов о значениях, которые может принимать флаг grbit, а точнее об одном из них. Флаг JET_bitColumnTagged используется для обозначения колонок, чьи значения хранятся отдельно от самой таблицы. Вместо самих данных в таблице хранится ссылка на данные, находящиеся в другом месте. Сейчас я поясню, что это значит.

ESE при работе с файлом базы данных использует понятие «размер страницы» [page size]. Размер страницы, выраженный в байтах, определяет следующие аспекты поведения ESE:

По умолчание значение “page size” равняется 4096 байтам. Его можно изменить с помощью вызова функции JetSetSystemParameter, используя «системный» параметр JET_paramDatabasePageSize. Допустимыми значениями являются 2048, 4096 и 8192 байт.

ПРИМЕЧАНИЕ

Нужно заметить, что ESE содержит большое количество «системных» параметров, которые управляют различными аспектами поведением движка, как-то:

- Местоположением базы данных.

- Названием файлов.

- Возможностью регистрации функций обратного вызова.

- Использованием event log’а и уровнем логирования, а также многое другое.

Все параметры устанавливаются при помощи вызова упомянутой функции JetSetSystemParameter. Эта функция принимает идентификатор параметра и его новое значение – число, строку или указатель – в зависимости от типа параметра.

А чтобы иметь возможность хранить данные, чей размер превышает размер страницы, как раз и используется флаг JET_bitColumnTagged. Обычно его применяют для типов JET_coltypLongBinary и JET_coltypLongText. В некоторых случаях использование этого флага является обязательным. Например, если колонка является “multi-valued” (флаг JET_bitColumnMultiValued), при её создании также должен быть также указан и флаг JET_bitColumnTagged.

Возвращаемых значений в этой структуре два, как и в структуре JET_TABLECREATE. Но в данном случае полезными оказываются оба. Первое возвращаемое значение – это идентификатор новосозданной колонки (columnid), а второй – код ошибки (err), описывающий причину, по которой данная колонка не была (если не была) создана.

И последняя структура, которая понадобится при создании таблицы – это структура, описывающая первичные ключи и индексы таблицы - JET_INDEXCREATE.

typedef struct tagJET_INDEXCREATE
{
  // размер структуры
  unsigned long cbStruct;
  // название индекса
  char *szIndexName;
  // ключ индекса
  char *szKey;
  // длина ключа
  unsigned long cbKey;
  // набор флагов индекса
  JET_GRBIT grbit;
  // плотность индекса
  unsigned long       ulDensity;

  union
  {
    // lcid для индекса (если JET_bitIndexUnicode НЕ указан)
    unsigned long     lcid;
    // указатель на структуру JET_UNICODEINDEX 
    // (если JET_bitIndexUnicode указан)
    JET_UNICODEINDEX  *pidxunicode;
  };

  union
  {
    // максимальная длина колонок переменной длины в ключе индекса 
    // (если не указано значение JET_bitIndexTupleLimits)
    unsigned long     cbVarSegMac;
#ifdef JET_VERSION_SERVER2003
    // Указатель на структуру JET_TUPLELIMITS 
    // (если указано значение JET_bitIndexTupleLimits)
    JET_TUPLELIMITS   *ptuplelimits;
#endif // ! JET_VERSION_SERVER2003
  };

  // указатель на структуру JET_CONDITIONALCOLUMN
  JET_CONDITIONALCOLUMN   *rgconditionalcolumn;
  // число условных колонок
  unsigned long       cConditionalColumn;
  // возвращаемый код ошибки
  JET_ERR         err;
} JET_INDEXCREATE;

Эта структура куда интересней своих предшественников. Кроме ожидаемых атрибутов, а именно:

Таблица содержит несколько интересных полей:

Каждая создаваемая таблица должна иметь первичный индекс. Но его не обязательно указывать при создании таблицы – если первичный индекс не будет указан, ESE создаст его самостоятельно и незаметно для пользователя.

Как и в случае структуры, используемой для создания колонки, структура содержит поле err, через которое возвращается код ошибки (если таковая случилась).

Ниже приведен код, создающий таблицу “TestTable” с двумя колонками – “PK” и “Value”. В этой таблице создаётся первичный ключ “PK_index”, а также индекс “Value_index”.

  JET_COLUMNCREATE columnCreate[2]  = { 0 };

  columnCreate[0].cbStruct     = sizeof(JET_COLUMNCREATE);
  columnCreate[0].szColumnName = "PK";
  columnCreate[0].coltyp       = JET_coltypLong;
  columnCreate[0].grbit        = JET_bitColumnAutoincrement;
  columnCreate[0].err          = JET_errSuccess;

  columnCreate[1].cbStruct     = sizeof(JET_COLUMNCREATE);
  columnCreate[1].szColumnName = "Value";
  columnCreate[1].coltyp       = JET_coltypLongText;
  columnCreate[1].cbMax        = 1024;
  columnCreate[1].grbit        = JET_bitColumnTagged;
  columnCreate[1].cp           = 1200;
  columnCreate[1].err          = JET_errSuccess;

  JET_INDEXCREATE indexCreate[2] = { 0 };
  indexCreate[0].cbStruct        = sizeof(JET_INDEXCREATE);
  indexCreate[0].szIndexName     = "PK_index";
  indexCreate[0].szKey           = "+PK\0";
  indexCreate[0].cbKey           =
    static_cast< unsigned long >(::strlen(indexCreate[0].szKey) + 2);
  indexCreate[0].grbit           = JET_bitIndexPrimary;
  indexCreate[0].err             = JET_errSuccess;

  indexCreate[1].cbStruct    = sizeof(JET_INDEXCREATE);
  indexCreate[1].szIndexName = "Value_index";
  indexCreate[1].szKey       = "+Value\0";
  indexCreate[1].cbKey       =
    static_cast< unsigned long >(::strlen(indexCreate[1].szKey) + 2);
  indexCreate[1].grbit       = JET_bitIndexUnique;
  indexCreate[1].err         = JET_errSuccess;

  JET_TABLECREATE tableCreate = { 0 };
  tableCreate.cbStruct        = sizeof(tableCreate);
  tableCreate.szTableName     = "TestTable";
  tableCreate.rgcolumncreate  = columnCreate;
  tableCreate.cColumns        =
    sizeof(columnCreate) / sizeof(columnCreate[0]);
  tableCreate.rgindexcreate   = indexCreate;
  tableCreate.cIndexes        =
    sizeof(indexCreate) / sizeof(indexCreate[0]);
  tableCreate.tableid         = JET_tableidNil;

  err = ::JetCreateTableColumnIndex(sessionID, dbID, &tableCreate);
  if(JET_errSuccess != err)
  {
    throw CError(err);
  }

Работа с данными

Добавление, модификация и удаление данных

Добавление строк в таблицу, равно как и модификация существующих строк, происходит с помощью триплета функций JetPrepareUpdate/JetSetColumns/JetUpdate. Прежде чем описывать поведение JetSetColumns, кратко остановимся на функциях, которые начинают и заканчивают обновление данных – JetPrepareUpdate и JetUpdate.

С помощью функции JetPrepareUpdate пользователь задаёт тип исполняемой операции:

JET_ERR JET_API JetPrepareUpdate(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  unsigned long prep
);

Могут быть выполнены три основные операции и ещё три, которые являются вариациями на тему основных. Тип операции передаётся с помощью параметра prep. Для основных операций параметр prep принимает следующие значения:

Помимо этих трёх основных, есть три дополнительных значения:

С помощью функции JetUpdate подготовленные данные заносятся в таблицу.

JET_ERR JET_API JetUpdate(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  void      *pvBookmark,
  unsigned long   cbBookmark,
  unsigned long   *pcbActual
);

Функция возвращает закладку [bookmark]. Закладка является нормализованной формой ключа и в дальнейшем может быть использована для позиционирования на только что созданной записи (с помощью функции JetGotoBookmark). Закладку можно сохранить в другой таблице (для организации ссылок – ESE не поддерживает foreign keys на уровне движка).

Теперь рассмотрим функцию, с помощью которой данные помещаются в ячейку.

JET_ERR JET_API JetSetColumns(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  JET_SETCOLUMN *psetcolumn,
  unsigned long csetcolumn
);

Вся существенная информация передаётся с помощью структуры JET_SETCOLUMN.

typedef struct 
{
  JET_COLUMNID  columnid;
  const void    *pvData;
  unsigned long cbData;
  JET_GRBIT     grbit;
  unsigned long ibLongValue;
  unsigned long itagSequence;
  JET_ERR       err;
} JET_SETCOLUMN;

Наряду с полями, назначение которых вполне очевидно, а именно:

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

Первое из них – ibLongValue. Это поле служит для того, чтобы позволить считывать значения «длинных» типов данных (JET_coltypLongBinary и JET_coltypLongText) не целиком, а по частям. В этом поле содержится смещение в байтах, начиная с которого будут читаться значения. Для обычных типов данных это поле должно устанавливаться в 0.

Второе поле, itagSequence, предназначено для того, чтобы обозначать порядковый номер редактируемого значения для multi-valued колонки. Если происходит вставка данных в multi-valued колонку, поле должно содержать значение 0. Если колонка является single-valued, это поле также должно иметь значение 0.

Ниже приведён код, вставляющий запись в созданную нами таблицу:

  err = ::JetPrepareUpdate(sessionID, tableCreate.tableid, JET_prepInsert);

  if(JET_errSuccess != err)
    throw CError(err);

  JET_SETCOLUMN setColumn = { 0 };
  wchar_t szFirstRow[]    = L"FirstInsertedRow";
  setColumn.columnid      = columnCreate[1].columnid;
  setColumn.pvData        = szFirstRow;
  setColumn.cbData        = sizeof(szFirstRow);
  setColumn.err           = JET_errSuccess;

  err = ::JetSetColumns(sessionID, tableCreate.tableid, &setColumn, 1);

  if(JET_errSuccess != err)
    throw CError(err);

  err = ::JetUpdate(sessionID, tableCreate.tableid, 0, 0, 0);

  if(JET_errSuccess != err)
    throw CError(err);

Удаление данных из таблицы производится с помощью вызова функции JetDelete. Прототип функции очень прост.

JET_ERR JET_API JetDelete(
  JET_SESID sesid,
  JET_TABLEID tableid
);

Функция удаляет запись, на которую указывает курсор.

Интересно, что для нашей таблицы приведенный выше код вставки данных выполнится с ошибкой. Функция JetSetColumns вернёт значение JET_errNotInTransaction – “The operation must occur within a transaction”.

Причина, по которой ESE требует оборачивать упомянутый вызов JetSetColumns в транзакцию, заключается в том, что приведённый код изменяет значение «длинного» типа данных, а это значение (напомню) хранится отдельно от основной таблицы. А это значит, что (из-за ошибки) может возникнуть ситуация, в которой ESE обновит информацию в основной таблице, но не обновит в дополнительной (либо, наоборот), вследствие чего данные окажутся в несогласованном состоянии. Чтобы избежать подобной ситуации, ESE требует оборачивать в транзакцию операции модификации тех типов данных, которые хранятся отдельно от основной таблицы. Если бы типом второй колонки являлся тип JET_coltypText (а не JET_coltypLongText, как сейчас), мы могли бы обойтись без транзакции.

Раз речь зашла о транзакциях, давайте рассмотрим их подробнее.

Транзакции

Начало, конец и отмена транзакции обозначаются вызовами функций JetBeginTransaction, JetCommitTransaction и JetRollback соответственно. Функции просты, ниже приведены прототипы.

JET_ERR JET_API JetBeginTransaction(
  JET_SESID sesid
);

JET_ERR JET_API JetCommitTransaction(
  JET_SESID sesid,
  JET_GRBIT grbit
);

JET_ERR JET_API JetRollback(
  JET_SESID sesid,
  JET_GRBIT grbit
);

Функции обычные-преобычные. Единственный интересный параметр, grbit, хорошо описан в MSDN (обычно его значение выставляется в нуль). Лучше поговорим о том, как реализован механизм транзакций в ESE.

Как я упомянул в самом начале статьи, транзакционные механизмы выполнены с использованием техники snapshot’ов (моментальных снимков). Другими словами, с того момента, когда сессия входит первый раз в состояние транзакции, содержимое базы данных замораживается. «Разморозка» выполняется по завершению транзакции (вызов функции JetCommitTransaction или JetRollback) и подразумевает под собой внесение информации, изменённой в процессе транзакции, в базу данных.

Благодаря этому интересному механизму транзакции не используют блокировку данных на чтение [read lock] – внутри транзакции сессия может прочитать лишь значения, имевшиеся на момент начала транзакции. Блокировка на запись происходит в полном объёме, если так можно выразиться. А именно – ESE учитывает, какие именно данные были изменены в процессе транзакции A, с тем, чтобы не позволить транзакции B повторно изменить те же самые данные. Если такое случается, транзакция B получает ошибку JET_errWriteConflict. В этом случае (в случае получения такой ошибки) необходимо завершить транзакцию B (подтвердить или отменить её) и повторить попытку модификации данных.

ESE хранит моментальные снимки как набор версий страниц, изменённых с начала текущей транзакции. Можно рассматривать этот механизм как механизм “copy-on-write”. Транзакция A работает с оригинальными страницами, находящимися в базе данных, до тех пор, пока не приступает к изменению данных. Как только транзакция A изъявляет желание приступить к изменению данных, ESE даёт транзакции A копию страницы и помечает эту страницу как заблокированную для записи. Если другая транзакция B изъявит желание модифицировать ту же страницу, которую сейчас модифицирует транзакция A, то она (транзакция B) получит ошибку JET_errWriteConflict. В момент вызова JetCommitTransaction страницы копируются в базу данных. При вызове JetRollback они удаляются. Всё достаточно просто и логично. Количеством страниц, используемых для транзакций, управляет один из «системных» параметров, JET_paramMaxVerPages.

Транзакции могут быть вложенными. Вложенность не добавляет ничего нового – каждому вложенному вызову JetBeginTransaction должен соответствовать вложенный же вызов JetCommitTransaction или JetRollback. Транзакция завершается после самого внешнего вызова JetCommitTransaction или JetRollback, за одним исключением – JetRollback может быть вызвана с параметром (тот самый флаг grbit), указывающим на необходимость выхода из всех вложенных транзакций.

Добавлю пару слов об особенностях использования транзакций.

Первая особенность заключается в том, что по умолчанию после начала транзакции все вызовы функций, использующих идентификатор сессии, должны совершаться в том же потоке, в котором был совершён вызов JetBeginTransaction. Представляется, что это ограничение нельзя назвать существенным. Но и его можно обойти с помощью вызовов функций JetSetSessionContext и JetResetSessionContext.

Вторая заключается в том, что код, использующий транзакции, может выполняться быстрее, нежели код, выполняющийся вне транзакции. Эта интересная особенность объясняется тем, что при отсутствии активной транзакции ESE оборачивает в отдельную транзакцию каждую функцию, читающую или модифицирующую данные, что существенным образом может сказаться на производительности. Поэтому MSDN настоятельно советует приложениям при выполнении упомянутых операций находиться в контексте транзакции.

Вернёмся к коду, вставляющему данные в таблицу. Исправленная версия (которая не генерирует ошибку) выглядит следующим образом:

  err = ::JetBeginTransaction(sessionID);
  if(JET_errSuccess != err)
  {
    throw CError(err);
  }

  err = ::JetPrepareUpdate(sessionID, tableCreate.tableid, JET_prepInsert);

  if(JET_errSuccess != err)
  {
    ::JetRollback(sessionID, 0);
    throw CError(err);
  }

  JET_SETCOLUMN setColumn = { 0 };
  wchar_t szFirstRow[]    = L"FirstInsertedRow";
  setColumn.columnid      = columnCreate[1].columnid;
  setColumn.pvData        = szFirstRow;
  setColumn.cbData        = sizeof(szFirstRow);
  setColumn.err           = JET_errSuccess;

  err = ::JetSetColumns(sessionID, tableCreate.tableid, &setColumn, 1);

  if(JET_errSuccess != err)
  {
    ::JetRollback(sessionID, 0);
    throw CError(err);
  }

  err = ::JetUpdate(sessionID, tableCreate.tableid, 0, 0, 0);

  if(JET_errSuccess != err)
  {
    ::JetRollback(sessionID, 0);
    throw CError(err);
  }

  err = ::JetCommitTransaction(sessionID, 0);

  if(JET_errSuccess != err)
  {
    ::JetRollback(sessionID, 0);
    throw CError(err);
  }

Подобным образом я добавлю ещё три значения в таблицу – "SecondInsertedRow", "ThirdInsertedRow" и “FourthInsertedRow”. Эти записи нам понадобятся в следующем разделе.

Поиск данных и чтение данных

Перейдём к описанию процесса чтения данных из таблиц. Начнём с самого простого случая – последовательного считывания из таблицы всех записей. Чтобы иметь возможность прочитать какие-то данные из таблицы, необходимо, чтобы с текущим курсором был ассоциирован один из табличных индексов. Ассоциация производится с помощью вызова функции JetSetCurrentIndex:

JET_ERR JET_API JetSetCurrentIndex(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  const char    *szIndexName
);

В качестве значения параметра szIndexName передаётся имя одного из ранее созданных индексов (“PK_index” или “Value_index” для созданной нами таблицы).

После того, как установлена связь курсора и индекса, можно перемещаться по строкам таблицы с помощью вызова функции JetMove:

JET_ERR JET_API JetMove(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  long          cRow,
  JET_GRBIT     grbit
);

а считывать значения с помощью вызова функции JetRetrieveColumn:

JET_ERR JET_API JetRetrieveColumn(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  JET_COLUMNID  columnid,
  void          *pvData,
  unsigned long cbData,
  unsigned long *pcbActual,
  JET_GRBIT     grbit,
  JET_RETINFO   *pretinfo
);

Обе функции (перемещения и чтения) несложны, поэтому я не буду подробно останавливаться на них. Скажу лишь, что с помощью JetMove можно перемещаться как относительно текущей позиции курсора (допустим, на следующую запись), так и абсолютно (на первую или последнюю запись). Функция JetRetrieveColumn с помощью флага grbit позволяет управлять нюансами чтения данных (нюансы слишком специфичны, чтобы останавливаться на них подробно), а также с помощью флага grbit или параметра pretinfo можно указать, что требуется считывать значения «длинных» типов данных и значения multi-valued колонок.

Последовательное чтение данных продемонстрировано в следующем примере:

  err = ::JetSetCurrentIndex(sessionID, tableCreate.tableid, "Value_index");
  if(JET_errSuccess != err)
  {
    throw CError(err);
  }

  for(err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveFirst, 0);
    JET_errSuccess == err;
    err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
  {
    unsigned long nPK = 0;
    unsigned long nReadBytes = 0;

    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[0].columnid,
      &nPK,
      sizeof(nPK),
      &nReadBytes,
      0,
      0);

    assert(nReadBytes == sizeof(nPK));
    std::wcout << nPK;

    if(JET_errSuccess != err)
      break;

    wchar_t buffer[1024] = { 0 };
    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[1].columnid,
      &buffer,
      sizeof(buffer),
      &nReadBytes,
      0,
      0);

    assert(nReadBytes < sizeof(buffer));
    std::wcout << L'\t' << buffer << std::endl;
  }

  if(JET_errNoCurrentRecord != err)
    throw CError(err);

Посмотрев на вывод в консоли, можно увидеть, что значения действительно возвращаются в порядке увеличения значений индекса “Value_index”.

1 FirstInsertedRow

4 FourthInsertedRow

2 SecondInsertedRow

3 ThirdInsertedRow

Читать записи одну за другой – не самый распространённый способ работы с таблицами. Чаще используется поиск значений в таблице. Поиск в ESE реализуется с помощью функций JetSetCurrentIndex, JetMakeKey, JetSeek и JetSetIndexRange.

С первой из них мы уже познакомились – она позволяет указать имя текущего индекса. Давайте поподробнее посмотрим на остальные функции.

Функция JetMakeKey задаёт ключ (критерий), который будет использоваться при поиске записей. Ключ связывается с текущим индексом и курсором (это значит, что функция должна вызываться после вызова функции JetSetCurrentIndex). С помощью последовательных вызовов JetMakeKey можно задать значения для каждой из колонок, формирующих индекс.

JET_ERR JET_API JetMakeKey(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  const void    *pvData,
  unsigned long cbData,
  JET_GRBIT     grbit
);

Функция JetSeek позволяет позиционировать курсор на записи, удовлетворяющей критериям поиска:

JET_ERR JET_API JetSeek(
  JET_SESID     sesid,
  JET_TABLEID   tableid,
  JET_GRBIT     grbit
);

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

Функция JetSeek вернёт JET_errSuccess, если ей удалось позиционировать курсор на записи, содержащей указанное значение индекса. Функция вернёт JET_errNoCurrentRecord, если такая запись не была найдена.

Посмотрим, как можно позиционироваться на значении индекса “Value_index” начинающегося с символов “Fo” (на втором значении индекса “Value_index”):

  wchar_t bufferSearchCriteria[] = L"Fo";
  err = ::JetMakeKey(sessionID,
    tableCreate.tableid,
    &bufferSearchCriteria,
    sizeof(bufferSearchCriteria),
    JET_bitNewKey);

  if(JET_errSuccess != err)
    throw CError(err);

  for(err = ::JetSeek(sessionID, tableCreate.tableid, JET_bitSeekGE);
    !(err < 0);
    err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
  {
    unsigned long nPK = 0;
    unsigned long nReadBytes = 0;

    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[0].columnid,
      &nPK,
      sizeof(nPK),
      &nReadBytes,
      0,
      0);

    assert(nReadBytes == sizeof(nPK));
    std::wcout << nPK;

    if(JET_errSuccess != err)
      break;

    wchar_t buffer[1024] = { 0 };
    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[1].columnid, // колонка «Value»
      &buffer,
      sizeof(buffer),
      &nReadBytes,
      0,
      0);

    assert(nReadBytes < sizeof(buffer));
    std::wcout << L'\t' << buffer << std::endl;
  }

  if(JET_errNoCurrentRecord != err)
    throw CError(err);

Этот код выведет записи из таблицы, в порядке возрастания индекса, начиная с первого найденного вхождения и заканчивая последним в таблице.

4 FourthInsertedRow

2 SecondInsertedRow

3 ThirdInsertedRow

Видно, что мы ограничили выборку – первая запись, которую мы прочитали, удовлетворяет критерию поиска.

Чтобы найти все записи, которые в колонке "Value" (с индексом 1) содержат значения, начинающиеся с символов “Fo”, и никакие другие, потребуется ещё один вызов функции. На этот раз – функции JetSetIndexRange. Вызов этой функции сформирует временный диапазон значений индекса, одной границей которого будет текущее положение индекса, а второй – последнее значение, удовлетворяющее ключу поиска. По полученному диапазону можно будет «пройтись», используя функцию JetMove с параметром cRow, равным значению JET_MoveNext или положительному смещению (при условии, что при вызове JetSetIndexRange использовался параметр JET_bitRangeUpperLimit. Если это не так, то по диапазону придётся идти в обратном направлении – в этом случае при вызове JetMove grbit необходимо будет установить в JET_MovePrevious или использовать отрицательное смещение). Вызов JetMove с любым другим параметром приведёт к тому, что курсор покинет временный диапазон значений индекса.

  err = ::JetMakeKey(sessionID,
    tableCreate.tableid,
    &bufferSearchCriteria,
    sizeof(bufferSearchCriteria),
    JET_bitNewKey | JET_bitPartialColumnStartLimit);

  if(JET_errSuccess != err)
    throw CError(err);

  // установить текущий курсор на запись “FourthInsertedRow”
  err = ::JetSeek(sessionID, tableCreate.tableid, JET_bitSeekGE);

  if(err < 0)
    throw CError(err);

  err = ::JetMakeKey(sessionID,
    tableCreate.tableid,
    &bufferSearchCriteria,
    sizeof(bufferSearchCriteria),
    JET_bitNewKey | JET_bitPartialColumnEndLimit);

  if(JET_errSuccess != err)
    throw CError(err);

  // ограничим набор вхождений индекса 
  for(err = ::JetSetIndexRange(sessionID,
                   tableCreate.tableid,
                   JET_bitRangeInclusive | JET_bitRangeUpperLimit);
    !(err < 0);
    ::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
  {
    unsigned long nPK = 0;
    unsigned long nReadBytes = 0;
    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[0].columnid,
      &nPK,
      sizeof(nPK),
      &nReadBytes,
      0,
      0);

    if(JET_errSuccess != err)
      break;

    assert(nReadBytes == sizeof(nPK));
    std::wcout << nPK;
    wchar_t buffer[1024] = { 0 };

    err = ::JetRetrieveColumn(sessionID,
      tableCreate.tableid,
      columnCreate[1].columnid,
      &buffer,
      sizeof(buffer),
      &nReadBytes,
      0,
      0);

    assert(nReadBytes < sizeof(buffer));
    std::wcout << L'\t' << buffer << std::endl;
  }

Хочу обратить ваше внимание на следующие моменты:

Приведённый выше фрагмент найдёт и выведет на печать содержимое одной записи:

4 FourthInsertedRow

Если вы захотите реализовать полноценный полнотекстовый поиск, вам понадобится специальный тип индекса – tuple-индекс, упоминавшийся выше.

Теперь обещанные пояснения, касающиеся значения JET_bitSetIndexRange, которое можно задать при вызове функции JetSeek. Вызов JetSeek с параметром JET_bitSetIndexRange, можно рассматривать как ещё один способ поиска (но с более строгими критериями). Отличия от способа, использующего вызов JetSetIndexRange, состоят в следующем:

  1. Работает только строгий поиск. Это значит, что флаг JET_bitSetIndexRange можно комбинировать только с JET_bitSeekEQ. Попытки использовать JET_bitSetIndexRange без JET_bitSeekEQ закончатся неудачно – JetSeek вернёт невнятное значение, равное числу 313 (положительное возвращаемое в ESE значение обозначает предупреждение, но файл esent.h не содержит предупреждения с таким кодом), а последующие вызовы JetMove будут работать некорректно.
  2. Чтобы произвести поиск, не нужен предварительный вызов JetSeek, который позиционирует нас на начало индекса – вызов JetSeek с флагом JET_bitSetIndexRange сразу сформирует временный диапазон значений индекса.
  3. Предшествующий вызову JetSeek вызов JetMakeKey должен производиться без указания параметров JET_bitStrLimit, JET_bitSubStrLimit, JET_bitPartialColumnStartLimit и JET_bitPartialColumnEndLimit. Причина этого ограничения представляется понятной – поскольку поиск происходит по точному совпадению и без предварительного позиционирования, постольку нет необходимости задавать, как именно будут включаться границы из диапазона значений индекса.

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

Как устроена база

ESE для своей работы использует несколько файлов. Если выполнить команду dir в директории, в которой был запущен наш пример, среди прочих можно увидеть следующие файлы:

01.07.2006 20:27 8 192 edb.chk

01.07.2006 20:27 5 242 880 edb.log

01.07.2006 20:26 5 242 880 res1.log

01.07.2006 20:26 5 242 880 res2.log

01.07.2006 20:27 1 056 768 test.db

Файл базы данных

Единственный файл, предназначение которого кажется понятным сразу – файл test.db – это имя мы задавали при вызове функции JetCreateDatabase.

В этом файле содержится:

Видно, что был создан файл размером в один мегабайт. По мере вставки данных размер файл будет увеличиваться. Значение, на которое будет увеличиваться размер файла, можно задать с помощью «системного» параметра JET_paramDbExtensionSize (напомню, что параметры устанавливаются с помощью вызова функции JetSetSystemParameter). Размер указывается в страницах.

Файл транзакций

Следующий файл, на который следует обратить внимание – это файл транзакций. В нашем случае он носит имя edb.log (имя можно изменить с помощью «системного» параметра JET_paramBaseName).

Как сказано в MSDN, файлы транзакций [Transaction Log Files] «содержат список операций, выполненных над файлами базы данных. Этот список содержит достаточно информации для того, чтобы привести базу данных в логически согласованное состояние после неожиданного завершения процесса или выключения системы». Таким образом, в случае сбоя приложения, операционной системы, питания и т.п., файлы транзакций будут содержать список операций, которые нужно будет «проиграть», чтобы привести базу данных в согласованное состояние. В документации по ESE эта процедура именуется “soft recovery”. Процедура выполняется автоматически при вызове JetInit/JetInit2/JetInit3.

Здесь мы видим единственный файл, но MSDN говорит о нём во множественном числе. Дело в том, что в течение работы с БД файлы транзакций могут накапливаться. Каждый из файлов транзакций имеет фиксированный размер (5 мегабайт по умолчанию, но этот размер можно изменить с помощью параметра JET_paramLogFileSize). Все текущие транзакции ESE записывает в текущий лог транзакций (edb.log).

Когда текущий лог заполняется, ESE переименовывает его в edb000001.log и создаёт новый файл с именем edb.log. Когда и этот лог заполняется, ESE переименовывает его в edb000002.log, и так далее. Таким образом, нельзя сказать, какой файл содержит актуальные данные, которые могут понадобиться для восстановления базы данных. Точнее, нельзя сказать точно, какой из файлов семейства edb*.log не содержит актуальных транзакций, которые могут понадобиться для восстановления базы, и по этой причине не следует перемещать, удалять, переименовывать или каким-либо другим способом манипулировать этими файлами.

Безопасно файлы транзакций могут быть удалены в следующих случаях:

  1. При операции резервного копирования базы данных [full backup], с помощью одной из следующих функций: JetBackup, JetTruncateLog, JetTruncateLogInstance.
  2. В процессе обычной работы, если включена опция циклического логирования транзакций. Возможность циклического логирования контролируется с помощью системного параметра JET_paramCircularLog. Циклическое логирование подразумевает автоматическое удаление log-файлов, которые уже не понадобятся для (возможного) восстановления базы данных.

Резервные файлы транзакций

Файлы с названиями res1.log и res2.log также являются файлами транзакций, но не обычными, как рассмотренные выше, а резервными. Во время инициализации движка ESE создаёт эти файлы (каждый размером по 5 мегабайт), чтобы обеспечить «чистое выключение» [clean shutdown].

Необходимость в этих файлах возникает, если приложение, использующее ESE, работает в условиях нехватки дискового пространства. В этом случае движок не может создать новый log-файл (если в таковом возникает необходимость), и всё, что может сделать движок – это отсоединить базу данных. А чтобы эта операция прошла «чисто», может потребоваться дополнительное место (например, чтобы записать rollback текущих операций). В этой ситуации будет использовано место в резервных файлах транзакции.

Файл контрольной точки

Файл edb.chk является файлом контрольной точки [checkpoint file]. Сейчас я поясню, что это означает.

Особенностью алгоритмов модификации в ESE является то, что операции с БД в первую очередь записываются в log-файлы и сохраняются в памяти. Непосредственно в сам файл БД эти операции попадают позже (интересным моментом здесь является то, что операции могут записываться в БД не в том порядке, в котором они попадали в log-файл – MSDN утверждает, что так делается для повышения производительности). Таким образом, log-файл в общем случае содержит как операции, информация о которых попала в базу данных, так и те, которые ещё не были внесены в главный файл. Контрольная точка – это не что иное, как временная метка, которая отмечает в log-файле то место, про которое известно, что все записи в log-файле до этого места успешно попали в файл базы данных. Состояние записей в log-файле, которые появились позже этой метки, неизвестно – возможно, информация о них содержится только в памяти, возможно, она уже попала в основной файл. Файл edb.chk, наряду с файлами транзакций, используется при процедуре «soft recovery».

Путь к файлу, содержащему контрольную точку, можно задать с помощью системного параметра JET_paramSystemPath.

Индексация Unicode-строк

Интересно посмотреть, как ESE работает с индексами, построенными по Unicode-строкам (дальше такие индексы буду для краткости называть Unicode-индексами). Как я уже говорил выше, работа с Unicode-строками не вызывает проблем. Вызывает ли проблемы использование Unicode-индексов?

Прежде всего, замечу, что движок ESE хранит вторичные индексы в отдельных структурах, и позволяет добавлять и удалять вторичные индексы после того, как таблица была создана. Данное замечание не относится к первичным индексам – первичные индексы создаются в момент создания таблицы и не могут быть изменены или удалены.

Напомню, что при построении Unicode-индексов ESE использует функцию LCMapString. А при её использовании наблюдается очень любопытный побочный эффект – дело в том, что результат работы этой функции (LCMapString) может изменяться в зависимости от версии операционной системы (Windows). Поскольку ESE использует результат работы функции LCMapString для создания ключа сортировки, это означает, что ключ сортировки, созданный под Windows 2000, может отличаться от ключа сортировки, который получился бы при вызове функции LCMapString на операционной системе Windows XP и Windows 2003. Это, в свою очередь, означает, что Unicode-индексы, созданные на одной операционной системе, будут работать неверно на другой. MSDN не описывает возможных последствий данной ситуации, но можно предположить, что, неверно будет работать сортировка колонок, содержащих Unicode-индексы, а также вставка данных в такие колонки.

Описанная особенность, возможно, не была бы проблемой для многих категорий приложений. Но ситуация хуже, чем может показаться сначала – функция LCMapString может возвращать разный результат даже на одной и той же версии Windows, т.к. поведение функции может измениться после установки очередного пакета обновления.

Означает ли это, что при работе с ESE от использования Unicode-индексов следует отказаться? Краткий ответ – нет. Полный же ответ таков:

  1. Unicode-строки нельзя использовать для создания первичных ключей.
  2. При использовании вторичных Unicode-индексов перед подключением базы данных необходимо будет выполнять специальные подготовительные действия.

Начнём со второго пункта. Разработчик может заставить ESE проверить версию библиотеки NLS (National Language Support, именно эта библиотека отвечает за реализацию LCMapString), используемую операционной системой. Эту проверку ESE выполняет при вызове JetAttachDatabase. Если окажется так, что текущая версия библиотеки новее той версии, которая использовалась при построении индексов, функция JetAttachDatabase вернёт ошибку. Если функция вернула ошибку, необходимо пересоздать Unicode-индексы, используемые в таблицах.

Теперь поподробнее об этих шагах. По умолчанию функция JetAttachDatabase не производит проверку версии NLS библиотеки. Чтобы проверка была произведена, необходимо до вызова функции JetInit установить в true значение системного параметра JET_paramEnableIndexChecking, отвечающего за проверку Unicode-индексов. Если параметр был соответствующим образом установлен, и функция JetAttachDatabase обнаружила устаревшие индексы, эта функция вернёт одну из следующих ошибок:

Последняя ошибка означает, что необходимо пересоздать вторичные индексы. Первая говорит о том, что недействительными стали первичные ключи, которые пересоздать невозможно. Именно этим объясняется запрет на использование Unicode-строки в качестве первичных ключей.

Пересоздание индексов не должно вызвать проблем, поскольку операция очень проста – индексы сначала нужно удалить вызовом JetDeleteIndex, а затем заново создать вызовом JetCreateIndex2. Задачу удаления индексов можно возложить и на ESE. Для этого достаточно при вызове JetAttachDatabase передать флаг JET_bitDbDeleteCorruptIndexes – испорченные индексы будут удалены автоматически, а функция вернёт значение JET_wrnCorruptIndexDeleted. Разработчику останется только создать их заново.

MSDN описывает ещё одну возможность автоматического удаления Unicode-индексов. Но эта возможность описана неаккуратно и непоследовательно, а потому вызывает вопросы. MSDN утверждает, что можно автоматически удалить индексы, если до вызова JetInit установить системный параметр – JET_paramEnableIndexCleanup – в значение true.

Первое, что вызывает вопрос после прочтения документации – это фраза о том, что автоматическое удаление Unicode-индексов может произойти при вызове JetInit (видимо, подразумевается, что оно может и не произойти при вызове этой функции). Эта фраза находит частичное объяснение в следующем абзаце, где вместо термина «автоматически удалить» [automatically clean up] используется термин «инкрементное удаление» [incremental cleanup]. Значит ли это, что удаление может происходить по мере необходимости? Если так, то остаётся непонятно, как в таком случае будут пересоздаваться удалённые индексы? Второй вопрос вызывает фраза о том, что «инкрементное удаление» не всегда возможно. MSDN говорит, что в случае невозможности «инкрементного удаления», Unicode-индексы будут обрабатываться так же, как если бы был использован флаг JET_paramEnableIndexChecking. Но это означает, что при использовании флага JET_paramEnableIndexCleanup придётся реализовать и алгоритм, относящийся к использованию флага JET_paramEnableIndexChecking, а именно: проверить результат вызова JetAttachDatabase и пересоздать (при необходимости) индексы.

Несколько проясняет ситуацию комментарий, который содержится в esent.h для параметра JET_paramEnableIndexCleanup. В нём ни слова не упоминается об удалении индексов, но говорится о том, что при использовании данного флага будет использоваться «внутренняя корректирующая таблица» [internal fixup table] для исправления недействительных индексов. Из этого комментария также следует, что этот метод не сможет исправить всех недействительных индексов, но будет прозрачен для приложения. Можно предположить, что задумывался этот флаг как нечто, дающее возможность движку в момент работы приложения автоматически исправлять индексы. Но, похоже на то, что реализация не удалась авторам (поскольку остаются варианты, когда корректировка работать не будет), и полезность данного системного параметра сомнительна.

Закончу разговор об Unicode-индексах замечанием, касающимся алгоритма определения недействительности этих индексов. Из документации следует, что до Windows 2003 (а значит, и до Windows XP) ESE не хранила информацию о текущей используемой версии NLS. Но, что интересно, флаг, отвечающий за проверку индексов – JET_paramEnableIndexChecking – доступен и в Windows 2000. Это может означать следующее:

Разработчикам, для которых важны упомянутые аспекты работы с Unicode-индексами на операционной системе Windows 2000, придётся провести дополнительные исследования.

Можно лишь заметить, что с большой натяжкой приемлемым можно назвать первый вариант (если исходить из того, что документация верна). Наверное, он устроит серверные приложения, которые редко выключаются и могут позволить себе при включении перестроить индексы. Он, скорее всего, не устроит как приложения, рассчитанные на работу с большими объёмами данных, так и desktop-приложения, которые часто перезапускаются.

Заключение

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

Среди недостатков:

ПРИМЕЧАНИЕ

С нашей точки зрения, создать удобные обертки для таких языков, как Visual Basic или C#, не составит особого труда, так что основным противопоказанием к использованию этой библиотеки является крайняя низкоуровневость библиотеки, и наличие более высокоуровневых и мощных открытых аналогов типа Firebird, уже снабженных обертками для .NET. – прим.ред.

Объективности ради необходимо заметить, что данная технология не является уникальной в своём роде. Существуют легковесные движки, которые могут работать как внутрипроцессные сервера – например, такие как Firebird (спасибо Михаилу Купаеву за информацию, :)) и SQLLite. И хотя я ничего не могу сказать о первом из них, мне известен как минимум один случай успешного применения второго (т.е. SQLLite) в коммерческом проекте. Сравнение перечисленных технологий заслуживает (конечно же) отдельного исследования.

Представляется, что в первую очередь данная технология заинтересует создателей серверных приложений, которым необходимо надёжное хранилище информации, и которым по тем или иным причинам неудобно или невозможно использовать обычную СУБД. Но нельзя исключать и того, что эта технология заинтересует создателей desktop-приложений, оказавшись полезной и им.

Ссылки


Эта статья опубликована в журнале RSDN Magazine #1-2007. Информацию о журнале можно найти здесь
    Сообщений 22    Оценка 190        Оценить