Files
progcode/web/scripts/upgrade-2018-12.mjs
huncode c72a72b8f3
Build and deploy / deploy (push) Successful in 14s
revise late 2018 editorial articles
2026-07-31 10:09:53 +03:00

468 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const paragraph = (content) => '<p>' + content + '</p>';
const heading = (content) => '<h2>' + content + '</h2>';
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
function figure(src, alt, caption) {
return [
'<figure>',
'<img src="' + src + '" alt="' + alt + '" />',
'<figcaption>' + caption + '</figcaption>',
'</figure>',
].join('');
}
function dataTable(headers, rows) {
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
const body = rows.map((row) => (
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
)).join('');
return '<div class="table-scroll"><table><thead><tr>' + head
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function sourceList(items) {
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a></li>'
)).join('') + '</ul>';
}
const sources = {
includeModule: {
label: '1С-Битрикс — CModule::IncludeModule',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php',
},
update: {
label: '1С-Битрикс — CIBlockElement::Update',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
},
getList: {
label: '1С-Битрикс — CIBlockElement::GetList',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php',
},
beforeUpdate: {
label: '1С-Битрикс — событие OnBeforeIBlockElementUpdate',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/events/onbeforeiblockelementupdate.php',
},
translit: {
label: '1С-Битрикс — CUtil::translit',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php',
},
phpExceptions: {
label: 'PHP Manual — Exceptions',
url: 'https://www.php.net/exceptions',
},
};
const practiceArticle = {
slug: 'editorial-2018-12-practice-legacy-refactoring',
title: 'Bitrix API. Как заменить один вызов Update, не переписывая модуль',
categories: ['Bitrix', 'PHP'],
cover: '/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg',
excerpt: 'Небольшой защищённый шов вокруг CIBlockElement::Update: ограничиваем вход, меняем одно поле CODE, читаем результат обратно и не обещаем переписать весь legacy-модуль.',
readingMinutes: 13,
contentHtml: [
paragraph('После точечной правки карточки товар иногда исчезает по привычному URL: поле <code>CODE</code> пришло пустым или ушло не в тот инфоблок. Цена ошибки — не только 404 для посетителя. Следующая выгрузка или шаблон может прочитать уже другой адрес, и разбирать придётся не одну строку в обработчике, а весь след изменения.'),
paragraph('Разберём один вопрос: <strong>как вынести маленький защищённый шов вокруг <code>CIBlockElement::Update</code>, когда нужно менять только символьный код элемента?</strong> Это учебный пример для старого Bitrix и PHP 7.2. Он не предполагает, что у нас есть доступ к вашему модулю, тестовой базе или журналу изменений. Сначала ограничиваем вход, затем меняем одно поле и читаем его обратно.'),
heading('Симптом показывает место, а не весь объём переделки'),
paragraph('В legacy-файле изменение часто прячется между разбором <code>$_POST</code>, подключением шаблона, проверкой прав и отправкой письма. В таком месте легко решить, что нужен новый модуль каталога. Пока доказан только другой факт: один переход <code>вход формы → CODE элемента</code> нельзя безопасно увидеть и повторить. Значит, первым делом отделяем этот переход, а не переносим все соседние строки.'),
paragraph('Шов — это небольшая функция или класс, у которого видны вход, ожидаемое изменение и ошибка. Он не обязан знать, как отрисовывается форма и кому потом отправляется уведомление. Для примера шов получает ID элемента, новый код и ожидаемый ID инфоблока. На выходе он возвращает короткий отчёт: поле уже имело нужное значение либо было обновлено. Остальные части старого файла пока остаются на месте.'),
figure(
'/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg',
'Карта шва legacy-модуля Bitrix: форма передаёт ID и CODE в выделенный CodeWriter, тот проверяет инфоблок, вызывает CIBlockElement Update и читает элемент обратно; шаблон и внешняя интеграция остаются по сторонам.',
'Шов обводит только переход к полю CODE. Он не изображает замену всего шаблона, импорта или каталога.',
),
heading('До кода записываем, что вправе изменить'),
paragraph('Узкий контракт полезнее списка пожеланий. Входом считаем положительный ID, непустой символьный код и заранее известный инфоблок. Изменением считаем только поле <code>CODE</code>. До вызова API проверяем, что элемент существует и относится к этому инфоблоку. После вызова читаем тот же элемент и сравниваем фактическое значение. Если код уже совпал, писать в БД второй раз не нужно: это отдельный наблюдаемый результат, а не ошибка.'),
dataTable(
['Часть шва', 'Проверка до записи', 'Действие', 'Признак готовности'],
[
['Модуль', '<code>CModule::IncludeModule("iblock")</code> вернул <code>true</code>', 'Разрешить работу с API инфоблоков', 'Нет скрытого подключения класса из другого файла'],
['Элемент', 'Выборка по ID вернула строку', 'Сравнить <code>IBLOCK_ID</code> и текущий <code>CODE</code>', 'Не меняем чужой инфоблок и не создаём элемент по ошибке'],
['Вход', 'ID положительный, код после <code>trim()</code> не пустой', 'Передать ровно одно поле в <code>Update</code>', 'Форма не превращает пустое значение в случайное обновление'],
['Запись', '<code>Update()</code> вернул <code>true</code>', 'Прочитать элемент той же выборкой', 'Возвращённый <code>CODE</code> совпадает с ожидаемым'],
['Отказ', 'API вернул <code>false</code> или проверка не прошла', 'Остановить текущий путь с понятным сообщением', 'Нет продолжения к шаблону как после успешной записи'],
],
),
paragraph('Такой контракт не гарантирует уникальность кода на любом сайте. В одном проекте код формирует компонент, в другом — импорт, в третьем на него влияют обработчики события. Задача этого шага скромнее: не дать конкретному вызову <code>Update</code> изменить неизвестный элемент или умолчать о неуспехе. Правило уникальности, транслитерация и маршруты каталога добавляются отдельными проверками, если они действительно принадлежат вашему случаю.'),
heading('Выделяем CodeWriter, а не новый «слой приложения»'),
paragraph('Ниже обычный PHP-класс. Он использует старый API Bitrix потому, что именно он уже находится в рассматриваемом коде. Перед работой он подключает модуль <code>iblock</code>, читает элемент через <code>CIBlockElement::GetList</code> и передаёт в <code>Update</code> только <code>CODE</code>. В примере нет автозагрузчика, контейнера зависимостей или обещания миграции на другой API: эти вещи не нужны, чтобы сделать один вызов понятнее.'),
codeBlock([
'<?php',
'// local/php_interface/lib/ProductCodeWriter.php — PHP 7.2',
'final class ProductCodeWriter',
'{',
' private $expectedIblockId;',
'',
' public function __construct($expectedIblockId)',
' {',
' $this->expectedIblockId = (int) $expectedIblockId;',
' }',
'',
' public function write($elementId, $code)',
' {',
" if (!CModule::IncludeModule('iblock')) {",
" throw new RuntimeException('The iblock module is not available');",
' }',
'',
' $elementId = (int) $elementId;',
' $code = trim((string) $code);',
'',
" if ($elementId <= 0 || $code === '') {",
" throw new InvalidArgumentException('Element ID and CODE are required');",
' }',
'',
' $before = $this->find($elementId);',
" if (!$before || (int) $before['IBLOCK_ID'] !== $this->expectedIblockId) {",
" throw new RuntimeException('Unexpected element or iblock');",
' }',
'',
" if ((string) $before['CODE'] === $code) {",
" return array('changed' => false, 'code' => $code);",
' }',
'',
' $element = new CIBlockElement();',
" if (!$element->Update($elementId, array('CODE' => $code))) {",
" throw new RuntimeException($element->LAST_ERROR ?: 'Bitrix Update failed');",
' }',
'',
' $after = $this->find($elementId);',
" if (!$after || (string) $after['CODE'] !== $code) {",
" throw new RuntimeException('CODE was not read back after Update');",
' }',
'',
" return array('changed' => true, 'code' => $after['CODE']);",
' }',
'',
' private function find($elementId)',
' {',
' $result = CIBlockElement::GetList(',
' array(),',
" array('ID' => (int) $elementId),",
' false,',
' false,',
" array('ID', 'IBLOCK_ID', 'CODE')",
' );',
'',
' return $result->Fetch();',
' }',
'}',
].join('\n')),
paragraph('Код не заменяет права доступа и не делает код уникальным сам по себе. Он также не оборачивает обработчики Bitrix в транзакцию: документация <code>CIBlockElement::Update</code> указывает, что до и после записи работают события. Поэтому ответ <code>true</code> нужен, но сам по себе не равен доказательству, что шаблон, индекс или внешняя система уже увидели нужный адрес. Здесь мы доказываем только то, что выбрали правильный элемент и прочитали обратно его <code>CODE</code>.'),
heading('Подключаем шов из старого обработчика одной строкой'),
paragraph('Старый файл может по-прежнему собирать <code>$_POST</code>, проверять сессию и решать, какой шаблон показать. Замена касается только места, где раньше напрямую вызывался <code>CIBlockElement::Update</code>. Обработчик получает отчёт и сам выбирает, как показать ошибку. Это важно: класс не должен делать <code>echo</code>, редирект или запись в глобальный <code>$APPLICATION</code>, иначе ответственность снова смешается.'),
codeBlock([
'<?php',
'// fragment from a legacy save handler',
'try {',
' $writer = new ProductCodeWriter(7); // 7 — ID инфоблока этого сценария',
" $report = $writer->write($_POST['ID'], $_POST['CODE']);",
'',
" $message = $report['changed']",
" ? 'Символьный код обновлён.'",
" : 'Символьный код уже совпадает.';",
'} catch (InvalidArgumentException $error) {',
' $message = $error->getMessage();',
'} catch (RuntimeException $error) {',
' // В конкретном проекте здесь выбирают локальный журнал и вывод формы.',
' $message = $error->getMessage();',
'}',
].join('\n')),
paragraph('В таком виде изменение можно снять отдельно в истории: одна правка добавляет <code>ProductCodeWriter</code> и переключает один вызов. Не стоит в том же коммите переименовывать шаблоны, менять поля инфоблока и переписывать импорт. Если поведение расходится, разница будет лежать либо на входе, либо в шве, либо после него. Чем меньше одновременно изменённых мест, тем легче вернуть старую строку без отката соседней работы.'),
heading('Проверяем один след, затем расширяем покрытие'),
orderedList([
'Найти прямой вызов <code>CIBlockElement::Update</code> и записать его входы: ID, инфоблок, поле и источник кода.',
'Выбрать изолированный элемент на разрешённом тестовом контуре; не брать рабочую карточку ради быстрой проверки.',
'Сохранить исходный <code>CODE</code> и ожидаемый новый код рядом с проверкой, не в комментарии памяти.',
'Вызвать шов только для этого элемента и проверить отчёт <code>changed</code>.',
'Снова выбрать элемент API-запросом и сравнить фактический <code>CODE</code> с ожидаемым.',
'Если появилось расхождение, вернуть вызов на прежний путь или восстановить сохранённое значение по процедуре проекта; не добавлять второй <code>Update</code> «на всякий случай».',
'Лишь после понятного результата подключать следующий вызов и отдельно разбирать его предусловия.',
]),
heading('Границы защищённого шва'),
paragraph('Этот приём не устраняет все риски старого Bitrix-проекта. Он не говорит, какой <code>CODE</code> нужен для SEO, как синхронизировать его с торговыми предложениями и можно ли менять его у опубликованного элемента. Если на инфоблоке есть обработчик <code>OnBeforeIBlockElementUpdate</code>, он вправе изменить поля или отменить обновление. Значит, перед включением на конкретном сайте нужно посмотреть зарегистрированные обработчики и проверить их на разрешённом сценарии.'),
paragraph('Также не следует превращать каждую строку PHP в класс. Если в файле один вызов и он уже имеет ясный вход, достаточно функции с тем же контрактом. Шов оправдан, когда повторяемая операция сейчас смешана с формой или когда нужно зафиксировать её проверку. Цель не в количестве файлов. Цель — увидеть, что именно меняется, и получить точку, куда можно поставить следующий локальный тест.'),
heading('Итог: маленькая замена оставляет понятный след'),
paragraph('Для первого рефакторинга достаточно вынести один вызов <code>Update</code>, ограничить инфоблок и поле, а затем прочитать результат обратно. Это не переписывает модуль и не обещает, что все связи каталога стали безопасными. Зато следующий разработчик получает конкретный маршрут: вход, проверка, запись, повторная выборка и ясная точка отказа. Когда этот маршрут устойчив, рядом можно вынести следующий — но только после отдельной проверки.'),
sourceList([sources.includeModule, sources.getList, sources.update, sources.beforeUpdate, sources.phpExceptions]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-12-mechanism-legacy-refactoring',
title: 'Bitrix. Почему один save.php ломается от «маленькой» правки',
categories: ['Bitrix', 'PHP'],
cover: '/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg',
excerpt: 'Почему смешанная ответственность делает локальную правку опасной: отделяем обработку формы, изменение инфоблока и вывод результата, не строя большую архитектуру поверх старого Bitrix-кода.',
readingMinutes: 13,
contentHtml: [
paragraph('Кнопка «Сохранить» иногда выводит ошибку, хотя элемент инфоблока уже изменён, а при повторе пользователь запускает второй побочный эффект. Цена ошибки — дублирующая отправка, потерянная причина сбоя и опасная правка «только сообщения» в файле, который одновременно меняет данные, строит HTML и зовёт соседнюю интеграцию.'),
paragraph('Один вопрос этой заметки: <strong>почему смешанная ответственность в старом <code>save.php</code> делает даже локальный рефакторинг рискованным?</strong> В 2018 году для ответа не нужен большой набор паттернов. Достаточно показать порядок действий: форма передала данные, Bitrix изменил элемент, затем код решил, что показать или вызвать дальше. Если эти шаги не разделены, по одному сообщению в браузере нельзя понять, какой из них уже произошёл.'),
heading('Один HTTP-запрос может оставить несколько разных следов'),
paragraph('Старый обработчик обычно вырос постепенно. Сначала он сохранял название товара. Потом в него добавили проверку картинки, затем письмо менеджеру, затем очистку кеша и кусок шаблона. Все строки исполняются в одном PHP-процессе, но владеют разными состояниями. Поле инфоблока живёт в Битрикс, сообщение — в форме, а уведомление — в другом канале. Ошибка после первого действия не отменяет автоматически уже сделанное изменение.'),
paragraph('Опасность не в длине файла. Маленький файл тоже смешивает ответственность, если функция одновременно читает <code>$_POST</code>, меняет элемент, печатает HTML и решает, что делать с ошибкой. В нём невозможно выбрать простую проверку: мы не знаем, считать ли «успехом» ответ браузеру, результат <code>Update()</code> или факт, что следующее действие не было вызвано. Поэтому сначала даём каждому следу имя.'),
figure(
'/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg',
'Схема смешанной ответственности в legacy save.php: один файл одновременно читает POST, изменяет CODE в инфоблоке, формирует HTML и запускает внешнее действие; ошибка на позднем шаге не сообщает, что уже сохранилось.',
'Красные стрелки показывают независимые побочные эффекты. Их порядок нельзя восстановить только по одному сообщению формы.',
),
heading('Сначала фиксируем наблюдаемые границы'),
paragraph('Для локальной переделки достаточно трёх ролей. Обработчик формы принимает и проверяет вход. Операция записи меняет один элемент инфоблока и возвращает результат или ошибку. Представление решает, какой текст показать. Внешняя отправка, кеш или импорт остаются отдельными соседями: их не надо прятать в новую функцию только потому, что они находятся рядом. Если они важны для сценария, порядок и отдельный признак их выполнения описываются позже.'),
dataTable(
['Что делает старый файл', 'Какой след остаётся', 'Почему это опасно при смешении', 'Самый маленький шов'],
[
['Читает <code>$_POST</code>', 'Непроверенные строки формы', 'Пустой ID может дойти до записи под видом обычной ошибки', 'Преобразовать вход в массив с ID и названием до работы с API'],
['Вызывает <code>CIBlockElement::Update</code>', 'Изменение в инфоблоке или <code>LAST_ERROR</code>', 'HTML ниже по файлу не доказывает результат записи', 'Вернуть из функции отчёт или исключение'],
['Выводит HTML', 'Текст в браузере', 'Сообщение «готово» может появиться не на том пути', 'Показывать текст после известного результата операции'],
['Запускает интеграцию', 'Отдельный сетевой или файловый эффект', 'Повтор формы способен повторить уже выполненное действие', 'Оставить вызов рядом с явным условием успеха'],
['Меняет глобальное состояние', 'Сессия, кеш, глобальные переменные', 'Тест и диагностика зависят от порядка строк', 'Передавать нужное значение аргументом в малую функцию'],
],
),
paragraph('Таблица не предлагает разнести старый сайт по слоям за один день. Она нужна для более короткого решения: выбрать единственный след, который сейчас нужен задаче, и не потерять его среди остальных. Например, если исправляем пустой символьный код, первым швом будет сохранение <code>CODE</code>. Письмо менеджеру и кеш можно временно оставить в старом файле, но не использовать их как доказательство того, что код элемента записан.'),
heading('Как выглядит смешение в коде'),
paragraph('Этот фрагмент намеренно похож на обычный legacy-обработчик. Он не взят из конкретного проекта и не должен быть скопирован в production. Его задача — показать, почему ошибка в конце не отвечает на вопрос о середине. Вызов <code>sendPartnerNotice()</code> обозначает уже существующую соседнюю операцию; статья не утверждает, что она выполнялась или что любой сайт должен её иметь.'),
codeBlock([
'<?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 'Сохранено';",
'}',
].join('\n')),
paragraph('В примере можно увидеть минимум три исхода: вход не прошёл, Bitrix отказал в обновлении, запись прошла, но следующий шаг вернул ошибку. Последний исход особенно неприятен. Если вокруг <code>sendPartnerNotice()</code> появится исключение, браузер может показать общую ошибку, хотя <code>NAME</code> уже записан. Повторить POST после этого — не нейтральная проверка. Поэтому не маскируем все пути одним текстом и не добавляем «повторить Update» после любого сбоя.'),
heading('Выносим запись в функцию с одним ответом'),
paragraph('Первое извлечение можно сделать обычной функцией. Вход ей передают явно: ID и нормализованное название. Она не печатает HTML, не читает глобальный <code>$_POST</code> и не вызывает интеграцию. Она либо возвращает ID обновлённого элемента, либо останавливает текущий путь исключением. PHP исключения здесь используются не как модная абстракция, а чтобы код формы не продолжился как после успешной записи.'),
codeBlock([
'<?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();',
'}',
].join('\n')),
paragraph('После этого можно решить, что делать с интеграцией, но не смешивать решение с первым швом. Если уведомление допустимо только после успешной записи, его вызывают после <code>saveProductName()</code> и фиксируют отдельно, что именно считается успехом интеграции. Если интеграция упала, обработчик честно показывает её отдельную ошибку и не делает вид, что карточка не менялась. Восстановление поля, повтор сети и очередь — следующие задачи с собственными условиями, а не одна строка в <code>catch</code>.'),
heading('Почему обработчики Bitrix усиливают путаницу'),
paragraph('Метод <code>CIBlockElement::Update</code> не одинок: до изменения могут выполниться обработчики <code>OnBeforeIBlockElementUpdate</code>, которые могут изменить входные поля или отменить действие. После записи также есть события. Поэтому функция записи должна сохранить первоначальный смысл: она возвращает только результат вызова API и его сообщение. Ей не нужно обещать, что все слушатели, поиск, кеш или внешний каталог уже находятся в согласованном состоянии.'),
paragraph('Эта оговорка особенно полезна при разборе старого кода. Если новая функция внезапно меняет больше, чем старая строка, сначала смотрим обработчики и фактический набор полей. Не добавляем <code>PROPERTY_VALUES</code> «для полноты»: документация события отдельно предупреждает, что неосторожная работа с этим массивом может очистить остальные свойства, когда Update был вызван без них. Узкий массив полей — защита от лишнего изменения, а не неполнота примера.'),
heading('Порядок локальной переделки'),
orderedList([
'Назвать один симптом: например, после формы неизвестно, записано ли название или ошибка случилась после записи.',
'Выписать из файла все побочные эффекты в их фактическом порядке: изменение элемента, HTML, кеш, письмо, интеграция.',
'Выбрать только один эффект для первой замены и описать вход, выход и отказ.',
'Вынести его в функцию или небольшой класс без <code>echo</code>, <code>$_POST</code> и сторонней отправки.',
'Подключить шов одним вызовом из старого файла и сохранить прежний порядок для действий, которые ещё не разбирались.',
'На разрешённом тестовом контуре проверить положительный и отрицательный вход отдельно от шаблона.',
'Если нужно менять следующий эффект, начать новый короткий разбор, а не расширять первый шов до всего файла.',
]),
heading('Где такое разделение не решает проблему'),
paragraph('Функция записи не создаёт транзакцию между инфоблоком и внешним API. Она не отменяет уже сделанное уведомление и не знает, можно ли повторить сетевой запрос. Если сценарий требует атомарности нескольких систем, это отдельная задача: сначала нужно описать данные, порядок и допустимый повтор. Называть такую задачу «добавим try/catch» было бы опаснее, чем оставить честную границу.'),
paragraph('Также не надо принудительно выносить весь шаблон из PHP-файла, если ошибка живёт в одной операции записи. Старый формат может остаться старым. Результат локального рефакторинга измеряется проще: у операции есть собственный вход, собственный результат, понятная ошибка и короткий способ проверить, где оборвался сценарий. Это уже делает следующую правку меньше.'),
heading('Итог: порядок важнее размера файла'),
paragraph('Маленькая правка опасна, когда один файл выдаёт несколько несвязанных эффектов за один успех. Разделив форму, изменение инфоблока и последующее действие хотя бы на уровне функций, мы не строим новую архитектуру. Мы возвращаем причинность: сообщение формы не подменяет результат <code>Update</code>, а ошибка интеграции не стирает факт сохранения. С этой точки можно выбрать следующий шов без большого переписывания.'),
sourceList([sources.includeModule, sources.update, sources.beforeUpdate, sources.phpExceptions]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-12-field-legacy-refactoring',
title: 'Bitrix. Замена старого обработчика: один элемент, проверка и откат',
categories: ['Bitrix', 'PHP'],
cover: '/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg',
excerpt: 'Полевой учебный разбор: как заменить один legacy-участок формирования CODE, проверить его на выбранном элементе и подготовить честный откат без обещаний о бесшовной миграции.',
readingMinutes: 14,
contentHtml: [
paragraph('Нужно заменить старый участок, который формирует <code>CODE</code>, но его вызовы разбросаны между импортом и формой редактирования. Цена ошибки — не «некрасивый рефакторинг»: один новый код может сломать URL элемента, а поспешный откат поверх неизвестного значения способен затереть изменение другого человека.'),
paragraph('Это учебный полевой разбор одного вопроса: <strong>как заменить один legacy-обработчик Bitrix на проверяемый путь и оставить возможность отката?</strong> Он не описывает реальный проект, запуск или результат релиза. Возьмём один элемент на разрешённом тестовом контуре, снимем его исходное значение, направим только этот вызов через новый код и после записи снова прочитаем элемент. Так можно увидеть расхождение до того, как расширять замену на импорт.'),
heading('Выбираем один участок, а не «весь импорт»'),
paragraph('Представим старую функцию <code>legacyUpdateCode()</code>. Она получает название, сама делает транслитерацию и сразу вызывает <code>CIBlockElement::Update</code>. Задача не в том, чтобы объявить её плохой. Она уже может обслуживать десятки строк импорта. В первом проходе меняем только один контролируемый вызов: выбранный ID, известный инфоблок и понятное ожидаемое значение. Остальные обращения продолжают пользоваться старой функцией, пока для них не записаны такие же условия.'),
paragraph('Полезно заранее выписать карту вызова на бумаге или в задаче: форма редактирования, import-скрипт, cron, обработчик события. Мы не утверждаем, что нашли эти места в конкретном репозитории — их надо искать в своём проекте. Карта нужна, чтобы не перепутать локальный опыт с глобальной заменой. Если один вызов проходит новый путь, это не разрешение переключить остальные молча.'),
figure(
'/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg',
'Схема безопасной замены legacy-участка: снимок ID, IBLOCK_ID и старого CODE; выбор старого или нового CodeWriter для одного вызова; повторная выборка; при расхождении возврат маршрута и сохранённое исходное значение для согласованного отката.',
'Откат на схеме сначала выключает новый маршрут. Восстановление поля выполняют только по сохранённому снимку и после проверки, что его не менял другой процесс.',
),
heading('Снимок до изменения — это материал для проверки, а не журнал на словах'),
paragraph('До вызова сохраняем минимум: ID элемента, ID инфоблока, прежний <code>CODE</code>, новый расчётный код и время проверки. Эти значения можно положить в тестовый сценарий, временный защищённый журнал или запись задачи — способ зависит от правил проекта. Не стоит печатать в общий лог весь массив элемента: в нём могут оказаться поля, которые не нужны для данной операции. Нам достаточно того, что позволит сравнить один переход.'),
dataTable(
['Шаг', 'Что фиксируем', 'Что считаем успехом', 'Что делаем при расхождении'],
[
['До переключения', '<code>ID</code>, <code>IBLOCK_ID</code>, старый <code>CODE</code>', 'Элемент существует и принадлежит ожидаемому инфоблоку', 'Не запускать новый путь для этого ID'],
['Расчёт', 'Название и параметры транслитерации', 'Новый код не пустой и понятен человеку', 'Остановить сценарий, не писать заглушку'],
['Запись', 'Выбранный writer и ответ <code>Update</code>', 'API сообщил успех без скрытой повторной записи', 'Вернуть управление старому маршруту для следующих попыток'],
['Повторная выборка', 'Фактический <code>CODE</code> по тому же ID', 'Совпадает с расчётным значением', 'Сначала отключить новый маршрут, затем расследовать события и вход'],
['Откат поля', 'Сохранённый старый код и текущий код', 'Текущий код всё ещё тот, который поставил опыт', 'Не перезаписывать элемент; согласовать восстановление вручную'],
],
),
paragraph('Последняя строка важнее всего. Откат маршрута и откат данных — разные действия. Переключатель может вернуть последующие вызовы на старую функцию. Но если новый путь уже изменил поле, автоматическое восстановление безопасно только при проверке, что между снимком и откатом значение не менял импорт, редактор или обработчик. Если такой гарантии нет, честнее остановиться и сравнить состояние, чем вернуть «старый» код поверх чужой работы.'),
heading('Новый writer отвечает только за расчёт и одно поле'),
paragraph('В примере ниже старый и новый writers существуют рядом. Это не постоянная архитектура и не рекомендация держать две реализации вечно. Две функции нужны на время локальной проверки, чтобы маршрут можно было вернуть без массового удаления кода. Новый вариант перед записью проверяет инфоблок, формирует символьный код стандартной функцией Bitrix и передаёт в <code>Update</code> только поле <code>CODE</code>.'),
codeBlock([
'<?php',
'// CodeWriter.php — учебный пример для PHP 7.2 и legacy Bitrix API',
'final class CheckedCodeWriter',
'{',
' private $iblockId;',
'',
' public function __construct($iblockId)',
' {',
' $this->iblockId = (int) $iblockId;',
' }',
'',
' public function writeFromName($elementId, $name)',
' {',
" if (!CModule::IncludeModule('iblock')) {",
" throw new RuntimeException('Module iblock is unavailable');",
' }',
'',
' $before = $this->find($elementId);',
" if (!$before || (int) $before['IBLOCK_ID'] !== $this->iblockId) {",
" throw new RuntimeException('Unexpected element or iblock');",
' }',
'',
" $code = CUtil::translit(trim((string) $name), 'ru', array(",
" 'change_case' => 'L',",
" 'replace_space' => '-',",
" 'replace_other' => '-',",
" 'delete_repeat_replace' => true,",
" 'max_len' => 100,",
' ));',
'',
" if ($code === '') {",
" throw new InvalidArgumentException('CODE is empty after transliteration');",
' }',
'',
' $element = new CIBlockElement();',
" if (!$element->Update((int) $elementId, array('CODE' => $code))) {",
" throw new RuntimeException($element->LAST_ERROR ?: 'Element update failed');",
' }',
'',
" return array('before' => $before['CODE'], 'after' => $code);",
' }',
'',
' private function find($elementId)',
' {',
' $result = CIBlockElement::GetList(',
' array(),',
" array('ID' => (int) $elementId),",
' false,',
' false,',
" array('ID', 'IBLOCK_ID', 'CODE')",
' );',
'',
' return $result->Fetch();',
' }',
'}',
].join('\n')),
paragraph('Параметры <code>CUtil::translit</code> здесь выбраны для примера: нижний регистр, дефисы вместо пробелов и ограничение длины. Они не доказывают, что именно такой код нужен вашему каталогу. Например, правила SEO могут требовать другой язык, сохранение старых URL или дополнительную проверку уникальности. До подключения нового writer эти условия следует назвать отдельно. Нельзя делать вывод о совпадении поведения только по тому, что обе функции вернули непустую строку.'),
heading('Маршрут выбираем явно и на короткое время'),
paragraph('Для контролируемого опыта достаточно простого переключателя в локальной конфигурации. Он не должен быть скрыт в шаблоне или зависеть от случайного параметра URL. В примере константу задаёт окружение, которое уже контролирует проект. По умолчанию остаётся старый путь. Новый маршрут включают только для заранее выбранной проверки, а не для всех вызовов импорта.'),
codeBlock([
'<?php',
'// config.php: по умолчанию старый маршрут',
"defined('USE_CHECKED_CODE_WRITER') || define('USE_CHECKED_CODE_WRITER', false);",
'',
'function updateCodeForOneScenario($elementId, $name)',
'{',
" if (USE_CHECKED_CODE_WRITER !== true) {",
' // Существующая функция остаётся точкой возврата.',
' return legacyUpdateCode($elementId, $name);',
' }',
'',
' $writer = new CheckedCodeWriter(7);',
' return $writer->writeFromName($elementId, $name);',
'}',
'',
'// Перед опытом сравниваем ID с заранее выбранным значением.',
"if ((int) $elementId !== 451) {",
" throw new RuntimeException('The checked route is not enabled for this element');",
'}',
].join('\n')),
paragraph('Числа <code>7</code> и <code>451</code> в примере не являются настройкой для копирования. Они показывают, что контур должен назвать свой инфоблок и тестовый элемент явно. В рабочем коде значения берут из согласованной конфигурации, а не из формы. Если такого контура нет, не следует подменять его production-карточкой. Сначала подготовьте разрешённый элемент и способ увидеть его до и после вызова.'),
heading('Проверяем новую ветку и готовим откат до запуска'),
paragraph('В Bitrix <code>Update</code> вызывает события, поэтому сравнение не заканчивается на его булевом результате. После вызова снова выбираем элемент и проверяем <code>CODE</code>. Если новое значение не совпало с расчётным, первым действием будет выключить новый маршрут для следующих запросов. Затем смотрим вход, обработчики и фактическое значение. Откат поля не следует запускать автоматически из <code>catch</code>: в нём недостаточно информации о чужих изменениях.'),
orderedList([
'Составить карту старых вызовов и выбрать один разрешённый сценарий, не заявляя, что карта уже полна.',
'Снять перед опытом ID, инфоблок и прежний <code>CODE</code>; отдельно записать ожидаемую строку после транслитерации.',
'Оставить переключатель нового writer выключенным по умолчанию и подготовить понятный способ вернуть его в <code>false</code>.',
'Включить новый путь только для выбранного ID на тестовом контуре.',
'После вызова прочитать элемент заново через API и сравнить фактическое поле с ожидаемым.',
'При расхождении сразу отключить новый маршрут. Восстанавливать старый <code>CODE</code> можно только после проверки, что текущая строка принадлежит этому опыту.',
'Сохранить итог проверки рядом с задачей и лишь затем решать, нужен ли второй сценарий или доработка правил транслитерации.',
]),
heading('Чего не доказывает один удачный элемент'),
paragraph('Один элемент не проверяет все алфавиты, дубликаты, права редакторов, торговые предложения, SEO-шаблоны и работу импорта по расписанию. Он также не показывает, что в проекте нет обработчика, меняющего <code>CODE</code> после нашего вызова. Это не недостаток маленького опыта, если он честно ограничен. Для следующего сценария понадобятся новые входы, ожидаемый результат и такой же снимок до изменения.'),
paragraph('Не нужно изображать такую замену как «бесшовную миграцию». Две реализации на короткое время добавляют стоимость: их нужно держать рядом, понимать разницу и потом удалить старый путь отдельной задачей. Но эта стоимость видна. В отличие от массовой подмены, она оставляет точку возврата и позволяет остановиться после первого расхождения, а не искать причину среди сотен уже изменённых карточек.'),
heading('Итог: откат начинается до записи'),
paragraph('Локальная замена становится безопаснее, когда до вызова известны исходное значение, выбранный маршрут и критерий сравнения после записи. Сначала выключаем новый путь для следующих запросов, затем решаем вопрос данных по сохранённому снимку. Такой порядок не делает legacy-код современным сам по себе. Он даёт следующей правке то, чего обычно не хватает в старом обработчике: один контролируемый элемент, наблюдаемый результат и честный путь назад.'),
sourceList([sources.includeModule, sources.getList, sources.update, sources.beforeUpdate, sources.translit]),
].join('\n'),
};
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isDirectExecution = Boolean(process.argv[1])
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectExecution) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2018-12.mjs --print-revisions\n');
}
}