From 3f706c29e985ffe207ceccb516086ee038fa4faf Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 13:05:24 +0300 Subject: [PATCH] editorial: revise articles 029-034 to 10/10 --- editorial/agent-rewrites/029.json | 2 +- editorial/agent-rewrites/030.json | 4 ++-- editorial/agent-rewrites/031.json | 8 +++++++- editorial/agent-rewrites/032.json | 2 +- editorial/agent-rewrites/033.json | 6 +++--- editorial/agent-rewrites/034.json | 4 ++-- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/editorial/agent-rewrites/029.json b/editorial/agent-rewrites/029.json index 65b2e52..e7496b3 100644 --- a/editorial/agent-rewrites/029.json +++ b/editorial/agent-rewrites/029.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-03-mechanism-d-lessons", "title": "D и C API: как закрыть небезопасную границу буфера", "excerpt": "Как проверить pointer, length и lifetime до вызова C, изолировать @trusted и не принять атрибут @safe за доказательство всей системы.", - "contentHtml": "

C-функция получает указатель и длину буфера. Указатель не несёт длину сам. Если длина пришла из заголовка пакета, а буфер содержит меньше байт, C прочитает за его пределами. Симптомы плавают: редкое падение, испорченный результат, ошибка только на одной нагрузке или незаметное повреждение памяти. Цена ошибки — потеря данных, аварийное завершение процесса и уязвимость, которую трудно связать с исходным запросом.

\n

Тезис: безопасная граница D/C строится не атрибутом на имени функции. Нужны проверенный диапазон, ясный владелец памяти, известное время жизни и маленький участок, где компилятор не может проверить внешний контракт. В D этот участок обычно помечают @trusted. Наружу он должен отдавать интерфейс, который можно вызывать из @safe кода.

\n

Как возникает ошибка

\n

Рассмотрим условный C API. Он принимает адрес, число байт и возвращает код. C доверяет вызывающему. Он не знает capacity исходного массива и не может проверить, что length соответствует выделенной памяти. D тоже не восстановит этот факт из одного raw pointer.

\n
extern(C) int decode_packet(const(ubyte)* data, size_t length);\n\n// Учебный пример: здесь нет реальной библиотеки и production-данных.\nint call_decoder(const(ubyte)[] input) {\n    return decode_packet(input.ptr, input.length);\n}
\n

Сам вызов выглядит убедительно, но контракт неполон. Нужно знать, читает ли функция ровно length байт, ожидает ли завершающий ноль, сохраняет ли указатель после возврата и кто освобождает возвращённую память. Если C сохраняет адрес, передача временного массива становится ошибкой lifetime. Если формат требует заголовок фиксированного размера, проверка только верхней границы не подтверждает корректность пакета.

\n

Что именно обещают атрибуты

\n

@safe ограничивает набор операций, которые могут привести к повреждению памяти. Это обещание относится к проверяемому D-коду и его интерфейсу. Оно не проверяет реализацию неизвестной C-библиотеки, её ABI, размер структуры или смысл поля.

\n

@system разрешает низкоуровневые операции. Такой код может выполнять арифметику указателей и другие действия, которые требуют ручного доказательства. @trusted сохраняет эти возможности внутри тела, но разрешает вызов из безопасного кода. Поэтому @trusted — не знак «компилятор проверил». Это ручное обещание автора. Чем больше тело trusted-функции, тем больше непроверенных предположений в одном месте.

\n

Узкий wrapper должен принимать сильное представление входа. Slice D связывает адрес и длину. Но slice не знает, соблюдает ли внешний API null termination, не освобождает ли C память во время вызова и не сохраняет ли адрес. Эти условия остаются частью контракта библиотеки.

\n
\"Матрица
Безопасный путь начинается после проверки длины и времени жизни. Цвет атрибута не заменяет проверку внешнего контракта.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Падение на длинном пакетеЗаявленная длина больше capacityСравнить длину с размером slice до вызоваОстановить вызов и вернуть ошибку формата
Успешный вызов, затем сбойC сохранила указатель на временный буферПрочитать ownership и проверить escape адресаЗапретить сохранение или передать копию
Ошибка только на C-строкеНет null terminatorПроверить завершающий байт и длину строкиДобавить terminator либо вызвать byte API
Работает в одном targetНе совпали ABI, alignment или layoutСверить header, calling convention и размеры типовЗафиксировать ABI-тест для каждого target
Функция помечена @trusted, но меняет всёВ wrapper спрятали парсинг и бизнес-логикуПосчитать обязанности и raw операции в телеОставить только boundary check и вызов C
\n

Минимальная проверка перед переходом в C

\n

Проверка должна отвечать на разные вопросы отдельно. Сначала адрес принадлежит живому объекту. Затем длина целая, неотрицательная и не выходит за capacity. Потом проверяется формат: минимальный размер, terminator, допустимый enum или версия. Только после этого вызывается C-функция. Один boolean с именем isValid скрывает слишком много условий и плохо объясняет отказ.

\n

Ниже учебный checker. Он не анализирует D-память и не вызывает библиотеку. Он показывает отрицательный путь: неизвестный владелец и длина за пределами capacity не превращаются в safe-interface. Числа нужны только для иллюстрации правил.

\n
struct BoundaryResult {\n    string kind;\n    string action;\n}\n\nBoundaryResult checkBoundary(size_t capacity, size_t length, bool pointerChecked) {\n    if (!pointerChecked) {\n        return BoundaryResult(`system`, `проверить указатель и владельца`);\n    }\n    if (length > capacity) {\n        return BoundaryResult(`reject`, `остановить вызов до C`);\n    }\n    return BoundaryResult(`safe-interface`, `передать проверенный slice`);\n}\n\n// Учебные входы: 16/8 -> safe-interface; 16/24 -> reject.\n// 16/8 без проверки указателя -> system.
\n

Реальный wrapper должен учитывать переполнение при вычислении диапазона, нулевую длину, alignment, null pointer, правила потока и возвращаемый код ошибки. Если функция принимает offset + length, нельзя сначала сложить значения без проверки переполнения. Сравнение через length <= capacity - offset безопаснее, если сначала доказано, что offset <= capacity.

\n

Ownership и lifetime нельзя угадывать

\n

Параметр scope помогает выразить, что функция не должна сохранять ссылку на переданный объект. Но атрибут не переписывает документацию C. Если библиотека кладёт адрес в глобальное состояние или использует его в другом потоке, wrapper должен запретить такой сценарий или передать отдельную копию с явным владельцем.

\n

Возвращаемый raw pointer создаёт обратную задачу. До преобразования в D slice нужно знать размер объекта и способ освобождения. Если размер неизвестен, безопасного представления нет. Если C требует специальную функцию освобождения, вызов free из D неверен. Копирование в D-буфер часто увеличивает стоимость, но даёт ясный lifetime. Это инженерный trade-off, а не деталь синтаксиса.

\n

Не объединяйте в @trusted чтение файла, разбор формата, бизнес-правила и FFI. Тогда тест на один указатель не покрывает остальные решения. Пусть trusted-тело делает одну вещь: проверяет инвариант, формирует вызов и возвращает результат с понятным ownership.

\n

Действия по порядку

\n
  1. Выписать прототип C-функции и смысл каждого указателя, длины, возвращаемого адреса и кода ошибки.
  2. Зафиксировать ownership: кто создаёт, кто читает, кто сохраняет и кто освобождает каждый буфер.
  3. Проверить ABI: размер и layout структур, alignment, порядок байтов, calling convention и target-платформы.
  4. Сформировать минимальный D wrapper со slice или копией и вынести raw операции в короткое @trusted-тело.
  5. Добавить тесты на пустой вход, длину 0, точную capacity, capacity+1, null, overflow, неверный terminator и повторный вызов.
  6. Прогнать компиляцию и runtime-проверки тем же компилятором, ABI и target, которые использует продукт; отдельно проверить sanitizer или эквивалентный инструмент.
  7. Оставить публичную функцию @safe только после доказательства интерфейса. Неясную или непроверяемую ветку пометить @system и запретить случайный вызов.
\n

Ограничения и отрицательный путь

\n

Memory safety не означает переносимость, корректный порядок байтов, отсутствие логической ошибки или правильный ABI. @safe не делает C-библиотеку безопасной. @trusted не создаёт доказательство автоматически. Даже верная проверка capacity не замечает неверный enum, неправильную версию структуры или гонку за буфер.

\n

Если C API сохраняет входной адрес, простой вызов с borrowed slice нельзя считать готовым. Если невозможно установить размер возвращённого объекта, нужно копирование, дополнительный API или отказ от интеграции. Если target изменяет layout, один зелёный тест на локальной машине ничего не доказывает. Если неясно, кто освобождает память, не передавайте владение через границу.

\n

Код checker выше не даёт production-результата. Он не видит aliasing, реальный lifetime, alignment и calling convention. Его можно использовать только как учебную форму таблицы решений. Доказательство создают контракт библиотеки, тесты на реальном ABI и наблюдаемое поведение сборки продукта.

\n

Проверяемый критерий готовности

\n

Граница готова, если другой инженер может по документации и коду ответить на пять вопросов: какой диапазон читается, кто владеет памятью, может ли адрес пережить вызов, какой ABI используется и как сообщается ошибка. Для каждого вопроса есть тест или явное стоп-условие. Невалидная длина не достигает C-вызова. Возвращаемый буфер освобождается тем способом, который требует библиотека.

\n

Дополнительная проверка должна проходить на всех target-платформах продукта. Успешный тест недостаточен: нужен тест, который намеренно нарушает длину, lifetime и формат и получает контролируемый отказ. Только после этого @safe на внешней функции описывает проверенный интерфейс, а не надежду на реализацию C.

\n

Проверяемые источники

\n" + "contentHtml": "

C-функция получает указатель и длину буфера. Указатель не несёт длину сам. Если длина пришла из заголовка пакета, а буфер содержит меньше байт, C прочитает за его пределами. Симптомы плавают: редкое падение, испорченный результат, ошибка только на одной нагрузке или незаметное повреждение памяти. Цена ошибки — потеря данных, аварийное завершение процесса и уязвимость, которую трудно связать с исходным запросом.

\n

Тезис: безопасная граница D/C строится не атрибутом на имени функции. Нужны проверенный диапазон, ясный владелец памяти, известное время жизни и маленький участок, где компилятор не может проверить внешний контракт. В D этот участок обычно помечают @trusted. Наружу он должен отдавать интерфейс, который можно вызывать из @safe кода.

\n

Как возникает ошибка

\n

Рассмотрим условный C API. Он принимает адрес, число байт и возвращает код. C доверяет вызывающему. Он не знает capacity исходного массива и не может проверить, что length соответствует выделенной памяти. D тоже не восстановит этот факт из одного raw pointer.

\n
extern(C) @system int decode_packet(\n    const(ubyte)* data,\n    size_t length\n);\n\nint call_decoder(scope const(ubyte)[] input) @trusted {\n    enum headerSize = 4;\n    if (input.length < headerSize)\n        return -1; // формат отклонён до перехода в C\n\n    // Доказать отдельно: C читает только length байт\n    // и не сохраняет input.ptr после возврата.\n    return decode_packet(input.ptr, input.length);\n}
\n

Сам вызов выглядит убедительно, но контракт неполон. Нужно знать, читает ли функция ровно length байт, ожидает ли завершающий ноль, сохраняет ли указатель после возврата и кто освобождает возвращённую память. Если C сохраняет адрес, передача временного массива становится ошибкой lifetime. Если формат требует заголовок фиксированного размера, проверка только верхней границы не подтверждает корректность пакета.

\n

Что именно обещают атрибуты

\n

@safe ограничивает набор операций, которые могут привести к повреждению памяти. Это обещание относится к проверяемому D-коду и его интерфейсу. Оно не проверяет реализацию неизвестной C-библиотеки, её ABI, размер структуры или смысл поля.

\n

@system разрешает низкоуровневые операции. Такой код может выполнять арифметику указателей и другие действия, которые требуют ручного доказательства. @trusted сохраняет эти возможности внутри тела, но разрешает вызов из безопасного кода. Поэтому @trusted — не знак «компилятор проверил». Это ручное обещание автора. Чем больше тело trusted-функции, тем больше непроверенных предположений в одном месте.

\n

Узкий wrapper должен принимать сильное представление входа. Slice D связывает адрес и длину. Но slice не знает, соблюдает ли внешний API null termination, не освобождает ли C память во время вызова и не сохраняет ли адрес. Эти условия остаются частью контракта библиотеки.

Практический критерий для такого wrapper простой: если функция объявлена @trusted, рядом должны быть названы все условия, при которых вызов C определён. В этом примере их два: диапазон ограничен переданной длиной, а указатель используется только до возврата. Не переносите эти условия в комментарий «на всякий случай»: закрепите их тестом или ссылкой на header. Если доказать условие нельзя, граница остаётся @system.

\n
\"Матрица
Безопасный путь начинается после проверки длины и времени жизни. Цвет атрибута не заменяет проверку внешнего контракта.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Падение на длинном пакетеЗаявленная длина больше capacityСравнить длину с размером slice до вызоваОстановить вызов и вернуть ошибку формата
Успешный вызов, затем сбойC сохранила указатель на временный буферПрочитать ownership и проверить escape адресаЗапретить сохранение или передать копию
Ошибка только на C-строкеНет null terminatorПроверить завершающий байт и длину строкиДобавить terminator либо вызвать byte API
Работает в одном targetНе совпали ABI, alignment или layoutСверить header, calling convention и размеры типовЗафиксировать ABI-тест для каждого target
Функция помечена @trusted, но меняет всёВ wrapper спрятали парсинг и бизнес-логикуПосчитать обязанности и raw операции в телеОставить только boundary check и вызов C
\n

Минимальная проверка перед переходом в C

\n

Проверка должна отвечать на разные вопросы отдельно. Сначала адрес принадлежит живому объекту. Затем длина целая, неотрицательная и не выходит за capacity. Потом проверяется формат: минимальный размер, terminator, допустимый enum или версия. Только после этого вызывается C-функция. Один boolean с именем isValid скрывает слишком много условий и плохо объясняет отказ.

\n

Ниже учебный checker. Он не анализирует D-память и не вызывает библиотеку. Он показывает отрицательный путь: неизвестный владелец и длина за пределами capacity не превращаются в safe-interface. Числа нужны только для иллюстрации правил.

\n
struct BoundaryResult {\n    string kind;\n    string action;\n}\n\nBoundaryResult checkBoundary(size_t capacity, size_t length, bool pointerChecked) {\n    if (!pointerChecked) {\n        return BoundaryResult(`system`, `проверить указатель и владельца`);\n    }\n    if (length > capacity) {\n        return BoundaryResult(`reject`, `остановить вызов до C`);\n    }\n    return BoundaryResult(`safe-interface`, `передать проверенный slice`);\n}\n\n// Учебные входы: 16/8 -> safe-interface; 16/24 -> reject.\n// 16/8 без проверки указателя -> system.
\n

Реальный wrapper должен учитывать переполнение при вычислении диапазона, нулевую длину, alignment, null pointer, правила потока и возвращаемый код ошибки. Если функция принимает offset + length, нельзя сначала сложить значения без проверки переполнения. Сравнение через length <= capacity - offset безопаснее, если сначала доказано, что offset <= capacity.

\n

Ownership и lifetime нельзя угадывать

\n

Параметр scope помогает выразить, что функция не должна сохранять ссылку на переданный объект. Но атрибут не переписывает документацию C. Если библиотека кладёт адрес в глобальное состояние или использует его в другом потоке, wrapper должен запретить такой сценарий или передать отдельную копию с явным владельцем.

\n

Возвращаемый raw pointer создаёт обратную задачу. До преобразования в D slice нужно знать размер объекта и способ освобождения. Если размер неизвестен, безопасного представления нет. Если C требует специальную функцию освобождения, вызов free из D неверен. Копирование в D-буфер часто увеличивает стоимость, но даёт ясный lifetime. Это инженерный trade-off, а не деталь синтаксиса.

\n

Не объединяйте в @trusted чтение файла, разбор формата, бизнес-правила и FFI. Тогда тест на один указатель не покрывает остальные решения. Пусть trusted-тело делает одну вещь: проверяет инвариант, формирует вызов и возвращает результат с понятным ownership.

\n

Действия по порядку

\n
  1. Выписать прототип C-функции и смысл каждого указателя, длины, возвращаемого адреса и кода ошибки.
  2. Зафиксировать ownership: кто создаёт, кто читает, кто сохраняет и кто освобождает каждый буфер.
  3. Проверить ABI: размер и layout структур, alignment, порядок байтов, calling convention и target-платформы.
  4. Сформировать минимальный D wrapper со slice или копией и вынести raw операции в короткое @trusted-тело.
  5. Добавить тесты на пустой вход, длину 0, точную capacity, capacity+1, null, overflow, неверный terminator и повторный вызов.
  6. Прогнать компиляцию и runtime-проверки тем же компилятором, ABI и target, которые использует продукт; отдельно проверить sanitizer или эквивалентный инструмент.
  7. Оставить публичную функцию @safe только после доказательства интерфейса. Неясную или непроверяемую ветку пометить @system и запретить случайный вызов.
\n

Ограничения и отрицательный путь

\n

Memory safety не означает переносимость, корректный порядок байтов, отсутствие логической ошибки или правильный ABI. @safe не делает C-библиотеку безопасной. @trusted не создаёт доказательство автоматически. Даже верная проверка capacity не замечает неверный enum, неправильную версию структуры или гонку за буфер.

\n

Если C API сохраняет входной адрес, простой вызов с borrowed slice нельзя считать готовым. Если невозможно установить размер возвращённого объекта, нужно копирование, дополнительный API или отказ от интеграции. Если target изменяет layout, один зелёный тест на локальной машине ничего не доказывает. Если неясно, кто освобождает память, не передавайте владение через границу.

\n

Код checker выше не даёт production-результата. Он не видит aliasing, реальный lifetime, alignment и calling convention. Его можно использовать только как учебную форму таблицы решений. Доказательство создают контракт библиотеки, тесты на реальном ABI и наблюдаемое поведение сборки продукта.

\n

Проверяемый критерий готовности

\n

Граница готова, если другой инженер может по документации и коду ответить на пять вопросов: какой диапазон читается, кто владеет памятью, может ли адрес пережить вызов, какой ABI используется и как сообщается ошибка. Для каждого вопроса есть тест или явное стоп-условие. Невалидная длина не достигает C-вызова. Возвращаемый буфер освобождается тем способом, который требует библиотека.

\n

Дополнительная проверка должна проходить на всех target-платформах продукта. Успешный тест недостаточен: нужен тест, который намеренно нарушает длину, lifetime и формат и получает контролируемый отказ. Только после этого @safe на внешней функции описывает проверенный интерфейс, а не надежду на реализацию C.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/030.json b/editorial/agent-rewrites/030.json index 181a170..ad25468 100644 --- a/editorial/agent-rewrites/030.json +++ b/editorial/agent-rewrites/030.json @@ -2,6 +2,6 @@ "index": 30, "slug": "editorial-2027-03-practice-d-lessons", "title": "D для прикладной утилиты: как проверить, нужен ли новый язык", - "excerpt": "Перед переходом на D измерьте workload, найдите границу с native-кодом и сравните стоимость toolchain с реальным выигрышем. Учебный фильтр и критерий готовности помогают принять решение, включая отказ от миграции.", - "contentHtml": "

Утилита запускается медленно, занимает больше памяти, чем ожидалось, или требует вызова C-библиотеки. Команда сразу предлагает переписать её на D: язык компилируется в native binary, умеет работать с C ABI и даёт контроль над памятью. Но симптом ещё не показывает причину. Задержку может создавать сеть, формат файла, лишние копии или неверная граница API. Цена ошибочного выбора — новый компилятор, сборочный pipeline, обучение и месяцы поддержки без исправления узкого места.

\n

Тезис простой: D стоит проверять не по списку свойств языка, а по контракту задачи. Сначала зафиксируйте workload и бюджет, затем найдите участок, который действительно изменится при смене языка. После этого сравните D с текущим инструментом по измеримому эффекту и полной стоимости доставки. Если доказательства не складываются, решение остаться на текущем языке будет корректным результатом.

\n

Сначала отделите симптом от причины

\n

Фраза «нужна производительность» не задаёт задачи. Для CLI важны время запуска, время обработки одного входа, пиковая память и размер бинарника. Для фонового процесса важны throughput, steady-state latency и поведение после нескольких часов работы. Для сервиса добавляются конкуренция, timeout и наблюдаемость. Для вызова C-библиотеки важны layout структуры, calling convention, ownership указателей и код ошибки.

\n

Запишите один сценарий, а не среднее впечатление. Например: «утилита читает 2 ГБ логов, должна обработать файл менее чем за 20 секунд, запускается на Linux x86_64 и arm64, а парсер отдаёт данные в C-библиотеку». В таком описании уже видны единица нагрузки, предел времени, targets и native boundary. Без них benchmark легко превращается в сравнение несопоставимых программ.

\n
\"Карта
Выбор начинается с workload и ограничений. D появляется как проверяемый вариант только после описания границы задачи.
\n

Механизм решения

\n

У решения есть четыре связанные части. Workload показывает, сколько данных и операций проходит через код. Бюджет latency задаёт допустимую цену одной операции. Native boundary показывает, нужен ли прямой доступ к C, системному вызову или нативному формату. Target matrix показывает, сколько раз придётся собрать, протестировать и доставить бинарник.

\n

D может быть сильным кандидатом, когда горячий участок вычисляет данные локально, нужен native deployment или уже есть C ABI. Но это только основание для эксперимента. У D остаются стоимость компилятора и зависимостей, различия runtime, диагностика бинарника, упаковка под несколько архитектур и время команды. Нативный бинарник не отменяет сетевую задержку и не делает внешний API безопасным.

\n

Контрактные проверки полезны внутри функции. Precondition проверяет входной инвариант, postcondition — свойство результата. Они не заменяют проверку пользовательского файла, обработку ошибки и тесты. Проверка должна принадлежать тому уровню, который владеет условием: parser проверяет формат, доменный код — смысл, wrapper — указатель, длину и время жизни.

\n

Матрица перед сменой языка

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Долгий запускИмпорт модулей, чтение конфигурации, сетьПрофиль cold start с отключённой сетьюИсправить инициализацию; язык менять только при доказанном CPU-узком месте
Медленная обработка файлаКопии строк, декодирование, неверный алгоритмПрофиль CPU и аллокаций на одном входеСравнить алгоритм и парный прототип D
Рост RSSДолгоживущие ссылки, кэш, фрагментацияСнять профиль памяти по этапам batchУкоротить lifetime; не отключать GC по одному графику
Падение в C-вызовеНеверная длина, layout или ownershipСверить header, размер, offset и код возвратаИзолировать wrapper и остановить вызов при несовпадении
Сложная доставкаНесколько архитектур и ручная упаковкаСобрать чистые артефакты для каждого targetСравнить цену toolchain с выигрышем runtime
\n

Учебный фильтр требований

\n

Следующая функция не измеряет скорость и не выбирает язык автоматически. Она превращает карточку задачи в явные условия. Числа учебные: throughput 12 000 и бюджет 20 мс нельзя переносить на другое железо. В реальном решении их заменяют измерениями одного workload.

\n
function decideD(input) {\n  const throughput = Number(input.throughput);\n  const latencyBudgetMs = Number(input.latencyBudgetMs);\n  const targets = Number(input.deploymentTargets);\n  if (!Number.isFinite(throughput) || throughput <= 0) return { decision: 'reject', reason: 'нет измеримой нагрузки' };\n  if (latencyBudgetMs <= 0) return { decision: 'reject', reason: 'нет бюджета задержки' };\n  if (targets > 2 && !input.nativeBoundary) return { decision: 'compare', reason: 'сначала сравнить toolchain' };\n  if (input.nativeBoundary && throughput > 10000) return { decision: 'prototype-d', reason: 'есть основание для парного прототипа' };\n  return { decision: 'keep-current-tool', reason: 'смена языка не обоснована' };\n}\n\nconsole.log(decideD({ throughput: 12000, latencyBudgetMs: 20, nativeBoundary: true, deploymentTargets: 1 }));\n// Учебный результат: { decision: 'prototype-d', ... }
\n

Положительная ветка означает только «собрать прототип». Она не означает «переписать продукт». Ветка keep-current-tool нужна намеренно: если нагрузка мала, границы с native-кодом нет, а текущий стек уже покрывает доставку, новый язык увеличит риск без доказанной пользы. Ветка compare останавливает преждевременный выбор при широкой матрице targets.

\n

Как проверять нативную границу

\n

Указатель и длина образуют один контракт. Сам указатель не сообщает, сколько байт можно читать. Wrapper должен получить буфер, проверить его владельца и диапазон, а затем передать в C только проверенный slice или пару pointer/length. Если библиотека сохраняет адрес после возврата, обычного временного буфера недостаточно: нужен согласованный lifetime или копия.

\n

Структуру тоже нельзя считать совместимой по имени полей. Сверьте размер, offsets, alignment, порядок байтов и calling convention. Отдельно зафиксируйте значения кода ошибки. Частично заполненный output не равен успешному результату. Сначала проверьте код возврата, затем версию и layout, потом отдайте значение доменному коду.

\n

Действия по порядку

\n
  1. Записать единицу нагрузки, размер входа, бюджет задержки, пик памяти, targets и ожидаемый результат.
  2. Повторить симптом на одном входе и снять профиль CPU, аллокаций, памяти или cold start. Не менять язык до появления измеримого узкого места.
  3. Сравнить текущий инструмент и D по алгоритму, библиотекам, сборке, отладке, размеру артефакта и времени поддержки.
  4. Описать native boundary: типы, размер, ownership, lifetime, порядок байтов, код ошибки и вариант отказа.
  5. Собрать маленький парный прототип с одинаковым входом, выходом и методикой замера. Проверить положительный и отрицательный путь.
  6. Принять решение по заранее заданному критерию. Сохранить измерения и стоимость поддержки рядом с кодом прототипа.
\n

Ограничения

\n

Фильтр не заменяет profiler, benchmark и review ABI. Его пороги вымышлены и нужны только для формы проверки. Даже хороший benchmark не переносит результат на другую архитектуру, версию компилятора, размер данных или режим нагрузки. Нативная сборка не гарантирует меньшую память. Контракт не исправляет неверную бизнес-логику.

\n

Оценка должна учитывать отрицательный путь. Если wrapper не может доказать длину, lifetime или layout, вызов нужно остановить. Если D выигрывает только в искусственном микротесте, но требует отдельной упаковки и дежурства, выигрыш не доказан. Если текущий язык после устранения лишних копий укладывается в бюджет, миграция не нужна.

\n

Проверяемый критерий готовности

\n

Решение готово, когда для одного и того же workload есть повторяемые замеры текущего инструмента и прототипа D, описаны targets и native boundary, а также измерена цена сборки и поддержки. Для каждого результата указаны вход, версия toolchain, архитектура, число повторов и критерий успеха. Вызов C проходит только после проверки размера, lifetime и кода ошибки. Команда может объяснить не только почему D быстрее, но и почему это преимущество покрывает стоимость доставки.

\n

Проверяемые источники

" + "excerpt": "Перед переходом на D измерьте workload, найдите границу с native-кодом и сравните стоимость toolchain с реальным выигрышем. Воспроизводимый benchmark и критерий готовности помогают принять решение, включая отказ от миграции.", + "contentHtml": "

Утилита запускается медленно, занимает больше памяти, чем ожидалось, или требует вызова C-библиотеки. Команда сразу предлагает переписать её на D: язык компилируется в native binary, статически типизирован и умеет работать с C ABI. Но набор свойств ещё не объясняет симптом. Задержку может создавать сеть, формат файла, лишние копии или неверная граница API. Цена ошибочного перехода — новый компилятор, сборочный pipeline, обучение и месяцы поддержки без исправления узкого места.

\n

Разберём лабораторный сценарий: CLI читает поток логов, считает строки и выделяет записи с ERROR. Для него задан бюджет — обработать один и тот же вход не дольше 20 секунд, а пиковая память должна остаться ниже 512 МБ. Числа и программа учебные; они не являются результатом замера чужой системы. Задача статьи — дать процедуру, по которой можно получить собственные данные и решить, оправдан ли прототип на D.

\n

Сначала отделите симптом от причины

\n

Фраза «нужна производительность» не задаёт инженерной задачи. Для CLI нужны время холодного старта, время обработки фиксированного входа, throughput (объём данных в секунду), пиковая память и размер артефакта. Для фонового процесса добавляются длительность работы, задержка отдельных операций и поведение после нескольких часов. Для вызова C-библиотеки важны типы, layout структуры, calling convention, ownership указателей и код ошибки.

\n

Запишите сценарий до эксперимента. Например: «утилита читает 2 ГБ логов через stdin, считает число строк и ошибок, должна завершиться менее чем за 20 секунд на Linux x86_64, результат — два числа в stdout». В описании есть единица нагрузки, граница времени, target и наблюдаемый output. Без этих условий benchmark превращается в сравнение разных программ на разных входах.

\n
\"Карта
Смена языка появляется только после фиксации нагрузки, ограничений и одинаковых условий сравнения. Отсутствие измеримого узкого места — достаточная причина остановиться.
\n

Что именно добавляет D

\n

Официальная спецификация описывает D как системный язык, который компилируется в native-код, статически типизирован и поддерживает автоматическое и ручное управление памятью. Это делает D разумным кандидатом для локальной CPU-нагрузки, самостоятельного бинарника или уже существующей C-интеграции. Но «кандидат» не означает «готовая замена»: библиотеки, toolchain, диагностика, сборка под архитектуры и сопровождение входят в стоимость решения.

\n

У D есть несколько механизмов, которые влияют на эксперимент. @nogc запрещает функциям прямо или косвенно выполнять операции с GC-кучей, но не превращает всю программу в код без аллокаций. @safe ограничивает часть операций, способных повредить память; @trusted оставляет ручную ответственность внутри маленькой проверенной границы. Контракты in и out выражают предусловия и постусловия, однако их запуск может зависеть от настроек компилятора. Поэтому каждый механизм нужно включить в проверку, а не использовать как рекламное обещание.

\n

При C-вызове объявление extern(C) задаёт согласованную C-связь и последовательность вызова. Оно не угадывает неверный прототип, размер буфера, время жизни указателя или способ освобождения памяти. Даже если функция вызывается, это ещё не доказательство корректности всей границы.

\n

Матрица решения до прототипа

\n
Симптом → гипотеза → проверка → решение
СимптомГипотезаПроверкаРешение
Долгий запускИмпорт, конфигурация или сеть, а не CPUПрофиль cold start с отключённой сетью и пустым рабочим наборомИсправить инициализацию; язык менять только при доказанном CPU-узком месте
Медленно читается файлАлгоритм, декодирование или лишняя копияСравнить CPU и аллокации на одном входеСначала убрать копии; затем сделать парный прототип
Растёт RSSКэш, удерживаемые ссылки или буферизация всего файлаЗамерить память по этапам и размеру входаПерейти на потоковую обработку; не обещать эффект от native binary
Падает C-вызовПрототип, layout, длина или ownership не совпадаютСверить header, размеры, offsets, lifetime и код возвратаИзолировать FFI-wrapper и остановить вызов при неизвестном условии
Сложно доставлятьНесколько targets, зависимостей и ручных шаговСобрать чистые артефакты и описать pipelineСопоставить стоимость toolchain с измеренным выигрышем
\n

Таблица задаёт порядок расследования, а не автоматический выбор. Если профиль показывает, что 80% времени занимает чтение сети, переход на D не меняет главную причину. Если потоковая обработка уже укладывается в бюджет, языковая миграция не нужна. Если bottleneck находится в вызове C, полезнее сначала проверить контракт границы, а не переписывать окружающий код.

\n

Сделайте benchmark воспроизводимым

\n

Сравнивайте два исполняемых файла, которым подаётся один байтовый вход и которые выдают один логический результат. Зафиксируйте checksum файла, версию исходников, компилятор, флаги, архитектуру, число повторов и способ измерения. Не смешивайте cold start с прогретым процессом. Не сравнивайте debug-сборку текущего инструмента с release-сборкой D.

\n

Для учебного CLI создайте D-проект через DUB — официальный build и package manager экосистемы D — и замените содержимое source/app.d таким кодом:

\n
import std.stdio : stdin, writefln;\nimport std.string : indexOf;\n\nvoid main()\n{\n    size_t lines;\n    size_t errors;\n\n    foreach (line; stdin.byLine())\n    {\n        ++lines;\n        if (line.indexOf(\"ERROR\") >= 0)\n            ++errors;\n    }\n\n    writefln(\"%s %s\", lines, errors);\n}
\n

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

\n
dub init log-counter --type=application\ncd log-counter\ndub test\ndub build --build=release\nsha256sum ../benchmark/input/logs.txt\n/usr/bin/time -f '%e sec %M KB' ./log-counter < ../benchmark/input/logs.txt > /dev/null
\n

Последняя команда использует GNU time и подходит для Linux. На macOS синтаксис системного time отличается, поэтому зафиксируйте другой измеритель, например hyperfine для времени и отдельный инструмент для памяти. Важно не название команды, а одинаковая методика для D и текущей реализации.

\n

Сравните не только секунды

\n

Один удачный прогон не доказывает превосходство. Выполните минимум пять повторов после одинаковой подготовки окружения и сохраните минимум, медиану и разброс. Повторите тест для маленького, среднего и предельного входа. Если результат меняется вместе с размером данных, запишите сложность и проверьте, не измеряется ли случайно файловый кеш.

\n

Проверяйте корректность раньше скорости. Для каждого входа сравните stdout, код возврата и поведение на повреждённой строке. Затем сравните время, peak RSS, размер бинарника и время сборки с чистого checkout. Если D быстрее, но выдаёт другое число ошибок или не умеет объяснить невалидный UTF-8, это не выигрыш, а несовместимый результат.

\n
Минимальный протокол сравнения
ИзмерениеЧто фиксироватьКритерий принятия
Корректностьstdout, stderr, exit code на тех же входахРезультаты совпадают или различие явно согласовано
Runtimeмедиана и диапазон пяти повторовМедиана ниже бюджета и выигрыш не исчезает на предельном входе
Памятьpeak RSS и зависимость от размера файлаПик не превышает лимит; рост объясним выбранным алгоритмом
Доставкавремя clean build, размер, targets, зависимостиКоманда может повторить сборку без ручного локального состояния
Поддержкасложность отладки, тестов, обновлений и FFIЕсть владелец и понятная процедура изменения
\n

Критерий должен быть задан до просмотра результатов. Например: «D принимаем в следующий этап, если на трёх размерах входа сохраняется корректность, медиана быстрее текущей реализации минимум на 25%, peak RSS не выходит за лимит, а clean build и cross-compilation укладываются в согласованный pipeline». Порог 25% — проектное решение, а не свойство D. Если хотя бы одно обязательное условие не выполнено, прототип возвращается на разбор или закрывается.

\n

Как проверить границу с C

\n

Native-интеграция может быть главным аргументом в пользу D, но она же добавляет риск. Указатель и длина образуют пару: адрес сам по себе не сообщает, сколько байт разрешено читать. До вызова wrapper должен проверить диапазон, нулевой адрес, alignment и время жизни буфера. Если C сохраняет адрес после возврата, временный slice нельзя считать достаточным контрактом.

\n

Структуру нельзя объявлять совместимой по совпадению имён полей. Сверьте размер, offsets, alignment, порядок байтов и calling convention с теми header и compiler flags, которыми собрана библиотека. Если API возвращает указатель, назовите allocator и парную функцию освобождения. Вызов общего free не становится правильным только потому, что он компилируется.

\n

В D выделите короткую границу: она проверяет вход, вызывает C, сначала смотрит return code и только затем читает output. Ошибка должна превращаться в результат, который верхний слой умеет обработать. @trusted полезен как табличка ответственности, но не заменяет проверку ABI и документации библиотеки.

\n

Действия по порядку

\n
  1. Записать один workload: вход, output, бюджет времени, лимит памяти, targets и допустимые ошибки.
  2. Повторить симптом на фиксированном входе и измерить текущую реализацию. Сначала найти CPU, I/O, память или границу API.
  3. Зафиксировать одинаковую методику: checksum, release-флаги, компилятор, архитектура, прогрев и число повторов.
  4. Собрать маленький D-прототип без лишних библиотек и сравнить его корректность с текущим инструментом.
  5. Измерить медиану, разброс, peak RSS, clean build, размер бинарника и цену сборки под каждый target.
  6. Если есть C, проверить прототип, layout, указатели, lifetime, allocator и код ошибки до расширения wrapper.
  7. Сопоставить измеренный выигрыш с ежемесячной стоимостью поддержки: обновлениями, отладкой, наймом и доставкой.
  8. Принять решение по заранее заданному критерию: мигрировать, оставить узкий компонент на D или остаться на текущем языке.
\n

Ограничения применимости

\n

Учебный CLI не моделирует сервис с конкурентными запросами, задержкой сети, большим числом файлов, интерактивным UX или длительным жизненным циклом процесса. Результат на Linux x86_64 нельзя переносить на arm64, Windows, другой компилятор, другую версию runtime или другой размер входа без повторной проверки.

\n

Пороги 20 секунд, 512 МБ и 25% вымышлены для формы эксперимента. Они не обещают, что D будет быстрее или экономнее. @nogc контролирует GC-аллокации конкретной функции, но не отменяет системный allocator, I/O и сторонние библиотеки. @safe не делает C-код безопасным, а extern(C) не проверяет ownership. Контракты не заменяют тесты на повреждённых входах.

\n

Остановите переход, если workload не воспроизводится, критерий успеха появился после замеров, результат отличается по смыслу, C-граница не описана или clean build требует ручного состояния. Отказ от нового языка — не провал эксперимента. Это экономически полезный результат, если он избавляет команду от миграции, которая не исправляет исходную причину.

\n

Проверяемый критерий готовности

\n

Решение готово к следующему этапу, когда другой инженер получает фиксированный вход, команды сборки, версии toolchain и скрипт измерения; может повторить проверку на текущей и D-реализации; видит одинаковую корректность, время, память и стоимость доставки; понимает границы ABI и знает стоп-условия. Если этих данных нет, готов только вопрос, а не обоснование перехода.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/031.json b/editorial/agent-rewrites/031.json index 62fdff7..8bb0e90 100644 --- a/editorial/agent-rewrites/031.json +++ b/editorial/agent-rewrites/031.json @@ -1 +1,7 @@ -{"index":31,"slug":"editorial-2027-02-field-bitrix-lessons","title":"Миграция пользовательского поля Bitrix: сохранить смысл, а не только значение","excerpt":"Как перенести поле пользователя из legacy API Bitrix в новый контракт без тихой потери данных: mapping, пустые значения, read-back, повторный запуск и критерий готовности.","contentHtml":"

Симптом появляется после успешного вызова Bitrix API. Пользователь получил новый ID, но телефон остался пустым. Email сохранился в другом регистре. Повторный запуск создал вторую связь с внешней системой. В логах есть только true от CUser::Update, поэтому команда не видит, на каком шаге исчезло значение.

Цена ошибки выше, чем одна неверная строка. По телефону не находится пользователь. Уведомление уходит на старый адрес. Импорт нельзя безопасно повторить. Если исходное значение уже перезаписано, восстановление зависит от резервной копии или ручного поиска.

Тезис: миграция поля готова не тогда, когда API принял запрос. Она готова, когда команда может показать mapping, прочитать сохранённый смысл обратно и повторить тот же вход без дубля или неожиданной очистки.

Механизм: поле проходит четыре границы

Legacy-код обычно передаёт массив с именами Bitrix: PERSONAL_PHONE, EMAIL, XML_ID. Новый код хочет получить объект вроде { phone, email, externalId }. Это не простая замена имён. Каждое поле имеет формат, правило пустого значения и обратное представление.

Первая граница — вход. Отсутствующий PERSONAL_PHONE может означать «не менять телефон», а пустая строка — «очистить телефон». Если привести оба состояния к null, адаптер потеряет команду пользователя.

Вторая граница — нормализация. Для телефона допустимы пробелы и разные формы записи, но правило должно быть конкретным. Для email можно привести регистр к нижнему, если это разрешает контракт приложения. Нельзя применять одну функцию ко всем значениям: XML_ID, комментарий и парольный хэш имеют разные правила.

Третья граница — запись. Официальный метод CUser::Update принимает ID и массив полей. Успех означает, что метод не сообщил об ошибке. Он не доказывает, что downstream-обработчик, индекс или внешний обмен увидели ожидаемый смысл.

Четвёртая граница — чтение. После записи нужно получить ту же запись способом, которым её читает приложение. Сравнивайте не только ID и флаг успеха. Сравнивайте поля, внешний идентификатор, состояние пустоты и, если это важно, время изменения.

\"Цикл
Успешная запись занимает середину цикла. Доказательство результата появляется после повторного чтения и проверки повторяемости.

Mapping должен описывать смысл

Начните с небольшой таблицы. В ней видны не только старое и новое имя, но также пустое состояние, источник и проверка результата.

ПолеLegacy-входКаноническое значениеПроверка
ТелефонPERSONAL_PHONE, строка с пробеламиphone, нормализованная строкаread-back и формат
EmailEMAIL, исходный регистрemail, регистр по правилу контрактавалидность и точное чтение
СвязьXML_IDexternalIdодна запись при retry
Не переданключ отсутствуетunchangedстарое значение не меняется
Очищенключ есть, значение пустоеclearдва разных теста

Если пришёл неизвестный ключ, не угадывайте его назначение по похожему имени. Остановите преобразование и сообщите, какое поле не входит в контракт. Если email не проходит правило приложения, не превращайте его в пустую строку. Верните ошибку до записи.

Учебный пример: нормализация до вызова Bitrix

Следующая функция — изолированный учебный пример. Она не подключается к Bitrix и не обещает результат реального окружения. Её задача — показать, где сохраняется различие между отсутствующим полем и явной очисткой.

function toCanonical(input) {\n  const result = {};\n\n  if (Object.hasOwn(input, 'PERSONAL_PHONE')) {\n    if (input.PERSONAL_PHONE === '') {\n      result.phone = { action: 'clear' };\n    } else {\n      const phone = String(input.PERSONAL_PHONE).trim();\n      if (!phone) throw new Error('phone-invalid');\n      result.phone = { action: 'set', value: phone };\n    }\n  }\n\n  if (Object.hasOwn(input, 'EMAIL')) {\n    const email = String(input.EMAIL).trim().toLowerCase();\n    if (!email.includes('@')) throw new Error('email-invalid');\n    result.email = { action: 'set', value: email };\n  }\n\n  if (Object.hasOwn(input, 'XML_ID')) {\n    result.externalId = { action: 'set', value: String(input.XML_ID) };\n  }\n\n  return result;\n}\n\nconst value = toCanonical({\n  PERSONAL_PHONE: ' +7 900 000-00-00 ',\n  EMAIL: 'User@Example.TEST',\n  XML_ID: 'crm-17',\n});\n\n// phone: set '+7 900 000-00-00'\n// email: set 'user@example.test'\n// externalId: set 'crm-17'

Для учебного входа функция удаляет внешние пробелы, приводит email к нижнему регистру и оставляет связь как строку. Это выбранные правила примера, а не универсальная политика Bitrix. В реальном проекте их нужно заменить правилами доменного контракта.

Адаптер записи строится после такой проверки. Для unchanged поле не добавляется. Для clear передаётся явное значение очистки, согласованное с API и проектом.

$fields = [];\n\nif ($canonical['phone']['action'] === 'set') {\n    $fields['PERSONAL_PHONE'] = $canonical['phone']['value'];\n}\n\nif ($canonical['phone']['action'] === 'clear') {\n    $fields['PERSONAL_PHONE'] = '';\n}\n\nif ($canonical['email']['action'] === 'set') {\n    $fields['EMAIL'] = $canonical['email']['value'];\n}\n\n$user = new CUser;\nif (!$user->Update($userId, $fields)) {\n    throw new RuntimeException($user->LAST_ERROR);\n}

Код показывает только форму вызова. Он не проверяет права, события, пользовательские поля и транзакцию. Перед использованием нужно подтвердить, что модуль и нужная поверхность API доступны в конкретной установке. Для D7 и legacy-пути нельзя считать классы взаимозаменяемыми без отдельного mapping.

Симптомы и проверки

СимптомПричинаПроверкаДействие
API вернул успех, поле пустоеНеверное имя, формат или обработчик изменил значениеСравнить mapping, raw-вход и read-backИсправить границу и повторить на одной записи
Повторный запуск создаёт дубльНет стабильного внешнего ключа или retry неидемпотентенПовторить вход по XML_ID и проверить количество записейЗакрепить ключ операции и запретить создание без него
Поле исчезает при частичном обновленииMissing и clear сведены к одному значениюПроверить отсутствие ключа и пустую строку отдельноПередавать очистку только явной командой
Один сервер принимает вызов, другой — нетМодуль не подключён или поверхности API различаютсяПроверить IncludeModule, класс и метод в целевой средеОстановить миграцию и выбрать совместимый adapter
Email стал недействительнымНормализация скрыла ошибку или правило шире контрактаПроверить валидатор до записи и значение после чтенияВернуть ошибку, не записывать пустой заменитель

Порядок действий

  1. Выберите одно поле и один стабильный идентификатор записи. Не начинайте с массового запуска.
  2. Снимите фактический вход: ключи, типы, пустые значения, внешний ID и источник данных.
  3. Составьте mapping table. Отдельно назовите состояния missing, clear, invalid и unchanged.
  4. Проверьте доступность модуля и метода в целевой версии Bitrix. Если условие не выполнено, остановите изменение.
  5. Прогоните нормализацию на обезличенных значениях. Проверьте пробелы, регистр, пустоту, неверный формат и неизвестный ключ.
  6. Запишите одну запись через адаптер. Сохраните безопасный идентификатор операции, не помещая персональные данные в лог.
  7. Сделайте read-back: сравните поля, пустые состояния, внешний ID и побочные признаки, важные для приложения.
  8. Повторите тот же вход. Убедитесь, что запись не дублируется, значение не меняется без правила, а операция остаётся обратимой.
  9. Только после этого расширяйте выборку. Любое расхождение сначала классифицируйте как mapping, формат, права, событие или версию API.

Ограничения

Документация Bitrix описывает публичный API, но не знает локальные события, права, пользовательские поля, обработчики и SQL-ограничения проекта. Успешный вызов в одной установке не доказывает совместимость другой. Версия ядра помогает сузить поиск, но не заменяет проверку фактической поверхности.

Read-back может отличаться от входа по допустимому правилу сервера. Телефон может получить другой формат. Время изменения зависит от среды. Такие расхождения нужно разделить на ожидаемую нормализацию и потерю смысла. Нельзя объявлять их одинаковыми только потому, что совпал ID.

Учебный JavaScript-пример не является production-валидатором. Проверка email.includes('@') намеренно упрощена. PHP-функция filter_var может быть частью технической проверки email, но она не определяет бизнес-правила, разрешённые домены и требуемое поведение пустого поля.

Если read-back невозможен, миграция не получает доказательство сохранения. В этом случае не расширяйте объём. Сначала добавьте безопасный способ проверить запись или оставьте адаптер за границей массового запуска. Отрицательное решение лучше тихой потери данных.

Проверяемый критерий готовности

Одна миграция поля готова, если другой инженер может восстановить вход и получить тот же результат: mapping объясняет каждое переданное поле, missing и clear различаются, API-поверхность подтверждена, read-back совпадает по смысловым значениям, а повторный запуск не создаёт дубль.

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

Проверяемые источники

"} +{ + "index": 31, + "slug": "editorial-2027-02-field-bitrix-lessons", + "title": "Миграция данных пользователя Bitrix: сохранить смысл, а не только значение", + "excerpt": "Как перенести данные пользователя из legacy-контракта Bitrix без тихой потери: различить стандартные и UF-поля, сохранить пустые состояния, сделать read-back и безопасно повторить операцию.", + "contentHtml": "

Сбой обнаруживается после внешне успешной миграции. Bitrix вернул true, но телефон оказался пустым, email изменил регистр, а повторный запуск создал вторую связь. Такая ошибка стоит дороже одной неверной строки: поиск пользователя перестаёт работать, уведомление уходит не туда, а восстановление исходного значения требует отдельного источника данных.

Первое действие — перестать считать ответ CUser::Update доказательством результата. Метод сообщает, что вызов прошёл без собственной ошибки, но не подтверждает смысл сохранённых данных, работу локальных обработчиков или результат последующего чтения. Миграция готова только тогда, когда mapping понятен, запись прочитана обратно, а повтор того же входа не меняет результат неожиданно.

Что именно переносится

В Bitrix нужно разделить стандартные поля пользователя и пользовательские поля. К первым относятся, например, EMAIL и PERSONAL_PHONE. Пользовательские поля обычно имеют имена UF_*, а их конкретные коды, типы и множественные значения задаёт сама установка. Поэтому имя из одной базы нельзя переносить в другую только по совпадению текста.

Есть и третье различие — действие над полем. Отсутствующий ключ означает «не менять», явное пустое значение может означать «очистить», а некорректное значение должно остановить операцию до записи. Если adapter превращает все три состояния в null, он теряет часть команды источника и может стереть данные при частичном обновлении.

\"Цикл
Запись — только середина цикла. Доказательство сохранённого смысла появляется после read-back и проверки повторного запуска.

Mapping должен описывать контракт

Сначала составьте карту полей для одной целевой записи. В ней зафиксируйте не только имена, но и тип, действие при пустом значении, правило нормализации и способ проверки. Имя UF_LEGACY_ID ниже — пример строкового пользовательского поля; в реальном проекте его нужно заменить кодом, который существует в целевой установке.

Пример карты переноса одной записи
ИсточникЦель BitrixПравилоДоказательство
phonePERSONAL_PHONEобрезать внешние пробелы; пустое значение не скрыватьread-back и проверка формата
emailEMAILприменить только правило доменного контрактаточное чтение и отрицательный тест
legacyIdUF_LEGACY_IDпроверить тип и уникальность в этой установкепоиск по стабильному ключу
ключ отсутствуетполе не включать в updateстарое значение сохраняетсятест partial update
ключ есть, значение пустоеявная очистка по контрактуне смешивать с missingотдельный тест clear

Такая карта отвечает на вопрос, который обычно теряется в legacy-коде: что должен сделать adapter, если поле пришло не в идеальной форме. Для списка, файла или множественного пользовательского поля нельзя автоматически использовать правило обычной строки. Сначала прочитайте тип поля в целевой установке и зафиксируйте ожидаемое представление.

Нормализация не должна скрывать ошибку

Нормализуйте данные до вызова API и возвращайте не только значение, но и действие. В учебном JavaScript-примере ниже отсутствующий ключ вообще не попадает в результат, пустая строка становится явным clear, а очевидно неверный email отклоняется. Это граница примера, а не универсальный валидатор почты.

function toCanonical(input) { const result = {}; if (Object.hasOwn(input, 'phone')) { if (input.phone === '') { result.phone = { action: 'clear' }; } else if (typeof input.phone !== 'string') { throw new Error('phone-type'); } else { const phone = input.phone.trim(); if (!phone) throw new Error('phone-invalid'); result.phone = { action: 'set', value: phone }; } } if (Object.hasOwn(input, 'email')) { if (input.email === '') { result.email = { action: 'clear' }; } else if (typeof input.email !== 'string') { throw new Error('email-type'); } else { const email = input.email.trim().toLowerCase(); if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(email)) throw new Error('email-invalid'); result.email = { action: 'set', value: email }; } } if (Object.hasOwn(input, 'legacyId')) { if (input.legacyId === '') { result.legacyId = { action: 'clear' }; } else { const legacyId = String(input.legacyId).trim(); if (!legacyId) throw new Error('legacy-id-invalid'); result.legacyId = { action: 'set', value: legacyId }; } } return result; } const canonical = toCanonical({ phone: ' +7 900 000-00-00 ', email: 'User@Example.TEST', legacyId: 'crm-17' }); // phone: set '+7 900 000-00-00' // email: set 'user@example.test' // legacyId: set 'crm-17'

Пример намеренно не угадывает формат телефона и не объявляет проверку email полной. Нижний регистр email может быть правилом вашего домена, но не универсальной гарантией для всех систем. Если бизнес-контракт требует E.164, разрешённый список доменов или сохранение исходного регистра, эти правила должны появиться в отдельной функции и тестах. Нельзя превращать ошибку в пустое значение ради того, чтобы запись прошла.

Запись и read-back — разные операции

После нормализации соберите только те поля, для которых есть действие. Для пользовательского поля подставьте фактический UF_*-код и представление, подтверждённое в целевой установке. Метод CUser::Update принимает ID и массив полей; при ошибке он возвращает false и оставляет текст в LAST_ERROR.

$fields = []; if (($canonical['phone']['action'] ?? null) === 'set') { $fields['PERSONAL_PHONE'] = $canonical['phone']['value']; } if (($canonical['phone']['action'] ?? null) === 'clear') { $fields['PERSONAL_PHONE'] = ''; } if (($canonical['email']['action'] ?? null) === 'set') { $fields['EMAIL'] = $canonical['email']['value']; } if (($canonical['legacyId']['action'] ?? null) === 'set') { $fields['UF_LEGACY_ID'] = $canonical['legacyId']['value']; } $user = new CUser; if (!$user->Update($userId, $fields)) { throw new RuntimeException($user->LAST_ERROR); } // Следующий шаг: получить эту же запись приложенческим способом // и сравнить поля, пустые состояния и внешний ключ.

Вызов не должен быть последней строкой мигратора. Сохраните безопасный идентификатор операции и перечень изменённых полей, но не кладите телефон, email и другие персональные данные в обычный лог. Затем прочитайте запись тем же слоем, которым её использует приложение. Сравнивайте смысловые значения, а не только ID и флаг успеха.

У официального описания есть важная граница: если пользователя с указанным ID нет, ошибка может не возникнуть. Поэтому read-back должен подтвердить существование целевой записи. Для пользовательского поля также нужно убедиться, что код поля, тип и права относятся именно к целевой установке.

Симптомы отделяют гипотезы

Диагностика после одного запуска
СимптомГипотезаПроверкаРешение
API вернул успех, значение не читаетсяневерное имя, тип или локальный обработчиксравнить mapping, вход и read-backостановить batch и проверить одну запись
Пропуск поля очистил старое значениеmissing сведён к clearповторить partial update без ключане включать отсутствующий ключ в $fields
Повтор создал дубльоперация создаёт запись вместо поиска по ключунайти запись по стабильному legacyIdразделить upsert, update и создание
В одном окружении работает, в другом нетразный набор модулей, UF-кодов или правпроверить поверхность API и схему поля в каждой средене расширять запуск до устранения расхождения
Email стал пустым после ошибкивалидатор заменил invalid на fallbackпроверить отрицательный тест до записивернуть ошибку и оставить исходное значение

Таблица нужна не для классификации задним числом. Она задаёт следующий fetch или тест. Если read-back показывает другой телефон, это ещё не доказывает потерю: сервер мог применить согласованную нормализацию. Если поле исчезло, ищите точку расхождения между исходным ключом, mapping, обработчиком и способом чтения.

Безопасный порядок миграции

  1. Выберите одну запись. Зафиксируйте стабильный ID источника и целевой ID, если он уже известен. Не начинайте с массового запуска.
  2. Снимите вход. Сохраните имена ключей, типы и факт пустоты; персональные значения в диагностике обезличьте.
  3. Проверьте схему. Разделите стандартные поля и UF_*, подтвердите тип, множественность, обязательность и права.
  4. Составьте mapping. Для каждого ключа запишите set, clear, unchanged или invalid.
  5. Прогоните нормализацию отдельно. Проверьте пробелы, регистр, неверный тип, неизвестное поле и пустое значение.
  6. Запишите одну запись. Передайте только разрешённые изменения. При false сохраните LAST_ERROR и прекратите расширение.
  7. Сделайте read-back. Сравните значения, пустые состояния, внешний ключ и существование записи через прикладной способ чтения.
  8. Повторите тот же вход. Убедитесь, что результат не меняется, новая связь не дублируется, а операция остаётся наблюдаемой.
  9. Расширяйте выборку порциями. На каждом шаге проверяйте количество обновлённых записей, ошибки и расхождения между входом и read-back.

Retry и откат требуют отдельного решения

CUser::Update обновляет запись по ID, но сам по себе не решает задачу поиска записи по ключу источника и не делает весь процесс миграции идемпотентным. Сначала найдите существующую связь по стабильному ключу, затем решите, допустим ли update. Создание новой записи без проверки ключа — отдельная операция с отдельными правилами и риском дубля.

Перед batch-запуском определите, что делать при частичном успехе. Остановка на первой ошибке проще для контроля, очередь с повтором лучше для большого объёма, а обратная миграция возможна только при сохранённом старом значении. Не называйте процесс обратимым, если вы не храните исходный снимок и не проверили восстановление на тестовой записи.

Ограничения применимости

Документация Bitrix описывает публичный метод и базовые поля, но не знает локальные обработчики событий, права, пользовательские поля, индексы, настройки валидации и формат обмена конкретного проекта. Коды UF_* и их типы нельзя переносить между установками без проверки схемы.

Нормализация телефона и email в статье учебная. Нижний регистр email может быть правилом вашего домена, но не универсальной гарантией для всех систем. Проверка через регулярное выражение не заменяет бизнес-ограничения, подтверждение адреса или проверку уникальности.

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

Проверяемый критерий готовности

Одна запись считается перенесённой, когда другой инженер может восстановить вход и объяснить каждое изменение: mapping различает стандартное поле и UF_*, missing не очищает значение, invalid останавливает запись, read-back подтверждает смысл, а повтор не создаёт дубль.

Для расширения на batch нужны те же доказательства на отрицательных ветках: неизвестный код поля останавливает запуск, недоступная схема не приводит к частичному обновлению, ошибка API не маскируется пустым fallback, а расхождение read-back классифицируется как ожидаемая нормализация или потеря данных. Если хотя бы один сигнал отсутствует, следующий шаг — собрать его, а не объявлять миграцию успешной.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/032.json b/editorial/agent-rewrites/032.json index 9eb72c0..a622e5d 100644 --- a/editorial/agent-rewrites/032.json +++ b/editorial/agent-rewrites/032.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-02-mechanism-bitrix-lessons", "title": "Bitrix API и версия: имя метода не обещает одинаковый контракт", "excerpt": "Как проверить установленную поверхность Bitrix API, сопоставить поля legacy и D7 и остановить миграцию, если среда не подтверждает совместимость.", - "contentHtml": "

Ошибка начинается без падения. В документации найден метод, класс подключён, вызов возвращает ID или объект. Но на другой установке модуль не загружен, поле называется иначе, пустая строка означает другое состояние, а обработчик события меняет результат. Симптом появляется позже: форма теряет значение, импорт создаёт дубль, редкая операция падает после обновления. Цена ошибки — не только исправление PHP. Команда получает повреждённые данные, повторную загрузку и миграцию, которую уже нельзя безопасно повторить.

\n

Тезис: совместимость Bitrix проверяют не по имени класса и не по номеру версии. Нужна граница из трёх фактов: модуль подключён, нужная поверхность API доступна, а вход и выход совпадают с контрактом проекта. Только после этого выбирают legacy-вызов, D7 или адаптер между ними.

\n

Механизм ошибки

\n

У старого и нового API может быть одна предметная область, но разные правила. Документация Bitrix указывает CUser и Bitrix\\Main\\UserTable как поверхности работы с пользователями. Это не утверждение, что вызовы взаимозаменяемы в конкретном проекте. У них могут различаться способ выборки, типы полей, ошибки, события и требования к версии ядра.

\n

Сначала проверяют загрузчик. CModule::IncludeModule('iblock') или \\Bitrix\\Main\\Loader::includeModule('iblock') отвечает на вопрос «модуль установлен и подключён?». Ответ true ещё не подтверждает нужный метод и mapping полей. Ответ false закрывает путь к следующему слою: нельзя маскировать отсутствие модуля вызовом класса, который случайно доступен через другой bootstrap.

\n

Затем проверяют поверхность. Нужны точные операции: найти запись, создать, обновить, получить идентификатор и разобрать ошибку. Проверка только существования класса слишком слаба. Она не отвечает, принимает ли метод нужные поля и сохранит ли различие между отсутствующим полем и явной очисткой.

\n

Последний слой — смысл результата. Успешный ID доказывает, что операция вернула идентификатор. Он не доказывает, что значение записалось в нужный формат, обработчики отработали ожидаемо, а публичная выборка увидит запись. Поэтому контракт нужно проверять через повторное чтение и отрицательные случаи.

\n
Матрица проверки Bitrix API: модуль, поверхность, поля и семантика результата
Имя метода — только первый слой. Надёжная граница проходит через подключённый модуль, доступную операцию, mapping полей и проверенный смысл результата.
\n

Учебный пример: один контракт для двух поверхностей

\n

Ниже — учебная функция. Она не обращается к реальному серверу и не обещает поддержку перечисленных версий. Входной manifest нужно получить в конкретной среде безопасной диагностикой. Функция только разделяет отсутствие модуля, legacy-поверхность и D7-поверхность.

\n
function chooseUserSurface(manifest) {\n    if (!manifest.moduleLoaded) {\n        return { kind: 'stop', reason: 'module-not-loaded' };\n    }\n\n    if (manifest.methods.includes('CUser::Update')) {\n        return { kind: 'legacy', operation: 'update-user' };\n    }\n\n    if (manifest.methods.includes('Bitrix\\\\Main\\\\UserTable')) {\n        return { kind: 'd7', operation: 'update-user' };\n    }\n\n    return { kind: 'stop', reason: 'operation-not-confirmed' };\n}\n\n// Учебные данные. Это не результат работы production-установки.\nconst surface = chooseUserSurface({\n    moduleLoaded: true,\n    methods: ['CUser::Update'],\n});\n\nconsole.log(surface);\n// { kind: 'legacy', operation: 'update-user' }
\n

Важен порядок условий. Сначала функция останавливается при отсутствии модуля. Затем она выбирает подтверждённую операцию, а не любой похожий класс. Если обе поверхности доступны, выбор должен задавать адаптер проекта: например, установленная версия, зафиксированный набор полей и проверенная матрица регрессии. Автоматически предпочитать D7 только потому, что он новее, нельзя.

\n

Адаптер должен принимать канонический вход. Для пользователя это может быть объект с id, email, phone и явными состояниями missing и clear. Внутри адаптера поля переводятся в формат выбранного API. Так legacy-детали не расползаются по формам, импорту и обработчикам.

\n
function toLegacyFields(user) {\n    const fields = {};\n\n    if (user.email.state === 'value') {\n        fields.EMAIL = user.email.value;\n    } else if (user.email.state === 'clear') {\n        fields.EMAIL = '';\n    }\n\n    if (user.phone.state === 'value') {\n        fields.PERSONAL_PHONE = user.phone.value.trim();\n    }\n\n    return fields;\n}
\n

Этот код показывает только mapping. Он не вызывает CUser::Update, не проверяет права и не описывает локальные события. В рабочем проекте перед вызовом нужно зафиксировать, что означает пустое поле, кто владеет нормализацией, какие ошибки возвращает API и что должен увидеть read-back. Если D7-модель хранит поле в другом представлении, адаптер должен преобразовать его обратно в тот же канонический результат.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы Bitrix API
СимптомПричинаПроверкаДействие
Класс найден, вызов падает на части серверовМодуль не установлен или не подключён в этом bootstrapПроверить IncludeModule в том же окружении и записать результатОстановить операцию с диагностикой либо подключить модуль явно
Одинаковое имя поля даёт разный результатРазличаются тип, формат или пользовательское полеСравнить mapping, тип значения, missing и clearОставить преобразование в адаптере и добавить read-back
Update вернул успех, но данные не видныПроверяется только код ответа; фильтр или событие меняет выборкуПрочитать запись по ID и выполнить контрольный запрос с условиями каталогаРазделить факт записи и публичную видимость
После обновления появился неизвестный методДокументация описывает другую версию или другую поверхностьСверить версию ядра, модуль и фактический manifest операцийВернуть адаптер к подтверждённой операции или ограничить поддержку
Повторный импорт создаёт новые записиВ контракте нет стабильного ключа и идемпотентного поискаПовторить тот же вход и сравнить внешний ключ и результат чтенияНайти существующую запись по согласованному ключу до создания
Миграция проходит на тесте, но меняет production-смыслТест проверяет ID, но не события, права и пустые состоянияДобавить characterization-тесты для успеха, ошибки, retry и очисткиНе заменять поверхность до закрытия отрицательного пути
\n

Порядок действий

\n
  1. Записать предметную операцию: что создаём, ищем или обновляем, какой результат считаем успехом и какие данные нельзя изменить.
  2. Зафиксировать версию ядра, идентификатор модуля, bootstrap и официальный источник документации для выбранного вызова.
  3. Собрать в целевой среде минимальный manifest: модуль подключён, класс или таблица доступны, нужные операции найдены.
  4. Составить таблицу mapping для каждого поля. Отдельно описать значение, отсутствие, явную очистку, нормализацию и внешний ключ.
  5. Проверить legacy и D7 на одном наборе учебных входов. Сравнить не только ID, но и read-back, коды ошибок и побочные события.
  6. Спрятать выбранную поверхность за узким адаптером. Наружу вернуть канонический результат и классифицированную ошибку.
  7. Проверить повторный запуск и отрицательный путь: отсутствующий модуль, неизвестная операция, плохое поле, отказ прав и частичный результат.
  8. Если хотя бы один обязательный слой не подтверждён, остановить миграцию и оставить текущий вызов. Сначала закрыть неизвестность, затем менять API.
\n

Ограничения

\n

Документация Bitrix описывает публичную поверхность, но не знает локальные обработчики, права, переопределения в /local, структуру инфоблока и фактический bootstrap. Две установки с одним номером версии могут иметь разные модули и данные. Поэтому ссылка на страницу API не является доказательством совместимости проекта.

\n

Manifest тоже не равен полному тесту. Он подтверждает доступность слоя, но не доказывает корректность SQL, событий и бизнес-правил. Не следует печатать в диагностике пользовательские данные. Достаточно версии, имени модуля и названий операций. Секреты и значения полей в такой вывод не входят.

\n

Иногда правильное решение — не мигрировать. Если legacy-вызов покрыт тестами, выполняет нужную операцию и новый API не даёт проверяемого выигрыша, адаптер может сохранить старую поверхность. Если новый метод доступен, но его mapping или события не доказаны, переход откладывают. Остановка с причиной безопаснее частичной миграции и ручного восстановления данных.

\n

Проверяемый критерий готовности

\n

Граница готова, когда повторяемая проверка показывает: нужный модуль подключается; операция существует в каждой обязательной среде; поля имеют записанный mapping; значение, отсутствие и очистка дают ожидаемый read-back; ошибка и отказ прав не превращаются в успех; повторный запуск не создаёт дубль; а сборка и тесты проходят без ручного вмешательства. Для каждой версии нужен сохранённый результат проверки. Если нет хотя бы одного из этих доказательств, готов только план проверки, а не миграция.

\n

Проверяемые источники

" + "contentHtml": "

Ошибка при миграции Bitrix часто появляется не в строке вызова. Документация нашла класс, автозагрузка сработала, операция вернула успешный результат — а на другой установке модуль не подключён, пользовательское поле имеет другой тип или обработчик изменяет данные после записи. Через несколько часов форма теряет значение, импорт создаёт дубль, а откат уже требует ручного восстановления.

\n

Главный вывод: совместимость нельзя вывести из имени класса или номера версии. Её нужно доказать для конкретной операции: модуль подключён в нужном bootstrap, метод действительно доступен, поля имеют согласованный mapping, а результат выдерживает повторное чтение и отрицательные случаи. Если хотя бы один слой неизвестен, миграция ещё не готова.

\n

Имя метода — только первый слой

\n

Bitrix документирует два поколения поверхности для одной предметной области. Класс CUser относится к старому ядру, а Bitrix\\Main\\UserTable — к D7 и ORM. В документации прямо указано, что UserTable является аналогом CUser. Это полезная подсказка для поиска, но не обещание побитной совместимости: разные методы принимают разные аргументы, возвращают разные типы результата и по-разному сообщают об ошибках.

\n

Начинать нужно с модуля. Для пользовательской области это обычно модуль main, а не iblock. Старый вызов CModule::IncludeModule('main') проверяет, установлен ли модуль, и подключает его файл include.php. D7-вариант \\Bitrix\\Main\\Loader::includeModule('main') подключает модуль по имени и возвращает true или false; в документации для метода также перечислено исключение LoaderException. Ни один из этих ответов не доказывает, что нужная операция и её поля совпадают с ожиданиями приложения.

\n

Следующий слой — поверхность операции. Наличие класса доказывает только возможность разрешить имя. Для миграции обновления пользователя нужно отдельно подтвердить CUser::Update или выбранный D7-вызов, а затем зафиксировать набор полей, права и наблюдаемый результат. Проверка через class_exists без проверки операции создаёт ложное чувство совместимости.

\n
Четыре границы совместимости Bitrix API: модуль main, операция обновления, mapping полей и результат чтения
Контракт проходит четыре проверки: подключён ли модуль, доступна ли операция, одинаково ли трактуются поля и подтверждается ли результат повторным чтением.
\n

Что именно различается между CUser и D7

\n

Сравнивать нужно не названия классов, а один сценарий от входа до чтения. Например, пусть импорт обновляет email и телефон пользователя по стабильному локальному ID. В legacy-поверхности поля называются EMAIL и PERSONAL_PHONE. Документация CUser описывает их как строковые поля. Но проект может добавлять пользовательские поля, нормализовать телефон, ограничивать смену email или подключать обработчики события. Эти правила находятся за пределами общей сигнатуры.

\n
Что доказать перед заменой поверхности
СлойCUserD7 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-операцииПовторное чтение и контроль публичной выборки
\n

У этой таблицы есть важная оговорка: последний столбец не заполняется документацией автоматически. Его заполняет команда на своей установке. В частности, официальная страница CUser::Update сообщает, что метод возвращает true при успехе и false при ошибке, а текст ошибки находится в LAST_ERROR. Та же страница отдельно говорит: если пользователя с указанным ID нет, ошибки не возникает. Значит, одного булева результата недостаточно — отсутствие записи нужно проверять до или после изменения.

\n

Воспроизводимая диагностика поверхности

\n

Диагностика должна быть безопасной: она выводит имена модулей и операций, но не 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 и версию ядра, а не подменять имя класса.

\n

Версия тоже входит в отчёт, но не заменяет поведенческую проверку. В официальной документации CUser и UserTable есть собственные границы версий: CUser описан с версии 3.0.6, UserTable наследует DataManager, а для старых версий модуля Main документация указывает другой класс-родитель. Эти сведения помогают понять, какой код вообще может встретиться в установке. Они не отвечают, как локальные обработчики и пользовательские поля поведут себя в конкретном проекте.

\n

Канонический вход и read-back

\n

Адаптер должен принимать один формат данных независимо от выбранной поверхности. У каждого поля полезно различать три состояния: missing — поле не участвует в обновлении, value — записывается новое значение, clear — значение очищается явно. Если передавать пустую строку вместо отдельного состояния, код теряет намерение вызывающей стороны и начинает зависеть от поведения конкретного API.

\n
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, отсутствие поля и состояние, которое видит дальнейшая бизнес-логика.

\n

Успех записи и видимость — разные утверждения. Обработчик может изменить поле, индекс или статус; кеш может показать старое значение; фильтр каталога может исключить запись. Поэтому characterization-тест должен проверять как минимум исходное чтение, запись, повторное чтение и запрос потребителя. Если запись должна быть идемпотентной, второй запуск с тем же внешним ключом обязан обновить найденную сущность, а не создать новую.

\n

Симптом → причина → проверка → действие

\n
Диагностика несовпадения контрактов Bitrix
СимптомВероятная причинаПроверкаДействие
Класс найден, а вызов падает на части серверовmain не установлен или не подключён в реальном bootstrapЗаписать результат Loader::includeModule('main') без пользовательских данныхОстановить операцию с причиной либо исправить подключение
Метод существует, но поле отклоняетсяТип, имя или форма пользовательского поля различаютсяСверить карту полей, входной тип и состояние clearОставить преобразование в адаптере и добавить отрицательный тест
Update вернул успех, но пользователя нет в чтенииID не существует, чтение использует другой фильтр или сработал обработчикПроверить существование ID, перечитать запись и выполнить запрос потребителяРазделить отсутствие записи, факт изменения и публичную видимость
После перехода значения стали другимиLegacy и ORM по-разному нормализуют дату, телефон или пользовательское полеПрогнать одинаковый набор входов и сравнить канонический read-backЗафиксировать mapping либо оставить прежнюю поверхность
Повторный импорт создал дубльПеред созданием не используется стабильный внешний ключПовторить вход и сравнить количество записей и ключиСначала искать сущность, затем обновлять или создавать
На тесте всё работает, в рабочей среде — нетРазличаются права, обработчики, модули, данные или bootstrapСравнить обезличенный manifest и прогнать smoke-набор в целевой средеОграничить поддержку средами с подтверждённым контрактом
\n

Порядок безопасной миграции

\n
  1. Назвать одну предметную операцию и её инварианты: что можно изменить, что должно остаться прежним и какой результат увидит потребитель.
  2. Зафиксировать окружение: версию ядра, идентификатор модуля, bootstrap, права технического пользователя и включённые локальные обработчики.
  3. Проверить подключение main в реальном пути выполнения. Отдельно сохранить результат и исключение, не записывая значения полей.
  4. Подтвердить точную операцию: класс или таблицу, метод, аргументы, результат, исключения и способ получения текста ошибки.
  5. Составить mapping полей. Для каждого поля описать тип, нормализацию, missing, value, clear и внешний ключ.
  6. Запустить одинаковый набор на legacy и D7 в тестовой копии данных. Сравнить не только код ответа, но и read-back, события, права и публичную выборку.
  7. Спрятать вызов за адаптером с каноническим входом и классифицированными причинами остановки. Не смешивать выбор поверхности с бизнес-логикой формы или импорта.
  8. Проверить повторный запуск, несуществующий ID, неверное поле, отказ прав, исключение загрузчика и частичный результат.
  9. Включать новую поверхность поэтапно, с журналом обезличенных исходов и возможностью вернуть старый адаптер. При несовпадении контракта остановить переход, а не продолжать на догадке.
\n

Ограничения применимости

\n

Официальная документация описывает публичный API, но не локальную конфигурацию. Она не знает обработчики в /local, права, состав пользовательских полей, кеши, настройки сайтов и фактический bootstrap. Даже одинаковая версия ядра не гарантирует одинаковый набор модулей и данных.

\n

Проверка доступности метода не является тестом миграции. method_exists не выявляет семантику события, права записи, ограничения базы и работу фильтра потребителя. Manifest нужен для ранней остановки и сравнения сред, а не как единственное доказательство.

\n

Нельзя переносить mapping из примера в проект без проверки. Имена EMAIL и PERSONAL_PHONE относятся к стандартной модели пользователя, но у проекта могут быть собственные UF_*-поля, другой источник истины или обязательная нормализация. Секреты и персональные значения в диагностический вывод не входят.

\n

Иногда безопасный результат — не мигрировать. Если legacy-вызов покрыт тестами, а новый API не даёт измеримого выигрыша, адаптер может сохранить старую поверхность. Если модуль, операция или mapping не подтверждены, остановка с понятной причиной дешевле частичной записи и ручного восстановления.

\n

Критерий готовности

\n

Переход можно считать готовым только после повторяемого набора доказательств: модуль подключается в каждой обязательной среде; точная операция доступна; поля имеют записанный mapping; value, missing и clear дают ожидаемый read-back; ошибка, исключение и отказ прав не превращаются в успех; несуществующий ID обрабатывается явно; повторный запуск не создаёт дубль; запрос потребителя видит правильное состояние. Если не закрыт хотя бы один пункт, готова проверка неизвестности, но не замена API.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/033.json b/editorial/agent-rewrites/033.json index 663dddb..785849b 100644 --- a/editorial/agent-rewrites/033.json +++ b/editorial/agent-rewrites/033.json @@ -1,7 +1,7 @@ { "index": 33, "slug": "editorial-2027-02-practice-bitrix-lessons", - "title": "Bitrix legacy без догадок: сохранить вызов, поставить адаптер или заменить API", - "excerpt": "Как принять решение по старому Bitrix API: отделить наблюдаемое поведение от имени класса, проверить границу и не начинать миграцию без контракта.", - "contentHtml": "

После замены старого вызова Bitrix страница продолжает открываться, но новый пользователь не создаётся. В логах остаётся общий отказ. Административная форма показывает успех, хотя обработчик, который отправляет данные во внешнюю систему, не сработал. Цена ошибки — не один сломанный метод. Команде приходится восстанавливать порядок событий, формат полей и правила, которые раньше были спрятаны в legacy-коде.

\n

Возраст класса не доказывает его опасность. Имя нового API не доказывает совместимость. Безопасное решение начинается с наблюдаемого контракта: какие входы принимает код, какое состояние меняет, что возвращает, какие события запускает и как сообщает об отказе. Пока контракт не проверен, есть три действия: сохранить вызов, обернуть его адаптером или заменить после сравнения поведения.

\n

Симптом сначала, название класса потом

\n

Типичный симптом выглядит безобидно: после обновления вызова исчезает запись, меняется формат телефона или внешний обработчик получает пустой идентификатор. Система может не упасть. Она продолжит работать с неполным состоянием. Поэтому проверять нужно не только отсутствие исключения. Нужны чтение результата, события, права, повторный запуск и реакция на частичный отказ.

\n

Риск выше, если одна функция выполняет несколько операций. Она может привести email к нижнему регистру, создать пользователя, вызвать обработчик и вернуть ID. Замена класса меняет сразу четыре соглашения. Сравнение строк вызова этого не показывает.

\n

В Bitrix старый CUser и D7-класс Bitrix\\\\Main\\\\UserTable относятся к одной предметной области, но это не делает их взаимозаменяемыми в проекте. Нужно проверить mapping полей, способ ошибки, порядок событий и доступность модуля в конкретной установке. Документация даёт публичную поверхность API. Она не знает локальные обработчики, пользовательские поля и скрытые callers.

\n
Дерево решения для Bitrix legacy: проверка callers и контракта перед сохранением, адаптацией или заменой API
Сначала проверяют контракт. Если побочные эффекты неизвестны, ветка ведёт к остановке изменения и сбору фактов.
\n

Три решения и их границы

\n

Сохранить — оставить текущий вызов и ограничить изменение вокруг него. Это подходит, когда callers мало, побочные эффекты не описаны, а задача не требует нового контракта. Сохранение не убирает технический долг. Оно не даёт неизвестному поведению разойтись дальше и оставляет обратимый шаг.

\n

Обернуть — поставить одну границу между приложением и Bitrix API. Адаптер принимает поля проекта, проверяет обязательные значения, вызывает legacy-код и переводит ошибку в согласованный результат. Внешний код перестаёт зависеть от PERSONAL_PHONE, подключения модуля и объекта, в котором Bitrix хранит последнюю ошибку.

\n

Заменить — перейти на новый контракт, а не только поменять имя класса. Сначала описывают mapping полей, порядок операций, успешный результат, ошибку, события и требования к версии. Затем запускают старый и новый путь на одинаковых входах. Если различие намеренное, его фиксируют как изменение поведения. Если случайное, замену откладывают.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Вызов вернул ID, но внешняя система не получила записьсобытие зависит от старого порядкажурнал событий и повторное чтение записисохранить путь или обернуть его
Обновление прошло, поле стало пустымпустое значение трактуется как очисткасравнить отсутствие поля, null и пустую строкузадать mapping и правило пустого значения
Класс не найденмодуль не подключён или API недоступно в версиипроверить IncludeModule и поверхность методовостановить замену и уточнить контракт
Повторный запуск создал дубльоперация не различает обработанный объектзапустить один вход дважды и сравнить IDдобавить ключ идемпотентности
Новый вызов работает для одного callercaller-ы передают разные форматынайти все места вызова и фактические входыпоставить адаптер с единым входом
\n

Таблица задаёт порядок вопросов, но не доказывает причину сама. Для каждой строки нужен артефакт: лог, diff полей, результат повторного запуска, список callers или проверка подключения модуля. Если артефакта нет, решение остаётся гипотезой.

\n

Учебный пример адаптера

\n

Ниже приведён учебный PHP-фрагмент. Он показывает форму узкой границы и не утверждает, что конкретный проект должен обновлять пользователя именно так. Перед применением нужно сверить версию Bitrix, права, обработчики и правила хранения контактов.

\n
<?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

Адаптер делает три вещи. Он отличает отсутствие поля от переданного значения. Он приводит email к одному формату. Он переводит отказ Bitrix в исключение, которое может обработать caller. Он не объявляет успехом сам факт вызова метода. После обновления нужен read-back: получить пользователя, сравнить поля и проверить побочный обработчик.

\n

Отрицательный путь важнее короткого примера. Если проект разрешает очистить email пустой строкой, проверка выше неверна. Если обработчик ожидает исходный регистр, lowercase меняет контракт. Если старый метод обновляет связанные поля, узкий wrapper скрывает обязательную операцию. В этих случаях пример нельзя переносить целиком. Нужно изменить mapping, расширить контракт или оставить legacy-вызов.

\n

Что выяснить до миграции

\n

Начните с callers. Поиск по имени метода недостаточен: вызов может находиться в сервисе, обработчике события, шаблоне или административной форме. Для каждого caller запишите входные поля, права, ожидаемый результат и реакцию на ошибку. Если один caller передаёт пустую строку, а другой не передаёт поле, это разные операции.

\n

Затем зафиксируйте владельца состояния. Кто создаёт запись? Кто меняет её после события? Кто формирует внешний ID? Какой код считается успехом? Где находится последняя ошибка? Ответы должны подтверждаться кодом, логом или чтением данных. Фраза «Bitrix сам вызывает обработчик» не является проверкой: обработчик зависит от модуля, версии, прав и условий события.

\n

Третья граница — версия. Для старого API проверьте доступность модуля и метода в выполняемой среде. Для нового API проверьте namespace, mapping полей, типы значений и поведение исключений. Вызов CModule::IncludeModule должен быть частью проверки границы, а не строкой, которую добавляют после сбоя.

\n
<?php\nif (!CModule::IncludeModule('main')) {\n    throw new RuntimeException('Bitrix main module is unavailable');\n}\n\n$user = new CUser();\n$ok = $user->Update($userId, $fields);\nif (!$ok) {\n    throw new RuntimeException($user->LAST_ERROR);\n}
\n

Этот второй фрагмент тоже учебный. Он проверяет подключение модуля, но не проверяет права, события, mapping или корректность данных. В конкретном проекте идентификатор модуля и способ вызова нужно сверить с установленной версией.

\n

Действия по порядку

\n
  1. Соберите один воспроизводимый симптом: вход, caller, версия среды, права, результат и цену отказа.
  2. Найдите все вызовы и выпишите фактические поля, значения по умолчанию, события и обработку ошибок.
  3. Проверьте подключение нужного модуля и доступную поверхность API в выполняемой версии.
  4. Добавьте проверки для успеха, обязательного поля, пустого значения, ошибки и повторного запуска.
  5. Выберите сохранить, обернуть или заменить. Если контракт неизвестен, остановите миграцию.
  6. Для адаптера опишите canonical input/output: поля, формат, пустое состояние, ошибку, ID и владельца состояния.
  7. Сравните старый и новый путь на одинаковых учебных входах. Проверьте read-back, события, права и отрицательный путь.
  8. Оставьте только изменение, для которого можно назвать проверку, результат и способ отката.
\n

Ограничения и критерий готовности

\n

Переход на D7 или другой новый слой не переносит автоматически пользовательские поля, события, права и внешние идентификаторы. Даже правильный mapping опасен, если операция выполняется внутри транзакции или участвует в импорте. Нужно понять поведение при повторе и частичном отказе.

\n

Unit-тест на чистом объекте не проверяет модуль, реальную версию, события и права. Но полная копия production-среды не обязательна для первого шага. Достаточно сузить границу, зафиксировать факты и назвать, чего учебная проверка не покрывает. Не следует объявлять замену готовой только потому, что новый метод вернул ожидаемый ID.

\n

Иногда правильный результат — не менять код. Если нет воспроизводимого входа, список callers неполный, а ошибка зависит от неизвестного обработчика, сохранение вызова снижает риск. Сначала получают недостающий контракт. Затем возвращаются к адаптеру или замене.

\n

Изменение готово, когда другой разработчик может повторить проверку по записи входа и получить тот же результат. В записи есть callers, версия и подключение модуля, mapping полей, события, успешный и отрицательный путь, read-back и способ отката. Для замены старый и новый путь дают одинаковый результат либо команда явно согласовала изменение. Если пункт неизвестен, готова не миграция, а следующая проверка.

\n

Проверяемые источники

" + "title": "Bitrix legacy без догадок: как сравнить CUser и D7 перед миграцией", + "excerpt": "Практический способ решить, что делать со старым вызовом Bitrix: зафиксировать наблюдаемый контракт, проверить callers и события, а затем выбрать сохранение, адаптер или замену.", + "contentHtml": "

После замены старого вызова Bitrix страница может продолжить отвечать 200, а новый пользователь всё равно не появится во внешней системе. В административной форме будет успех, в логах — общий отказ, а повторная попытка иногда создаст дубль. Такая поломка возникает не из-за возраста класса. Она появляется, когда команда сравнивает две строки кода и не сравнивает поведение вокруг них.

\n

Разберём один типовой сценарий: проект обновляет работу с пользователем и выбирает между CUser и D7 Bitrix\\Main\\UserTable. Безопасное решение опирается на наблюдаемый контракт. Нужно знать входные поля, владельца состояния, результат, ошибки, события, права и поведение при повторе. Пока хотя бы один из этих пунктов неизвестен, есть три честных действия: сохранить вызов, поставить адаптер или отложить замену до получения фактов.

\n

Начните с симптома и границы операции

\n

Сначала запишите не название класса, а то, что увидел пользователь или соседняя система. Например: форма вернула успешный ответ, запись в таблице пользователей изменилась, но обработчик не отправил внешний идентификатор. Другой вариант: обновление прошло, а поле телефона исчезло. Третий: второй запуск с теми же данными создал ещё одну запись.

\n

Для каждого симптома нужны пять наблюдений: точный вход, caller, результат основного вызова, изменённое состояние и побочный эффект. Caller — это конкретный код, который передаёт данные в операцию: контроллер, обработчик события, импорт или административная форма. Один и тот же метод могут вызывать несколько callers с разными правилами пустых значений и правами.

\n

Сформулируйте границу так: «получить email и телефон, изменить пользователя с ID 42, вернуть подтверждённый результат, запустить нужный обработчик». Если в этой фразе нет способа подтвердить результат, контракт неполон. Возвращённый ID или true ещё не доказывают, что дочерняя интеграция приняла данные.

\n
Дерево решения для Bitrix legacy: после проверки callers, полей и побочных эффектов выбирается сохранение, адаптер или замена API
Решение начинается с контракта. Если побочные эффекты не подтверждены, сначала фиксируют их, а не расширяют зону изменения.
\n

Какие части контракта нельзя потерять

\n

У старого вызова обычно несколько обязанностей, даже если они скрыты в одной функции. Он может нормализовать email, очищать поле пустой строкой, проверять права, вызывать обработчики и записывать внешний ID. Миграция, которая переносит только список полей, переносит не контракт, а его видимую часть.

\n

Вход. Зафиксируйте отличие между отсутствующим ключом, null, пустой строкой и пробелами. Для проекта это могут быть четыре разных команды: не менять значение, очистить его, отклонить запрос или сохранить нормализованный текст.

\n

Результат. У CUser::Update официальный контракт — логическое значение: при ошибке метод возвращает false, а текст находится в LAST_ERROR. Если проект считает успехом отправку данных в CRM, это уже второй результат, который надо проверять отдельно.

\n

События. Обработчик может менять состояние после основной записи. В официальном описании OnAfterUserUpdate прямо сказано, что событие вызывается после попытки изменения свойств методом CUser::Update, а в параметрах доступны результат и сообщение ошибки. Поэтому при переходе на ORM нельзя автоматически считать, что локальный обработчик, его порядок и его данные останутся прежними. Их нужно найти и проверить в конкретной версии и конфигурации.

\n

Права и версия. Доступность класса, модуля, поля и операции зависит от выполняемой установки. Документация описывает публичный API, но не знает пользовательские поля проекта, зарегистрированные обработчики и ограничения роли. Это не недостаток документации, а граница того, что можно утверждать по ссылке.

\n

CUser и UserTable: одна область, разные модели

\n

Bitrix называет Bitrix\\Main\\UserTable аналогом старого CUser, но слово «аналог» не означает замену один к одному. Страница D7 описывает UserTable как класс для работы с пользователями, наследующий DataManager. У DataManager::update другая форма контракта: статический вызов принимает первичный ключ и массив данных и возвращает объект результата.

\n

У старого и нового путей различаются как минимум поверхности ошибок и событий. Старый путь — объект CUser, булев результат и LAST_ERROR. ORM-путь — статический метод сущности и объект результата, который надо обработать по правилам D7. Если адаптер скрывает эту разницу, он обязан определить единый результат наружу: например, подтверждённое изменение пользователя или структурированную ошибку.

\n

Данные тоже нельзя переносить механически. Поле PERSONAL_PHONE может быть стандартным, а UF_... — пользовательским полем со своим типом и форматом. Массив для поля-списка, строка для текста и значение для файла требуют разных проверок. Сверьте карту полей в работающей установке и зафиксируйте преобразования рядом с контрактом адаптера.

\n

Ещё одна граница — операция, а не только запись. Если после изменения пользователя срабатывает синхронизация, уведомление или расчёт доступа, сравнивайте цепочку целиком. Новый ORM-вызов, который обновил строку, может быть технически успешным и функционально неполным.

\n
Как связать наблюдение с решением
НаблюдениеЧто проверитьАртефактБезопасный следующий шаг
Старый вызов меняет пользователя, но внешний обработчик не даёт записькакой обработчик запускается и где формируется внешний IDсписок обработчиков, лог и read-backсохранить путь до фиксации цепочки или обернуть её
Пустой email или телефон меняет состояниеотличие отсутствующего ключа, null и пустой строкинабор входов и снимок полей до/послеописать mapping и явное правило очистки
Один путь возвращает false, другой — объект результатакак caller распознаёт ошибку и получает сообщениеконтракт ответа и отрицательный тестпривести ошибки к одному интерфейсу адаптера
Класс или поле недоступны в окруженииверсию ядра, модуль, карту полей и права роливерсия, проверка загрузки и результат чтения картыостановить замену до устранения неизвестного
Повторный запуск создаёт дубльидемпотентность и владелец внешнего идентификаторадва одинаковых запуска и сравнение IDдобавить ключ повторения или оставить старый путь
\n

Таблица не является доказательством причины. Она задаёт минимальный набор наблюдений. Для строки «обработчик не дал запись» нужен реальный журнал или чтение внешней системы; для строки о пустом значении — входной набор и состояние до и после. Если команда может назвать только предположение, миграция ещё не готова.

\n

Учебный адаптер для старого пути

\n

Ниже — небольшой PHP-пример границы вокруг CUser::Update. Он намеренно принимает проектные имена email и phone, а наружу отдаёт ID только после успешного вызова. В этом варианте пустой телефон означает очистку, а пустой email отклоняется. Это решение примера, не универсальное правило Bitrix.

\n
<?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 пользователя и отдельная проверка побочного результата.

\n

У кода есть намеренные ограничения. Он не проверяет права, не знает обязательность email в конкретной установке и не нормализует телефон по правилам бизнеса. Он также не гарантирует, что ID существует: официальное описание CUser::Update отмечает, что отсутствие пользователя с указанным ID само по себе не даёт ошибки. Если для проекта это важно, адаптер должен явно прочитать запись до обновления или проверить результат чтением после него.

\n

Как сравнить старый и новый путь

\n

Не начинайте с массовой замены. Сначала соберите characterization test — тест, который фиксирует фактическое поведение старого пути, даже если оно кажется неудобным. Для одинакового входа запишите поля до операции, ответ, ошибку, события, внешний ID и поля после операции. Такой тест не объявляет старое поведение правильным; он показывает, что именно нельзя потерять случайно.

\n

Затем подготовьте отдельный тест для D7. Официальный DataManager::update возвращает объект результата, поэтому проверьте не только отсутствие исключения, но и успешность результата, сообщение ошибки и фактическое состояние записи. Если объект результата обрабатывается иначе, чем LAST_ERROR, адаптер должен скрыть эту разницу, а не заставлять каждый caller знать обе модели.

\n

События проверяйте наблюдением, а не названием. Найдите регистрации OnBeforeUserUpdate, OnAfterUserUpdate и проектные обработчики, проверьте условия их выполнения и порядок записи. Для нового пути отдельно установите, какие ORM-события и локальные интеграции используются. Если официальная страница описывает только старое событие, это повод провести проверку, а не обещать совместимость.

\n

Минимальный набор сравнений выглядит так: корректный email и телефон; отсутствие email при изменении телефона; пустой телефон для очистки; невалидный email; несуществующий ID; недостаточные права; повторный запуск; отказ внешней системы после записи. Для каждого случая нужны ожидаемый результат, наблюдаемое состояние и решение о том, допустимо ли отличие.

\n

Когда оставить, обернуть или заменить

\n

Оставить — разумно, если задача не требует нового API, callers немного, а побочные эффекты ещё не описаны. Это не отказ от улучшений. Команда фиксирует границу и избегает изменения нескольких неизвестных одновременно.

\n

Обернуть — полезно, когда callers несколько или старый API проникает в разные слои. Адаптер принимает канонический вход проекта, применяет mapping, единообразно возвращает успех или ошибку и оставляет место для read-back. Его задача — уменьшить число мест, где живут знания о Bitrix, а не спрятать незавершённую миграцию.

\n

Заменить — можно после сравнения поведения и осознанного решения об отличиях. Для каждого намеренного отличия нужна запись: что меняется, почему это безопасно, кто владеет обновлённым состоянием и как откатить релиз. Если команда не может воспроизвести старый результат, замену лучше отложить.

\n

Порядок проверки

\n
  1. Запишите симптом, конкретный вход, caller, версию Bitrix, роль и цену ошибки.
  2. Найдите все callers и выпишите фактические поля, значения по умолчанию, обработчики и реакцию на отказ.
  3. Снимите состояние пользователя и связанной системы до операции, включая внешний ID.
  4. Проверьте доступность класса, модуля, поля и метода в выполняемой установке.
  5. Зафиксируйте поведение старого пути на успешном, отрицательном, пустом и повторном входе.
  6. Опишите канонический input/output адаптера: формат полей, очистку, ID, ошибку и владельца состояния.
  7. Сравните старый и новый путь на одинаковых данных, включая read-back и побочные события.
  8. Оставьте только тот шаг, для которого названы тест, наблюдаемый результат и способ отката.
\n

Ограничения применимости

\n

Эта схема подходит для выбора границы вокруг legacy-вызова. Она не заменяет аудит безопасности, ревизию ролей и прав, миграцию схемы или проверку производительности. Если операция работает в импорте, транзакции или очереди, отдельно исследуйте повтор, частичный отказ и порядок подтверждения внешней системы.

\n

Учебный пример нельзя копировать без сверки версии, обязательных полей, формата пользовательских полей, прав и зарегистрированных обработчиков. Нормализация email и разрешение очистки телефона — проектные решения. Для файла, списка или множественного пользовательского поля понадобятся другие типы входа и отдельные тесты.

\n

Unit-тест на чистом объекте не доказывает поведение реального модуля и событий. Полная копия production для первого шага тоже не обязательна: достаточно ограничить тестовую среду, указать, что она не покрывает, и не выдавать её результат за доказательство совместимости. Критерий готовности простой: другой разработчик повторяет вход, видит тот же результат, понимает намеренные отличия и знает, как вернуть старый путь.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/034.json b/editorial/agent-rewrites/034.json index 21ae276..8763311 100644 --- a/editorial/agent-rewrites/034.json +++ b/editorial/agent-rewrites/034.json @@ -2,6 +2,6 @@ "index": 34, "slug": "editorial-2027-01-field-debugging-decade", "title": "502 без догадок: как восстановить цепочку запроса по логам", - "excerpt": "Пошаговый разбор 502 по access и application log: как связать события, отличить отказ до приложения от ошибки в нём и не назвать причину без доказательства.", - "contentHtml": "

Клиент получает 502. В access log шлюза видны маршрут, время и внешний статус. В журнале приложения нет записи с тем же запросом. Инженер открывает последний релиз и начинает искать ошибку в коде. Через несколько часов выясняется, что шлюз не дождался upstream или не смог разобрать его ответ. Исправление приложения не меняет ситуацию, а время расследования уже потеряно.

\n

Цена ошибки состоит не только в часах. Команда может откатить исправный релиз, увеличить таймаут без понимания причины или добавить повторные запросы к уже перегруженной зависимости. Следующий дежурный получит уверенную, но неверную запись: «упало приложение». Она направит новое расследование по тому же ложному следу.

\n

Рабочий тезис простой: 502 надо разбирать как разрыв цепочки событий. Сначала фиксируют, где появился статус. Затем ищут связанную запись приложения по идентификатору. Только после подтверждения передачи запроса переходят к зависимости. Отсутствие записи — тоже результат. Он ограничивает вывод и открывает отдельную проверку.

\n

Что означает внешний статус

\n

RFC 9110 определяет 502 как ответ gateway или proxy, который получил недействительный ответ от сервера, к которому обращался для выполнения запроса. Это описание роли узла, а не доказательство того, что конкретный сервис сломан. Клиент видит ответ ближайшей границы. Причина может находиться между шлюзом и upstream: в соединении, таймауте, формате ответа, маршрутизации или фильтре.

\n

Поэтому первая запись должна называть субъект результата. Поле edge.status=502 точнее, чем сообщение «сервер вернул 502». Рядом нужны route, method, duration_ms, время события, имя узла и ключ корреляции. Без этих полей access log подтверждает симптом, но не даёт короткого пути к следующему слою.

\n

Как строится доказательная цепочка

\n

Для одной попытки запроса нужны минимум два источника: access log на границе и application log в сервисе. Они связываются по точному request_id или по trace context. Время и путь помогают проверить совпадение, но не должны быть единственным ключом. Два запроса к одному маршруту могут попасть в одно и то же временное окно.

\n

Если application event найден, сравните время, маршрут, статус и длительность. Запись приложения с 500 показывает, что запрос дошёл до приложения и там завершился ошибкой. Она не объясняет, почему произошёл отказ зависимости. Запись приложения с 200 при внешнем 502 показывает расхождение границ: надо проверять retry, кэш, преобразование статуса или другой upstream.

\n

Если application event не найден, не подставляйте приложение в роль виновника. Проверьте timeout до приложения, правила маршрутизации, формат идентификатора, фильтры коллектора и задержку доставки. После этого можно сказать только: «внешний отказ подтверждён, запись приложения не найдена». Это полезный вывод, потому что он отделяет неисправность канала наблюдения от отказа бизнес-операции.

\n
\"Цепочка
Полевой разбор начинается с внешнего события. Разрыв между access и application не позволяет объявить приложение причиной.
\n

Симптом → причина → проверка → действие

\n
Матрица первой проверки для 502
СимптомВозможная причинаПроверкаДействие
502 в edge, application event отсутствуетtimeout, маршрут до сервиса или потеря записисверить upstream, окно времени, collector и формат idзафиксировать разрыв; не обвинять приложение
502 в edge, application 500 с тем же idошибка обработки запроса в сервисесравнить время, route, статус и dependency eventисследовать ошибку приложения и её границу
502 в edge, application 200retry, cache или преобразование ответа на proxyпроверить попытки, upstream и mapping статусовразделить результат приложения и результат клиента
В access нет request idнеполная схема structured logпроверить конфигурацию полей и передачу заголовкаисправить корреляцию до следующего разбора
\n

Учебный пример корреляции

\n

Ниже приведён учебный пример для локальной проверки идеи. Массивы не взяты из production и не доказывают поведение конкретного шлюза. Функция получает события одной попытки и классифицирует только наблюдаемый разрыв.

\n
const edge = [\n  { requestId: 'r-1', at: '12:00:01.100', status: 502, durationMs: 3000 },\n  { requestId: 'r-2', at: '12:00:02.100', status: 502, durationMs: 420 },\n];\n\nconst app = [\n  { requestId: 'r-2', at: '12:00:02.080', status: 500, error: 'db unavailable' },\n];\n\nfunction classify(edgeEvent, appEvents) {\n  const appEvent = appEvents.find((event) => event.requestId === edgeEvent.requestId);\n  if (!appEvent) return 'gateway-failed-before-app-or-event-missing';\n  if (appEvent.status >= 500) return 'app-error-reached-gateway';\n  return 'status-mapping-needs-check';\n}\n\nedge.map((event) => ({\n  requestId: event.requestId,\n  result: classify(event, app),\n}));\n// r-1: gateway-failed-before-app-or-event-missing\n// r-2: app-error-reached-gateway
\n

Для r-1 код видит только отсутствие события. Это не различает timeout, сбой коллектора и неверный идентификатор. Для r-2 найдено согласованное событие приложения. Оно подтверждает путь запроса, но не доказывает, что база была первопричиной. Следующий запрос должен проверить dependency event, лимит соединений и время ожидания.

\n

В рабочем коде нормализуйте схему на входе, сохраняйте номер попытки и не смешивайте повторные запросы в одну карточку. Не включайте в общий журнал токены, тело формы, email и сырые заголовки. Безопасный fingerprint может помочь связать закрытый источник с публичной карточкой, если правила хранения это разрешают.

\n

Действия по порядку

\n
  1. Скопируйте одну попытку из edge log: timestamp, route, method, status, duration и request id.
  2. Уточните, какой узел сформировал 502 и какой upstream он выбирал.
  3. Найдите application events по точному id в ограниченном временном окне. Запишите число найденных событий.
  4. Сверьте время, route, status и номер попытки. Отдельно отметьте retry, очередь и возможный clock skew.
  5. Если приложение подтверждено, проверьте dependency event и только затем формулируйте рабочую гипотезу о причине.
  6. Если приложение не найдено, проверьте timeout, маршрутизацию, collector, формат идентификатора и задержку доставки.
  7. Запишите вывод как наблюдение и следующий тест: например, «нет application event; проверить timeout и collector».
  8. После изменения повторите тот же запрос и убедитесь, что цепочка снова собирается по идентификатору.
\n

Где метод перестаёт работать

\n

Лог может быть неполным. Sampling удаляет часть событий. Буферизация меняет порядок доставки. Collector может отбросить запись или обрезать длинное сообщение. Часы сервисов могут расходиться. Поэтому близкое время не заменяет ключ корреляции, а найденный ключ не гарантирует полноту цепочки.

\n

Один request id может пережить retry или быть создан заново на новой попытке. Это надо выяснять по attempt, span id и временным интервалам. Trace context помогает передавать связь между HTTP-границами, но не доказывает, что каждый сервис записал событие или что наблюдаемый участок был причиной сбоя.

\n

Структурированные поля упрощают поиск, но не делают журнал достоверным автоматически. Формат RFC 5424 предусматривает отдельную область для parseable structured data; конкретная система всё равно может неправильно настроить поля, транспорт или collector. Если корреляция часто ломается, сначала исправьте контракт логирования. Новый экран наблюдаемости не компенсирует отсутствующий идентификатор.

\n

Метод также не заменяет нагрузочный анализ и проверку контракта ответа. Если 502 появляется только при перегрузке, одного разбора карточки недостаточно. Нужны распределение длительностей, число retry, состояние очередей и лимиты соединений. Эти данные расширяют расследование, но не отменяют первый шаг: определить, где появился внешний статус.

\n

Критерий готовности

\n

Разбор готов, когда для одной повторной попытки можно показать access event, application event или явно подтверждённый разрыв, согласованные время и маршрут, номер попытки и следующий проверяемый вывод. Исправление готово, когда после него тот же сценарий даёт ожидаемый статус, цепочка событий собирается по идентификатору, а отрицательный путь остаётся различимым. Формулировка «проблема решена» без этих наблюдений недостаточна.

\n

Проверяемые источники

\n" + "excerpt": "Практический разбор 502 по access и application log: как установить границу отказа, проверить корреляцию по request ID и не объявить приложение причиной без подтверждения.", + "contentHtml": "

Клиент получает 502, а в журнале приложения не находится запись с тем же запросом. Самый дорогой ответ в этой ситуации — открыть последний релиз и начать исправлять код. 502 мог сформировать proxy после таймаута, ошибки соединения или недействительного ответа upstream. Могла потеряться и сама запись: sampling, collector или неверное поле корреляции оставляют ту же картину.

\n

Цена неверной атрибуции измеряется не только часами. Команда откатывает исправный релиз, повышает таймаут без проверки границы или добавляет повторные запросы к уже перегруженной зависимости. Следующий дежурный получает уверенную формулировку «упало приложение» и повторяет тот же маршрут расследования.

\n

Надёжный разбор начинается с вопроса «кто сформировал этот статус?». Сначала фиксируем событие на границе, затем связываем его с попыткой в приложении по идентификатору. Только найденное и согласованное событие разрешает перейти к зависимости. Если запись не найдена, это результат наблюдения, а не доказательство того, что запрос не дошёл.

\n

502 описывает границу, а не виновника

\n

RFC 9110 определяет 502 как статус, который сервер в роли gateway или proxy возвращает после недействительного ответа от входного сервера, к которому он обращался для выполнения запроса. В этой формулировке нет имени конкретного виноватого сервиса. Статус сообщает о проблеме на участке между посредником и upstream либо о том, что посредник не смог принять его ответ.

\n

Отделяйте 502 от 504. 502 говорит о недействительном ответе, а 504 — об отсутствии своевременного ответа от upstream или другой вышестоящей системы. На практике конкретный proxy может использовать собственные детали диагностики и маппинг ошибок, поэтому RFC объясняет семантику статуса, но не заменяет документацию вашего узла.

\n

В access-событии зафиксируйте субъект результата: edge.status=502, edge.name и upstream.name, если последний известен. Добавьте route, method, точное время, duration_ms, request_id, trace_id и attempt. Набор полей не универсален, но без него внешний лог показывает симптом и почти не помогает выбрать следующий запрос.

\n

Сначала докажите саму связь

\n

Для одной попытки нужны как минимум access event на границе и application event в сервисе. Ищите по точному request_id или по корректному trace context. Маршрут и временное окно — вторичные признаки: два одинаковых запроса могут прийти одновременно, а часы сервисов могут иметь небольшой сдвиг.

\n

W3C Trace Context задаёт формат заголовка traceparent для передачи идентификаторов между HTTP-границами. Это полезный транспорт связи, но не обещание полной записи: sampled-флаг не гарантирует, что трасса будет сохранена, а промежуточный узел может создать новый контекст при невалидном входе. Поэтому проверяйте и сам заголовок, и фактическое событие в каждом важном слое.

\n

Корреляция считается подтверждённой, если совпали не только ID, но и операция: маршрут, время, номер попытки и ожидаемый слой. Одного одинакового ID мало. При retry ищите дочерние span или отдельные значения attempt; иначе можно принять ответ первой попытки за результат второй.

\n
\"Диаграмма
Идентификатор направляет поиск от внешнего симптома к событию приложения, но причинность требует проверки времени, статуса и границы операции.
\n

Четыре наблюдаемые исхода

\n
Матрица первой проверки для одной попытки 502
Что найденоЧто это подтверждаетЧто ещё не доказаноСледующий запрос
Edge 502; application event отсутствуетНа границе зафиксирован 502Неизвестно, дошёл ли запрос до приложенияПроверить timeout, маршрут, collector и формат ID
Edge 502; application 500 с тем же ID и attemptПриложение обработало эту попытку с ошибкойНеизвестна первопричина внутри приложения или зависимостиСверить dependency event, длительность и лимиты
Edge 502; application 200 с тем же IDПриложение завершило свою операцию успешноНе объяснено расхождение с клиентским статусомПроверить retry, cache и mapping ответа на proxy
В edge нет request IDСхема наблюдения неполнаНельзя надёжно связать слои по времениИсправить генерацию и передачу ID до следующего разбора
\n

У таблицы есть важная асимметрия. Строка «application event отсутствует» допускает несколько причин: запрос мог остановиться до приложения, запись могла не попасть в хранилище, а идентификатор мог измениться по дороге. Поэтому формулировка должна оставаться отрицательной: «событие не найдено в проверенном источнике и окне», а не «приложение не получило запрос».

\n

Воспроизводимая классификация без ложной причинности

\n

Ниже — самостоятельный пример на JavaScript. События вымышлены и нужны только для проверки корреляции. Функция не пытается угадать первопричину: она различает наличие согласованного application event и оставляет отдельный статус для отсутствующей записи.

\n
const edgeEvents = [\n  { requestId: 'r-1', attempt: 1, status: 502, durationMs: 3000 },\n  { requestId: 'r-2', attempt: 1, status: 502, durationMs: 420 },\n];\n\nconst applicationEvents = [\n  { requestId: 'r-2', attempt: 1, status: 500, durationMs: 180, error: 'dependency unavailable' },\n];\n\nfunction classify(edgeEvent, appEvents) {\n  const match = appEvents.find((event) =>\n    event.requestId === edgeEvent.requestId\n    && event.attempt === edgeEvent.attempt\n  );\n\n  if (!match) return 'application-event-not-found';\n  if (match.status >= 500) return 'application-error-observed';\n  return 'application-success-edge-failure-needs-check';\n}\n\nconsole.log(edgeEvents.map((event) => ({\n  requestId: event.requestId,\n  result: classify(event, applicationEvents),\n})));\n// r-1: application-event-not-found\n// r-2: application-error-observed
\n

Для r-1 результат ограничен: в переданном массиве нет совпадения по двум полям. Он не различает timeout, потерю записи и ошибку маршрутизации. Для r-2 наблюдается application 500 с теми же ID и попыткой. Это подтверждает обработку запроса приложением, но не доказывает, что строка dependency unavailable — первопричина; её надо сопоставить с журналом зависимости.

\n

В рабочей системе добавьте проверку схемы до классификации: ID не должен быть пустым, attempt — неотрицательным целым, а время — разбираться однозначно. Храните число найденных событий и источник поиска. Не помещайте в общий лог токены, тело формы, email или сырые заголовки; для закрытой корреляции используйте разрешённый идентификатор и действующие правила хранения.

\n

Порядок расследования

\n
  1. Сохраните одну карточку запроса из edge log: время с часовым поясом, route, method, status, duration, request ID, trace ID и attempt.
  2. Определите узел, который записал 502, и зафиксируйте выбранный им upstream. Не называйте приложение причиной только по URL.
  3. Найдите application events по точному ID в узком окне. Запишите источник, диапазон времени и количество совпадений.
  4. Сверьте route, время, attempt, статус и длительность. Отдельно отметьте retry, очередь и clock skew.
  5. Если application event согласован, проверьте зависимость: её ID операции, таймаут, ответ, число попыток и лимит соединений.
  6. Если event не найден, отдельно проверьте путь до приложения, правила маршрутизации, collector, sampling и преобразование ID.
  7. Сформулируйте вывод в двух строках: наблюдение и следующий тест. Например: «edge 502; application event не найден в окне 12:00:00–12:00:05; проверить timeout и collector».
  8. После изменения повторите безопасный запрос с тем же набором полей и сравните положительный и отрицательный пути.
\n

Ограничения применимости

\n

Метод требует хотя бы одного надёжного события на границе. Если access log сам неполон, расследование начинается с восстановления его схемы, а не с чтения application stack trace. Sampling, буферизация и задержка доставки могут удалить или переставить события. Близкое время не заменяет ID, а найденный ID не гарантирует полноту цепочки.

\n

Retry меняет картину. Прокси может повторить запрос, приложение — создать новый span, а пользователь — отправить его ещё раз. Один request ID иногда живёт дольше одной попытки, иногда меняется на границе. Нужны attempt, span ID и правила, по которым именно ваша система связывает повторы.

\n

Структурированный лог повышает разбираемость, но не делает данные истинными автоматически. RFC 5424 описывает structured data как parseable-формат и допускает, что collector проигнорирует некорректный элемент. Это означает практическую границу: схему полей надо тестировать на реальном транспорте, а не только на примере конфигурации.

\n

Разбор одной карточки не заменяет анализ нагрузки. Если 502 появляется только при насыщении пула, нужны распределение задержек, число retry, состояние очередей и лимиты соединений. Если проблема связана с TLS, DNS, HTTP/2 или конкретным форматом ответа, потребуется проверка соответствующего протокола. Приведённый алгоритм выбирает границу следующего теста, но не обещает одну причину для всех 502.

\n

Критерий готовности

\n

Расследование можно закрывать, когда для повторённой попытки показаны access event и application event либо явно зафиксирован разрыв наблюдения; совпадают маршрут, время и attempt; зависимость проверена там, где это разрешает цепочка; назван следующий измеримый результат. Исправление подтверждено только после повторного запроса: ожидаемый статус получен, цепочка снова связывается по ID, а отрицательный путь остаётся различимым.

\n

Проверяемые источники

\n" }