SxGeoSharp. Интерфейс на C# для базы данных SypexGeo. Часть I — инициализация (и введение)

Преамбула

Проект SypexGeo это автономная файловая бинарная база данных, хранящая IPv4 адреса, и позволяющая определить их географическую привязку, а также, соответствующий PHP-скрипт, позволяющий использовать ее на своем сайте, для определения страны и города по IP.
Примеры использования в блоге уже неоднократно описывались.
Мне понадобилось ее использовать на машине без интернета, для того, чтобы анализировать некоторые логи, на PHP (консольном) получалось довольно неуклюже и неудобно, поставить web-сервер не было возможности, потому подумал и решил, почему бы не расковырять базу, благо формат открыт, и не прикрутить к своим программам интерфейс на C#.
Сразу говорю, текст будет довольно длинный и нудный, поскольку это еще и что-то типа технической документации в вольной форме, написанной по работе, вдруг кто-то косяки поправит или кому-то понадобится.
Написан этот интерфейс наверняка не оптимально, и наверняка требует доработки. Спецификация формата написана довольно скупо, так что о кое-каких вещах приходилось догадываться, где-то, правда в мелочах, было наврано, где-то приходилось подглядывать в исходник от создателей (на PHP), где-то даже расчехлить hiew, а где-то переделывать PHP алгоритмы под C#. Это собой отдельную трудность представляло, потому что в PHP вся работа с типами благополучно переложена на интерпретатор, и PHP легко может работать с числом или с массивом байт, как со строкой, и наоборот.

Спецификация формата доступна на официальном сайте

SxGeoSharp работает с бесплатными вариантами базы SxGeoCountry (SxGeo.dat) и SxGeoCity (SxGeoCity.dat) версии 2.2 (Unicode, Windows-1251 и теоретически Latin-1). Платных вариантов баз не было, кому надо чтобы было — пишите в комментарии, договоримся.

Общая структура БД

Любая база данных SypexGeo:
1. Начинается с заголовка размером в 40 байт, хранящего основные параметры БД.
2. Далее идет «Индекс первых байт»
3. «Основной индекс»
4. Таблица диапазонов IP-адресов
База SxGeoCountry на этом заканчивается. А в БД SxGeoCity далее следуют справочники. Идут они в таком порядке:

1. Справочник регионов
2. Справочник стран
3. Справочник городов

Данные в справочниках хранятся в «Универсальном формате упаковки данных»

Заголовок базы данных

Театр начинается с вешалки, а БД с заголовка. Вот им и займемся в первую очередь.
Заголовок в БД SypexGeo занимает 40 байт, и содержит поля следующих типов данных: строки Unicode (UTF-8), все строки английские, так что символ 1 байт, что облегчает работу, беззнаковые целые числа размером 1 байт (byte), 2 байта (ushort) и 4 байта (uint).
Внимание: Все числовые данные в заголовке хранятся в big-endian. Т.е. в системе с little-endian (большинство x86/64 машин) их надо будет конвертировать.

Заведем соответствующую структуру SxGeoHeader.
Надо сказать, что в структуру я добавил еще и дополнительные поля, не указанные в спецификации, чтобы хранить вычисляемые значения типа начала определенного справочника. Для простоты я свел все данные по полям заголовка в таблицы:

Поля оригинального заголовка (по спецификации)

Имя в SxGeoHeader Имя в исходнике PHP Размерность (байт) Тип C# Описание
3 string Сигнатура файла (‘SxG’)
Timestamp time 4 Uint -> DateTime Время и дата создания БД
DBType type 1 Byte -> Enum Тип базы данных. Согласно спецификации существуют следующие варианты (0 — Universal, 1 — SxGeo Country, 2 — SxGeo City, 11 — GeoIP Country, 12- GeoIP City, 21 — ipgeobase)
DBEncoding charset 1 Byte -> Enum Кодировка (0 — UTF-8, 1 — latin1, 2 — cp1251)
fbIndexLen b_idx_len 1 Byte Количество элементов в индексе первых байт
mIndexLen m_idx_len 2 ushort Количество элементов в основном индексе
Range range 2 ushort Блоков в одном элементе индекса
DiapCount db_items 4 uint Количество диапазонов
IdLen id_len 1 byte Размер ID-блока в байтах (1 для стран, 3 для городов
MaxRegion max_region 2 ushort Максимальный размер записи региона — до 64 кб (в байтах)
MaxCity max_city 2 ushort Максимальный размер записи города — до 64 кб (в байтах)
RegionSize region_size 4 uint Размер справочника регионов (в байтах)*
CitySize city_size 4 uint Размер справочника городов (в байтах)
MaxCountry max_country 2 ushort Максимальный размер записи страны — до 64 кб (в байтах)
CountrySize country_size 4 uint Размер справочника стран (в байтах)
PackSize pack_size 2 ushort Размер описания формата упаковки города/региона/страны**
PackFormat размер = PackSize string Описание формата упаковки города/региона/страны

* Обратите внимание! Размер справочников указан в байтах, а не в количестве записей. Запись справочника имеет переменную длину (что создает определенный геморрой). Подробнее будет разобрано далее.

** На самом деле, описание упаковки (для базы SxGeoCity) идет в таком порядке:
— страна
— город
— регион

Добавленные в структуру SxGeoHeader поля:

Имя Тип C# Назначение
block_len uint Длина одного блока диапазонов
fb_begin uint Начало (смещение в файле) индекса первых байт
midx_begin uint Начало (смещение в файле) основного индекса
db_begin uint Начало (смещение в файле) диапазонов
regions_begin long Начало (смещение в файле) справочника регионов
cites_begin long Начало (смещение в файле) справочника городов
countries_begin long Начало (смещение в файле) справочника стран
pack_country string Формат упаковки записи страны
pack_city string Формат упаковки записи города
pack_region string Формат упаковки записи региона

Код структуры SxGeoHeader на PasteBin

Дополнительные перечисления и мелкая корректировка с придирками.

Внимательный читатель, наверное заметил, что некоторые поля подразумевают выбор из нескольких вариантов, что проще преобразовать в соответствующее перечисление или принятый в C# тип, чем хранить в исходном. Это касается полей DBType и DBEncoding, а также Timestamp и Version.

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

public enum SxGeoType
{
    Universal = 0,
    SxGeoCountry = 1,
    SxGeoCity = 2,
    GeoIPCountry = 11,
    GeoIPCity=12,
    ipgeobase=21
}

и

public enum SxGeoEncoding
{
    UTF8=0,
    Latin1=1,
    CP1251=2
}

Касательно типа БД, я столкнулся с первой свиньей, подложенной оригинальной спецификацией. Фактически, для SxGeoCity (SxGeoCity.dat) значение поля DBType ВНЕЗАПНО оказалось = 3, покопавшись в оригинальном исходнике, понял, что 3 это якобы «SxGeoCity EN«, посмотрел в hiew, увидел в БД русские буквы, впал в когнитивный диссонанс и менять ничего не стал, все равно это поле пока нужно только для информации.

Timestamp мне показалось некузяво хранить в виде uint, и я решил его преобразовать из uint в DateTime, благо это довольно просто

Возникли непонятки и с Version, точнее — в спецификации не написано, где ставить точку (сама версия хранится в виде byte), с начала или с конца, ну и хрен с ним, решил я и сделал вот так:

private string GetVersion(byte ver)
{
    string v = ver.ToString();
    if (ver < 10) return v;
    v = v.Insert(v.Length - 1, ".");
    return v;
}

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

Поля, свойства и конструктор класса

Пора бы кратко описать поля и свойства класса, дабы на этом более не останавливаться, и перейти к чтению базы данных и прочим интересным вещам. Если какое-то поле (свойство) без пояснений будет встречаться далее, то надо смотреть сюда.

Переменные, относящиеся к работе класса

Видимость Тип Имя Значение по умолчанию get/set (для свойства) Описание
private string FileName string.Empty - Путь к файлу БД
private bool IsOpen false - Открыт ли файл базы данных
public string ErrorMessage null get; private set; Cообщение об ошибке
private FileStream SxStream null Поток для чтения БД
public bool RevBO { get; set; } Флаг изменения порядка байт
public long FileSize { get; private set; } Размер файла БД

Режим использования памяти:
public SxGeoMode DatabaseMode { get; set; } - публичное свойство, устанавливающее способ работы с памятью. В оригинале их было определено два - FILE_MODE и MEMORY_MODE, в первом случае к БД обращались как к файлу на диске, во втором - грузили файл в память (в соответствующие случаю массивы), я же решил поступать следующим образом:

1. При инициализации (открытии) БД после чтения заголовка читаются в память "Индекс первых байт" и "Основной индекс". Они довольно маленькие и проще их прочитать сразу, чем потом за ними бегать на диск.
2. Если DatabaseMode == SxGeoMode.FileMode, все данные читаются из файла.
3. Если DatabaseMode == SxGeoMode.MemoryDiapMode, в память загружается также и таблица диапазонов ("Диапазоны" в спецификации)
4. Если DatabaseMode == SxGeoMode.MemoryAllMode, в память загружаются и справочники, если они есть.

Режимы использования памяти определены в следующем перечислении:

public enum SxGeoMode
{
    FileMode = 0, //все кроме индексов читается из файла
    MemoryDiapMode = 1, //в память загружаются диапазоны IP
    MemoryAllMode = 2 //в память загружается все
}

Видимость Тип Имя Описание
private SxGeoHeader Header Заголовок БД
private uint[] fb_idx_arr Индекс первых байт
private uint[] m_idx_arr Основной индекс
private byte[] db_b База данных (диапазоны)
private byte[] regions_db справочник регионов в режиме MemoryAll
private byte[] cities_db справочник городов и стран (совмещенный) в режиме MemoryAll

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

Видимость: Тип Имя Значение Описание
private Dictionary IPInfo null Информация об IP-адресе
private Dictionary IPInfoTypes null Типы данных информационных полей
private string [] ignore_fields {"country_seek",
"id","region_seek",
"country_id"} Поля базы данных, игнорируемые в ответе
private string [] ignore_fields_ru {"name_ru"} Поля базы данных, содержащие русский текст
public bool RemoveRU { get; set; } (свойство по умолчанию false) Если true - из ответа удаляются поля из массива ignore_fields_ru

Конструктор простой:

public SxGeoDB(string DBPath)
{
    FileName = DBPath;
    RevBO = BitConverter.IsLittleEndian;
    DatabaseMode = SxGeoMode.FileMode;
    RemoveRU = false;
}

Устанавливаем имя файла БД, как указанное в вызове конструктора, режим работы с памятью в файловый, удаление полей с русским текстом в false, а флаг изменения порядка байт в зависимости от порядка байт, используемого в системе. Если у нас система использует little-endian порядок, то надо порядок байт для данных, полученных из базы, менять поскольку, хотя и не везде, но об этом позже, в базе используется порядок big-endian. Забегая вперед скажу, что кроме заголовка он используется везде, кроме "универсального формата упаковки", т.е. в записях справочников данные в little-endian.

Функции для чтения БД

Это приватная функция byte[] ReadBytes(FileStream FST, int Count) с заодно контролирующая реально прочитанное количество байт. Поскольку, какие либо механизмы контроля целостности БД отсутствуют, мне показалось целесообразным так сделать. Если количество реально прочитанных байт меньше числа, указанного в переменной Count - функция вернет null, а в ErrorMessage будет помещен текст "Format error". В этой функции также обрабатывается и исключение при ошибке чтения

Так же была сделана публичная функция-обертка над приватной byte[] ReadBytes(int Count), и публичная функция bool Seek(long Offset, SeekOrigin Origin), позволяющая перемещаться по файлу БД. Последние две функции были сделаны для отладки и загрузки справочников в DataSet. От последнего, впрочем, какой-то практической пользы я пока не вижу, но оставил эти функции на будущее.
Код функций на PasteBin

Функции для чтения заголовка

В заголовке могут встретиться 4 типа данных: byte, строка, состоящая из однобайтовых символов, ushort и uint (оба в big-endian). Байт мы прочитаем и так, вызвав функцию ReadByte() класса FileStream. Строку из однобайтовых символов надо просто преобразовать из массива байт в строку:

private string BytesToString(byte[] bytes)
{
//Преобразует массив байт в однобайтовую строку
// (1251, но для данных целей кодировка не важна)
if (bytes == null) return null;
return Encoding.GetEncoding(1251).GetString(bytes);
}

С ushort и uint тоже сложностей не возникает, надо прочитать или 2 или 4 байта, если в системе порядок little-endian, перевернуть массив, и преобразовать BitConverter'ом его в соответствующее число:

private ushort ReadUShort(FileStream FST, bool revers)
{
    byte[] buf = ReadBytes(FST, 2);
    if (buf == null) return 0;
    if (revers) Array.Reverse(buf);
    return BitConverter.ToUInt16(buf,0);
}

private uint ReadUInt(FileStream FST, bool revers)
{
    byte[] buf = ReadBytes(FST, 4);
    if (buf == null) return 0;
    if (revers) Array.Reverse(buf);
    return BitConverter.ToUInt32(buf, 0);
}

К функциям для чтения заголовка, можно также отнести функцию для получения версии формата БД и timestamp'а.

Код функций на PasteBin

Закрытие базы данных

В классе SxGeoDB создана функция CloseDB(), которая вызывается при наличии ошибки в работе с файлом базы данных, или же должна вызываться при завершении операций с ней. Функция закрывает файловый поток SxStream и устанавливает флаг IsOpen в значение false.

//закрытие базы данных
public void CloseDB()
{
    if (SxStream != null) SxStream.Close();
    IsOpen = false;
}

Открытие базы данных, чтение и проверка заголовка, чтение индексов.

Данный функционал реализован в публичной функции bool OpenDB(), которую необходимо вызвать перед любыми операциями с БД Sypex Geo.

1. Пытаемся узнать размер файла, если он меньше 40 байт, то это точно не файл БД SxGeo.
2. Пытаемся файл открыть, и создаем поток SxStream для чтения файла. Если на этом этапе случилась ошибка, то закрываем базу данных и возвращаем false.

try
{
    FileInfo fi = new FileInfo(FileName);
    FileSize = fi.Length;
    if ( FileSize < 40)
    {
        ErrorMessage = "Bad SxGeo file";
        return false;
    }

    SxStream = new FileStream(FileName, FileMode.Open, FileAccess.Read,
        FileShare.Read);
}
catch (Exception ex)
{
    ErrorMessage = ex.Message;
    CloseDB();
    return false;
}

3. Читаем и проверяем сигнатуру файла:

//проверка сигнатуры ('SxG')
string sgn = BytesToString(ReadBytes(SxStream, 3));
if (sgn != "SxG")
{
    ErrorMessage = "Bad signature";
    CloseDB();
    return false;
}

4. Читаем версию, timestamp, тип базы и кодировку, и сохраняем значения в соответствующие поля заголовка. Timestamp изначально читается, как значение типа uint, но он понадобится в двух видах - как DateTime, в который он будет преобразован, и как число из переменной tstamp - для проверки корректности файла БД.

//чтение timestamp
uint tstamp = ReadUInt(SxStream, RevBO);
Header.Timestamp = UnixTimeToDateTime(tstamp);

5. Читаем тип БД и кодировку.

//тип базы
Header.DBType = (SxGeoType)SxStream.ReadByte();
//кодировка
Header.DBEncoding = (SxGeoEncoding)SxStream.ReadByte();

6. Последовательно, согласно спецификации, читаем весь основной заголовок.

//чтение всего остального заголовка
Header.fbIndexLen = (byte)SxStream.ReadByte(); ////элементов в индексе первых байт (b_idx_len/byte)
Header.mIndexLen = ReadUShort(SxStream,RevBO); //элементов в основном индексе (m_idx_len/ushort)
Header.Range = ReadUShort(SxStream,RevBO); //Блоков в одном элементе индекса (range/ushort)
//...
Header.MaxCountry = ReadUShort(SxStream, RevBO); //Максимальный размер записи страны - до 64 кб (max_country)
Header.CountrySize = ReadUInt(SxStream,RevBO); //Размер справочника стран (country_size)
Header.PackSize = ReadUShort(SxStream, RevBO); //Размер описания формата упаковки города/региона/страны (pack_size)*/

7. Проверяем, не случилось ли где по ходу чтения ошибки (ReadBytes() заполнит переменную ErrorMessage):

if (!string.IsNullOrEmpty(ErrorMessage))
{
    CloseDB();
    return false;
}

8. И выполняем дополнительную проверку (взято из оригинального исходника на PHP):

if (Header.fbIndexLen * Header.mIndexLen * Header.Range *
                Header.DiapCount * tstamp * Header.IdLen == 0)
{
    ErrorMessage = "Wrong file format";
    CloseDB();
    return false;
}

9. Получаем описание формата упаковки. Он сохранен в файле в виде строки, описывающей формат, размер строки указан в поле PackSize, так что читаем нужное количество байт, преобразуем их в строку.
Формат для каждого справочника - подстрока полученной строки, разделитель между подстроками, символ с кодом 0x00. Описание формата справочников для SxGeoCity идет в такой последовательности:
- Формат справочника стран
- Формат справочника регионов
- Формат справочника городов

Для SxGeoCountry размер строки формата == 0, и самой строки нет. После заголовка для SxGeoCountry начинаются индексы БД.

//вытаскиваем описание формата упаковки
if (Header.PackSize != 0)
{
    byte[] packformat = ReadBytes(SxStream, Header.PackSize);
    Header.PackFormat = BytesToString(packformat);
    //разбираем формат упаковки на составляющие структуры
    string[] pack = Header.PackFormat.Split('\0');
    if (pack.Length > 0) Header.pack_country = pack[0];
    if (pack.Length > 1) Header.pack_region = pack[1];
    if (pack.Length > 2) Header.pack_city = pack[2];
}

10. Вычисляем длину блока диапазонов:

Header.block_len = 3+(uint)Header.IdLen; //длина 1 блока диапазонов

11. Загружаем в память "Индекс первых байт". Он маленький, 224 целочисленных значения, размером 4 байта. Т.е. суммарно 896 байт (максимально по спецификации 255 * 4 = 1020 байт). Проще загрузить его в память, чем бегать за ним на диск.

//вытаскиваем индекс первых байт
fb_idx_arr = new uint[Header.fbIndexLen];
for (int i = 0; i < Header.fbIndexLen;i++)
{
    fb_idx_arr[i] = ReadUInt(SxStream, RevBO);
}

12. Загружаем основной индекс. Он тоже не чудовищный, так что опять же - проще сразу загрузить. Там данные (беззнаковые числа) размером по 4 байта, количеством Header.mIndexLen, т.е. получилось 1775 * 4 = 7100.

//вытаскиваем основной индекс
m_idx_arr = new uint[Header.mIndexLen];
for (int i = 0; i < Header.mIndexLen; i++)
{
    m_idx_arr[i] = ReadUInt(SxStream, RevBO);
}

13. Читаем базу данных диапазонов IP, если установлен соответствующий режим использования памяти.
Если установлен режим "из файла" (DatabaseMode = SxGeoMode.FileMode), то этот момент пропускаем. Иначе читаем диапазоны в память:

//читаем базу диапазонов IP, 
//если не установлен режим чтения из файла
if (DatabaseMode != SxGeoMode.FileMode)
{
    db_b = new byte[Header.DiapCount * Header.block_len];
    db_b = ReadBytes(SxStream, (int)(Header.DiapCount * Header.block_len));                
}

14. Если установлен режим "все в память" (DatabaseMode == SxGeoMode.MemoryAllMode), то загружаем в память справочники:

//загружаем справочники в память
if (DatabaseMode == SxGeoMode.MemoryAllMode)
{
    //регионы
    if (Header.RegionSize > 0)
    {
        regions_db = new byte[Header.RegionSize];
        regions_db = ReadBytes(SxStream, (int)Header.RegionSize);
    }

    //города (справочник стран совмещен со справочником городов)
    if (Header.CitySize > 0)
    {
        cities_db = new byte[Header.CitySize];
        cities_db = ReadBytes(SxStream, (int)Header.CitySize);
    }

}

15. Вычисляем начальные смещения для компонентов базы данных:

//Начало индекса первых байт
Header.fb_begin = 40 + (uint)Header.PackSize;
//начало основного индекса
Header.midx_begin = Header.fb_begin + (uint)Header.fbIndexLen * 4;
//начало диапазонов
Header.db_begin = Header.midx_begin + (uint)Header.mIndexLen * 4;
//начало справочника регионов
Header.regions_begin = Header.db_begin + Header.DiapCount *
Header.block_len;
//начало справочника стран
Header.countries_begin = Header.regions_begin + Header.RegionSize;
//начало справочника городов
Header.cites_begin = Header.countries_begin + Header.CountrySize;

16. Устанавливаем флаг успешности инициализации БД в true и завершаем функцию инициализации.

IsOpen = true;
return true;

Вся функция DbOpen() на PasteBin

One Response to SxGeoSharp. Интерфейс на C# для базы данных SypexGeo. Часть I — инициализация (и введение)

  1. Pingback: SxGeoSharp. Интерфейс на C# для базы данных SypexGeo. — Содержание | Персональный блог Толика Панкова

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

*