IP Tool — База данных IP адресов

Вступление


Долгое время я пользовался библиотекой 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


Подготовка к сравнительному тесту


$ cd /path/to/test/dir
$ mkdir csv
$ mkdir csv/sxgeo
$ mkdir t

необходимо скопировать файлы SxGeo.php и SxGeoCity.dat в текущую директорию (/path/to/test/dir)


Установка IPTool


$ composer require ddrv/iptool:~1.0

Импорт БД SxGeo в csv файлы


import.php
<?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);
    }
}
$sxgeo = new ExtSxGeo( __DIR__.DIRECTORY_SEPARATOR.'SxGeoCity.dat',2);
$sxgeo->parseBase();

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


$ php import.php

Создание БД IPTool из полученных csv файлов


convert.php
<?php
/* Создание БД IPTool из полученных csv файлов */
require_once(__DIR__.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.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);
}

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


$ php convert.php

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


$ ls -l *.dat
...
-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 раза (что не есть плюс)


compare.php
<?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;

Сравненительный тест скорости iptool-1.0.6 и SxGeo-2.2.3


$ php compare.php

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


IP Tool : 0.026905059814453
SxGeo   : 0.031632900238037

IP Tool : 0.025413036346436
SxGeo   : 0.023004055023193

IP Tool : 0.016932010650635
SxGeo   : 0.022341012954712

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


IP Tool : 0.0013048648834229
SxGeo   : 0.00016021728515625

IP Tool : 0.00047779083251953
SxGeo   : 0.00011301040649414

IP Tool : 0.00046205520629883
SxGeo   : 0.00035595893859863

UPD2. В версии 1.0.7 алгоритм поиска переведён на бинарный поиск


Сравненительный тест скорости iptool-1.0.7 и SxGeo-2.2.3


$ php compare.php

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


IP Tool : 0.012892961502075
SxGeo   : 0.033740043640137

IP Tool : 0.0073931217193604
SxGeo   : 0.032436847686768

IP Tool : 0.0043089389801025
SxGeo   : 0.028012990951538

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


IP Tool : 0.0011000633239746
SxGeo   : 0.0009000301361084

IP Tool : 0.00040006637573242
SxGeo   : 0.00079989433288574

IP Tool : 0.00030016899108887
SxGeo   : 0.00020003318786621

Вывод


Нужно работать над размером БД;


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

UPD 3


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


Теперь проект живёт здесь:


Создание базы GitHub Packagist
Поиск по базе GitHub Packagist

Поддержать автора
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    +1
    Версию от MaxMind не рассматривали в качестве альтернативы?
      0
      В MaxMind разве можно добавить свои данные?
        0
        Нельзя.
        А зачем вам добавлять свои данные? Поделитесь сценарием.
          0
          Например, мне нужна не гео-база, а база ip ботов. Или провайдеров. Или вообще, всё вместе. Тогда я собираю одну базу:
          • гео — на основе того же maxmind
          • боты — на основе, к примеру ipgrabber
          • провайдеры — отдельным скриптом собираю инфу в RIPE

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

          Ведь IP база — это не только геолокация…
      +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 МБ.

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

      Извиняюсь, многословно получилось :)
        0
        и увидел, что при любом ip у вас $stop равен последнему блоку. Т.е. всегда выборка до конца.

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

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

          С базами городов, очень большой оверхед получается, если хранить, как в вашем случае записи фиксированной длины (тоже по началу был соблазн так сделать, чтобы ускорить выборку). Но есть там всякие городки с «километровыми» названиями, у такого городка может быть один крошечный диапазон 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-адреса и ничего :)
            0
            В моём случае случае, задача по проектированию справочников ложится на пользователя инструмента. В документации я привёл всего лишь пример (хотя признаю, крайне неудачный. Обещаю поправить).
            Что касается связи между справочниками — я пока не придумал, как это реализовать в универсальном механизме. У Вас это решается кодом, но у вас более узкая задача.
          0
          Исправил работу индекса, провёл сравнительный тест, результаты уже поинтереснее :)

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое