Проект 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'а.
В классе 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
Pingback: SxGeoSharp. Интерфейс на C# для базы данных SypexGeo. — Содержание | Персональный блог Толика Панкова