Решаемая задача

Зная UUID дома (ранее идентификатор ФИАС) требуется найти почтовый индекс, город, улицу и номер дома для отдельно взятого региона, и на этих данных создать собственные справочники. Используемая РСУБД — PostgeSQL, используемый фреймворк — Laravel 10.

Кому ещё может оказаться полезен данный материал

Всем, кто решает схожую задачу. Тем, кто решает задачу от противного, т.е. приводит в порядок свои базы, находя «Идентификатор ФИАС» по наименованиям городов, улиц и т.п. А так же всем, кто хочет разобраться в структуре базы ГАР, в рамках решаемой задачи.

В предыдущей части

Исследование выгрузки базы ГАР с целью автоматизации загрузки данных в свою базу

Что в этой части?

Пролог

Для нетерпеливых

Концепция приложения

Описание требований к системе

Создание и настойка приложения

Создание и применение миграций

Автоматизация скачивания файла с выгрузкой базы ГАР

Извлечение необходимых данных из архива

Парсинг xml файлов и создание задачи (Job) на вставку данных в таблицу

Создание абстрактного базового класса для записи данных в таблицы БД

Реализация наследуемых классов

Постановка заданий в очередь

Многопоточная асинхронная обработка очереди

Создание представления (VIEW) и модели для удобного доступа к данным

«Актуализация» неактивных данных

Собираем всё вместе

Пролог

В первой части рассмотрена выгрузка базы ГАР. В этой части хотел коротенечко изложить суть приложения, но вышло как в известной комедии: коротенько, минут на 40. Поэтому выделил раздел для нетерпеливых, в котором кратко описано как установить и использовать рабочее приложение. Статья реализована в виде туториала, в котором шаг за шагом собирается рабочее приложение. Весь код приведён в тексте статьи. По возможности он убран в спойлеры. Приложение протестировано на СУБД PostgreSQL и MariaDB. Если я что-то где-то упустил, пишите. Буду рад внести исправления.

Для нетерпеливых

Инструкция по установке и использованию

Клонируйте приложение с github.com или скачайте его архивом

git clone git@github.com:IggorGor/gar.git

Перейдите в каталог с приложением и настройте права на каталоги

cd gar
sudo chown -R $USER:www-data storage
sudo chown -R $USER:www-data bootstrap/cache
chmod -R 775 storage
chmod -R 775 bootstrap/cache

Настройте .env и config/gar.php Если ваша база не PostgreSQL, имя базы обязательно должно быть gar. Подробнее в описании требований. Настройте номер своего региона.

Установите зависимости

composer install --no-dev --optimize-autoloader

Выполните миграции

php artisan migrate

Для работы должен быть установлен wget (есть версия и для Windows). Если путь к wget не прописан в PATH, следует прописать путь к утилите в файле config/gar.php.

Выполните команду

php artisan gar:complete-full-import

Дождитесь её выполнения. Время зависит от производительности вашей системы и скорости Интернета.

Воспользуйтесь представлением gar.gar_data_by_uuid для доступа к данным

select * from gar.gar_data_by_uuid
where city_name = 'Ново-Талицы'
and street_name = '5-я Изумрудная'

Или воспользуйтесь моделью Models\Gar\GarDataByUUID

$result = GarDataByUUID::where('house_object_guid', '=',
  '5cef293c-745f-4053-bed6-05466f2758f4')
  ->first();

Если что-то не заработало или испытываете проблемы с производительностью, прочитайте низлежащий материал. Подберите таймаут для процесса воркера и время повторного запуска задачи (Job). Уберите индексы из миграций и оптимизируйте количество процессов для загрузки данных.

В приложении реализованы следующие дополнительные команды

gar:full-download — скачивает выгрузку в файловое хранилище
gar:full-extract — извлекает необходимые файлы из выгрузки
gar:full-import — ставит задания на парсинг xml и запись данных в БД
gar:start-workers — запускает обработчики очереди Laravel

Также ознакомьтесь со способом «актуализации» неактуальных данных.

Концепция приложения

Полностью консольное приложение, не требующее web-сервера. Для работы нужен только PHP, wget и подключение к базе данных. Скачивает выгрузку базы ГАР по расписанию или по вызову консольной команды. Парсит файлы, в объёме достаточном для выполнения поставленной задачи (см. часть I) и загружает их в базу, используя несколько параллельных асинхронных процессов. Настройка воркеров и из запуск не требуется.

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

Описание требований к системе

Для работы потребуется php версии не ниже 8.1, утилита wget, composer, и доступ к РСУБД PostgreSQL.

Можно ли использовать другие СУБД, поддерживаемые Laravel?

Изначально приложение было спроектировано только для работы с PostgreSQL, но затем, по просьбам трудящихся, добавлена возможность работы с MariaDB. В теории да, должно работать, но я тестировал приложение только для PostgreSQL и MariaDB.

В случае PostgreSQL создаётся схема, которая затем пишется в бэкап, и этот бэкап разворачивается на проде.

Для MariaDB нет возможности создать схему (схема в MariaDB это синоним базы данных). Поэтому необходимость добавления схемы определяется в миграциях. Тем не менее, обращение к таблицам идёт как "schema.table". Если имя схемы совпадает с именем БД, то для MariaDB всё работает.

В любом случае, всегда можно доработать миграции и модели под конкретную ситуацию.

Можно ли использовать PHP ниже 8.1?

Можно, но тогда придётся отказаться от использования фасада Illuminate\Support\Facades\Process. Впрочем всё можно реализовать с помощью старого доброго exec(), а также отказаться от использования конструкции match(), и то в случае, если php будет ниже восьмёрки.

Создание и настойка приложения

Выполняем команду:

composer create-project laravel/laravel gar

Заходим в каталог с приложением

cd gar

Настраиваем права

sudo chown -R $USER:www-data storage
sudo chown -R $USER:www-data bootstrap/cache
chmod -R 775 storage
chmod -R 775 bootstrap/cache

Удаляем ненужные миграции, модели и конфигурации

rm ./database/migrations/2014_10_12_000000_create_users_table.php
rm ./database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php
rm ./database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php
rm ./database/factories/UserFactory.php
rm ./app/Models/User.php
rm ./config/sanctum.php

О последней строчке: из коробки в Laravel установлен sanctum, наличие которого приведёт к появлению таблицы personal_access_tokens. Простого удаления миграции не хватит, чтобы избавиться от этой таблицы. sanctum использует миграцию из папки vendor. Чтобы этого избежать, удаляем sanctum

composer remove laravel/sanctum

Если не удалить файл конфигурации, мы получим ошибку: «Class "Laravel\Sanctum\Sanctum" not found», т.к. файл конфигурации содержит строку: «use Laravel\Sanctum\Sanctum;»

Настраиваем конфигурацию в .env.

Следует настроить соединение с базой данных. Важно! Если СУБД отлична от PostgreSQL база данных должна называться строго «gar». В противном случае придётся вносить изменения в файлы миграций, моделей и вспомогательных классов.

QUEUE_CONNECTION следует присвоить значение, отличное от sync, в противном случае не удастся реализовать многопоточную загрузку. В моём случае стоит database.

Настоятельно рекомендую отключить режим отладки, записав в APP_DEBUG=false, а в APP_ENV=production.

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

Если отключение режима отладки не помогло, пересоберите приложение, выполнив

composer install --no-dev

Композер исключит все пакеты для разработки. После этих процедур за память можно не переживать.

Создание и применение миграций

Миграции создавались строго по xsd схемам, с учётом обнаруженных несоответствий. В случае коллизий тип полей выбирался максимально производительным и удобным из возможных. Например, вместо строк с лидирующим нулём '01' применялся целочисленный тип.

В миграциях создаются индексы. Такое решение принято потому, что на основе загруженных таблиц, будет создано представление (VIEW) и индексирование необходимо для увеличения производительности. С другой стороны, построение индексов может существенно снизить скорость загрузки. В моём случае на загрузку таблиц для моего региона в СУБД PostgreSQL тратится 14 минут. Я счёл это разумным временем. Но время загрузки может сильно отличаться от системы к системе. Если вы будете испытывать проблемы с производительностью — удалите индексы. В дальнейшем их можно построить отдельной миграцией.

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

Добавляем миграцию для таблицы с заданиями:

php artisan queue:table
Создание моделей и миграций для таблиц, разобранных в части I

Содержимое моделей однотипное. Задаётся имя таблицы, т.к. она лежит в схеме, отличной от public, указывается, что не используется автоинкремент и поля created_at/updated_at, и что все поля доступны для массового присвоения.

Пример содержимого модели таблицы house_params
Пример содержимого модели таблицы house_params

В начале создаём схему «gar». Сама схема создаётся в зависимости от содержимого файла .env. Если СУБД отлична от pgsql, то схема создаваться не будет.

php artisan make:migration create_schema_gar
Содержимое миграции create_schema_gar
<?php

use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
    public function up(): void
    {
        if (config('database.default') === 'pgsql') DB::unprepared('create schema if not exists gar');
    }

    public function down(): void
    {
        if (config('database.default') === 'pgsql') DB::unprepared('drop schema if exists gar');
    }
};

Таблица addr_objs

Команда artisan

php artisan make:model Gar\AddrObjs -m

Миграция xxxx_xx_xx_xxxxxx_create_addr_objs_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('gar.addr_objs', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('object_id')->comment('Глобальный уникальный идентификатор объекта');
            $table->index('object_id');
            $table->uuid('object_guid')->comment('Глобальный уникальный идентификатор адресного объекта');
            $table->index('object_guid');
            $table->bigInteger('change_id')->comment('ID изменившей транзакции');
            $table->string('name', 250)->comment('Наименование');
            $table->string('type_name', 50)->comment('Краткое наименование типа объекта');
            $table->integer('level')->comment('Уровень адресного объекта');
            $table->index('level');
            $table->integer('oper_type_id')->comment('Статус действия над записью – причина появления записи');
            $table->bigInteger('prev_id')->nullable()->comment('Идентификатор записи связывания с предыдущей исторической записью');
            $table->bigInteger('next_id')->nullable()->comment('Идентификатор записи связывания с последующей исторической записью');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_actual')->comment('Статус актуальности адресного объекта ФИАС');
            $table->boolean('is_active')->comment('Признак действующего адресного объекта');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.addr_objs');
    }
};

Модель Models/Gar/AddrObjs.php

<?php

namespace App\Models\Gar;
use Illuminate\Database\Eloquent\Model;

class AddrObj extends Model
{
    protected $table = 'gar.addr_objs';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

Таблица adm_hierarchies

Команда artisan

php artisan make:model Gar\AdmHierarchy -m

Миграция xxxx_xx_xx_xxxxxx_create_adm_hierarchies_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.adm_hierarchies', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('object_id')->comment('Глобальный уникальный идентификатор объекта');
            $table->index('object_id');
            $table->bigInteger('parent_obj_id')->comment('Идентификатор родительского объекта');
            $table->index('parent_obj_id');
            $table->bigInteger('change_id')->comment('ID изменившей транзакции');
            $table->string('region_code', 4)->nullable()->comment('Код региона');
            $table->string('area_code', 4)->nullable()->comment('Код района');
            $table->string('city_code', 4)->nullable()->comment('Код города');
            $table->string('place_code', 4)->nullable()->comment('Код населенного пункта');
            $table->string('plan_code', 4)->nullable()->comment('Код ЭПС');
            $table->string('street_code', 4)->nullable()->comment('Код улицы');
            $table->bigInteger('prev_id')->nullable()->comment('Идентификатор записи связывания с предыдущей исторической записью');
            $table->bigInteger('next_id')->nullable()->comment('Идентификатор записи связывания с последующей исторической записью');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_active')->comment('Признак действующего адресного объекта');
            $table->string('path')->comment('Материализованный путь к объекту (полная иерархия)');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.adm_hierarchies');
    }
};

Модель Models/Gar/AdmHierarchy.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class AdmHierarchy extends Model
{
    protected $table = 'gar.adm_hierarchies';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];

}

Таблица houses

Команда artisan

php artisan make:model Gar\House -m

Миграция xxxx_xx_xx_xxxxxx_create_houses_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.houses', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('object_id')->comment('Глобальный уникальный идентификатор объекта');
            $table->index('object_id');
            $table->uuid('object_guid')->comment('Глобальный уникальный идентификатор адресного объекта');
            $table->index('object_guid');
            $table->bigInteger('change_id')->comment('ID изменившей транзакции');
            $table->string('house_num', 50)->nullable()->comment('Основной номер дома');
            $table->string('add_num_1', 50)->nullable()->comment('Дополнительный номер дома 1');
            $table->string('add_num_2', 50)->nullable()->comment('Дополнительный номер дома 2');
            $table->bigInteger('house_type')->nullable()->comment('Основной тип дома');
            $table->index('house_type');
            $table->bigInteger('add_type_1')->nullable()->comment('Дополнительный тип дома 1');
            $table->index('add_type_1');
            $table->bigInteger('add_type_2')->nullable()->comment('Дополнительный тип дома 2');
            $table->index('add_type_2');
            $table->integer('oper_type_id')->comment('Статус действия над записью – причина появления записи');
            $table->bigInteger('prev_id')->nullable()->comment('Идентификатор записи связывания с предыдущей исторической записью');
            $table->bigInteger('next_id')->nullable()->comment('Идентификатор записи связывания с последующей исторической записью');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_actual')->comment('Статус актуальности адресного объекта ФИАС');
            $table->boolean('is_active')->comment('Признак действующего адресного объекта');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.houses');
    }
};

Модель Models/Gar/House.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class House extends Model
{
    protected $table = 'gar.houses';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

Таблица houses_add_types

Команда artisan

php artisan make:model Gar\HouseAddType -m

Миграция xxxx_xx_xx_xxxxxx_create_house_add_types_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.house_add_types', function (Blueprint $table) {
            $table->id();
            $table->string('name', 250)->comment('Наименование');
            $table->string('short_name', 50)->nullable()->comment('Краткое наименование');
            $table->string('desc', 250)->nullable()->comment('Описание');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_active')->comment('Статус активности');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.house_add_types');
    }
};

Модель Models/Gar/HouseAddType.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class HouseAddType extends Model
{
    protected $table = 'gar.house_add_types';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

Таблица house_params

Команда artisan

php artisan make:model Gar\HouseParam -m

Миграция xxxx_xx_xx_xxxxxx_create_house_params_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.house_params', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('object_id');
            $table->index('object_id');
            $table->bigInteger('change_id');
            $table->integer('change_id_end');
            $table->integer('type_id');
            $table->index('type_id');
            $table->string('value');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->index('end_date');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.house_params');
    }
};

Модель Models/Gar/HouseParam.php

<?php

namespace App\Models\Gar;
use Illuminate\Database\Eloquent\Model;

class HouseParam extends Model
{
    protected $table = 'gar.house_params';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

Таблица house_types

Команда artisan

php artisan make:model Gar\HouseType -m

Миграция xxxx_xx_xx_xxxxxx_create_house_types_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.house_types', function (Blueprint $table) {
            $table->id();
            $table->string('name', 250)->comment('Наименование');
            $table->string('short_name', 50)->comment('Краткое наименование');
            $table->string('desc', 250)->comment('Описание');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_active')->comment('Статус активности');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.house_types');
    }
};

Модель Models/Gar/HouseType.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class HouseType extends Model
{
    protected $table = 'gar.house_types';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];

}

Таблица object_levels

Команда artisan

php artisan make:model Gar\ObjectLevel -m

Миграция xxxx_xx_xx_xxxxxx_create_object_levels_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.object_levels', function (Blueprint $table) {
            $table->id()->comment('Уникальный идентификатор записи. Ключевое поле. Номер уровня объекта');
            $table->string('name', 250)->comment('Наименование');
            $table->string('short_name', 50)->nullable()->comment('Краткое наименование');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
            $table->boolean('is_active')->comment('Статус активности');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.object_levels');
    }
};

Модель Models/Gar/ObjectLevel.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class ObjectLevel extends Model
{
    protected $table = 'gar.object_levels';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

Таблица param_types

Команда artisan

php artisan make:model Gar\ParamType -m

Миграция xxxx_xx_xx_xxxxxx_create_param_types_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('gar.param_types', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('desc');
            $table->string('code');
            $table->boolean('is_active');
            $table->date('update_date')->comment('Дата внесения (обновления) записи');
            $table->date('start_date')->comment('Начало действия записи');
            $table->date('end_date')->comment('Окончание действия записи');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('gar.param_types');
    }
};

Модель Models/Gar/ParamType.php

<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class ParamType extends Model
{
    protected $table = 'gar.param_types';
    public $timestamps = false;
    public $incrementing = false;
    protected $guarded = [];
}

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

Таблица с версиями выгрузок

Команда artisan

php artisan make:model Version -m

Миграция

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('versions', function (Blueprint $table) {
            $table->id();
            $table->string('version');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('versions');
    }
};

Модель

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Version extends Model
{
    protected $guarded = ['id'];
}

Можно выполнять миграции

php artisan migrate

Если приложение в состояние production, artisan выдаст предупреждение. Нужно согласиться с выполнением миграций.

База данных готова. Можно переходить к загрузке выгрузки базы ГАР (простите за тавтологию).

Автоматизация скачивания файла с выгрузкой базы ГАР

На сайте налоговой по адресу http://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo расположен сервис, возвращающий JSON, в котором находится информация о версии базы, а также URL адреса, с которых можно скачать полную выгрузки и diff-архив. Изначально я пытался скачать файл средствами PHP. На момент написания статьи архив составлял 39GB и в дальнейшем он будет только расти, т.к. база содержит ретроспективные, неактуальные записи.

Использование функции copy() завершилось провалом. Функция бодро рапортовала об успешном окончании, но архив базы оказывался битым. Опыты в должном объёме мешала проводить налоговая. После двух попыток скачивания мой IP блокировался. Как обойти блокировку описано чуть ниже.

Затем я попытался использовать фасад Http — обёртку над Guzzle. Дело тоже закончилось плачевно.

Тогда я решил использовать внешний процесс, и задействовать проверенную временем утилиту wget. Есть версия и для Windows, которая отлично себя проявила на этапе разработки и тестирования. wget цеплялся в файл выгрузки мёртвой хваткой, и не отпускал его, пока полностью не скачивал на клиентскую машину. Для примера, лог wget, в котором скачивание длилось 18 часов 20 минут.

Лог wget
Лог wget из которого видно, что скачивание длилось 18 часов 20 минут
Лог wget из которого видно, что скачивание длилось 18 часов 20 минут

Для запуска процесса wget используется фасад Process. Процесс вызывается синхронно.

Т.к. приложение консольное, для скачивания файла с выгрузкой создана artisan команда. Плюсы такого подхода: в любой момент можно запустить эту команду для скачивания архива руками, разместить вызов этой команды в планировщике Laravel или вызвать эту команду из cron / планировщика Windows, если планировщик Laravel не запущен.

Создание команды:

php artisan make:command GarFullDownloadCommand
Содержимое файла app/Console/Commands/GarFullDownloadCommand.php
<?php

namespace App\Console\Commands;

use App\Services\GarService;
use Exception;
use Illuminate\Console\Command;

class GarFullDownloadCommand extends Command
{
    protected $signature = 'gar:full-download';

    protected $description = 'Command download full GAR archive from server';

    public function handle(): void
    {
        GarService::downloadFullGarArchive();
    }
}

В методе handle команды идёт обращение к классу GarService. Это вспомогательный класс, в котором собрана вся логика по загрузке и обработке выгрузки.

В папке app создаём каталог Services, и создаём класс GarService.

Класс GarService
<?php

namespace App\Services;

class GarService
{

}

Реализуем публичный статический метод downloadFullGarArchive().

GarService::downloadFullGarArchive()
<?php
  public static function downloadFullGarArchive(): bool
    {
        $result = Http::get('https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo');
        if ($result->ok()) {
            $garArray = json_decode($result->body(), true);
            $version = Version::select('version')->latest()->limit(1)->first();
            if (is_null($version) or $version->version < $garArray['VersionId']) {
                GarService::initDirectories();  
                GarService::deleteOldFullZipFile();
                GarService::deleteOldLogFile();
                $cmd = GarService::getCmd($garArray['GarXMLFullURL']);
                $processResult = Process::forever()->run($cmd);
                return $processResult->successful();
            }
        }
        return false;
    }

Разберём, что здесь происходит. С помощью фасада Http идёт обращение к сервису, расположенному по адресу https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo. В ответе содержится следующий JSON

Из таблицы versions извлекается номер предыдущей скачанной версии. Если это первое скачивание, или номер в базе меньше текущего номера, в файловом хранилище создаётся каталог gar (если он ещё не создан), удаляются предыдущие скачанные файлы и логи, и запускается утилита wget c переданными ей параметрами.

Сам вызов происходит с помощью фасада Process

$processResult = Process::forever()->run($cmd);

Метод forever() устанавливает время таймаута равным бесконечности, а run() запускает процесс синхронно.

Дорабатываем GarService ещё четырьмя методами
<?php
    private static function getCmd(string $downloadUrl): string
    {
        if (config('gar.download_mode') == 'alternate')
            $downloadUrl = str_replace('fias-file', 'fias', $downloadUrl);
        return config('gar.wget_path') . 'wget ' . $downloadUrl . " -O " .
            Storage::path(config('gar.xml_full_zip_file_name')) . " -o " . Storage::path(config('gar.wget_log_file_name'));
    }

    private static function initDirectories(): void
    {
        if (!Storage::directoryExists('gar')) Storage::makeDirectory('gar');
    }

    public static function deleteOldFullZipFile(): void
    {
        if (Storage::exists(config('gar.xml_full_zip_file_name')))
            Storage::delete(config('gar.xml_full_zip_file_name'));
    }

    public static function deleteOldLogFile(): void
    {
        if (Storage::exists(config('gar.xml_full_zip_file_name')))
            Storage::delete(config('gar.xml_full_zip_file_name'));
    }

Сервис обращается к файлу конфигурации. Сам файл расположен в config/gar.php.

Вот его код
<?php

return [
    // Код обрабатываемого региона
    'region_code' => 37,

    // Путь к архиву полной выгрузки справочника ГАР
    'xml_full_zip_file_name' => 'gar/gar_full_xml.zip',

    // Путь к логу утилиты wget
    'wget_log_file_name' => 'gar/wget.log',

    // Путь к каталогу, для распаковки архива с полной выгрузкой
    'unzip_full_path' => 'gar/unzip/full',

    // Путь к каталогу, содержащему утилиту wget
    'wget_path' => 'D:/OSPanel/modules/wget/bin/',

    // Уровень логирования: 0 — не логировать, 1 — краткие логи, 2 — подробные логи
    'log_level' => 1, // 0, 1, 2

    // Режим скачивания: normal — скачивать с дефолтного адреса, alternate — скачивать с альтернативного адреса (медленно)
    'download_mode' => 'normal',
];

Назначение параметров указано в комментариях.

О параметре download_mode: если ваш IP заблокировали и не удаётся скачать архив выгрузки с адреса по умолчанию, то можно подменить домен, заменив fias-file.nalog.ru на fias.nalog.ru. В этом случае скорость скачивания будет очень скромной, но зато на этом домене не банят налево и направо. Чтобы активировать эту подмену, задайте параметру download_mode значение alternate.

Теперь приложение способно скачать свежую выгрузку полной базы. Наберите в консоли команду:

php artisan gar:full-download

и идите обедать. Через пару часов полученный файл можно будет найти в файловом хранилище Laravel по адресу app\gar\gar_full_xml.zip (если вы не меняли конфигурацию).

Извлечение необходимых данных из архива

Для решения поставленной задачи не требуется весь объём данных. Нужно выгрузить четыре справочных таблицы (общих для всех регионов) и четыре таблицы для моего региона. Номер региона задан в конфигурационном файле config/gar.php.

Для разархивации используется класс ZipArchive.

GarService::extractFullGar() +
<?php
    public static function extractFullGar(): bool
    {
        $zip = new ZipArchive();
        $status = $zip->open(Storage::path(config('gar.xml_full_zip_file_name')));
        if ($status !== true) return false;
        else {
            self::deleteOldUnzipFiles();
            self::makeUnzipDirectory();
            $extractArray = self::getExtractFiles($zip);
            if (count($extractArray) != 0)
                $zip->extractTo(Storage::path(config('gar.unzip_full_path')), $extractArray);

        }
        $zip->close();
        return true;
    }

    private static function deleteOldUnzipFiles(): void
    {
        if (Storage::directoryExists(config('gar.unzip_full_path')))
            Storage::deleteDirectory(config('gar.unzip_full_path'));
    }

    private static function makeUnzipDirectory(): void
    {
        if (!Storage::directoryExists(config('gar.unzip_full_path'))) {
            Storage::makeDirectory(config('gar.unzip_full_path'));
        }
    }

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

GarService::getExtractFiles()
    private static function getExtractFiles(ZipArchive $zip): array
    {
        $extractArray = [];
        for ($i = 0; $i < $zip->count(); $i++) {
            $fileName = $zip->getNameIndex($i);
            $dir = dirname($fileName);
            if (($dir == '.' or $dir == config('gar.region_code')) and self::verifyFileName($fileName))
                $extractArray[] = $fileName;
        }
        return $extractArray;
    }

Необходимость извлечения определяется методом verifyFileName()

GarService::verifyFileName()
    private static function verifyFileName(string $fileName): bool
    {
        foreach (self::$regFileNames as $regFileName) {
            if (preg_match($regFileName, basename($fileName))) return true;
        }
        return false;
    }

Для определения необходимости извлечения файла используются регулярные выражения. Но, возможно, использование strpos было бы лучшим выбором. Регулярные выражения собраны в массив. Этот массив потребуется в дальнейшем, когда придётся находить текущее наименование файла, извлёчённого из архива.

GarService::$regFileNames
    private static array $regFileNames = [
        'AS_OBJECT_LEVELS' => '/^AS_OBJECT_LEVELS_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_HOUSE_TYPES' => '/^AS_HOUSE_TYPES_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_ADDHOUSE_TYPES' => '/^AS_ADDHOUSE_TYPES_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_PARAM_TYPES' => '/^AS_PARAM_TYPES_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_HOUSES_PARAMS' => '/^AS_HOUSES_PARAMS_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_ADDR_OBJ' => '/^AS_ADDR_OBJ_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_ADM_HIERARCHY' => '/^AS_ADM_HIERARCHY_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
        'AS_HOUSES' => '/^AS_HOUSES_\d{8}_[0-9abcdef]{8}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{4}-[0-9abcdef]{12}.XML$/i',
    ];

Создаём команду

php artisan make:command GarFullExtractCommand
Содержимое файла app/Console/Commands/GarFullExtractCommand.php
<?php

namespace App\Console\Commands;

use App\Services\GarService;
use Exception;
use Illuminate\Console\Command;

class GarFullExtractCommand extends Command
{
    protected $signature = 'gar:full-extract';

    protected $description = 'Command unzips the necessary files from the general upload';

    /**
     * @throws Exception
     */
    public function handle(): void
    {
        GarService::extractFullGar();
    }
}

Вызываем команду

php artisan gar:full-extract

После небольшого ожидания требуемые файлы извлечены из архива и лежат в файловом хранилище Laravel по адресу app/gar/unzip/full.

Парсинг xml файлов и создание задачи (Job) на вставку данных в таблицу

Самый простой способ распарсить .xml файл — использовать SimpleXML. На выходе имеем массив, в котом размещается разобранное дерево документа. Но в данном случае этот метод не подходит, т.к. файлы выгрузки очень «тяжёлые», а SimpleXML загружает файл в память полностью.

Я решил применить «гибридный» подход. Читать .xml файл с помощью класса XMLReader, затем нужный узел преобразовывать в класс SimpleXMLElement и работать с его атрибутами. Благо простота дерева в файлах выгрузки позволяет сделать это с минимумом усилий.

Схема организации файла выгрузки, на примере таблицы houses
<?xml version="1.0" encoding="utf-8"?>
<HOUSES>
  <HOUSE ... />
  <HOUSE ... />
  ...
  <HOUSE ... />
</HOUSES>

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

Следующий код демонстрирует общую идею разбора .xml (малозначимый код опущен)

Концепция идеи
// Читаем файл до тех пор, пока не выйдем на интересующий элемент
while ($xml->read() && $xml->name !== 'HOUSE') ;
// Выполняем цикл до тех пор, пока наименование элемента равно ожидаемому
while ($xml->name === 'HOUSE') {
  // Элемент имеет аттрибуты?
  if ($xml->hasAttributes) {
    // Преобразуем атрибуты элемента в массив
    $node = (array)simplexml_load_string($xml->readOuterXml());
    $node = $node['@attributes'];
  }
  $xml->next('HOUSE');
}

В результате переменная $node будет содержать массив

Массив
array:14 [
  "ID" => "50008317"
  "OBJECTID" => "82641773"
  "OBJECTGUID" => "5d99249e-64b6-4f59-8e53-2bcac59be3cd"
  "CHANGEID" => "122770604"
  "HOUSENUM" => "298"
  "HOUSETYPE" => "2"
  "OPERTYPEID" => "10"
  "PREVID" => "0"
  "NEXTID" => "71774225"
  "UPDATEDATE" => "2021-10-08"
  "STARTDATE" => "2014-02-13"
  "ENDDATE" => "2021-10-08"
  "ISACTUAL" => "0"
  "ISACTIVE" => "0"
]

Осталось положить этот массив в нужную таблицу. И, конечно же, при массовой вставке вносить в таблицу нужно максимально возможное количество записей за один раз.

На основе вышеизложенного создаю класс задания, который можно ставить в очередь Laravel

php artisan make:job ImportGar
Содержимое класса задания
<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use XMLReader;

class ImportGar implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 60 * 60;
    public int $tries = 1;

    public function __construct(
        public string $fileName,
        public string $nodeName,
        public string $serviceName
    )
    {
    }

    public function handle(): void
    {
        if (Storage::fileExists($this->fileName)) {
            $this->xmlParse();
        }
    }

    private function xmlParse(): void
    {
        $xml = new XMLReader();
        if ($xml->open(Storage::path($this->fileName))) $this->readXml($xml);
    }

    private function readXml(XMLReader $xml): void
    {
        $serviceName = new $this->serviceName;
        while ($xml->read() && $xml->name !== $this->nodeName) ;
        while ($xml->name === $this->nodeName) {
            if ($xml->hasAttributes) {
                $node = (array)simplexml_load_string($xml->readOuterXml());
                $node = $node['@attributes'];
                $serviceName->addLine($node);
            }
            $xml->next($this->nodeName);
        }
        $this->deleteProcessedFile();
    }

    private function deleteProcessedFile(): void
    {
        Storage::delete($this->fileName);
    }

}

Несколько слов о реализации:

Свойство $timeout следует установить достаточным для того, чтобы был разобран самый «тяжёлый» файл. Этот параметр нужно подобрать экспериментально. Он будет сильно зависеть от дисковой системы вашей машины. В моём случае для PostgreSQL на SDD это время составило 13 минут, для MariaDB на HDD — почти два часа.

Важно! Следует поправить параметр retry_after для вашего соединения с очередью в файле конфигурации config/queue.php. Его значение должно быть чуть-чуть больше, чем таймаут. В противном случае возможен повторный запуск задания, в то время, когда оно ещё выполняется.

В задание передаётся имя обрабатываемого файла, наименование элемента в дереве xml и наименование класса, отвечающего за загрузку данных в таблицу БД. Обращение к этому классу идёт на строке 49 ($serviceName->addLine()). Об этом классе мы сейчас и поговорим.

Создание абстрактного базового класса для записи данных в таблицы БД

Загрузка данных однотипна для каждой таблицы, различаются лишь имена таблиц, количество и состав столбцов. Создаём абстрактный базовый класс, который реализует общую логику. В папке Services создаём папку Gar, в которой создаём файл CommonGar.php

Cодержимое класса CommonGar
<?php

namespace App\Services\Gar;

use Illuminate\Support\Facades\DB;
use Log;

abstract class CommonGar
{
    private array $insertArray;
    private int $maxLineToSave;
    private int $alreadySave;

    public function __construct()
    {
        DB::table($this->getTableName())->truncate();
        if (config('gar.log_level') > 0) Log::info('Таблица ' . $this->getTableName() . ' очищена');
        $this->maxLineToSave = floor(65535 / count($this->getKeysArray()));
        $this->alreadySave = 0;
    }

    public function __destruct()
    {
        if (isset($this->insertArray) and count($this->insertArray) > 0) $this->saveToTable();
        if (config('gar.log_level') > 0) Log::info('Всего записано в таблицу ' . $this->getTableName() . ' ' . $this->alreadySave);
    }

    abstract protected function getTableName(): string;

    abstract protected function canProcessed(array $inputArray): bool;

    abstract protected function getKeysArray(): array;

    private function mapInputValues(array $inputValues): array
    {
        $keysArray = $this->getKeysArray();
        $output = [];
        foreach ($keysArray as $key => $newKey) {
            if (key_exists($key, $inputValues)) {
                $newValue = match ($inputValues[$key]) {
                    'true' => true,
                    'false' => false,
                    default => $inputValues[$key]
                };
            } else $newValue = null;
            $output[$newKey] = $newValue;
        }
        return $output;
    }

    private function clearInsertArray(): void
    {
        $this->insertArray = [];
    }

    private function saveToTable(): void
    {
        DB::table($this->getTableName())->insert($this->insertArray);
        $this->alreadySave += count($this->insertArray);
        if (config('gar.log_level') > 1) Log::info('Записано в таблицу ' . $this->getTableName() . ' ' . count($this->insertArray) . ' записей, всего: ' . $this->alreadySave);
    }

    private function processSave(): void
    {
        $this->saveToTable();
        $this->clearInsertArray();
    }

    public function addLine(array $inputArray): void
    {
        if ($this->canProcessed($inputArray)) $this->insertArray[] = $this->mapInputValues($inputArray);
        if (count($this->insertArray) >= $this->maxLineToSave) $this->processSave();
    }

}

Для массовой загрузки лучше всего подойдёт метод insert фасада DB:

DB::table($tableName)->insert($insertArray);

Здесь и далее всегда используется фасад DB, т.к. обращение к модели вызовет накладные расходы. Модель будет создавать события, выполнять глобальные скоупы (Global Scopes) и отрабатывать прочую логику. На больших объёмах данных вся эта работа может превратиться в лишние минуты. А обращение к фасаду DB это работа с базой данных через PDO.

Но массовая загрузка через PDO ограничена количеством параметров, которое не может превышать 65535. В нашем случае один параметр — это одно значение, передаваемое для вставки. Все таблицы разные и количество параметров, требуемых для загрузки одной записи — различно. Поэтому и количество строк, возможное для одновременной загрузки будет отличаться от таблице в таблице.

Свойство $maxLineToSave; содержит максимально возможное количество строк, вставляемых в таблицу. Данное значение вычисляется в конструкторе класса.

Конструктор
    public function __construct()
    {
        DB::table($this->getTableName())->truncate();
        if (config('gar.log_level') > 0) Log::info('Таблица ' . $this->getTableName() . ' очищена');
        $this->maxLineToSave = floor(65535 / count($this->getKeysArray()));
        $this->alreadySave = 0;
    }

Максимально возможное количество строк вычисляется как частное от деления 65535 на количество полей в таблице, округлённое в меньшую сторону.

Класс содержит три абстрактных метода, которые должны быть реализованы в потомках

    abstract protected function getTableName(): string;

    abstract protected function canProcessed(array $inputArray): bool;

    abstract protected function getKeysArray(): array;

function getTableName() возвращает имя таблицы, необходимое для манипуляций с нею (очистка, вставка)

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

будет выглядеть так:
    protected function canProcessed(array $inputArray): bool
    {
        return !(array_key_exists('ISACTIVE', $inputArray) and !$inputArray['ISACTIVE']);
    }

А для таблицы house_params

так:
    protected function canProcessed(array $inputArray): bool
    {
        return (array_key_exists('TYPEID', $inputArray) and $inputArray['TYPEID'] == 5);
    }

В таблице house_params нас интересует только почтовый индекс, а он хранится в строках с type_id = 5. Все остальные строки можно игнорировать.

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

getKeysArray() возвращает ассоциативный массив, ключи в котором совпадают с ключами массива, полученного из элемента XML, а значение является именем поля в таблице.

Пример, реализации функции для таблицы param_types
protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'NAME' => 'name',
            'DESC' => 'desc',
            'CODE' => 'code',
            'ISACTIVE' => 'is_active',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
        ];
    }

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

Функция mapInputValues() принимает на вход массив атрибутов из xml файла. Обходит массив ключей, полученных из getKeysArray(). Если во входных данных ключ найден, создаётся запись в выходном массиве с новым ключом и старым значением. Если ключ не найден, в выходной массив записывается пара — новый ключ => null. Также заменяются строки 'true' и 'false' на значения true и false. Причём такая подмена потребовалась только в MariaDB. PostgreSQL прекрасно справлялся и со строками.

function mapInputValues()
    private function mapInputValues(array $inputValues): array
    {
        $keysArray = $this->getKeysArray();
        $output = [];
        foreach ($keysArray as $key => $newKey) {
            if (key_exists($key, $inputValues)) {
                $newValue = match ($inputValues[$key]) {
                    'true' => true,
                    'false' => false,
                    default => $inputValues[$key]
                };
            } else $newValue = null;
            $output[$newKey] = $newValue;
        }
        return $output;
    }

Единственный публичный метод в данном классе — function addLine(). Этот метод добавляет строку в буфер, и как только размер буфера становится максимально возможным, вставляет его в таблицу.

function addLine()
    public function addLine(array $inputArray): void
    {
        if ($this->canProcessed($inputArray)) $this->insertArray[] = $this->mapInputValues($inputArray);
        if (count($this->insertArray) >= $this->maxLineToSave) $this->processSave();
    }

В деструкторе класса идёт проверка на заполненность буфера, и если в нём ещё что-то осталось, он также сбрасывается в таблицу.

Назначение остальных методов должно быть понятно, заострять на них внимание не буду.

Имея базовый класс, создаём отдельные классы для каждой из таблиц

Реализация наследуемых классов

Реализация классов
Services/Gar/AddrObj.php
<?php

namespace App\Services\Gar;

class AddrObj extends CommonGar
{
    protected function getTableName(): string
    {
        return 'gar.addr_objs';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'OBJECTID' => 'object_id',
            'OBJECTGUID' => 'object_guid',
            'CHANGEID' => 'change_id',
            'NAME' => 'name',
            'TYPENAME' => 'type_name',
            'LEVEL' => 'level',
            'OPERTYPEID' => 'oper_type_id',
            'PREVID' => 'prev_id',
            'NEXTID' => 'next_id',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTUAL' => 'is_actual',
            'ISACTIVE' => 'is_active',
        ];
    }
}

Services/Gar/AdmHierarchy.php
<?php

namespace App\Services\Gar;

class AdmHierarchy extends CommonGar
{
    protected function getTableName(): string
    {
        return 'gar.adm_hierarchies';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'OBJECTID' => 'object_id',
            'PARENTOBJID' => 'parent_obj_id',
            'CHANGEID' => 'change_id',
            'REGIONCODE' => 'region_code',
            'AREACODE' => 'area_code',
            'CITYCODE' => 'city_code',
            'PLACECODE' => 'place_code',
            'PLANCODE' => 'plan_code',
            'STREETCODE' => 'street_code',
            'PREVID' => 'prev_id',
            'NEXTID' => 'next_id',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTIVE' => 'is_active',
            'PATH' => 'path',
        ];
    }
}

Services/Gar/House.php
<?php

namespace App\Services\Gar;

class House extends CommonGar
{

    protected function getTableName(): string
    {
        return 'gar.houses';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'OBJECTID' => 'object_id',
            'OBJECTGUID' => 'object_guid',
            'CHANGEID' => 'change_id',
            'HOUSENUM' => 'house_num',
            'ADDNUM1' => 'add_num_1',
            'ADDNUM2' => 'add_num_2',
            'HOUSETYPE' => 'house_type',
            'ADDTYPE1' => 'add_type_1',
            'ADDTYPE2' => 'add_type_2',
            'OPERTYPEID' => 'oper_type_id',
            'PREVID' => 'prev_id',
            'NEXTID' => 'next_id',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTUAL' => 'is_actual',
            'ISACTIVE' => 'is_active',
        ];
    }
}

Services/Gar/HouseAddType.php
<?php

namespace App\Services\Gar;

class HouseAddType extends CommonGar
{

    protected function getTableName(): string
    {
        return 'gar.house_add_types';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'NAME' => 'name',
            'SHORTNAME' => 'short_name',
            'DESC' => 'desc',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTIVE' => 'is_active',
        ];
    }
}

Services/Gar/HouseParam.php
<?php

namespace App\Services\Gar;

class HouseParam extends CommonGar
{
    protected function getTableName(): string
    {
        return 'gar.house_params';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'OBJECTID' => 'object_id',
            'CHANGEID' => 'change_id',
            'CHANGEIDEND' => 'change_id_end',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'TYPEID' => 'type_id',
            'VALUE' => 'value',
            'ENDDATE' => 'end_date',
        ];
    }
}

Services/Gar/HouseType.php
<?php

namespace App\Services\Gar;

class HouseType extends CommonGar
{

    protected function getTableName(): string
    {
        return 'gar.house_types';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'NAME' => 'name',
            'SHORTNAME' => 'short_name',
            'DESC' => 'desc',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTIVE' => 'is_active',
        ];
    }
}

Services/Gar/ObjectLevel.php
<?php

namespace App\Services\Gar;

class ObjectLevel extends CommonGar
{

    protected function getTableName(): string
    {
        return 'gar.object_levels';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return true;
    }

    protected function getKeysArray(): array
    {
        return [
            'LEVEL' => 'id',
            'NAME' => 'name',
            'SHORTNAME' => 'short_name',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
            'ISACTIVE' => 'is_active',
        ];
    }
}

Services/Gar/ParamType.php
<?php

namespace App\Services\Gar;

class ParamType extends CommonGar
{

    protected function getTableName(): string
    {
        return 'gar.param_types';
    }

    protected function canProcessed(array $inputArray): bool
    {
        return !(array_key_exists('ISACTIVE', $inputArray) and !$inputArray['ISACTIVE']);
    }

    protected function getKeysArray(): array
    {
        return [
            'ID' => 'id',
            'NAME' => 'name',
            'DESC' => 'desc',
            'CODE' => 'code',
            'ISACTIVE' => 'is_active',
            'UPDATEDATE' => 'update_date',
            'STARTDATE' => 'start_date',
            'ENDDATE' => 'end_date',
        ];
    }
}

Постановка заданий в очередь

Всё готово для того, чтобы поставить задачи в очередь. Есть класс задания и классы, отвечающие за вставку данных в таблицы. Возвращаемся к сервису Services/GarService.php и добавляем в него три метода

function setImportJobs()
    public static function setImportJobs(): void
    {
        // HouseParam
        self::setJob('AS_HOUSES_PARAMS', 'PARAM', Gar\HouseParam::class);

        // AdmHierarchy
        self::setJob('AS_ADM_HIERARCHY', 'ITEM', Gar\AdmHierarchy::class);

        // House
        self::setJob('AS_HOUSES', 'HOUSE', Gar\House::class);

        // AddrObj
        self::setJob('AS_ADDR_OBJ', 'OBJECT', Gar\AddrObj::class);

        // ParamType
        self::setJob('AS_PARAM_TYPES', 'PARAMTYPE', Gar\ParamType::class);

        //ObjectLevels
        self::setJob('AS_OBJECT_LEVELS', 'OBJECTLEVEL', Gar\ObjectLevel::class);

        // HouseTypes
        self::setJob('AS_HOUSE_TYPES', 'HOUSETYPE', Gar\HouseType::class);

        // HouseAddTypes
        self::setJob('AS_ADDHOUSE_TYPES', 'HOUSETYPE', Gar\HouseAddType::class);
    }

Функция создаёт задания на парсинг и вставку данных для каждой таблицы. Само задание добавляется с помощью метода setJob(), в который передаётся ключ из массива $regFileNames, имя XML элемента и имя класса, унаследованного от CommonGar. Функция располагает задания от самого «тяжёлого» файла к самому «лёгкому». Это будет иметь значение при попытке минимизировать количество параллельных асинхронных процессов, при загрузке данных в базу.

function setJob()
    private static function setJob(string $keyFileName, string $nodeName, string $serviceName): void
    {
        $fileName = self::getFileName(self::$regFileNames[$keyFileName]);
        if (Storage::fileExists($fileName)) ImportGar::dispatch($fileName, $nodeName, $serviceName);
    }

setJob() использует функцию getFileName() для получения имени выгруженного файла по ключу из массива $regFileNames.

function getFileName()
    private static function getFileName(string $filePattern): string
    {
        $files = Storage::files(config('gar.unzip_full_path'), true);
        foreach ($files as $file) {
            if (preg_match($filePattern, basename($file))) return $file;
        }
        return '';
    }

В своё время мне приходилось писать рекурсивную функцию, чтобы обойти всё дерево каталогов и найти искомые файлы. С Laravel всё значительно проще. Метод фасада Storage::files() возвращает плоский массив с именами всех файлов из дерева каталогов. Остаётся их только перебрать.

И, наконец, создаём команду, которая ставит задачи в очередь

php artisan make:command GarFullImportCommand
Содержимое файла Console/Commands/GarFullImportCommand.php
<?php

namespace App\Console\Commands;

use App\Services\GarService;
use Exception;
use Illuminate\Console\Command;

class GarFullImportCommand extends Command
{
    protected $signature = 'gar:full-import';

    protected $description = 'Command import full download from GAR';

    /**
     * @throws Exception
     */
    public function handle(): void
    {
        GarService::setImportJobs();
    }
}

Выполняем команду

php artisan gar:full-import
Проверяем, что задания добавлены в очередь

Многопоточная асинхронная обработка очереди

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

Для начала я исследовал, какое время мне вообще нужно для обработки заданий, и какое количество воркеров будет оптимальным.

Исследования

Исследования проводились на локальной машине. СУБД PostgreSQL установлено на хост-машину в виде исполняемых файлов. Конфигурация из коробки не изменялась.

MariaDB запущена из контейнера Docker.

Команда Docker
docker run --name mariadb -p 127.0.0.1:3306:3306 \
-v e:\dev\mariadb\data:/var/lib/mysql \
-v e:\dev\mariadb\logs:/var/lib/mysql/logs \
-v e:\dev\mariadb\etc:/etc/mysql/conf.d \
-e MYSQL_ROOT_PASSWORD=secret -it  mariadb:10.4

Для PostgreSQL я получил вот такие результаты

Результаты PostgreSQL

Здесь у меня задания были собраны наоборот, от лёгкого к тяжёлому. Самое тяжёлое задание выполняется 12,5 минут. Как и положено, я провёл замеры трижды, среднее значение близко к предъявленному.

Результаты для MariaDB

Результаты MariaDB

В среднем так и было, один раз исполнение задачи заняло свыше двух часов.

Такое огромное время объясняется тем, что данные у PostgreSQL хранятся на SSD, а у MariaDB на HDD.

Расчистив место на SSD я перенёс данные MariaDB на SSD. Результаты повторных испытаний

Повторные испытания MariaDB

Уже значительно лучше, хотя и не дотягивает до PostgreSQL. Но здесь следует учесть неоптимальную конфигурацию.

Далее я провёл исследование на тему: даст ли выигрыш многопоточная асинхронная загрузка, и будет ли этот выигрыш существенен. Вдруг запись в один поток, последовательно, таблица за таблицей, будет не намного медленнее, ведь при многопоточной вставке СУБД вынуждена писать в несколько таблиц одновременно, а устройство ввода-вывода одно. Правда запись идёт не постоянно, сначала накапливаются буферы, затем они сбрасываются в таблицы.

Исследование показало, что да — многопоточная вставка даёт существенный прирост в производительности. В моём случае время такой загрузки возрастало незначительно, относительно самого тяжёлого задания. Например, для MariaDB на SSD выполнение самого тяжёлого задания заняло 20 минут, а многопоточная загрузка заняла 23 минуты, в то время, как линейная загрузка заняла бы 37 минут.

Немаловажен и тот факт, что чтение идёт с одного физического устройства, а запись — на другое. Если бы всё это происходило на одном диске, а диск этот был бы HDD, время на позиционирование могло бы быть огромным.

Эмпирическим путём я пришёл к выводу, что достаточно 2-х воркеров. Самое тяжёлое задание выполняется по времени чуть больше, чем все остальные. Но тут можно попасть на неприятную ситуацию, когда один воркер выполнил одно тяжёлое задание, но успел прихватить второе тяжёлое, пока конкурирующий воркер выполнял лёгкие. В таком случае выигрыш по времени нивелируется. Поэтому я решил запускать свыше двух воркеров.

Дорабатываю файл конфигурации config/gar.php

    // Количество воркеров, для обработки заданий
    'num_workers' => 4,

Воркеры также запускаются командой

Создаём команду

php artisan make:command GarStartWorkersCommand
Содержимое файла Console/Commands/GarStartWorkersCommand.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Process\Pool;
use Illuminate\Support\Facades\Process;

class GarStartWorkersCommand extends Command
{
    protected $signature = 'gar:start-workers';

    protected $description = 'Command description';

    public function handle(): void
    {
        Process::pool(function (Pool $pool) {
            for ($i = 0; $i < config('gar.num_workers'); $i++) {
                $pool->path(base_path())->timeout(3600)->command('php artisan queue:work --stop-when-empty');
            }
        })->start()->wait();
    }
}

Что в ней происходит:

Создаётся пул процессов вызовом Process::pool(). В метод pool передаётся замыкание, в котором асинхронно запускаются несколько параллельных процессов (количество берётся из файла конфигурации). Т.к. процессы одни и те же, используется цикл. Метод path задаёт путь к рабочему каталогу, timeout задаёт время таймаута, а метод command принимает команду, которая вызывает процесс.

Воркеры запускаются с опцией --stop-when-empty, что гарантирует завершение процесса, если очередь задания будет пуста.

Запускаем команду

php artisan gar:start-workers

и идём пить чай.

Готово, данные лежат по своим таблицам.

А теперь то, ради чего всё это и затевалось.

Создание представления (VIEW) и модели для удобного доступа к данным

Чтобы не держать в голове все взаимосвязи между таблицами и удобно обращаться к загруженным данным, сведём все данные к представлению, для которого можно создать модель.

Создание представления

Создание миграции

php artisan make:migration create_gar_data_by_uuid_view
Содержимое миграции
<?php

use Illuminate\Database\Migrations\Migration;

return new class extends Migration {
    public function up(): void
    {
        $sql = "
drop view if exists gar.gar_data_by_uuid;

create view gar.gar_data_by_uuid as
SELECT COALESCE(hp.value, 'Не задан') AS post_index,
       ao1.object_id                  AS city_object_id,
       ao1.object_guid                AS city_object_guid,
       ao1.type_name                  AS city_type_name,
       ao1.name                       AS city_name,
       ol1.short_name                 AS city_level_short_name,
       ol1.name                       AS city_level_name,
       ao.object_id                   AS street_object_id,
       ao.object_guid                 AS street_object_guid,
       ao.type_name                   AS street_type_name,
       ao.name                        AS street_name,
       ol.name                        AS street_level_name,
       ol.short_name                  AS street_level_short_name,
       h.object_id                    AS house_object_id,
       h.object_guid                  AS house_object_guid,
       ht.name                        AS house_type_name,
       ht.short_name                  AS house_type_short_name,
       h.house_num,
       hat.short_name                 AS house_add_type_1_short_name,
       hat.name                       AS house_add_type_1_name,
       h.add_num_1                    AS house_add_num_1,
       hat1.short_name                AS house_add_type_2_short_name,
       hat1.name                      AS house_add_type_2_name,
       h.add_num_2                    AS house_add_num_2,
       h.is_active                    AS house_active
FROM gar.houses h
         LEFT JOIN gar.adm_hierarchies ah ON ah.object_id = h.object_id AND ah.is_active = true
         LEFT JOIN gar.adm_hierarchies ah1 ON ah1.object_id = ah.parent_obj_id AND ah1.is_active = true
         LEFT JOIN gar.addr_objs ao ON ao.object_id = ah.parent_obj_id AND ao.is_active = true
         LEFT JOIN gar.addr_objs ao1 ON ao1.object_id = ah1.parent_obj_id AND ao1.is_active = true
         LEFT JOIN gar.house_types ht ON ht.id = h.house_type AND ht.is_active = true
         LEFT JOIN gar.house_add_types hat ON hat.id = h.add_type_1 AND hat.is_active = true
         LEFT JOIN gar.house_add_types hat1 ON hat1.id = h.add_type_2 AND hat1.is_active = true
         LEFT JOIN gar.object_levels ol ON ol.id = ao.level AND ol.is_active = true
         LEFT JOIN gar.object_levels ol1 ON ol1.id = ao1.level AND ol1.is_active = true
         LEFT JOIN gar.house_params hp ON hp.object_id = h.object_id AND hp.type_id = 5 AND hp.end_date >= now()
WHERE h.is_actual = true";
        DB::unprepared($sql);
    }

    public function down(): void
    {
        DB::unprepared('drop view if exists gar.gar_data_by_uuid;');
    }
};

Создание модели

php artisan make:model Gar\GarDataByUUID
Содержимое модели
<?php

namespace App\Models\Gar;

use Illuminate\Database\Eloquent\Model;

class GarDataByUUID extends Model
{
    public $timestamps = false;
    protected $table = 'gar.gar_data_by_uuid';
}

Выполняем миграцию

php artisan migrate

Готово. Можно обращаться к представлению, как к обычной таблице. Использовать её в запросах, подзапросах, объединениях (join). А можно обращаться к представлению, как к модели Laravel.

Например:

$result = GarDataByUUID::where('house_object_guid', '=',
  '5cef293c-745f-4053-bed6-05466f2758f4')
  ->first();

dump($result->attributesToArray());
Результат выполнения запроса
Результат выполнения запроса

Представление выводит последовательно: индекс, город, улицу, дом. Ненужные атрибуты можно отфильтровать в секции select.

«Актуализация» неактивных данных

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

Как понять, что информация о доме неактуальна?

Поле house_active равно false, а все поля, кроме, house_object_id и house_object_guid содержат null.

Как актуализировать информацию о доме?

Теоретические аспекты актуализации изложены в первой части. Перейдём к практике.

Добавим в сервис GarService публичный метод activateHouse

GarService::activateHouse
    public static function activateHouse(string $guid): void
    {
        $house = House::where('object_guid', '=', $guid)
            ->orderBy('end_date', 'desc')
            ->limit(1)
            ->get()[0];
        $house->update(['is_active' => 'true', 'is_actual' => 'true']);
        $adm_hierarchy = AdmHierarchy::where('object_id', '=', $house->object_id)
            ->orderBy('end_date', 'desc')
            ->limit(1)
            ->get()[0];
        $adm_hierarchy->update(['is_active' => 'true']);
    }

Теперь для «актуализации» неактивного дома достаточно вызвать данный метод.

Важно! Этот метод работает только для полной выгрузки. При выгрузке дельт этот метод приведёт к коллизиям.

Собираем всё вместе

Добавим последнюю команду, которая автоматически проделывает все шаги: скачивает выгрузку, парсит её, запускает воркеры и заносит данные в таблицы.

php artisan make:command GarCompleteFullUmportCommand
Содержимое команды
<?php

namespace App\Console\Commands;

use App\Services\GarService;
use Exception;
use Illuminate\Console\Command;

class GarCompleteFullImportCommand extends Command
{
    protected $signature = 'gar:complete-full-import';

    protected $description = 'Command performs full import to the database';

    /**
     * @throws Exception
     */
    public function handle(): void
    {
        if (GarService::downloadFullGarArchive())
            if (GarService::extractFullGar()) {
                GarService::setImportJobs();
                $this->call('gar:start-workers');
            }
    }
}

Теперь, когда возникнет нужда, скачать и импортировать свежую версию выгрузки базы ГАР достаточно вызвать эту команду

php artisan gar:complete-full-import

Также эту команду можно разместить в планировщике Laravel или в cron.

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


  1. sanchezzzhak
    16.10.2023 14:18
    +1

    В место SimpleXml лучше использовать XmlReader.

    SimpleXml загружает всё в память.

    XmlReader читает xml блоками и не важно сколько xml весит. Я парсил xml и по 3гб и общее потребление было 20мб на скрипт.


    1. 2medic Автор
      16.10.2023 14:18
      +1

      Я именно его и использую. Чуть ниже

      Я решил применить «гибридный» подход. Читать .xml файл с помощью класса XMLReader, затем нужный узел преобразовывать в класс SimpleXMLElement и работать с его атрибутами. Благо простота дерева в файлах выгрузки позволяет сделать это с минимумом усилий.


  1. ikrusenstern
    16.10.2023 14:18
    +2

    Есть готовое решение которе справляется с задачей лучше:

    https://github.com/liquetsoft/fias-component базовый компонент, на его основе есть библиотеки для работы с базой ФИАС в формате ГАР для рахных фреймворков итд. Например для LaravelSymfony или для импорта сразу в Elasticsearch.


    1. 2medic Автор
      16.10.2023 14:18
      +1

      Спасибо, интересно! Мне бы пораньше Ваш комментарий прочитать :)


  1. 2medic Автор
    16.10.2023 14:18

    Ответил не туда, комментарий удалён