Контейнеризация справочников НСИ
Привет, меня зовут Комаров Алексей, я 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
@RestController
@RequestMapping(value = "/api/v1/uer")
public class UerController {
@ResponseStatus(HttpStatus.OK)
@GetMapping
public FindUerRecordsResponse find(@RequestParam Map map) {
FindUerRecordsResponse response = new FindUerRecordsResponse();
List 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 find(@RequestParam Map map) {
String gid = map.get("gid");
String code = map.get("code");
String uerName = map.get("uerName");
String groupCode = map.get("group");
String subgroupCode = map.get("subgroup");
String section = map.get("section");
String workType = map.get("workType");
String mu = map.get("mu");
String projectPin = map.get("projectPin");
String projectCode = map.get("projectCode");
String recordStatus = map.get("recordStatus");
String curatorApproveType = map.get("curatorApproveType");
String methodologApproveType = map.get("methodologApproveType");
Specification uerSpecification = Specification
.where(StringUtils.hasText(gid) ? UerSpec.equalGid(gid) : null)
.and(StringUtils.hasText(code) ? UerSpec.likeCode(code) : null)
.and(StringUtils.hasText(uerName) ? UerSpec.likeUerName(uerName) : null)
.and(StringUtils.hasText(groupCode) ? UerSpec.equalUerGroup(groupCode) : null)
.and(StringUtils.hasText(subgroupCode) ? UerSpec.equalUerSubgroup(subgroupCode) : null)
.and(StringUtils.hasText(section) ? UerSpec.likeSection(section) : null)
.and(StringUtils.hasText(workType) ? UerSpec.equalWorkType(workType) : null)
.and(StringUtils.hasText(mu) ? UerSpec.equalMu(mu) : null)
.and(StringUtils.hasText(projectPin) ? UerSpec.likeProjectPin(projectPin) : null)
.and(StringUtils.hasText(projectCode) ? UerSpec.likeProjectCode(projectCode) : null)
.and(StringUtils.hasText(recordStatus) ? UerSpec.equalRecordStatus(recordStatus) : null)
.and(StringUtils.hasText(curatorApproveType)
? UerSpec.equalCuratorApproveType(Boolean.parseBoolean(map.get("curatorApproveType")))
: null)
.and(StringUtils.hasText(methodologApproveType)
? UerSpec.equalMethodologApproveType(Boolean.parseBoolean(map.get("methodologApproveType")))
: null);
Pageable page = PageRequest.of(0, 1000, Sort.by(Sort.Direction.DESC, "creationDate"));
List list = uerRepository.findAll(uerSpecification, page).toList();
return list.stream().map(o -> uerMapper.toUerDto(o)).collect(Collectors.toList());
}
}
public class UerSpec {
public static Specification equalCuratorApproveType(Boolean value) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.equal(root.get("curatorApproveType"), value);
});
}
public static Specification equalGid(String gid) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.equal(root.get("gid"), gid);
});
}
public static Specification equalMethodologApproveType(Boolean value) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.equal(root.get("methodologApproveType"), value);
});
}
public static Specification equalMu(String value) {
return ((root, query, criteriaBuilder) -> {
Join g = root.join("mu");
return criteriaBuilder.equal(g.get("symbol"), value);
});
}
public static Specification equalRecordStatus(String value) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.equal(root.get("recordStatus"), new RecordStatus(Integer.parseInt(value)));
});
}
public static Specification equalUerGroup(String groupCode) {
return ((root, query, criteriaBuilder) -> {
Join g = root.join("uerGroup");
return criteriaBuilder.equal(g.get("groupCode"), groupCode);
});
}
public static Specification equalUerSubgroup(String subgroupCode) {
return ((root, query, criteriaBuilder) -> {
Join g = root.join("uerSubgroup");
return criteriaBuilder.equal(g.get("groupCode"), subgroupCode);
});
}
public static Specification equalWorkType(String workType) {
return ((root, query, criteriaBuilder) -> {
WorkType o = new WorkType();
o.setId(Long.parseLong(workType));
return criteriaBuilder.equal(root.get("workType"), o);
});
}
public static Specification isActual(Integer isActual) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.equal(root.get("isActual"), isActual);
});
}
public static Specification likeCode(String code) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.like(root.get("code"), contains(code));
});
}
public static Specification likeProjectCode(String value) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.like(root.get("projectCode"), value);
});
}
public static Specification likeProjectPin(String value) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.like(root.get("projectPin"), value);
});
}
public static Specification likeUerName(String uerName) {
return ((root, query, criteriaBuilder) -> {
return criteriaBuilder.like(root.get("uerName"), contains(uerName));
});
}
private static String contains(String expression) {
return MessageFormat.format("%{0}%", expression);
}
}
Поиск в БД выполняется с помощью Spring JPA Repository:
public interface UerRepository extends JpaRepository, JpaSpecificationExecutor {
}
Раздел «Запросы»
Архитектура и логика работы раздела «Запросы» в части поиска и просмотра результатов запросов полностью аналогична архитектуре раздела «Записи».
Для создания запроса вызывается метод «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 static Map map = new HashMap<>();
static {
map.put(RS.S011, S011Draft.class);
map.put(RS.S012, S012ReviewByInitiatorFromExpert.class);
map.put(RS.S013, S013ReviewByInitiatorFromCurator.class);
map.put(RS.S020, S020SendToExpert.class);
map.put(RS.S022, S022SendToExpertFromInitiatorReview.class);
map.put(RS.S023, S023ReviewByExpertFromCurator.class);
map.put(RS.S024, S024ReviewByExpertFromMethodolog.class);
map.put(RS.S030, S030SendToCurator.class);
map.put(RS.S033, S033SendToCuratorFromExpertReview.class);
map.put(RS.S034, S034ReviewByCuratorFromMethodolog.class);
map.put(RS.S040, S040SendToMethodolog.class);
map.put(RS.S044, S044SendToMethodologFromCuratorReview.class);
map.put(RS.S120, S120SendToRS.class);
map.put(RS.S140, S140Cancel.class);
}
private final ApplicationContext context;
public BusinessCommand buildProcessor(String status) {
Class> c = map.get(status);
BusinessCommand bean = (BusinessCommand) context.getBean(c);
return bean;
}
}
Рассмотрим в качестве примера переход на шаг согласования после заполнения пользователем черновика запроса:
@Component
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 list = new ArrayList<>();
String[] roles = BusinessRules.getPotencialOwnerRole(targetStatus);
for (int i = 0; i < roles.length; i++) {
String string = roles[i];
List userList = helper.getRoleMembers(string);
Iterator 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 roleMembersEmails = helper.getRoleMembersEmails(Roles.EXPERT);
String subject = String.format("Запрос %s отправлен на согласование эксперту", request.getId());
String url = "test_host";
String body = String.format(
"Запрос %s на %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 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 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(
"Эксперт изменил данные запроса %s на %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 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:latest .
- docker push nexus.host/uer-redos:latest
.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 остается всегда одной и той же для всех видов справочников, при необходимости можно и ее дополнить нужными полями, на работе приложения это никак не скажется.