Вступление


Долгое время я пользовался библиотекой SxGeo от zapimir. И до недавнего времени меня всё устраивало. Устраивало до тех пор, пока не было необходимости добавлять в БД свои данные.


Не найдя в интернете упаковщика данных от SxGeo и не найдя в себе силы требовать нужный мне функционал от разработчика, было принято решение писать свой костыль. Хотя на это решение повлиял и ещё 2 недостатка используемой библиотеки:


  • ограничение по количеству справочников;
  • невозможность узнать интервал адресов, в который входит искомый адрес;
  • отсутствие пакета в packagist.

Собственно, делюсь с вами своей разработкой.


Отличия между прототипом и моим решением:


  • IPTool — это всего лишь инструмент для создания базы данных и поиска в ней, в то время, как проект SxGeo — проект, предоставляющий не только инструментарий, но и сами базы данных;
  • База данных IPTool занимает больше места (т.к. первый адрес диапазона хранится полностью и занимает 4 байта, в то время, как в SxGeo только 3 байта);
  • IPTool имеет только один режим — чтение данных с диска (Режим подгрузки базы в память — в планах);
  • Помимо данных, IPTool возвращает диапазон IP адресов, в который входит искомый адрес;
  • IPTool предусматривает методы получения данных из справочников (всех или по порядковому номеру);
  • В базе данных IPTool предусмотрена возможность лицензирования самой базы данных;
  • IPTool легко устанавливается с помощью Composer;

Использование


Инициализация IP Tool


/* Путь к базе данных - /path/to/iptool.database */
$iptool = new \Ddrv\Iptool\Iptool('/path/to/iptool.database');

Получение информации о базе данных


print_r($iptool->about());

Array
(
    [created] => 1507199627
    [author] => Anonymous Author
    [license] => MIT
    [networks] => Array
        (
            [count] => 276148
            [data] => Array
                (
                    [country] => Array
                        (
                            [0] => code
                            [1] => name
                        )
                )
        )
)

Поиск информации об IP адресе


print_r($iptool->find('81.32.17.89'));

Array
(
    [network] => Array
        (
            [0] => 81.32.0.0
            [1] => 81.48.0.0
        )
    [data] => Array
        (
            [country] => Array
                (
                    [code] => es
                    [name] => Spain
                )
        )
)

Получить все элементы справочника


print_r($iptool->getRegister('country'));

Array
(
    [1] => Array
        (
            [code] => cn
            [name] => China
        )
    [2] => Array
        (
            [code] => es
            [name] => Spain
        )
...
    [N] => Array
        (
            [code] => jp
            [name] => Japan
        )
)

Получение элемента справочника по его порядковому номеру


print_r($iptool->getRegister('country',2));

Array
    (
        [code] => cn
        [name] => China
    )
)

Процесс создания БД более трудоёмкий, но он описан с документации, которая доступна в репозитории и в wiki GitHub'а на русском и ломаном английском.


UPD1. Сравнение скорости работы IPTool и SxGeo


Для большей достоверности результатов, я создал БД для IPTool на основе данных SxGeo


Выгрузка БД SxGeo в .csv


<?php
/* Выгрузка БД SxGeo в .csv */
include_once __DIR__.DIRECTORY_SEPARATOR.'SxGeo.php';
class ExtSxGeo extends SxGeo
{
    public function parseBase() 
    {
        $s=0;
        $firstIp = '0.0.0.0';
        $seek = 0;
        $data = $this->parseCity($seek,1);
        $sxNet = fopen(__DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxNet.csv','w');
        $sxCnt = fopen(__DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxCnt.csv','w');
        $sxRgn = fopen(__DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxRgn.csv','w');
        $sxCts = fopen(__DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxCts.csv','w');
        $ids = [
            'cnt' => [],
            'rgn' => [],
            'cts' => [],
        ];
        for ($octet=1;$octet<=223;$octet++) {
            $bip = pack('C',$octet);
            $min = $this->b_idx_arr[$octet-1];
            $max = $this->b_idx_arr[$octet];
            for ($b=$min; $b<=$max;$b++) {
                fseek($this->fh, $this->db_begin + $b * $this->block_len);
                $block = fread($this->fh, $this->block_len);
                $i = unpack('C4',$bip.substr($block,0,3));
                $ip = implode('.',$i);
                $lastIp = long2ip(ip2long($ip)-1);
                $csvNet = [
                    $firstIp,
                    $lastIp,
                    $data['city']['id'],
                    $data['region']['id'],
                    $data['country']['id'],
                ];
                fputcsv($sxNet,$csvNet);
                if (!isset($ids['cts'][$data['city']['id']])) {
                    $ids['cts'][$data['city']['id']] = true;
                    $csvCts = [
                        $data['city']['id'],
                        $data['city']['lat'],
                        $data['city']['lon'],
                        $data['city']['name_ru'],
                        $data['city']['name_en'],
                    ];
                    fputcsv($sxCts,$csvCts);
                }
                if (!isset($ids['rgn'][$data['region']['id']])) {
                    $ids['rgn'][$data['region']['id']] = true;
                    $csvRgn = [
                        $data['region']['id'],
                        $data['region']['iso'],
                        $data['region']['name_ru'],
                        $data['region']['name_en'],
                    ];
                    fputcsv($sxRgn,$csvRgn);
                }
                if (!isset($ids['cnt'][$data['country']['id']])) {
                    $ids['cnt'][$data['country']['id']] = true;
                    $csvCnt = [
                        $data['country']['id'],
                        $data['country']['iso'],
                        $data['country']['lat'],
                        $data['country']['lon'],
                        $data['country']['name_ru'],
                        $data['country']['name_en'],
                    ];
                    fputcsv($sxCnt,$csvCnt);
                }
                $firstIp = $ip;
                $seek = hexdec(bin2hex(substr($block, $this->block_len - $this->id_len, $this->id_len)));
                $data = $this->parseCity($seek,1);
            }
        }
        $lastIp = '255.255.255.255';
        $csvNet = [
            $firstIp,
            $lastIp,
            $data['city']['id'],
            $data['region']['id'],
            $data['country']['id'],
        ];
        fputcsv($sxNet,$csvNet);
        if (!isset($ids['cts'][$data['city']['id']])) {
            $ids['cts'][$data['city']['id']] = true;
            $csvCts = [
                $data['city']['id'],
                $data['city']['lat'],
                $data['city']['lon'],
                $data['city']['name_ru'],
                $data['city']['name_en'],
            ];
            fputcsv($sxCts,$csvCts);
        }
        if (!isset($ids['rgn'][$data['region']['id']])) {
            $ids['rgn'][$data['region']['id']] = true;
            $csvRgn = [
                $data['region']['id'],
                $data['region']['iso'],
                $data['region']['name_ru'],
                $data['region']['name_en'],
            ];
            fputcsv($sxRgn,$csvRgn);
        }
        if (!isset($ids['cnt'][$data['country']['id']])) {
            $ids['cnt'][$data['country']['id']] = true;
            $csvCnt = [
                $data['country']['id'],
                $data['country']['iso'],
                $data['country']['lat'],
                $data['country']['lon'],
                $data['country']['name_ru'],
                $data['country']['name_en'],
            ];
            fputcsv($sxCnt,$csvCnt);
        }
        fclose($sxNet);
        fclose($sxCnt);
        fclose($sxRgn);
        fclose($sxCts);
    }

    protected function saveCSV() 
    {
    }
}
$sxgeo = new ExtSxGeo( __DIR__.DIRECTORY_SEPARATOR.'SxGeoCity.dat',2);
$sxgeo->parseBase();

Запускаем скрипт и ждём.


Создание БД IPTool из .csv


<?php
/* Создание БД IPTool из .csv */
require_once(__DIR__.DIRECTORY_SEPARATOR.'Converter.php');

/* Используем директорию для хранения временных файлов. У скрипта должны быть права на запись в эту директорию. */
$tmpDir = __DIR__.'/t';

/* Инициализируем класс Converter. */
$converter = new \Ddrv\Iptool\Converter($tmpDir);

/* Указываем путь для сохранения БД. Скрипт должен иметь права на запись этого файла. */
$dbFile = __DIR__.DIRECTORY_SEPARATOR.'iptool.sxgeo.city.dat';

/* Запоминаем в переменные пути к нужным CSV файлам. */
$sxNet = __DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxNet.csv';
$sxCnt = __DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxCnt.csv';
$sxRgn = __DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxRgn.csv';
$sxCts = __DIR__.DIRECTORY_SEPARATOR.'csv'.DIRECTORY_SEPARATOR.'sxgeo'.DIRECTORY_SEPARATOR.'sxCts.csv';

/* Устанавливаем инфорацию об авторе. */
$converter->setAuthor('Ivan Dudarev');

/* Указываем лицензию. */
$converter->setLicense('MIT');

/* Добавляем исходники в формате CSV. */
$converter->addCSV('sxNet',$sxNet);
$converter->addCSV('sxCnt',$sxCnt);
$converter->addCSV('sxRgn',$sxRgn);
$converter->addCSV('sxCts',$sxCts);

/* Описываем справочник Country. */
$country = array(
    'id' => array(
        'type' => 'int',
        'column' => 0,
    ),
    'iso' => array(
        'type' => 'string',
        'column' => 1,
        'transform' => 'low',
    ),
    'lat' => array(
        'type' => 'double',
        'column' => 2,
    ),
    'lon' => array(
        'type' => 'double',
        'column' => 3,
    ),
    'nameRu' => array(
        'type' => 'string',
        'column' => 4,
    ),
    'nameEn' => array(
        'type' => 'string',
        'column' => 5,
    ),
);
$converter->addRegister('country','sxCnt',0, $country);

/* Описываем справочник Region. */
$region = array(
    'id' => array(
        'type' => 'int',
        'column' => 0,
    ),
    'iso' => array(
        'type' => 'string',
        'column' => 1,
        'transform' => 'low',
    ),
    'nameRu' => array(
        'type' => 'string',
        'column' => 2,
    ),
    'nameEn' => array(
        'type' => 'string',
        'column' => 3,
    ),
);
$converter->addRegister('region','sxRgn',0, $region);

/* Описываем справочник City. */
$city = array(
    'id' => array(
        'type' => 'int',
        'column' => 0,
    ),
    'lat' => array(
        'type' => 'double',
        'column' => 1,
    ),
    'lon' => array(
        'type' => 'double',
        'column' => 2,
    ),
    'nameRu' => array(
        'type' => 'string',
        'column' => 3,
    ),
    'nameEn' => array(
        'type' => 'string',
        'column' => 4,
    ),
);
$converter->addRegister('city','sxCts',0, $city);

/* Описываем диапазоны. */
$data = array(
    'city' => 2,
    'region' => 3,
    'country' => 4,
);
$converter->addNetworks('sxNet', 'ip', 0, 1, $data);

$errors = $converter->getErrors();
if (!$errors) {
    $converter->create($dbFile);
} else {
    print_r($errors);
}

Запускаем скрипт и ждём.


Сравнение величины БД


ivan@ddrv ~/test $ ls -l
...
-rw-r--r-- 1 www www 13435116 Jun 30 15:46 SxGeoCity.dat
-rw-r--r-- 1 www www 33190825 Oct 12 06:40 iptool.sxgeo.city.dat
...

Объём базы IPTool больше в 3 раза (что не есть плюс)


Запуск теста


<?php
require_once(__DIR__.DIRECTORY_SEPARATOR.'Iptool.php');
require_once(__DIR__.DIRECTORY_SEPARATOR.'SxGeo.php');
$dbFile = __DIR__.DIRECTORY_SEPARATOR.'iptool.sxgeo.city.dat';
$iptool = new \Ddrv\Iptool\Iptool($dbFile);
$sxgeo = new SxGeo( __DIR__.DIRECTORY_SEPARATOR.'SxGeoCity.dat',2);

/* Готовим данные для теста */
$ips = [];
for ($i=0;$i<100;$i++) {
    $ipa = [];
    for($octet = 0;$octet<4;$octet++) {
        $ipa[] = rand(0,255);
    }
    $ip = implode('.',$ipa);
    $ips[] = $ip;
}
/* IPTool */
$res = [];
$t1 = microtime(true);
foreach ($ips as $ip) {
    $res[] = $iptool->find($ip);
}
$t2 = microtime(true);
echo 'IP Tool : '.($t2-$t1).PHP_EOL;
/* SxGeo */
$res = [];
$t1 = microtime(true);
foreach ($ips as $ip) {
    $res[] = $sxgeo->getCityFull($ip);
}
$t2 = microtime(true);
echo 'SxGeo   : '.($t2-$t1).PHP_EOL;

Результат трёх тестов


ivan@ddrv ~/test $ php compare.php
IP Tool : 0.026905059814453
SxGeo   : 0.031632900238037

ivan@ddrv ~/test $ php compare.php
IP Tool : 0.025413036346436
SxGeo   : 0.023004055023193

ivan@ddrv ~/test $ php compare.php
IP Tool : 0.016932010650635
SxGeo   : 0.022341012954712

Но если определять не 100, а один адрес (А эти условия более реальны), результаты печальнее


ivan@ddrv ~/test $ php compare.php
IP Tool : 0.0013048648834229
SxGeo   : 0.00016021728515625

ivan@ddrv ~/test $ php compare.php
IP Tool : 0.00047779083251953
SxGeo   : 0.00011301040649414

ivan@ddrv ~/test $ php compare.php
IP Tool : 0.00046205520629883
SxGeo   : 0.00035595893859863

Вывод


Вывода я сделал два:


  • Нужно работать над размером БД;
    • реализовать связь между справочниками, это заметно сократит размер базы диапазонов;
    • склеивать интервалы с одинаковыми данными (хотя в данной БД таковых нет, они взяты из SxGeo как есть);
    • хранить начальные адреса диапазонов в виде 3х байт, как в SxGeo.
  • Улучшить работу поиска. Нужно будет попробовать вместо перебора использовать бинарный поиск. Возможно, по аналогии с SxGeo, добавить основной индекс

Комментарии (9)


  1. He11ion
    10.10.2017 12:44
    +1

    Версию от MaxMind не рассматривали в качестве альтернативы?


    1. trawl Автор
      10.10.2017 12:46

      В MaxMind разве можно добавить свои данные?


      1. aleksandro
        10.10.2017 15:58

        Нельзя.
        А зачем вам добавлять свои данные? Поделитесь сценарием.


        1. trawl Автор
          10.10.2017 16:12

          Например, мне нужна не гео-база, а база ip ботов. Или провайдеров. Или вообще, всё вместе. Тогда я собираю одну базу:

          • гео — на основе того же maxmind
          • боты — на основе, к примеру ipgrabber
          • провайдеры — отдельным скриптом собираю инфу в RIPE

          И всё. С помощью этой тулзы я смогу одним запросом получить всю необходимую инфу, а не держать 3 разные библиотеки, не инициализировать их все и не делать к к каждой по запросу.

          Ведь IP база — это не только геолокация…


  1. zapimir
    10.10.2017 17:25
    +1

    ограничение по количеству справочников;

    Ну ограничение весьма условно, хотя уже в новой версии формата, будет расширен функционал, чтобы количество справочников не ограничивалось (добавятся специальные типы полей).
    невозможность узнать интервал адресов, в который входит искомый адрес;

    Это уже больше возможность API. В принципе сам формат позволяет это сделать. Но из-за оптимизаций при сохранении файла, это немного лишается смысла. Оптимизации заключаются в склеивании идущих подряд записей, которые ссылаются на один элемент справочника (т.е. если, к примеру, идет 10 IP диапазонов в Москве, то они сохраняются в базу как один, но вывести границы этого большого диапазона не проблема).

    Что касается вашей реализации.
    Бегло глянул, вы по сути выкинули главное :)
    Убрали основной индекс, и оставили индекс первых байт IP. А также выкинули бинарный поиск, и сделали обычный перебор.

    В моём случае, основное предназначение индекса первых байт было убрать кучу повторяющихся первых байт из базы, тем самым заметно снизив размер базы, и ускорив поиск, так как нужно было сравнивать на один байт меньше (тем более этот байт почти всегда в блоке был одинаковый).

    Но изначально был основной индекс, задача которого была поделить базу на равные фрагменты. Поскольку IP диапазоны очень не равномерны, и для одного первого байта может быть пару диапазонов, для другого несколько сотен тысяч. И соответственно скорость поиска будет очень плавать, в зависимости от того в какой диапазон попадешь.

    Но более того у вас похоже ошибка в алгоритме, так как даже индекс по первым байтам не очень хорошо работает.

    Сгенерил по вашим примерам базу на основе Geolite City. И ради интереса запустил тест скорости (получилось меньше 200 запросов в секунду, что озадачило, учитывая что в SxGeo обычно скорость без кеширований около 18 тысяч).
    Посмотрел исходник и смутилj вычисление $start и $stop для выборки нужного блока.

    Добавил
    echo "$start - $stop\n"; 

    перед
    $blockCount = $stop-$start;

    и увидел, что при любом ip у вас $stop равен последнему блоку. Т.е. всегда выборка до конца. В итоге получается для IP начинающихся на 1 в переменную $blocks запихивает все блоки, а это в данном случае 15 МБ.

    Также не совсем понял, у вас нет зависимостей между справочниками? Ну чтобы когда находишь город, в этой записи были ссылки на соответствующие записи в справочниках региона и страны.

    Извиняюсь, многословно получилось :)


    1. trawl Автор
      10.10.2017 17:55

      и увидел, что при любом ip у вас $stop равен последнему блоку. Т.е. всегда выборка до конца.

      Ого! Вот это я накосячил! Спасибо!
      Также не совсем понял, у вас нет зависимостей между справочниками? Ну чтобы когда находишь город, в этой записи были ссылки на соответствующие записи в справочниках региона и страны.

      У меня нет возможности указать зависимости между справочниками.
      В моём примере информация о городе, регионе и стране хранится в одном справочнике «geo». При желании, можно сделать три справочника (город, регион, страна), что уменьшит размер базы, но увеличит количество дисковых операций при поиске. Но опять же, связь будет только между диапазоном и справочником.


      1. zapimir
        10.10.2017 18:51

        что уменьшит размер базы

        С базами городов, очень большой оверхед получается, если хранить, как в вашем случае записи фиксированной длины (тоже по началу был соблазн так сделать, чтобы ускорить выборку). Но есть там всякие городки с «километровыми» названиями, у такого городка может быть один крошечный диапазон IP, но из-за него разбухает вся база. А если туда еще как в случае с SxGeo записать подробную инфу о регионе и стране (включая названия на 7 языках). То размер справочников будет в несколько раз больше, чем сами диапазоны.

        К примеру у меня в SxGeo Max Multi названия стран на 7 языках занимают в виде текста до 380 байт, если их записывать в каждый город (их 84 000) получится оверхед 32 МБ, в базе SxGeo справочник стран занимает 30 КБ, а вся база полностью 17,8 МБ. По моему не лучшее решение, чтобы вместо дополнительной мелкой операции чтения плодить лишние 32 МБ. Регионы до 490 байт, города до 230.

        Это получается 380+490+230 (города) умножаем на количество записей городов 84000 получается 92 МБ + еще 15 МБ сами диапазоны итого 107 против 17,8, как по мне многовато за 2 дополнительных операции чтения по 500 байт. У Maxmind вообще прыжки по файлу на каждый бит IP-адреса и ничего :)


        1. trawl Автор
          11.10.2017 07:30

          В моём случае случае, задача по проектированию справочников ложится на пользователя инструмента. В документации я привёл всего лишь пример (хотя признаю, крайне неудачный. Обещаю поправить).
          Что касается связи между справочниками — я пока не придумал, как это реализовать в универсальном механизме. У Вас это решается кодом, но у вас более узкая задача.


    1. trawl Автор
      13.10.2017 08:27

      Исправил работу индекса, провёл сравнительный тест, результаты уже поинтереснее :)