Толик Панков
hex_laden
............ .................. ................
November 2020
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30

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#Описание
--3stringСигнатура файла ('SxG')
Timestamptime4Uint -> DateTime Время и дата создания БД
DBTypetype1Byte -> EnumТип базы данных. Согласно спецификации существуют следующие варианты (0 - Universal, 1 - SxGeo Country, 2 - SxGeo City, 11 - GeoIP Country, 12- GeoIP City, 21 - ipgeobase)
DBEncodingcharset1Byte -> EnumКодировка (0 - UTF-8, 1 - latin1, 2 - cp1251)
fbIndexLenb_idx_len1ByteКоличество элементов в индексе первых байт
mIndexLenm_idx_len2ushortКоличество элементов в основном индексе
Rangerange2ushortБлоков в одном элементе индекса
DiapCountdb_items4uintКоличество диапазонов
IdLenid_len1byteРазмер ID-блока в байтах (1 для стран, 3 для городов
MaxRegionmax_region2ushortМаксимальный размер записи региона - до 64 кб (в байтах)
MaxCitymax_city2ushortМаксимальный размер записи города - до 64 кб (в байтах)
RegionSizeregion_size4uintРазмер справочника регионов (в байтах)*
CitySizecity_size4uintРазмер справочника городов (в байтах)
MaxCountrymax_country2ushortМаксимальный размер записи страны - до 64 кб (в байтах)
CountrySizecountry_size4uintРазмер справочника стран (в байтах)

PackSizepack_size2ushortРазмер описания формата упаковки города/региона/страны**
PackFormat-размер = PackSizestringОписание формата упаковки города/региона/страны


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

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


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

ИмяТип C#Назначение
block_lenuintДлина одного блока диапазонов
fb_beginuintНачало (смещение в файле) индекса первых байт
midx_beginuintНачало (смещение в файле) основного индекса
db_beginuintНачало (смещение в файле) диапазонов
regions_beginlongНачало (смещение в файле) справочника регионов
cites_beginlongНачало (смещение в файле) справочника городов
countries_beginlongНачало (смещение в файле) справочника стран
pack_countrystringФормат упаковки записи страны
pack_citystringФормат упаковки записи города
pack_regionstringФормат упаковки записи региона


Код структуры 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 (для свойства)Описание
privatestring FileName string.Empty-Путь к файлу БД
private bool IsOpenfalse-Открыт ли файл базы данных
public stringErrorMessagenull get; private set; Cообщение об ошибке
private FileStream SxStreamnullПоток для чтения БД

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 //в память загружается все
}


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


Переменные для сохранения результатов поиска:
Видимость:ТипИмяЗначениеОписание
privateDictionary<string, object="object">IPInfonullИнформация об IP-адресе
privateDictionary<string, type="Type">IPInfoTypesnullТипы данных информационных полей
privatestring []ignore_fields{"country_seek",
"id","region_seek",
"country_id"}Поля базы данных, игнорируемые в ответе
privatestring []ignore_fields_ru{"name_ru"}Поля базы данных, содержащие русский текст
publicboolRemoveRU{ 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


Это репост с сайта http://tolik-punkoff.com
Оригинал: http://tolik-punkoff.com/2018/09/27/sxgeosharp-interfejs-na-c-dlya-bazy-dannyh-sypexgeo-chast-i-initsializatsiya-i-vvedenie/

From:
Identity URL: 
имя пользователя:    
Вы должны предварительно войти в LiveJournal.com
 
E-mail для ответов: 
Вы сможете оставлять комментарии, даже если не введете e-mail.
Но вы не сможете получать уведомления об ответах на ваши комментарии!
Внимание: на указанный адрес будет выслано подтверждение.
Username:
Password:
Subject:
No HTML allowed in subject
Message:



Notice! This user has turned on the option that logs IP addresses of anonymous posters.