Підручник з API інтерфейсу COM: Java Spring Boot + JACOB Library

У цій статті я покажу вам, як вбудувати бібліотеку JACOB у програму Spring Boot. Це допоможе вам викликати API інтерфейсу COM через бібліотеку DLL у вашому веб-додатку.

Крім того, для ілюстративних цілей я надам опис COM API, щоб ви змогли побудувати свою програму поверх неї. Ви можете знайти всі фрагменти коду в цьому репозиторії GitHub.

Але спочатку коротке зауваження: на C Signs ми застосували це рішення, яке дозволило нам інтегруватися з EMIS Health. Це електронна система обліку пацієнтів, яка використовується у первинній медичній допомозі у Великобританії. Для інтеграції ми використовували надану ними бібліотеку DLL.

Підхід, який я вам покажу тут (дезінфікований, щоб уникнути витоку будь-якої конфіденційної інформації), впроваджений у виробництво більше двох років тому, і з тих пір довів свою довговічність.

Оскільки нещодавно ми застосували абсолютно новий підхід до інтеграції з EMIS, стару систему буде закрито через місяць-два. Тож цей підручник - це його пісня лебідь. Спи, мій маленький принце.

Що таке DLL API?

Спочатку почнемо з чіткого опису бібліотеки DLL. Для цього я підготував короткий макет оригінальної технічної документації.

Давайте розглянемо його, щоб побачити, що таке три методи інтерфейсу COM.

Метод InitialiseWithID

Цей метод - це функція безпеки, необхідна на місці, яка дозволяє нам отримати зв’язок із сервером API, який ми хочемо інтегрувати з бібліотекою.

Для цього потрібен AccountID(GUID) поточного користувача API (для доступу до сервера) та деякі інші аргументи ініціалізації, перелічені нижче.

Ця функція також підтримує функцію автоматичного входу. Якщо клієнт має зареєстровану версію запущеної системи (бібліотека є частиною цієї системи) і викликає метод на тому самому хості, API автоматично завершить вхід під обліковим записом цього користувача. Потім він поверне SessionIDдля наступних викликів API.

В іншому випадку клієнту потрібно продовжити Logonфункцію (див. Наступну частину), використовуючи повернене LoginID.

Для виклику функції використовуйте ім'я InitialiseWithIDз такими аргументами:

Ім'яIn outТипОпис
адресуВРядокнаданий IP-сервер інтеграції
Номер рахункуВРядокнадано унікальний рядок GUID
Ім'я користувачаВийшовРядокРядок GUID, який використовується для виклику API входу
ПомилкаВийшовРядокОпис помилки
РезультатВийшовЦіле число-1 = Посилання на помилку

1 = Успішна ініціалізація в очікуванні входу

2 = Неможливо підключитися до сервера через відсутність сервера або неправильні дані

3 = Невідповідний ідентифікатор облікового запису

4 = Автовхід успішний

Ідентифікатор сеансуВийшовРядокGUID, що використовується для подальших взаємодій (якщо авторизація успішна)

Метод входу

Цей метод визначає повноваження користувача. Ім'я користувача - це ідентифікатор, який використовується для входу в систему. Пароль - це пароль API, встановлений для цього імені користувача.

У сценарії успіху виклик повертає SessionIDрядок (GUID), який повинен бути переданий іншим наступним викликам для їх автентифікації.

Для виклику функції використовуйте ім'я Logonз такими аргументами:

Ім'яIn outТипОпис
Ім'я користувачаВРядокІдентифікатор входу, повернутий методом ініціалізації Initialise with ID
ім'я користувачаВРядокнадане ім’я користувача API
парольВРядокнаданий пароль API
Ідентифікатор сеансуВийшовРядокGUID, що використовується для подальших взаємодій (якщо вхід успішний)
ПомилкаВийшовРядокОпис помилки
РезультатВийшовЦіле число-1 = Технічна помилка

1 = Успішно

2 = Термін дії закінчився

3 = Невдало

4 = Недійсний ідентифікатор для входу або ідентифікатор для входу не має доступу до цього продукту

Метод getMatchedUsers

Цей дзвінок дозволяє знаходити записи даних користувачів, які відповідають певним критеріям. Термін пошуку може посилатися лише на одне поле одночасно, наприклад, прізвище, ім’я чи дату народження.

Успішний виклик повертає рядок XML із даними в ньому.

Для виклику функції використовуйте ім'я getMatchedUsersз такими аргументами:

Ім'яIn outТипОпис
Ідентифікатор сеансуВРядокІдентифікатор сеансу, повернутий методом входу
MatchTermВРядокПошуковий термін
MatchedListВийшовРядокXML, що відповідає наданій відповідній схемі XSD
Ідентифікатор сеансуВийшовРядокGUID, що використовується для подальших взаємодій (якщо вхід успішний)
ПомилкаВийшовРядокОпис помилки
РезультатВийшовЦіле число-1 = Технічна помилка

1 = Користувачі знайдені

2 = Доступ заборонено

3 = Немає користувачів

Потік застосунків бібліотеки DLL

Щоб було легше зрозуміти те, що ми хочемо реалізувати, я вирішив створити просту схему.

Він описує покроковий сценарій того, як веб-клієнт може взаємодіяти з нашим серверним додатком за допомогою його API. Він інкапсулює взаємодію з бібліотекою DLL і дозволяє отримувати гіпотетичних користувачів із наданим терміном відповідності (критерії пошуку):

Реєстрація COM

Тепер давайте дізнаємось, як ми можемо отримати доступ до бібліотеки DLL. Щоб мати можливість взаємодіяти зі стороннім інтерфейсом COM, його потрібно додати до реєстру.

Ось що сказано в документах:

Реєстр - це системна база даних, яка містить інформацію про конфігурацію системного обладнання та програмного забезпечення, а також про користувачів системи. Будь-яка програма на базі Windows може додавати інформацію до реєстру та читати інформацію назад із реєстру. Клієнти шукають у реєстрі цікаві компоненти для використання.

Реєстр зберігає інформацію про всі COM-об'єкти, встановлені в системі. Кожного разу, коли програма створює екземпляр компонента COM, реєструється консультація, щоб вирішити або CLSID, або ProgID компонента в імені шляху DLL сервера або EXE, що його містить.

Визначивши сервер компонента, Windows або завантажує сервер у робочий простір клієнтської програми (компоненти, що працюють в процесі), або запускає сервер у власному просторі процесів (локальний та віддалений сервери).

Сервер створює екземпляр компонента і повертає клієнту посилання на один з інтерфейсів компонента.

Щоб дізнатись, як це зробити, офіційна документація Microsoft говорить:

Ви можете запустити інструмент командного рядка, який називається інструментом реєстрації збірки (Regasm.exe), щоб зареєструвати або скасувати реєстрацію збірки для використання з COM.

Regasm.exe додає інформацію про клас до системного реєстру, щоб клієнти COM могли прозоро використовувати клас .NET Framework.

Клас RegistrationServices забезпечує еквівалентну функціональність. Керований компонент повинен бути зареєстрований у реєстрі Windows, перш ніж його можна активувати з клієнта COM

Переконайтеся, що на хост-машині встановлені необхідні .NET Frameworkкомпоненти. Після цього ви можете виконати таку команду CLI:

C:\Windows\Microsoft.NET\Framework\v2.0.50727\RegAsm.exe {PATH_TO_YOUR_DLL_FILE} /codebase

З'явиться повідомлення про те, чи файл успішно зареєстровано. Тепер ми готові до наступного кроку.

Визначення основи програми

DllApiService

Перш за все, давайте визначимо інтерфейс, який описує нашу бібліотеку DLL такою, якою вона є:

public interface DllApiService { /** * @param accountId identifier for which we trigger initialisation * @return Tuple3 from values of Outcome, SessionID/LoginID, error * where by the first argument you can understand what is the result of the API call */ Mono initialiseWithID(String accountId); /** * @param loginId is retrieved before using {@link DllApiService#initialiseWithID(String)} call * @param username * @param password * @return Tuple3 from values of Outcome, SessionID, Error * where by the first argument you can understand what is the result of the API call */ Mono logon(String loginId, String username, String password); /** * @param sessionId is retrieved before using either * {@link DllApiService#initialiseWithID(String)} or * {@link DllApiService#logon(String, String, String)} calls * @param matchTerm * @return Tuple3 from values of Outcome, MatchedList, Error * where by the first argument you can understand what is the result of the API call */ Mono getMatchedUsers(String sessionId, String matchTerm); enum COM_API_Method { InitialiseWithID, Logon, getMatchedUsers } }

Як ви могли помітити, усі методи відображають визначення інтерфейсу COM, описаного вище, за винятком initialiseWithIDфункції.

Я вирішив опустити addressзмінну в підписі (IP сервера інтеграції) і ввести її як змінну середовища, яку ми будемо реалізовувати.

Пояснення сесії

Щоб отримати будь-які дані за допомогою бібліотеки, спочатку нам потрібно отримати файл SessionID.

Відповідно до блок-схеми вище, це передбачає виклик initialiseWithIDметоду першим. Після цього, залежно від результату, ми отримаємо або SessionID, або LoginIDдля використання в наступних Logonвикликах.

Отже, в основному це двоступеневий процес за кадром. Тепер давайте створимо інтерфейс, а після цього реалізацію:

public interface SessionIDService { /** * @param accountId identifier for which we retrieve SessionID * @param username * @param password * @return Tuple3 containing the following values: * result ( Boolean), sessionId (String) and status (HTTP Status depending on the result) */ Mono getSessionId(String accountId, String username, String password); }
@Service @RequiredArgsConstructor public class SessionIDServiceImpl implements SessionIDService { private final DllApiService dll; @Override public Mono getSessionId(String accountId, String username, String password) { return dll.initialiseWithID(accountId) .flatMap(t4 -> { switch (t4.getT1()) { case -1: return just(of(false, t4.getT3(), SERVICE_UNAVAILABLE)); case 1: { return dll.logon(t4.getT2(), username, password) .map(t3 -> { switch (t3.getT1()) { case -1: return of(false, t3.getT3(), SERVICE_UNAVAILABLE); case 1: return of(true, t3.getT2(), OK); case 2: case 4: return of(false, t3.getT3(), FORBIDDEN); default: return of(false, t3.getT3(), BAD_REQUEST); } }); } case 4: return just(of(true, t4.getT2(), OK)); default: return just(of(false, t4.getT3(), BAD_REQUEST)); } }); } }

API фасад

Наступним кроком є ​​розробка нашого API веб-додатків. Він повинен представляти та інкапсулювати нашу взаємодію з API інтерфейсу COM:

@Configuration public class DllApiRouter { @Bean public RouterFunction dllApiRoute(DllApiRouterHandler handler) { return RouterFunctions.route(GET("/api/sessions/{accountId}"), handler::sessionId) .andRoute(GET("/api/users/{matchTerm}"), handler::matchedUsers); } }

Окрім Routerкласу, давайте визначимо реалізацію його обробника з логікою для отримання ідентифікатора Session і даних користувача.

Для другого сценарію, щоб мати змогу здійснити getMatchedUsersвиклик API DLL відповідно до конструкції, використовуймо обов’язковий заголовок X-SESSION-ID:

@Slf4j @Component @RequiredArgsConstructor public class DllApiRouterHandler { private static final String SESSION_ID_HDR = "X-SESSION-ID"; private final DllApiService service; private final AccountRepo accountRepo; private final SessionIDService sessionService; public Mono sessionId(ServerRequest request) { final String accountId = request.pathVariable("accountId"); return accountRepo.findById(accountId) .flatMap(acc -> sessionService.getSessionId(accountId, acc.getApiUsername(), acc.getApiPassword())) .doOnEach(logNext(t3 -> { if (t3.getT1()) { log.info(format("SessionId to return %s", t3.getT2())); } else { log.warn(format("Session Id could not be retrieved. Cause: %s", t3.getT2())); } })) .flatMap(t3 -> status(t3.getT3()).contentType(APPLICATION_JSON) .bodyValue(t3.getT1() ? t3.getT2() : Response.error(t3.getT2()))) .switchIfEmpty(Mono.just("Account could not be found with provided ID " + accountId) .doOnEach(logNext(log::info)) .flatMap(msg -> badRequest().bodyValue(Response.error(msg)))); } public Mono matchedUsers(ServerRequest request) { return sessionIdHeader(request).map(sId -> Tuples.of(sId, request.queryParam("matchTerm") .orElseThrow(() -> new IllegalArgumentException( "matchTerm query param should be specified")))) .flatMap(t2 -> service.getMatchedUsers(t2.getT1(), t2.getT2())) .flatMap(this::handleT3) .onErrorResume(IllegalArgumentException.class, this::handleIllegalArgumentException); } private Mono sessionIdHeader(ServerRequest request) { return Mono.justOrEmpty(request.headers() .header(SESSION_ID_HDR) .stream() .findFirst() .orElseThrow(() -> new IllegalArgumentException(SESSION_ID_HDR + " header is mandatory"))); } private Mono handleT3(Tuple3 t3) { switch (t3.getT1()) { case 1: return ok().contentType(APPLICATION_JSON) .bodyValue(t3.getT2()); case 2: return status(FORBIDDEN).contentType(APPLICATION_JSON) .bodyValue(Response.error(t3.getT3())); default: return badRequest().contentType(APPLICATION_JSON) .bodyValue(Response.error(t3.getT3())); } } private Mono handleIllegalArgumentException(IllegalArgumentException e) { return Mono.just(Response.error(e.getMessage())) .doOnEach(logNext(res -> log.info(String.join(",", res.getErrors())))) .flatMap(res -> badRequest().contentType(MediaType.APPLICATION_JSON) .bodyValue(res)); } @Getter @Setter @NoArgsConstructor public static class Response implements Serializable { private String message; private Set errors; private Response(Set errors) { this.errors = errors; } public static Response error(String error) { return new Response(singleton(error)); } } }

Сутність рахунку

Як ви могли помітити, ми імпортували AccountRepoв обробник маршрутизатора, щоб знайти сутність у базі даних за наданим accountId. Це дозволяє нам отримати відповідні облікові дані користувача API та використовувати всі три у Logonвиклику API DLL .

Щоб отримати більш чітке уявлення, давайте також визначимо керовану Accountсутність:

@TypeAlias("Account") @Document(collection = "accounts") public class Account { @Version private Long version; /** * unique account ID for API, provided by supplier * defines restriction for data domain visibility * i.e. data from one account is not visible for another */ @Id private String accountId; /** * COM API username, provided by supplier */ private String apiUsername; /** * COM API password, provided by supplier */ private String apiPassword; @CreatedDate private Date createdAt; @LastModifiedDate private Date updatedOn; }

Налаштування бібліотеки JACOB

Зараз готові всі частини нашого додатку, крім основного - конфігурація та використання бібліотеки JACOB. Почнемо з налаштування бібліотеки.

Бібліотека розповсюджується через sourceforge.net. Я не знайшов його доступним ніде на Central Maven Repo чи будь-якому іншому сховищі в Інтернеті. Тому я вирішив імпортувати його вручну в наш проект як локальний пакет.

Для цього я завантажив його і помістив у кореневу папку під /libs/jacob-1.19.

Після цього встановіть наступну конфігурацію maven-install-plugin в pom.xml. Це додасть бібліотеку до локального сховища на installетапі збірки Maven :

 org.apache.maven.plugins maven-install-plugin   install-jacob validate  ${basedir}/libs/jacob-1.19/jacob.jar default net.sf.jacob-project jacob 1.19 jar true   install-file    

Це дозволить вам легко додати залежність, як зазвичай:

 net.sf.jacob-project jacob 1.19 

Імпорт бібліотеки завершено. Тепер давайте підготуємо його до використання.

Для взаємодії з компонентом COM JACOB надає обгортку, яка називається ActiveXComponentкласом (як я вже згадував раніше).

У нього є метод, що називається, invoke(String function, Variant... args)що дозволяє нам робити саме те, що ми хочемо.

Взагалі кажучи, наша бібліотека створена для створення ActiveXComponentкомпонента, щоб ми могли використовувати його де завгодно в додатку (і ми хочемо його в реалізації DllApiService).

Тож давайте визначимо окрему весну @Configurationз усіма основними препаратами:

@Slf4j @Configuration public class JacobCOMConfiguration { private static final String COM_INTERFACE_NAME = "NAME_OF_COM_INTERFACE_AS_IN_REGISTRY"; private static final String JACOB_LIB_PATH = System.getProperty("user.dir") + "\\libs\\jacob-1.19"; private static final String LIB_FILE = System.getProperty("os.arch") .equals("amd64") ? "\\jacob-1.19-x64.dll" : "\\jacob-1.19-x86.dll"; private File temporaryDll; static { log.info("JACOB lib path: {}", JACOB_LIB_PATH); log.info("JACOB file lib path: {}", JACOB_LIB_PATH + LIB_FILE); System.setProperty("java.library.path", JACOB_LIB_PATH); System.setProperty("com.jacob.debug", "true"); } @PostConstruct public void init() throws IOException { InputStream inputStream = new FileInputStream(JACOB_LIB_PATH + LIB_FILE); temporaryDll = File.createTempFile("jacob", ".dll"); FileOutputStream outputStream = new FileOutputStream(temporaryDll); byte[] array = new byte[8192]; for (int i = inputStream.read(array); i != -1; i = inputStream.read(array)) { outputStream.write(array, 0, i); } outputStream.close(); System.setProperty(LibraryLoader.JACOB_DLL_PATH, temporaryDll.getAbsolutePath()); LibraryLoader.loadJacobLibrary(); log.info("JACOB library is loaded and ready to use"); } @Bean public ActiveXComponent dllAPI() { ActiveXComponent activeXComponent = new ActiveXComponent(COM_INTERFACE_NAME); log.info("API COM interface {} wrapped into ActiveXComponent is created and ready to use", COM_INTERFACE_NAME); return activeXComponent; } @PreDestroy public void clean() { temporaryDll.deleteOnExit(); log.info("Temporary DLL API library is cleaned on exit"); } }

Варто згадати, що, окрім визначення компонента, ми ініціалізуємо компоненти бібліотеки на основі ISA хост-машини (архітектура набору інструкцій).

Крім того, ми дотримуємося деяких загальних рекомендацій щодо копіювання файлу відповідної бібліотеки. Це дозволяє уникнути будь-якого потенційного пошкодження оригінального файлу під час роботи. Нам також потрібно очистити всі виділені ресурси, коли програми припиняються.

Тепер бібліотека налаштована та готова до використання. Нарешті, ми можемо реалізувати наш останній головний компонент , який допомагає нам взаємодіяти з DLL API:   DllApiServiceImpl.

Як реалізувати службу API бібліотеки DLL

Оскільки всі виклики COM API будуть готуватися з використанням загального підходу, давайте реалізуємо InitialiseWithIDспочатку. Після цього всі інші методи можна легко реалізувати подібним чином.

Як я вже згадував раніше, для взаємодії з COM-інтерфейсом JACOB надає нам ActiveXComponentклас, що має invoke(String function, Variant... args)метод.

Якщо ви хочете дізнатись більше про Variantклас, у документації JACOB сказано наступне (ви можете знайти це в архіві або /libs/jacob-1.19в проекті):

Багатоформатний тип даних, що використовується для всіх зворотних дзвінків та більшості комунікацій між Java та COM. Він забезпечує єдиний клас, який може обробляти всі типи даних.

Це означає, що всі аргументи, визначені в InitialiseWithIDпідписі, повинні бути обгорнуті new Variant(java.lang.Object in)та передані invokeметоду. Використовуйте той самий порядок, що вказаний в описі інтерфейсу на початку цієї статті.

Єдине інше важливе, чого ми ще не торкалися, - це як розрізнити inта outввести аргументи.

З цією метою Variantнадає конструктор, який приймає об’єкт даних та інформацію про те, чи є це посиланням чи ні. Це означає, що після invokeвиклику після виклику можна отримати доступ до всіх варіантів, які були ініціалізовані як посилання. Тож ми можемо витягти результати з outаргументів.

Щоб зробити це, просто передати додаткову логічну змінну конструктору в якості другого параметра: new Variant(java.lang.Object pValueObject, boolean fByRef).

Ініціалізація Variantоб’єкта як посилання ставить додаткову вимогу до клієнта вирішувати, коли звільняти значення (щоб воно могло бути скасовано збирачем сміття).

Для цього у вас є safeRelease()метод, який передбачається викликати, коли значення береться з відповідного Variantоб’єкта.

Поєднання всіх частин дає нам наступну реалізацію послуги:

@RequiredArgsConstructor public class DllApiServiceImpl implements DllApiService { @Value("${DLL_API_ADDRESS}") private String address; private final ActiveXComponent dll; @Override public Mono initialiseWithID(final String accountId) { return Mono.just(format("Calling %s(%s, %s, %s, %s, %s, %s)",// InitialiseWithID, address, accountId, "loginId/out", "error/out", "outcome/out", "sessionId/out")) .doOnEach(logNext(log::info)) //invoke COM interface method and extract the result mapping it onto corresponding *Out inner class .map(msg -> invoke(InitialiseWithID, vars -> InitialiseWithIDOut.builder() .loginId(vars[3].toString()) .error(vars[4].toString()) .outcome(valueOf(vars[5].toString())) .sessionId(vars[6].toString()) .build(), // new Variant(address), new Variant(accountId), initRef(), initRef(), initRef(), initRef())) //Handle the response according to the documentation .map(out -> { final String errorVal; switch (out.outcome) { case 2: errorVal = "InitialiseWithID method call failed. DLL API request outcome (response code from server via DLL) = 2 " +// "(Unable to connect to server due to absent server, or incorrect details)"; break; case 3: errorVal = "InitialiseWithID method call failed. DLL API request outcome (response code from server via DLLe) = 3 (Unmatched AccountID)"; break; default: errorVal = handleOutcome(out.outcome, out.error, InitialiseWithID); } return of(out, errorVal); }) .doOnEach(logNext(t2 -> { InitialiseWithIDOut out = t2.getT1(); log.info("{} API call result:\noutcome: {}\nsessionId: {}\nerror: {}\nloginId: {}",// InitialiseWithID, out.outcome, out.sessionId, t2.getT2(), out.loginId); })) .map(t2 -> { InitialiseWithIDOut out = t2.getT1(); //out.outcome == 4 auto-login successful, SessionID is retrieved return of(out.outcome, out.outcome == 4 ? out.sessionId : out.loginId, t2.getT2()); }); } private static Variant initRef() { return new Variant("", true); } private static String handleOutcome(Integer outcome, String error, COM_API_Method method) { switch (outcome) { case 1: return "no error"; case 2: return format("%s method call failed. DLL API request outcome (response code from server via DLL) = 2 (Access denied)", method); default: return format("%s method call failed. DLL API request outcome (response code from server via DLL) = %s (server technical error). " + // "DLL API is temporary unavailable (server behind is down), %s", method, outcome, error); } } /** * @param method to be called in COM interface * @param returnFunc maps Variants (references) array onto result object that is to be returned by the method * @param vars arguments required for calling COM interface method * @param  type of the result object that is to be returned by the method * @return result of the COM API method invocation in defined format */ private  T invoke(COM_API_Method method, Function returnFunc, Variant... vars) { dll.invoke(method.name(), vars); T res = returnFunc.apply(vars); asList(vars).forEach(Variant::safeRelease); return res; } @SuperBuilder private static abstract class Out { final Integer outcome; final String error; } @SuperBuilder private static class InitialiseWithIDOut extends Out { final String loginId; final String sessionId; }

Два інших методи, Logonі getMatchedUsers, реалізовані відповідно. Ви можете звернутися до мого репозитарію GitHub для отримання повної версії послуги, якщо ви хочете перевірити її.

Вітаємо - ти мало чого навчився

Ми пройшли покроковий сценарій, який показав нам, як гіпотетичний COM API можна розповсюджувати та викликати на Java.

Ми також дізналися, як бібліотеку JACOB можна налаштувати та ефективно використовувати для взаємодії з бібліотекою DDL у вашому додатку Spring Boot 2.

Невеликим вдосконаленням було б кешування отриманого SessionID, що може покращити загальний потік. Але це трохи виходить за рамки цієї статті.

Якщо ви хочете дослідити далі, ви можете знайти це на GitHub, де це реалізовано за допомогою механізму кешування Spring.

Сподіваюся, вам сподобалось пережити все зі мною та знайшов цей підручник корисним!