浅析eureka.instance.appname与eureka服务获取机制

在使用Spring Cloud全家桶的时候,遇到了一个不大不小的坑,可能还是中文教学中对配置的解析不够到位所致,因此对eureka.instance.appname配置理解不正确。本文浅析一下该配置的意义和Eureka服务获取机制

问题描述

学习的时候都知道,spring.application.nameeureka.instance.appname都可以设定注册在Eureka中的服务名,但是若用eureka.instance.appname设定而不设置spring.application.name,从Eureka服务器中却无法获取服务信息。不管是用RibbonFeign还是DiscoveryClient对象都不能正确获取。经过测试和源码分析,找到了问题所在,下面一步步解析。

spring.application.nameeureka.instance.appname的区别

之前只知道,这两个都属性都可以设定服务名,我们也会在Eureka的仪表盘上看到这个名字。在旧版本的Eureka显示以spring.application.name为优先,在新版本的Eureka显示以eureka.instance.appname为优先。但是两者的区别到底在哪里呢,优先级的变化又是如何出现的呢?

org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean 配置类

该类定义了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配置,若其存在就赋给了appnamevirtualHostNamesecureVirtualHostName三个属性。因此在旧版本中,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配置属性对appnamevirtualHostNamesecureVirtualHostName三个属性覆盖赋值操作。但是这里有个加载顺序的问题,EnvironmentAware接口的方法执行会在Spring Boot的配置属性赋值之前进行,因此当我们配置文件中设置eureka.instance.appname后会再次覆盖回来,这就是为什么新版本中Eureka仪表盘会显示eureka.instance.appname设定的名字。

Eureka客户端的注册行为

我们先看到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将作为显示在仪表盘上的名字(BuildersetAppName方法会自动将名字转为全大写)。

那么不管是什么方式,只要Eureka记录的服务名应该就可以用于实例信息获取,是哪里出现问题导致仅设置eureka.instance.appname无法获取实例呢?

Eureka 的服务获取机制

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设定的名字,instanceIDeureka.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 Eureka Client 中的实例获取接口DiscoveryClient

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会给三个属性appnamevirtualHostNamesecureVirtualHostName填值,但是现在版本的Eureka这个操作被前置了,也就是我们单独设定的appname会将值再次更新,这就造成了实际appnamevirtualHostName两项属性不匹配的问题,因此就无法从appname配置的服务名获取实例了。

正确的做法有两种:

  • 坚决使用spring.application.name,不配置eureka.instance.appname,一劳永逸
  • eureka.instance.appnameeureka.instance.virtual-host-name都进行配置,设定为相同的值,就可以用负载均衡器获取实例了。

额外的话

实际上原版NetflixEureka中的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可以不断的做更优化的改进。

你可能感兴趣的:(Spring,Cloud)