{ "index": 33, "slug": "editorial-2027-02-practice-bitrix-lessons", "title": "Bitrix legacy без догадок: как сравнить CUser и D7 перед миграцией", "excerpt": "Практический способ решить, что делать со старым вызовом Bitrix: зафиксировать наблюдаемый контракт, проверить callers и события, а затем выбрать сохранение, адаптер или замену.", "contentHtml": "
После замены старого вызова Bitrix страница может продолжить отвечать 200, а новый пользователь всё равно не появится во внешней системе. В административной форме будет успех, в логах — общий отказ, а повторная попытка иногда создаст дубль. Такая поломка возникает не из-за возраста класса. Она появляется, когда команда сравнивает две строки кода и не сравнивает поведение вокруг них.
\nРазберём один типовой сценарий: проект обновляет работу с пользователем и выбирает между CUser и D7 Bitrix\\Main\\UserTable. Безопасное решение опирается на наблюдаемый контракт. Нужно знать входные поля, владельца состояния, результат, ошибки, события, права и поведение при повторе. Пока хотя бы один из этих пунктов неизвестен, есть три честных действия: сохранить вызов, поставить адаптер или отложить замену до получения фактов.
Сначала запишите не название класса, а то, что увидел пользователь или соседняя система. Например: форма вернула успешный ответ, запись в таблице пользователей изменилась, но обработчик не отправил внешний идентификатор. Другой вариант: обновление прошло, а поле телефона исчезло. Третий: второй запуск с теми же данными создал ещё одну запись.
\nДля каждого симптома нужны пять наблюдений: точный вход, caller, результат основного вызова, изменённое состояние и побочный эффект. Caller — это конкретный код, который передаёт данные в операцию: контроллер, обработчик события, импорт или административная форма. Один и тот же метод могут вызывать несколько callers с разными правилами пустых значений и правами.
\nСформулируйте границу так: «получить email и телефон, изменить пользователя с ID 42, вернуть подтверждённый результат, запустить нужный обработчик». Если в этой фразе нет способа подтвердить результат, контракт неполон. Возвращённый ID или true ещё не доказывают, что дочерняя интеграция приняла данные.
У старого вызова обычно несколько обязанностей, даже если они скрыты в одной функции. Он может нормализовать email, очищать поле пустой строкой, проверять права, вызывать обработчики и записывать внешний ID. Миграция, которая переносит только список полей, переносит не контракт, а его видимую часть.
\nВход. Зафиксируйте отличие между отсутствующим ключом, null, пустой строкой и пробелами. Для проекта это могут быть четыре разных команды: не менять значение, очистить его, отклонить запрос или сохранить нормализованный текст.
Результат. У CUser::Update официальный контракт — логическое значение: при ошибке метод возвращает false, а текст находится в LAST_ERROR. Если проект считает успехом отправку данных в CRM, это уже второй результат, который надо проверять отдельно.
События. Обработчик может менять состояние после основной записи. В официальном описании OnAfterUserUpdate прямо сказано, что событие вызывается после попытки изменения свойств методом CUser::Update, а в параметрах доступны результат и сообщение ошибки. Поэтому при переходе на ORM нельзя автоматически считать, что локальный обработчик, его порядок и его данные останутся прежними. Их нужно найти и проверить в конкретной версии и конфигурации.
Права и версия. Доступность класса, модуля, поля и операции зависит от выполняемой установки. Документация описывает публичный API, но не знает пользовательские поля проекта, зарегистрированные обработчики и ограничения роли. Это не недостаток документации, а граница того, что можно утверждать по ссылке.
\nBitrix называет Bitrix\\Main\\UserTable аналогом старого CUser, но слово «аналог» не означает замену один к одному. Страница D7 описывает UserTable как класс для работы с пользователями, наследующий DataManager. У DataManager::update другая форма контракта: статический вызов принимает первичный ключ и массив данных и возвращает объект результата.
У старого и нового путей различаются как минимум поверхности ошибок и событий. Старый путь — объект CUser, булев результат и LAST_ERROR. ORM-путь — статический метод сущности и объект результата, который надо обработать по правилам D7. Если адаптер скрывает эту разницу, он обязан определить единый результат наружу: например, подтверждённое изменение пользователя или структурированную ошибку.
Данные тоже нельзя переносить механически. Поле PERSONAL_PHONE может быть стандартным, а UF_... — пользовательским полем со своим типом и форматом. Массив для поля-списка, строка для текста и значение для файла требуют разных проверок. Сверьте карту полей в работающей установке и зафиксируйте преобразования рядом с контрактом адаптера.
Ещё одна граница — операция, а не только запись. Если после изменения пользователя срабатывает синхронизация, уведомление или расчёт доступа, сравнивайте цепочку целиком. Новый ORM-вызов, который обновил строку, может быть технически успешным и функционально неполным.
\n| Наблюдение | Что проверить | Артефакт | Безопасный следующий шаг |
|---|---|---|---|
| Старый вызов меняет пользователя, но внешний обработчик не даёт запись | какой обработчик запускается и где формируется внешний ID | список обработчиков, лог и read-back | сохранить путь до фиксации цепочки или обернуть её |
| Пустой email или телефон меняет состояние | отличие отсутствующего ключа, null и пустой строки | набор входов и снимок полей до/после | описать mapping и явное правило очистки |
| Один путь возвращает false, другой — объект результата | как caller распознаёт ошибку и получает сообщение | контракт ответа и отрицательный тест | привести ошибки к одному интерфейсу адаптера |
| Класс или поле недоступны в окружении | версию ядра, модуль, карту полей и права роли | версия, проверка загрузки и результат чтения карты | остановить замену до устранения неизвестного |
| Повторный запуск создаёт дубль | идемпотентность и владелец внешнего идентификатора | два одинаковых запуска и сравнение ID | добавить ключ повторения или оставить старый путь |
Таблица не является доказательством причины. Она задаёт минимальный набор наблюдений. Для строки «обработчик не дал запись» нужен реальный журнал или чтение внешней системы; для строки о пустом значении — входной набор и состояние до и после. Если команда может назвать только предположение, миграция ещё не готова.
\nНиже — небольшой PHP-пример границы вокруг CUser::Update. Он намеренно принимает проектные имена email и phone, а наружу отдаёт ID только после успешного вызова. В этом варианте пустой телефон означает очистку, а пустой email отклоняется. Это решение примера, не универсальное правило Bitrix.
<?php\nfunction updateUserContact(int $userId, array $input): int\n{\n if ($userId < 1) {\n throw new InvalidArgumentException('userId must be positive');\n }\n\n $fields = [];\n if (array_key_exists('email', $input)) {\n $email = trim((string) $input['email']);\n if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {\n throw new InvalidArgumentException('email is invalid');\n }\n $fields['EMAIL'] = strtolower($email);\n }\n\n if (array_key_exists('phone', $input)) {\n $fields['PERSONAL_PHONE'] = trim((string) $input['phone']);\n }\n if ($fields === []) {\n throw new InvalidArgumentException('no fields to update');\n }\n\n $user = new CUser();\n if ($user->Update($userId, $fields) === false) {\n throw new RuntimeException($user->LAST_ERROR);\n }\n return $userId;\n}\nВ примере есть три существенные границы. array_key_exists не даёт случайно превратить отсутствие поля в очистку. Нормализация email вынесена в одно место. Ошибка старого API переводится в исключение, понятное caller-у. При этом функция не обещает, что внешний обработчик завершился: после неё нужен read-back пользователя и отдельная проверка побочного результата.
У кода есть намеренные ограничения. Он не проверяет права, не знает обязательность email в конкретной установке и не нормализует телефон по правилам бизнеса. Он также не гарантирует, что ID существует: официальное описание CUser::Update отмечает, что отсутствие пользователя с указанным ID само по себе не даёт ошибки. Если для проекта это важно, адаптер должен явно прочитать запись до обновления или проверить результат чтением после него.
Не начинайте с массовой замены. Сначала соберите characterization test — тест, который фиксирует фактическое поведение старого пути, даже если оно кажется неудобным. Для одинакового входа запишите поля до операции, ответ, ошибку, события, внешний ID и поля после операции. Такой тест не объявляет старое поведение правильным; он показывает, что именно нельзя потерять случайно.
\nЗатем подготовьте отдельный тест для D7. Официальный DataManager::update возвращает объект результата, поэтому проверьте не только отсутствие исключения, но и успешность результата, сообщение ошибки и фактическое состояние записи. Если объект результата обрабатывается иначе, чем LAST_ERROR, адаптер должен скрыть эту разницу, а не заставлять каждый caller знать обе модели.
События проверяйте наблюдением, а не названием. Найдите регистрации OnBeforeUserUpdate, OnAfterUserUpdate и проектные обработчики, проверьте условия их выполнения и порядок записи. Для нового пути отдельно установите, какие ORM-события и локальные интеграции используются. Если официальная страница описывает только старое событие, это повод провести проверку, а не обещать совместимость.
Минимальный набор сравнений выглядит так: корректный email и телефон; отсутствие email при изменении телефона; пустой телефон для очистки; невалидный email; несуществующий ID; недостаточные права; повторный запуск; отказ внешней системы после записи. Для каждого случая нужны ожидаемый результат, наблюдаемое состояние и решение о том, допустимо ли отличие.
\nОставить — разумно, если задача не требует нового API, callers немного, а побочные эффекты ещё не описаны. Это не отказ от улучшений. Команда фиксирует границу и избегает изменения нескольких неизвестных одновременно.
\nОбернуть — полезно, когда callers несколько или старый API проникает в разные слои. Адаптер принимает канонический вход проекта, применяет mapping, единообразно возвращает успех или ошибку и оставляет место для read-back. Его задача — уменьшить число мест, где живут знания о Bitrix, а не спрятать незавершённую миграцию.
\nЗаменить — можно после сравнения поведения и осознанного решения об отличиях. Для каждого намеренного отличия нужна запись: что меняется, почему это безопасно, кто владеет обновлённым состоянием и как откатить релиз. Если команда не может воспроизвести старый результат, замену лучше отложить.
\nЭта схема подходит для выбора границы вокруг legacy-вызова. Она не заменяет аудит безопасности, ревизию ролей и прав, миграцию схемы или проверку производительности. Если операция работает в импорте, транзакции или очереди, отдельно исследуйте повтор, частичный отказ и порядок подтверждения внешней системы.
\nУчебный пример нельзя копировать без сверки версии, обязательных полей, формата пользовательских полей, прав и зарегистрированных обработчиков. Нормализация email и разрешение очистки телефона — проектные решения. Для файла, списка или множественного пользовательского поля понадобятся другие типы входа и отдельные тесты.
\nUnit-тест на чистом объекте не доказывает поведение реального модуля и событий. Полная копия production для первого шага тоже не обязательна: достаточно ограничить тестовую среду, указать, что она не покрывает, и не выдавать её результат за доказательство совместимости. Критерий готовности простой: другой разработчик повторяет вход, видит тот же результат, понимает намеренные отличия и знает, как вернуть старый путь.
\nLAST_ERROR и параметров обновления.CUser::Update, его результата и сообщения ошибки.