首先我们来看一下,微服务架构下关于配置文件的一些问题:
基于上面这些问题,我们就需要"配置中心"的加入来解决这些问题。
当加入了服务配置中心之后,我们的系统架构图会变成下面这样:
Nacos可以当统一的配置管理器服务。
当微服务部署的实例越来越多时,逐个修改微服务配置就会效率低下,而且很容易出错,所以我们需要一种统一配置管理方案,可以集中管理所有实例的配置。
而Naocs除了可以做注册中心,同样也可以做配置管理来使用:
如何在nacos中管理配置呢?
微服务要拉取nacos中管理的共享配置,并且将拉取到的共享配置与本地的application.yml配置合并,才能完成项目上下文的初始化,完成项目启动:
因此Spring引入了一种新的配置文件:bootstrap.yml文件(或者bootstrap.properties的文件),会在application.yml之前被读取,如果我们将Nacos地址配置到bootstrap.yaml中,那么在项目引导阶段就可以读取Nacos中的配置了,流程如下:
1. 引入nacos-config依赖:引入Nacos的配置管理依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
2. 在resource目录中添加一个bootstrap.yml文件,这个文件是引导文件,它的优先级高于 application.yml
配的就是Data ID:
spring:
application:
name: userservice # 配置服务名称
profiles:
active: dev #开发环境,这里是dev
cloud:
nacos:
server-addr: localhost:8848 # 配置Nacos地址
config:
file-extension: yaml # 文件后缀名
3. 读取nacos配置
在user-service中的UserController中添加业务逻辑,读取pattern.dateformat配置:
针对于本例用来格式化时间要实现配置热更新,可以使用两种方式:需要通过下面两种配置实现
方式一:在@Value注入的变量所在类上添加注解@RefreshScope
方式二:使用@ConfigurationProperties注解代替@Value注解
当nacos、服务本地同时出现相同属性时,多种配置的优先级有高低之分:
OpenFeign是一个声明式的HTTP客户端,是Spring Cloud在Eureka公司开源的Feign基础上改造而来,官方地址:https://github.com/OpenFeign/feign
其作用就是基于SpringMVC的常见注解,帮我们优雅的实现http请求的发送~!
OpenFeign已经被Spring Cloud自动装配,实现起来非常简答:
org.springframework.cloud
spring-cloud-starter-openfeign
org.springframework.cloud
spring-cloud-starter-loadbalancer
旧版本中用的才是Ribbon,新版本中用的都是loadbalancer。
- Spring Cloud 2020.0.0之前的版本使用的是spring-cloud-netflix-ribbon,一开始都是使用Ribbon作为负载均衡组件的,不过现在的Spring Cloud 2020.0.0 及后续新版本已经弃用Ribbon了,而是使用Spring Cloud Load Balancer模块作为负载均衡组件,用来替代Ribbon,不过这个也不是独立的模块,而是spring-cloud-commons中的一个子模块。
Spring Cloud LoadBalancer提供了自己负载均衡的抽象接口ReactiveLoadBalancer,并且提供了两种策略实现:
- RoundRobinLoadBalancer(轮循)
- RandomLoadBalancer(随机)
目前相比Ribbon来说负载均衡策略还是比较简单的。
2. 在启动类通过添加@EnableFeignClients注解,启用OpenFeign功能
在cart-service
中,定义一个新的接口,编写Feign客户端:
其中代码如下:
package com.hmall.cart.client;
import com.hmall.cart.domain.dto.ItemDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List queryItemByIds(@RequestParam("ids") Collection ids);
}
这里只需要声明接口,无需实现方法。接口中的几个关键信息:
Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:
HttpURLConnection:默认实现,不支持连接池 => 每一次都需要重新创建连接,因此效率极低
Apache HttpClient :支持连接池
OKHttp:支持连接池
引入依赖:
io.github.openfeign
feign-okhttp
开启连接池:
在application.yml
配置文件中开启Feign的连接池功能feign:
okhttp:
enabled: true # 开启OKHttp功能
重启服务,连接池就生效了。
所谓最近实践,就是使用过程中总结的经验,最好的一种使用方式。
OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:
NONE:不记录任何日志信息,这是默认值。
BASIC(推荐):仅记录请求的方法,URL以及响应状态码和执行时间
HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。
要自定义日志级别需要声明一个类型为Logger.Level的Bean,在其中定义日志级别:
也可以基于Java代码来修改日志级别,先声明一个类,然后声明一个Logger.Level的对象
public class DefaultFeignConfiguration {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.BASIC; // 日志级别为BASIC
}
}
如果要全局生效,将其放到启动类的@EnableFeignClients这个注解中:
@EnableFeignClients(defaultConfiguration = DefaultFeignConfiguration .class)
如果是局部生效,则把它放到对应的@FeignClient这个注解中:
@FeignClient(value = "userservice", configuration = DefaultFeignConfiguration .class)
由于每个微服务都有不同的地址或端口,入口不同,在与前端做联调时会发现:
单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息,而微服务拆分后,每个微服务都独立部署,这就存在一些问题:
可以通过API网关技术来解决上述问题。
网关的核心功能特性
网关就是网络的关口,是指系统的统一入口,它封装了应用程序的内部结构,为客户端提供统一服务,一些与业务本身功能无关的公共逻辑可以在这里实现,负责前端请求的路由 => 服务路由(告诉你在几楼几号这叫做路由)、转发(你找不着带你过去这叫做转发)以及用户登录时的身份校验(身份认证和权限校验,做过滤拦截)(检查你户口本)。
数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验。
网关是所有微服务的统一入口
更通俗的来讲,网关就像是以前园区传达室的大爷。
外面的人要想进入园区,必须经过大爷的认可,如果你是不怀好意的人,肯定被直接拦截。
外面的人要传话或送信,要找大爷。大爷帮你带给目标人。
现在,服务网关就起到同样的作用,前端请求不能直接访问微服务,而是要请求网关:
如果微服务有做集群,网关还要进行负载均衡:
org.springframework.cloud
spring-cloud-starter-gateway
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
org.springframework.cloud
spring-cloud-starter-loadbalancer
${project.artifactId}
org.springframework.boot
spring-boot-maven-plugin
2. 编写启动类SpringBootApplication
3. 编写基础配置和路由规则(配置路由规则:spring cloud getway routes)
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
gateway:
routes: # 网关路由配置
- id: user-service # 路由规则id,自定义(一般情况下与微服务名称保持一致),只要唯一即可
# uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址(不推荐)
uri: lb://userservice # 路由的目标地址(路由目标微服务) lb就是负载均衡,后面跟服务名称
predicates: # 路由断言,判断请求是否符合规则,符合规则才路由到目标也就是判断请求是否符合路由规则的条件
- Path=/user/** # 这个是按照路径匹配,只要以/user/开头就符合要求
4. 启动网关服务进行测试
读取并解析用户配置定义的断言规则
例如Path=/user/**是按照路径匹配,这个规则是由
"org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory"类来处理的
Spring提供了12种基本的RoutePredicateFactory实现:
名称 | 说明 | 示例 |
---|---|---|
After | 是某个时间点后的请求 | - After=2037-01-20T17:42:47.789-07:00[America/Denver] |
Before | 是某个时间点之前的请求 | - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai] |
Between | 是某两个时间点之前的请求 | - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver] |
Cookie | 请求必须包含某些cookie | - Cookie=chocolate, ch.p |
Header | 请求必须包含某些header | - Header=X-Request-Id, \d+ |
Host | 请求必须是访问某个host(域名) | - Host=.somehost.org,.anotherhost.org |
Method | 请求方式必须是指定方式 | - Method=GET,POST |
Path | 请求路径必须符合指定规则 | - Path=/red/{segment},/blue/** |
Query | 请求参数必须包含指定参数 | - Query=name, Jack或者- Query=name |
RemoteAddr | 请求者的ip必须是指定范围(对IP地址做限制) | - RemoteAddr=192.168.1.1/24 |
Weight | 权重处理 |
我们只需要掌握Path这种路由工程就可以了。
既然网关是所有微服务的入口,一切请求都需要先经过网关,我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就都解决了:
此时,登录校验的流程图:(将用户信息向后传递)
如图所示:
客户端请求进入网关后由HandlerMapping对请求做判断,HandlerMapping找到与当前请求匹配的路由规则(Route
)并存入上下文,然后将请求交给请求处理器WebHandler去处理。
WebHandler则会加载网关中配置生效的多个过滤器,加载当前路由下需要执行的过滤器链(Filter Chain),放入到集合并排序,形成过滤器链,然后按照顺序逐一执行这些过滤器(后面称为Filter)。
图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行 => pre顺序执行,post倒序执行。
只有在所有Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务(如果过滤器的pre逻辑执行失败,则会直接结束,不会再往下去执行了)。
微服务返回结果后,再倒序执行Filter的post逻辑。
最终把响应结果返回。
上图我们得知:最终请求转发是由一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个,而我们需要在请求转发之前去做用户的身份校验,如果能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器的执行顺序定义到NettyRoutingFilter之前,这就符合我们的需求了!
该如何实现一个网关过滤器呢?
这两种过滤器的方法签名完全一致:
/**
* 处理请求并将其传递给下一个过滤器
* @param exchange 当前请求的上下文,其中包含request、response等各种数据
* @param chain 过滤器链,基于它向下传递请求
* @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
*/
Mono filter(ServerWebExchange exchange, GatewayFilterChain chain);
Mono是一个回调函数,回调函数里面的逻辑就是post里面的逻辑了,在实际开发中基本上不会写post。
网关完成JWT登录校验后获取到登录用户的身份信息之后,网关还需要将请求转发到下游的微服务,微服务又该如何获取用户身份呢?
因此,接下来我们要做两件事情:
步骤一:在网关的登录校验过滤器中,把获取到的用户信息保存写入到下游请求的请求头中。
需要用到ServerWebExchange类提供的API,示例如下:
exchange.mutate() // mutate就是对下游请求做更改 添加请求头的名字和请求头的值
.request(builder -> builder.header("user-info", userInfo))
.build(); // 返回新的exchange
第二步:定义一个common模块编写Spring MVC拦截器,获取登录用户信息
提示:获取到用户信息后需要保存到ThreadLocal,对应的工具类在common中已经定义好了:
package com.hmall.common.interceptor;
import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 自定义Spring MVC拦截器
*/
public class UserInfoInterceptor implements HandlerInterceptor {
/**
* 该方法是在Controller之前执行
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的用户信息(请求头里只能存字符串,所以返回值类型为String)
String userInfo = request.getHeader("user-info");
// 2.判断是否为空
if (StrUtil.isNotBlank(userInfo)) {
// 不为空,保存到ThreadLocal
UserContext.setUser(Long.valueOf(userInfo));
}
// 3.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 业务执行完成后完成用户的清理:移除用户
UserContext.removeUser();
}
}
Spring MVC的拦截器要想生效,还需要编写Spring MVC的配置类:
package com.hmall.common.config;
import com.hmall.common.interceptor.UserInfoInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ConditionalOnClass(DispatcherServlet.class) // 仅对Spring MVC生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截器注册器
registry.addInterceptor(new UserInfoInterceptor());
}
}
不过,需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。
基于SpringBoot的自动装配原理,我们要将其添加到resources目录下的META-INF/spring.factories文件中:
将配置类的全类名添加到该文件即可。
使用OpenFeign在服务之间传递用户信息
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户的信息。
但是有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务(业务链比较长),而这个过程中也需要传递登录用户的信息,比如下单业务,流程如下:
下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
public interface RequestInterceptor {
/**
* Called for every request.
* Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}
我们只需要实现这个接口,然后调用apply()方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中,这样一来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。
其中的RequestTemplate类中提供了一些方法可以让我们修改请求头:
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 获取登录用户
Long userId = UserContext.getUser();
if(userId == null) { // 防止NPE
// 如果为空则直接跳过
return;
}
// 如果不为空则放入请求头中,传递给下游微服务
template.header("user-info", userId.toString());
}
};
}
好了,现在微服务之间通过OpenFeign调用时也会传递登录用户信息了。
微服务远程调用
微服务注册、发现
微服务请求路由、负载均衡
微服务登录用户信息传递
GaetwayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理:
Spring提供了33种不同的路由过滤器(工厂),每种路由过滤器都有独特的作用,例如:
名称 | 说明 |
---|---|
AddRequestHeaderGatewayFilterFactory | 添加请求头的过滤器,给当前请求添加一个请求头(key-value形式)并传递到下游微服务 |
RemoveRequestHeader | 移除请求中的一个请求头 |
AddResponseHeader | 给响应结果中添加一个响应头 |
RemoveResponseHeader | 从响应结果中移除有一个响应头 |
RequestRateLimiter | 限制请求的流量 |
......
Gateway内置的GatewayFilter过滤器使用起来非常简单,只需在服务的application.yml文件当中来简单配置即可,并且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route。
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://userservice
predicates:
- Path=/user/**
default-filters: # 默认过滤项,default-filters下的过滤器可以作用于所有路由
- AddRequestHeader=Truth, Itcast is freaking awesome!
public interface GlobalFilter {
/**
* 处理当前请求,有必要的话通过{@link GatewayFilterChain}将请求交给下一个过滤器处理
*
* @param exchange 当前请求的上下文,里面可以获取Request、Response等信息
* @param chain 过滤器链,基于它向下传递请求,用来把请求委托给下一个过滤器
* @return {@code Mono} 根据返回值标记当前请求是否被完成或拦截,标记过滤器业务结束 chain.filter(exchange)放行
*/
Mono filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
在filter中编写自定义逻辑,可以实现下列功能:
拦截器是在Servlet之后Controller之前,而过滤器是在Servlet之前。
package cn.itcast.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 自定义全局过滤器
* 过滤器的顺序除了可以通过注解来指定,还可以通过Ordered接口来指定 => 责任链模式
*/
@Order(-1) // 该注解是一个顺序注解,设置过滤器先后顺序的,这个值越小,优先级越高
@Component
public class AuthorizationFilter implements GlobalFilter, Ordered {
/**
* 自定义全局过滤器,拦截请求并判断用户身份(登录认证过滤器)
*
* @param exchange 请求上下文,里面可以获取Request,Response等信息
* @param chain 用来把请求委托给下一个过滤器
* @return 返回标识当前过滤器业务结束
*/
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// TODO 模拟登录校验逻辑
// 1.获取request请求参数 .var自动补全左边
// MultiValueMap queryParams = exchange.getRequest().getQueryParams();
ServerHttpRequest request = exchange.getRequest();
MultiValueMap queryParams = request.getQueryParams();
// 2.获取参数中的authorization参数
String authorization = queryParams.getFirst("authorization");
// 3.判断参数值是否等于admin
if ("admin".equals(authorization)) {
// 4.是,放行 只有这一个API
return chain.filter(exchange);
} else {
// 5.否,拦截
// 5.1 禁止访问,设置响应状态码:HttpStatus是一个枚举类 403:服务端拒绝访问
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
// 5.2 结束处理
return exchange.getResponse().setComplete();
}
}
/**
* 设置过滤器的优先级或执行顺序
*
* @return 该方法的返回值越小, 优先级越高
*/
@Override
public int getOrder() {
return -1;
}
}
实现GlobalFilter接口
添加注解@Order注解或者实现Ordered接口
编写处理逻辑
过滤器一定要有顺序~!
请求进入网关会碰到三类过滤器:当前路由的过滤器(局部路由过滤器)、DefaultFilter(全局路由过滤器)、GlobalFilter
请求路由后,会将当前路由过滤器和DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器:
GetwayFilterAdapter:过滤器适配器 => 适配器模式
在网关当中,所有的GlobalFilter都可以被适配成GetwayFilter,从这个角度来讲,我们可以认为网关中的所有过滤器最终都是GetwayFilter类型,既然是同一种类型,那我们当然可以扔到同一种集合中去做排序,放到同一个过滤器链中,排序以后依次执行。
getFilter()方法是加载路由过滤器和defaultFilter,handle()方法就是去加载GlobalFilter并且对GlobalFilter去做装饰,把它变成GetwayFilter,最后把所有过滤器合并做排序的:
跨域问题:浏览器禁止请求的发起者与微服务发生跨域Ajax请求,请求被浏览器拦截的问题。
解决方案:CORS
网关处理跨域问题采用的同样是CORS方案(CORS是浏览器去询问服务器你让不让跨域,它有一次询问,这个询问的请求方式是options,默认情况下这种请求方式是会被网关拦截的,所以要改为true,就是让网关不拦截options类型的请求,这样CORS的询问请求就会被正常发出了),只需要在网关Getway服务的application.yml文件当中简单配置即可实现:
spring:
cloud:
gateway:
# 。。。
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
corsConfigurations:
'[/**]': #直接拦截哪些请求
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:8090"
allowedMethods: # 允许的跨域ajax的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期,减少每一次对服务器的Ajax请求而造成的访问压力
跨域的CORS方案对性能会有一定的损耗,为了减少损耗,我们可以给跨域请求设置有效期,有效期范围内,浏览器将不再发起询问,而是直接放行,从而提高性能。