Кнопка «Сохранить» иногда выводит ошибку, хотя элемент инфоблока уже изменён, а при повторе пользователь запускает второй побочный эффект. Цена ошибки — дублирующая отправка, потерянная причина сбоя и опасная правка «только сообщения» в файле, который одновременно меняет данные, строит HTML и зовёт соседнюю интеграцию.
Один вопрос этой заметки: почему смешанная ответственность в старом save.php делает даже локальный рефакторинг рискованным? В 2018 году для ответа не нужен большой набор паттернов. Достаточно показать порядок действий: форма передала данные, Bitrix изменил элемент, затем код решил, что показать или вызвать дальше. Если эти шаги не разделены, по одному сообщению в браузере нельзя понять, какой из них уже произошёл.
Один HTTP-запрос может оставить несколько разных следов
Старый обработчик обычно вырос постепенно. Сначала он сохранял название товара. Потом в него добавили проверку картинки, затем письмо менеджеру, затем очистку кеша и кусок шаблона. Все строки исполняются в одном PHP-процессе, но владеют разными состояниями. Поле инфоблока живёт в Битрикс, сообщение — в форме, а уведомление — в другом канале. Ошибка после первого действия не отменяет автоматически уже сделанное изменение.
Опасность не в длине файла. Маленький файл тоже смешивает ответственность, если функция одновременно читает $_POST, меняет элемент, печатает HTML и решает, что делать с ошибкой. В нём невозможно выбрать простую проверку: мы не знаем, считать ли «успехом» ответ браузеру, результат Update() или факт, что следующее действие не было вызвано. Поэтому сначала даём каждому следу имя.
Сначала фиксируем наблюдаемые границы
Для локальной переделки достаточно трёх ролей. Обработчик формы принимает и проверяет вход. Операция записи меняет один элемент инфоблока и возвращает результат или ошибку. Представление решает, какой текст показать. Внешняя отправка, кеш или импорт остаются отдельными соседями: их не надо прятать в новую функцию только потому, что они находятся рядом. Если они важны для сценария, порядок и отдельный признак их выполнения описываются позже.
| Что делает старый файл | Какой след остаётся | Почему это опасно при смешении | Самый маленький шов |
|---|---|---|---|
Читает $_POST | Непроверенные строки формы | Пустой ID может дойти до записи под видом обычной ошибки | Преобразовать вход в массив с ID и названием до работы с API |
Вызывает CIBlockElement::Update | Изменение в инфоблоке или LAST_ERROR | HTML ниже по файлу не доказывает результат записи | Вернуть из функции отчёт или исключение |
| Выводит HTML | Текст в браузере | Сообщение «готово» может появиться не на том пути | Показывать текст после известного результата операции |
| Запускает интеграцию | Отдельный сетевой или файловый эффект | Повтор формы способен повторить уже выполненное действие | Оставить вызов рядом с явным условием успеха |
| Меняет глобальное состояние | Сессия, кеш, глобальные переменные | Тест и диагностика зависят от порядка строк | Передавать нужное значение аргументом в малую функцию |
Таблица не предлагает разнести старый сайт по слоям за один день. Она нужна для более короткого решения: выбрать единственный след, который сейчас нужен задаче, и не потерять его среди остальных. Например, если исправляем пустой символьный код, первым швом будет сохранение CODE. Письмо менеджеру и кеш можно временно оставить в старом файле, но не использовать их как доказательство того, что код элемента записан.
Как выглядит смешение в коде
Этот фрагмент намеренно похож на обычный legacy-обработчик. Он не взят из конкретного проекта и не должен быть скопирован в production. Его задача — показать, почему ошибка в конце не отвечает на вопрос о середине. Вызов sendPartnerNotice() обозначает уже существующую соседнюю операцию; статья не утверждает, что она выполнялась или что любой сайт должен её иметь.
<?php
// save.php — упрощённый пример смешанной ответственности
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$elementId = (int) $_POST['ID'];
$name = trim((string) $_POST['NAME']);
if ($elementId <= 0 || $name === '') {
echo 'Заполните ID и название.';
return;
}
CModule::IncludeModule('iblock');
$element = new CIBlockElement();
if (!$element->Update($elementId, array('NAME' => $name))) {
echo $element->LAST_ERROR;
return;
}
// Детали этой интеграции здесь неизвестны.
sendPartnerNotice($elementId, $name);
echo 'Сохранено';
}
В примере можно увидеть минимум три исхода: вход не прошёл, Bitrix отказал в обновлении, запись прошла, но следующий шаг вернул ошибку. Последний исход особенно неприятен. Если вокруг sendPartnerNotice() появится исключение, браузер может показать общую ошибку, хотя NAME уже записан. Повторить POST после этого — не нейтральная проверка. Поэтому не маскируем все пути одним текстом и не добавляем «повторить Update» после любого сбоя.
Выносим запись в функцию с одним ответом
Первое извлечение можно сделать обычной функцией. Вход ей передают явно: ID и нормализованное название. Она не печатает HTML, не читает глобальный $_POST и не вызывает интеграцию. Она либо возвращает ID обновлённого элемента, либо останавливает текущий путь исключением. PHP исключения здесь используются не как модная абстракция, а чтобы код формы не продолжился как после успешной записи.
<?php
// ProductNameWriter.php — PHP 7.2
function saveProductName($elementId, $name)
{
if (!CModule::IncludeModule('iblock')) {
throw new RuntimeException('Module iblock is unavailable');
}
$elementId = (int) $elementId;
$name = trim((string) $name);
if ($elementId <= 0 || $name === '') {
throw new InvalidArgumentException('ID and NAME are required');
}
$element = new CIBlockElement();
if (!$element->Update($elementId, array('NAME' => $name))) {
throw new RuntimeException($element->LAST_ERROR ?: 'Element update failed');
}
return $elementId;
}
// В обработчике формы остаётся только порядок сценария.
try {
$savedId = saveProductName($_POST['ID'], $_POST['NAME']);
$message = 'Карточка сохранена: ' . $savedId;
} catch (InvalidArgumentException $error) {
$message = $error->getMessage();
} catch (RuntimeException $error) {
$message = $error->getMessage();
}
После этого можно решить, что делать с интеграцией, но не смешивать решение с первым швом. Если уведомление допустимо только после успешной записи, его вызывают после saveProductName() и фиксируют отдельно, что именно считается успехом интеграции. Если интеграция упала, обработчик честно показывает её отдельную ошибку и не делает вид, что карточка не менялась. Восстановление поля, повтор сети и очередь — следующие задачи с собственными условиями, а не одна строка в catch.
Почему обработчики Bitrix усиливают путаницу
Метод CIBlockElement::Update не одинок: до изменения могут выполниться обработчики OnBeforeIBlockElementUpdate, которые могут изменить входные поля или отменить действие. После записи также есть события. Поэтому функция записи должна сохранить первоначальный смысл: она возвращает только результат вызова API и его сообщение. Ей не нужно обещать, что все слушатели, поиск, кеш или внешний каталог уже находятся в согласованном состоянии.
Эта оговорка особенно полезна при разборе старого кода. Если новая функция внезапно меняет больше, чем старая строка, сначала смотрим обработчики и фактический набор полей. Не добавляем PROPERTY_VALUES «для полноты»: документация события отдельно предупреждает, что неосторожная работа с этим массивом может очистить остальные свойства, когда Update был вызван без них. Узкий массив полей — защита от лишнего изменения, а не неполнота примера.
Порядок локальной переделки
- Назвать один симптом: например, после формы неизвестно, записано ли название или ошибка случилась после записи.
- Выписать из файла все побочные эффекты в их фактическом порядке: изменение элемента, HTML, кеш, письмо, интеграция.
- Выбрать только один эффект для первой замены и описать вход, выход и отказ.
- Вынести его в функцию или небольшой класс без
echo,$_POSTи сторонней отправки. - Подключить шов одним вызовом из старого файла и сохранить прежний порядок для действий, которые ещё не разбирались.
- На разрешённом тестовом контуре проверить положительный и отрицательный вход отдельно от шаблона.
- Если нужно менять следующий эффект, начать новый короткий разбор, а не расширять первый шов до всего файла.
Где такое разделение не решает проблему
Функция записи не создаёт транзакцию между инфоблоком и внешним API. Она не отменяет уже сделанное уведомление и не знает, можно ли повторить сетевой запрос. Если сценарий требует атомарности нескольких систем, это отдельная задача: сначала нужно описать данные, порядок и допустимый повтор. Называть такую задачу «добавим try/catch» было бы опаснее, чем оставить честную границу.
Также не надо принудительно выносить весь шаблон из PHP-файла, если ошибка живёт в одной операции записи. Старый формат может остаться старым. Результат локального рефакторинга измеряется проще: у операции есть собственный вход, собственный результат, понятная ошибка и короткий способ проверить, где оборвался сценарий. Это уже делает следующую правку меньше.
Итог: порядок важнее размера файла
Маленькая правка опасна, когда один файл выдаёт несколько несвязанных эффектов за один успех. Разделив форму, изменение инфоблока и последующее действие хотя бы на уровне функций, мы не строим новую архитектуру. Мы возвращаем причинность: сообщение формы не подменяет результат Update, а ошибка интеграции не стирает факт сохранения. С этой точки можно выбрать следующий шов без большого переписывания.