上一篇中,我们构建了一个简单的Spring Cloud Demo项目,涵盖了服务注册/发现,服务间的相互调用,以及熔断降级等内容。但如果服务需要暴露给外部进行使用,比如移动端,或者web端,则还需要考虑更多的事情。整个服务端的部署情况对于外部调用方应该是一个黑盒,外部调用方无法了解到每个服务具体是部署到哪一个IP或者域名下面,为了安全性也不太可能允许外部调用方直接连接到Consul去查询服务注册的情况,这样我们就需要一个服务网关来集中对外部请求进行路由和负载均衡,同时验证调用方的权限和身份。如下图所示:
基础介绍
服务网关的概念有点类似于传统的反向代理服务器(如nginx),但反向代理一般都只是做业务无关的转发请求,而服务网关与服务的整合程度更高,可以看作也是整个服务体系的组成部分,通过过滤器等组件可以在网关中集成一些业务处理的操作(比如权限认证等)。Spring Cloud Gateway正是Spring官方推出的服务网关的实现框架,它主要包含三个核心的概念:
- Route: 负责将某个外部请求路由到一个合适的地址,包含一个ID,一个目标地址,一系列的Predicate和Filter;
- Predicate: 基于Java 8 Function Predicate的断言机制,用于将请求匹配到某一个Route
- Filter: 类似于Servlet filter,可以在请求传递给下一级处理器之前对请求或响应进行修改,用于实现权限验证,日志记录,限流等功能
整个工作流程如下图所示:
网关集成
我们现在来为我们的demo项目加入一个服务网关。首先需要创建一个新的模块,名字叫Gateway,在pom.xml中加入如下依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
在application.yml中加入如下内容:
server:
port: 9000
spring:
application:
name: gateway
cloud:
consul:
host: 192.168.1.220
port: 8500
discovery:
prefer-ip-address: true
gateway:
routes:
- id: order-service
#lb协议会激活LoadBalancerClient来解析后续的地址,自动根据注册的服务实例进行负载均衡
uri: lb://order-service
filters:
- Log
# 转发时去掉请求地址的服务名前缀
- StripPrefix=1
predicates:
- Path=/order-service/**
从以上配置可以很容易看出来,gateway模块其实也会注册到consul中成为一个服务,并通过consul获取其它服务的相关信息。上面的配置中我们加入了一个名为order-service的路由,其中predicates定义了这个路由的匹配规则,也就是访问路径以/order-service/开头的请求,就会被路由到 lb://order-service的地址 (地址代表的含义参见注释)。
断言
predicates用于定义route的匹配规则,可以针对请求的几乎所有内容进行匹配,例如针对特定的header进行匹配:
predicates:
- Header=X-Request-Id, \d+**
针对Cookie进行匹配:
predicates:
- Cookie=mycookie,mycookievalue
匹配特定域名的请求
predicates:
- Host=**.somehost.org,**.anotherhost.org
更多predicates种类的介绍可以查看 这里
过滤器
刚才的路由配置中,我们定义了两个过滤器: Log,StripPrefix,这些都属于GatewayFilter,每个Route可以定义多个GatewayFilter。Spring Cloud Gateway已经内置了多个很有用的GatewayFilter实现,例如StripPrefix就是内置的用于转发时修改请求地址的过滤器。其它内置过滤器的作用可以查看 这里。如果内置过滤器不能满足我们的需求,那就需要自行实现新的过滤器了。
我们现在来添加一个简单的过滤器日志过滤器,用于打印出每次请求所花费的时间:
@Slf4j
public class LogGatewayFilterFactory extends AbstractGatewayFilterFactory<LogGatewayFilterFactory.Config> {
private static final String REQUEST_START_TIME = "request_start_time";
public LogGatewayFilterFactory() {
// 这里需要将自定义的config传过去,否则会报告ClassCastException
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
exchange.getAttributes().put(REQUEST_START_TIME, System.currentTimeMillis());
return chain.filter(exchange).then(
Mono.fromRunnable(() -> {
Long startTime = exchange.getAttribute(REQUEST_START_TIME);
if (startTime != null) {
log.info("请求地址:{},消耗时间:{}ms", exchange.getRequest().getURI(), System.currentTimeMillis() - startTime);
}
})
);
};
}
public static class Config {
}
}
自定义过滤器需要实现一个新的GatewayFilterFactory,其类名也需要遵循XXXGatewayFilterFactory的规则,这样的话在配置中只需要配置“XXX”的部分就可以正常被识别了,例如 LogGatewayFilterFactory就只需要配置成“Log”就行了。代码中的内部类Config是用于接收配置时传递的参数(类似于Log=true),这里不需要参数所以只是一个空类。需要注意的是Spring Cloud Gateway是使用 Spring WebFlux 来构建的,所以filter这里的写法是基于Reactor异步模式的,和传统的同步请求模式(如Spring MVC)不太一样。
定义了新的过滤器之后需要将其注册到容器:
@Bean
public LogGatewayFilterFactory logGatewayFilterFactory() {
return new LogGatewayFilterFactory();
}
GatewayFilter都是基于Route进行配置的,Spring Cloud Filter还定义了一种GlobalFilter,不需要在配置文件中配置,作用在所有的路由上。GlobalFilter同样支持自定义新的过滤器,只需要实现GlobalFilter和Ordered接口即可,详细情况我们后面在讲到权限的时候再介绍。
权限管理
服务网关的一大作用就是可以对外部的请求进行集中权限认证,这样每个具体的服务就不用操心权限管理的问题了,可以专心于业务的实现。基本的思路是外部客户端首先需要获取一个由系统中独立的认证中心负责签发的accessToken,然后每次请求服务时在http header中携带该Token,服务网关负责校验accessToken的有效性以及是否具备访问该服务的权限,具体的思路和我之前介绍单系统权限管理的思路比较类似,可以查看 Spring Boot整合Shiro和JWT的无状态权限管理方案 这篇文章。
我们首先需要在服务网关中定义一个GlobalFilter对所有的外部请求进行过滤,代码如下:
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private AuthService authService;
private AuthConfigProperties authConfig;
public AuthGlobalFilter(AuthConfigProperties authConfig, AuthService authService) {
this.authConfig = authConfig;
this.authService = authService;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String reqPath = exchange.getRequest().getURI().getPath();
String token = exchange.getRequest().getHeaders().getFirst(authConfig.getHeaderKeyOfToken());
if (!authService.verifyToken(reqPath, token)) {
log.warn("没有授权的访问,{}", reqPath);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
//获取token中存储的用户唯一标识,并放入request header中,供后端业务服务使用
String account = authService.getAccountByToken(token);
ServerHttpRequest request = exchange.getRequest().mutate()
.header(authConfig.getHeaderKeyOfAccount(), account).build();
return chain.filter(exchange.mutate().request(request).build());
}
/**
* 过滤器的优先级,越低越高
*/
@Override
public int getOrder() {
return 1;
}
}
功能很简单,就是对请求头部的token进行校验,如果成功就将从token中解析出来的用户账户信息放入转发的请求头中供后端的业务服务使用,否则返回UNAUTHORIZED。这个Filter也需要注册到容器中:
@Bean
public AuthGlobalFilter authGlobalFilter(AuthService authService) {
return new AuthGlobalFilter(authConfig, authService);
}
对token进行校验的核心逻辑在authService.verifyToken方法中,代码如下:
/**
* 验证token的有效性及是否具备对该url的访问权限,
* 判定规则参考了shiro的一些设定
*/
public boolean verifyToken(String url, String token) {
if (Strings.isNullOrEmpty(token)) {
return false;
}
//获取每个Url所对应的权限控制符
String urlPermission = getUrlPermission(url);
if ("anno".equals(urlPermission)) {
return true;
} else {
//获取token中包含的用户唯一标识
String account = jwtHelper.getAccount(token);
if (Strings.isNullOrEmpty(account)) {
return false;
}
//获取token的加密密钥
String secret = getUserSecret(account);
//校验accessToken
if (jwtHelper.verify(token, secret) == null) {
return false;
}
// 如果url仅要求验证用户有效性,则直接通过
if (Strings.isNullOrEmpty(urlPermission) ||
"authc".equals(urlPermission)) {
return true;
}
// 进一步判断用户权限
if (urlPermission.startsWith("perms")) {
Set<String> userPerms = this.getUserPermissions(account);
String perms = urlPermission.substring(urlPermission.indexOf("[") + 1, urlPermission.lastIndexOf("]"));
return userPerms.containsAll(Arrays.asList(perms.split(",")));
}
}
return false;
}
服务网关首先需要知道不同的服务地址需要什么样的权限才允许访问,这里采用了类似Shiro配置的格式,类似这样如下的格式,实际环境中可能是从数据库或配置文件中读取:
/**
* 获取所有的接口url与用户权限的映射关系,格式仿造了shiro的权限配置格式
*/
public Map<String, String> getAllUrlPermissionsMap() {
Map<String, String> urlPermissionsMap = Maps.newHashMap();
urlPermissionsMap.put("/api/order/orders", "authc");
urlPermissionsMap.put("/api/order/create-order", "perms[order]");
urlPermissionsMap.put("/api/storage/**", "perms[storage]");
return urlPermissionsMap;
}
通过Spring 提供的工具类AntPathMatcher,就可以查询到每个请求url所需要的权限标识符,再根据权限标识符去检查token对应的用户是否具备相应的权限。对这部分感兴趣的同学可以去查看源码。
本文的相关代码可以查看这里 spring-cloud-demo