Технологии

Пора двигаться быстро. Даже если ты на Битриксе (часть 3)

Привет, хабровчане! На связи Алиса — тимлид в e-commerce агентстве KISLOROD. Кто о чем, а я продолжаю рассказывать, как сшипперить Bitrix и Laravel. В первой части я рассказывала, как подружить Laravel с Битриксом так, чтобы никто не пострадал. Во второй — как устроить единый вход без шаринга сессий, ускорить каталог с OpenSearch, внедрить outbox-публикации и навести порядок в наблюдаемости. Теперь третий шаг — разгружаем чтение. Каталог в Битриксе — система, проверенная временем. Она хорошо справляется с хранением и администрированием, но если нагрузить ее фильтрами, фасетами и сортировками, начинаются задержки, лишние запросы, теряется плавность работы. Мы решили не перегружать основную систему, а освободить ее от тяжелого чтения: вынесли все в Laravel и OpenSearch. Битрикс продолжает делать то, что умеет лучше всего, а быстрые ответы теперь приходят оттуда, где для этого все подготовлено. Как это работает Путь данных устроен просто и надежно: Контентщик нажимает «Сохранить» в Битриксе — привычный процесс не меняется. Событие попадает в outbox, где фиксируется и ждет своего часа, даже если очередь временно недоступна. Через Redis Streams событие уходит в Laravel — быстро и без лишних зависимостей. Консюмер обновляет витрины в svc_catalog_* , готовит данные для поиска.Документ индексируется в OpenSearch и становится доступен для запроса. С этого момента все, что связано с отображением каталога — сайт, внутренние панели, партнерские интерфейсы — запрашивает данные у Laravel API. Если вдруг OpenSearch временно недоступен, Laravel не делает драму из ситуации. Он возвращает предсказуемый результат из Redis или аккуратную заглушку — страницу можно открыть, список не пустой, пользователь не теряется. А в это время система уже сообщает, что что-то идет не по плану: срабатывают алерты, в логах видны детали, а в дашборде — четкий сигнал, где и когда началось замедление. Никаких асапов в чате, работаем не постфактум, а по делу. Что именно выносим и зачем Битрикс отлично справляется с ролью редактора. Но его модель хранения — это набор таблиц и связей, которые для чтения хороши только в теории. А на практике каждый фильтр превращается в SQL-квест с множеством джойнов и подзапросов. Поэтому в read-модели мы идём по другому пути: складываем нужные поля в плоскую структуру; приводим типы к единому виду — чтобы можно было сразу фильтровать и агрегировать; текстовые значения дублируем, где нужно — для поиска и сортировки; свойства и остатки выносим как nested-объекты; фасеты и агрегаты считаем заранее, при записи. Скрытый текст Ставим официальный PHP-клиент OpenSearch или используем HTTP-запросы. Ниже пример маппинга с полями под полнотекст, фильтры и фасеты. { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "index": { "refresh_interval": "1s" }, "analysis": { "analyzer": { "ru_text": { "tokenizer": "standard", "filter": ["lowercase", "russian_stop", "russian_stemmer"] } }, "filter": { "russian_stop": { "type": "stop", "stopwords": "_russian_" }, "russian_stemmer": { "type": "stemmer", "language": "russian" } } } }, "mappings": { "properties": { "id": { "type": "keyword" }, "sku": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ru_text", "fields": { "raw": { "type": "keyword" } } }, "brand": { "type": "keyword" }, "category": { "type": "keyword" }, "price": { "type": "double" }, "price_type": { "type": "keyword" }, "in_stock": { "type": "boolean" }, "warehouses": { "type": "nested", "properties": { "id": { "type": "keyword" }, "qty": { "type": "double" } }}, "attrs": { "type": "nested", "properties": { "code": { "type": "keyword" }, "value_str": { "type": "keyword" }, "value_num": { "type": "double" } }}, "created_at": { "type": "date" }, "updated_at": { "type": "date" } } } } В индекс кладем только то, что нужно для поиска, фильтрации и отображения в листинге. А полную карточку собираем отдельно — уже из витрины. Как устроен индекс Структура у нас максимально утилитарная: keyword, boolean и double — для фильтрации;text с анализатором — для поиска по названию;title.raw — для сортировки по алфавиту;attrs и warehouses — как nested, чтобы обрабатывать условия типа «цвет = красный и размер = L» внутри одного товара;updated_at — чтобы отслеживать свежесть данных. Это позволяет OpenSearch быстро отвечать даже на сложные запросы — с текстом, фильтрами, сортировкой и фасетами. Laravel наносит ответный удар Поиск и карточки товаров теперь живут в Laravel. Один контроллер отвечает за оба сценария: ищет и показывает. Всё просто — валидируем запрос, строим DSL для OpenSearch, быстро кешируем. Никаких тяжёлых фильтров в Битриксе, никаких задержек. Если вам нужен продовый уровень: добавьте поддержку ETag, If-None-Match и троттлинг по ключам партнёров. Но даже без этого API выдаёт стабильный ответ с фильтрами, фасетами и агрегациями — фронту только отрисовать. А для карточки: Laravel собирает полную информацию из svc_catalog_* и возвращает JSON. Если товар не найден — отдаём 404, если найден — отдаем готовый к рендеру объект. Обновление индекса — через события. Как только товар меняется: Событие попадает в outbox. Laravel консюмер получает его через Streams. Собирает свежую карточку из витрин svc_catalog_* .Формирует документ. Обновляет его в OpenSearch. Сбивает кеш — чтобы все пересчитать на фронте. Если товар удален — просто удаляем документ из индекса. Скрытый текст Сначала поставим клиент и опишем обертку. composer require opensearch-project/opensearch-php:^2.2 Сервис работы с индексом: client = $client ?? ClientBuilder::create()->setHosts([env('OS_HOST', 'http://localhost:9200')])->build(); $this->index = $index ?? env('OS_INDEX', 'catalog_v1'); } public function upsert(array $doc): void { $this->client->index([ 'index' => $this->index, 'id' => (string) $doc['id'], 'body' => $doc, 'refresh' => false, ]); } public function delete(int|string $id): void { $this->client->delete(['index' => $this->index, 'id' => (string) $id]); } public function search(array $dsl): array { $res = $this->client->search(['index' => $this->index, 'body' => $dsl]); return $res['hits'] ?? []; } public function dsl(string $q, array $filters, array $facets, int $page, int $perPage): array { $must = []; $filter = []; if ($q !== '') { $must[] = ['multi_match' => [ 'query' => $q, 'fields' => ['title^2', 'title.raw', 'brand', 'sku'], 'type' => 'best_fields' ]]; } // простые фильтры: brand, category, price ranges foreach (['brand', 'category', 'price_type'] as $f) { if (! empty($filters[$f])) { $filter[] = ['terms' => [$f => (array) $filters[$f]]]; } } if (! empty($filters['price_min']) || ! empty($filters['price_max'])) { $range = []; if (isset($filters['price_min'])) $range['gte'] = (float) $filters['price_min']; if (isset($filters['price_max'])) $range['lte'] = (float) $filters['price_max']; $filter[] = ['range' => ['price' => $range]]; } // nested-атрибуты: attrs.code=value_str if (! empty($filters['attrs']) && is_array($filters['attrs'])) { foreach ($filters['attrs'] as $code => $vals) { $filter[] = [ 'nested' => [ 'path' => 'attrs', 'query' => [ 'bool' => [ 'must' => [ ['term' => ['attrs.code' => $code]], ['terms' => ['attrs.value_str' => (array) $vals]] ] ] ] ] ]; } } $aggs = []; foreach ($facets as $name) { $aggs[$name] = match ($name) { 'brand' => ['terms' => ['field' => 'brand', 'size' => 50]], 'category' => ['terms' => ['field' => 'category', 'size' => 50]], 'price' => ['histogram' => ['field' => 'price', 'interval' => 500]], default => null, }; } $aggs = array_filter($aggs); return [ 'from' => max(0, ($page - 1) * $perPage), 'size' => $perPage, 'sort' => [['title.raw' => 'asc']], 'query' => [ 'bool' => [ 'must' => $must, 'filter' => $filter, ] ], 'aggs' => $aggs, ]; } } Мы уже сделали общую консольную команду чтения Streams. Теперь добавим конкретный job, который собирает документ для индекса payloadJson, true, flags: \JSON_THROW_ON_ERROR); $id = (int) $payload['id']; // тянем свежие данные из витрин svc_catalog_* $row = \DB::table('svc_catalog_product') ->select([ 'id', 'sku', 'title', 'brand', 'category', 'price', 'price_type', 'in_stock', 'updated_at', ]) ->where('id', $id) ->first(); if ($row === null) { $index->delete($id); return; } $attrs = \DB::table('svc_catalog_attrs') ->where('product_id', $id) ->get(['code', 'value_str', 'value_num']) ->map(fn($a) => [ 'code' => (string) $a->code, 'value_str' => $a->value_str ? (string) $a->value_str : null, 'value_num' => $a->value_num ? (float) $a->value_num : null, ])->all(); $wh = \DB::table('svc_inventory') ->where('product_id', $id) ->get(['warehouse_id as id', 'qty']) ->map(fn($w) => ['id' => (string) $w->id, 'qty' => (float) $w->qty]) ->all(); $doc = [ 'id' => (string) $row->id, 'sku' => (string) $row->sku, 'title' => (string) $row->title, 'brand' => (string) $row->brand, 'category' => (string) $row->category, 'price' => (float) $row->price, 'price_type' => (string) $row->price_type, 'in_stock' => (bool) $row->in_stock, 'attrs' => \array_values($attrs), 'warehouses' => \array_values($wh), 'updated_at' => (string) $row->updated_at, ]; $index->upsert($doc); // инвалидация кэшей карточки и листингов \Cache::tags(['product', 'product:'.$id])->flush(); } } Контроллер делает две вещи: валидирует вход, строит DSL и кеширует ответ на короткое время. В проде добавьте ETag/If-None-Match и лимиты validate([ 'q' => ['nullable', 'string', 'max:128'], 'brand' => ['array'], 'brand.*' => ['string', 'max:64'], 'category' => ['array'], 'category.*' => ['string', 'max:64'], 'price_min' => ['nullable', 'numeric', 'min:0'], 'price_max' => ['nullable', 'numeric', 'min:0'], 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:60'], 'attrs' => ['array'], ]); $page = (int) ($data['page'] ?? 1); $perPage = (int) ($data['per_page'] ?? 24); $q = (string) ($data['q'] ?? ''); $filters = \Arr::only($data, ['brand', 'category', 'price_min', 'price_max', 'price_type', 'attrs']); $facets = ['brand', 'category', 'price']; $cacheKey = 'search:' . \md5(\json_encode([$q, $filters, $page, $perPage])); $result = Cache::remember($cacheKey, 10, function () use ($index, $q, $filters, $facets, $page, $perPage) { $dsl = $index->dsl($q, $filters, $facets, $page, $perPage); return $index->search($dsl); }); return response()->json([ 'ok' => true, 'hits' => $result['hits'] ?? [], 'total' => $result['total']['value'] ?? 0, 'page' => $page, 'per' => $perPage, 'took' => $result['took'] ?? null, 'aggs' => $result['aggregations'] ?? new \stdClass(), ]); } public function show(int $id): JsonResponse { $data = Cache::remember("product:$id", 30, function () use ($id) { $card = \DB::table('svc_catalog_product')->where('id', $id)->first(); if (! $card) { return null; } $attrs = \DB::table('svc_catalog_attrs')->where('product_id', $id)->get(); $wh = \DB::table('svc_inventory')->where('product_id', $id)->get(); return [ 'product' => $card, 'attrs' => $attrs, 'stock' => $wh, ]; }); if ($data === null) { return response()->json(['ok' => false, 'error' => 'not_found'], 404); } return response()->json(['ok' => true, 'data' => $data]); } } Маршруты: group(function () { Route::get('/catalog/search', [CatalogController::class, 'search']); Route::get('/products/{id}', [CatalogController::class, 'show'])->whereNumber('id'); }); У нас уже есть команда, которая читает Redis Streams и распределяет события. Теперь добавляем к ней конкретную задачу — обновить витрину товара и отправить его в индекс. Как это работает: Из события берём id товара.Тянем свежие данные из витрины svc_catalog_product .Если товара больше нет — удаляем из индекса. Иначе собираем все нужные куски: свойства, остатки, мета. Собираем документ — и в upsert .Не забываем: сбрасываем кэш карточки и листинга, чтобы все обновилось. Получается, одно событие — один индексированный документ. Пришло — обработали, обновили, отдали. Если что-то пошло не так, Laravel сам повторит попытку. Скрытый текст payloadJson, true, flags: \JSON_THROW_ON_ERROR); $id = (int) $payload['id']; // 1) тянем свежие данные из витрин svc_catalog_* $row = \DB::table('svc_catalog_product') ->select([ 'id', 'sku', 'title', 'brand', 'category', 'price', 'price_type', 'in_stock', 'updated_at', ]) ->where('id', $id) ->first(); if ($row === null) { $index->delete($id); return; } $attrs = \DB::table('svc_catalog_attrs') ->where('product_id', $id) ->get(['code', 'value_str', 'value_num']) ->map(fn($a) => [ 'code' => (string) $a->code, 'value_str' => $a->value_str ? (string) $a->value_str : null, 'value_num' => $a->value_num ? (float) $a->value_num : null, ])->all(); $wh = \DB::table('svc_inventory') ->where('product_id', $id) ->get(['warehouse_id as id', 'qty']) ->map(fn($w) => ['id' => (string) $w->id, 'qty' => (float) $w->qty]) ->all(); $doc = [ 'id' => (string) $row->id, 'sku' => (string) $row->sku, 'title' => (string) $row->title, 'brand' => (string) $row->brand, 'category' => (string) $row->category, 'price' => (float) $row->price, 'price_type' => (string) $row->price_type, 'in_stock' => (bool) $row->in_stock, 'attrs' => \array_values($attrs), 'warehouses' => \array_values($wh), 'updated_at' => (string) $row->updated_at, ]; $index->upsert($doc); // инвалидация кэшей карточки и листингов \Cache::tags(['product', 'product:'.$id])->flush(); } } Публичные страницы больше не вызывают тяжелые компоненты catalog.section . Вместо этого запрос в Laravel API через легкий обертку-клиент. Если вы используете свои шаблоны, достаточно изменить источник данных. Если фронт выделен, он с самого начала работает через API. Для админки все осталось по-прежнему. Кнопка «Сохранить» работает моментально, потому что индексация и сборка карточек идут в фоне, ошибки не блокируют интерфейс, а отказ OpenSearch не приводит к падению страницы, потому что будет безопасный ответ из Redis. Пример безопасного клиента в Битрикс с короткими таймаутами и корреляцией: Скрытый текст 2, 'streamTimeout' => 2, 'waitResponse' => true, 'redirect' => true, 'retries' => 1, ]); $client->setHeader('Content-Type', 'application/json'); $client->setHeader('X-Request-Id', DiagHelper::getRequestId()); $q = ['q' => (string) $_GET['q'], 'page' => (int) ($_GET['p'] ?? 1)]; $client->query('GET', 'https://api.example.ru/api/v1/catalog/search?' . http_build_query($q)); $list = Json::decode($client->getResult() ?: '{"ok":false}'); Fallback и деградация OpenSearch может притормозить или временно лечь — это нормально. Мы к этому готовы. Laravel не паникует, а спокойно отдает кешированные подборки из Redis: популярное, похожее, хиты продаж. Пользователь ничего не замечает, страница не разваливается. Карточки товаров вообще не зависят от поиска: собираются напрямую из svc_catalog_* . А OpenSearch остается для «вкусного» — подсказок, похожих товаров, умных фильтров. Реплей и миграции Если вдруг схема изменилась и решили денормализовать по-другому, это не повод для аврала. Outbox, настроенный ещё на старте, позволяет все переиграть. На проде это удобно запускать батчами командой php artisan catalog:reindex --since= .... Так мы снимаем основной тормоз каталога: Битрикс продолжает владеть записью и админкой, а публичное чтение уезжает в быстрый слой Laravel+OpenSearch. Админка та же, только быстрее Когда контентщики жалуются, что «Битрикс тормозит», чаще всего речь не о сохранении. Сама кнопка «Сохранить» работает нормально. Болит другое — открытие карточки. Пока подтянутся справочники, пока загрузятся свойства, пока все это отрендерится — человек уже успел выпить чаю. Но мы не ломаем интерфейс, не переучиваем команду. Вместо этого — встраиваем тонкую прослойку, которая делает все, как раньше, только быстрее: Лениво подгружаем вкладки — данные подтягиваются только при первом клике. Справочники и автокомплиты — переводим на быстрые REST-эндпоинты в Laravel, без лишней нагрузки. Метаданные и списки — кешируем на несколько минут, чтобы не гонять одно и то же. Внешне — ничего не поменялось. Контентщик работает в привычной форме. Но «холодный старт» карточки перестает тянуться вечность, а сохранение больше не зависит от ответа внешнего сервиса. Архитектурно это работает так. В Битриксе мы помечаем «тяжелые» блоки формы — те, что обычно тормозят — плейсхолдерами. Вместо того, чтобы грузить их сразу, подключаем легкий JS, который подгружает содержимое после клика по нужной вкладке. Без запроса нет нагрузки. Справочники, бренды, города и другие большие списки не загружаем «оптом». Вместо этого используем автокомплиты, которые ходят в быстрый REST на Laravel. Таймауты короткие, а нагрузка минимальная. Метаданные полей, структуры HL-блоков, подсказки — все это кешируем: в Managed Cache на стороне Битрикса и в Redis на стороне Laravel. Поэтому даже повторные заходы в формы становятся легче. Наблюдаемость сохраняем: P95 по открытию формы и по «Сохранить» меряется и алертится. Если вдруг где-то что-то поплыло, мы это видим сразу, не дожидаясь гневных сообщений от коллег. Битрикс: легкая вставка в админку и ленивые вкладки Подключаем модульный JS только в админке, не трогая публичку. В нем перехватываем клики по вкладкам и подкачиваем контент. Инициализация модуля и ассетов: Скрытый текст Инициализация модуля и ассетов: addJs('/local/modules/project.adminaccelerator/assets/admin-boost.js'); Asset::getInstance()->addCss('/local/modules/project.adminaccelerator/assets/admin-boost.css'); } }); Контроллер для частичной подгрузки вкладок: [ '+prefilters' => [ new ActionFilter\HttpMethod(['GET']), new ActionFilter\Authentication(), ], ], ]; } public function loadAction(int $elementId, string $tabCode): array { global $USER; if (! $USER->IsAdmin() && ! $USER->CanDoOperation('edit_php')) { $this->addError(new \Bitrix\Main\Error('Access denied')); return ['html' => '']; } // Здесь рендерится то, что раньше грузилось синхронно: // например, длинный список связанных сущностей, логи изменений, историю заказов и т.п. ob_start(); include __DIR__ . '/../views/tabs/' . basename($tabCode) . '.php'; $html = (string) ob_get_clean(); return ['html' => $html]; } } Для загрузки «тяжелых» вкладок мы используем Bitrix\Main\Engine\Controller . Он позволяет отдать HTML-фрагмент по запросу — как раз то, что нужно для ленивой загрузки из JS. Контроллер обязательно проверяет права доступа, чтобы не отдавать лишнего. Регистрируем его стандартно: action=project:adminaccelerator.AdminTab.load . А дальше вызываем из JS, когда пользователь кликает по нужной вкладке. В результате — та же форма, но открывается она быстро, потому что не тащит за собой все сразу. Скрытый текст JS: ленивая подгрузка вкладок и автокомплит: // /local/modules/project.adminaccelerator/assets/admin-boost.js (function () { function onReady(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); } function q(sel, root){ return (root||document).querySelector(sel); } function qa(sel, root){ return Array.from((root||document).querySelectorAll(sel)); } onReady(function () { // Ленивая подгрузка вкладок qa('.adm-detail-tabs-block .adm-detail-tab').forEach(function (tab) { tab.addEventListener('click', function () { var code = tab.getAttribute('data-tab-code'); var target = q('.adm-detail-content-wrap[data-tab-code="' + code + '"]'); if (target && !target.getAttribute('data-loaded')) { target.setAttribute('data-loaded', '1'); target.innerHTML = '
Загружаем…
'; var params = new URLSearchParams({ action: 'project:adminaccelerator.AdminTab.load', elementId: (q('input[name=ID]') || { value: 0 }).value || 0, tabCode: code }); fetch('/bitrix/services/main/ajax.php?' + params, { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (data) { target.innerHTML = data.html || ''; }) .catch(function () { target.innerHTML = '
Ошибка загрузки
'; }); } }, { once: true }); }); // Автокомплит для HL-полей qa('[data-accel="hl-suggest"]').forEach(function (input) { var hlCode = input.getAttribute('data-hl'); var dd = document.createElement('div'); dd.className = 'accel-suggest'; input.parentNode.appendChild(dd); var timer = null; input.addEventListener('input', function () { clearTimeout(timer); var q = input.value.trim(); if (q.length < 2) { dd.innerHTML = ''; return; } timer = setTimeout(function () { var url = '/api/admin/meta/hl/' + encodeURIComponent(hlCode) + '/suggest?q=' + encodeURIComponent(q); fetch(url, { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (res) { dd.innerHTML = (res.items || []).map(function (it) { return '
' + it.text + '
'; }).join(''); }); }, 180); }); dd.addEventListener('click', function (e) { var item = e.target.closest('.accel-suggest__item'); if (!item) return; input.value = item.textContent; var hidden = input.parentNode.querySelector('input[type=hidden]'); if (hidden) hidden.value = item.getAttribute('data-id'); dd.innerHTML = ''; }); }); }); })(); CSS на минимум, чтобы не мешались подсказки: /* /local/modules/project.adminaccelerator/assets/admin-boost.css */ .accel-suggest { position: relative; background:#fff; border:1px solid #c9d3dc; max-height:240px; overflow:auto; } .accel-suggest__item { padding:6px 8px; cursor:pointer; } .accel-suggest__item:hover { background:#eef2f7; } Laravel: быстрые эндпоинты для админки и кеш метаданных Когда админка тормозит, часто виноваты не «медленные сервера», а десятки тысяч строк, которые она зачем-то тянет в каждую форму. Справочники, свойства, подсказки — все грузится сразу и синхронно. Мы пошли проще: сделали легкие REST-эндпоинты, которые отдают только нужное. Один — подсказывает значения из HL-справочников. Второй — отдает метаданные свойств. Оба защищены SSO и ролью admin , работают быстро и кешируются. Как устроено Подсказки из HL-справочников: таблицы svc_hl_* собираются асинхронно по событиям;при запросе мы читаем только первые 20 совпадений; кешируем в Redis на минуту — для устойчивости и скорости. Метаданные свойств: отдаются по iblockId ;читаются из svc_iblock_props ;кешируются на 5 минут. На Laravel ничего сложного: обычный контроллер с авторизацией и простым SQL. В Битриксе добавляем Managed Cache, чтобы даже эти быстрые REST-запросы не слать лишний раз. Таймауты жесткие: 180–300 мс для подсказок, 1 секунда — потолок. При ошибке UI не разваливается, ведь возвращаемся к стандартному поведению Битрикс. Откат делаем через фича-флаг: отключаем ассеты модуля, и все возвращается как было. В итоге контентщикам не нужно ничего переучивать: формы остаются прежними, вкладки на месте. Но теперь вместо того, чтобы грузить все сразу, они загружаются по запросу. Результат — тот же, но «холодный старт» стал внятным, а «Сохранить» не ждет справочник с городами. Хотите выкатывать по шагам? Этот подход дружит с фича-флагами и спокойно живет рядом со старой схемой. А потом как пойдет. Без подвисаний на «Сохранить» Если в админке и есть что-то, что по-настоящему раздражает, то это работа с изображениями. Загрузил баннер, выбрал пресеты — и вот ты уже не сохраняешь, а ждешь, пока PHP крутит ресайзы, сжимает JPEG и расставляет водяные знаки. А на фоне таймаут, дубль запроса и битые данные. Мы выносим это из критического пути: Laravel берет на себя ресайзы, конвертацию, и отдает все через CDN. Bitrix не страдает, все работает как раньше, только быстро. Поток загрузки: Форма в админке загружает файл на POST /api/media/upload .Laravel сохраняет оригинал в S3, создает запись media_assets и ставит задачи в очередь.Воркеры делают пресеты (WebP, AVIF, JPEG), пишут манифест. Bitrix сразу получает media_id , использует CDN-ссылки на/media/{id}/{preset} .Если пресет ещё не готов — Laravel отдает заглушку. В Laravel используем Filesystem с диском media (S3-совместимый). Скрытый текст Контроллер метаданных и подсказок: authorizeAdmin($request); $q = (string) $request->query('q', ''); if ($q === '' || \mb_strlen($q) < 2) { return response()->json(['items' => []]); } $cacheKey = 'hl:suggest:' . $code . ':' . \mb_strtolower($q); $items = Cache::remember($cacheKey, 60, function () use ($code, $q) { // Витрина для HL-справочника в нашей БД (svc_hl_{code}) $table = 'svc_hl_' . Str::snake($code); return DB::table($table) ->select(['id', 'name']) ->where('name', 'like', $q . '%') ->orderBy('name') ->limit(20) ->get() ->map(fn ($r) => ['id' => (int) $r->id, 'text' => (string) $r->name]) ->all(); }); return response()->json(['items' => $items]); } public function properties(Request $request): JsonResponse { $this->authorizeAdmin($request); $iblockId = (int) $request->query('iblockId'); $cacheKey = 'admin:props:' . $iblockId; $props = Cache::remember($cacheKey, 300, function () use ($iblockId) { // Читаем из нашей витрины описаний свойств, собранной асинхронно из Битрикс return DB::table('svc_iblock_props') ->where('iblock_id', $iblockId) ->orderBy('sort') ->get(['code', 'name', 'type', 'is_heavy']) ->all(); }); return response()->json(['items' => $props]); } private function authorizeAdmin(Request $request): void { $user = $request->user(); if (! $user || ! $user->hasRole('admin')) { abort(403); } } } Маршруты: prefix('api/admin/meta')->group(function () { Route::get('/hl/{code}/suggest', [MetaController::class, 'hlSuggest']); Route::get('/properties', [MetaController::class, 'properties']); }); На стороне Битрикс держим Managed Cache для часто используемых метаданных: getManagedCache(); $key = 'accel:props:' . $iblockId; if ($cache->read(300, $key)) { /** @var array $props */ $props = $cache->get($key); return $props; } $http = new \Bitrix\Main\Web\HttpClient(['socketTimeout' => 1, 'streamTimeout' => 1]); $json = (string) $http->get('https://api.example.ru/api/admin/meta/properties?iblockId=' . $iblockId); $res = \Bitrix\Main\Web\Json::decode($json); $props = (array) ($res['items'] ?? []); $cache->set($key, $props); return $props; } Пресеты для файлопомойки описываем в конфиге: env('MEDIA_DISK', 'media'), 'presets' => [ 'thumb' => ['w' => 120, 'h' => 120, 'fit' => 'cover', 'format' => 'webp', 'q' => 82], 'card' => ['w' => 400, 'h' => 300, 'fit' => 'cover', 'format' => 'webp', 'q' => 82], 'cover' => ['w' => 1600, 'h' => 600, 'fit' => 'cover', 'format' => 'avif', 'q' => 50], 'orig' => ['format' => 'jpeg', 'q' => 85], // безопасный JPEG для публичной раздачи ], ]; .env: FILESYSTEM_DISK=media MEDIA_DISK=media AWS_ACCESS_KEY_ID=xxx AWS_SECRET_ACCESS_KEY=xxx AWS_DEFAULT_REGION=eu-central-1 AWS_BUCKET=project-media AWS_URL=https://cdn.example.ru # публичная раздача через CDN config/filesystems.php (фрагмент): 'disks' => [ 'media' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'options' => [ 'CacheControl' => 'public, max-age=31536000, immutable', ], ], ], Две таблицы: media_assets (оригинал и мета) и media_variants (пресеты и их состояние): id(); $t->string('uuid', 36)->unique(); $t->string('mime', 64); $t->unsignedInteger('size'); $t->unsignedInteger('width')->nullable(); $t->unsignedInteger('height')->nullable(); $t->string('path'); // s3 key оригинала $t->json('exif')->nullable(); $t->timestamps(); }); Schema::create('media_variants', function (Blueprint $t): void { $t->id(); $t->foreignId('media_id')->constrained('media_assets')->cascadeOnDelete(); $t->string('preset', 32); $t->string('mime', 64)->nullable(); $t->unsignedInteger('size')->nullable(); $t->unsignedInteger('width')->nullable(); $t->unsignedInteger('height')->nullable(); $t->string('path')->nullable(); // s3 key пресета $t->string('status', 16)->default('pending'); // pending|ready|failed $t->text('error')->nullable(); $t->timestamps(); $t->unique(['media_id', 'preset']); }); } public function down(): void { Schema::dropIfExists('media_variants'); Schema::dropIfExists('media_assets'); } }; Контроллер загрузки: validate([ 'file' => ['required', 'file', 'max:12288', 'mimetypes:image/jpeg,image/png,image/webp,image/avif'], ]); $file = $data['file']; $uuid = (string) Str::uuid(); // безопасное имя и ключ $key = 'orig/' . $uuid . '/' . preg_replace('~[^a-z0-9\.\-_]~i', '_', $file->getClientOriginalName()); // пишем оригинал в S3 Storage::disk(config('media.disk'))->put($key, file_get_contents($file->getRealPath()), [ 'visibility' => 'public', 'ContentType' => $file->getMimeType(), 'CacheControl' => 'public, max-age=31536000, immutable', ]); // можно здесь же нормализовать EXIF-ориентацию и перезаписать оригинал при необходимости $imageSize = @getimagesize($file->getRealPath()); $width = $imageSize[0] ?? null; $height = $imageSize[1] ?? null; $mediaId = DB::transaction(function () use ($uuid, $file, $key, $width, $height) { $id = DB::table('media_assets')->insertGetId([ 'uuid' => $uuid, 'mime' => $file->getMimeType(), 'size' => $file->getSize(), 'width' => $width, 'height' => $height, 'path' => $key, 'exif' => null, 'created_at' => now(), 'updated_at' => now(), ]); foreach (array_keys(config('media.presets')) as $preset) { DB::table('media_variants')->insert([ 'media_id' => $id, 'preset' => $preset, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now(), ]); } return $id; }); // запускаем асинхронную генерацию GeneratePresets::dispatch($mediaId); return response()->json([ 'ok' => true, 'media_id' => $mediaId, 'uuid' => $uuid, 'manifest' => [ 'orig' => $this->publicUrl($key), 'ready' => false, ], ], 201); } private function publicUrl(string $key): string { return Storage::disk(config('media.disk'))->url($key); } } Маршруты: group(function (): void { Route::post('/api/media/upload', [MediaController::class, 'upload']); }); Из преимуществ такого подхода: контентщик не ждет ресайза; превью — по готовым CDN-ссылкам; сервис не ломает админку — все совместимо; фронт работает с готовыми форматами; очередь — фоновая, воркеры масштабируются. Это быстрая победа: UX остается прежним, но сохраняется за доли секунды, без подвисаний и гонки потоков. Если что-то пошло не так,.всегда можно откатить. Но в большинстве случаев оно просто начинает работать. Когда контроллер загрузки не мешает жить Когда Битрикс отправляет файл на POST /api/media/upload, Laravel: проверяет тип и размер — без сюрпризов; сохраняет оригинал в S3 с безопасным именем; пишет мета: размеры, MIME, UUID; создает будущие пресеты в статусе pending ;запускает задачу GeneratePresets в очередь. На выходе — media_id, UUID и ссылка на оригинал. Даже повторная загрузка отработает аккуратно — процесс идемпотентен. Фоновая очередь ресайзов Каждая задача берет оригинал, проверяет, не готов ли уже нужный пресет, и если надо: делает ресайз в нужном формате (cover или contain); сжимает в AVIF, WebP или JPEG; кладет в S3 с CDN-ключами; обновляет media_variants : размеры, статус, путь. Ошибки логируются, но не мешают другим пресетам. Удалить оригинал или попробовать снова можно когда угодно. Скрытый текст where('id', $this->mediaId)->first(); if (! $media) { return; } $origKey = $media->path; $origTmp = tempnam(sys_get_temp_dir(), 'orig_'); file_put_contents($origTmp, $disk->get($origKey)); foreach (config('media.presets') as $preset => $cfg) { $row = DB::table('media_variants') ->where('media_id', $this->mediaId) ->where('preset', $preset) ->first(); if ($row && $row->status === 'ready') { continue; } try { $img = new Imagick($origTmp); $img->setImageColorspace(Imagick::COLORSPACE_RGB); $img->setImageBackgroundColor('white'); // фон под непрозрачный JPEG $img = $this->resize($img, $cfg); $format = $cfg['format'] ?? 'webp'; $q = (int) ($cfg['q'] ?? 82); if ($format === 'jpeg') { $img->setImageFormat('jpeg'); $img->setImageCompressionQuality($q); $img->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); } elseif ($format === 'webp') { $img->setImageFormat('webp'); $img->setImageCompressionQuality($q); } elseif ($format === 'avif') { $img->setImageFormat('avif'); // для AVIF Imagick использует libheif, качество может отличаться $img->setOption('heic:quality', (string) $q); } $key = 'variants/' . $media->uuid . '/' . $preset . '.' . $format; $disk->put($key, (string) $img, [ 'visibility' => 'public', 'ContentType' => $this->mimeByExt($format), 'CacheControl' => 'public, max-age=31536000, immutable', ]); DB::table('media_variants') ->where('media_id', $this->mediaId) ->where('preset', $preset) ->update([ 'mime' => $this->mimeByExt($format), 'size' => $disk->size($key), 'width' => $img->getImageWidth(), 'height' => $img->getImageHeight(), 'path' => $key, 'status' => 'ready', 'updated_at' => now(), ]); } catch (\Throwable $e) { DB::table('media_variants') ->where('media_id', $this->mediaId) ->where('preset', $preset) ->update([ 'status' => 'failed', 'error' => $e->getMessage(), 'updated_at' => now(), ]); } } @unlink($origTmp); } private function resize(Imagick $img, array $cfg): Imagick { $w = $cfg['w'] ?? null; $h = $cfg['h'] ?? null; $fit = $cfg['fit'] ?? 'contain'; // contain|cover if ($w && $h) { if ($fit === 'cover') { $img->cropThumbnailImage($w, $h); } else { $img->thumbnailImage($w, $h, true); } } elseif ($w) { $img->thumbnailImage($w, 0); } elseif ($h) { $img->thumbnailImage(0, $h); } return $img; } private function mimeByExt(string $ext): string { return match ($ext) { 'jpeg', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'avif' => 'image/avif', 'png' => 'image/png', default => 'application/octet-stream', }; } } Эндпоинт, который всегда возвращает корректный URL: where('media_id', $id)->where('preset', $preset)->first(); $asset = DB::table('media_assets')->where('id', $id)->first(); $disk = Storage::disk(config('media.disk')); if ($row && $row->status === 'ready' && $row->path) { return redirect()->away($disk->url($row->path), 302); } // fallback - оригинал или статика-заглушка return redirect()->away($disk->url($asset->path ?? 'static/placeholder.png'), 302); } public function manifest(int $id): JsonResponse { $asset = DB::table('media_assets')->find($id); if (! $asset) { return response()->json(['ok' => false], 404); } $variants = DB::table('media_variants')->where('media_id', $id)->get()->map(function ($v) { return [ 'preset' => $v->preset, 'status' => $v->status, 'url' => $v->path ? Storage::disk(config('media.disk'))->url($v->path) : null, ]; })->all(); return response()->json([ 'ok' => true, 'id' => $id, 'orig' => Storage::disk(config('media.disk'))->url($asset->path), 'items' => $variants, ]); } } Маршруты публичной раздачи: whereNumber('id'); Route::get('/api/media/{id}/manifest', [MediaPublicController::class, 'manifest'])->whereNumber('id'); В форме вместо загрузки "в файл" отправляем запрос на POST /api/media/upload и сохраняем в инфоблоке не бинарь, а media_id и нужные пресеты. Шаблоны фронта показывают картинки через /media/{id}/{preset} - это даёт кэшируемые, долговечные URL. 3, 'streamTimeout' => 3]); $http->setHeader('Content-Type', 'application/octet-stream'); $http->setHeader('X-Filename', $file['name']); $http->post('https://api.example.ru/api/media/upload', file_get_contents($file['tmp_name'])); $res = Json::decode((string)$http->getResult()); return (int)($res['media_id'] ?? 0) ?: null; } Далее поле типа "строка" или "число" хранит media_id. На стороне шаблона: Зачем вообще это все: ресайзы не должны мешать жить В стандартной схеме все происходит синхронно: контентщик жмет «Сохранить», и PHP тут же начинает обрабатывать картинки — жмет, ресайзит, кладет в папки, добавляет водяные знаки. В этот момент сервер грустит, а форма превращается в таймер. Мы это меняем. Разделяем «принять файл» и «обработать файл». Laravel забирает оригинал, кладет в S3 и говорит: «Принял, обрабатываю». А дальше очередь делает своё. Раздача пресетов: если готов — отдали, если нет — заменили Фронту все равно, есть ли готовый пресет. Он просто просит/media/123/card — и всегда что-то получает: если пресет уже готов, это будет оптимальный формат с CDN; если нет — отдаем оригинал или заглушку; в любом случае — пользователь не видит пустоты. Такой подход работает даже при нагрузке или деградации — не нужен фолбэк в шаблонах, все работает из коробки. Как понять, что с изображением По адресу /api/media/{id}/manifest можно получить полную картину: какой пресет есть; в каком он статусе (готов, pending, failed); по каким URL их можно забрать. Это можно использовать в UI, чтобы перерисовывать превью, или просто для мониторинга. Почему UUID UUID создается один раз при загрузке. Он попадает в путь к оригиналу и к каждому варианту. Если картинку перезаливают — путь меняется. Это важно: CDN кэширует только нужное; старые URL больше не актуальны; нет «призраков» старых изображений в кэше. А если вдруг что-то пошло не так, ошибки не ломают процесс. Пресет может не получиться — но остальные продолжат. Ошибки записываются в базу, попадают в Sentry, можно триггерить повторную генерацию. А если изображение конфиденциальное — используем temporaryUrl() с подписью и временем жизни. Доступ строго по ссылке. И самое важное: админка не ждет. С точки зрения пользователя ничего не поменялось — форма та же. Но теперь кнопка «Сохранить» отрабатывает за секунду. Админка живет, фронт показывает, сервер не перегружается — все на своих местах. Хочу сразу предупредить, мы не воюем с Битриксом, не лепим рядом «нормальный движок» и не спорим, как надо было с самого начала. Мы работаем с тем, что есть — с десятками тысяч товаров, с редакторами, которым важно «чтобы не лагало», и с задачами, где нельзя подождать. Битрикс остается хозяином админки. Laravel берет на себя все, что должно быть быстрым: витрины, фильтры, справочники, медиа. Все это уживается в одной продовой экосистеме, без костылей и с уважением к зоне ответственности. Продолжение следует...

Фильтры и сортировка