Информационная многоножка: вяжем системы правильно
Раскрываю секреты интеграции, собранные за практически десятилетие непрерывной разработки.
Задача интеграции
Как показывает практика, в современном мире, внутреннее ИТ в любой сколько-нибудь живой и работающей компании очень быстро превращается в зоопарк из самых фантастических тварей.
Как-то стандартизировать набор используемого ПО нельзя, свести к минимуму не получается:
один софт хорошо делает одно, другой — другое, третий нравится начальству, а четвертый далив нагрузкупо скидке.
И раз за разом встает задача интеграции всего этого зоопарка не то чтобы в монолит, но хотя-бы в нечто, не мешающее жить и работать.
Потому как без интеграции все очень быстро скатывается в необходимость ручного ввода сразу в несколько ИТ-систем:
вбиваем ручками заказы в CRM, затем в 1С для бухов и до кучи в систему складского учета.
Очень надеюсь что у вас такого нет, но ситуация сама по себе распространенная и такой вот дичи в реальных компаниях очень много.
Для того чтобы подобного цирка не происходило как раз и используется специальная разработка: интеграционное решение.
Ниже я расскажу про самые частые варианты задач, которые решаются интеграцией, опишу ключевые проблемы и возможные решения.
Вариант первый: внешний источник
Допустим у вас есть некая CRM, в которую необходимо переодически заливать актуальные курсы валют. ЦБ РФ предоставляет вебсервис, который отдает такую информацию.
Бывают и куда более сложные варианты, где работа с внешним источником данных происходит через специальное клиентское SDK.
необходимо написать некий клиентский код, который будет формировать запросы, получать и парсить ответы а затем загружать полученные данные в вашу систему.
Типовая задача для современного программиста.
Вариант второй: входящий вызов
Вы выставляете в интернет некое API, вызываемое из внешней системы.
Чаще всего это сейчас называется вебхук (web-hook) а вызов используется для оповещения о событии.
Для примера возьму классику в виде вставки в багтрекер информации о коммите в репозиторий:
Разработчик отправляет коммит, сервер обслуживающий репозиторий ( например Gitlab ) этот коммит принимает и делает вызов вашего API через вебхук.
{"before":"077a85dd266e6f3573ef7e9ef8ce3343ad659c4e","after":"95cd4a99e93bc4bbabacfa2cd10e6725b1403c60",<SNIP>} example.com - - [14/May/2014:07:45:26 EDT] "POST / HTTP/1.1" 200 0 - -> /
Вот тут подробная статья со всеми примерами, если интересно погрузиться в тему.
Вариант третий: двусторонний обмен
Самый сложный вариант, когда обе связываемых системы представляют интерес друг для друга и необходим обмен бизнесовыми (не техническими) данными в обе стороны между системами.
Тут уже «классических» примеров не бывает, поэтому опишу один из недавних кейсов:
Нужно было внести управление неким бизнес-функционалом одной системы в другую. Если конкретнее то нужно было дать конечным пользователям одного крупного портала управлять своими виртуальными счетами в совершенно другой внешней системе.
Естественно что такая же логика управления должна была продолжать работать в этой внешней системе без изменений — т. е. пользователь мог зайти и туда тоже и управлять своими счетами «извне», с другой стороны портала.
Пользователей много, счетов тоже.
Общие проблемы
Несмотря на кажущиеся существенные отличия, ключевые проблемы во всех трех кейсах одинаковые:
стабильная работа под «плавающей» нагрузкой, возможность временного отключения интеграции без потери данных и минимальное вмешательство в работу конечных систем.
Расскажу подробнее про каждую.
Работа под нагрузкой
Любая интеграция между системами тяжело прогнозируема в плане как объема данных так и скачков роста.
В том смысле, что вы конечно можете подсчитать количество ваших заявок, ваших пользователей и возможный прирост клиентской базы за период эксплуатации, но современный мир очень уж динамичный и турбулентный, поэтому в любой момент вас может накрыть как волна роста как и отскок деградации.
Поэтому решение как минимум должно продолжать функционировать при скачкообразном росте, а в идеале вообще никакой рост не должен как-то влиять на работу.
Ну и неплохо уметь возвращать используемые ресурсы если масштаб интеграции уменьшается.
Да, так тоже бывает — ниже расскажу как такое сделать.
Временное отключение
Очевидно что сами интегрируемые системы временами отключают для обслуживания: установки обновлений, решения каких-то проблем или просто из-за сбоя.
Вообщем всегда есть «maintance time» — временное окно, в течение которого производятся технические работы.
Чтобы ваша интеграция не копила ошибки в попытках передать данные в отключенную систему, нужно иметь возможность ее временно отключать.
Очевидным решением кажется полностью остановить интеграционный сервис, но в этом случае уже вторая сторона (с которой реализуется интеграция), будет копить ошибки в попытках связаться с интеграционным сервисом.
Поэтому отключать надо не сам сервис а логику обработки и передачи данных, естественно с возможностью включения обратно.
Минимальное вмешательство
Последний но наверное самый важный пункт.
Дело в том что когда разрабатывается какая-то система никто сразу не планирует возможность интеграции.
В лучшем случае подразумевается что создаваемый софт — пуп земли, поэтому интегрироваться будут только с ним и только в рамках предоставляемого API.
Которое «мы создадим когда-нибудь потом».
Вообщем наверное процентов 95 всего коммерческого софта не расчитаны на внешнюю интеграцию, а поставляемые через интеграционный сервис данные для конечной системы являются чуждыми.
Что очевидно порождает проблемы.
Вообщем чем меньше ваша интеграция создает проблем для конечных систем тем лучше, поскольку никакое интеграционное решение не является поддерживаемым и одобряемым производителем ПО вариантом использования.
Все проблемы, которые возникнут при интеграции только и исключительно ваши.
На самом деле конечно всегда есть варианты, есть «пакетные решения», допиливаемые в процессе установки у заказчика и много чего еще.
Теперь кратко расскажу о существующих решениях и подходах, чтобы у вас не сложилось впечатление будто это какая-то неведомая редкость и «горячая тема» для нового стартапа.
Как у дидов
Не буду углубляться уж совсем в далекое (по меркам ИТ) прошлое, ограничимся последними 20ю годами, те возьмем период с начала 2000х.
Вовсю расцвел интернет, бурно растет телеком и народ усиленно качает первые торренты с музыкой и фильмами.
Уже тогда в ИТ количество задействованных систем росло лавинообразно, при этом появились стандарты для вебсервисов (в первую очередь речь про SOAP). Что привело к идее создания некоего универсального решения, где можно без привлечения разработчиков создавать интеграционные проекты.
На практике разумеется все оказалось несколько сложнее и любой более-менее сложный проект интеграции требовал участия разработчиков.
Дальше все предсказуемо скатывалось в обычное программирование, без всех красивостей со стрелочками и блоками.
Про все это направление ESB, MOM — расскажу в отдельной статье, благо опыт позволяет.
Сейчас остановимся на том простом факте что в нынешних реалиях все подобные решения отошли на второй план и если еще используются то больше по инерции.
Новое время
Примерно с 2014го, когда началось сильное развитие виртуализации, микросервисной архитектуры, RESTful вебсервисов, пополам с асинхронным безумием из Node.js — подход к реализации интеграций между системами поменялся.
От универсальности стали отказываться в пользу небольшого сервиса, написанного «на коленке» специально под задачу интеграции в этом конкретном месте.
Поэтому современная интеграция это чаще всего маленький но отдельный микросервис на Go/Java/Node.js в докере, перекладывающий данные из одной системы в другую.
Можно долго рассуждать о причинах, что де «универсальные решения слишком сложные», с ними долго возиться и все такое прочее.
С моей точки зрения это просто дань моде, но не технически обоснованный выбор.
Вторым популярным вариантом современной интеграции является использование готовых адаптеров для популярных систем.
Для конечного пользователя это выглядит как-то так:
По-сути это отдельное мини-приложение, написанное под конкретную платформу из которой будет происходить подключение.
Чаще всего такие приложения еще и публикуются в специальных маркетплейсах, поддерживаемых вендором платформы.
Вообщем это ближе уже к современной мобильной разработке чем к старым корпоративным интеграционным решениям.
Мы такое делать умеем и любим, если у вас есть ваша любимая система и к ней нужно реализовать вот такие «клиентские» адаптеры интеграции — пишите, поможем.
Теперь наконец перехожу к главному — к своим наработкам и идеям по созданию идеальной интеграции:
Рассказываю как это сделать, поехали.
Решение
речь про специальную реализацию — код, написанный под конкретную задачу интеграции, без «универсальных решений».
Создается отдельный вебсервис — мини-приложение со своим веб-интерфейсом. Технически мы используем Java и Spring Boot, взаимодействия с вебсервисами SOAP чаще всего реализованы через JAX-WS ради максимальной совместимости.
Выбор Java обусловлен максимальной кроссплатформенностью и богатым опытом взаимодействия с чужими вебсервисами, реализованными на всяких .NET, Node.js и Golang.
Как показывает практика, добиться такого же уровня работы с вебсервисами из других языков сильно сложнее.
Интерфейс максимально статический, построенный на Thymeleaf и чистом Javascript, без кодогенерации, без Typescript и Angular/React.
Все это ради сокращения возможных проблем со сборкой и развертыванием и снижения требований к среде запуска.
Не стоит забывать что это прежде всего административный интерфейс, который должен работать всегда и из любого браузера.
Вот так выглядит интерфейс интеграционного шлюза на примере одного из прошлых проектов:
Как видите, тут реализованы функции ручной отправки запросов, в данном случае в систему-источник данных.
Это необходимо для проверки корректности работы, поскольку с другой стороны данной интеграции находится активно разрабатываемая система.
Само взаимодействие происходит в фоне, с определенной переодичностью. Каждый сеанс обмена данными фиксируется в журнале:
Вот так выглядит детализация для одной записи:
Обратите внимание на раздел с дополнительными полями — это специально реализованное пространство в стиле 'ключ-значение' для отображения технических деталей, заполняемое в процессе работы интеграции.
Также тут фиксируется время, затраченное на весь процесс обработки — на скриншоте видно что сервис останавливали, поэтому большая разница между началом и концом обработки.
Но это лишь интерфейс, теперь перейдем к деталям реализации.
Работа с пользователями и авторизация
Интеграционные сервисы абсолютно точно стоит изолировать от доступа извне, выбранный в качестве примера проект также эксплуатируется в закрытом контуре.
Это позволяет упростить аутентификацию, сведя все до ввода логина с паролем:
Обратите внимание на детализацию информации о версии: тут и номер сборки и дата со временем.
Отображается это все сразу (до авторизации) также по причине работы в закрытом контуре:
куда важнее дать поддержке информацию об установленной версии чем требовать авторизацию (которая может сломаться)
Также мы проработали еще один важный аспект связанный с практическим использованием:
Фактически их за все время эксплуатации будет 2-3.
Ближайшая аналогия — ваш домашний роутер, который вы один раз настроили и забыли.
Такое небольшое количество пользователей означает что нет практического смысла использовать базу данных в качестве хранилища, достаточно какого-то файла, считываемого при старте.
Именно так мы и поступили, реализовав хранение записей о пользователях в CSV-файле на диске.
Cам интерфейс при этом получился достаточно сложным, закрывающим все возможные потребности по ведению учеток пользователей в таком сервисе:
Как видите тут есть назначение ролей, смена пароля, отключение и добавление - весь необходимый набор.
Сам механизм авторизации при этом стандартный для Spring Security:
/** * стандартная API-функция Spring Security для отдачи пользователя по логину * * @see UserDetailsService * @param login логин пользователя * @return * @throws UsernameNotFoundException */ @Override public SignGateUser loadUserByUsername(String login) throws UsernameNotFoundException { // по соглашению данного API - если пользователь не найден должно быть // выброшено исключение UsernameNotFoundException if (!users.containsKey(login)) { throw new UsernameNotFoundException(SystemError.messageFor(0x6015, login)); } final SignGateUser user = users.get(login); if (log.isDebugEnabled()) { log.debug("найден пользователь {}", user); } if (user.getRoles().isEmpty()) { throw new UsernameNotFoundException(SystemError.messageFor(0x6016, login)); } return user; }
Кстати обратите внимание на коды ошибок и сообщений — это сделано специально для упрощения последующего сопровождения, чтобы по коду ошибки можно было быстро понять причину сбоя.
Входящая обработка
Данный проект имеет входящий вебсервис - тот самый вебхук (web-hook), который вызывается снаружи, другой системой.
В примере ниже, частично показана реализация обработки такого входящего запроса о получении справочника:
/** * Проверка статуса выполнения запроса. * * @param request * @param getDictionaryRequest * @return */ @HystrixCommand(commandKey = "getDictionary", groupKey = "sgateInputWS", fallbackMethod = "getDictionaryFallback") public GetDictionaryResponse getDictionary(HttpServletRequest request, GetDictionaryRequest getDictionaryRequest) { processFallback(); final GetDictionaryResponse out = new GetDictionaryResponse(); // проверка на отключение сервиса if (!ps.isEnabled()) { // сообщение об отключении вебсервиса setError(0x8006, out); return out; } // проверка API ключа ( если авторизация включена ) final Result rr = checkSecret(request); if (rr != null) { // сообщение об ошибке авторизации вебсервиса setError(rr.getCode(), out); return out; } // инф-я о запросе final SignLogRecord.RequestInfo ri = fillRequestInfo(request); final ProcessingService.ProcessDTO dto = new ProcessingService.ProcessDTO(); // тип действия dto.setActionType("GET_DICT"); dto.setReq(ri); // id справочника dto.getRequestData().put("dictId", getDictionaryRequest.getDictId()); // версия ( опционально ) dto.getRequestData().put("version", getDictionaryRequest.getVersion()); // тип выгружаемых данных ( CSV,XML,XLS,JSON ) dto.getRequestData().put("exportType", getDictionaryRequest.getExportType()); // начальная дата актуальности данных ( опционально ) if (getDictionaryRequest.getDictFromDt() != null) { dto.getRequestData().put("dictFromDt", getDictionaryRequest.getDictFromDt().toString()); } // конечная дата актуальности данных ( опционально ) if (getDictionaryRequest.getDictToDt() != null) { dto.getRequestData().put("dictToDt", getDictionaryRequest.getDictToDt().toString()); } // id организации final String orgId = request.getHeader(HTTP_ORG_ID_HEADER); if (orgId != null) { dto.getRequestData().put("orgId", orgId); } ... // отправка на обработку ps.processInput(dto); out.setRequestId(ri.getRequestId()); out.setGeneratedAt(getXMLGregorianCalendarNow()); return out; }
Как видите мы использовали Hystrix для автоматического отключения входящего сервиса при перегрузке - в качестве защиты от ДДОС либо при большом количестве сбойных запросов.
Как только срабатывает триггер Hystrix, происходит переключение с реального метода обработки на заглушку:
/** * Метод заглушки в случае сбоя при вызове * * @param request * @param getDictionaryRequest * @return */ public GetDictionaryResponse getDictionaryFallback(HttpServletRequest request, GetDictionaryRequest getDictionaryRequest) { // отключение вебсервиса при большом количестве сбоев ps.setEnabled(false);wasFallback=true; final GetDictionaryResponse out = new GetDictionaryResponse(); out.setRequestId(null); out.setGeneratedAt(getXMLGregorianCalendarNow()); // сообщение об отключении вебсервиса out.setErrorCode(String.format("0x%x", 0x8001)); out.setErrorMessage(SystemError.messageFor(0x8001, false)); return out; }
Следующий этап обработки входящего запроса заключается в формировании записи в базу сервиса, с простановкой статуса 'ОЖИДАНИЕ ОБРАБОТКИ'.
Данный статус является признаком для SQL-выборки, которой в фоне, с определенной переодичностью выгребаются созданные записи.
Технически это реализация очереди сообщений через записи в базе данных.
Ниже приведен код реализации (частично):
@Service public class ProcessingService extends AbstractService { @Autowired private PluginService ps; @Autowired private EPProcessingService sfs; @Autowired protected FileService fs; @Value("${app.api.input.enabled:true}") private boolean enabled; private Date lastRequestDt; @PostConstruct void onInit() { // при перезагрузке сервера // все записи в статусе 'PROCESSING' помечаются как 'FAIL' srr.markAllProcessingAsFailed(); } /** * Запуск обработки входящего XML * * @param dto */ public void processInput(ProcessDTO dto) { if (!enabled) { // проверка на отключение обработки log.debug(SystemMessage.of("sgate.system.message.inputApiDisabled")); return; } // формирование начальной записи в журнале подписи SignLogRecord sr = new SignLogRecord(); sr.setActionState(SignLogRecord.ActionState.PROCESSING); sr.setCreatedDt(new Date()); sr.setUpdatedDt(sr.getCreatedDt()); sr.setWsActionType(dto.getActionType()); sr.setRequest(dto.req); sr.getRequestData().getProperties().putAll(dto.requestData); try { sr.setReceivedFile(dto.getFileLink()); // метод Spring Repository для сохранения в СУБД sr = srr.saveAndFlush(sr); // запуск обработчиков в модулях ps.fireStartProcessingHandlers(sr); // асинхронная отправка в сервис подписи sfs.transferToEP(sr); } catch (Exception e) { log.error(e.getLocalizedMessage(), e); // сохраняем информацию об исключении в этой же записи persistException(sr, e); } finally { lastRequestDt = new Date(); } }
На данном этапе обработка не завершается, запись лишь сформирована и сохранена в базе данных.
В фоне, с фиксированной переодичностью происходит выборка небольшого количества записей в статусе 'ОЖИДАНИЕ ОБРАБОТКИ':
/** * Пример переодической фоновой обработки приходящих запросов из общей базы * Забираются 20 последних запросов в статусе PROCESSING_EP */ @Scheduled(initialDelay = 0, fixedDelay = 5000) public void checkRecords() { // выборка первых 200 записей final Page<SignLogRecord> records = srr.getLatestUpdatesInput(PageRequest.of(0, 200)); if (records.isEmpty()) { return; } for (SignLogRecord r : records) { makeUpdates(r); } }
Дальше уже происходит необходимая обработка, в данном проекте это генерация данных справочников путем выборки из базы данных конечной системы.
Поэтому непосредственно интеграция реализована через SQL-запросы в СУБД конечной системы.
Но это конечно далеко не все интересные детали.
Работа с файлами
Как вы могли заметить, сервис достаточно много работает с приложениями к запросам-ответам в виде бинарных файлов.
В данном проекте это zip-архивы с данными, в других это например XML с запросом или ответом.
Каждый раз когда есть необходимость работы с такими бинарными приложениями, встают проблемы их хранения, связывания с записями и выгрузки по запросу.
Мы используем файловое хранилище на диске, раскидывая файлы по подкаталогам сформированным из текущей даты:
public String uploadFile(String xmlData, String filename) throws IOException { // Полный путь до файла в хранилище будет: // /2017/11/23/ffffff-ddd-dddd.png final String path = LOGS_CATALOG_FORMAT.format(new Date()); final File dir = new File(this.localStorageDir, path); if (!dir.exists() && !dir.mkdirs()) { throw SystemError.withCode(0x6005, dir); } final File saved = new File(dir, filename); FileUtils.writeStringToFile(saved, xmlData, "UTF-8"); return String.format("%s/%s/%s", this.folderPrefix, path, filename); }
Это позволяет обойти ключевую проблему для таких хранилищ:
большое количество файлов в одном каталоге.
С разбивкой на даты получается адекватное количество файлов в каждом подкаталоге, а значит есть возможность их скачивать для бекапа или просто просматривать с диска, без существенных тормозов, характерных для варианта со 100к+ файлов в одном каталоге.
Панель администратора
Как в любом приличном сервисе, у нас тоже есть своя панель администратора, с прямыми ссылками на все ключевые страницы интерфейса:
Обратите внимание на статистику — сервис собирает и отображает базовую статистику по элементам системы, что важно для быстрой оценки состояния.
Также мы реализовали перезагрузку на ходу — сервис умеет сам себя перезапускать, поэтому возможно например перенастроить подключение к базе данных и перезапустить, без необходимости входа на удаленный сервер через ssh.
Как это реализовать — опишу в отдельной статье.
Системный журнал
Большой опыт эксплуатации и активного использования практически всех возможных схем и сервисов логирования, показал что вообщем-то сами по себе логи нахер не нужны (нужна телеметрия — тоже надо отдельно рассказывать), поэтому мы постепенно ушли от записи и хранения логов на диске.
И стали использовать небольшой самоочищаемый буфер в памяти для последних 1000 записей:
Этого вполне хватает для отладки и решения конкретных проблем без необходимости удаленного входа на сервер и переборски файлов с логами.
Реализация основана на CyclicBufferAppender из проекта Logback, пример такого проекта вот тут.
В результате получается такая отдельная страница, где отображаются свежие записи из лога серверной стороны.
Итого
Это далеко не все тонкости и детали, используемые в наших интеграционных проектах, а скорее общая концепция и обзорная экскурсия.
Если есть вопросы по теме интеграции между системами, как это происходит и работает — пишите, задавайте вопросы и мы обязательно ответим.