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

51301a86f703dd31b036ecdd7b97f12b.jpg

Привет, меня зовут Комаров Алексей, я 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 и передает их на вход Service:

@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 остается всегда одной и той же для всех видов справочников, при необходимости можно и ее дополнить нужными полями, на работе приложения это никак не скажется.

© Habrahabr.ru