7

I'm asking myself a question without finding responses for it. Maybe someone here would have ideas about that ;-) Using a services registry (Eureka) in Spring Cloud with RestTemplate and Feign clients, I have different build versions of the same service. The build version being documented through Actuator's /info endpoint.

{
"build": {
"version": "0.0.1-SNAPSHOT",
"artifact": "service-a",
"name": "service-a",
"group": "com.mycompany",
"time": 1487253409000
}
}
...
{
"build": {
"version": "0.0.2-SNAPSHOT",
"artifact": "service-a",
"name": "service-a",
"group": "com.mycompany",
"time": 1487325340000
}
}

Is there any mean to ask for a particular build version at client's call? Should I use gateway's routing filters in order to manage that? But the version detection would remain an issue I guess...

Well, any suggestion appreciated.

Thomas Escolan
  • 985
  • 1
  • 8
  • 17
  • 1
    I dont think there is anything out the box that will help. You would need to convey the version information to the Eureka server as well so Eureka clients would know the version. You could probably do that via Eureka metadata. As far as Feign clients then leveraging that information, you would probably have to use the discovery service APIs to then get the metadata about the service and decide which instance to call. – Ryan Baxter Feb 20 '17 at 16:23
  • I think you are right :-( but this is a real issue since you first have to collect every service instances and then filter them by version – Thomas Escolan Feb 21 '17 at 13:11
  • Probably a good start : https://jmnarloch.wordpress.com/2015/11/25/spring-cloud-ribbon-dynamic-routing/ – Thomas Escolan Feb 21 '17 at 15:26
  • 2
    When debugging DiscoveryClient#getInstances result, I can see that EurekaDiscoveryClient$EurekaServiceInstance has an InstanceInfo property which holds the service name and a version property, set to UNKNOWN. Still digging ;-) – Thomas Escolan Feb 21 '17 at 15:45
  • My current idea is to make spring-cloud-netflix-eureka-server collect the version info from Actuator's endpoint, put this info into InstanceInfo and/or metadata, extend the current API to accept a version criteria AND ribbon client to both use the API and client-filter versions. Just to be done ;-) – Thomas Escolan Feb 21 '17 at 16:15
  • +nice to have : dashboard display of the version – Thomas Escolan Feb 21 '17 at 16:18
  • actually, the info collection and display is already done into Spring Boot Admin. Just saying for memo – Thomas Escolan Feb 21 '17 at 16:32
  • pb found for discovery client: COMMONS org.springframework.cloud.client.discovery.DiscoveryClient doesn't allow to getInstances with version and org.springframework.cloud.client.ServiceInstance cannot carry version (workaround: use metadata in this particular case) – Thomas Escolan Feb 21 '17 at 16:41
  • InstanceInfo is built into EurekaConfigBasedInstanceInfoProvider#get. InstanceInfo#version is deprecated. InstanceInfo#MetaData is filled via EurekaInstanceConfig#MetadataMap being initialized as a bean in EurekaClientConfigServerAutoConfiguration#init – Thomas Escolan Feb 22 '17 at 14:16
  • Build informations are provided when available into a BuildProperties bean (see InfoContributorAutoConfiguration#buildInfoContributor) – Thomas Escolan Feb 22 '17 at 14:28

3 Answers3

2

Ok. This is the code to inject the build version into the service ("service-a") instance metadata to be registered by Eureka:

@Configuration
@ConditionalOnClass({ EurekaInstanceConfigBean.class, EurekaClient.class })
public class EurekaClientInstanceBuildVersionAutoConfiguration {

    @Autowired(required = false)
    private EurekaInstanceConfig instanceConfig;

    @Autowired(required = false)
    private BuildProperties buildProperties;

    @Value("${eureka.instance.metadata.keys.version:instanceBuildVersion}")
    private String versionMetadataKey;

    @PostConstruct
    public void init() {
        if (this.instanceConfig == null || buildProperties == null) {
            return;
        }
        this.instanceConfig.getMetadataMap().put(versionMetadataKey, buildProperties.getVersion());
    }
}

This is the code to validate metadata transmission within a "service-b":

@Component
public class DiscoveryClientRunner implements CommandLineRunner {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private DiscoveryClient client;

    @Override
    public void run(String... args) throws Exception {
        client.getInstances("service-a").forEach((ServiceInstance s) -> {
            logger.debug(String.format("%s: %s", s.getServiceId(), s.getUri()));
            for (Entry<String, String> md : s.getMetadata().entrySet()) {
                logger.debug(String.format("%s: %s", md.getKey(), md.getValue()));
            }
        });
    }
}

Notice that if "dashed composed" (i.e. "instance-build-version"), the metadata key is Camel Case forced.

And this is the solution I found to filter service instances according to their version:

@Configuration
@EnableConfigurationProperties(InstanceBuildVersionProperties.class)
public class EurekaInstanceBuildVersionFilterAutoConfig {

    @Value("${eureka.instance.metadata.keys.version:instanceBuildVersion}")
    private String versionMetadataKey;

    @Bean
    @ConditionalOnProperty(name = "eureka.client.filter.enabled", havingValue = "true")
    public EurekaInstanceBuildVersionFilter eurekaInstanceBuildVersionFilter(InstanceBuildVersionProperties filters) {
        return new EurekaInstanceBuildVersionFilter(versionMetadataKey, filters);
    }
}

@Aspect
@RequiredArgsConstructor
public class EurekaInstanceBuildVersionFilter {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private final String versionMetadataKey;
    private final InstanceBuildVersionProperties filters;

    @SuppressWarnings("unchecked")
    @Around("execution(public * org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient.getInstances(..))")
    public Object filterInstances(ProceedingJoinPoint jp) throws Throwable {
        if (filters == null || !filters.isEnabled()) logger.error("Should not be filtering...");
        List<ServiceInstance> instances = (List<ServiceInstance>) jp.proceed();
        return instances.stream()
                .filter(i -> filters.isKept((String) jp.getArgs()[0], i.getMetadata().get(versionMetadataKey))) //DEBUG MD key is Camel Cased!
                .collect(Collectors.toList());
    }
}

@ConfigurationProperties("eureka.client.filter")
public class InstanceBuildVersionProperties {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * Indicates whether or not service instances versions should be filtered
     */
    @Getter @Setter
    private boolean enabled = false;

    /**
     * Map of service instance version filters.
     * The key is the service name and the value configures a filter set for services instances
     */
    @Getter
    private Map<String, InstanceBuildVersionFilter> services = new HashMap<>();

    public boolean isKept(String serviceId, String instanceVersion) {
        logger.debug("Considering service {} instance version {}", serviceId, instanceVersion);
        if (services.containsKey(serviceId) && StringUtils.hasText(instanceVersion)) {
            InstanceBuildVersionFilter filter = services.get(serviceId);
            String[] filteredVersions = filter.getVersions().split("\\s*,\\s*");    // trimming
            logger.debug((filter.isExcludeVersions() ? "Excluding" : "Including") + " instances: " + Arrays.toString(filteredVersions));
            return contains(filteredVersions, instanceVersion) ? !filter.isExcludeVersions() : filter.isExcludeVersions();
        }
        return true;
    }

    @Getter @Setter
    public static class InstanceBuildVersionFilter {
        /**
         * Comma separated list of service version labels to filter
         */
        private String versions;
        /**
         * Indicates whether or not to keep the associated instance versions.
         * When false, versions are kept, otherwise they will be filtered out
         */
        private boolean excludeVersions = false;
    }
}

You can specify for every consumed service a list of expected or avoided versions and the discovery will be filtered accordingly.

logging.level.com.mycompany.demo=DEBUG

eureka.client.filter.enabled=true

eureka.client.filter.services.service-a.versions=0.0.1-SNAPSHOT

Please submit as comments any suggestion. Thx

Thomas Escolan
  • 985
  • 1
  • 8
  • 17
  • please see comments of alternate solutions to simplify registering part of the process : you can add metadata with simple properties. – Thomas Escolan Mar 02 '17 at 10:24
1

Service 1 registers v1 and v2 with Eureka

Service 2 discovers and sends requests to Service 1's v1 and v2 using different Ribbon clients

I got this demo to work and will blog about it in the next couple of days.

http://tech.asimio.net/2017/03/06/Multi-version-Service-Discovery-using-Spring-Cloud-Netflix-Eureka-and-Ribbon.html

The idea I followed was for RestTemplate to use a different Ribbon client for each version because each client has its own ServerListFilter.


Service 1

application.yml

...
eureka:
  client:
    registerWithEureka: true
    fetchRegistry: true
    serviceUrl:
      defaultZone: http://localhost:8000/eureka/
  instance:
    hostname: ${hostName}
    statusPageUrlPath: ${management.context-path}/info
    healthCheckUrlPath: ${management.context-path}/health
    preferIpAddress: true
    metadataMap:
      instanceId: ${spring.application.name}:${server.port}

---
spring:
   profiles: v1
eureka:
  instance:
    metadataMap:
      versions: v1

---
spring:
   profiles: v1v2
eureka:
  instance:
    metadataMap:
      versions: v1,v2
...

Service 2

application.yml

...
eureka:
  client:
    registerWithEureka: false
    fetchRegistry: true
    serviceUrl:
      defaultZone: http://localhost:8000/eureka/

demo-multiversion-registration-api-1-v1:
   ribbon:
     # Eureka vipAddress of the target service
     DeploymentContextBasedVipAddresses: demo-multiversion-registration-api-1
     NIWSServerListClassName: com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
     # Interval to refresh the server list from the source (ms)
     ServerListRefreshInterval: 30000

demo-multiversion-registration-api-1-v2:
   ribbon:
     # Eureka vipAddress of the target service
     DeploymentContextBasedVipAddresses: demo-multiversion-registration-api-1
     NIWSServerListClassName: com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
     # Interval to refresh the server list from the source (ms)
     ServerListRefreshInterval: 30000
...

Application.java

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

AppConfig.java (See how the Ribbon client name matches the Ribbon key found in application.yml

...
@Configuration
@RibbonClients(value = {
    @RibbonClient(name = "demo-multiversion-registration-api-1-v1", configuration = RibbonConfigDemoApi1V1.class),
    @RibbonClient(name = "demo-multiversion-registration-api-1-v2", configuration = RibbonConfigDemoApi1V2.class)
})
public class AppConfig {

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

RibbonConfigDemoApi1V1.java

...
public class RibbonConfigDemoApi1V1 {

    private DiscoveryClient discoveryClient;

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

    @Autowired
    public void setDiscoveryClient(DiscoveryClient discoveryClient) {
        this.discoveryClient = discoveryClient;
    }
}

RibbonConfigDemoApi1V2.java is similar but using RibbonClientApi.DEMO_REGISTRATION_API_1_V2

RibbonClientApi.java

...
public enum RibbonClientApi {

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

    DEMO_REGISTRATION_API_1_V2("demo-multiversion-registration-api-1", "v2");

    public final String serviceId;
    public final String version;

    private RibbonClientApi(String serviceId, String version) {
        this.serviceId = serviceId;
        this.version = version;
    }
}

VersionedNIWSServerListFilter.java

...
public class VersionedNIWSServerListFilter<T extends Server> extends DefaultNIWSServerListFilter<T> {

    private static final String VERSION_KEY = "versions";

    private final DiscoveryClient discoveryClient;
    private final RibbonClientApi ribbonClientApi;

    public VersionedNIWSServerListFilter(DiscoveryClient discoveryClient, RibbonClientApi ribbonClientApi) {
        this.discoveryClient = discoveryClient;
        this.ribbonClientApi = ribbonClientApi;
    }

    @Override
    public List<T> getFilteredListOfServers(List<T> servers) {
        List<T> result = new ArrayList<>();
        List<ServiceInstance> serviceInstances = this.discoveryClient.getInstances(this.ribbonClientApi.serviceId);
        for (ServiceInstance serviceInstance : serviceInstances) {
            List<String> versions = this.getInstanceVersions(serviceInstance);
            if (versions.isEmpty() || versions.contains(this.ribbonClientApi.version)) {
                result.addAll(this.findServerForVersion(servers, serviceInstance));
            }
        }
        return result;
    }

    private List<String> getInstanceVersions(ServiceInstance serviceInstance) {
        List<String> result = new ArrayList<>();
        String rawVersions = serviceInstance.getMetadata().get(VERSION_KEY);
        if (StringUtils.isNotBlank(rawVersions)) {
            result.addAll(Arrays.asList(rawVersions.split(",")));
        }
        return result;
    }
...

AggregationResource.java

...
@RestController
@RequestMapping(value = "/aggregation", produces = "application/json")
public class AggregationResource {

    private static final String ACTORS_SERVICE_ID_V1 = "demo-multiversion-registration-api-1-v1";
    private static final String ACTORS_SERVICE_ID_V2 = "demo-multiversion-registration-api-1-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);
    }

    @Autowired
    public void setLoadBalancedRestTemplate(RestTemplate loadBalancedRestTemplate) {
        this.loadBalancedRestTemplate = loadBalancedRestTemplate;
    }
}
ootero
  • 3,235
  • 2
  • 16
  • 22
  • Thanks a lot, I'll have a close look. But that doesn't seem to address my use case : what's the use for a service (1) to declare/publish several versions for itself?? This is probably violating SRP. My need was to deploy two autonomous versions of Service 1 and then to filter non supported versions from the client (Service 2) side. Plus my solutions avoids to manipulate several HTTP (Ribbon) clients. – Thomas Escolan Mar 01 '17 at 10:09
  • You got a very good point, by the way, on suggesting the eureka.instance property to provide metadatas (I missed it); so my EurekaClientInstanceBuildVersionAutoConfiguration class can be replaced by "eureka.instance.metadata-map.instanceBuildVersion=@pom.version@" in my examples. Thx – Thomas Escolan Mar 01 '17 at 11:35
  • "what's the use for a service (1) to declare/publish several versions for itself?" I believe APIs should never break backwards compat, unless they (client and services) are deployed in a very controlled environment such as an existing client never hits a newer APIs. – ootero Mar 01 '17 at 12:53
  • "Plus my solutions avoids to manipulate several HTTP (Ribbon) clients" That was just a how to Demo. In a real world scenario there might be "slow to upgrade" clients that would be sending requests to API v1 while other clients might be hitting API v2. If the core business of a "slow to upgrade client" is not directly related to what API v1, v2 offer, it might take years for them to upgrade, think of google maps v2 and v3. I firmly believe it would be a bad practice to force clients to upgrade overnight. – ootero Mar 01 '17 at 12:58
  • Thank you very much for your views, @ootero. My idea is that there is a no need for 2 consecutive APIs to be served in the same instance. Bandwidth usage might be different for the versions and that way you couldn't scale them separately. I was more thinking of several instances of each versions available, decreasing v1 number of instances as v2 adoption would require more "modern" service instances. There is no break in this approach, no need to cut the service as load balancing allows for the transition. – Thomas Escolan Mar 02 '17 at 10:21
  • Decreasing v1 usage and new v2 adoption might take years, think of Google Maps v2. What if a bug is discovered in v1? Would you now have two source code repos? one for xxxx-service-v1 and xxxx-service-v2 and passing all the complexity to DevOps (source code repo, CICD pipelines, QA, Staging, ... environments? Multiple binaries from single repo? That's not what the 12 factors app manifesto proposes. – ootero Nov 07 '17 at 16:11
0

This is the trick for hacking Eureka Dashboard. Add this AspectJ aspect (because InstanceInfo used in EurekaController is not a Spring Bean) to the @EnableEurekaServer project:

@Configuration
@Aspect
public class EurekaDashboardVersionLabeler {

    @Value("${eureka.instance.metadata.keys.version:instanceBuildVersion}")
    private String versionMetadataKey;

    @Around("execution(public * com.netflix.appinfo.InstanceInfo.getId())")
    public String versionLabelAppInstances(ProceedingJoinPoint jp) throws Throwable {
        String instanceId = (String) jp.proceed();
        for (StackTraceElement ste : Thread.currentThread().getStackTrace()) {
            // limit to EurekaController#populateApps in order to avoid side effects
            if (ste.getClassName().contains("EurekaController")) {
                InstanceInfo info = (InstanceInfo) jp.getThis();
                String version = info.getMetadata().get(versionMetadataKey);
                if (StringUtils.hasText(version)) {
                    return String.format("%s [%s]", instanceId, version);
                }
                break;
            }
        }
        return instanceId;
    }

    @Bean("post-construct-labeler")
    public EurekaDashboardVersionLabeler init() {
        return EurekaDashboardVersionLabeler.aspectOf();
    }

    private static EurekaDashboardVersionLabeler instance = new EurekaDashboardVersionLabeler();
    /** Singleton pattern used by LTW then Spring */
    public static EurekaDashboardVersionLabeler aspectOf() {
        return instance;
    }
}

You also have to add a dependency not provided by starters:

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-eureka-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjrt</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

And activate the LTW a runtime with a VM arg, of course:

-javaagent:D:\.m2\repository\org\aspectj\aspectjweaver\1.8.9\aspectjweaver-1.8.9.jar
Thomas Escolan
  • 985
  • 1
  • 8
  • 17