
Привет, меня зовут Комаров Алексей, я 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/
Добавить комментарий