在使用Spring Cloud
全家桶的时候,遇到了一个不大不小的坑,可能还是中文教学中对配置的解析不够到位所致,因此对eureka.instance.appname
配置理解不正确。本文浅析一下该配置的意义和Eureka
服务获取机制
学习的时候都知道,spring.application.name
和eureka.instance.appname
都可以设定注册在Eureka
中的服务名,但是若用eureka.instance.appname
设定而不设置spring.application.name
,从Eureka
服务器中却无法获取服务信息。不管是用Ribbon
、Feign
还是DiscoveryClient
对象都不能正确获取。经过测试和源码分析,找到了问题所在,下面一步步解析。
spring.application.name
和eureka.instance.appname
的区别之前只知道,这两个都属性都可以设定服务名,我们也会在Eureka
的仪表盘上看到这个名字。在旧版本的Eureka
显示以spring.application.name
为优先,在新版本的Eureka
显示以eureka.instance.appname
为优先。但是两者的区别到底在哪里呢,优先级的变化又是如何出现的呢?
该类定义了eureka.instance
开头的配置项,我们只关注到appname
的初始化。
在spring-cloud-netflix-eureka-client:1.2.3.RELEASE
及之前版本,该类实现了接口org.springframework.beans.factory.InitializingBean
来对该类初始化进行额外操作:
@ConfigurationProperties("eureka.instance")
public class EurekaInstanceConfigBean implements CloudEurekaInstanceConfig, EnvironmentAware, InitializingBean {
...
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public void afterPropertiesSet() throws Exception {
RelaxedPropertyResolver springPropertyResolver = new RelaxedPropertyResolver(this.environment, "spring.application.");
String springAppName = springPropertyResolver.getProperty("name");
if(StringUtils.hasText(springAppName)) {
setAppname(springAppName);
setVirtualHostName(springAppName);
setSecureVirtualHostName(springAppName);
}
}
...
}
我们可以看到通过EnvironmentAware
接口的setEnvironment
方法先获取了Spring Boot
的环境变量配置;然后经过InitializingBean
接口的afterPropertiesSet
方法,在对象属性载入之后,获取spring.application.name
配置,若其存在就赋给了appname
、virtualHostName
、secureVirtualHostName
三个属性。因此在旧版本中,spring.application.name
会覆盖掉eureka.instance.appname
。
在spring-cloud-netflix-eureka-client:1.2.3.RELEASE
之后的版本中,该类不在使用接口org.springframework.beans.factory.InitializingBean
,而采取了另一种设置方式:
@ConfigurationProperties("eureka.instance")
public class EurekaInstanceConfigBean implements CloudEurekaInstanceConfig, EnvironmentAware {
...
// 1.2.4.RELEASE版本
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
// set some defaults from the environment, but allow the defaults to use relaxed binding
RelaxedPropertyResolver springPropertyResolver = new RelaxedPropertyResolver(this.environment, "spring.application.");
String springAppName = springPropertyResolver.getProperty("name");
if(StringUtils.hasText(springAppName)) {
setAppname(springAppName);
setVirtualHostName(springAppName);
setSecureVirtualHostName(springAppName);
}
}
// 2.1.0.RELEASE版本,由于Environment类升级不在需要RelaxedPropertyResolver解析
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
// set some defaults from the environment, but allow the defaults to use relaxed binding
String springAppName = this.environment.getProperty("spring.application.name", "");
if (StringUtils.hasText(springAppName)) {
setAppname(springAppName);
setVirtualHostName(springAppName);
setSecureVirtualHostName(springAppName);
}
}
...
}
我们看到后续版本中,直接通过EnvironmentAware
接口的setEnvironment
方法进行从spring.application.name
配置属性对appname
、virtualHostName
、secureVirtualHostName
三个属性覆盖赋值操作。但是这里有个加载顺序的问题,EnvironmentAware
接口的方法执行会在Spring Boot
的配置属性赋值之前进行,因此当我们配置文件中设置eureka.instance.appname
后会再次覆盖回来,这就是为什么新版本中Eureka
仪表盘会显示eureka.instance.appname
设定的名字。
我们先看到org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration.EurekaClientConfiguration
有如下方法:
@Configuration
... //省略大量注解配置
public class EurekaClientAutoConfiguration {
...
@Configuration
@ConditionalOnMissingRefreshScope
protected static class EurekaClientConfiguration {
...
@Bean
@ConditionalOnMissingBean(value = ApplicationInfoManager.class, search = SearchStrategy.CURRENT)
public ApplicationInfoManager eurekaApplicationInfoManager(
EurekaInstanceConfig config) {
InstanceInfo instanceInfo = new InstanceInfoFactory().create(config);
return new ApplicationInfoManager(config, instanceInfo);
}
...
}
...
}
看到这里用到了InstanceInfoFactory
工厂来生成InstanceInfo
对象,我们追进去create
方法:
public class InstanceInfoFactory {
public InstanceInfo create(EurekaInstanceConfig config) {
LeaseInfo.Builder leaseInfoBuilder = LeaseInfo.Builder.newBuilder()
.setRenewalIntervalInSecs(config.getLeaseRenewalIntervalInSeconds())
.setDurationInSecs(config.getLeaseExpirationDurationInSeconds());
// Builder the instance information to be registered with eureka
// server
InstanceInfo.Builder builder = InstanceInfo.Builder.newBuilder();
String namespace = config.getNamespace();
if (!namespace.endsWith(".")) {
namespace = namespace + ".";
}
builder.setNamespace(namespace).setAppName(config.getAppname())
.setInstanceId(config.getInstanceId())
.setAppGroupName(config.getAppGroupName())
.setDataCenterInfo(config.getDataCenterInfo())
.setIPAddr(config.getIpAddress()).setHostName(config.getHostName(false))
.setPort(config.getNonSecurePort())
.enablePort(InstanceInfo.PortType.UNSECURE,
config.isNonSecurePortEnabled())
.setSecurePort(config.getSecurePort())
.enablePort(InstanceInfo.PortType.SECURE, config.getSecurePortEnabled())
.setVIPAddress(config.getVirtualHostName())
.setSecureVIPAddress(config.getSecureVirtualHostName())
.setHomePageUrl(config.getHomePageUrlPath(), config.getHomePageUrl())
.setStatusPageUrl(config.getStatusPageUrlPath(),
config.getStatusPageUrl())
.setHealthCheckUrls(config.getHealthCheckUrlPath(),
config.getHealthCheckUrl(), config.getSecureHealthCheckUrl())
.setASGName(config.getASGName());
// Start off with the STARTING state to avoid traffic
if (!config.isInstanceEnabledOnit()) {
InstanceInfo.InstanceStatus initialStatus = InstanceInfo.InstanceStatus.STARTING;
if (log.isInfoEnabled()) {
log.info("Setting initial instance status as: " + initialStatus);
}
builder.setStatus(initialStatus);
}
else {
if (log.isInfoEnabled()) {
log.info("Setting initial instance status as: "
+ InstanceInfo.InstanceStatus.UP
+ ". This may be too early for the instance to advertise itself as available. "
+ "You would instead want to control this via a healthcheck handler.");
}
}
// Add any user-specific metadata information
for (Map.Entry<String, String> mapEntry : config.getMetadataMap().entrySet()) {
String key = mapEntry.getKey();
String value = mapEntry.getValue();
// only add the metadata if the value is present
if (value != null && !value.isEmpty()) {
builder.add(key, value);
}
}
InstanceInfo instanceInfo = builder.build();
instanceInfo.setLeaseInfo(leaseInfoBuilder.build());
return instanceInfo;
}
}
这个方法很长,但其实我们主要关注到其就是通过配置信息用构建器InstanceInfo.Builder
创建一个实例。
所以实际上注册到Eureka
中的信息就是将InstanceInfo
序列化后发送给服务器上,eureka.instance.appname
将作为显示在仪表盘上的名字(Builder
的setAppName
方法会自动将名字转为全大写)。
那么不管是什么方式,只要Eureka
记录的服务名应该就可以用于实例信息获取,是哪里出现问题导致仅设置eureka.instance.appname
无法获取实例呢?
Eureka
服务器实际上是通过RESTful API
的形式进行交互的,不管是注册还是获取都是如此。我们查阅官方Wiki
页可以找到获取服务的API
(页面中API
带有/v2
,但Eureka V2
已停止开发,这里还是修正为原版的API
):
操作 | HTTP行为 | 描述 |
---|---|---|
查询所有实例 | GET /eureka/apps | HTTP 成功码: 200 输出格式: JSON/XML |
查询所有名为appID的实例 | GET /eureka/apps/appID | HTTP 成功码: 200 输出格式: JSON/XML |
查询appID下指定instanceID名的实例 | GET /eureka/apps/appID/instanceID | HTTP 成功码: 200 输出格式: JSON/XML |
我们可以通过API
从服务器获取所有想要的实例。这里appID
指的就是eureka.instance.appname
设定的名字,instanceID
为eureka.instance.instance-id
设定的值。
这里看上去没有任何问题,经过测试API也没有错误,有效的获取到了服务实例的完整信息:
<application>
<name>FEIGN-CLIENTname>
<instance>
<instanceId>feign-client-10000instanceId>
<hostName>127.0.0.1hostName>
<app>FEIGN-CLIENTapp>
<ipAddr>127.0.0.1ipAddr>
<status>UPstatus>
<overriddenstatus>UNKNOWNoverriddenstatus>
<port enabled="true">10000port>
<securePort enabled="false">443securePort>
<countryId>1countryId>
<dataCenterInfo class="com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo">
<name>MyOwnname>
dataCenterInfo>
<leaseInfo>
<renewalIntervalInSecs>5renewalIntervalInSecs>
<durationInSecs>15durationInSecs>
<registrationTimestamp>1582862248455registrationTimestamp>
<lastRenewalTimestamp>1582862293383lastRenewalTimestamp>
<evictionTimestamp>0evictionTimestamp>
<serviceUpTimestamp>1582862248456serviceUpTimestamp>
leaseInfo>
<metadata>
<zone>zone-1zone>
<management.port>10000management.port>
metadata>
<homePageUrl>http://127.0.0.1:10000/homePageUrl>
<statusPageUrl>http://127.0.0.1:10000/actuator/infostatusPageUrl>
<healthCheckUrl>http://127.0.0.1:10000/actuator/healthhealthCheckUrl>
<vipAddress>unknownvipAddress>
<secureVipAddress>unknownsecureVipAddress>
<isCoordinatingDiscoveryServer>falseisCoordinatingDiscoveryServer>
<lastUpdatedTimestamp>1582862248456lastUpdatedTimestamp>
<lastDirtyTimestamp>1582862248366lastDirtyTimestamp>
<actionType>ADDEDactionType>
instance>
application>
这里的属性和InstanceInfo
对象中的属性是一一对应的。
但是这个实例信息,我们在Ribbon
或者Feign
中用FEIGN-CLIENT
却出现了实例数为0
的异常,这是为什么呢,我们就要来探究Java
编写的Eureka Client
是如何获取每个实例信息的。
在Spring CLoud
体系中,不论是Ribbon
还是Feign
,其底层实现负载均衡也就是从Eureka
获取服务列表的实例都是通过org.springframework.cloud.client.discovery.DiscoveryClient
接口的实现对象。在Eureka Client
其默认实现为类org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient
。
我们来看Spring CLoud
定义的DiscoveryClient
接口:
public interface DiscoveryClient extends Ordered {
int DEFAULT_ORDER = 0;
//返回描述信息
String description();
//返回对应serviceId的实例列表
List<ServiceInstance> getInstances(String serviceId);
//返回注册在服务器上的服务名列表
List<String> getServices();
//返回执行时的顺序
@Override
default int getOrder() {
return DEFAULT_ORDER;
}
}
很显然,获取服务实例的核心方法就在getInstances(String serviceId)
上了!我们来看EurekaDiscoveryClient
中该方法的实现:
public class EurekaDiscoveryClient implements DiscoveryClient {
...
@Override
public List<ServiceInstance> getInstances(String serviceId) {
List<InstanceInfo> infos = this.eurekaClient.getInstancesByVipAddress(serviceId,
false);
List<ServiceInstance> instances = new ArrayList<>();
for (InstanceInfo info : infos) {
instances.add(new EurekaServiceInstance(info));
}
return instances;
}
...
}
这里调用了一个getInstancesByVipAddress
方法来获取实例,这个方法是Netflix
原版Eureka
中实现的,其方法体在类com.netflix.discovery.DiscoveryClient
中:
@Singleton
public class DiscoveryClient implements EurekaClient {
...
public List<InstanceInfo> getInstancesByVipAddress(String vipAddress, boolean secure) {
return this.getInstancesByVipAddress(vipAddress, secure, this.instanceRegionChecker.getLocalRegion());
}
public List<InstanceInfo> getInstancesByVipAddress(String vipAddress, boolean secure, @Nullable String region) {
if (vipAddress == null) {
throw new IllegalArgumentException("Supplied VIP Address cannot be null");
} else {
Applications applications;
if (this.instanceRegionChecker.isLocalRegion(region)) {
applications = (Applications)this.localRegionApps.get();
} else {
applications = (Applications)this.remoteRegionVsApps.get(region);
if (null == applications) {
logger.debug("No applications are defined for region {}, so returning an empty instance list for vip address {}.", region, vipAddress);
return Collections.emptyList();
}
}
return !secure ? applications.getInstancesByVirtualHostName(vipAddress) : applications.getInstancesBySecureVirtualHostName(vipAddress);
}
}
...
}
这里就是重中之重了。我们看到getInstancesByVipAddress
方法的第一个参数其实并不是serviceId
,其实是vipAddress
!这个vipAddress
属性的解读是virtual ip address
也就是虚拟IP地址,有没有想到前面配置属性时候提到的,spring.application.name
属性会覆盖virtualHostName
属性?没错这个eureka.instance.virtual-host-name
的配置项才是通过getInstances(String serviceId)
方法实际查找的属性值,而当其没有被配置时默认为unknown
。因此传入eureka.instance.appname
的值是无法找到实例的。
从上述分析中,我们可以看到真正坑的地方是,spring.application.name
会给三个属性appname
、virtualHostName
、secureVirtualHostName
填值,但是现在版本的Eureka
这个操作被前置了,也就是我们单独设定的appname
会将值再次更新,这就造成了实际appname
、virtualHostName
两项属性不匹配的问题,因此就无法从appname
配置的服务名获取实例了。
正确的做法有两种:
spring.application.name
,不配置eureka.instance.appname
,一劳永逸eureka.instance.appname
和eureka.instance.virtual-host-name
都进行配置,设定为相同的值,就可以用负载均衡器获取实例了。实际上原版Netflix
的Eureka
中的com.netflix.discovery.EurekaClient
接口有个通过appName获取实例的方法getInstancesByVipAddressAndAppName
,我们可以在com.netflix.discovery.DiscoveryClient
类中找到其唯一实现:
@Singleton
public class DiscoveryClient implements EurekaClient {
...
public List<InstanceInfo> getInstancesByVipAddressAndAppName(String vipAddress, String appName, boolean secure) {
List<InstanceInfo> result = new ArrayList();
if (vipAddress == null && appName == null) {
throw new IllegalArgumentException("Supplied VIP Address and application name cannot both be null");
} else if (vipAddress != null && appName == null) {
return this.getInstancesByVipAddress(vipAddress, secure);
} else if (vipAddress == null && appName != null) {
Application application = this.getApplication(appName);
if (application != null) {
result = application.getInstances();
}
return (List)result;
} else {
Iterator var6 = this.getApplications().getRegisteredApplications().iterator();
label67:
while(var6.hasNext()) {
Application app = (Application)var6.next();
Iterator var8 = app.getInstances().iterator();
while(true) {
while(true) {
String instanceVipAddress;
InstanceInfo instance;
do {
if (!var8.hasNext()) {
continue label67;
}
instance = (InstanceInfo)var8.next();
if (secure) {
instanceVipAddress = instance.getSecureVipAddress();
} else {
instanceVipAddress = instance.getVIPAddress();
}
} while(instanceVipAddress == null);
String[] instanceVipAddresses = instanceVipAddress.split(",");
String[] var11 = instanceVipAddresses;
int var12 = instanceVipAddresses.length;
for(int var13 = 0; var13 < var12; ++var13) {
String vipAddressFromList = var11[var13];
if (vipAddress.equalsIgnoreCase(vipAddressFromList.trim()) && appName.equalsIgnoreCase(instance.getAppName())) {
((List)result).add(instance);
break;
}
}
}
}
}
return (List)result;
}
}
...
}
通过这个方法,不论是vipAddress
还是appName
都可以获取实例集合。但是Spring Cloud
在做服务发现的抽象时,为了考虑其他服务发现组件如ZooKeeper
等的整合,其规定的接口中并没有设定这个方法。所以在Spring Cloud
体系下,我们只能使用vipAddress
作为获取实例的关键字。
可能这就是框架封装带来的代价吧,隐去了原本底层更丰富的接口功能。希望Spring Cloud
可以不断的做更优化的改进。