计划
- 实现统一的用户鉴权、统一的接口调用次数统计(把 API 网关应用到项目中)
业务逻辑:
- 用户发送请求到 API 网关
- 请求日志
- (黑白名单)
- 用户鉴权(判断 ak、sk 是否合法)
- 请求的模拟接口是否存在?
- 请求转发,调用模拟接口
- 响应日志
- 调用成功,接口调用次数 + 1
- 调用失败,返回一个规范的错误码
开发
请求转发,调用模拟接口(配置式)
用什么规则呢?可以使用前缀匹配断言
假设我们提供的接口地址都是以: http://localhost:8123/api/ 开头,并且都有一个共同的路径前缀 /api。我们可以配置一个前缀匹配路由器,使得所有路径前缀为 /api/** 的请求都被匹配到,并且进行转发到对应的路径 http://localhost:8123/api/**。
Spring Cloud Gateway 官方文档,有很多的断言,有一个叫 前缀匹配断言
它的作用是可以匹配前缀,并且通过使用 {},可以获取动态的参数。通过 segment 参数拿到用户请求的地址。
gateway项目中的 applicaiton.yml
server:
port: 8090
spring:
cloud:
gateway:
routes:
# 定义了一个名为"api_route"的路由规则,该规则将匹配以"/api/"开头的路径,例如"/api/user",
# 并将这些请求转发到"http://localhost:8123"这个目标地址
- id: api_route
uri: http://localhost:8123/
predicates:
- Path=/api/**
访问 http://localhost:8090/api/name/get?name=sujie8090, 实际上调用的localhost: 8123
现在转发就已经实现了
添加全局过滤器(编程式)
转发已经完成了,我们要添加一些业务逻辑,要使用全局过滤器。
官方文档: 全局过滤器
全局过滤器示例代码
public class CustomGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("custom global filter");
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -1;
}
}
Ordered 表示一个执行顺序。Spring Cloud Gateway 在请求转发到真实接口之前会经过多个过滤器。通过 Ordered 就可以编排这些过滤器的优先级,确定哪个过滤器先拦截,哪个后拦截。
示例代码实现一个叫 filter 的方法,这里还有一个日志,我们所有的业务逻辑都可以写在这个 filter 里。 验证它有没有生效,重启项目。请求接口
控制台也显示设置的日志信息,说明全局过滤器能拦截到请求。
编写其他业务逻辑
1. 请求日志
请求日志我们怎么拿到呢?怎么拿到请求信息的?
一般来说,先拿到 request 对象,再从 request 里拿到它的请求地址、请求参数、请求头之类的。
Spring Cloud Gateway 里也可以这么做,示例代码给我们提供了两个参数。
- exchange(路由交换机):我们所有的请求的信息、响应的信息、响应体、请求体都能从这里拿到。
- chain(责任链模式):因为我们的所有过滤器是按照从上到下的顺序依次执行,形成了一个链条。所以这里用了一个
chain
,如果当前过滤器对请求进行了过滤后发现可以放行,就要调用责任链中的next
方法,相当于直接找到下一个过滤器,这里称为filter
。
request 能拿到什么:
Cookies、Path(请求路径)、QueryParams(请求参数)、RemoteAddress(远程调用地址)、SslInfo(SSL信息)、Body(请求体)、Headers(请求头)等等。
// 1. 请求日志
ServerHttpRequest request = exchange.getRequest();
log.info("请求唯一标识 :" + request.getId());
log.info("请求路径 :" + request.getPath().value());
log.info("请求方法 :" + request.getMethod());
log.info("请求参数 :" + request.getQueryParams());
log.info("请求来源地址 :" + request.getRemoteAddress());
访问 127.0.0.1:8090/api/name/get?name=sujie,返回响应结果
信息都拿到了,我们的请求日志算打上了
2. 黑白名单
例如,如果某个远程地址频繁访问,我们可以将其添加到黑名单并拒绝访问。
写一个全局的常量。在这里我们用一个白名单,通常建议在权限管理中尽量使用白名单,少用黑名单。白名单的原则是只允许特定的调用,这样可能会更加安全,或者你可以默认情况下全禁止。
//设置一个规则,如果请求的来源地址不是 127.0.0.1,就拒绝它的访问。
private static final List<String> IP_WHITE_LIST = Arrays.asList("127.0.0.1");
怎样进行拒绝呢?
通过刚刚用到的 exchange,获取到响应对象,从而控制该响应,直接设置状态码为 403(禁止访问),然后拦截掉。
// 访问控制:黑白名单
// 拿到响应对象
ServerHttpResponse response = exchange.getResponse();
if (!IP_WHITE_LIST.contains(hostString)) {
// 设置响应状态码为 403 FORBIDDEN 禁止访问
response.setStatusCode(HttpStatus.FORBIDDEN);
// 返回处理完成的响应
return response.setComplete();
}
3. 用户鉴权
首先从请求头中获取参数
判断随机数要大于一万
设置时间和当前时间不能超过 5 分钟
判断 secretKey 是否合法,这一步我们需要生成一个签名并将其与预期的签名进行比对
// 3. 用户鉴权
HttpHeaders headers = request.getHeaders();
// 从请求头中获取参数
String accessKey = headers.getFirst("accessKey");
String nonce = headers.getFirst("nonce");
String timestamp = headers.getFirst("timestamp");
String sign = headers.getFirst("sign");
String body = headers.getFirst("body");
// todo 实际情况应该是去数据库中查是否已分配给用户
if (!"sujie".equals(accessKey)) {
return handleNoAuth(response);
}
// 直接校验如果随机数大于1万,则抛出异常,并提示"无权限"
if (Long.parseLong(nonce) > 10000) {
return handleNoAuth(response);
}
// 首先,获取当前时间的时间戳,以秒为单位
// System.currentTimeMillis()返回当前时间的毫秒数,除以1000后得到当前时间的秒数。
long currentTime = System.currentTimeMillis() / 1000;
// 定义一个常量FIVE_MINUTES,表示五分钟的时间间隔(乘以60,将分钟转换为秒,得到五分钟的时间间隔)。
final long FIVE_MINUTES = 60 * 5L;
// 判断当前时间与传入的时间戳是否相差五分钟或以上
// Long.parseLong(timestamp)将传入的时间戳转换成长整型
// 然后计算当前时间与传入时间戳之间的差值(以秒为单位),如果差值大于等于五分钟,则返回true,否则返回false
if ((currentTime - Long.parseLong(timestamp)) >= FIVE_MINUTES) {
// 如果时间戳与当前时间相差五分钟或以上,调用handleNoAuth(response)方法进行处理
return handleNoAuth(response);
}
// todo 实际情况中是从数据库中查出 secretKey
String serverSign = SignUtils.genSign(body, "abcdefgh");
// 如果生成的签名不一致,则抛出异常,并提示"无权限"
if (!sign.equals(serverSign)) {
return handleNoAuth(response);
}
4 判断请求的模拟接口是否存在
用户鉴权搞定,接下来判断请求的模拟接口是否存在
模拟接口的地址信息是存储在 backend 项目的数据库内,需要从数据库中查询是否有符合要求的接口。
建议像这种业务层面的请求参数最好不要放到全局网关中处理,而是在业务层面自己处理。
因为在我们的项目中,并没有引入操作数据库的依赖,如 MyBatis 等。之前在 backend 项目中引入了这些依赖,因此在网关中再引入的话,会造成重复。
建议是,如果我们已经有现成的访问数据库的方法,或者有可以操作数据库的现成接口,如果那个方法比较复杂,建议使用远程调用的方式调用那个可以操作数据库的项目提供的接口,这样会更方便。
有好几种方法,其中包括 HTTP 请求和 RPC。对于 HTTP 请求,可以自己编写客户端,使用一些常见的库比如 HTTPClient、RestTemplate 或者 Feign。而对于 RPC,也有多种实现方式,例如 Java 中可以使用 Dubbo 框架。
先打个 todo
5. 剩余的业务逻辑
在接口调用成功后,我们需要对接口次数进行累加的操作要将这个项目的方法暴露出来,这样在网关里就可以直接调用它,先留下todo
接下来写请求转发,调用模拟接口,就是 chain.filter 执行这个操作。
6. 后端调用
sdk 项目把 8123(接口) 改成 8090(网关), 重新打包,回到 backend 项目刷新依赖
启动 backend 项目、前端项目
调用成功,可以拿到请求头
7.自定义响应处理
问题:
预期是等模拟接口调用完成,才记录响应日志、统计调用次数。
但现实是 chain.filter 方法立刻返回了,直到 filter 过滤器 return 后才调用了模拟接口。
为了解决这个问题,Spring Cloud Gateway 提供了一个自定义响应处理的装饰器。
ServerHttpRequest 请求对象里,它可以定义装饰器
装饰者设计模式的作用是在原本的类的基础上对其能力进行增强。
这样哪怕它是异步的,当最后执行这个方法时,装饰器也能做额外的事情。
gateway 项目单独写个方法
/**
* 处理响应
*
* @param exchange
* @param chain
* @return
*/
public Mono<Void> handleResponse(ServerWebExchange exchange, GatewayFilterChain chain) {
try {
// 获取原始的响应对象
ServerHttpResponse originalResponse = exchange.getResponse();
// 获取数据缓冲工厂
DataBufferFactory bufferFactory = originalResponse.bufferFactory();
// 获取响应的状态码
HttpStatus statusCode = originalResponse.getStatusCode();
// 判断状态码是否为200 OK(按道理来说,现在没有调用,是拿不到响应码的,对这个保持怀疑 沉思.jpg)
if(statusCode == HttpStatus.OK) {
// 创建一个装饰后的响应对象(开始穿装备,增强能力)
ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
// 重写writeWith方法,用于处理响应体的数据
// 这段方法就是只要当我们的模拟接口调用完成之后,等它返回结果,
// 就会调用writeWith方法,我们就能根据响应结果做一些自己的处理
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
log.info("body instanceof Flux: {}", (body instanceof Flux));
// 判断响应体是否是Flux类型
if (body instanceof Flux) {
Flux<? extends DataBuffer> fluxBody = Flux.from(body);
// 返回一个处理后的响应体
// (这里就理解为它在拼接字符串,它把缓冲区的数据取出来,一点一点拼接好)
return super.writeWith(fluxBody.map(dataBuffer -> {
// 读取响应体的内容并转换为字节数组
byte[] content = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(content);
DataBufferUtils.release(dataBuffer);//释放掉内存
// 构建日志
StringBuilder sb2 = new StringBuilder(200);
sb2.append("<--- {} {} \n");
List<Object> rspArgs = new ArrayList<>();
rspArgs.add(originalResponse.getStatusCode());
//rspArgs.add(requestUrl);
String data = new String(content, StandardCharsets.UTF_8);//data
sb2.append(data);
log.info(sb2.toString(), rspArgs.toArray());//log.info("<-- {} {}\n", originalResponse.getStatusCode(), data);
// 将处理后的内容重新包装成DataBuffer并返回
return bufferFactory.wrap(content);
}));
} else {
log.error("<--- {} 响应code异常", getStatusCode());
}
return super.writeWith(body);
}
};
// 对于200 OK的请求,将装饰后的响应对象传递给下一个过滤器链,并继续处理(设置repsonse对象为装饰过的)
return chain.filter(exchange.mutate().response(decoratedResponse).build());
}
// 对于非200 OK的请求,直接返回,进行降级处理
return chain.filter(exchange);
}catch (Exception e){
// 处理异常情况,记录错误日志
log.error("gateway log exception.\n" + e);
return chain.filter(exchange);
}
}
我们把要对响应之后的操作,全写到这个方法里。