基于SpringCloud实现微服务体系搭建全系列教程
1 - Eureka
注册中心Eureka全知道
1.1 - 1.Eureka注册中心初体验
在SpringCloud微服务体系中,有几个比较重要的组件,如注册中心,配置中心,网关,安全、负载均衡、监控等等,接下来我们将来看一下这些常用的组件有什么用,在微服务架构下的该怎么用。
本文为为第一篇,注册中心Eureka的使用说明
I. 基本介绍
1. 注册中心
注册中心,主要的核心点是服务的注册与发现。
简单来讲,就是我们的所有服务都会在注册中心上标识自己,注册中心统一管理所有的服务名与具体的应用之间的映射关系,这样微服务之间的访问,就可以直接通过服务名来相互通信,相比较于直接通过ip端口的访问,这样的好处是当某个服务下线、新增或者换了机器,对调用者而言,只要维持一份注册中心的最新映射表即可,不需要其他任何改动逻辑。
我们通常可用的注册中心有 Eureka
, Consul
, Zookeeper
, nacos
等,在我们后续的教程中会逐一进行介绍
Eureka2.x 闭源,1.x虽然可用,但新项目的话不建议再使用它,比如
Consul
,nacos
都是不错的选择如果出于学习的目的,或者由于历史原因(比如我),学习了解一下Eureka知识点也没什么坏处
2. Eureka
Eureka是Netflix开源的服务发现组件,本身是一个基于REST的服务,通常包含Server和Client端
原理如下图
- server: 提供服务注册,并在服务注册表中存储所有可用服务节点的信息
- client: 简化与Server之间的交互,比如封装了发送心跳,获取注册信息表等基本操作
II. 实例演示
1. 版本说明
后续的演示项目中,我们的环境与版本信息如下
- 开发环境: IDEA + maven
- SpringBoot:
2.2.1.RELEASE
- SpringCloud:
Hoxton.M2
2. Eureka Server端
Eureka区分了Server和Client两端,即我们有一个独立的注册中心服务,其他的微服务则作为Client端
Server端核心依赖如下
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
然后在配置文件中,添加一些基本信息
server:
port: 8081 #服务注册中心端口号
eureka:
instance:
hostname: 127.0.0.1 #服务注册中心IP地址
client:
registerWithEureka: false #是否向服务注册中心注册自己
fetchRegistry: false #是否检索服务
serviceUrl: #服务注册中心的配置内容,指定服务注册中心的位置
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
请注意,上面的registerWithEureka
这个配置,设置为false,不像自己注册服务(后续会介绍多个Eureka实例时,可以如何配置)
然后再启动类上,添加注解@EnableEurekaServer
来申明Eureka服务
@EnableEurekaServer
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
到此,一个Eureka服务端已经完成,此时我们可以直接访问http://localhost:8081
,会看到一个自带的控制台,会提供一些基本信息
3. Eureka 客户端
我们这里设计两个客户端,一个提供服务,另外一个调用,演示一下Eureka的基本功能
a. 客户端 eureka-service-provider
客户端需要在pom文件中,添加下面的关键依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
接下来需要在配置文件中,指定注册中心的地址,以及服务名(请注意,这个服务名是重要线索,后面会用到!!!)
server:
port: 8082 #服务端口号
eureka:
client:
serviceUrl: #注册中心的注册地址
defaultZone: http://127.0.0.1:8081/eureka/
spring:
application:
name: eureka-service-provider #服务名称--调用的时候根据名称来调用该服务的方法
同样的需要在启动类上,通过@EnableEurekaClient
来标注客户端
@EnableEurekaClient
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
我们在这个项目中,写一个基本REST接口,供后面的服务进行调用
@RestController
@RequestMapping(path = "userService")
public class UserService {
@Override
@RequestMapping(path = "/getUserById")
public UserDTO getUserById(@RequestParam long userId) {
UserDTO userDTO = new UserDTO();
userDTO.setUserId(userId);
userDTO.setNickname("一灰灰blog");
userDTO.setUserName("yihuihuiblog");
userDTO.setPhone(88888888L);
return userDTO;
}
}
再看一下上面的实现,你会发现和平时写的Controller没有任何的区别
到这里第一个Eureka客户端已经完成,并提供了一个REST接口,接下来我们开始写第二个Eureka客户端,用来访问上面的REST服务
b. 客户端 eureka-service-consumer
基本的流程和上面没有任何区别,只是将配置文件稍微改一下
server:
port: 8083 #服务端口号
eureka:
client:
serviceUrl: #注册中心的注册地址
defaultZone: http://127.0.0.1:8081/eureka/
spring:
application:
name: eureka-service-consumer #服务名称--调用的时候根据名称来调用该服务的方法
那么在这个服务中,如何访问 eureka-service-provider
提供的服务呢?
- 通过
RestTemplate
来实现
请注意,这个RestTemplate和我们普通的new RestTemplate()
创建的不一样哦,我们是通过如下方式获取实例
@Bean
@LoadBalanced
public RestTemplate rest() {
return new RestTemplate();
}
重点关注方法上的@LoadBalanced
注解,这个会在后续的Ribbon的章节中深入介绍,在这里只需要知道通过它生成的RestTemplate
,在发起访问时,会借助Eureka的注册信息表,将服务名翻译为对应的ip+端口号
接下来就是我们的访问环节,写法如下
@Autowired
private RestTemplate restTemplate;
@GetMapping(path = "uid")
public String getUser(int userId) {
UserDTO dto = restTemplate
.getForObject("http://eureka-service-provider/userService/getUserById?userId=" + userId, UserDTO.class);
return userId + "'s info: " + dto;
}
请着重看一下访问的url: "http://eureka-service-provider/userService/getUserById?userId=" + userId
,这里没有域名,没有ip,是直接通过服务名进行访问的
4. 测试与小结
我们依次将上面的Server和两个Client启动,然后访问http://localhost:8081
,查看Eureka控制台,可以看到如下界面,两个客户端都已经注册好了
然后再测试一下通过consumer访问provider的服务
到此Eureka的核心功能已经演示完毕,当然如果仅仅只是这样,这个学习成本好像很低了,作为一个有思考的小青年,看了上面的流程自然会有几个疑问
- 安全问题
- 注册中心控制台直接访问,这要是暴露出去了…
- 一个Eureka实例,单点故障怎么解
- 服务注册多久生效?服务下线多久会从注册信息表中摘除?服务存活判断是怎样的?
- 通过
RestTemplate
方式使用,很不优雅啊,有没有类似rmi的通过类调用的方式呢?
II. 其他
0. 项目
2 - Feign
基于Feign实现微服务访问
2.1 - 1.Feign请求参数包装异常问题定位
通过Feign包装rpc的调用姿势,在使用的版本中发现一个奇怪的bug,大部分场景下请求正常,少数情况下请求返回400,记录下原因
场景复现
1. 环境相关版本
Spring版本如
<spring.boot.version>2.0.1.RELEASE</spring.boot.version>
<spring.cloud.version>Finchley.RELEASE</spring.cloud.version>
Feign版本
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
对应的feign-core版本为
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<version>9.5.1</version>
2. 服务接口
接口形如
@RequestMapping(value = "getMarketDailySummary")
BaseRsp<MarketDailySummaryDTO> getMarketDailySummary(@RequestParam("datetime") Long datetime,
@RequestParam(value = "coinIds") List<Integer> coinIds,
@RequestParam(value = "pairIds") List<Integer> pairIds);
使用时报400的case
marketDailyReportService.getMarketDailySummary(1551836411000L, Arrays.asList(1, 2, 3, 10), Arrays.asList());
简单来说,接口参数为集合的情况下,如果传一个空集合,那么这就会出现400的错误
通过在提供服务的应用中,写一个fitler拦截请求,打印出请求参数
@Component
@WebFilter(value = "/**")
public class ReqFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
try {
System.out.println(servletRequest.getParameterMap());
} finally {
filterChain.doFilter(servletRequest, servletResponse);
}
}
@Override
public void destroy() {
}
}
然后发起rpc调用前面的测试用例,通过断点查看请求参数,确实只有两个参数,而我们传入空pairIds集合,直接被吃掉了
再对应到我们的api声明方式,要求三个参数,因此问题就很清晰了,解决办法就是在api中参数的必填设置为false即可
@RequestMapping(value = "getMarketDailySummary")
BaseRsp<MarketDailySummaryDTO> getMarketDailySummary(@RequestParam("datetime") Long datetime,
@RequestParam(value = "coinIds", required = false) List<Integer> coinIds,
@RequestParam(value = "pairIds", required = false) List<Integer> pairIds);
上面只是表层的解决了问题,接下来就需要确定,为什么请求参数会被吃掉,通过浅显的推测,多半原因在feign的请求参数封装上了
2. 问题定位
对于容易复现的问题,最佳的定位方法就是debug了,直接单步进去,找到对应的请求参数封装逻辑,
第一步定位到RequestTemplate
的创建
// feign.SynchronousMethodHandler#invoke
@Override
public Object invoke(Object[] argv) throws Throwable {
// 下面这一行为目标逻辑,创建请求模板类,请求参数封装肯定是在里面了
RequestTemplate template = buildTemplateFromArgs.create(argv);
Retryer retryer = this.retryer.clone();
while (true) {
try {
return executeAndDecode(template);
} catch (RetryableException e) {
retryer.continueOrPropagate(e);
if (logLevel != Logger.Level.NONE) {
logger.logRetry(metadata.configKey(), logLevel);
}
continue;
}
}
}
接下来深入进去之后,参数解析的位置
// feign.ReflectiveFeign.BuildTemplateByResolvingArgs#resolve
protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable,
Map<String, Object> variables) {
// Resolving which variable names are already encoded using their indices
Map<String, Boolean> variableToEncoded = new LinkedHashMap<String, Boolean>();
for (Entry<Integer, Boolean> entry : metadata.indexToEncoded().entrySet()) {
Collection<String> names = metadata.indexToName().get(entry.getKey());
for (String name : names) {
variableToEncoded.put(name, entry.getValue());
}
}
// 核心逻辑了,使用请求参数来替换模板中的占位
return mutable.resolve(variables, variableToEncoded);
}
}
再进去一步就到了根源点
// feign.RequestTemplate#replaceQueryValues(java.util.Map<java.lang.String,?>, java.util.Map<java.lang.String,java.lang.Boolean>)
void replaceQueryValues(Map<String, ?> unencoded, Map<String, Boolean> alreadyEncoded) {
Iterator<Entry<String, Collection<String>>> iterator = queries.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Collection<String>> entry = iterator.next();
if (entry.getValue() == null) {
continue;
}
Collection<String> values = new ArrayList<String>();
for (String value : entry.getValue()) {
if (value.indexOf('{') == 0 && value.indexOf('}') == value.length() - 1) {
Object variableValue = unencoded.get(value.substring(1, value.length() - 1));
// only add non-null expressions
if (variableValue == null) {
// 如果请求参数为null,也不会凭借到url参数中
continue;
}
if (variableValue instanceof Iterable) {
// 将目标集中在这里,如果请求参数时空集合,下面的for循环不会走到,所以也就不会拼接在url参数中
for (Object val : Iterable.class.cast(variableValue)) {
String encodedValue = encodeValueIfNotEncoded(entry.getKey(), val, alreadyEncoded);
values.add(encodedValue);
}
} else {
String encodedValue = encodeValueIfNotEncoded(entry.getKey(), variableValue, alreadyEncoded);
values.add(encodedValue);
}
} else {
values.add(value);
}
}
if (values.isEmpty()) {
iterator.remove();
} else {
entry.setValue(values);
}
}
}
下图是我们最终定位的一个截图,从代码实现来看,feign的设计理念是,如果请求参数为null,空集合,则不会将参数拼接到最终的请求参数中,也就导致最终发起请求时,少了一个参数
问题清晰之后,然后就可以确认下是bug还是就是这么设计的了,最简单的办法就是看最新的代码有没有改掉了,从git上,目前已经更新到10.x;10.x与9.x的差别挺大,底层很多东西重写了,然而官方的Spring-Cloud-openfeing
并没有升级到最新,so,只能取看9.7.0版本的实现了,和9.5.2并没有太大的区别;
so,站在feign开发者角度出发,这么设计的理由可能有以下几点
- 既然允许传入空集合、null参数,那么在api的声明时,就有必要加上
require=False
- 对于这种无效的请求参数,也没有太大的必要传过去(虽然从使用者角度来说,你就应该老老实实的把我调用的参数都丢过去)
3. 小结
最后小结一下,使用feign作为SpringCloud的rpc封装工具时,请注意,
- 如果api的请求参数允许为null,请在注解中显示声明;
- 此外请求方传入的null、空集合最终不会拼装的请求参数中,即对于接受者而言,就像没有这个参数一样,对于出现400错误的场景,可以考虑下是否是这种问题导致的
- 对于复杂的请求参数,推荐使用DTO来替代多参数的类型(因为这样接口的复用性是最佳的,如新增和修改条件时,往往不需要新增api)