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

Предыдущая часть: Enovia умерла, да здравствует JMatrixPlatform: пересобираю легендарную платформу на Java и Vue

Начинаю короткий цикл с мини-историями разработки JMatrixPlatform. Информации очень много, но я начну с задачи взаимодействия технических специалистов с самой системой и её данными.

Проблема

Для очередной пре-сейл кастомизации заказчик прислал все свои исторические данные в Excel и попросил загрузить их в платформу для последующей демонстрации. Это был стандартный набор данных: различные структуры, классификаторы, справочные элементы и сам содержательный контент. В общей сложности я насчитал порядка нескольких тысяч объектов различных типов. И тут в моей голове прозвучало длинное "пу пу пууу..." .

И если вернуться к возможностям Enovia, то там это вообще "изи": подготовка и импорт занял бы ровно 5 минут. Но в JMatrixPlatform - "всё есть код" и теперь у меня проблема. Я не могу быстро прогружать исторические данные, не написав java кода. Вдобавок, аналитик потерял возможность самостоятельно управлять данными без моей помощи.

Что ж, пошёл искать решения:

  1. Дополнить REST API batch сервисами. Хороший вариант. Но преобразование Excel в json массив станет новой проблемой. Можно конечно использовать VBA или другой скриптинг, но раньше мы пользовались простыми Excel формулами для составления простейших DSL команд вида: "add businessobject Person LOGIN 0 fullName 'ФИО' 'lastName' 'Фамилия' ...;". Составить формулой полноценный json объект - серьёзное препятствие. Расставлять двойные кавычки в формулах - та ещё боль (кто знает, тот знает). Плюс понадобится стороннее приложение типа postman, чтобы выполнить такой запрос, поскольку Swagger UI для этой задачи неудобен. В общем, вариант рабочий, но превращает простую задачу в квест.

  2. Использовать curl + json для каждого объекта. Тоже хороший вариант. Но нужен pre-script для получения токена. Плюс также надо преобразовать Excel в json. Ну и импорт будет останавливаться на каждой строчке, в которой будет ошибка. Не вариант.

  3. Есть же SQL!!! Нет, он тоже не подходит. Вся логика работы с данными осуществляется в самом приложении JMatrixPlatform. Импортировать в обход приложения в данном случае - выстрел себе в ногу. Плюс нужен веб клиент БД для выполнения SQL. Отметаю.

  4. Писать нативный импорт из 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'ами в сваггере или постмане.

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

Скриншот Excel заказчика с моими формулами
Скриншот Excel заказчика с моими формулами

Копирую получившееся "скрипты" в 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. «Хлопотно это!»

На этом всё. Сегодня без народной мудрости :-)