Как исправить ошибку восстановления версии объекта при изменении его метаданных в 1С?

Программист 1С v8.3 (Управляемые формы) 1С:Управление торговлей Управленческий учет IT и автоматизация бизнеса
← На главную

Каждый разработчик 1С, работающий с типовыми или доработанными конфигурациями, рано или поздно сталкивается с подсистемой «Версионирование объектов» из Библиотеки стандартных подсистем (БСП). Этот механизм незаменим для аудита изменений: он позволяет увидеть, кто, когда и что изменил в документе или справочнике, а при необходимости — вернуться к предыдущему состоянию объекта. Для интерактивного отката правок конкретного пользователя можно задействовать готовое решение для отката изменений пользователя.

Однако в старых версиях БСП (и, соответственно, в таких конфигурациях, как УТ 11.1, УТ 10.3, КА 1.1, УПП 1.3) кроется серьезная архитектурная проблема. Стоит программисту добавить новый реквизит в документ или справочник, как вся накопленная история версий этого объекта становится «бомбой замедленного действия». При попытке восстановить любую версию, записанную до внесения изменений в метаданные, система выдает критическую ошибку и аварийно завершает процесс восстановления.

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

Выясняем причину: почему падает стандартный механизм?

Чтобы понять, где именно происходит сбой, давайте обратимся к программному коду БСП. Падение происходит на этапе десериализации сохраненной версии документа:


ЧтениеFastInfoSet.УстановитьДвоичныеДанные(ДвоичныеДанные);

Попытка
    Объект = ПрочитатьXML(ЧтениеFastInfoSet);
Исключение
    // Запись в журнал регистрации...
    ТекстСообщенияОбОшибке = НСтр("ru = 'Не удалось перейти на выбранную версию...'");
КонецПопытки;

В данном процессе задействованы два ключевых звена, сочетание которых и приводит к ошибке:

  1. Формат FastInfoSet: Платформа использует этот формат для бинарного сжатия XML-данных с целью экономии места в базе данных. Проблема в том, что FastInfoSet очень чувствителен к структуре сериализованных данных. Если метаданные объекта изменились, объект чтения теряет возможность корректно инициализировать поток для сопоставления старой структуры с новой конфигурацией.
  2. Жесткая десериализация через ПрочитатьXML(): Метод платформы ПрочитатьXML() выполняет строгое сопоставление структуры XML-файла с текущим описанием объекта в конфигураторе. Если в конфигурации появился новый реквизит (которого, естественно, еще нет в старом XML-файле версии), то платформа ожидает его увидеть, не находит и выбрасывает системное исключение Ошибка преобразования данных XML. Восстановление становится невозможным.

Давайте разберем по шагам, как можно обойти это платформенное ограничение и заставить систему «мягко» читать устаревшие версии объектов.

---

Решение: Переделка штатного метода под «мягкое» чтение XML

Для того чтобы обойти жесткое ограничение метода ПрочитатьXML(), нам потребуется модифицировать общий модуль, отвечающий за версионирование объектов (обычно это ВерсионированиеОбъектов или аналогичный в вашей конфигурации). Нам нужно реализовать двухэтапный алгоритм:

  1. Попытаться прочитать данные стандартным быстрым способом.
  2. Если стандартный метод вызвал исключение — перехватить его, распаковать бинарные данные FastInfoSet (или ZIP-архив) в текстовый XML и выполнить ручной разбор тегов, сопоставляя только те реквизиты, которые реально присутствуют в сохраненной версии.

Рассмотрим подробнее программную реализацию этого подхода.

Шаг 1: Основной метод восстановления объекта

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


Функция ВосстановитьОбъектПоXML(АдресВоВременномХранилище, Ссылка) Экспорт
    
    УстановитьПривилегированныйРежим(Истина);
    
    ДвоичныеДанные = ПолучитьИзВременногоХранилища(АдресВоВременномХранилище);
    
    ЧтениеFastInfoSet = Новый ЧтениеFastInfoSet;
    Попытка
        ЧтениеFastInfoSet.УстановитьДвоичныеДанные(ДвоичныеДанные);    
        ЧтениеFastInfoSet.Прочитать();
        ЧтениеFastInfoSet.УстановитьДвоичныеДанные(ДвоичныеДанные);
    Исключение
        // Если FastInfoSet не смог прочитать двоичные данные напрямую, 
        // возможно, они упакованы в ZIP. Распакуем их во временный файл.
        ИмяЗИП = КаталогВременныхФайлов() + Лев(Новый УникальныйИдентификатор(), 8) + ".zip";
        ДвоичныеДанные.Записать(ИмяЗИП);
        
        ФайлАрхива = Новый ЧтениеZIPФайла(ИмяЗИП); 
        ИмяФайлаXML = КаталогВременныхФайлов() + ФайлАрхива.Элементы[0].ПолноеИмя;
        ФайлАрхива.Извлечь(ФайлАрхива.Элементы[0], КаталогВременныхФайлов(), РежимВосстановленияПутейФайловZIP.НеВосстанавливать); 
        ФайлАрхива.Закрыть();
        
        Текст = Новый ТекстовыйДокумент;
        Текст.Прочитать(ИмяФайлаXML);
        
        ЧтениеFastInfoSet = Новый ЧтениеXML;
        ЧтениеFastInfoSet.УстановитьСтроку(Текст.ПолучитьТекст());
        
        Текст = "";
        УдалитьФайлы(ИмяФайлаXML);
        УдалитьФайлы(ИмяЗИП);
    КонецПопытки;
    
    ФлагИсключение = Ложь;
    
    Попытка
        // Пробуем стандартное платформенное чтение
        Объект = ПрочитатьXML(ЧтениеFastInfoSet);
    Исключение 
        // Если упали — взводим флаг для запуска ручного парсинга
        ФлагИсключение = Истина;
    КонецПопытки;
    
    Если ФлагИсключение Тогда
        ФлагИсключение = Ложь;
        Попытка
            // Запускаем ручной парсер, который вернет структуру прочитанных данных
            Результат = РазборПредставленияОбъектаXML(ДвоичныеДанные, Ссылка);
                
            Объект = Ссылка.ПолучитьОбъект();
            
            // Заполняем реквизиты шапки документа/справочника
            Для Каждого Реквизит Из Результат.Реквизиты Цикл
                ИмяРеквизита = Реквизит.НаименованиеРеквизита;
                
                // Пропускаем системные реквизиты, управляемые платформой
                Если ИмяРеквизита = "Ref" Или ИмяРеквизита = "UUID" Тогда
                    Продолжить;
                КонецЕсли;
                
                // Отрабатываем стандартные реквизиты
                Если ИмяРеквизита = "DeletionMark" Тогда
                    Объект.ПометкаУдаления = Реквизит.ЗначениеРеквизита;
                    Продолжить;
                КонецЕсли;
                Если ИмяРеквизита = "Date" Тогда
                    Объект.Дата = Реквизит.ЗначениеРеквизита;
                    Продолжить;
                КонецЕсли;
                Если ИмяРеквизита = "Number" Тогда
                    Объект.Номер = Реквизит.ЗначениеРеквизита;
                    Продолжить;
                КонецЕсли;
                Если ИмяРеквизита = "Posted" Тогда
                    Объект.Проведен = Реквизит.ЗначениеРеквизита;
                    Продолжить;
                КонецЕсли;
                
                // Заполняем только те реквизиты, которые реально существуют в текущих метаданных
                Если Объект.Метаданные().Реквизиты.Найти(ИмяРеквизита) <> Неопределено Тогда
                    Объект[ИмяРеквизита] = Реквизит.ЗначениеРеквизита;
                КонецЕсли;
            КонецЦикла;
            
            // Заполняем табличные части
            Для Каждого ТабЧастьСоответствие Из Результат.ТабличныеЧасти Цикл
                ИмяТЧ = ТабЧастьСоответствие.Ключ;
                ТЗ = ТабЧастьСоответствие.Значение;
                
                Если ТЗ <> Неопределено И ТЗ.Количество() > 0 Тогда
                    // Проверяем, существует ли табличная часть в текущих метаданных объекта
                    Если ОбщегоНазначения.ЕстьТабЧастьДокумента(ИмяТЧ, Объект.Метаданные()) Тогда
                        Объект[ИмяТЧ].Очистить(); 
                        
                        Для Каждого СтрокаТЗ Из ТЗ Цикл
                            НоваяСтрока = Объект[ИмяТЧ].Добавить();
                            ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаТЗ);
                        КонецЦикла;
                    КонецЕсли;
                КонецЕсли;
            КонецЦикла;
            
        Исключение
            ФлагИсключение = Истина;
        КонецПопытки;
    КонецЕсли;
    
    Если ФлагИсключение Тогда
        ТекстСообщенияОбОшибке = ПодробноеПредставлениеОшибки(ИнформацияОбОшибке());
        ЗаписьЖурналаРегистрации("Версионирование.ОшибкаВосстановления", УровеньЖурналаРегистрации.Ошибка,,, ТекстСообщенияОбОшибке);
        Возврат Неопределено;
    КонецЕсли;
        
    Возврат Объект;
    
КонецФункции

Шаг 2: Реализация ручного парсера XML

Для разбора структуры XML воспользуемся последовательным чтением через ЧтениеXML. Наша задача — обойти дерево XML-документа, собрать имена реквизитов и их значения, а также воссоздать строки табличных частей в виде таблиц значений. Такой подход полностью застрахует нас от ошибок преобразования типов при изменении состава метаданных.


Функция РазборПредставленияОбъектаXML(ДвоичныеДанные, Ссылка)
    
    // Структура для хранения результатов парсинга
    Результат = Новый Структура;
    
    ТаблицаРеквизитов = Новый ТаблицаЗначений;
    ТаблицаРеквизитов.Колонки.Добавить("НаименованиеРеквизита", Новый ОписаниеТипов("Строка"));
    ТаблицаРеквизитов.Колонки.Добавить("ЗначениеРеквизита");
    
    Результат.Вставить("Реквизиты", ТаблицаРеквизитов);
    Результат.Вставить("ТабличныеЧасти", Новый Соответствие);
    
    ЧтениеXML = Новый ЧтениеXML;
    
    // Пытаемся инициализировать чтение двоичных данных
    Попытка
        ЧтениеXML.УстановитьДвоичныеДанные(ДвоичныеДанные);
    Исключение
        // Если это сжатый ZIP, распакуем аналогично первому шагу
        ИмяЗИП = КаталогВременныхФайлов() + Лев(Новый УникальныйИдентификатор(), 8) + ".zip";
        ДвоичныеДанные.Записать(ИмяЗИП);
        ФайлАрхива = Новый ЧтениеZIPФайла(ИмяЗИП); 
        ИмяФайлаXML = КаталогВременныхФайлов() + ФайлАрхива.Элементы[0].ПолноеИмя;
        ФайлАрхива.Извлечь(ФайлАрхива.Элементы[0], КаталогВременныхФайлов(), РежимВосстановленияПутейФайловZIP.НеВосстанавливать); 
        ФайлАрхива.Закрыть();
        
        Текст = Новый ТекстовыйДокумент;
        Текст.Прочитать(ИмяФайлаXML);
        
        ЧтениеXML.УстановитьСтроку(Текст.ПолучитьТекст());
        УдалитьФайлы(ИмяФайлаXML);
        УдалитьФайлы(ИмяЗИП);
    КонецПопытки;
    
    // Запускаем цикл последовательного чтения XML
    ТекущаяТабличнаяЧасть = "";
    ТекущаяСтрокаТЧ = Неопределено;
    ТаблицаТЧ = Неопределено;
    
    Пока ЧтениеXML.Прочитать() Цикл
        ТипУзла = ЧтениеXML.ТипУзла;
        
        Если ТипУзла = ТипУзлаЧтенияXML.НачалоЭлемента Тогда
            ИмяЭлемента = ЧтениеXML.Имя;
            
            // Проверяем, не является ли элемент объявлением табличной части
            Если Найти(ИмяЭлемента, "TabularSection.") > 0 Или Найти(ИмяЭлемента, "ТабличнаяЧасть.") > 0 Тогда
                ТекущаяТабличнаяЧасть = Сред(ИмяЭлемента, Найти(ИмяЭлемента, ".") + 1);
                ТаблицаТЧ = Новый ТаблицаЗначений;
                Результат.ТабличныеЧасти.Вставить(ТекущаяТабличнаяЧасть, ТаблицаТЧ);
                Продолжить;
            КонецЕсли;
            
            // Если мы внутри табличной части и встретили строку (обычно элемент "Row" или "Строка")
            Если ЗначениеЗаполнено(ТекущаяТабличнаяЧасть) И (ИмяЭлемента = "Row" Или ИмяЭлемента = "Строка") Тогда
                ТекущаяСтрокаТЧ = ТаблицаТЧ.Добавить();
                Продолжить;
            КонецЕсли;
            
            // Чтение значения реквизита шапки или поля табличной части
            Если ЧтениеXML.Прочитать() И ЧтениеXML.ТипУзла = ТипУзлаЧтенияXML.Текст Тогда
                ЗначениеТекста = ЧтениеXML.Значение;
                
                Если ЗначениеЗаполнено(ТекущаяТабличнаяЧасть) Тогда
                    // Добавляем колонку в ТЗ табличной части, если ее еще нет
                    Если ТаблицаТЧ.Колонки.Найти(ИмяЭлемента) = Неопределено Тогда
                        ТаблицаТЧ.Колонки.Добавить(ИмяЭлемента);
                    КонецЕсли;
                    ТекущаяСтрокаТЧ[ИмяЭлемента] = ЗначениеТекста;
                Else
                    // Реквизит шапки
                    НовыйРеквизит = ТаблицаРеквизитов.Добавить();
                    НовыйРеквизит.НаименованиеРеквизита = ИмяЭлемента;
                    НовыйРеквизит.ЗначениеРеквизита = ЗначениеТекста;
                КонецЕсли;
            КонецЕсли;
            
        ИначеЕсли ТипУзла = ТипУзлаЧтенияXML.КонецЭлемента Тогда
            ИмяЭлемента = ЧтениеXML.Имя;
            // Если вышли из табличной части, сбрасываем контекст
            Если Найти(ИмяЭлемента, "TabularSection.") > 0 Или Найти(ИмяЭлемента, "ТабличнаяЧасть.") > 0 Тогда
                ТекущаяТабличнаяЧасть = "";
                ТаблицаТЧ = Неопределено;
            КонецЕсли;
        КонецЕсли;
    КонецЦикла;
    
    ЧтениеXML.Закрыть();
    Возврат Результат;
    
КонецФункции

Важное примечание по типам данных: Представленный выше алгоритм парсинга считывает значения как строки. Для полноценного восстановления ссылочных типов, чисел и дат в продакшн-решениях рекомендуется использовать типизацию данных (например, преобразовывать строки XML к типам метаданных через XMLЗначение(Тип, СтроковоеЗначение)). Платформа 1С умеет автоматически восстанавливать значения простых типов и ссылок, если передать ей целевой тип реквизита из метаданных.

---

Альтернативные и современные методы решения проблемы

Если вы работаете на современной версии платформы 1С:Предприятие (начиная с релиза 8.3.11) и используете актуальные версии БСП, у вас есть гораздо более изящные пути решения этой проблемы, не требующие написания сложных ручных парсеров.

1. Переход на платформенный механизм «История данных»

В новых версиях платформы фирма «1С» реализовала встроенный механизм версионирования — «История данных». В отличие от старого БСП-версионирования, он имеет ряд критических преимуществ:

2. Использование фабрики XDTO (мягкое чтение)

Если вы хотите оставить старую подсистему версионирования, но переписать ее более технологично, откажитесь от прямого вызова ПрочитатьXML. Задействуйте Фабрику XDTO:

3. Переход на сериализацию в JSON

Если вы разрабатываете собственную подсистему версионирования или глубоко кастомизируете существующую, рассмотрите возможность хранения версий в формате JSON вместо XML:


// Сериализация объекта в JSON-строку
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Объект);
JSONСтрока = ЗаписьJSON.Закрыть();

Десериализация JSON-строки в соответствие или структуру никогда не вызовет ошибку при добавлении новых реквизитов в метаданные. Недостающие свойства просто не будут заполнены, что позволит вам безопасно применить метод ЗаполнитьЗначенияСвойств(Объект, СтруктураИзJSON).

---

Резюме

Падение версионирования из-за изменения метаданных — классическая проблема старых релизов 1С. Мы проанализировали причины сбоя формата FastInfoSet и предложили надежный обходной путь с ручным парсингом структуры XML. Для устаревших конфигураций (УТ 10.3 / УТ 11.1) данный код является единственным способом спасти накопленную историю изменений. Если же вы работаете на современных версиях платформы — рассмотрите возможность планомерного перехода на встроенную «Историю данных». При необходимости обратного переноса данных из-за ограничений свёртки можно применить инструмент перехода с механизма История данных на Версионирование объектов БСП.

← На главную