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<string, object="object"> | IPInfo | null | Информация об IP-адресе |
private | Dictionary<string, type="Type"> | 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(byt es);
}
С
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Это репост с сайта http://tolik-punkoff.com
Оригинал: http://tolik-punkoff.com/2018/09/27/sxge