Проект 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. — Содержание | Персональный блог Толика Панкова