Skip to content

guide service client spring

devonfw-core edited this page Dec 13, 2022 · 5 revisions

Service Client in devon4j-spring

This guide is about consuming (calling) services from other applications (micro-services) in devon4j-spring.

Dependency

You need to add (at least one of) these dependencies to your application:

<!-- Starter for asynchronous consuming REST services via Jaca HTTP Client (Java11+) -->
<dependency>
  <groupId>com.devonfw.java.starters</groupId>
  <artifactId>devon4j-starter-http-client-rest-async</artifactId>
</dependency>
<!-- Starter for synchronous consuming REST services via Jaca HTTP Client (Java11+) -->
<dependency>
  <groupId>com.devonfw.java.starters</groupId>
  <artifactId>devon4j-starter-http-client-rest-sync</artifactId>
</dependency>
<!-- Starter for synchronous consuming REST services via Apache CXF (Java8+)
  NOTE: This is an alternative to devon4j-starter-http-client-rest-sync
  -->
<!--
<dependency>
  <groupId>com.devonfw.java.starters</groupId>
  <artifactId>devon4j-starter-cxf-client-rest</artifactId>
</dependency>
-->
<!-- Starter for synchronous consuming SOAP services via Apache CXF (Java8+) -->
<dependency>
  <groupId>com.devonfw.java.starters</groupId>
  <artifactId>devon4j-starter-cxf-client-ws</artifactId>
</dependency>

Features

When invoking a service, you need to consider many cross-cutting aspects. You might not think about them in the very first place and you do not want to redundantly implement them multiple times. Therefore, you should consider using this approach. The following sub-sections list the covered features and aspects:

Simple usage

Assuming you already have a Java interface MyService of the service you want to invoke:

package com.company.department.foo.mycomponent.service.api.rest;
...

@Path("/myservice")
public interface MyService extends RestService {

  @POST
  @Path("/getresult")
  MyResult getResult(MyArgs myArgs);

  @DELETE
  @Path("/entity/{id}")
  void deleteEntity(@PathParam("id") long id);
}

Then, all you need to do is this:

@Named
public class UcMyUseCaseImpl extends MyUseCaseBase implements UcMyUseCase {
  @Inject
  private ServiceClientFactory serviceClientFactory;

  ...
  private void callSynchronous(MyArgs myArgs) {
    MyService myService = this.serviceClientFactory.create(MyService.class);
    // call of service over the wire, synchronously blocking until result is received or error occurred
    MyResult myResult = myService.myMethod(myArgs);
    handleResult(myResult);
  }

  private void callAsynchronous(MyArgs myArgs) {
    AsyncServiceClient<MyService> client = this.serviceClientFactory.createAsync(MyService.class);
    // call of service over the wire, will return when request is send and invoke handleResult asynchronously
    client.call(client.get().myMethod(myArgs), this::handleResult);
  }

  private void handleResult(MyResult myResult) {
    ...
  }
  ...
}

As you can see, both synchronous and asynchronous invocation of a service is very simple and type-safe. However, it is also very flexible and powerful (see following features). The actual call of myMethod will technically call the remote service over the wire (e.g. via HTTP), including marshalling the arguments (e.g. converting myArgs to JSON) and unmarshalling the result (e.g. converting the received JSON to myResult).

Asynchronous Invocation of void Methods

If you want to call a service method with void as the return type, the type-safe call method cannot be used as void methods do not return a result. Therefore you can use the callVoid method as following:

  private void callAsynchronousVoid(long id) {
    AsyncServiceClient<MyService> client = this.serviceClientFactory.createAsync(MyService.class);
    // call of service over the wire, will return when request is send and invoke resultHandler asynchronously
    Consumer<Void> resultHandler = r -> { System.out.println("Response received")};
    client.callVoid(() -> { client.get().deleteEntity(id);}, resultHandler);
  }

You may also provide null as resultHandler for "fire and forget". However, this will lead to the result being ignored, so even in the case of an error you will not be notified.

Configuration

This solution allows a very flexible configuration on the following levels:

  1. Global configuration (defaults)

  2. Configuration per remote service application (microservice)

  3. Configuration per invocation.

A configuration on a deeper level (e.g. 3) overrides the configuration from a higher level (e.g. 1).

The configuration on Level 1 and 2 are configured via application.properties (see configuration guide). For Level 1, the prefix service.client.default. is used for properties. Further, for level 2, the prefix service.client.app.«application». is used where «application» is the technical name of the application providing the service. This name will automatically be derived from the java package of the service interface (e.g. foo in MyService interface before) following our packaging conventions. In case these conventions are not met, it will fall back to the fully qualified name of the service interface.

Configuration on Level 3 has to be provided as a Map argument to the method ServiceClientFactory.create(Class<S> serviceInterface, Map<String, String> config). The keys of this Map will not use prefixes (such as the ones above). For common configuration parameters, a type-safe builder is offered to create such a map via ServiceClientConfigBuilder. E.g. for testing, you may want to do:

this.serviceClientFactory.create(MyService.class,
  new ServiceClientConfigBuilder().authBasic().userLogin(login).userPassword(password).buildMap());

Here is an example of a configuration block for your application.properties:

service.client.default.url=https://api.company.com/services/${type}
service.client.default.timeout.connection=120
service.client.default.timeout.response=3600

service.client.app.bar.url=https://bar.company.com:8080/services/rest
service.client.app.bar.auth=basic
service.client.app.bar.user.login=user4711
service.client.app.bar.user.password=ENC(jd5ZREpBqxuN9ok0IhnXabgw7V3EoG2p)

service.client.app.foo.url=https://foo.company.com:8443/services/rest
# authForward: simply forward Authorization header (e.g. with JWT) to remote service
service.client.app.bar.auth=authForward

Service Discovery

You do not want to hardwire service URLs in your code, right? Therefore, different strategies might apply to discover the URL of the invoked service. This is done internally by an implementation of the interface ServiceDiscoverer. The default implementation simply reads the base URL from the configuration. You can simply add this to your application.properties as in the above configuration example.

Assuming your service interface has the fully qualified name com.company.department.foo.mycomponent.service.api.rest.MyService, then the URL would be resolved to https://foo.company.com:8443/services/rest, as the «application» is foo.

Additionally, the URL might use the following variables that will automatically be resolved:

  • ${app} to «application» (useful for default URL)

  • ${type} to the type of the service. E.g. rest in case of a REST service and ws for a SOAP service.

  • ${local.server.port} for the port of your current Java servlet container running the JVM. Should only be used for testing with spring-boot random port mechanism (technically spring cannot resolve this variable, but we do it for you here).

Therefore, the default URL may also be configured as:

service.client.default.url=https://api.company.com/${app}/services/${type}

As you can use any implementation of ServiceDiscoverer, you can also easily use eureka (or anything else) instead to discover your services. However, we recommend to use istio instead, as described below.

Headers

A very common demand is to tweak (HTTP) headers in the request to invoke the service. May it be for security (authentication data) or for other cross-cutting concerns (such as the Correlation ID). This is done internally by implementations of the interface ServiceHeaderCustomizer. We already provide several implementations such as:

  • ServiceHeaderCustomizerBasicAuth for basic authentication (auth=basic).

  • ServiceHeaderCustomizerOAuth for OAuth: passes a security token from security context such as a JWT via OAuth (auth=oauth).

  • ServiceHeaderCustomizerAuthForward forwards the Authorization HTTP header from the running request to the request to the remote service as is (auth=authForward). Be careful to avoid security pitfalls by misconfiguring this feature, as it may also contain sensitive credentials (e.g. basic auth) to the remote service. Never use as default.

  • ServiceHeaderCustomizerCorrelationId passed the Correlation ID to the service request.

Additionally, you can add further custom implementations of ServiceHeaderCustomizer for your individual requirements and additional headers.

Timeouts

You can configure timeouts in a very flexible way. First of all, you can configure timeouts to establish the connection (timeout.connection) and to wait for the response (timeout.response) separately. These timeouts can be configured on all three levels as described in the configuration section above.

Error Handling

Whilst invoking a remote service, an error may occur. This solution will automatically handle such errors and map them to a higher level ServiceInvocationFailedException. In general, we separate two different types of errors:

  • Network error
    In such a case (host not found, connection refused, time out, etc.), there is not even a response from the server. However, in advance to a low-level exception you will get a wrapped ServiceInvocationFailedException (with code ServiceInvoke) with a readable message containing the service that could not be invoked.

  • Service error
    In case the service failed on the server-side, the error result will be parsed and thrown as a ServiceInvocationFailedException with the received message and code.

This allows to catch and handle errors when a service-invocation failed. You can even distinguish business errors from the server-side from technical errors and implement retry strategies or the like. Further, the created exception contains detailed contextual information about the service that failed (service interface class, method, URL), which makes it much easier to trace down errors. Here is an example from our tests:

While invoking the service com.devonfw.test.app.myexample.service.api.rest.MyExampleRestService#businessError[http://localhost:50178/app/services/rest/my-example/v1/business-error] the following error occurred: Test of business error. Probably the service is temporary unavailable. Please try again later. If the problem persists contact your system administrator.
2f43b03e-685b-45c0-9aae-23ff4b220c85:BusinessErrorCode

You may even provide your own implementation of ServiceClientErrorFactory instead to provide an own exception class for this purpose.

Handling Errors

In case of a synchronous service invocation, an error will be immediately thrown so you can surround the call with a regular try-catch block:

  private void callSynchronous(MyArgs myArgs) {
    MyService myService = this.serviceClientFactory.create(MyService.class);
    // call of service over the wire, synchronously blocking until result is received or error occurred
    try {
      MyResult myResult = myService.myMethod(myArgs);
      handleResult(myResult);
    } catch (ServiceInvocationFailedException e) {
      if (e.isTechnical()) {
        handleTechnicalError(e);
      } else {
        // error code you defined in the exception on the server side of the service
        String errorCode = e.getCode();
        handleBusinessError(e, errorCode;
      }
    } catch (Throwable e) { // you may not handle this explicitly here...
      handleTechnicalError(e);
    }
  }

If you are using asynchronous service invocation, an error can occurr in a separate thread. Therefore, you may and should define a custom error handler:

  private void callAsynchronous(MyArgs myArgs) {
    AsyncServiceClient<MyService> client = this.serviceClientFactory.createAsync(MyService.class);
    Consumer<Throwalbe> errorHandler = this::handleError;
    client.setErrorHandler(errorHandler);
    // call of service over the wire, will return when request is send and invoke handleResult asynchronously
    client.call(client.get().myMethod(myArgs), this::handleResult);
  }

  private void handleError(Throwalbe error) {
    ...
  }
}

The error handler consumes Throwable, and not only RuntimeException, so you can get notified even in case of an unexpected OutOfMemoryError, NoClassDefFoundError, or other technical problems. Please note that the error handler may also be called from the thread calling the service (e.g. if already creating the request fails). The default error handler used if no custom handler is set will only log the error and do nothing else.

Logging

By default, this solution will log all invocations including the URL of the invoked service, success or error status flag and the duration in seconds (with decimal nano precision as available). Therefore, you can easily monitor the status and performance of the service invocations. Here is an example from our tests:

Invoking service com.devonfw.test.app.myexample.service.api.rest.MyExampleRestService#greet[http://localhost:50178/app/services/rest/my-example/v1/greet/John%20Doe%20%26%20%3F%23] took PT20.309756622S (20309756622ns) and succeded with status 200.

Resilience

Resilience adds a lot of complexity, which typically means that addressing this here would most probably result in not being up-to-date and not meeting all requirements. Therefore, we recommend something completely different: the sidecar approach (based on sidecar pattern). This means that you use a generic proxy app that runs as a separate process on the same host, VM, or container of your actual application. Then, in your app, you call the service via the sidecar proxy on localhost (service discovery URL is e.g. http://localhost:8081/${app}/services/${type}) that then acts as proxy to the actual remote service. Now aspects such as resilience with circuit breaking and the actual service discovery can be configured in the sidecar proxy app, independent of your actual application. Therefore, you can even share and reuse configuration and experience with such a sidecar proxy app even across different technologies (Java, .NET/C#, Node.JS, etc.). Further, you do not pollute the technology stack of your actual app with the infrastructure for resilience, throttling, etc. and can update the app and the sidecar independently when security-fixes are available.

Various implementations of such sidecar proxy apps are available as free open source software. Our recommendation in devonfw is to use istio. This not only provides such a side-car, but also an entire management solution for service-mesh, making administration and maintenance much easier. Platforms like OpenShift support this out of the box.

However, if you are looking for details about side-car implementations for services, you can have a look at the following links:

Clone this wiki locally