Решаемая задача
Зная 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
Настройте в описании требований. Настройте номер своего региона.
Установите зависимости
composer install --no-dev --optimize-autoloader
Выполните миграции
php artisan migrate
Для работы должен быть установлен wget (есть версия и для Windows). Если путь к wget не прописан в PATH, следует прописать путь к утилите в файле select * from gar.gar_data_by_uuid where city_name = 'Ново-Талицы' and street_name = '5-я Изумрудная'
Или воспользуйтесь моделью $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, утилита 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, и что все поля доступны для массового присвоения.
В начале создаём схему «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 используется фасад 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 команды идёт обращение к классу
Реализуем публичный статический метод
Разберём, что здесь происходит. С помощью фасада Http идёт обращение к сервису, расположенному по адресу https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo. В ответе содержится следующий JSON Из таблицы $processResult = Process::forever()->run($cmd);
Метод
Сервис обращается к файлу конфигурации. Сам файл расположен в
Назначение параметров указано в комментариях. О параметре php artisan gar:full-download
и идите обедать. Через пару часов полученный файл можно будет найти в файловом хранилище Laravel по адресу app\gar\gar_full_xml.zip (если вы не меняли конфигурацию). Для решения поставленной задачи не требуется весь объём данных. Нужно выгрузить четыре справочных таблицы (общих для всех регионов) и четыре таблицы для моего региона. Номер региона задан в конфигурационном файле ZipArchive. Чтобы извлечь часть архива в метод
Необходимость извлечения определяется методом
Для определения необходимости извлечения файла используются регулярные выражения. Но, возможно, использование strpos было бы лучшим выбором. Регулярные выражения собраны в массив. Этот массив потребуется в дальнейшем, когда придётся находить текущее наименование файла, извлёчённого из архива. Создаём команду Вызываем команду После небольшого ожидания требуемые файлы извлечены из архива и лежат в файловом хранилище Laravel по адресу app/gar/unzip/full. Самый простой способ распарсить .xml файл — использовать SimpleXML. На выходе имеем массив, в котом размещается разобранное дерево документа. Но в данном случае этот метод не подходит, т.к. файлы выгрузки очень «тяжёлые», а SimpleXML загружает файл в память полностью. Я решил применить «гибридный» подход. Читать .xml файл с помощью класса XMLReader, затем нужный узел преобразовывать в класс SimpleXMLElement и работать с его атрибутами. Благо простота дерева в файлах выгрузки позволяет сделать это с минимумом усилий. Файл содержит один корневой элемент, в который вложены пустые элементы. Все данные, относящиеся к дому, расположены в атрибутах элементов. Спасибо разработчикам. Вместо атрибутов они могли собрать данные в дерево, а потом сиди и его разбирай. Следующий код демонстрирует общую идею разбора .xml (малозначимый код опущен) В результате переменная $node будет содержать массив Осталось положить этот массив в нужную таблицу. И, конечно же, при массовой вставке вносить в таблицу нужно максимально возможное количество записей за один раз. На основе вышеизложенного создаю класс задания, который можно ставить в очередь Laravel Несколько слов о реализации: Свойство $timeout следует установить достаточным для того, чтобы был разобран самый «тяжёлый» файл. Этот параметр нужно подобрать экспериментально. Он будет сильно зависеть от дисковой системы вашей машины. В моём случае для PostgreSQL на SDD это время составило 13 минут, для MariaDB на HDD — почти два часа. Важно! Следует поправить параметр retry_after для вашего соединения с очередью в файле конфигурации config/queue.php. Его значение должно быть чуть-чуть больше, чем таймаут. В противном случае возможен повторный запуск задания, в то время, когда оно ещё выполняется. В задание передаётся имя обрабатываемого файла, наименование элемента в дереве xml и наименование класса, отвечающего за загрузку данных в таблицу БД. Обращение к этому классу идёт на строке 49 ( Загрузка данных однотипна для каждой таблицы, различаются лишь имена таблиц, количество и состав столбцов. Создаём абстрактный базовый класс, который реализует общую логику. В папке Services создаём папку Gar, в которой создаём файл CommonGar.php Для массовой загрузки лучше всего подойдёт метод insert фасада DB: Здесь и далее всегда используется фасад DB, т.к. обращение к модели вызовет накладные расходы. Модель будет создавать события, выполнять глобальные скоупы (Global Scopes) и отрабатывать прочую логику. На больших объёмах данных вся эта работа может превратиться в лишние минуты. А обращение к фасаду DB это работа с базой данных через PDO. Но массовая загрузка через PDO ограничена количеством параметров, которое не может превышать 65535. В нашем случае один параметр — это одно значение, передаваемое для вставки. Все таблицы разные и количество параметров, требуемых для загрузки одной записи — различно. Поэтому и количество строк, возможное для одновременной загрузки будет отличаться от таблице в таблице. Свойство
Максимально возможное количество строк вычисляется как частное от деления 65535 на количество полей в таблице, округлённое в меньшую сторону. Класс содержит три абстрактных метода, которые должны быть реализованы в потомках
А для таблицы house_params В таблице house_params нас интересует только почтовый индекс, а он хранится в строках с type_id = 5. Все остальные строки можно игнорировать. Как сказано выше, мы фактически имеем массив, который можно напрямую вставлять в таблицу, но нас не устраивают наименования ключей, которые расходятся с соглашениями Laravel. Чтобы навести порядок используются две функции
Результат работы этой функции участвует в подсчёте максимального количества строк, возможного к единовременной загрузке в таблицу. См. код конструктора. Функция
Единственный публичный метод в данном классе —
В деструкторе класса идёт проверка на заполненность буфера, и если в нём ещё что-то осталось, он также сбрасывается в таблицу. Назначение остальных методов должно быть понятно, заострять на них внимание не буду. Имея базовый класс, создаём отдельные классы для каждой из таблиц Всё готово для того, чтобы поставить задачи в очередь. Есть класс задания и классы, отвечающие за вставку данных в таблицы. Возвращаемся к сервису
Функция создаёт задания на парсинг и вставку данных для каждой таблицы. Само задание добавляется с помощью метода
В своё время мне приходилось писать рекурсивную функцию, чтобы обойти всё дерево каталогов и найти искомые файлы. С Laravel всё значительно проще. Метод фасада php artisan make:command GarFullImportCommand
Выполняем команду Дальше можно просто настроить воркеры на обслуживание очереди, добавив их в желаемом количестве. Но я решил пойти другим путём, и запускать воркеры самостоятельно. Для начала я исследовал, какое время мне вообще нужно для обработки заданий, и какое количество воркеров будет оптимальным. Исследования проводились на локальной машине. СУБД PostgreSQL установлено на хост-машину в виде исполняемых файлов. Конфигурация из коробки не изменялась. MariaDB запущена из контейнера Docker. Для PostgreSQL я получил вот такие результаты Здесь у меня задания были собраны наоборот, от лёгкого к тяжёлому. Самое тяжёлое задание выполняется 12,5 минут. Как и положено, я провёл замеры трижды, среднее значение близко к предъявленному. Результаты для MariaDB В среднем так и было, один раз исполнение задачи заняло свыше двух часов. Такое огромное время объясняется тем, что данные у PostgreSQL хранятся на SSD, а у MariaDB на HDD. Расчистив место на SSD я перенёс данные MariaDB на SSD. Результаты повторных испытаний Уже значительно лучше, хотя и не дотягивает до PostgreSQL. Но здесь следует учесть неоптимальную конфигурацию. Далее я провёл исследование на тему: даст ли выигрыш многопоточная асинхронная загрузка, и будет ли этот выигрыш существенен. Вдруг запись в один поток, последовательно, таблица за таблицей, будет не намного медленнее, ведь при многопоточной вставке СУБД вынуждена писать в несколько таблиц одновременно, а устройство ввода-вывода одно. Правда запись идёт не постоянно, сначала накапливаются буферы, затем они сбрасываются в таблицы. Исследование показало, что да — многопоточная вставка даёт существенный прирост в производительности. В моём случае время такой загрузки возрастало незначительно, относительно самого тяжёлого задания. Например, для MariaDB на SSD выполнение самого тяжёлого задания заняло 20 минут, а многопоточная загрузка заняла 23 минуты, в то время, как линейная загрузка заняла бы 37 минут. Немаловажен и тот факт, что чтение идёт с одного физического устройства, а запись — на другое. Если бы всё это происходило на одном диске, а диск этот был бы HDD, время на позиционирование могло бы быть огромным. Эмпирическим путём я пришёл к выводу, что достаточно 2-х воркеров. Самое тяжёлое задание выполняется по времени чуть больше, чем все остальные. Но тут можно попасть на неприятную ситуацию, когда один воркер выполнил одно тяжёлое задание, но успел прихватить второе тяжёлое, пока конкурирующий воркер выполнял лёгкие. В таком случае выигрыш по времени нивелируется. Поэтому я решил запускать свыше двух воркеров. Дорабатываю файл конфигурации // Количество воркеров, для обработки заданий 'num_workers' => 4,
Воркеры также запускаются командой Создаём команду Что в ней происходит: Создаётся пул процессов вызовом Чтобы не держать в голове все взаимосвязи между таблицами и удобно обращаться к загруженным данным, сведём все данные к представлению, для которого можно создать модель. Создание миграции Создание модели Выполняем миграцию Готово. Можно обращаться к представлению, как к обычной таблице. Использовать её в запросах, подзапросах, объединениях (join). А можно обращаться к представлению, как к модели Laravel. Например: Представление выводит последовательно: индекс, город, улицу, дом. Ненужные атрибуты можно отфильтровать в секции select. Изначально я загружал только активные и актуальные данные. Но очень быстро выяснилось, что некоторые дома находятся в неактивном состоянии. Такое происходит потому, что компании и юр. лица, которым принадлежат дома, не озабочены актуализацией информации. Но работать с такими домами нужно. Поэтому приходится их «актуализировать». Как понять, что информация о доме неактуальна? Поле house_active равно false, а все поля, кроме, house_object_id и house_object_guid содержат null. Как актуализировать информацию о доме? Теоретические аспекты актуализации изложены в первой части. Перейдём к практике. Добавим в сервис
Теперь для «актуализации» неактивного дома достаточно вызвать данный метод. Важно! Этот метод работает только для полной выгрузки. При выгрузке дельт этот метод приведёт к коллизиям. Добавим последнюю команду, которая автоматически проделывает все шаги: скачивает выгрузку, парсит её, запускает воркеры и заносит данные в таблицы. Теперь, когда возникнет нужда, скачать и импортировать свежую версию выгрузки базы ГАР достаточно вызвать эту команду Также эту команду можно разместить в планировщике Laravel или в cron.<?php namespace App\Services; class GarService { }<?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; }
<?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')); } <?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', ]; Извлечение необходимых данных из архива
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')); } } 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; } private static function verifyFileName(string $fileName): bool { foreach (self::$regFileNames as $regFileName) { if (preg_match($regFileName, basename($fileName))) return true; } return false; } 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Парсинг xml файлов и создание задачи (Job) на вставку данных в таблицу
Схема организации файла выгрузки, на примере таблицы houses
<?xml version="1.0" encoding="utf-8"?> <HOUSES> <HOUSE ... /> <HOUSE ... /> ... <HOUSE ... /> </HOUSES>Концепция идеи
// Читаем файл до тех пор, пока не выйдем на интересующий элемент 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'); }Массив
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" ]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); } } Создание абстрактного базового класса для записи данных в таблицы БД
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(); } } DB::table($tableName)->insert($insertArray); 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; } abstract protected function getTableName(): string; abstract protected function canProcessed(array $inputArray): bool; abstract protected function getKeysArray(): array; protected function canProcessed(array $inputArray): bool { return !(array_key_exists('ISACTIVE', $inputArray) and !$inputArray['ISACTIVE']); }так:
protected function canProcessed(array $inputArray): bool { return (array_key_exists('TYPEID', $inputArray) and $inputArray['TYPEID'] == 5); }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', ]; } 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; } 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', ]; } } Постановка заданий в очередь
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); } 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); } 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 ''; }Содержимое файла 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Проверяем, что задания добавлены в очередь

Многопоточная асинхронная обработка очереди
Исследования
Команда 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

Результаты MariaDB

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

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(); } } Создание представления (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$result = GarDataByUUID::where('house_object_guid', '=', '5cef293c-745f-4053-bed6-05466f2758f4') ->first(); dump($result->attributesToArray());
«Актуализация» неактивных данных
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
ссылка на оригинал статьи https://habr.com/ru/articles/764392/
Добавить комментарий