Spring Cloud笔记(二)

一. 前言

​ 上一部分对Spring Boot跟Spring Cloud有了基本的认识,同时也添加了Eureka注册中心,使得最基本的服务注册,服务发现都可以实现。但这肯定不够,所以这里继续补充关于Spring Cloud的知识。

二. 添加Ribbon实现负载均衡

关键点:当Client可以用Eureka Server定位到多个服务列表,应该选择哪一个?比如这里的例子,我们启动两个Provider,注册到Eureka Server上。此时Consumer去调用Provider(不再直接调用某一IP,端口,而是符合的服务列表,在这里只要是Provider服务即可)。此时我们需要实现负载均衡,避免某一个Provider服务过载,导致出错。

步骤:

先引入依赖,Ribbon。据说引入了spring-cloud-starter-netflix-eureka-client就可以,因为client已经包含了Ribbon,但我前面不需要引入client也可以实现,所以这里直接引用Ribbon,应该引用Client也行

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>

(单独取其一即可)

然后给RestTemplate添加一个@LoadBalanced注解,表明该RestTemplate要使用负载均衡算法(这里当然就是直接默认的负载均衡算法,具体的参数配置后面再说)值得一提的是,@LoadBalanced只能用于setter方法处,所以是不能在构造方法上使用该注解的。

然后改了一下Controller,主要是增加了一个LoadBalancerClient,还有RestTemplate的获取地址不再是硬耦合IP+端口,而是具体的服务名即可。同时还增加了一个log-user-instance,但这个主要是用于记录日志,方便观察到底是哪个Provider被调用了,所以这个并不是关键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
public class MovieController {
private static final Logger LOGGER = LoggerFactory.getLogger(MovieController.class);

@Autowired
private RestTemplate restTemplate;

private DiscoveryClient discoveryClient;

private LoadBalancerClient loadBalancerClient;

@Bean
@LoadBalanced
public RestTemplate getRestTemplate() {
return new RestTemplate();
}

@Autowired
public MovieController(DiscoveryClient discoveryClient, LoadBalancerClient lbc) {
this.discoveryClient = discoveryClient;
this.loadBalancerClient = lbc;
}
}

注意,RestTemplate不能改成构造注入,否则加不了@LoadBalanced。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/user/{id}")
public User findById(@PathVariable Integer id) {
return restTemplate.getForObject("http://microservice-provider-user/") + id, User.class);
}

@GetMapping("/user-instance")
public List<ServiceInstance> showInfo() {
return discoveryClient.getInstances("microservice-simple-provider-user");
}

@GetMapping("/log-user-instance")
public void logUserInstance() {
ServiceInstance serviceInstance = loadBalancerClient.choose("microservice-simple-provider-user");
MovieController.LOGGER.info("{}:{}:{}", serviceInstance.getServiceId(),
serviceInstance.getHost(), serviceInstance.getPort());
}

getForObject里原本是直接写上了IP+端口的,成为了具体的服务名即可。而user-instance是上一章的遗留问题,当时没能查看到这个网页,原来是忘记写GetMapping了。最后一个log-user-instance就是拿来记录日志的,不用管。

然后测试,先启动Eureka,再启动两个Provider(先启动一个,修改yml里的端口号再启动一个。这样就相当于通过两个端口启动了两个Provider。当然这种做法有点蠢,如何在不修改配置的情况下启动多个?https://blog.csdn.net/forezp/article/details/76408139

最后启动Consumer,这时候调用localhost:8010/user-instance等等的都是可行的,但在调用localhost:8010/user/1 的时候出现了问题,表示找不到该hostname:

I/O error on GET request for “http://user-service/hi": user-service; nested exception is

java.net.UnknownHostException: microservice-provider-user

显然,就是调用/user/{id}的时候,下面的getForObject找不到那个host。找了很多解决方法,比如是不是hostname写错了,但其实这个逻辑并不通。因为user-instance都可行了,说明确实就是这个hostname。然后网上说要导入client包,导了也不行。接着又想是不是不能同时使用构造注入跟设值注入?发现也不是,看我上面的代码,就是同时使用两种注入方式。

最后的解决方法是一个我很早就看到了的错误,需要加入@LoadBalanced注解。这个参数我当然知道有何作用,而且我第一时间就加了,错在哪?因为我最后才发现,我在Application启动类就已经注入了RestTemplate,然后我改Ribbon的时候是在Controller里再注入一次RestTemplate,然后@LoadBalanced等等都是在Controller里进行的。所以,由于先启动的肯定是Application启动类,这时候就已经进行了@RestTemplate的注入操作了,后面在Controller里的其实已经是废设,自然那个@LoadBalanced也是没有任何效果的。于是我把Controller里的RestTemplate去掉了注入,在Application启动类里的注入增加了@LoadBalanced,就成功了。然后再测试了一下把Application启动类的注入彻底去掉,在Controller里重新加回注入跟@LoadBalanced,也成功。

负载的效果可能不是很明显,因为我测试的方式比较stupid,就是一次一次调用,看是不是轮流调用两个Provider,确实如此。当然肯定不是严格按照1:1的,在数据很大的时候应该会有小许偏差,但总体上比例就会是在1:1左右。

chapter5剩下的内容:Ribbon负载均衡的配置,包括Java类配置和属性配置(yml)两种方式。其中Java类需要注意不能放在@ComponentScan所能scan的包,不然就公用配置了。一般来说yml更简洁更方便。还有脱离Eureka使用Ribbon的方法(其实就是client包改成了ribbon,然后yml里增加一下ribbon的属性参数)。最后还有一个Lazy加载跟Hungry加载(即只要项目启动就加载,避免第一次调用时响应过慢)这些内容看起来都不难,而且现在不关键,就先不敲代码尝试了.

三. 使用Feign实现声明式的REST调用

使用Feign实现声明式的REST调用,并且配置REST的部分参数。

使用RestTemplate虽然已经可以满足所有功能,但比较繁琐(url高度耦合到每一个Mapping处,比如要写一遍Service名,多个参数的时候url会更加的臃肿)

使用Feign的好处:让编写Web Service客户端更加简单,因为它支持Spring MVC注解,JAX-RS注解,整合了Eureka,Ribbon。也就是说,只要使用Feign注解来替代,那么就能在实现Spring MVC的同时,也自动实现负载均衡。

使用步骤:

①添加依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>

②创建一个Feign接口,添加@FeignClient(这个FeignClient,用于注入到Controller中,替代RestTemplate进行Restful API的映射设计,以及自动实现负载均衡)

1
2
3
4
5
6
@FeignClient(name = "microservice-simple-provider-user",
configuration = {FeignConfiguration.class, FooConfiguration.class})
public interface UserFeignClient {
@RequestLine("GET /{id}")
public User findById(@Param("id") Integer id);
}

③注入到Controller中:(值得注意的是,由于是接口对象,那么就没办法实现一个setter去注入了,会报错但其实是可行的,因为是通过注解自动生成的FeignClient对象)

1
2
@Autowired
private UserFeignClient userFeignClient;

④最后就是调用:url都已经被封装到接口里了,使得URL更加的简洁,同时后面多参数的时候会更加明显。

1
2
3
4
@GetMapping("/user/{id}")
public User findById(@PathVariable Integer id) {
turn userFeignClient.findById(id);
}

关于配置,只需要定义一些Feign的配置类,然后在接口的@FeignClient的configuration属性指明即可。

(PS:配置类最好不写@Configuration注解,如果写了,那么不要放在@ComponentScan所扫描的包中,否则会成为全局配置,被所有@FeignClient共享,除非你确实想要成为全局配置。但是由于@FeignClient的configuration属性就可以直接指明,一般没有必要写这个注解)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class FeignConfiguration {
@Bean
public Contract feignContract() {
return new feign.Contract.Default();
}
}

public class FooConfiguration {
@Bean
public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
return new BasicAuthRequestInterceptor("user", "password");
}
}

上面的两个配置类都已经被添加到UserFeignClient中,第一个把contract改成了feign的原生默认契约,使得可以使用feign自带的注解(如@RequestLine),第二个相当于为Feign添加拦截器,这样当一些接口需要进行给予Http Basic认证才能调用,就需要上面的认证信息。

同时上面还有Spring cloud官网文档上写的Feign的配置属性,可以学习。

除了使用Java类配置,用yml配置也是可以的,跟Ribbon一样,用yml配置更加的简洁。

1
2
3
4
5
6
7
8
feign:
client:
config:
feignName: # 改成default就是全局配置
connectTimeout: 5000
readTimeout: 5000
loggerLevel: full
decode404: false

果不其然。。目前还没看出Feign的好处,因为我们这里只是相当于用feign替代了RestTemplate的Restful设计,然后还能增加一些超时,日志的配置而已,关键还是要自定义feign,可以彻底增强Restful API的操作,比如相同的API,不同角色的用户会发生不同的行为。

话说中间在Console看到了一个错误:Batch update failure with HTTP status code 401; discarding 1 replication tasks,显然就是注册服务到Eukera Server失败,网上的解决方法是在Server处的yml配置还是要在defaultZone增加那个user,password,增加了之后就可以了。

四. 手动创建Feign

前面对Consumer使用了Feign,感觉也只是替代了RestTemplate的Restful设计,稍微加了一些日志配置,意义不大。但如果是手动创建,将能完成更强的逻辑。

接下来对Provider服务手动创建Feign。

首先,导入依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

之前对于Security包,我只在Eureka Server处添加了该依赖包,但在其他地方添加感觉没有必要,不仅需要同样的用户密码,而且如果密码不同,还可能导致无法注册到Eureka的情况。所以这次需要对Provider额外再增加一个security,感觉会在认证这里出现问题,事实也是如此的。但主要问题在于对Security的配置不熟悉,知道了哪些属性起到什么作用就好。

首先我们需要给Provider的Security添加配置(其实之前Eureka的Security也有配置,不过只是禁用了一下CSRF:

之前的:

1
2
3
4
5
6
7
8
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
super.configure(http);
}
}

(可以看出来主要是注解@EnableWebSecurity。然后配置的导入,要么@Configuration,要么在启动类里Import(xxx.class),都是一样的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// all request need HTTP Basic auth
http.authorizeRequests().anyRequest().authenticated().and().
formLogin().and().httpBasic();
}

@Bean
public PasswordEncoder passwordEncoder() {
// A password encoder that does nothing Useful for testing with plain text
return getInstance();
}

@Autowired
private CustomUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

@Component
class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
if ("user".equals(username))
return new SecurityUser("user", "password1", "user-role");
else if ("admin".equals(username))
return new SecurityUser("admin", "password2", "admin-role");
else
return null;
}
}
}

关于这里的配置类,具体说一下。configure方法,这个其实就是登录功能。不要看起来很复杂,其实and就是起连接作用。然后authorizeRequests就是要验证request的意思,anyRequest就是每一个请求都要验证,没有例外。authenticated:

1
2
3
4
public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry
authenticated() {
return this.access("authenticated");
}

大概也就是通过验证的意思,其他的还有denyAll,rememberMe等等的功能。formLogin就是以一个登录界面显示,而不是弹窗。最后就是表明要基于HTTP Basic来验证。

至于passwordEncoder方法,这是一个明文编码器,因为涉及密码的时候都是要加密的,但这里只是测试,所以就用一个不作任何操作的密码编辑器就好。

验证的过程需要我们根据用户账号和密码,如果通过了就返回一个UserDetails的对象,这是一个接口,所以我们需要手动创建一个实现类,重写相关方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class SecurityUser implements UserDetails {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String password;
private String role;

public SecurityUser(String username, String password, String role) {
super();
this.username = username;
this.password = password;
this.role = role;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
SimpleGratedAuthority authority = new SimpleGrantedAuthority(role);
authorities.add(authority);
return authorities;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

(下面还有一些setter,getter就省略了。。虽然我中途有一个错误就是写错了getter,当时自动生成的getPassword方法竟然返回的是null,而不是password,使得我一直密码登录不通过。最后还是一步一步调试才发现是getter出现了问题)

其实关键的方法也没什么,getAuthorities主要是对role的配置而已,因为这是需要返回Collections对象,所以看起来不太一样,其实就是一个getter。但下面这4个方法其实挺关键的,看方法名就大概知道是什么作用,是否锁定账号,是否允许登录。IDEA默认生成的这4个方法是返回false的,这会导致:即使账号密码一致,也会一直卡在登录的界面。

接着的内部类CustomUserDetailsService就是注入一个自定义的Bean。这里就是登录验证的地方,首先获取登录界面的user参数(loadUserByUsername),然后根据账号密码去进行匹配,生成一个UserDetails的对象并返回。如果这里出错(即没有预设的Username),那么就会返回一个null,登录直接失败。当我们通过了验证,返回一个UserDetails之后,这时候我们会再调用第二个configure方法:

1
2
3
4
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(this.userDtailsService).passwordEncoder(passwordEncoder());
}

在这里,就是将登录界面的密码与UserDetails进行匹配。如果密码一致,则登录成功。否则会显示:Not granted any authorities(我就是因为getter写错了导致一直显示Not granted any authorities)

最后一切成功,Provider成功注册到Eureka,没有因为多增加一个Security而导致无法注册。同时Provider服务本身也增加了认证机制,使得可以根据不同的账号密码(一般应该是根据用户名,获取role,然后做出不同的工作,但这里只是为了演示Feign的根据情况做出不同处理的作用,所以只是print了一下role。)

两个Security的配置信息关键在于:

Eureka Server的配置无须做出改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
application:
name: microservice-discovery-eureka-ha
security:
user:
name: hong
password: root
eureka:
client:
defaultZone: http://localhost:8761/eureka/
register-with-eureka: false
fetch-registry: false
server:
port: 8761

而对于Provider服务,为了注册到Eureka的认证,还是注入Bean的方法比较好(其实yml好像也是一样的,但不要在defaultZone里乱写):

1
2
3
4
5
6
7
8
9
@Bean
public DiscoveryClientOptionalArgs discoveryClientOptionalArgs() {
DiscoveryClientOptionalArgs discoveryClientOptionalArgs =
new DiscoveryClientOptionalArgs();
List<ClientFilter> additionalFilters = new ArrayList<>();
additionalFilters.add(new HTTPBasicAuthFilter("hong", "root"));
discoveryClientOptionalArgs.setAdditionalFilters(additionalFilters);
return discoveryClientOptionalArgs;
}

然后Provider服务的自身认证机制,就直接写在自己的Security配置类里就好:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
if ("user".equals(username))
return new SecurityUser("user", "password1", "user-role");
else if ("admin".equals(username))
return new SecurityUser("admin", "password2", "admin-role");
else
return null;
}
}

给consumer service也使用自定义Feign Client。

改成了使用FeignClient的默认配置,然后创建了两个Feign Client(一个UserClient,一个AdminClient),绑定到同一个service(Provider),但使用不同的账号去访问(User/Admin),于是也会得到不同的效果,在Provider控制台也会显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Autowired
public MoviewController(DiscoveryClient discoveryClient, Decoder decoder,
LoadBalancerClient loadBalancerClient, Client client) {
this.discoveryClient = discoveryClient;
this.loadBalancerClient = loadBalancerClient;
userUserFeignClient = Feign.builder().client(client).encoder(getEncoder()).
decoder(decoder).requestInterceptor(new BasicAuthRequestInterceptor("user",
"password1")).target(UserFeignClient.class,
"http://microservice-simple-provider-user");
adminUserFeignClient = Feign.builder().client(client).encoder(getEncoder()).
decoder(decoder).requestInterceptor(new BasicAuthRequestInterceptor("admin",
"password2")).target(UserFeignClient.class,
"http://microservice-simple-provider-user");
}

@GetMapping("/user-user/{id}")
public User findByIdUser(@PathVariable String id) {
return userUserFeignClient.findById(id);
}

@GetMapping("admin-user/{id}")
public User findByIdAdmin(@PathVariable String id) {
return adminUserFeignClient.findById(id);
}

关于decoder,encoder这部分纠结了好久,最后甚至用到了阿里巴巴的JSONObject包自实现了一个(当然,是上网cv下来的,但搞依赖包也烦了一小会)。可是最后才发现,这部分就没意义,反正都能映射到provider服务就好了,然后在provider的控制台也是会把结果打印出来的。所以关键在于:

1
requestInterceptor(new BasicAuthRequestInterceptor("xxx", "xxxx")).target(xx.class, "x");

requestInterceptor传入 一个BasicAuthRequestInterceptor,表示账号密码,target就是目标服务。

PS:中途还把id改成了String,其实没什么影响,不过懒得改回去了,万物皆可String

之后的日志配置跟多参数,我感觉一开始是头脑清醒的,结果中间就不知道做什么了。

首先,前面是要先把自定义的FeignClient给注释掉的,然后再在controller里定义了两个,直接在controller里调用相应的FeignClient。结果我改成多参数的时候,首先要先把那个原本的FeignClient弄回来,其实那时候是注释掉了三个地方,结果我只改回了两个,然后启动类的注释忘记去掉了,于是一直在莫名其喵。最后还是还原成之前的代码,然后对比了一下才发现忘记一个注释了,所以前面做的很多就是无用功。

本章节剩下的部分,主要是多参数Feign跟上传文件。其实我觉得还蛮重要的,但我认为作者这里偷懒了,它的代码根本就不能实现,只是画个饼。更关键的是,由于版本的原因,它的代码存在1.x的代码(与2.x不兼容),而我导入2.x的包却没有相关的jar包,搞得我还得上网找一个自实现的Decoder。然后也尝试就这个包换成1.x,发现更恐怖了,整个项目不兼容。总而言之,这部分真的只能搁置。但好歹,最后总算解决了那个不能识别注解的问题。网上都说是Feign默认注解有问题,那个@RequestLine不行。我怀疑他们用的都是1.x。而2.x恰恰相反,就是要使用这个默认注解,然后使用@RequestLine而不是@RequestMapping。总而言之,烦了一整天这个问题。但我觉得主要还是,一开始没有耐心地考虑问题,主要一开始我用@RequestLine确实不可行,于是我也想着改。但其实我当时出错的原因不是这个,而是其他。。所以,一开始对错误的范围认知有误,导致后面也一直错,确定问题的范围真的很关键!不过还好,最后虽然无法生成User,但还是成功将多参数传到了Provider服务。说明Feign多参数的传递还是可行的,而且多次的尝试也让我对整个微服务的架构有了更多的认识(Provider跟Consumer真的是很关键的两个角色)。

-------------本文结束感谢您的阅读-------------