Как правильно распарсить тело HTTP-запроса multipart/form-data в структуру 1С?

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

При разработке HTTP-сервисов в среде 1С:Предприятие часто возникает задача принимать запросы, отправленные из веб-форм или через API-клиенты (например, Postman) в формате multipart/form-data. Этот формат незаменим, когда клиенту необходимо передать одновременно и простые текстовые параметры (например, логин, пароль, идентификаторы), и один или несколько двоичных файлов (изображения, документы, архивы).

Однако встроенная поддержка автоматического разбора составных multipart-сообщений в платформе 1С на уровне простых свойств объекта HTTPСервисЗапрос отсутствует. Если попытаться прочитать тело такого запроса как обычный текст, мы получим сырой поток байтов, разделенный специальными маркерами-границами (boundaries), служебными заголовками и метаданными. В этой статье мы подробно рассмотрим, как правильно, быстро и без повреждения бинарных данных распарсить тело такого HTTP-запроса в удобную структуру 1С.

В чем сложность «ручного» разбора multipart-сообщений?

Прежде чем перейти к программному коду, разберем теоретическую базу формата. Согласно стандарту RFC 7578, тело запроса multipart/form-data состоит из нескольких частей, разделенных уникальной строкой-границей (boundary). Эта граница генерируется клиентом автоматически и передается в заголовке запроса Content-Type.

Посмотрим на типичную структуру тела такого запроса:


----------------------------860489963022007158480667
Content-Disposition: form-data; name="username"

ff
----------------------------860489963022007158480667
Content-Disposition: form-data; name="password"

ddd
----------------------------860489963022007158480667--

Каждая часть сообщения содержит свои собственные заголовки (например, Content-Disposition с указанием имени переменной name и имени файла filename), пустую строку-разделитель, а затем само значение параметра или тело файла. Весь запрос завершается границей с добавлением двух дефисов в конце (например, --860489963022007158480667--).

Важное предупреждение: Ни в коем случае нельзя преобразовывать все тело запроса в строку с помощью метода ПолучитьТелоКакСтроку(), чтобы потом разобрать его обычными функциями работы со строками или регулярными выражениями! Если в запросе передается бинарный файл (картинка или архив), такое преобразование безвозвратно испортит его содержимое. Символы с нулевым байтом и специфические бинарные последовательности будут заменены символами неопределенной кодировки (обычно знаками вопроса или пустыми квадратами), и файл станет нечитаемым.

Для правильного и безопасного разбора мы должны использовать потоковую работу с двоичными данными, которая появилась в платформе 1С:Предприятие начиная с версии 8.3.10.

Инструменты для работы с потоками: ЧтениеДанных и Поток

Для решения нашей задачи мы применим современные объекты платформы: ЧтениеДанных, ПотокДляЧтения и механизмы поиска маркеров в двоичных данных. Они позволяют последовательно считывать данные из потока тела HTTP-запроса, находить разделители границ и извлекать части сообщения непосредственно в бинарном виде.

Давайте по шагам разберем логику нашего будущего алгоритма:

  1. Выделим строку-разделитель (boundary) из общего заголовка запроса Content-Type.
  2. Сформируем массив маркеров-разделителей с учетом возможных управляющих символов перевода строки (CRLF и LF).
  3. Инициализируем объект ЧтениеДанных для последовательного чтения тела запроса.
  4. В цикле будем находить следующую часть сообщения с помощью метода ПрочитатьДо() по нашим маркерам.
  5. Каждую найденную часть будем анализировать: читать ее внутренние заголовки, определять имя параметра и наличие файла.
  6. Если это простой параметр — прочитаем его как строку. Если это файл — сохраним его двоичные данные без искажений.
  7. Соберем все извлеченные данные в результирующую структуру.

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

Полный код модуля парсинга multipart/form-data

Ниже представлен готовый к использованию код экспортной функции и вспомогательных процедур, которые вы можете разместить в общем модуле вашего HTTP-сервиса.


// Функция - Получить структуру параметров из HTTP-запроса
//
// Параметры:
//  Заголовки         - Соответствие - Заголовки HTTP-запроса.
//  ДвоичныеДанныеТело - ДвоичныеДанные - Тело HTTP-запроса, полученное через Запрос.ПолучитьТелоКакДвоичныеДанные().
// 
// Возвращаемое значение:
//  Структура - Распарсенные параметры. Текстовые параметры будут представлены строками,
//              файлы - массивом структур с полями "ИмяФайла" и "ДвоичныеДанные".
//
Функция ПолучитьСтруктуруПараметров(Заголовки, ДвоичныеДанныеТело) Экспорт
    
    Разделитель = ПолучитьРазделительСоставногоСообщения(Заголовки);
    Если Разделитель = Неопределено Тогда
        Возврат Новый Структура();
    КонецЕсли;
    
    // Формируем маркеры границ. По стандарту RFC перед границей всегда идут два дефиса.
    // Также учитываем различные варианты переводов строк (CRLF для Windows/стандарта и LF для Linux).
    Маркеры = Новый Массив();
    Маркеры.Добавить("--" + Разделитель);
    Маркеры.Добавить("--" + Разделитель + Символы.ПС);
    Маркеры.Добавить("--" + Разделитель + Символы.ВК);
    Маркеры.Добавить("--" + Разделитель + Символы.ВК + Символы.ПС);
    Маркеры.Добавить("--" + Разделитель + "--"); // Маркер завершения всего сообщения
    
    СтруктураПараметров = Новый Структура(); 
    
    ЧтениеДанных = Новый ЧтениеДанных(ДвоичныеДанныеТело);
    
    // Пропускаем все данные до первого разделителя (вступление запроса)
    ЧтениеДанных.ПропуститьДо(Маркеры);
    
    // В цикле читаем все части multipart-сообщения
    Пока Истина Цикл
        
        // Читаем блок данных до следующего маркера границы
        РезультатЧтенияЧасть = ЧтениеДанных.ПрочитатьДо(Маркеры);
        
        Если Не РезультатЧтенияЧасть.МаркерНайден Тогда
            // Если маркер не найден, значит, структура запроса нарушена
            Прервать;
        КонецЕсли;
        
        // Открываем локальный поток для чтения содержимого конкретной части
        ЧтениеЧасти = Новый ЧтениеДанных(РезультатЧтенияЧасть.ОткрытьПотокДляЧтения());
        
        // Читаем внутренние заголовки этой части (например, Content-Type, Content-Disposition)
        ЗаголовкиЧасти = ПрочитатьЗаголовки(ЧтениеЧасти);
        
        ИмяЧасти = ПолучитьИмяСообщения(ЗаголовкиЧасти);
        ИмяФайла = ПолучитьИмяФайла(ЗаголовкиЧасти);
        
        Если ИмяЧасти <> Неопределено Тогда
            
            Если ИмяФайла = Неопределено Тогда
                // Это обычный текстовый параметр формы. Читаем его как строку.
                СтруктураПараметров.Вставить(ИмяЧасти, ЧтениеЧасти.ПрочитатьСимволы());
            Иначе
                // Это файл. Извлекаем его двоичные данные без конвертации в строку.
                Если НЕ СтруктураПараметров.Свойство(ИмяЧасти) Тогда            
                    СтруктураПараметров.Вставить(ИмяЧасти, Новый Массив);            
                КонецЕсли;
                
                СтруктураФайла = Новый Структура();
                СтруктураФайла.Вставить("ИмяФайла", ИмяФайла);
                // Читаем оставшийся поток части целиком как двоичные данные
                СтруктураФайла.Вставить("ДвоичныеДанные", ЧтениеЧасти.Прочитать().ПолучитьДвоичныеДанные());
                
                СтруктураПараметров[ИмяЧасти].Добавить(СтруктураФайла);
            КонецЕсли;
            
        КонецЕсли; 
                
        // Если индекс встреченного маркера равен 4 (то есть "--" + Разделитель + "--"), 
        // значит мы достигли финального разделителя запроса.
        Если РезультатЧтенияЧасть.ИндексМаркера = 4 Тогда
            Прервать;
        КонецЕсли;
        
    КонецЦикла;
    
    Возврат СтруктураПараметров;
    
КонецФункции

// Функция парсинга внутренних заголовков конкретной части multipart-данных
Функция ПрочитатьЗаголовки(ЧтениеЧасти)
    
    Заголовки = Новый Соответствие();
    
    Пока Истина Цикл
        Стр = ЧтениеЧасти.ПрочитатьСтроку();
        
        // Пустая строка по стандарту отделяет заголовки от тела данных части
        Если Стр = "" Тогда
            Прервать;
        КонецЕсли;
        
        Части = СтрРазделить(Стр, ":");
        Если Части.Количество() >= 2 Тогда
            ИмяЗаголовка = СокрЛП(Части[0]);
            // Объединяем оставшиеся части, если в значении заголовка встретился символ двоеточия
            Значение = "";
            Для Шаг = 1 По Части.ВГРАНИЦА() Цикл
                Значение = Значение + ?(Шаг = 1, "", ":") + Части[Шаг];
            КонецЦикла;
            Заголовки.Вставить(ИмяЗаголовка, СокрЛП(Значение));
        КонецЕсли;
    КонецЦикла;
    
    Возврат Заголовки;
    
КонецФункции

// Извлекает границу (boundary) из общего Content-Type заголовка запроса
Функция ПолучитьРазделительСоставногоСообщения(Заголовки)
    
    // Ищем заголовок без учета регистра символов
    ТипСодержимого = Неопределено;
    Для Каждого КлючЗначение Из Заголовки Цикл
        Если ВРег(КлючЗначение.Ключ) = "CONTENT-TYPE" Тогда
            ТипСодержимого = КлючЗначение.Значение;
            Прервать;
        КонецЕсли;
    КонецЦикла;
    
    Если ТипСодержимого = Неопределено Тогда
        Возврат Неопределено;
    КонецЕсли;
    
    Свойства = СтрРазделить(ТипСодержимого, ";", Ложь);
    Граница = Неопределено;
    
    Для Каждого Свойство Из Свойства Цикл
        Части = СтрРазделить(Свойство, "=", Ложь);
        Если Части.Количество() < 2 Тогда
            Продолжить;
        КонецЕсли;
        
        ИмяСвойства = СокрЛП(Части[0]);
        Если ВРег(ИмяСвойства) <> "BOUNDARY" Тогда
            Продолжить;
        КонецЕсли;
        
        Граница = СокрЛП(Части[1]);
        // Очищаем от возможных кавычек по краям границы
        Граница = СтрЗаменить(Граница, """", "");
        Прервать;
    КонецЦикла;
        
    Возврат Граница;
    
КонецФункции

// Получает имя переменной (параметра формы) из Content-Disposition
Функция ПолучитьИмяСообщения(Заголовки)
    
    Описание = Заголовки.Получить("Content-Disposition");
    Если Описание = Неопределено Тогда
        Возврат Неопределено;
    КонецЕсли;
    
    Свойства = СтрРазделить(Описание, ";", Ложь);
    Имя = Неопределено;
    
    Для Каждого Свойство Из Свойства Цикл
        Части = СтрРазделить(Свойство, "=", Ложь);
        Если Части.Количество() < 2 Тогда
            Продолжить;
        КонецЕсли;
        
        ИмяСвойства = СокрЛП(Части[0]);
        Если ИмяСвойства <> "name" Тогда
            Продолжить;
        КонецЕсли;
        
        Имя = СокрЛП(Части[1]);
        Имя = СтрЗаменить(Имя, """", "");
        Прервать;
    КонецЦикла;
        
    Возврат Имя;
    
КонецФункции

// Получает имя исходного файла из Content-Disposition (если передан файл)
Функция ПолучитьИмяФайла(Заголовки)
    
    Описание = Заголовки.Получить("Content-Disposition");
    Если Описание = Неопределено Тогда
        Возврат Неопределено;
    КонецЕсли;
    
    Свойства = СтрРазделить(Описание, ";", Ложь);
    Имя = Неопределено;
    
    Для Каждого Свойство Из Свойства Цикл
        Части = СтрРазделить(Свойство, "=", Ложь);
        Если Части.Количество() < 2 Тогда
            Продолжить;
        КонецЕсли;
        
        ИмяСвойства = СокрЛП(Части[0]);
        Если ИмяСвойства <> "filename" Тогда
            Продолжить;
        КонецЕсли;
        
        Имя = СокрЛП(Части[1]);
        Имя = СтрЗаменить(Имя, """", "");
        Прервать;
    КонецЦикла;
        
    Возврат Имя;
    
КонецФункции

Разбор важных нюансов и подводных камней

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

1. Символы перевода строки (CRLF vs LF) и кроссплатформенность

Стандарт RFC предписывает использовать в качестве разделителей строк последовательность \r\n (возврат каретки и перевод строки). Однако на практике поведение клиентов и серверов может отличаться:

Именно поэтому в нашем коде мы инициализируем массив маркеров пятью различными комбинациями. Это гарантирует, что парсер успешно определит границу блока независимо от операционной системы сервера 1С и специфики веб-клиента.

2. Производительность при передаче файлов большого объема

Представленный алгоритм последовательного чтения через объект ЧтениеДанных работает достаточно эффективно, так как он не загружает весь входящий поток в ОЗУ в виде текстовых строк. Тем не менее, при работе с очень большими файлами (от 50–100 МБ и выше) метод ЧтениеЧасти.Прочитать().ПолучитьДвоичныеДанные() может приводить к пикам потребления оперативной памяти рабочим процессом rphost.

Если в вашей системе предполагается регулярная загрузка тяжелых файлов через HTTP-сервисы, рекомендуется оптимизировать этот участок. Вместо вызова метода Прочитать() в память, целесообразно использовать метод ЧтениеДанных.КопироватьВ() для прямой записи бинарного потока из тела запроса непосредственно во временный файл на диске сервера. Это позволит обрабатывать гигабайтные файлы с минимальным потреблением ресурсов сервера.

3. Кодирование имен файлов во входящих заголовках

Иногда при передаче файлов с кириллическими именами (например, Отчет_2023.xlsx) заголовок Content-Disposition может прийти в закодированном виде. Браузеры могут присылать имена файлов, закодированные по стандарту RFC 2047 (например, =?UTF-8?B?...==?=) или в формате URL-Encoding (например, %D0%9E%D1%82%D1%87%D0%B5%D1%82...).

Для корректного сохранения имен файлов на диске рекомендуется добавить в функцию ПолучитьИмяФайла обработку декодирования. Например, если в строке имени файла присутствует символ "%", к нему можно применить встроенную функцию платформы для декодирования URL:

Имя = КодироватьСтроку(Имя, СпособКодированияСтроки.АнкодингURL, "UTF-8");

Подведем итоги

Мы спроектировали и детально разобрали надежный инструмент для парсинга HTTP-запросов формата multipart/form-data на встроенном языке 1С. Использование низкоуровневых объектов работы с потоками двоичных данных позволило нам решить задачу элегантно, быстро и без угрозы повреждения структуры передаваемых бинарных файлов. Этот код легко внедряется в любые конфигурации 1С и стабильно работает как на Windows, так и на Linux-серверах.

← На главную