В процессе разработки бизнес-логики в системе «1С:Предприятие» перед программистом регулярно возникает задача: определить, какие именно реквизиты шапки или строки табличных частей были изменены пользователем (или программно) перед тем, как документ будет окончательно записан в базу данных. Это необходимо для ведения детального лога изменений, отправки точечных уведомлений (например, SMS-клиенту при изменении даты доставки), запуска бизнес-процессов интеграции или оптимизации проведения документа.
На форумах часто предлагают простые решения в лоб, которые при детальном анализе оказываются крайне неоптимальными и могут привести к серьезному падению производительности информационной базы. В этой статье мы подробно разберем, как правильно решать данную задачу, избегая критических ошибок, связанных с производительностью СУБД и логикой работы платформы.
Для того чтобы понять, что изменилось в документе, нам нужно сопоставить два состояния данных:
ЭтотОбъект) непосредственно перед записью. Эти данные еще не сохранены в базу данных.ЭтотОбъект.Ссылка).Анализ необходимо выполнять строго в обработчике события ПередЗаписью (в модуле объекта документа). Именно в этот момент у нас есть одновременный доступ и к модифицированным данным в оперативной памяти, и к старым данным в СУБД через обращение к ссылке. После перехода в обработчик ПриЗаписи старые данные в базе данных будут перезаписаны новыми, и сравнение станет невозможным.
Проанализируем основные подходы к сравнению реквизитов шапки, табличных частей, а также рассмотрим архитектурные нюансы передачи результатов этого анализа.
Частой ошибкой начинающих разработчиков является обход метаданных в цикле с непосредственным обращением к реквизитам ссылки. Рассмотрим типичный пример неоптимального кода:
// НЕПРАВИЛЬНЫЙ И НЕОПТИМАЛЬНЫЙ КОД!
Для каждого Реквизит Из Метаданные().Реквизиты Цикл
Если ЭтотОбъект[Реквизит.Имя] <> Ссылка[Реквизит.Имя] Тогда
// Реквизит изменился
КонецЕсли;
КонецЦикла;
В чем заключается опасность такого подхода?
При обращении к свойству через точку от ссылки (или через квадратные скобки вида Ссылка[Реквизит.Имя]) срабатывает механизм «ленивой загрузки» (lazy loading) платформы «1С:Предприятие». Если объект еще не был кэширован в текущем сеансе, то на каждом шаге цикла платформа будет делать отдельный SQL-запрос к базе данных для получения значения ровно одного реквизита. Если у вашего документа 50 реквизитов шапки, система выполнит 50 последовательных запросов к СУБД. При высокой интенсивности работы пользователей это мгновенно приведет к критическому снижению производительности и блокировкам таблиц.
Как сделать правильно?
Необходимо получить все старые значения реквизитов шапки из базы данных одним быстрым запросом. Для этого мы можем использовать либо стандартный механизм БСП (Библиотеки Стандартных Подсистем), либо написать прямой запрос самостоятельно.
Рассмотрим вариант с использованием функции БСП ОбщегоНазначения.ЗначенияРеквизитовОбъекта. Данный метод формирует один SQL-запрос ко всем необходимым полям таблицы и возвращает структуру со значениями:
// ПРАВИЛЬНЫЙ И ОПТИМИЗИРОВАННЫЙ ВАРИАНТ
Функция ОпределитьИзмененияШапки() Экспорт
ПереченьИзменений = Новый Структура;
// Если документ новый, то изменились все заполненные реквизиты
Если ЭтоНовый() Тогда
Для каждого Реквизит Из Метаданные().Реквизиты Цикл
Если ЗначениеЗаполнено(ЭтотОбъект[Реквизит.Имя]) Тогда
ПереченьИзменений.Вставить(Реквизит.Имя, ЭтотОбъект[Реквизит.Имя]);
КонецЕсли;
КонецЦикла;
Возврат ПереченьИзменений;
КонецЕсли;
// Формируем список имен реквизитов для анализа
МассивИмен = Новый Массив;
Для каждого Реквизит Из Метаданные().Реквизиты Цикл
МассивИмен.Добавить(Реквизит.Имя);
КонецЦикла;
// Добавим стандартные реквизиты, если они важны (например, ПометкаУдаления, Проведен)
МассивИмен.Добавить("ПометкаУдаления");
МассивИмен.Добавить("Проведен");
МассивИмен.Добавить("Дата");
МассивИмен.Добавить("Номер");
// Читаем данные из БД ОДНИМ запросом через БСП
СтарыеЗначения = ОбщегоНазначения.ЗначенияРеквизитовОбъекта(Ссылка, МассивИмен);
// Сравниваем значения в оперативной памяти
Для каждого ИмяРеквизита Из МассивИмен Цикл
Если ЭтотОбъект[ИмяРеквизита] <> СтарыеЗначения[ИмяРеквизита] Тогда
ПереченьИзменений.Вставить(ИмяРеквизита, Новый Структура("Старое, Новое", СтарыеЗначения[ИмяРеквизита], ЭтотОбъект[ИмяРеквизита]));
КонецЕсли;
КонецЦикла;
Возврат ПереченьИзменений;
КонецФункции
Этот алгоритм работает максимально быстро, так как обращение к базе данных происходит ровно один раз, независимо от количества реквизитов в документе.
Сравнение строк табличных частей — задача гораздо более сложная. Начинающие разработчики часто пытаются сравнивать строки по индексам (например, строка 0 в объекте сравнивается со строкой 0 в базе данных). Однако такой алгоритм полностью ломается, если пользователь:
Для надежного сопоставления строк табличной части, особенно в нетиповых конфигурациях, где у строк нет встроенного уникального идентификатора (как, например, КлючСвязи в некоторых типовых решениях), применяется элегантный математический метод — свертка таблиц с весовыми коэффициентами.
Рассмотрим этот алгоритм по шагам:
ТаблицаНовая.ТаблицаСтарая.Коэффициент.Коэффициент значением 1 для новой таблицы и значением -1 для старой таблицы.Номенклатура, Количество, Цена, Сумма), а в качестве суммируемой колонки — наш Коэффициент.Анализ результата свертки:
Коэффициент равен 0 — это полностью совпавшие строки. Они присутствовали и в старой, и в новой версии документа в неизменном виде.Выясним, как выглядит программная реализация этого красивого и быстрого метода без циклов по строкам:
Функция СравнитьТабличнуюЧастьТовары() Экспорт
РезультатСравнения = Новый Структура("Изменена, ДобавленныеСтроки, УдаленныеСтроки", Ложь, Новый Массив, Новый Массив);
// 1. Получаем новую ТЧ из памяти
ТаблицаНовая = ЭтотОбъект.Товары.Выгрузить();
ТаблицаНовая.Колонки.Добавить("Коэффициент", Новый ОписаниеТипов("Число"));
ТаблицаНовая.ЗаполнитьЗначения(1, "Коэффициент");
// 2. Получаем старую ТЧ из БД с помощью запроса
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| Товары.Номенклатура КАК Номенклатура,
| Товары.Количество КАК Количество,
| Товары.Цена КАК Цена,
| Товары.Сумма КАК Сумма
|ИЗ
| Документ.РеализацияТоваровУслуг.Товары КАК Товары
|ГДЕ
| Товары.Ссылка = &Ссылка";
Запрос.УстановитьПараметр("Ссылка", Ссылка);
ТаблицаСтарая = Запрос.Выполнить().Выгрузить();
ТаблицаСтарая.Колонки.Добавить("Коэффициент", Новый ОписаниеТипов("Число"));
ТаблицаСтарая.ЗаполнитьЗначения(-1, "Коэффициент");
// 3. Объединяем таблицы
Для каждого СтрокаСтарая Из ТаблицаСтарая Цикл
ЗаполнитьЗначенияСвойств(ТаблицаНовая.Добавить(), СтрокаСтарая);
КонецЦикла;
// 4. Сворачиваем по всем ключевым аналитикам и показателям
ТаблицаНовая.Свернуть("Номенклатура, Количество, Цена, Summa", "Коэффициент");
// 5. Проверяем результаты
Для каждого Строка Из ТаблицаНовая Цикл
Если Строка.Коэффициент = 1 Тогда
РезультатСравнения.Изменена = Истина;
// Данные добавлены или изменены на новые
РезультатСравнения.ДобавленныеСтроки.Добавить(Новый Структура("Номенклатура, Количество, Цена", Строка.Номенклатура, Строка.Количество, Строка.Цена));
ИначеЕсли Строка.Коэффициент = -1 Тогда
РезультатСравнения.Изменена = Истина;
// Данные были удалены или изменены на другие
РезультатСравнения.УдаленныеСтроки.Добавить(Новый Структура("Номенклатура, Количество, Цена", Строка.Номенклатура, Строка.Количество, Строка.Цена));
КонецЕсли;
КонецЦикла;
Возврат РезультатСравнения;
КонецФункции
Использование этого подхода позволяет избежать тяжелых вложенных циклов сравнения "каждый с каждым" и корректно отрабатывает любые манипуляции со строками, включая их перетаскивание, удаление и сортировку.
Если перед вами стоит задача не детально расписать, какой именно реквизит поменялся, а максимально быстро понять, изменился ли документ в принципе (например, для принятия решения о регистрации объекта в планах обмена), можно использовать сериализацию в формат XML или JSON.
Рассмотрим, как это работает. Мы сериализуем текущий объект и объект, прочитанный из базы данных по ссылке, исключая при этом служебные свойства (такие как ВерсияДанных, которая гарантированно меняется при любой записи). Сравнивая хэш-коды (MD5) или просто строки полученных текстов сериализации, мы мгновенно делаем вывод о наличии изменений.
Посмотрим на пример реализации данного метода с использованием формата XML:
Функция ОбъектДействительноМодифицирован() Экспорт
Если ЭтоНовый() Тогда
Возврат Истина;
КонецЕсли;
// Получаем XML-представление текущего объекта из оперативной памяти
ЗаписьXMLНовый = Новый ЗаписьXML;
ЗаписьXMLНовый.УстановитьСтроку();
ЗаписатьXML(ЗаписьXMLНовый, ЭтотОбъект);
СтрокаНовая = ЗаписьXMLНовый.Закрыть();
// Получаем XML-представление объекта из базы данных
СтарыйОбъект = Ссылка.ПолучитьОбъект();
ЗаписьXMLСтарый = Новый ЗаписьXML;
ЗаписьXMLСтарый.УстановитьСтроку();
ЗаписатьXML(ЗаписьXMLСтарый, СтарыйОбъект);
СтрокаСтарая = ЗаписьXMLСтарый.Закрыть();
// Чтобы избежать ложного срабатывания на версию данных, удалим ее из строк
// (в XML это узел ВерсияДанных или DataVersion)
СтрокаНовая = СтрЗаменить(СтрокаНовая, ЭтотОбъект.ВерсияДанных, "");
СтрокаСтарая = СтрЗаменить(СтрокаСтарая, СтарыйОбъект.ВерсияДанных, "");
Возврат (СтрокаНовая <> СтрокаСтарая);
КонецФункции
Этот способ позволяет выявить изменения в любых реквизитах шапки, адресах, контактной информации и во всех табличных частях одновременно, написав всего несколько строк кода.
Разберем важнейший архитектурный аспект, о котором часто забывают разработчики. Анализ изменений мы обязаны проводить в обработчике ПередЗаписью, так как только там доступна старая версия из СУБД. Но выполнять целевые действия, завязанные на эти изменения (например, отправлять SMS-сообщение клиенту, отправлять HTTP-запрос во внешнюю ERP-систему или создавать подчиненные документы), в обработчике ПередЗаписью категорически запрещено!
Почему это опасно?
Обработчик ПередЗаписью выполняется внутри транзакции записи. После его завершения платформа выполняет проведение документа или стандартные проверки (например, на наличие отрицательных остатков на складах). Если проверка не пройдет, транзакция будет отменена (произойдет Rollback), документ не запишется в базу данных. Но если вы уже отправили SMS или API-запрос к внешнему сервису в ПередЗаписью, это действие невозможно будет "откатить". В результате клиент получит ложное уведомление, а внешняя система рассинхронизируется с вашей базой.
Правильное архитектурное решение:
ПередЗаписью выполняем анализ изменений.ЭтотОбъект.ДополнительныеСвойства. Это системная структура, которая живет вместе с объектом в памяти на протяжении всей транзакции.ПриЗаписи (или после успешного завершения транзакции в подписках) проверяем наличие сохраненного анализа в ДополнительныеСвойства и выполняем все необходимые внешние действия.Проанализируем пример реализации данной схемы:
Процедура ПередЗаписью(Отказ, РежимЗаписи, РежимПроведения)
Если ОбменДанными.Загрузка Тогда
Возврат;
КонецЕсли;
// Шаг 1: Анализируем изменения
Изменения = ОпределитьИзмененияШапки(); // Наша функция из Раздела 1
// Шаг 2: Фиксируем изменения в Дополнительных Свойствах
ЭтотОбъект.ДополнительныеСвойства.Вставить("РезультатАнализаИзменений", Изменения);
КонецПроцедуры
Процедура ПриЗаписи(Отказ)
Если Отказ ИЛИ ОбменДанными.Загрузка Тогда
Возврат;
КонецЕсли;
// Шаг 3: Читаем сохраненные изменения и безопасно выполняем действия
Если ЭтотОбъект.ДополнительныеСвойства.Свойство("РезультатАнализаИзменений") Тогда
Изменения = ЭтотОбъект.ДополнительныеСвойства.РезультатАнализаИзменений;
Если Изменения.Свойство("ДатаДоставки") Тогда
// База данных гарантированно успешно записана!
// Теперь можно безопасно отправлять уведомление
ОтправитьСмсОбИзмененииДатыДоставки(ЭтотОбъект.Контрагент, Изменения.ДатаДоставки.Новое);
КонецЕсли;
КонецЕсли;
КонецПроцедуры
Используя эту связку, вы гарантируете стопроцентную согласованность данных между 1С и любыми внешними системами и сервисами уведомлений.
В современных конфигурациях перед написанием собственного кода обязательно проверьте, не решена ли задача стандартными инструментами:
ИсторияДанных.