Tutorial de API de interfaz COM: Java Spring Boot + Biblioteca JACOB

En este artículo, le mostraré cómo incrustar la biblioteca JACOB en su aplicación Spring Boot. Esto le ayudará a llamar a una API de interfaz COM a través de la biblioteca DLL en su aplicación web.

Además, con fines ilustrativos, proporcionaré una descripción de una API COM para que pueda construir su aplicación sobre ella. Puede encontrar todos los fragmentos de código en este repositorio de GitHub.

Pero primero, una nota rápida: en C the Signs implementamos esta solución que nos permitió integrarnos con EMIS Health. Es un sistema de registro de pacientes electrónico utilizado en atención primaria en el Reino Unido. Para la integración usamos su biblioteca DLL proporcionada.

El enfoque que le mostraré aquí (desinfectado para evitar la filtración de información confidencial) se lanzó a producción hace más de dos años y desde entonces ha demostrado su durabilidad.

Dado que recientemente empleamos un enfoque completamente nuevo para la integración con EMIS, el sistema anterior se cerrará en uno o dos meses. Entonces este tutorial es su canto de cisne. Duerme, mi principito.

¿Qué es la API de DLL?

Primero, comencemos con una descripción clara de la biblioteca DLL. Para ello, preparé una breve maqueta de la documentación técnica original.

Echemos un vistazo para ver cuáles son los tres métodos de una interfaz COM.

Método InitialiseWithID

Este método es una característica de seguridad requerida en el sitio que nos permite obtener una conexión a un servidor API que queremos integrar con la biblioteca.

Requiere el AccountID(GUID) del usuario API actual (para acceder al servidor) y algunos otros argumentos de inicialización que se enumeran a continuación.

Esta función también admite una función de inicio de sesión automático. Si un cliente tiene una versión registrada del sistema en ejecución (la biblioteca es parte de ese sistema) y llama al método en el mismo host, la API completará automáticamente el inicio de sesión con la cuenta de ese usuario. Luego devolverá el SessionIDpara posteriores llamadas a la API.

De lo contrario, el cliente debe continuar con la Logonfunción (ver la siguiente parte) usando el archivo devuelto LoginID.

Para llamar a la función, use el nombre InitialiseWithIDcon los siguientes argumentos:

Nombre En fuera Tipo Descripción
habla a En Cuerda IP del servidor de integración proporcionado
ID de la cuenta En Cuerda proporcionó una cadena GUID única
Ingresar identificación Afuera Cuerda Cadena GUID utilizada para la llamada a la API de inicio de sesión
Error Afuera Cuerda Error de descripción
Salir Afuera Entero -1 = Consulte el error

1 = Inicialización exitosa esperando inicio de sesión

2 = No se puede conectar al servidor debido a la ausencia del servidor o detalles incorrectos

3 = ID de cuenta inigualable

4 = Autologon exitoso

ID de sesión Afuera Cuerda GUID utilizado para interacciones posteriores (si el inicio de sesión automático se realizó correctamente)

Método de inicio de sesión

Este método determina la autoridad del usuario. El nombre de usuario aquí es el ID que se utiliza para iniciar sesión en el sistema. La contraseña es la contraseña de API establecida para ese nombre de usuario.

En el escenario exitoso, la llamada devuelve una SessionIDcadena (GUID) que debe pasarse a otras llamadas posteriores para autenticarlas.

Para llamar a la función, use el nombre Logoncon los siguientes argumentos:

Nombre En fuera Tipo Descripción
Ingresar identificación En Cuerda El ID de inicio de sesión devuelto por el método de inicialización Initialise with ID
nombre de usuario En Cuerda nombre de usuario API proporcionado
contraseña En Cuerda contraseña API proporcionada
ID de sesión Afuera Cuerda GUID utilizado para interacciones posteriores (si el inicio de sesión se realizó correctamente)
Error Afuera Cuerda Error de descripción
Salir Afuera Entero -1 = error técnico

1 = exitoso

2 = Caducado

3 = Sin éxito

4 = ID de inicio de sesión no válido o ID de inicio de sesión no tiene acceso a este producto

Método getMatchedUsers

Esta llamada le permite encontrar registros de datos de usuario que coincidan con criterios específicos. El término de búsqueda solo puede hacer referencia a un campo a la vez, como el apellido, el nombre o la fecha de nacimiento.

Una llamada exitosa devuelve una cadena XML con los datos en ella.

Para llamar a la función, use el nombre getMatchedUserscon los siguientes argumentos:

Nombre En fuera Tipo Descripción
ID de sesión En Cuerda El ID de sesión devuelto por el método de inicio de sesión
MatchTerm En Cuerda Término de búsqueda
MatchedList Afuera Cuerda XML conforme al esquema XSD correspondiente proporcionado
ID de sesión Afuera Cuerda GUID utilizado para interacciones posteriores (si el inicio de sesión se realizó correctamente)
Error Afuera Cuerda Error de descripción
Salir Afuera Entero -1 = error técnico

1 = Usuarios encontrados

2 = Acceso denegado

3 = Sin usuarios

Flujo de aplicaciones de la biblioteca DLL

Para que sea más fácil comprender lo que queremos implementar, decidí crear un diagrama de flujo simple.

Describe un escenario paso a paso de cómo un cliente web puede interactuar con nuestra aplicación basada en servidor utilizando su API. Encapsula la interacción con la biblioteca DLL y nos permite obtener usuarios hipotéticos con el término de coincidencia proporcionado (criterios de búsqueda):

Registro de COM

Ahora aprendamos cómo podemos acceder a la biblioteca DLL. Para poder interactuar con una interfaz COM de terceros, es necesario agregarla al registro.

Esto es lo que dicen los doctores:

El registro es una base de datos del sistema que contiene información sobre la configuración del hardware y software del sistema, así como sobre los usuarios del sistema. Cualquier programa basado en Windows puede agregar información al registro y leer información desde el registro. Los clientes buscan en el registro componentes interesantes para usar.

El registro mantiene información sobre todos los objetos COM instalados en el sistema. Siempre que una aplicación crea una instancia de un componente COM, se consulta el registro para resolver el CLSID o ProgID del componente en el nombre de ruta de la DLL o EXE del servidor que lo contiene.

Después de determinar el servidor del componente, Windows carga el servidor en el espacio de proceso de la aplicación cliente (componentes en proceso) o inicia el servidor en su propio espacio de proceso (servidores locales y remotos).

El servidor crea una instancia del componente y devuelve al cliente una referencia a una de las interfaces del componente.

Para saber cómo hacer eso, la documentación oficial de Microsoft dice:

Puede ejecutar una herramienta de línea de comandos denominada Herramienta de registro de ensamblados (Regasm.exe) para registrar o anular el registro de un ensamblado para su uso con COM.

Regasm.exe agrega información sobre la clase al registro del sistema para que los clientes COM puedan usar la clase .NET Framework de forma transparente.

La clase RegistrationServices proporciona la funcionalidad equivalente. Un componente administrado debe estar registrado en el registro de Windows antes de que pueda activarse desde un cliente COM

Asegúrese de que su máquina host haya instalado los .NET Frameworkcomponentes necesarios . Después de eso, puede ejecutar el siguiente comando CLI:

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

A message will display indicating whether the file was successfully registered. Now we're ready for the next step.

Defining the Backbone of the Application

DllApiService

First of all, let's define the interface that describes our DLL library as it is:

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 } }
      
     
    

As you might have noticed, all the methods map with the definition of the COM Interface described above, except for the initialiseWithID function.

I decided to omit the address variable in the signature (the IP of the integration server) and inject it as an environment variable which we will be implementing.

SessionIDService Explained

To be able to retrieve any data using the library, first we need to get the SessionID.

According to the flow diagram above, this involves calling the initialiseWithID method first. After that, depending on the result, we will get either the SessionID or LoginID to use in subsequent Logon calls.

So basically this is a two-step process behind the scenes. Now, let's create the interface, and after that, the implementation:

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 Facade

The next step is to design our web application API. It should represent and encapsulate our interaction with the COM Interface API:

@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); } }

Besides the Router class, let's define an implementation of its handler with logic for retrieving the SessionID and the user records data.

For the second scenario, to be able to make a DLL getMatchedUsers API call according to the design, let's use the mandatory header 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)); } } }

Account Entity

As you might have noticed, we've imported AccountRepo in the router's handler to find the entity in a database by the provided accountId. This lets us get the corresponding API user credentials and use all three in the DLL Logon API call.

To get a clearer picture, let's define the managed Account entity as well:

@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; }

The JACOB Library Setup

All parts of our application are ready now except the core – the configuration and use of the JACOB library. Let's start with setting up the library.

The library is distributed via sourceforge.net. I did not find it available anywhere on either the Central Maven Repo or any other repositories online. So I decided to import it manually into our project as a local package.

To do that, I downloaded it and put it in the root folder under /libs/jacob-1.19.

After that, put the following maven-install-plugin configuration into pom.xml. This will add the library to the local repository during Maven's install build phase:

 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    

That will let you easily add the dependency as usual:

 net.sf.jacob-project jacob 1.19 

The library import is finished. Now let's get it ready to use it.

To interact with the COM component, JACOB provides a wrapper called an ActiveXComponent class (as I mentioned before).

It has a method called invoke(String function, Variant... args) that lets us make exactly what we want.

Generally speaking, our library is set up to create the ActiveXComponent bean so we can use it anywhere we want in the app (and we want it in the implementation of DllApiService).

So let's define a separate Spring @Configuration with all the essential preparations:

@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"); } }

It's worth mentioning that, besides defining the bean, we initialize the library components based on the host machine's ISA (instruction set architecture).

Also, we follow some common recommendations to make a copy of the corresponding library's file. This avoids any potential corruption of the original file during runtime. We also need to cleanup all allocated resources when the applications terminates.

Now the library is set up and ready to use. Finally, we can implement our last main component that helps us interact with the DLL API:  DllApiServiceImpl.

How to Implement a DLL Library API Service

As all COM API calls are going to be cooked using a common approach, let's implement InitialiseWithID first. After that, all other methods can be implemented easily in a similar way.

Como mencioné antes, para interactuar con la interfaz COM, JACOB nos proporciona la ActiveXComponentclase que tiene el invoke(String function, Variant... args)método.

Si desea saber más sobre la Variantclase, la documentación de JACOB dice lo siguiente (puede encontrarlo en el archivo o debajo /libs/jacob-1.19del proyecto):

El tipo de datos multiformato utilizado para todas las devoluciones de llamada y la mayoría de las comunicaciones entre Java y COM. Proporciona una sola clase que puede manejar todos los tipos de datos.

Esto significa que todos los argumentos definidos en la InitialiseWithIDfirma deben incluirse new Variant(java.lang.Object in)y pasarse al invokemétodo. Utilice el mismo orden que se especifica en la descripción de la interfaz al principio de este artículo.

The only other important thing we haven't touched on yet is how to distinguish in and out type arguments.

For that purpose, Variant provides a constructor that accepts the data object and information about whether this is by reference or not. This means that after invoke is called, all variants that were initialized as references can be accessed after the call. So we can extract the results from out arguments.

To do that, just pass an extra boolean variable to the constructor as the second parameter: new Variant(java.lang.Object pValueObject, boolean fByRef).

Initializing the Variant object as reference puts an additional requirement on the client to decide when to release the value (so it can be scrapped by the garbage collector).

For that purpose, you have the safeRelease() method that is supposed to be called when the value is taken from the corresponding Variant object.

Putting all the pieces together gives us the following service's implementation:

@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; }
    

Two other methods, Logon and getMatchedUsers, are implemented accordingly. You can refer to my GitHub repo for a complete version of the service if you want to check it out.

Congratulations – You've Learned a Few Things

We've gone through a step by step scenario that showed us how a hypothetical COM API could be distributed and called in Java.

We also learned how the JACOB library can be configured and effectively used to interact with a DDL library within your Spring Boot 2 application.

A small improvement would be to cache the retrieved SessionID which could improve the general flow. But that's a bit outside the scope of this article.

If you want to investigate further, you can find that on GitHub where it's implemented using Spring's caching mechanism.

Hope you enjoyed going through everything with me and found this tutorial helpful!