Рассказываю, как я сделал простейший yaml сервис на Java и Vue для разовой загрузки данных, чтобы не писать тонны кода и не мучиться с JSON.
Предыдущая часть: Enovia умерла, да здравствует JMatrixPlatform: пересобираю легендарную платформу на Java и Vue
Начинаю короткий цикл с мини-историями разработки JMatrixPlatform. Информации очень много, но я начну с задачи взаимодействия технических специалистов с самой системой и её данными.
Проблема
Для очередной пре-сейл кастомизации заказчик прислал все свои исторические данные в Excel и попросил загрузить их в платформу для последующей демонстрации. Это был стандартный набор данных: различные структуры, классификаторы, справочные элементы и сам содержательный контент. В общей сложности я насчитал порядка нескольких тысяч объектов различных типов. И тут в моей голове прозвучало длинное "пу пу пууу..." .
И если вернуться к возможностям Enovia, то там это вообще "изи": подготовка и импорт занял бы ровно 5 минут. Но в JMatrixPlatform - "всё есть код" и теперь у меня проблема. Я не могу быстро прогружать исторические данные, не написав java кода. Вдобавок, аналитик потерял возможность самостоятельно управлять данными без моей помощи.
Что ж, пошёл искать решения:
Дополнить REST API batch сервисами. Хороший вариант. Но преобразование Excel в json массив станет новой проблемой. Можно конечно использовать VBA или другой скриптинг, но раньше мы пользовались простыми Excel формулами для составления простейших DSL команд вида: "add businessobject Person LOGIN 0 fullName 'ФИО' 'lastName' 'Фамилия' ...;". Составить формулой полноценный json объект - серьёзное препятствие. Расставлять двойные кавычки в формулах - та ещё боль (кто знает, тот знает). Плюс понадобится стороннее приложение типа postman, чтобы выполнить такой запрос, поскольку Swagger UI для этой задачи неудобен. В общем, вариант рабочий, но превращает простую задачу в квест.
Использовать curl + json для каждого объекта. Тоже хороший вариант. Но нужен pre-script для получения токена. Плюс также надо преобразовать Excel в json. Ну и импорт будет останавливаться на каждой строчке, в которой будет ошибка. Не вариант.
Есть же SQL!!! Нет, он тоже не подходит. Вся логика работы с данными осуществляется в самом приложении JMatrixPlatform. Импортировать в обход приложения в данном случае - выстрел себе в ногу. Плюс нужен веб клиент БД для выполнения SQL. Отметаю.
Писать нативный импорт из Excel под каждую сущность. Абсолютный бред. Импорт исторических данных - довольно частая история и в основном это одноразовый импорт. Он просто экономический нецелесообразен. И разработка импорта - это всегда долго. Плюс ещё нужно искать где находятся эти кнопки для импорта. Нужно что-то другое.
Похоже все варианты закончились. Использование специфических инструментов типа graphql даже не рассматривается - не подходят они, "и точка"!
Что ж, повторяю опять про себя: "пу пу пууу..." .
Проба пера. Решение
Пробую ещё раз сформулировать задачу: нужен немногословный простой сервис для работы с данными, при этом желательно, чтобы любые манипуляции выполнялись в одном окне, без беготни по разным endpoint'ам.
Получается нужен ровно один простой endpoint, который принимает массив json с данными, внутри которых есть информация о действии - создать, удалить, изменить и т.д. В идеале - с использованием уже существующих DTO.
Есть ли формат более минималистичный, чем JSON? Да - YAML! Он явно подходит лучше. Смотрим пример:
#запрос - createObject: type: ru.commons.matrix.schema.type.ATPPerson policy: ru.commons.matrix.schema.policy.ALCPerson - createObject: type: ru.commons.matrix.schema.type.ATPPerson1 policy: ru.commons.matrix.schema.policy.ALCPerson - createObject: type: ru.commons.matrix.schema.type.ATPPerson policy: ru.commons.matrix.schema.policy.ALCPerson #ответ --- - createObject: status: 200 message: null oid: "f4ba679e-9253-4a83-a390-44daf7ac7756" - createObject: status: 500 message: "Админтип ru.commons.matrix.schema.type.ATPPerson1 не найден. Введите корректное имя или обратитесь к администратору." - createObject: status: 200 message: null oid: "d9c98e74-bd17-4b3c-ae07-d9c307151c74"
Пробую сделать yaml с помощью Excel формулы, выглядит неплохо:
="- createObject: id: "&K2&" type: "&I2&" policy: "&J2&" code: "&B2&" title: '"&C2&"'" или ="- deleteObject: id: "&K2
Вы можете подумать, что это напоминает DSL. НЕТ! Это просто yaml-структура, которая использует те же DTO, что и REST API, но обёрнута в команду-действие. Такой yaml легко генерируется простой Excel формулой или LLM. Самих действий не так много и они в общем случае соответствуют уже существующим API.
Далее пробую реализовать. Весь листинг приводить не буду, только основные компоненты. Кому интересно, можно глянуть под спойлером.
Скрытый текст
Контроллер:
@RestController @RequestMapping("jql") @RequiredArgsConstructor public class JQLController { /** * Выполняет пакет JQL-команд. * * Используется LinkedHashMap для сохранения порядка входных и выходных команд * - входные команды обрабатываются в том порядке, в котором они указаны в YAML * - ответы возвращаются в том же порядке, что и запросы * - это важно для сценариев, где важен порядок выполнения (create → connect) * Jackson по умолчанию использует LinkedHashMap, но явное указание * защищает от случайной смены реализации в будущем. */ @PostMapping(consumes = "application/yaml", produces = "application/yaml") public ResponseEntity<List<LinkedHashMap<String, JQLResponseData>>> promote(@JPathContextVariable JContext ctx, @RequestBody List<LinkedHashMap<String, Object>> commands) { List<LinkedHashMap<String, JQLResponseData>> results = new ArrayList<>(commands.size()); for (Map<String, Object> command : commands) { Map<JQLEnum, IJQLDTO> parsed = fromYaml(command); //ошибка в команде не приводит к падению всего пакета //run это учитывает и здесь try catch не нужен results.add(run(ctx, parsed)); } return ResponseEntity.ok(results); } private static Map<JQLEnum, IJQLDTO> fromYaml(Map<String, Object> commands) { Map<JQLEnum, IJQLDTO> command = new LinkedHashMap<>(); for (Map.Entry<String, Object> entry : commands.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); JQLEnum jqlEnum = JQLEnum.valueOf(key); Class<? extends IJQLDTO> dtoClass = jqlEnum.getDTOClass(); IJQLDTO dto = JObjectJSON.MAPPER.convertValue(value, dtoClass); command.put(jqlEnum, dto); } return command; } /** * Выполняет команды и возвращает результат в том же формате. * * Вход: { "createObject": { "type": "...", "policy": "..." } } * Выход: { "createObject": { "status": 200, "id": "..." } } * * или * * Выход: { "createObject": { "status": 500, "message": "..." } } */ private static LinkedHashMap<String, JQLResponseData> run(JContext ctx, Map<JQLEnum, IJQLDTO> commands) { LinkedHashMap<String, JQLResponseData> response = new LinkedHashMap<>(); if (commands.isEmpty()) { return response; } try { ctx.getTxUpdate().executeWithoutResult(tx -> { for (Map.Entry<JQLEnum, IJQLDTO> entry : commands.entrySet()) { response.put(entry.getKey().name(), entry.getKey().execute(ctx, entry.getValue())); } }); } catch (JMatrixLocalizedError ex) { commands.keySet().forEach(el -> { response.put(el.name(), new JQLResponseData(500, ex.getLocalizedMessage(ctx.getLocale()))); }); } catch (Exception ex) { commands.keySet().forEach(el -> { response.put(el.name(), new JQLResponseData(500, ex.getMessage())); }); } return response; } }
Однако Spring по умолчанию не умеет работать с YAML, поэтому пришлось добавить конфигурацию:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { AbstractJackson2HttpMessageConverter yamlConverter = new AbstractJackson2HttpMessageConverter( new YAMLMapper(), MediaType.parseMediaType("application/yaml"), MediaType.parseMediaType("text/yaml"), MediaType.parseMediaType("application/x-yaml")) { }; converters.add(yamlConverter); } }
JQLResponseData - DTO для результата обработки команды. Все возвращаемые объекты наследуются от него:
@Getter public class JQLResponseData { private int status = 200; private String message = null; public JQLResponseData() { } public JQLResponseData(int status, String message) { this.status = status; this.message = message; } }
На пробу я реализовал enum с базовым набором команд, так было проще для MVP, иначе пришлось бы писать контейнер для регистрации команд-классов и затем мэппинг по имени команды и имени класса, а заказчик ждал структуру уже завтра:
public enum JQLEnum { createObject { @Override JQLResponseData execute(JContext ctx, IJQLDTO value) { JDTODomainObject dto = (JDTODomainObject) value; JDomainObject object; if (dto.getId() == null) { object = new JDomainObject(); } else { object = new JDomainObject(dto.getId()); } dto.unmap(object); object.create(ctx, JModel.getRequiredAdminByName(dto.getType()), JModel.getRequiredAdminByName(dto.getPolicy())); return new CreateDomainRS(object.getId()); } @Override public Class<? extends IJQLDTO> getDTOClass() { return JDTODomainObject.class; } }, deleteObject { @Override JQLResponseData execute(JContext ctx, IJQLDTO value) { DeleteDomainRQ dto = (DeleteDomainRQ) value; new JDomainObject(dto.getId()).delete(ctx); return new JQLResponseData(); } @Override public Class<? extends IJQLDTO> getDTOClass() { return DeleteDomainRQ.class; } }, //и т.д. abstract JQLResponseData execute(JContext ctx, IJQLDTO value); public abstract Class<? extends IJQLDTO> getDTOClass(); }
В качестве UI делаю простейшую страницу, разделённую на две области. Код Vue:
<template> <div class="jql-base-div"> <div ref="refRequests" class="jql-requests-div" @keyup.alt.enter="handleAltEnter"></div> <div ref="refResults" class="jql-response-div"></div> </div> </template> <script setup> import { useJServices } from '@/composables/useJServices'; const { serviceFetch } = useJServices() import ace from 'ace-builds'; import 'ace-builds/src-noconflict/mode-yaml'; import 'ace-builds/src-noconflict/theme-chrome'; import { onMounted, ref } from 'vue'; ace.config.set('basePath', '/ace') ace.config.set('workerPath', '/ace') ace.config.set('themePath', '/ace') const props = defineProps({ routeParams: Object, routeQuery: Object, requestBody: Object, metaComponent: Object }) const refRequests = ref(null) const refResults = ref(null) let aceEditorRequests = null let aceEditorResponse = null onMounted(() => { document.title = 'JMatrix: JQL' aceEditorRequests = ace.edit(refRequests.value) aceEditorRequests.setTheme("ace/theme/chrome") aceEditorRequests.session.setMode("ace/mode/yaml") aceEditorRequests.setOptions({ fontSize: "13px", showPrintMargin: false, readOnly: false }) aceEditorRequests.setValue(`- createObject: type: ru.matrix.schema.examples.type.ATPPerson policy: ru.matrix.schema.examples.policy.ALCPerson - createObject: type: ru.matrix.schema.examples.type.ATPPerson policy: ru.matrix.schema.examples.policy.ALCPerson`) aceEditorResponse = ace.edit(refResults.value) aceEditorResponse.setTheme("ace/theme/chrome") aceEditorResponse.session.setMode("ace/mode/yaml") aceEditorResponse.setOptions({ fontSize: "13px", showPrintMargin: true, readOnly: true }) }) const handleAltEnter = () => { request() } const request = () => { aceEditorResponse.setValue('---\nProcess: ...'); aceEditorRequests.setReadOnly(true) serviceFetch( { method: 'POST', headers: { "Content-Type": 'application/yaml', accept: 'application/yaml' } }, `/jql`, {}, aceEditorRequests.getValue() ).then((response) => { aceEditorResponse.setValue(response); aceEditorResponse.clearSelection(); aceEditorRequests.setReadOnly(false) }).catch((error) => { aceEditorResponse.setValue(JSON.stringify(error.message, null, 2)); aceEditorResponse.clearSelection(); aceEditorRequests.setReadOnly(false) }) } </script> <style scoped> .jql-base-div { height: 100%; width: 100%; display: flex; background-color: var(--jmatrix-color-background); gap: var(--jmatrix-spacing-md); } .jql-requests-div { width: 35%; border: 1px solid var(--jmatrix-color-border); border-radius: var(--jmatrix-border-radius); margin-top: var(--jmatrix-spacing-sm); margin-left: var(--jmatrix-spacing-sm); margin-bottom: var(--jmatrix-spacing-sm); } .jql-response-div { width: 65%; border: 1px solid var(--jmatrix-color-border); border-radius: var(--jmatrix-border-radius); margin-top: var(--jmatrix-spacing-sm); margin-right: var(--jmatrix-spacing-sm); margin-bottom: var(--jmatrix-spacing-sm); } </style>
Открываю UI, пробую:

Великолепно! Всё работает. Теперь я могу создавать, удалять, менять объекты и связи в одном окне, не бегая между различными endpoint'ами в сваггере или постмане.
Приступаю к загрузке всего, что там прислал заказчик. Готовлю "скриптинг" простыми формулами (вся чувствительная информация заменена звёздочками):

Копирую получившееся "скрипты" в UI, жму Alt+Enter - все данные залетают в систему со свистом:

Вся процедура массового создания заняла не более 5 минут, вместе с подготовкой. По идее, формулы можно было и не писать, а попросить любой LLM сгенерировать готовые yaml-скрипты, предоставив ему весь Excel и описав структуру. Но мне проще самому написать простую формулу в ячейке.
Уже реализованы команды:
createObject / deleteObject
createConnection / deleteConnection
В планах:
printObject / modifyObject / queryObjects
printConnection / modifyConnection / queryConnections
startTransaction / commitTransaction - для сложных сценариев, где нужна атомарность нескольких операций
UI: планирую добавить выпадающий список доступных команд с примерами, чтобы не держать синтаксис в голове. И кнопку для копирования результата, для вставки в Excel. Сейчас при копировании ответа, статусы команд разлетаются по разным строкам, а хочется получить структуру, которая легко сопоставляется с исходными ячейками, чтобы можно было отфильтровать, какие объекты созданы, а какие нет.
И да, я специально не упомянул про доступы к данным. В текущем виде решение доступно всем авторизованным пользователям, но это не создаёт проблем, поскольку разделение доступов осуществляется не на уровне API, а на уровне статусно-ролевой модели каждого объекта. Если у пользователя нет прав на объект - он его не увидит и не изменит, будь то REST API или yaml. Думаю расскажу об этом в следующей статье.
Заключение
Вот так очередной запрос заказчика заставил меня срочно придумать решение для массовой загрузки данных, с подготовкой yaml "на коленке". На всю реализацию ушёл один световой день, а сами данные я загрузил за 5 минут. Сама концепция мне чертовски понравилась. Всё работает ровно так как нужно. Я думал что придётся делать очередной DSL, а получилось даже лучше, чем я ожидал - все endpoint'ы теперь доступны в одном окне, с возможностью их выполнения в любом порядке и любой комбинации.
Отдельно добавлю, что это не замена REST API и не попытка создать очередной стандарт. Это инструмент для тех случаев, когда разработчиков нет рядом, а данные загрузить нужно, без головной боли. Особо опытные «внедренцы» могут, как и раньше, использовать REST и любой удобный им скрипт. Но даже я, обладая навыками скриптинга (VBA, JavaScript, PowerShell, Groovy и так далее) — не очень люблю эти манипуляции с импортом данных через REST. «Хлопотно это!»
На этом всё. Сегодня без народной мудрости :-)
