Кофе && Код

Утренние советы, best practices и продвинутые техники за чашкой кофе.

Управление конфигурацией приложения через Configuration

Управление конфигурацией приложения через Configuration

Проблема

Разработчики часто хранят конфигурационные параметры приложения в собственных файлах или таблицах БД. При этом в ядре 1С-Битрикс существует готовый механизм Bitrix\Main\Config\Configuration, который предоставляет единообразный интерфейс для работы с настройками через файлы .settings.php.

Решение

Класс Configuration работает с файлом .settings.php в директории /bitrix/ и поддерживает дополнительный файл .settings_extra.php для переопределения значений. Это позволяет разделять конфигурацию для разных окружений.

Чтение настроек

use Bitrix\Main\Config\Configuration;

// Получение значения через статический метод
$cacheSettings = Configuration::getValue('cache_flags');

// Получение через экземпляр с доступом как к массиву
$config = Configuration::getInstance();
$connections = $config['connections'];
$defaultConnection = $connections['value']['default']['host'] ?? null;

Запись настроек

use Bitrix\Main\Config\Configuration;

// Простая установка значения
Configuration::setValue('my_app_settings', [
    'api_endpoint' => 'https://api.example.com',
    'timeout' => 30,
    'debug' => false,
]);

// Через экземпляр с контролем readonly
$config = Configuration::getInstance();
$config->add('feature_flags', [
    'new_catalog' => true,
    'beta_checkout' => false,
]);
$config->saveConfiguration();

Настройки модулей

Каждый модуль может иметь собственный файл .settings.php. Доступ к ним осуществляется через передачу ID модуля:

use Bitrix\Main\Config\Configuration;

// Чтение конфигурации модуля iblock
$iblockConfig = Configuration::getInstance('iblock');
$apiSettings = $iblockConfig->get('api');

// Итерация по всем настройкам модуля
$moduleConfig = Configuration::getInstance('sale');
foreach ($moduleConfig as $key => $value) {
    // Обработка каждого параметра
}

Защита от перезаписи

Параметры можно защитить флагом readonly:

// В файле .settings.php
return [
    'license' => [
        'value' => 'XXXX-XXXX-XXXX',
        'readonly' => true, // Нельзя изменить через Configuration::setValue
    ],
    'debug' => [
        'value' => true,
        'readonly' => false,
    ],
];

Разделение окружений

Файл .settings_extra.php автоматически мержится с основным. Это удобно для переопределения настроек на dev/prod:

// .settings_extra.php (не коммитится в репозиторий)
return [
    'connections' => [
        'value' => [
            'default' => [
                'host' => 'localhost',
                'database' => 'dev_db',
            ],
        ],
    ],
];

Итог

Класс Configuration предоставляет типизированный доступ к настройкам приложения с поддержкой защиты от изменений и разделения окружений. Используйте его вместо собственных решений для централизованного управления конфигурацией.

Настройка OpenSearch для полнотекстового поиска в Битрикс

Настройка OpenSearch для полнотекстового поиска в Битрикс

Стандартный поиск Битрикс использует таблицы MySQL для хранения поискового индекса. На проектах с большим объёмом контента это приводит к медленным запросам и высокой нагрузке на базу данных. Начиная с версии 25.0 модуля поиска, Битрикс поддерживает OpenSearch (форк Elasticsearch) — полнотекстовый поисковый движок с морфологией и горизонтальным масштабированием.

Настройка через административный раздел

Подключение OpenSearch выполняется в настройках модуля «Поиск» без написания кода:

  1. Перейдите в раздел Настройки → Настройки продукта → Настройки модулей → Поиск
  2. Откройте вкладку Морфология
  3. В поле Полнотекстовый поиск с помощью выберите OpenSearch
  4. Заполните параметры подключения:
    • Сервер OpenSearch — адрес в формате https://host:9200
    • Пользователь — имя пользователя для авторизации
    • Пароль — пароль пользователя
    • Название индекса — произвольное имя (латиница, цифры, дефис, подчёркивание)
  5. Для каждого сайта выберите Анализатор поисковых запросов (russian, english и др.)
  6. Сохраните настройки

После сохранения система проверит подключение. При успешном соединении появится уведомление о необходимости переиндексации.

Переиндексация контента

После смены поискового движка выполните полную переиндексацию:

  1. Перейдите в Настройки → Поиск → Переиндексация
  2. Установите флаг Очистить индекс
  3. Нажмите Начать и дождитесь завершения

Все существующие данные будут перенесены в OpenSearch. Новый контент индексируется автоматически.

Как это работает

После настройки класс CSearchFullText::getInstance() автоматически возвращает экземпляр CSearchOpenSearch вместо стандартного движка. Все компоненты поиска (bitrix:search.page, bitrix:search.title) начинают работать через OpenSearch без изменения кода.

Система создаёт отдельный индекс для каждого сайта с суффиксом: {index_name}-{site_id}. Шаблоны индексов обновляются автоматически при изменении версии модуля.

Поддерживаемые языковые анализаторы

OpenSearch поддерживает морфологический анализ для 30 языков. Основные:

Анализатор Язык
russian Русский
english Английский
german Немецкий
french Французский
spanish Испанский

Преимущества OpenSearch

  • Быстрый полнотекстовый поиск на больших объёмах данных
  • Встроенная морфология для множества языков
  • Горизонтальное масштабирование через кластеризацию
  • Снижение нагрузки на основную базу данных
  • Релевантное ранжирование результатов

Переход на OpenSearch рекомендуется для проектов с количеством индексируемых документов свыше 50 000 или при высокой частоте поисковых запросов.

Работа с изображениями через Bitrix\Main\File\Image

Работа с изображениями через Bitrix\Main\File\Image

Разработчики часто используют CFile::ResizeImageGet() для изменения размеров изображений, не подозревая, что эта функция является обёрткой над современным D7 API. Классы Bitrix\Main\File\Image предоставляют прямой доступ к операциям с изображениями, что даёт больше контроля и гибкости.

Основные операции с Image

use Bitrix\Main\File\Image;
use Bitrix\Main\File\Image\Rectangle;
use Bitrix\Main\File\Image\Mask;

$image = new Image('/path/to/image.jpg');
$image->load();

// Получение информации о изображении
$info = $image->getInfo();
echo $info->getWidth() . 'x' . $info->getHeight(); // размеры
echo $info->getMime(); // MIME-тип
echo $info->getFormat(); // Image::FORMAT_JPEG, FORMAT_PNG, etc.

// Изменение размера (пропорционально)
$source = $image->getDimensions();
$destination = new Rectangle(800, 600);
$source->resize($destination, Image::RESIZE_PROPORTIONAL);
$image->resize($source, $destination);

// Сохранение с качеством 85%
$image->save(85);

Аналог unsharpmask из CFile::ResizeImageGet

// CFile::ResizeImageGet по умолчанию применяет sharpen с precision=15
$mask = Mask::createSharpen(15);
$image->filter($mask);

Режимы изменения размера

// RESIZE_PROPORTIONAL - сохраняет пропорции, вписывает в указанный прямоугольник
$source->resize($destination, Image::RESIZE_PROPORTIONAL);

// RESIZE_EXACT - кадрирует изображение по центру до точных размеров
$source->resize($destination, Image::RESIZE_EXACT);

// RESIZE_PROPORTIONAL_ALT - учитывает ориентацию (портрет/ландшафт)
$source->resize($destination, Image::RESIZE_PROPORTIONAL_ALT);

Расширенные возможности

// Поворот и отражение
$image->rotate(90); // поворот на 90°
$image->flipHorizontal(); // зеркальное отражение
$image->autoRotate($exifOrientation); // автокоррекция по EXIF

// Размытие
$image->blur(10); // sigma от 1 до 100

// Водяной знак (изображение)
use Bitrix\Main\File\Image\ImageWatermark;

$watermark = new ImageWatermark('/path/to/watermark.png');
$watermark->setAlignment('right', 'bottom')
    ->setPadding(20)
    ->setAlpha(0.7);
$image->drawWatermark($watermark);

// Сохранение в другой формат
$image->saveAs('/path/to/output.webp', 85, Image::FORMAT_WEBP);

Выбор движка: GD или Imagick

// По умолчанию используется GD
// Для Imagick зарегистрируйте сервис:
use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\File\Image\Imagick;

$serviceLocator = ServiceLocator::getInstance();
$serviceLocator->registerByCreator('main.imageEngine', fn() => new Imagick());

Классы Bitrix\Main\File\Image полностью покрывают функциональность CFile::ResizeImageGet(), включая proportional resize, exact crop, sharpen-фильтр и водяные знаки. Для типовых задач resizing проще использовать CFile::ResizeImageGet(), а для сложных сценариев с цепочкой операций — классы напрямую.

Параллельные HTTP-запросы через асинхронный API HttpClient

Параллельные HTTP-запросы через асинхронный API HttpClient

При интеграции с внешними сервисами часто требуется выполнить несколько HTTP-запросов. Последовательное выполнение приводит к суммированию времени ожидания каждого запроса. Если три API отвечают по 500мс, общее время составит 1.5 секунды. Класс Bitrix\Main\Web\HttpClient поддерживает асинхронное выполнение запросов через curl_multi, что позволяет выполнять их параллельно.

Для асинхронных запросов используется метод sendAsyncRequest(), который возвращает объект Promise. Promise реализует интерфейс Http\Promise\Promise и поддерживает цепочки обработчиков через метод then().

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Http\Request;
use Bitrix\Main\Web\Uri;

// Важно: для асинхронных запросов требуется CURL
$client = new HttpClient(['useCurl' => true]);

// Список URL для параллельных запросов
$urls = [
    'products' => 'https://api.example.com/products',
    'categories' => 'https://api.example.com/categories',
    'prices' => 'https://api.example.com/prices',
];

$promises = [];

foreach ($urls as $key => $url)
{
    // Создаём PSR-7 совместимый Request
    $request = new Request('GET', new Uri($url));
    
    // sendAsyncRequest() не блокирует выполнение
    $promises[$key] = $client->sendAsyncRequest($request);
}

// wait() блокирует до завершения всех запросов
$responses = $client->wait();

// Обрабатываем результаты
foreach ($promises as $key => $promise)
{
    try
    {
        // wait() на конкретном promise возвращает Response
        $response = $promise->wait();
        $data[$key] = json_decode((string)$response->getBody(), true);
    }
    catch (\Bitrix\Main\Web\Http\ClientException $e)
    {
        // Обработка ошибок сети
        $data[$key] = ['error' => $e->getMessage()];
    }
}

Promise поддерживает callback-функции для обработки успешных и неуспешных запросов:

$promise = $client->sendAsyncRequest($request);

// Регистрируем обработчики до вызова wait()
$promise->then(
    function ($response) {
        // Вызывается при успешном ответе
        // Можно модифицировать и вернуть response
        return $response;
    },
    function ($exception) {
        // Вызывается при ошибке
        // Логируем или обрабатываем исключение
        return $exception;
    }
);

// Запускаем выполнение
$client->wait();

Для POST-запросов с телом используйте Http\FormStream:

use Bitrix\Main\Web\Http\FormStream;

$body = new FormStream(['param1' => 'value1', 'param2' => 'value2']);
$request = new Request('POST', new Uri($url), ['Content-Type' => 'application/x-www-form-urlencoded'], $body);

$promise = $client->sendAsyncRequest($request);

Асинхронный API HttpClient использует curl_multi_exec() под капотом, что обеспечивает истинную параллельность на уровне сетевых операций. Три запроса по 500мс выполнятся примерно за 500мс вместо 1.5 секунд.

Работа с Highload-блоками по имени вместо ID

Работа с Highload-блоками по имени вместо ID

Продолжаем тему избавления кодовой базы от ID при работе с сущностями Битрикс.

Проблема

Типичный код работы с Highload-блоками содержит жёстко прописанные ID:

<?php
// Антипаттерн: ID зашит в код
$hlblock = HighloadBlockTable::getById(5)->fetch();
$entity = HighloadBlockTable::compileEntity($hlblock);

ID Highload-блока различается между окружениями: на dev-сервере это 5, на production — 12. При переносе кода приходится менять значения вручную или использовать конфигурационные файлы. Ситуация усугубляется, когда ID разбросаны по десяткам файлов проекта.

Решение

Метод HighloadBlockTable::resolveHighloadblock() принимает символьное имя HL-блока вместо числового ID. Имя задаётся при создании блока и остаётся неизменным при переносе между окружениями.

<?php
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;

Loader::includeModule('highloadblock');

// Правильно: используем символьное имя
$hlblock = HighloadBlockTable::resolveHighloadblock('Cities');

if ($hlblock !== null) {
    $entity = HighloadBlockTable::compileEntity($hlblock);
    $entityClass = $entity->getDataClass();
    
    $items = $entityClass::getList([
        'filter' => ['=UF_ACTIVE' => 1]
    ])->fetchAll();
}

Метод compileEntity также поддерживает имя

Символьное имя можно передавать напрямую в compileEntity() — метод внутри вызывает resolveHighloadblock():

<?php
// Компиляция entity по имени — без промежуточных вызовов
$entity = HighloadBlockTable::compileEntity('Cities');
$entityClass = $entity->getDataClass();

$cities = $entityClass::getList()->fetchAll();

Константы для имён HL-блоков

Для централизованного управления именами создайте класс с константами:

<?php
namespace App\Reference;

class HLBlock
{
    public const CITIES = 'Cities';
    public const REGIONS = 'Regions';
    public const COLORS = 'ProductColors';
    public const SIZES = 'ProductSizes';
}

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

<?php
use App\Reference\HLBlock;

$entity = HighloadBlockTable::compileEntity(HLBlock::CITIES);
$entityClass = $entity->getDataClass();

При таком подходе IDE подсказывает доступные HL-блоки, а опечатки выявляются на этапе статического анализа.

Встроенное кеширование

Метод resolveHighloadblock() кеширует результат запроса на 24 часа. Повторные вызовы с тем же именем не обращаются к базе данных:

<?php
// Первый вызов — запрос к БД, результат кешируется
$hlblock1 = HighloadBlockTable::resolveHighloadblock('Cities');

// Повторный вызов — данные из кеша ORM
$hlblock2 = HighloadBlockTable::resolveHighloadblock('Cities');

Валидация имени

Метод проверяет корректность символьного имени регулярным выражением /^[a-z0-9_]+$/i. При несуществующем или некорректном имени возвращается null:

<?php
$hlblock = HighloadBlockTable::resolveHighloadblock('NonExistent');
// $hlblock === null

Итог

Замените числовые ID на символьные имена HL-блоков через resolveHighloadblock(). Код станет переносимым между окружениями без ручных правок, а централизованные константы обеспечат контроль над используемыми справочниками.

Паттерн Repository в Битрикс через RepositoryInterface

Паттерн Repository в Битрикс через RepositoryInterface

Разработчики Битрикс часто работают напрямую с DataManager, размазывая логику доступа к данным по всему проекту. Это создаёт жёсткую связанность кода с ORM, затрудняет тестирование и нарушает принцип единой ответственности. В модуле main появились интерфейсы RepositoryInterface и SoftDeletableRepositoryInterface, которые позволяют строить чистую архитектуру.

Интерфейс Bitrix\Main\Repository\RepositoryInterface определяет три базовых метода:

interface RepositoryInterface
{
    public function getById(mixed $id): ?EntityInterface;
    public function save(EntityInterface $entity): void;
    public function delete(mixed $id): void;
}

Для реализации репозитория сущность должна имплементировать Bitrix\Main\Entity\EntityInterface:

use Bitrix\Main\Entity\EntityInterface;

class Order implements EntityInterface
{
    public function __construct(
        private ?int $id,
        private int $userId,
        private string $status,
        private float $amount
    ) {}

    public function getId(): mixed
    {
        return $this->id;
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    // Геттеры и сеттеры для остальных свойств...
}

Теперь создаём репозиторий с инкапсулированной логикой работы с БД:

use Bitrix\Main\Repository\RepositoryInterface;
use Bitrix\Main\Repository\SoftDeletableRepositoryInterface;
use Bitrix\Main\Repository\Exception\PersistenceException;
use Bitrix\Main\Entity\EntityInterface;

class OrderRepository implements RepositoryInterface, SoftDeletableRepositoryInterface
{
    public function getById(mixed $id): ?Order
    {
        $row = OrderTable::getById($id)->fetch();
        if (!$row) {
            return null;
        }

        return new Order($row['ID'], $row['USER_ID'], $row['STATUS'], $row['AMOUNT']);
    }

    public function save(EntityInterface $entity): void
    {
        $data = [
            'USER_ID' => $entity->getUserId(),
            'STATUS' => $entity->getStatus(),
            'AMOUNT' => $entity->getAmount(),
        ];

        if ($entity->getId()) {
            $result = OrderTable::update($entity->getId(), $data);
        } else {
            $result = OrderTable::add($data);
            if ($result->isSuccess()) {
                $entity->setId($result->getId());
            }
        }

        if (!$result->isSuccess()) {
            throw new PersistenceException('Ошибка сохранения', errors: $result->getErrors());
        }
    }

    public function delete(mixed $id): void
    {
        $result = OrderTable::delete($id);
        if (!$result->isSuccess()) {
            throw new PersistenceException('Ошибка удаления', errors: $result->getErrors());
        }
    }

    public function softDelete(mixed $id): void
    {
        OrderTable::update($id, ['DELETED' => 'Y']);
    }
}

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

class OrderService
{
    public function __construct(
        private RepositoryInterface $orderRepository
    ) {}

    public function createOrder(int $userId, float $amount): Order
    {
        $order = new Order(null, $userId, 'NEW', $amount);
        $this->orderRepository->save($order);

        return $order;
    }
}

Репозиторий изолирует бизнес-логику от деталей хранения данных. Код становится тестируемым — достаточно подставить mock-репозиторий. PersistenceException предоставляет унифицированную обработку ошибок через метод getErrors().

Избавляемся от ID инфоблока при работе с разделами

Избавляемся от ID инфоблока при работе с разделами

Проблема

В типичном проекте на 1С-Битрикс код работы с разделами напрямую взаимодействует с ID инфоблока. При каждом запросе к SectionTable или CIBlockSection приходится явно указывать фильтр по инфоблоку. Если нужно изменить ID инфоблока или перенести код на другой проект — начинается ад с поиском и заменой всех вхождений.

Решение

Класс Bitrix\Iblock\Model\Section предоставляет метод compileEntityByIblock(), который создаёт специализированный класс для работы с разделами конкретного инфоблока. Все запросы через этот класс автоматически фильтруются по нужному IBLOCK_ID без явного указания.

Было: магические числа везде

use Bitrix\Iblock\SectionTable;

// Приходится каждый раз указывать IBLOCK_ID
$sections = SectionTable::getList([
    'select' => ['ID', 'NAME', 'CODE'],
    'filter' => [
        'IBLOCK_ID' => 10, // Что это за инфоблок?
        'ACTIVE' => 'Y'
    ]
])->fetchAll();

// И снова тот же ID в другом месте
$section = SectionTable::getByPrimary(25, [
    'filter' => ['IBLOCK_ID' => 10] // Дублирование!
])->fetch();

Стало: чистый код без дублирования

use Bitrix\Iblock\Model\Section;

// Один раз создаём класс для инфоблока по API_CODE
$catalogSectionEntity = Section::compileEntityByIblock('catalog');

// IBLOCK_ID больше не нужен - всё фильтруется автоматически
$sections = $catalogSectionEntity::getList([
    'select' => ['ID', 'NAME', 'CODE'],
    'filter' => ['ACTIVE' => 'Y']
])->fetchAll();

Бонус: автоматическая связь с родителем

Помимо избавления от IBLOCK_ID, получаем ещё и связь PARENT_SECTION для работы с иерархией:

$catalogSectionEntity = Section::compileEntityByIblock('catalog');

// Получаем раздел с данными родителя одним запросом
$section = $catalogSectionEntity::getList([
    'filter' => ['=CODE' => 'laptops'],
    'select' => ['ID', 'NAME', 'PARENT_SECTION.NAME']
])->fetchObject();

echo $section->getName(); // "Ноутбуки"
echo $section->getParentSection()?->getName(); // "Компьютеры"

Практический пример: хлебные крошки без IBLOCK_ID

use Bitrix\Iblock\Model\Section;

// Инициализируем класс для каталога
$catalogSectionEntity = Section::compileEntityByIblock('catalog');

$section = $catalogSectionEntity::query()
    ->setSelect([
        'ID', 
        'NAME',
        'CODE',
        'PARENT_SECTION.NAME',
        'PARENT_SECTION.PARENT_SECTION.NAME'
    ])
    ->where('CODE', 'gaming-laptops')
    ->exec()
    ->fetchObject();

// Строим хлебные крошки рекурсивно
$breadcrumbs = [];
$current = $section;

while ($current) {
    $breadcrumbs[] = [
        'name' => $current->getName(),
        'code' => $current->getCode()
    ];
    $current = $current->getParentSection();
}

$breadcrumbs = array_reverse($breadcrumbs);

Что получаем

  1. Нет магических чисел: IBLOCK_ID указан один раз при создании класса
  2. Переносимость: изменили ID инфоблока — код работает без изменений
  3. Читаемость: не нужно помнить, что 10 — это каталог, а 15 — новости
  4. Меньше ошибок: невозможно забыть добавить фильтр по IBLOCK_ID
  5. Связь с родителем: автоматическая PARENT_SECTION для иерархии
Кастомный тип свойства для JSON-данных с валидацией

Кастомный тип свойства для JSON-данных с валидацией

Проблема

При работе со сложными структурированными данными в инфоблоках часто приходится хранить JSON в обычных строковых свойствах. Это создает проблемы: нет валидации при сохранении, неудобный интерфейс редактирования и риск повреждения данных. Стандартные типы свойств Битрикс не предоставляют встроенных механизмов для работы с JSON-структурами.

Решение

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

Регистрация типа свойства:

<?php
// /local/php_interface/init.php
$eventManager = \Bitrix\Main\EventManager::getInstance();

$eventManager->addEventHandler(
    'iblock',
    'OnIBlockPropertyBuildList',
    ['CustomPropertyJson', 'GetUserTypeDescription']
);

class CustomPropertyJson
{
    public static function GetUserTypeDescription(): array
    {
        return [
            'PROPERTY_TYPE' => 'S',
            'USER_TYPE' => 'json_data',
            'DESCRIPTION' => 'JSON данные',
            'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'],
            'ConvertToDB' => [__CLASS__, 'ConvertToDB'],
            'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'],
        ];
    }

    // Валидация и сохранение
    public static function ConvertToDB($property, $value)
    {
        if (empty($value['VALUE'])) {
            return $value;
        }

        // Проверяем валидность JSON
        $decoded = json_decode($value['VALUE'], true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            global $APPLICATION;
            $APPLICATION->ThrowException('Невалидный JSON: ' . json_last_error_msg());
            return false;
        }

        // Дополнительная валидация структуры
        if (isset($property['USER_TYPE_SETTINGS']['REQUIRED_FIELDS'])) {
            $required = explode(',', $property['USER_TYPE_SETTINGS']['REQUIRED_FIELDS']);
            foreach ($required as $field) {
                if (!isset($decoded[trim($field)])) {
                    global $APPLICATION;
                    $APPLICATION->ThrowException("Отсутствует обязательное поле: {$field}");
                    return false;
                }
            }
        }

        return $value;
    }

    // Форматирование при выводе
    public static function ConvertFromDB($property, $value)
    {
        $value['VALUE'] = htmlspecialcharsback($value['VALUE']);
        return $value;
    }

    // Интерфейс редактирования
    public static function GetPropertyFieldHtml($property, $value, $strHTMLControlName)
    {
        $currentValue = $value['VALUE'];

        return '<textarea 
    name="' . $strHTMLControlName['VALUE'] . '" 
    rows="10" 
    cols="50" 
    class="json-editor"
    placeholder=\'{"key": "value"}\'
    style="font-family: monospace; font-size: 14px; line-height: 1.4; padding: 8px; border: 1px solid #ccc; border-radius: 4px; width: 100%;">' .
            htmlspecialchars($currentValue) .
            '</textarea>
<script>
    BX.ready(function() {
        const textarea = document.querySelector(\'textarea[name="' . $strHTMLControlName['VALUE'] . '"]\');
        
        // Автоформатирование при потере фокуса
        textarea.addEventListener("blur", function() {
            try {
                if (this.value.trim()) {
                    const obj = JSON.parse(this.value);
                    this.value = JSON.stringify(obj, null, 2);
                    this.style.borderColor = "#28a745";
                }
            } catch(e) {
                this.style.borderColor = "#dc3545";
                console.error("JSON error:", e);
            }
        });

        // Проверка при вводе
        textarea.addEventListener("input", function() {
            try {
                if (this.value.trim()) {
                    JSON.parse(this.value);
                    this.style.borderColor = "#28a745";
                } else {
                    this.style.borderColor = "#ccc";
                }
            } catch(e) {
                this.style.borderColor = "#dc3545";
            }
        });

        // Инициализация проверки
        textarea.dispatchEvent(new Event("input"));
    });
</script>';
    }
}

Итог

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

InsertIgnore стратегия для безопасной вставки без дублирования

InsertIgnore стратегия для безопасной вставки без дублирования

Проблема дублирования записей

При массовой вставке данных в базу через ORM часто возникает проблема дублирования по уникальным полям. Стандартный метод add() генерирует ошибку при попытке вставить запись с существующим первичным ключом или уникальным индексом. Приходится вручную проверять существование записи через getList() перед вставкой, что неэффективно при массовых операциях.

Стратегия InsertIgnore

С версии 22.0 в Bitrix ORM появилась стратегия InsertIgnore, которая использует SQL-конструкцию INSERT IGNORE. Если запись с таким ключом существует, она игнорируется без ошибки. Это атомарная операция на уровне СУБД, что обеспечивает корректность даже при конкурентных запросах.

Переопределение стратегии в Table-классе

Самый простой способ — переопределить метод getAddStrategy() в вашем Table-классе:

namespace Mycompany\MyModule;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Data\AddStrategy;

class UserTokenTable extends DataManager
{
    public static function getTableName()
    {
        return 'b_user_token';
    }
    
    public static function getMap()
    {
        return [
            'ID' => ['data_type' => 'integer', 'primary' => true, 'autocomplete' => true],
            'USER_ID' => ['data_type' => 'integer', 'required' => true],
            'TOKEN' => ['data_type' => 'string', 'required' => true],
        ];
    }
    
    // Переопределяем стратегию для всех операций add()
    protected static function getAddStrategy(): AddStrategy\Contract\AddStrategy
    {
        return new AddStrategy\InsertIgnore(static::getEntity());
    }
}

// Теперь add() использует INSERT IGNORE автоматически
$result = UserTokenTable::add([
    'ID' => 1,
	  'USER' => 1,
    'TOKEN' => 'abc123'
]);

// Если запись существует - игнорируется без ошибки
// $result->isSuccess() вернёт true
// $result->getId() вернёт ID существующей записи

Массовая вставка с InsertIgnore

Для массовой вставки данных используйте метод addMulti():

use Bitrix\MyModule\UserTokenTable;

$tokens = [
    ['ID' => 10, 'USER_ID' => 1, 'TOKEN' => 'token1'],
    ['ID' => 11, 'USER_ID' => 2, 'TOKEN' => 'token2'],
    ['ID' => 12, 'USER_ID' => 3, 'TOKEN' => 'token3'],
    ['ID' => 10, 'USER_ID' => 1, 'TOKEN' => 'token1'], // дубликат - будет проигнорирован
];

// Все записи вставляются одним запросом
$result = UserTokenTable::addMulti($tokens);

// Проверка успешности
if ($result->isSuccess()) {
    // Операция выполнена, дубликаты проигнорированы
}

Указание уникальных полей

По умолчанию InsertIgnore проверяет первичный ключ. Если нужно проверять другие поля, передайте их в конструктор:

protected static function getAddStrategy(): AddStrategy\Contract\AddStrategy
{
    // Проверять уникальность по полям USER_ID и TOKEN
    return new AddStrategy\InsertIgnore(
        static::getEntity(),
        ['USER_ID', 'TOKEN']
    );
}

Проверка изменений базы

Метод isDBChanged() в результате показывает, была ли реально изменена база:

$result = UserTokenTable::add(['USER_ID' => 1, 'TOKEN' => 'abc']);

if ($result->isSuccess()) {
    if ($result->getData()['isDBChanged']) {
        // Запись была вставлена
    } else {
        // Запись уже существовала и была проигнорирована
    }
}

Важные ограничения

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

Когда использовать InsertIgnore

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

Выбор между send и sendImmediate для почтовых событий

Выбор между send и sendImmediate для почтовых событий

При отправке почтовых событий в 1С-Битрикс разработчики часто используют старый API CEvent::Send(), не зная о современном классе Bitrix\Main\Mail\Event и его двух принципиально разных методах отправки. Неправильный выбор метода приводит к блокировке выполнения скрипта при проблемах с почтовым сервером или избыточной нагрузке на базу данных.

Метод send(): отложенная отправка

Метод send() добавляет событие в очередь отправки (таблица b_event). Агент Битрикс обработает событие позже:

use Bitrix\Main\Mail\Event;

// Добавление в очередь с автоматическим выбором шаблона
$result = Event::send([
    'EVENT_NAME' => 'ORDER_CONFIRM',
    'LID' => 's1',
    'C_FIELDS' => [
        'ORDER_ID' => $orderId,
        'USER_EMAIL' => $email,
        'ORDER_LIST' => $orderHtml,
    ],
]);

// Проверка результата
if ($result->isSuccess()) {
    $eventId = $result->getId();
} else {
    $errors = $result->getErrorMessages();
}

Метод send() возвращает объект Result с методами проверки успешности и получения ошибок, что упрощает обработку по сравнению со старым API.

Отправка конкретного почтового шаблона

Для отправки определённого шаблона используйте параметр MESSAGE_ID:

// Отправка конкретного шаблона (ID из таблицы b_event_message)
$result = Event::send([
    'EVENT_NAME' => 'ORDER_CONFIRM',
    'LID' => 's1',
    'MESSAGE_ID' => 5, // ID конкретного шаблона
    'C_FIELDS' => [
        'ORDER_ID' => $orderId,
        'USER_EMAIL' => $email,
    ],
]);

Без MESSAGE_ID система выберет все активные шаблоны по EVENT_NAME и LID. С MESSAGE_ID будет использован только указанный шаблон, даже если есть другие для этого типа события.

Метод sendImmediate(): немедленная отправка

Метод sendImmediate() отправляет письмо синхронно, минуя очередь:

use Bitrix\Main\Mail\Event;

// Немедленная отправка с конкретным шаблоном
$sendResult = Event::sendImmediate([
    'EVENT_NAME' => 'CRITICAL_ALERT',
    'LID' => 's1',
    'MESSAGE_ID' => 12, // Опционально: конкретный шаблон
    'C_FIELDS' => [
        'MESSAGE' => 'Критическая ошибка в системе',
        'TIME' => date('Y-m-d H:i:s'),
    ],
]);

// Результат - строковая константа
if ($sendResult === Event::SEND_RESULT_SUCCESS) {
    // Отправлено успешно
} elseif ($sendResult === Event::SEND_RESULT_ERROR) {
    // Ошибка отправки
} elseif ($sendResult === Event::SEND_RESULT_TEMPLATE_NOT_FOUND) {
    // Шаблон не найден
}

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

Метод sendImmediate() возвращает строку, в отличие от send(), а не объект Result.

Обработка вложений

Оба метода поддерживают массив вложений в параметре FILE:

$result = Event::send([
    'EVENT_NAME' => 'DOCUMENT_READY',
    'LID' => ['s1', 's2'], // Множественные сайты
    'C_FIELDS' => ['DOC_NUMBER' => '123'],
    'FILE' => [
        456, // ID из b_file (не копируется)
        '/path/to/new.pdf', // Новый файл (будет скопирован)
    ],
]);

Система автоматически определяет тип вложения: числовые значения считаются ID файлов, строки - путями к файлам для копирования.

Итог

Используйте send() для пользовательских сценариев и sendImmediate() для фоновых задач. Параметр MESSAGE_ID позволяет точно контролировать, какой шаблон будет использован.

Безопасная работа с JSON через Bitrix\Main\Web\Json

Безопасная работа с JSON через Bitrix\Main\Web\Json

Проблема

При работе с JSON в 1С-Битрикс многие разработчики используют нативные функции json_encode() и json_decode() без должной обработки ошибок. Это приводит к silent-ошибкам: когда данные не кодируются корректно, функция возвращает false или null, но код продолжает выполняться. В результате возникают сложно диагностируемые проблемы с невалидными данными.

Решение

Класс Bitrix\Main\Web\Json предоставляет безопасные методы для работы с JSON, автоматически выбрасывая исключения при ошибках кодирования/декодирования.

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

use Bitrix\Main\Web\Json;
use Bitrix\Main\ArgumentException;

// Безопасное кодирование
try {
    $jsonString = Json::encode($data);
} catch (ArgumentException $e) {
    // Обработка ошибки кодирования
}

// Безопасное декодирование
try {
    $array = Json::decode($jsonString);
} catch (ArgumentException $e) {
    // Обработка ошибки декодирования
}

Преимущества класса

Класс автоматически использует оптимальные опции по умолчанию:

  • JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT — защита от XSS
  • JSON_UNESCAPED_UNICODE — корректная работа с кириллицей
  • JSON_INVALID_UTF8_SUBSTITUTE — замена невалидных символов UTF-8
  • JSON_THROW_ON_ERROR — автоматический выброс исключений

Валидация JSON

Класс предоставляет метод для проверки валидности JSON-строки:

use Bitrix\Main\Web\Json;
use Bitrix\Main\Application;

$request = Application::getInstance()->getContext()->getRequest();
$jsonString = $request->getPost('json_data') ?? '';

if (!Json::validate($jsonString)) {
    throw new \Bitrix\Main\ArgumentException(
        "Невалидный JSON в параметре json_data"
    );
}

$data = Json::decode($jsonString);

Кастомные опции кодирования

Вы можете переопределить опции при необходимости:

use Bitrix\Main\Web\Json;

// Форматированный вывод с отступами
$prettyJson = Json::encode(
    $data, 
    Json::DEFAULT_OPTIONS | JSON_PRETTY_PRINT
);

// Без экранирования слешей для URL
$jsonForApi = Json::encode(
    ['url' => 'https://example.com/path'],
    Json::DEFAULT_OPTIONS | JSON_UNESCAPED_SLASHES
);

Итог

Использование Bitrix\Main\Web\Json вместо нативных функций обеспечивает явную обработку ошибок и автоматическую защиту от XSS. Метод validate() позволяет безопасно проверять пользовательский ввод перед декодированием.

Правильная работа с Bitrix\Main\Result для обработки ошибок

Правильная работа с Bitrix\Main\Result для обработки ошибок

Проблема

Многие разработчики до сих пор возвращают булевы значения или массивы из своих методов, игнорируя стандартный класс Bitrix\Main\Result. Это приводит к неединообразной обработке ошибок, усложняет отладку и делает код несовместимым с современным API Битрикс.

Решение

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

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

use Bitrix\Main\Result;
use Bitrix\Main\Error;

function processOrder(int $orderId): Result
{
    $result = new Result();
    
    // Проверка существования заказа
    if (!$order = getOrder($orderId)) {
        $result->addError(new Error('Заказ не найден', 'ORDER_NOT_FOUND'));
        return $result;
    }
    
    // Проверка статуса
    if ($order['STATUS'] === 'PAID') {
        $result->addError(new Error('Заказ уже оплачен', 'ORDER_ALREADY_PAID'));
        return $result;
    }
    
    // Успешная обработка
    $result->setData([
        'order_id' => $orderId,
        'amount' => $order['PRICE'],
        'timestamp' => time()
    ]);
    
    return $result;
}

Проверка результата

$result = processOrder(123);

if ($result->isSuccess()) {
    $data = $result->getData();
    echo "Заказ обработан: " . $data['order_id'];
} else {
    // Получение всех ошибок
    foreach ($result->getErrors() as $error) {
        echo $error->getCode() . ': ' . $error->getMessage();
    }
    
    // Или только первой ошибки
    $error = $result->getError();
    echo $error->getMessage();
    
    // Или всех сообщений одним массивом
    $messages = $result->getErrorMessages();
}

Добавление множественных ошибок

function validateOrderData(array $data): Result
{
    $result = new Result();
    $errors = [];
    
    if (empty($data['email'])) {
        $errors[] = new Error('Email обязателен', 'EMAIL_REQUIRED');
    }
    
    if (empty($data['phone'])) {
        $errors[] = new Error('Телефон обязателен', 'PHONE_REQUIRED');
    }
    
    if (!empty($errors)) {
        $result->addErrors($errors);
    }
    
    return $result;
}

Передача ошибок между методами

function createUser(array $userData): Result
{
    $result = new Result();
    
    // Валидация
    $validationResult = validateUserData($userData);
    if (!$validationResult->isSuccess()) {
        $result->addErrors($validationResult->getErrors());
        return $result;
    }
    
    // Создание пользователя
    // ...
    
    return $result;
}

Дополнительные данные в ошибках

$error = new Error(
    'Недостаточно товара на складе',
    'INSUFFICIENT_STOCK',
    ['available' => 5, 'requested' => 10] // customData
);

// Получение дополнительных данных
$customData = $error->getCustomData();
echo "Доступно: " . $customData['available'];

Итог

Использование Result делает код предсказуемым, упрощает обработку ошибок и соответствует стандартам Битрикс. Все современные модули (Sale, Catalog, CRM) используют этот подход.