При разработке HTTP-сервисов в среде 1С:Предприятие часто возникает задача принимать запросы, отправленные из веб-форм или через API-клиенты (например, Postman) в формате multipart/form-data. Этот формат незаменим, когда клиенту необходимо передать одновременно и простые текстовые параметры (например, логин, пароль, идентификаторы), и один или несколько двоичных файлов (изображения, документы, архивы).
Однако встроенная поддержка автоматического разбора составных multipart-сообщений в платформе 1С на уровне простых свойств объекта HTTPСервисЗапрос отсутствует. Если попытаться прочитать тело такого запроса как обычный текст, мы получим сырой поток байтов, разделенный специальными маркерами-границами (boundaries), служебными заголовками и метаданными. В этой статье мы подробно рассмотрим, как правильно, быстро и без повреждения бинарных данных распарсить тело такого HTTP-запроса в удобную структуру 1С.
Прежде чем перейти к программному коду, разберем теоретическую базу формата. Согласно стандарту 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-запроса, находить разделители границ и извлекать части сообщения непосредственно в бинарном виде.
Давайте по шагам разберем логику нашего будущего алгоритма:
Content-Type.ЧтениеДанных для последовательного чтения тела запроса.ПрочитатьДо() по нашим маркерам.Рассмотрим подробную программную реализацию этой логики.
Ниже представлен готовый к использованию код экспортной функции и вспомогательных процедур, которые вы можете разместить в общем модуле вашего 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]);
Имя = СтрЗаменить(Имя, """", "");
Прервать;
КонецЦикла;
Возврат Имя;
КонецФункции
Для того чтобы ваш парсер работал стабильно в любой промышленной среде, проанализируем несколько критически важных технических аспектов, с которыми разработчики сталкиваются на практике.
Стандарт RFC предписывает использовать в качестве разделителей строк последовательность \r\n (возврат каретки и перевод строки). Однако на практике поведение клиентов и серверов может отличаться:
\n (LF).Именно поэтому в нашем коде мы инициализируем массив маркеров пятью различными комбинациями. Это гарантирует, что парсер успешно определит границу блока независимо от операционной системы сервера 1С и специфики веб-клиента.
Представленный алгоритм последовательного чтения через объект ЧтениеДанных работает достаточно эффективно, так как он не загружает весь входящий поток в ОЗУ в виде текстовых строк. Тем не менее, при работе с очень большими файлами (от 50–100 МБ и выше) метод ЧтениеЧасти.Прочитать().ПолучитьДвоичныеДанные() может приводить к пикам потребления оперативной памяти рабочим процессом rphost.
Если в вашей системе предполагается регулярная загрузка тяжелых файлов через HTTP-сервисы, рекомендуется оптимизировать этот участок. Вместо вызова метода Прочитать() в память, целесообразно использовать метод ЧтениеДанных.КопироватьВ() для прямой записи бинарного потока из тела запроса непосредственно во временный файл на диске сервера. Это позволит обрабатывать гигабайтные файлы с минимальным потреблением ресурсов сервера.
Иногда при передаче файлов с кириллическими именами (например, Отчет_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-серверах.