Контейнеризация справочников НСИ

от автора

Привет, меня зовут Комаров Алексей, я Java-разработчик в Норникель Спутник. Занимаюсь развитием и поддержкой системы АСУ НСИ (Автоматизированная система управления нормативно-справочной информацией).

В этой статье я хотел бы поделиться успешным опытом создания кастомного веб-приложения для ведения мастер-данных справочника УЕР и его запуска с использованием санкционно-независимого ПО.

Сценарий работы в системе

В АСУ НСИ ведутся и хранятся мастер-данные: материалы, контрагенты, работы и услуги, проекты и так далее. Реализован классический сценарий работы системы по ведению мастер-данных.

Логика работы пользователей следующая:

  • Пользователь заходит в систему

  • Ищет нужную запись

  • Если не нашел, то формирует запрос на создание.

  • Если нашел, то формирует запрос на изменение/выгрузку/блокировку

  • Заполняет все обязательные поля, проходит валидации

  • Отправляет на согласование

  • Ответственные пользователи согласовывают запрос

Каждый справочник имеет свой список согласующих и их последовательность. Запись проходит все шаги запроса, в финале ей присваивается уникальный идентификатор и запись выгружается в локальные системы. При этом каждая локальная система при получении записи возвращает ответ:

  • В случае успеха – локальный идентификатор записи

  • В случае ошибки при получении – код и текст ошибки

АСУ НСИ состоит из двух компонентов:

  • SAP Master Data Management (SAP MDM) – управление данными

  • SAP Enterprise Portal (SAP EP) – веб-портал, через который осуществляется работа пользователей с мастер-данными.

Ранее было принято решение о создании справочника и настройки цепочек согласования посредством Java веб-приложения в SAP EP, базой данных выступал SQL Server.

Удалось успешно реализовать гибкую и настраиваемую цепочку согласования. Появилась возможность внедрения любой бизнес-логики на любом шаге цепочки согласования в любом направлении, в том числе:

  • новая логика обработки данных (для примера, если цепочка согласования запроса долго ходит по кругу, то на пятый круг отправить почтовое уведомление руководителям)

  • сохранение данных для отчетности (история изменений записи на шагах запроса)

  • возможна вставка использования интеграции (отправка/получение данных смежных систем) на любых шагах согласования

  • рассылка почтовых уведомлений при выполнении настраиваемых
    условий в бизнес-логике

Ранее было принято решение провести оценку санкционно-независимых решений в области НСИ. Одним из решений может являться контейнеризация веб-приложения на санкционно-независимых компонентах.

Наш новый справочник как раз реализован на Java и работает с реляционной БД.

Мы понимали, что реализованный справочник в SAP EP представляет из себя веб-приложение на Java, которое может быть легко запущено с использованием санкционно-независимого ПО, поэтому решили реализовать это на практике:

  • Вынести Java-код из SAP EP

  • Доработать Java-код до полноценного Spring Boot Application, заменив библиотеки SAP по работе с пользователями и полномочиями на Spring Security

  • Перенести структуру БД в PostgreSQL и запустить в контейнере

  • Сделать образ Java приложения с использованием отечественного образа Red OS и Liberia JDK

  • Запустить!

Вдохновились и сделали!

Проведенное функциональное тестирование подтвердило успех выполненной разработки.

Детализируем выполненную разработку

Приложение содержит две составляющие: Пользовательская и Техническая.

Со стороны пользователя

Разделы приложения имеют следующие функции:

Раздел «Записи»

  • поиск записей

  • просмотр результатов поиска

  • просмотр карточки каждой отдельной записи, который включает: все поля записи, история изменения записи на каждом шаге каждого типа запроса

  • персонализация интерфейса пользователя

  • создание запросов на создание, изменение, блокирование, копирование, выгрузка в смежные системы записи

  • экспорт записей в MS Excel

Раздел «Запросы»

  • поиск по полям записи и запросов

  • просмотр результатов поиска

  • просмотр карточки каждого запроса, который включает:

    • все поля записи

    • история изменения записи на каждом шаге запроса

    • обработка запросов на создание, изменение, блокирование, копирование, выгрузку в локальную систему записи

    • экспорт запросов в MS Excel

    • валидации записи на шагах согласования

    • персонализация интерфейса пользователя

С технической стороны (много Java-кода)

Раздел «Записи»

Главной задачей данного раздела является поиск и просмотр записей. Раздел реализован следующим образом: REST Controller принимает GET-запрос и входные параметры в Map<String, String> и передает их на вход Service:

@RestController @RequestMapping(value = "/api/v1/uer") public class UerController { @ResponseStatus(HttpStatus.OK) @GetMapping public FindUerRecordsResponse find(@RequestParam Map<String, String> map) { FindUerRecordsResponse response = new FindUerRecordsResponse(); List<UerDto> list = uerService.find(map); response.setRecords(list); response.setRecordsCount(list.size()); return response; } }

Service получает параметры и выполняет поиск с помощью Specification в JPA Repository:

@Service @RequiredArgsConstructor public class UerService  { @Transactional public List<UerDto> find(Map<String, String> map) { Specification<Uer> uerSpecification =            Specification.where(UerSpec.equalGid(map.get("gid"))) .and(UerSpec.likeCode(map.get("code")))                 .and(UerSpec.likeUerName(map.get("uerName"))) .and(UerSpec.equalUerGroup(map.get("group")))                 .and(UerSpec.equalUerSubgroup(map.get("subgroup"))) .and(UerSpec.likeSection(map.get("section")))                 .and(UerSpec.equalWorkType(map.get("workType"))) .and(UerSpec.equalMu(map.get("mu")))                 .and(UerSpec.likeProjectPin(map.get("projectPin"))) .and(UerSpec.likeProjectCode(map.get("projectCode")))                 .and(UerSpec.equalRecordStatus(map.get("recordStatus"))) .and(UerSpec.equalCuratorApproveType(map.get("curatorApproveType"))) .and(UerSpec.equalMethodologApproveType(map.get("methodologApproveType")));  Pageable page = PageRequest.of(0, 1000, Sort.by(Sort.Direction.DESC, "creationDate")); List<Uer> list = uerRepository.findAll(uerSpecification, page).toList();  return list.stream().map(o -> uerMapper.toUerDto(o)).collect(Collectors.toList()); } }
public class UerSpec {  public static Specification<Uer> equalCuratorApproveType(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.equal(root.get("curatorApproveType"), Boolean.parseBoolean(value)); }) : null; }  public static Specification<Uer> equalGid(String gid) { return StringUtils.hasText(gid) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.equal(root.get("gid"), gid); }) : null; }  public static Specification<Uer> equalMethodologApproveType(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.equal(root.get("methodologApproveType"), Boolean.parseBoolean(value)); }) : null; }  public static Specification<Uer> equalMu(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { Join<UerClassifier, Uer> g = root.join("mu"); return criteriaBuilder.equal(g.get("symbol"), value); }) : null; }  public static Specification<Uer> equalRecordStatus(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.equal(root.get("recordStatus"), new RecordStatus(Integer.parseInt(value))); }) : null; }  public static Specification<Uer> equalUerGroup(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { Join<UerClassifier, Uer> g = root.join("uerGroup"); return criteriaBuilder.equal(g.get("groupCode"), value); }) : null; }  public static Specification<Uer> equalUerSubgroup(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { Join<UerClassifier, Uer> g = root.join("uerSubgroup"); return criteriaBuilder.equal(g.get("groupCode"), value); }) : null; }  public static Specification<Uer> equalWorkType(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { WorkType o = new WorkType(); o.setId(Long.parseLong(value)); return criteriaBuilder.equal(root.get("workType"), o); }) : null; }  public static Specification<Uer> isActual(Integer value) { return value == null ? null : ((root, query, criteriaBuilder) -> { return criteriaBuilder.equal(root.get("isActual"), value); }); }  public static Specification<Uer> likeCode(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.like(root.get("code"), contains(value)); }) : null; }  public static Specification<Uer> likeProjectCode(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.like(root.get("projectCode"), value); }) : null; }  public static Specification<Uer> likeProjectPin(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.like(root.get("projectPin"), value); }) : null; }  public static Specification<Uer> likeSection(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.like(root.get("section"), contains(value)); }) : null; }  public static Specification<Uer> likeUerName(String value) { return StringUtils.hasText(value) ? ((root, query, criteriaBuilder) -> { return criteriaBuilder.like(root.get("uerName"), contains(value)); }) : null; }  public static Specification<Uer> notEqualRecordStatus(String value) { return StringUtils.hasText(value) ? (root, query, criteriaBuilder) -> { Join<RecordStatus, Uer> t = root.join("recordStatus"); return criteriaBuilder.notEqual(t.get("gid"), value); } : null; }  private static String contains(String expression) { return MessageFormat.format("%{0}%", expression); } }

Поиск в БД выполняется с помощью Spring JPA Repository:

public interface UerRepository extends JpaRepository<Uer, Long>, JpaSpecificationExecutor<Uer> {      }

Раздел «Запросы»

Архитектура и логика работы раздела «Запросы» в части поиска и просмотра результатов запросов полностью аналогична архитектуре раздела «Записи».

Для создания запроса вызывается метод «create» класса RequestController, обрабатывающий POST запросы. На вход получаем RequestDto, в котором указывается тип необходимого запроса — создание, изменение, блокирование, копирование (дополнительно указывается из какой записи необходимо сделать копирование, взяв ее за основу).

Когда пользователь создал запрос и заполнил все необходимые поля, он нажимает одну из управляющих кнопок — сохранить, согласовать, отменить, отправить на уточнение или отправить на согласование. В этом случае выполняется PUT-запрос и запускается метод «update» класса RequestController, обрабатывающий PUT запросы. На вход получаем объект RequestDto со всеми данными, заполненными пользователем, action – кнопка, нажатая пользователем. Контроллер передает все полученные данные в класс Service, выполняющий всю бизнес-логику:

@RestController @RequestMapping(value = "/api/v1/requests") public class RequestController { ... @ResponseStatus(HttpStatus.OK) @PostMapping public RequestDto create(@RequestBody RequestDto requestDto) { return requestService.create(requestDto); }  @ResponseStatus(HttpStatus.OK) @PutMapping public RequestDto update(@RequestBody RequestDto requestDto) { return requestService.update(requestDto); } }

Класс Service выполняет часть бизнес-логики и передачу обработки классу BusinessCommand, который выполнит всю необходимую бизнес-логику для перехода из текущего статуса запроса в необходимый, в соответствии с нажатой кнопкой (отправить на согласование, отправить на уточнение, отменить, согласовать и т. д.) — это «Теория автоматов» (у нас есть текущее состояние, мы получили команду на переход в новое состояние, выбираем нужную бизнес-команду и выполняем надлежащую бизнес-логику). Код:

@Service @RequiredArgsConstructor public class RequestService { … private final BusinessCommandFactory factory;  @Transactional public RequestDto create(RequestDto in) { //в полученном объекте Dto приходит требуемый тип запроса:         //создание, изменение, блокирование, копирование. //Преобразуем маппером в объект Entity Request request = requestMapper.toRequestEntity(in); //получаем класс-обработчик статуса черновик - 011 BusinessCommand businessCommand = factory.buildProcessor(RS.S011); //обработчик businessCommand создаст запрос, запись,          //заполнит все необходимые поля начальными          //значениями и сделает любую нужную бинес-логику businessCommand.execute(request); // отдаем результат выполнения на фронтентд return requestMapper.toRequestDto(((S011Draft) buildProcessor).getReq()); } @Transactional public RequestDto update(RequestDto requestDto) { // запускаем валидации validator.execute(requestDto); // при успехе валидаций сохраняем полученные данные, что бы при форс-мажоре // пользователю не нужно было заново вводить данные uerService.save(requestDto); Request request = requestRepository.findById(requestDto.getId()).orElseThrow(); // сохраняем состояние сущности запроса (в ней же и сама запись) в историю // изменений на данном шаге helper.saveToHistory(request); // вычисляем следующий статус по текущему статусу запроса и выбранному action, // на вход передаем запрос, так как при вычислении конечного статуса могут // понадобится дополнительные данные из данных запроса String targetStatus = BusinessRules.getStatus(request); // получаем обработчика, который выполнит всю бинзес логику для перехода на // следующий статус BusinessCommand businessCommand = factory.buildProcessor(targetStatus); // запускаем обработку         businessCommand.execute(requestRepository.findById(requestDto.getId()).orElseThrow()); // отдаем результат выполнения на фронтентд return requestMapper.toRequestDto(requestRepository.findById(requestDto.getId()).orElseThrow()); } }

Класс BusinessCommandFactory — фабрика создающая классы BusinessCommand, выполняющие всю бизнес-логику при переходе на необходимый статус запроса. Класс содержит объект Map с ключом (статус запроса) и value (объект класса бизнес-команды). При вызове класса BusinessCommandFactory достаем из контекста Spring нужный бин бизнес-команды. Внутри бизнес-команды может быть встроена любая бизнес-логика.

@Component @RequiredArgsConstructor public class BusinessCommandFactory { private final Map<String, BusinessCommand> map;  public BusinessCommand buildProcessor(String status) { return map.get(status); } }

Рассмотрим в качестве примера переход на шаг согласования после заполнения пользователем черновика запроса:

@Component(RS.S020) public class S020SendToExpert extends BusinessCommand { ... public void execute(Request r) { //заполним даты рассылки электронных писем пользователям          //о необходимости обработать запрос deadlineService.fillNotifications(r, r.getRequestType().getCode(), Step.METHODOLOG);  helper.addToRequestHistory(r, RS.S020); r.setRequestStatus(helper.getRequestStatus(RS.S020)); r.setUserComment(null);  String targetStatus = RS.S020; //заполняем пользователей - потенциальных обработчиков на данном шаге  //в соответствии с ролевой моделью List<PotencialOwner> list = new ArrayList<>(); String[] roles = BusinessRules.getPotencialOwnerRole(targetStatus); for (int i = 0; i < roles.length; i++) { String string = roles[i]; List<UerUser> userList = helper.getRoleMembers(string); Iterator<UerUser> iterator = userList.iterator(); while (iterator.hasNext()) { UerUser uerUser = iterator.next(); PotencialOwner potencialOwner = new PotencialOwner(); potencialOwner.setRequest_id(r); potencialOwner.setUser_id(uerUser); list.add(potencialOwner); } } r.getPotencialOwners().clear(); r.getPotencialOwners().addAll(list);  // очищаем обработчика на предыдущем шаге r.getRequestProcessor().clear();  r = requestRepository.save(r); //отправляем уведомление о новом запросе всем ответственным за обработку  sendEmail(r); }  private void sendEmail(Request request) { List<String> roleMembersEmails = helper.getRoleMembersEmails(Roles.EXPERT); String subject = String.format("Запрос %s отправлен на согласование эксперту", request.getId()); String url = "test_host"; String body = String.format( "Запрос <a href='%s' target='_blank'>%s</a> на %s записи %s, %s отправлен на согласование.", url, request.getId(), request.getRequestType().getDescr(), request.getUer().getGid(), request.getUer().getUerName());  sendEmail(request, roleMembersEmails, subject, body); }  }

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

@Component public abstract class BusinessCommand {  ...  @Autowired protected Helper helper; @Autowired protected RequestMapper requestMapper; @Autowired protected EmailRepository emailRepository; @Autowired protected DeadlineService deadlineService; @Autowired protected RequestRepository requestRepository; @Autowired protected UerRepository uerRepository; @Autowired protected RequestTypeRepository requestTypeRepository; @Autowired protected RequestStatusRepository requestStatusRepository; @Autowired protected RecordStatusRepository recordStatusRepository;  public abstract void execute(Request request);  protected void sendEmail(Request request, List<String> emails, String subject, String bodyS) { StringBuilder body = new StringBuilder(); body.append(MailHelper.Texts.HEADER); body.append(bodyS); body.append(MailHelper.Texts.FOOTER); MailHelper.sendEmail(emails, subject, body.toString(), MailHelper.ContentType.HTML); Email email = new Email(); email.setRequestId(request.getId()); email.setRequestStatus(request.getRequestStatus().getCode()); email.setEmailFrom(Helper.mailFrom()); email.setEmailTo(String.join(", ", emails)); email.setSubject(subject); email.setBody(body.toString()); email.setCreationDate(new Timestamp(System.currentTimeMillis())); emailRepository.save(email); }  protected void sendEmailToInitiator(Request r) { List<String> email = helper.getCurrentUserEmail(); String subject = String.format("Эксперт изменил данные запроса %s внесенные инициатором", r.getId()); String host = "test_host"; String url = host + "/index.html#/task/" + r.getId(); String body = String.format( "Эксперт изменил данные запроса <a href='" + url + "' target=\"_blank\">%s</a> на %s УЕР, %s внесенные инициатором", r.getId(), r.getRequestType().getDescr(), r.getUer().getUerName()); sendEmail(r, email, subject, body); } }

История изменений записи на шагах запроса реализована в классе RequestService. При переходе на новый шаг мы сохраняем состояние записи на текущий момент, чтобы пользователь смог увидеть, как менялась запись от шага к шагу в цепочке согласования. С помощью библиотеки Jackson мы конвертируем объект в json-строку и складываем ее в отдельное поле в PostgreSQL.

@Component @RequiredArgsConstructor public class Helper { public void saveToHistory(Request request) {     ObjectMapper objectMapper = new ObjectMapper();     String json;     RequestDto requestDto = requestMapper.toRequestDto(request);           json = objectMapper.writeValueAsString(requestDto);     Uer u = request.getUer(); historyRepository.save(new UerHistory(u.getSodRecId(), u.getGid(), request.getId(), request.getRequestStatus().getCode(), json, "request", u.getSodRecId())); } }

Далее фронтенд запрашивает данные для определенной записи, получает список json-строк и передает их на вход объекту таблицы:

public List<String> findUerHistory(Integer id) { return historyRepository.findByUerIdOrderByIdAsc(id).stream().map(o -> o.getJson()).collect(Collectors.toList()); }

Запуск приложения (CI/CD)

В качестве основы для запуска используется Docker-образ Red OS с предварительно установленной Liberica JDK 17. Последовательность работы pipe:

  • создаем jar файл нашего веб-приложения

  • упаковываем его в образ Red OS

  • запускаем с помощью Liberica JDK

  • сохраняем образ в Nexus

  • запускаем на удаленной машине образ приложения и PostgreSQL с
    помощью docker-compose файла

Файл .gitlab-ci.yml

stages:  - build-jar  - build-image  - deploy-image   build-jar:  stage: build-jar  image: ${BUILD_IMAGE}   variables:    TRUSTED_ROOT_CERT_PATH: /tmp/cert.pem   script:    - apt-get update && apt-get --assume-yes install wget gnupg && wget -q -O - https://download.bell-sw.com/pki/GPG-KEY-bellsoft | apt-key add -    - echo "deb [arch=amd64] https://apt.bell-sw.com/ stable main" | tee /etc/apt/sources.list.d/bellsoft.list && apt-get update && apt-get --assume-yes install bellsoft-java17    - mvn clean package  artifacts:    paths:      - target/*.jar  build-image:  stage: build-image  dependencies:    - build-jar  script:    - docker login -u ${MAVEN_USER} -p ${MAVEN_PASS} nexus.host    - docker build -t nexus.host/uer-redos:1.0.1 .    - docker push nexus.host/uer-redos:1.0.1  .deploy-image:  stage: deploy-image  script: - ssh -p 9000 -o "StrictHostKeyChecking=no" ${TARGET_USER}:${TARGET_PASSWORD}@${TARGET_MACHINE} 'cd /uer; docker-compose down; docker-compose up -d'

Файл docker-compose

version: '3'  services:   back:     image: back     environment:       - SPRING_PROFILES_ACTIVE=dev       - spring.datasource.url=jdbc:postgresql://postgres:5432/postgres     ports:       - "9889:9889"    postgres:     image: postgres:14.7     domainname: postgres     ports:       - "5432:5432"     volumes:       - v-postgres:/var/lib/postgresql/data     environment:       - POSTGRES_USER=${POSTGRES_USER}       - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}     healthcheck:       test: ["CMD", "pg_isready", "-q", "-U", "postgres"]       interval: 5s       timeout: 1s       retries: 2

Выводы

Приложив не такие большие усилия, мы получили собственное полноценное рабочее веб-приложение, которое:

  • имеет возможность внедрения любой бизнес-логики на любом шаге цепочки согласования в любом направлении, в том числе:

    • новая логика обработки данных (для примера, если цепочка согласования запроса долго ходит по кругу, то на пятый круг отправить почтовое уведомление руководителям)

    • сохранение данных для отчетности (история изменений записи на шагах запроса)

    • возможна вставка использования интеграции (отправка/получение данных смежных систем) на любых шагах согласования

    • рассылка почтовых уведомлений при выполнении настраиваемых условий в бизнес-логике

  • работает на санкционно-независимых компонентах

  • мы прошли функциональное тестирование, что подтверждает работоспособность

  • Легко тиражируется — тиражирование данного решения легко может быть выполнено для других справочников и другой структуры данных, так как вся архитектура останется точно такой же — меняется только ключевая сущность Uer. Сущность Request остается всегда одной и той же для всех видов справочников, при необходимости можно и ее дополнить нужными полями, на работе приложения это никак не скажется.


ссылка на оригинал статьи https://habr.com/ru/articles/781716/