{ "index": 32, "slug": "editorial-2027-02-mechanism-bitrix-lessons", "title": "Bitrix API и версия: имя метода не обещает одинаковый контракт", "excerpt": "Как проверить установленную поверхность Bitrix API, сопоставить поля legacy и D7 и остановить миграцию, если среда не подтверждает совместимость.", "contentHtml": "
Ошибка при миграции Bitrix часто появляется не в строке вызова. Документация нашла класс, автозагрузка сработала, операция вернула успешный результат — а на другой установке модуль не подключён, пользовательское поле имеет другой тип или обработчик изменяет данные после записи. Через несколько часов форма теряет значение, импорт создаёт дубль, а откат уже требует ручного восстановления.
\nГлавный вывод: совместимость нельзя вывести из имени класса или номера версии. Её нужно доказать для конкретной операции: модуль подключён в нужном bootstrap, метод действительно доступен, поля имеют согласованный mapping, а результат выдерживает повторное чтение и отрицательные случаи. Если хотя бы один слой неизвестен, миграция ещё не готова.
\nBitrix документирует два поколения поверхности для одной предметной области. Класс CUser относится к старому ядру, а Bitrix\\Main\\UserTable — к D7 и ORM. В документации прямо указано, что UserTable является аналогом CUser. Это полезная подсказка для поиска, но не обещание побитной совместимости: разные методы принимают разные аргументы, возвращают разные типы результата и по-разному сообщают об ошибках.
Начинать нужно с модуля. Для пользовательской области это обычно модуль main, а не iblock. Старый вызов CModule::IncludeModule('main') проверяет, установлен ли модуль, и подключает его файл include.php. D7-вариант \\Bitrix\\Main\\Loader::includeModule('main') подключает модуль по имени и возвращает true или false; в документации для метода также перечислено исключение LoaderException. Ни один из этих ответов не доказывает, что нужная операция и её поля совпадают с ожиданиями приложения.
Следующий слой — поверхность операции. Наличие класса доказывает только возможность разрешить имя. Для миграции обновления пользователя нужно отдельно подтвердить CUser::Update или выбранный D7-вызов, а затем зафиксировать набор полей, права и наблюдаемый результат. Проверка через class_exists без проверки операции создаёт ложное чувство совместимости.
Сравнивать нужно не названия классов, а один сценарий от входа до чтения. Например, пусть импорт обновляет email и телефон пользователя по стабильному локальному ID. В legacy-поверхности поля называются EMAIL и PERSONAL_PHONE. Документация CUser описывает их как строковые поля. Но проект может добавлять пользовательские поля, нормализовать телефон, ограничивать смену email или подключать обработчики события. Эти правила находятся за пределами общей сигнатуры.
| Слой | CUser | D7 UserTable | Доказательство в проекте |
|---|---|---|---|
| Подключение | CModule::IncludeModule('main') | Loader::includeModule('main') | Успешный результат в том же bootstrap, где выполняется операция |
| Операция | CUser::Update($id, $fields) | ORM-метод выбранной модели | Точный вызов, аргументы, тип результата и обработка ошибки |
| Поля | EMAIL, PERSONAL_PHONE, UF_* | Поля из карты сущности и их типы | Таблица соответствий для value, missing и clear |
| Ошибка | false и LAST_ERROR | Результат ORM и исключения | Тест отказа прав, неверного поля и недоступной записи |
| Результат | Булево подтверждение операции | Результат ORM-операции | Повторное чтение и контроль публичной выборки |
У этой таблицы есть важная оговорка: последний столбец не заполняется документацией автоматически. Его заполняет команда на своей установке. В частности, официальная страница CUser::Update сообщает, что метод возвращает true при успехе и false при ошибке, а текст ошибки находится в LAST_ERROR. Та же страница отдельно говорит: если пользователя с указанным ID нет, ошибки не возникает. Значит, одного булева результата недостаточно — отсутствие записи нужно проверять до или после изменения.
Диагностика должна быть безопасной: она выводит имена модулей и операций, но не email, телефоны, токены и значения пользовательских полей. Запускайте её в том же окружении и через тот же bootstrap, который использует рабочий код. Иначе результат описывает диагностический скрипт, а не реальный путь запроса.
\n<?php\nuse Bitrix\\Main\\Loader;\n\nfunction inspectUserSurface(): array\n{\n $report = [\n 'module' => 'main',\n 'moduleLoaded' => false,\n 'legacyUpdate' => false,\n 'd7UserTable' => false,\n ];\n\n try {\n $report['moduleLoaded'] = Loader::includeModule('main');\n } catch (\\Throwable $exception) {\n return $report + ['reason' => 'module-load-exception'];\n }\n\n if (!$report['moduleLoaded']) {\n return $report + ['reason' => 'module-not-loaded'];\n }\n\n $report['legacyUpdate'] = class_exists('CUser')\n && method_exists('CUser', 'Update');\n $report['d7UserTable'] = class_exists('\\Bitrix\\Main\\UserTable')\n && method_exists('\\Bitrix\\Main\\UserTable', 'getMap');\n\n return $report;\n}\nЭтот фрагмент отвечает только на вопрос о доступности слоёв. Он не выбирает D7 автоматически и не пишет данные. Если moduleLoaded равен false, возвращается причина остановки. Если модуль загружен, но обе поверхности имеют значение false, нужно проверять bootstrap и версию ядра, а не подменять имя класса.
Версия тоже входит в отчёт, но не заменяет поведенческую проверку. В официальной документации CUser и UserTable есть собственные границы версий: CUser описан с версии 3.0.6, UserTable наследует DataManager, а для старых версий модуля Main документация указывает другой класс-родитель. Эти сведения помогают понять, какой код вообще может встретиться в установке. Они не отвечают, как локальные обработчики и пользовательские поля поведут себя в конкретном проекте.
\nАдаптер должен принимать один формат данных независимо от выбранной поверхности. У каждого поля полезно различать три состояния: missing — поле не участвует в обновлении, value — записывается новое значение, clear — значение очищается явно. Если передавать пустую строку вместо отдельного состояния, код теряет намерение вызывающей стороны и начинает зависеть от поведения конкретного API.
function toLegacyFields(array $user): array\n{\n $fields = [];\n\n if ($user['email']['state'] === 'value') {\n $fields['EMAIL'] = trim($user['email']['value']);\n } elseif ($user['email']['state'] === 'clear') {\n $fields['EMAIL'] = '';\n }\n\n if ($user['phone']['state'] === 'value') {\n $fields['PERSONAL_PHONE'] = trim($user['phone']['value']);\n } elseif ($user['phone']['state'] === 'clear') {\n $fields['PERSONAL_PHONE'] = '';\n }\n\n return $fields;\n}\nПреобразование ещё не является обновлением. Перед записью адаптер проверяет, что вход содержит допустимый идентификатор, а после записи перечитывает запись по тому же ключу. Для CUser это может быть GetByID или контролируемый запрос, для D7 — выбранная ORM-операция чтения. Сравнивать нужно канонический результат: нормализованный телефон, фактический email, отсутствие поля и состояние, которое видит дальнейшая бизнес-логика.
Успех записи и видимость — разные утверждения. Обработчик может изменить поле, индекс или статус; кеш может показать старое значение; фильтр каталога может исключить запись. Поэтому characterization-тест должен проверять как минимум исходное чтение, запись, повторное чтение и запрос потребителя. Если запись должна быть идемпотентной, второй запуск с тем же внешним ключом обязан обновить найденную сущность, а не создать новую.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Класс найден, а вызов падает на части серверов | main не установлен или не подключён в реальном bootstrap | Записать результат Loader::includeModule('main') без пользовательских данных | Остановить операцию с причиной либо исправить подключение |
| Метод существует, но поле отклоняется | Тип, имя или форма пользовательского поля различаются | Сверить карту полей, входной тип и состояние clear | Оставить преобразование в адаптере и добавить отрицательный тест |
| Update вернул успех, но пользователя нет в чтении | ID не существует, чтение использует другой фильтр или сработал обработчик | Проверить существование ID, перечитать запись и выполнить запрос потребителя | Разделить отсутствие записи, факт изменения и публичную видимость |
| После перехода значения стали другими | Legacy и ORM по-разному нормализуют дату, телефон или пользовательское поле | Прогнать одинаковый набор входов и сравнить канонический read-back | Зафиксировать mapping либо оставить прежнюю поверхность |
| Повторный импорт создал дубль | Перед созданием не используется стабильный внешний ключ | Повторить вход и сравнить количество записей и ключи | Сначала искать сущность, затем обновлять или создавать |
| На тесте всё работает, в рабочей среде — нет | Различаются права, обработчики, модули, данные или bootstrap | Сравнить обезличенный manifest и прогнать smoke-набор в целевой среде | Ограничить поддержку средами с подтверждённым контрактом |
main в реальном пути выполнения. Отдельно сохранить результат и исключение, не записывая значения полей.missing, value, clear и внешний ключ.Официальная документация описывает публичный API, но не локальную конфигурацию. Она не знает обработчики в /local, права, состав пользовательских полей, кеши, настройки сайтов и фактический bootstrap. Даже одинаковая версия ядра не гарантирует одинаковый набор модулей и данных.
Проверка доступности метода не является тестом миграции. method_exists не выявляет семантику события, права записи, ограничения базы и работу фильтра потребителя. Manifest нужен для ранней остановки и сравнения сред, а не как единственное доказательство.
Нельзя переносить mapping из примера в проект без проверки. Имена EMAIL и PERSONAL_PHONE относятся к стандартной модели пользователя, но у проекта могут быть собственные UF_*-поля, другой источник истины или обязательная нормализация. Секреты и персональные значения в диагностический вывод не входят.
Иногда безопасный результат — не мигрировать. Если legacy-вызов покрыт тестами, а новый API не даёт измеримого выигрыша, адаптер может сохранить старую поверхность. Если модуль, операция или mapping не подтверждены, остановка с понятной причиной дешевле частичной записи и ручного восстановления.
\nПереход можно считать готовым только после повторяемого набора доказательств: модуль подключается в каждой обязательной среде; точная операция доступна; поля имеют записанный mapping; value, missing и clear дают ожидаемый read-back; ошибка, исключение и отказ прав не превращаются в успех; несуществующий ID обрабатывается явно; повторный запуск не создаёт дубль; запрос потребителя видит правильное состояние. Если не закрыт хотя бы один пункт, готова проверка неизвестности, но не замена API.
\ntrue/false и исключение LoaderException.true/false, LAST_ERROR и поведение при несуществующем ID.