This post will be included in a chapter from a forthcoming book Building Microservices for the Cloud, A guide for busy Java developers I'm writing. If you enter your email address here, I'll send you the previous chapters as well as each new chapter when it gets published!
This post has been featured on http://www.baeldung.com/java-web-weekly-167.

MULTI-VERSION SERVICE DISCOVERY USING SPRING CLOUD NETFLIX EUREKA AND RIBBON

As services evolve over time, changes are made to the domain that very-likely require API versioning to prevent breaking backwards compatibility.

Unless you work in a very controlled environment, for instance, where it’s known that client applications are also distributed with the API services and there is no possibility of an old client using newer API services deployment, I’m a firm believer you should never break backwards compatibility.

I’ll say it again because this is very important, you should never break backwards compatibility, if you do, customers and partners get very angry and fixing it afterwards normally requires more effort than versioning from the beginning.

The question now is, how does versioning work in a microservice architecture where services are registered with and discovered using a Discovery infrastructure microservice such as Netflix Eureka?

Multi-version Service Discovery using Spring Cloud Netflix Eureka and Ribbon

In this post I’ll explain how to register and discover multiple version of a service using Spring Cloud Netflix Eureka and Ribbon.

REQUIREMENTS

  • Java 8 or Java 7. For Java 7, java.version property inside pom.xml needs to be updated accordingly.
  • Maven 3.3.x
  • Familiarity with Spring Framework.
  • A Eureka server instance for the Demo Service 1 to register with and the Demo Service 2 to fetch the registry from.

CREATE THE DEMO SERVICE 1

This is a simple Spring Boot application which implements two versions of the same endpoint and registers both versions with a Eureka server instance.

curl "https://start.spring.io/starter.tgz" -d bootVersion=1.4.5.RELEASE -d dependencies=actuator,cloud-eureka,web -d language=java -d type=maven-project -d baseDir=demo-multiversion-registration-api-1 -d groupId=com.asimio.demo.api -d artifactId=demo-multiversion-registration-api-1 -d version=0-SNAPSHOT | tar -xzvf -

This command will create a Maven project in a folder named demo-multiversion-registration-api-1 with most of the dependencies used in the accompanying source code.

bootstrap.yml:

spring:
  application:
    name: demo-multiversion-registration-api-1

This is the name the service will use to register with Eureka.

application.yml:

 1 ...
 2 eureka:
 3   client:
 4     registerWithEureka: true
 5     fetchRegistry: true
 6     serviceUrl:
 7       defaultZone: http://localhost:8000/eureka/
 8   instance:
 9     hostname: ${hostName}
10     statusPageUrlPath: ${management.context-path}/info
11     healthCheckUrlPath: ${management.context-path}/health
12     preferIpAddress: true
13     metadataMap:
14       instanceId: ${spring.application.name}:${server.port}
15 
16 ---
17 spring:
18   profiles: v1
19 eureka:
20   instance:
21     metadataMap:
22       versions: v1
23 
24 ---
25 spring:
26   profiles: v1v2
27 eureka:
28   instance:
29     metadataMap:
30       versions: v1,v2
31 ...

This file includes Eureka-related configuration settings, already explained here.
The interesting part is the Spring profiles, where a new metadata (key, value) pair is added to each profile. These (key, value) pairs make to Eureka through the registration process and will be used by Demo Service 2 to filter out service instances.

Application.java:

package com.asimio.api.multiversion.demo1.main;
...
@SpringBootApplication(scanBasePackages = { "com.asimio.api.multiversion.demo1.rest" })
@EnableDiscoveryClient
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

This is the application entry point telling the Spring container to scan com.asimio.api.multiversion.demo1.rest package for @Component or derivate annotations.

...v1.ActorResource.java:

package com.asimio.api.multiversion.demo1.rest.v1;
...
import com.asimio.api.multiversion.demo1.model.v1.Actor;
...
@RestController(value = "actorResourceV1")
@RequestMapping(produces = "application/json")
public class ActorResource {

  @RequestMapping(value = "/v1/actors/{id}", method = RequestMethod.GET)
  public Actor getActorVersion1InUrl(@PathVariable("id") String id, HttpServletRequest request) {
    return this.buildV1Actor(id, request.getServerName(), String.valueOf(request.getServerPort()));
  }
...
}

...v2.ActorResource.java:

package com.asimio.api.multiversion.demo1.rest.v2;
...
import com.asimio.api.multiversion.demo1.model.v2.Actor;
...
@RestController(value = "actorResourceV2")
@RequestMapping(produces = "application/json")
public class ActorResource {

  @RequestMapping(value = "/v2/actors/{id}", method = RequestMethod.GET)
  public Actor getActorVersion2InUrl(@PathVariable("id") String id, HttpServletRequest request) {
    return this.buildV2Actor(id, request.getServerName(), String.valueOf(request.getServerPort()));
  }
...
}

The resources implementation details doesn’t matter, just that ...v1.Actor and ...v2.Actor classes are different either in the number of attributes or their names.

Lets start a Eureka instance and Demo Service 1 and take a look at its registration metadata.

RUNNING THE EUREKA SERVER

Available in the source code section, the Eureka server could be downloaded, built and started as:

cd .../discovery-server
mvn clean package
java -Dspring.profiles.active=standalone -jar target/discovery-server.jar

For more running options please refer to Microservices Registration and Discovery using Spring Cloud Eureka Ribbon and Feign.

RUNNING THE DEMO SERVICE 1

Also available in the source code area, let’s start two instances, each corresponding to a different Spring profile:

cd .../demo-multiversion-registration-api-1
mvn clean package
java -DappPort=8601 -DhostName=localhost -Dspring.profiles.active=v1 -jar target/demo-multiversion-registration-api-1-server.jar
java -DappPort=8602 -DhostName=localhost -Dspring.profiles.active=v1v2 -jar target/demo-multiversion-registration-api-1-server.jar

Once registered with the Eureka server, demo-multiversion-registration-api-1 metadata looks like:

 1 curl http://localhost:8000/eureka/apps/DEMO-MULTIVERSION-REGISTRATION-API-1
 2 <application>
 3   <name>DEMO-MULTIVERSION-REGISTRATION-API-1</name>
 4   <instance>
 5     <instanceId>orlandos-mbp.3velopers.net:demo-multiversion-registration-api-1:8601</instanceId>
 6 ...
 7       <metadata>
 8         <instanceId>demo-multiversion-registration-api-1:8601</instanceId>
 9         <versions>v1</versions>
10       </metadata>
11 ...
12   </instance>
13   <instance>
14     <instanceId>orlandos-mbp.3velopers.net:demo-multiversion-registration-api-1:8602</instanceId>
15 ...
16     <metadata>
17       <instanceId>demo-multiversion-registration-api-1:8602</instanceId>
18       <versions>v1,v2</versions>
19     </metadata>
20 ...
21   </instance>
22 ...
23 </application>

Notice in line the presence of <versions>v1</versions> and <versions>v1,v2</versions> elements which corresponds with the new metadata added in Demo Service 1’s applicataion.yml.

CREATE THE DEMO SERVICE 2

Demo Service 2 is also a simple Spring Boot application exposing two endpoints and acting as a client of Demo Service 1 to demonstrate sending requests to different API versions.

Creating it is similar to Demo Service 1 creation but changing baseDir and artifactId parameters:

curl "https://start.spring.io/starter.tgz" -d bootVersion=1.4.5.RELEASE -d dependencies=actuator,cloud-eureka,web -d language=java -d type=maven-project -d baseDir=demo-multiversion-registration-api-2 -d groupId=com.asimio.demo.api -d artifactId=demo-multiversion-registration-api-2 -d version=0-SNAPSHOT | tar -xzvf -

application.yml:

 1 ...
 2 eureka:
 3   client:
 4     registerWithEureka: false
 5     fetchRegistry: true
 6     serviceUrl:
 7       defaultZone: http://localhost:8000/eureka/
 8 
 9 demo-multiversion-registration-api1-v1:
10   ribbon:
11     # Eureka vipAddress of the target service
12     DeploymentContextBasedVipAddresses: demo-multiversion-registration-api-1
13     NIWSServerListClassName: com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
14     # Interval to refresh the server list from the source (ms)
15     ServerListRefreshInterval: 30000
16 
17 demo-multiversion-registration-api1-v2:
18   ribbon:
19     # Eureka vipAddress of the target service
20     DeploymentContextBasedVipAddresses: demo-multiversion-registration-api-1
21     NIWSServerListClassName: com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
22     # Interval to refresh the server list from the source (ms)
23     ServerListRefreshInterval: 30000
24 ...

Two Ribbon clients are defined with ids: demo-multiversion-registration-api1-v1 and demo-multiversion-registration-api1-v2. Both target the same service: demo-multiversion-registration-api-1 via service discovery using Eureka.

Application.java:

package com.asimio.api.multiversion.demo2.main;
...
@SpringBootApplication(scanBasePackages = {
  "com.asimio.api.multiversion.demo2.config",
  "com.asimio.api.multiversion.demo2.rest"
})
@EnableDiscoveryClient
public class Application {

  public static void main(String[] args) {
   SpringApplication.run(Application.class, args);
  }
}

Similarly to Demo Service 1’s Application.java, this is the application’s entry point also indicating the Spring IoC container to scan com.asimio.api.multiversion.demo2.config and com.asimio.api.multiversion.demo2.rest packages for @Component-related annotations.

AppConfig.java:

package com.asimio.api.multiversion.demo2.config;
...
@Configuration
@RibbonClients(value = {
  @RibbonClient(name = "demo-multiversion-registration-api1-v1", configuration = RibbonConfigDemoApi1V1.class),
  @RibbonClient(name = "demo-multiversion-registration-api1-v2", configuration = RibbonConfigDemoApi1V2.class)
})
public class AppConfig {

  @Bean(name = "loadBalancedRestTemplate")
  @LoadBalanced
  public RestTemplate loadBalancedRestTemplate() {
    return new RestTemplate();
  }
}

This class instantiates the RestTemplate bean to send requests to Demo Service 1. @LoadBalanced indicates this RestTemplate instance will use a Ribbon client as the internal HTTP client to send requests.

It also defines two Ribbon clients where the names match the client keys found in Demo Service 2’s application.yml and configured by @RibbonClient configuration’s value.

RibbonConfigDemoApi1V1.java:

package com.asimio.api.multiversion.demo2.config;
...
public class RibbonConfigDemoApi1V1 {

  private DiscoveryClient discoveryClient;

  @Bean
  public ServerListFilter<Server> serverListFilter() {
    return new VersionedNIWSServerListFilter<>(this.discoveryClient, RibbonClientApi.DEMO_REGISTRATION_API1_V1);
  }
...
}

This is the first Ribbon client configuration implementation. It provides a specific ServerListFilter bean, reviewed here, but it could have also provided other beans such as implementation of:

IPing
IRule
ILoadBalancer
ServerListFilter
IClientConfig

Spring Cloud Netflix creates an ApplicationContext for each Ribbon client and configures the client components with these beans.

RibbonConfigDemoApi1V2.java is similar to RibbonConfigDemoApi1V1.java but using enum DEMO_REGISTRATION_API1_V2.

RibbonClientApi.java:

package com.asimio.api.multiversion.demo2.config;
...
public enum RibbonClientApi {

  DEMO_REGISTRATION_API1_V1("demo-multiversion-registration-api-1", "v1"),

  DEMO_REGISTRATION_API1_V2("demo-multiversion-registration-api-1", "v2");
...
}

A enum with service and version information. demo-multiversion-registration-api-1 is the service name found in the Eureka registry.

VersionedNIWSServerListFilter.java:

 1 package com.asimio.api.multiversion.demo2.niws.loadbalancer;
 2 ...
 3 public class VersionedNIWSServerListFilter<T extends Server> extends DefaultNIWSServerListFilter<T> {
 4 
 5   private static final String VERSION_KEY = "versions";
 6 
 7   private final DiscoveryClient discoveryClient;
 8   private final RibbonClientApi ribbonClientApi;
 9 
10   public VersionedNIWSServerListFilter(DiscoveryClient discoveryClient, RibbonClientApi ribbonClientApi) {
11     this.discoveryClient = discoveryClient;
12     this.ribbonClientApi = ribbonClientApi;
13   }
14 
15   @Override
16   public List<T> getFilteredListOfServers(List<T> servers) {
17     List<T> result = new ArrayList<>();
18     List<ServiceInstance> serviceInstances = this.discoveryClient.getInstances(this.ribbonClientApi.serviceId);
19     for (ServiceInstance serviceInstance : serviceInstances) {
20       List<String> versions = this.getInstanceVersions(serviceInstance);
21       if (versions.isEmpty() || versions.contains(this.ribbonClientApi.version)) {
22         result.addAll(this.findServerForVersion(servers, serviceInstance));
23       }
24     }
25     return result;
26   }
27 
28   private List<String> getInstanceVersions(ServiceInstance serviceInstance) {
29     List<String> result = new ArrayList<>();
30     String rawVersions = serviceInstance.getMetadata().get(VERSION_KEY);
31     if (StringUtils.isNotBlank(rawVersions)) {
32       result.addAll(Arrays.asList(rawVersions.split(",")));
33     }
34     return result;
35   }
36 ...
37 }

This is the ServerListFilter implementation used to configure each Ribbon client such the ones defined with @RibbonClient annotation.

All it does is filtering the server instances by id and version so that a Ribbon client doesn’t sends requests to a host that doesn’t support the requested version.

AggregationResource.java:

package com.asimio.api.multiversion.demo2.rest;
...
@RestController
@RequestMapping(value = "/aggregation", produces = "application/json")
public class AggregationResource {

  private static final String ACTORS_SERVICE_ID_V1 = "demo-multiversion-registration-api1-v1";
  private static final String ACTORS_SERVICE_ID_V2 = "demo-multiversion-registration-api1-v2";

  private RestTemplate loadBalancedRestTemplate;

  @RequestMapping(value = "/v1/actors/{id}", method = RequestMethod.GET)
  public com.asimio.api.multiversion.demo2.model.v1.Actor findActorV1(@PathVariable(value = "id") String id) {
    String url = String.format("http://%s/v1/actors/{id}", ACTORS_SERVICE_ID_V1);
    return this.loadBalancedRestTemplate.getForObject(url, com.asimio.api.multiversion.demo2.model.v1.Actor.class, id);
  }

  @RequestMapping(value = "/v2/actors/{id}", method = RequestMethod.GET)
  public com.asimio.api.multiversion.demo2.model.v2.Actor findActorV2(@PathVariable(value = "id") String id) {
    String url = String.format("http://%s/v2/actors/{id}", ACTORS_SERVICE_ID_V2);
    return this.loadBalancedRestTemplate.getForObject(url, com.asimio.api.multiversion.demo2.model.v2.Actor.class, id);
  }
...
}

Demo Service 2 implements APIs that delegates to either Demo Service 1’s v1 or v2. It does so using a Ribbon-configured RestTemplate bean. Notice the service ids used in both endpoints match Ribbon client keys as configured in Demo Service 2’s application.yml and AppConfig.java.

RUNNING THE DEMO SERVICE 2

Assuming the Eureka server is running and Demo Service 1 instances using v1 and v1v2 are also running, lets start Demo Service 2:

cd .../demo-multiversion-registration-api-2
mvn clean package
java -DappPort=8701 -DhostName=localhost -jar target/demo-multiversion-registration-api-2-server.jar

Let’s send a requests to v1:

curl "http://localhost:8701/aggregation/v1/actors/1"
{"actorId":"1","firstName":"120.240.1.192","lastName":"8601","lastUpdate":null}
curl "http://localhost:8701/aggregation/v1/actors/1"
{"actorId":"1","firstName":"120.240.1.192","lastName":"8602","lastUpdate":null}

Notice "lastName":"8601" and "lastName":"8602", it means both Demo Service 1 instances are being load-balanced.

Let’s send a requests to v2:

curl "http://localhost:8701/aggregation/v2/actors/1"
{"id":"1","first":"120.240.1.192","last":"8602"}
...
curl "http://localhost:8701/aggregation/v2/actors/1"
{"id":"1","first":"120.240.1.192","last":"8602"}

The Demo Service 1 instance listening on 8602 is the only one servicing v2. Once it gets shutdown requests fail as follow:

curl "http://localhost:8701/aggregation/v2/actors/1"
{"timestamp":1488776450866,"status":500,"error":"Internal Server Error","exception":"java.lang.IllegalStateException","message":"No instances available for demo-multiversion-registration-api1-v2","path":"/aggregation/v2/actors/1"}

That’s all for this post. Thanks for reading and feedback is always appreciated.

SOURCE CODE

Accompanying source code for this blog post can be found at:

REFERENCES