Вступление
Кто-то может сказать: "Ой, да что там руками формировать, XML простая". Однако протокол содержит некоторое количество расширений, таких как, например, WS-Addressing и WS-Security, которые могут превратить ручное формирование в боль.
На работе мне пришлось столкнуться с довольно замороченным soap-сервером, с которым не получалось легко работать из 1С. Сегодняшняя моя статья про то, как можно разработать легковесную прослойку между 1С и soap-сервером, принимающую в себя обычный http-запрос и перекладывающую содержимое в вызов soap-сервера. Естественно, код в статье максимально упрощен для простоты восприятия. Код работающего у меня production-решения сильно отличается от указанного примера и более «архитектурный» :).
Подготовка
В качестве инструмента для решения задачи я буду использовать node.js. Почему? Во-первых, мне так удобнее: я его знаю :). Во-вторых, на нем есть простые для запуска библиотеки для построения веб-приложений, работы с soap и кластеризацией. В качестве редактора я рекомендую использовать Visual Studio Code, но это уже дело вкуса. Тренироваться будем на классическом сервисе курсов валют.
После установки node.js в командной строке вам должна быть доступа утилита npm - пакетный менеджер для node.js. Для работавших с opm - это почти тоже самое, только для node.js и мощнее :).
Начнем разработку в пустом каталоге. Для первичной инициализации проекта нужно выполнить npm init - эта команда задаст манифест приложения с необходимыми полями. В целом, на все вопросы можно ответить значением по умолчанию.
После сразу установим все библиотеки, которые нам понадобятся для нашей прослойки с помощью команды:
npm install --save soap body-parser express
Файл с wsdl положим в корень каталога с именем DailyInfo.wsdl в кодировке UTF-8.
Для достижения нашей цели нам надо решить следующие задачи:
-
Написать веб-сервер, который сможет принимать POST запросы (это совсем не так сложно, как звучит).
-
Подключиться к soap-серверу как клиент.
-
Преобразовать входящий POST-запрос в вызов soap-метода и вернуть на клиент результат.
Страшно? 10 минут, помните?
Реализация – веб-сервер
Создадим скелет нашего приложения - файл index.js в корневом каталоге (если вы не указывали иное при выполнении npm init) со следующим содержимым:
// express – фреймворк для построения веб-приложений
const express = require('express');
// Преобразователь тела сообщения к объекту JavaScript. Мы его будем
// использовать для автопреобразования сообщения с Content-Type
// application/json из собственно JSON в объект.
const bodyParser = require("body-parser");
// Объявление главной функции. Async-возможность нам понадобится чуть позднее.
async function main() {
// Порт, который будет слушать веб-сервер
const port = 3000;
// Создание экземпляра веб-приложения
const app = express();
// Указание реагировать на POST-запрос
app.post(
"/", // по «пустому» ресурсу
bodyParser.json(), // с автоматическим преобразованием json-содержимого
(req, res) => { // и выводом Привет, мир :)
res.send("Hello, World");
}
);
// Запуск приложения – указание слушать порт
// и выводить сообщение в консоль по готовности
app.listen(port, () => console.log(`Test app listening on port ${port}!`));
}
// Точка входа
main();
Этим небольшим скриптом мы сразу же решили задачу №1 из нашего списка. Осталось запустить и проверить.
Для запуска приложения у нас есть два варианта:
-
запуск из командной строки через node index.js;
-
запуск отладчика в VSCode.
С первым вариантом все просто: вбили в консоль и радуемся:
Останавливаем работу через Ctrl-C.
Отладчик VSCode запускается по кнопке F5. В выпадающем меню надо выбрать Node.js:
После выбора node.js на вкладке Debug console можно убедиться, что наше приложение запустилось и готово обрабатывать запросы:
Для проверки работоспособности я воспользуюсь чудесным инструментом отладки http-запросов Postman:
В ответе сервиса видим, что он не может обработать GET-запрос, что логично. Поменяем запрос на POST и получим уже ожидаемый ответ:
Реализация – soap-клиент
Перейдем ко второй части – подключение по soap-серверу в качестве клиента. Для этого в уже существующий файл нужно добавить два участка кода. В секцию подключения библиотек добавим подключение “soap” – библиотеки, с помощью которой можно как подключиться к чужому soap-серверу, так и опубликовать собственный.
const soap = require("soap");
Внутрь функции main добавим создание soap-клиента:
// создание soap-клиента на базе предварительно скачанной wsdl.
// В качестве параметра может выступать как адрес к файлу на диске,
// так и URL, по которому этот WSDL можно получить (прямо как WS-Ссылка)
const soapClient = await soap.createClientAsync("./DailyInfo.wsdl");
soapClient.setEndpoint("http://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx");
Иии… всё. Теперь через созданного клиента мы можем вызывать методы soap-сервера, как указанные с «полным» именем в виде soapClient,service.port.methodName(), так и по короткому soapClient.methodName().
Реализация – Преобразование запроса
Добавим, собственно, вызов нужного нам soap-метода. В качестве API нашего сервиса предлагаю такую простую схему: в теле POST-запроса передается JSON следующей структуры:
-
method – строка – имя вызываемого soap-метода;
-
body – произвольный – тело soap-запроса в виде js-объекта.
Таким образом, получение списка валют на определенную дату через наш промежуточный сервис может выглядеть так:
{
"method": "GetCursOnDate",
"body": { "On_date": "2018-01-01" }
}
Заменим наш ответ «привет, мир» на следующий код:
// Десериализованное тело запроса доступно в переменной req.body
// В случае корректного запроса req.body будет содержать два свойства:
// method и body
// Попробуем получить указатель на функцию для вызова soap-метода
const soapMethod = soapClient[req.body.method];
// Если метод не нашелся, выбросим исключение
if (soapMethod == undefined) {
throw new Error("Wrong method name");
}
// Если все хорошо, вызовем soap-метод, передав ему в качестве параметров
// тело сообщения и обработчик результата вызова
soapMethod(req.body.body, (err, result) => {
// В случае возникновения ошибки вернем ее клиенту.
if (err) {
res.send(err);
return;
}
// Если все хорошо, переведем ответ в JSON и вернем клиенту.
res.send(JSON.stringify(result));
});
Кода меньше, чем комментариев :). Сохраняемся, перезапускаемся и снова идем в Postman. На вкладке body укажем, что мы отправляем raw-данные с типом application/json и содержимым из примера выше:
В результате видим тело soap-ответа в виде JSON.
Реализация – вызов из 1С
Postman – это хорошо, но мы же изначально пришли с проблемой вызова из 1С. Выполнить обычный POST-запрос из 1С не составит труда, однако, я приведу пример реализации здесь, чтобы показать работу с JSON и XDTO.
Для начала добавим пакет XDTO в конфигурацию 1С. Если WSDL от поставщика soap-сервера читается, можно сразу добавить WS-ссылку. Сэмулируем проблему "нечитабельности" wsdl и добавим XDTO пакет вручную. В этом нам поможет знание о том, что WSDL содержит XSD для содержимого всех сообщений и методов.
Вытащим из WSDL все содержимое тега s:schema в отдельный файл и перенесем объявление пространства имен s из заголовка WSDL в заголовок нового файла. Получится что-то вроде такого:
Сохраним содержимое в файл с разрешением XSD, и, если все прошло успешно, полученная схема успешно импортируется в конфигуратор как XDTO пакет:
Если от вендора пришла «нечитаемая» в 1С XSD, то использование фабрики XDTO из следующего примера не имеет смысла, однако десериализацию из JSON будет просто написать по аналогии с сериализацией.
Для формирования запросов создадим внешнюю обработку со следующим кодом:
&НаСервереБезКонтекста
Процедура ВыполнитьЗапросНаСервере()
// Создадим тело нашего запроса - параметры вызываемого soap-метода
ТелоЗапроса = Новый Структура;
ТелоЗапроса.Вставить("On_date", Дата(2018, 1, 1));
// Сериализуем его в JSON
ТекстСообщения = СериализоватьВJSON(ТелоЗапроса);
// Проверим, что полученный JSON удовлетворяет XSD
// Если бы у данного свойства был бы выделенный "тип объекта",
// то мы бы могли получить тип проще...
//ТипXDTO = ФабрикаXDTO.Тип(Метаданные.ПакетыXDTO.ПакетXDTO1.ПространствоИмен, "GetCursOnDate");
КорневыеСвойства = ФабрикаXDTO.Пакеты.Получить(Метаданные.ПакетыXDTO.ПакетXDTO1.ПространствоИмен).КорневыеСвойства;
СвойствоЗапросКурсовНаДату = КорневыеСвойства.Получить("GetCursOnDate");
ТипXDTO = СвойствоЗапросКурсовНаДату.Тип;
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(ТекстСообщения);
// Если здесь не выдалось исключения, значит, пакет корректен и его можно отправлять.
ФабрикаXDTO.ПрочитатьJSON(ЧтениеJSON, ТипXDTO);
// Создадим верхнеуровневую структуру, принимаемую промежуточным сервером
СтруктураЗапроса = Новый Структура;
СтруктураЗапроса.Вставить("method", "GetCursOnDate");
СтруктураЗапроса.Вставить("body", ТелоЗапроса);
// Сериализуем его в JSON для последующей отправки
ТекстСообщения = СериализоватьВJSON(СтруктураЗапроса);
// Создадим новое соединение с промежуточным сервером
Хост = "localhost";
Порт = 3000;
Таймаут = 30;
Соединение = Новый HTTPСоединение(Хост, Порт, , , , Таймаут);
// "Корневой" адрес ресурса, как мы его объявили в app.post
Ресурс = "/";
// Обязательно передаем тип содержимого для работы преобразователя body-parser
ЗаголовкиЗапроса = Новый Соответствие();
ЗаголовкиЗапроса.Вставить("Content-type", "application/json");
// Создаем и отправляем запрос
Запрос = Новый HTTPЗапрос(Ресурс, ЗаголовкиЗапроса);
Запрос.УстановитьТелоИзСтроки(ТекстСообщения);
Ответ = Соединение.ОтправитьДляОбработки(Запрос);
ТелоОтвета = Ответ.ПолучитьТелоКакСтроку();
// Десериализуем ответ сервиса из JSON в объект XDTO
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(ТелоОтвета);
ТипXDTO = ФабрикаXDTO.Тип(Метаданные.ПакетыXDTO.ПакетXDTO1.ПространствоИмен, "GetCursOnDateResponse");
Данные = ФабрикаXDTO.ПрочитатьJSON(ЧтениеJSON, ТипXDTO);
КонецПроцедуры
&НаСервереБезКонтекста
Функция СериализоватьВJSON(Объект)
Запись = Новый ЗаписьJSON;
Запись.УстановитьСтроку();
ЗаписатьJSON(Запись, Объект);
ТекстСообщения = Запись.Закрыть();
Возврат ТекстСообщения;
КонецФункции
&НаКлиенте
Процедура ВыполнитьЗапрос(Команда)
ВыполнитьЗапросНаСервере();
КонецПроцедуры
Некоторые пояснения по коду.
Первое, за что может зацепиться взгляд – использование «обычной» ЗаписиJSON вместо ФабрикиXDTO. Причина тут довольно проста – ФабрикаXDTO умеет записывать произвольные типы, но с «мусорными» тегами “#value” и “#type” (тип добавляется, только если это указано явно в настройках записи). Наш промежуточный сервер ни про какие value ничего не знает. Выхода тут два – либо научить понимать сервер, либо использовать упрощенный сериализатор на базе структуры и записи. Выбор за вами.
Второе – пляски с бубном вокруг ФабрикиXDTO. Это всего лишь проверка валидности нашего сообщения. Мы же порядочные граждане, хотим быть уверены, что мы не шлем soap-серверу что-то, чего он не ожидает. В конкретно данном случае дополнительный реверанс пришлось сделать для получения типа создаваемого свойства, т. к. исходная wsdl вообще не содержит явных описаний типов значений, а только описания свойств с вложенными описаниями типов.
А вот чтение сообщения мы уже выполним «честной» ФабрикойXDTO для получения объекта XDTO и возможности работы в объектной модели.
Ставим в конец процедуры точку останова, выполняем обработку… Вуаля:
Цель достигнута!
Кластеризация
Окей, сервис готов, можно в прод? :)
Если не страшно, то можно сразу и в прод, однако, я бы на вашем месте помимо обработки ошибок и общего причесывания кода добавил бы еще одну вещь. Node.js штука хоть и быстрая, но не всемогущая. Возможно вам знакома фраза, что «нода – асинхронная, но однопоточная». В новых версиях node.js уже появилась честная поддержка многопоточности, но для простоты воспользуемся другим старым и проверенным механизмом – кластеризацией. А асинхронность обработки в нашем случае есть, но нам не помешает воспользоваться дешевым ускорителем.
Ставим пакет cluster-service с помощью команды:
npm install -g cluster-service
Запускаем наше приложение в командной строке, но в вместо node укажем приложение cservice:
Наш промежуточный сервис запустился в режиме кластера с количеством потоков, равным количеству логических процессоров. Можете выполнить нагрузочное тестирование через тот же SoapUI и замерить количество обрабатываемых запросов в секунду при обычном запуске и при кластеризованном запуске – заметите ощутимую разницу.
Что там было про WS-Addressing?
Ах-да, заголовки, те самые soap-headers, которые не поддерживает 1С. Добавить их довольно просто – для этого в soapClient есть метод addSoapHeaders, в который можно передать либо готовую строку с заголовками, либо JS-объект. Попробуем реализовать добавление пары заголовков семейства WS-Addressing, а именно Action и MessageID.
Для генерации UUID сообщения установим библиотеку uuid:
npm install --save uuid
Добавим ее в секцию импорта библиотек:
const uuidv4 = require("uuid/v4");
Между проверкой указателя на soap-метод и самим вызовом soap-метода добавим заполнение заголовков:
// Создадим объект для хранения заголовков
const wsaHeader = {
MessageID: {
// В качестве значения для заголовка MessageID сгенерируем случайный UUID
$value: uuidv4()
},
Action: {
// Для Action передадим имя вызываемого метода, как того требует протокол
$value: req.body.method
}
};
// Очистим заголовки soap-запроса
soapClient.clearSoapHeaders();
// Добавим новый заголовок
soapClient.addSoapHeader(
wsaHeader, // объект, в котором хранятся заголовки
"WSA", // имя группы заголовков
"wsa", // префикс пространства имен
"http://www.w3.org/2005/08/addressing" // само пространство имен
);
Убедиться в корректности отправляемых заголовков можно через тот же SoapUI или настроив логирование запросов в промежуточном сервере.
Дополнительные вопросы
- А зачем JSON? Можно гонять туда-сюда XML?
- Можно, но зачем, если есть возможность гонять более легковесный JSON, а 1С уже умеет нативно с ним работать?
- Можно ли накрыть авторизацией?
- Можно, причем и веб-приложение (для этого надо добавить еще один middle-ware с авторизацией в вызов app.post), и soap-сервер, который можно поднять в этом же приложении как сервис обратного вызова в случае асинхронного soap-обмена.
Заключение
Вот таким нехитрым способом мы смогли обойти ограничение возможностей платформы 1С, таких как отсутствие поддержки soap-headers и не полной поддержки WSDL-описания.
Не бойтесь использовать другие языки в своей работе. Даже начальный уровень знаний какого-либо языка, фреймворка или технологии может существенно сократить вам время на разработку требуемой функциональности.
Полный код получившегося приложения, а также исходники обработки доступны в репозитории на GitHub.
P.S. В процессе написания статьи я в очередной раз вспомнил, почему я так люблю TypeScript - за ошибки во время компиляции, типизацию и более умную контекстную подсказку. Если у вас еще остались силы, то в качестве домашнего задания можете повторить этот же пример на TypeScript, благо настроить единственный json-файл с конфигом можно тоже почти автоматически.