Spring Cloud Alibaba-Gateway之路由、限流、熔断、日志、鉴权(3)
在微服务架构中,网关的职责包括路由、鉴权、限流、日志、监控、灰度发布等,目前主流的方案有Neflix Zuul和Spring Cloud Gateway。
一、路由
2 创建项目
创建一个SpringBoot项目,添加Cloud Gateway和Nacos相关依赖,不要添加Web依赖。完成后的pom如下:
<properties> <java.version>1.8</java.version> <spring-cloud.version>Greenwich.SR1</spring-cloud.version> <alibaba.version>0.9.0.RELEASE</alibaba.version> </properties> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>${alibaba.version}</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>${alibaba.version}</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <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>
application.yml
server: port: 8084
bootstrap.yml
spring: application: name: gateway cloud: nacos: config: server-addr: 127.0.0.1:8848 discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: enabled: true routes: - id: payment-router uri: lb://payment-service predicates: - Path=/pay/**
在上面的配置中:
id: payment-router 值随意,方便记忆并且在所有路由定义中唯一即可
uri: lb://payment-service lb://为固定写法,表示开启负载均衡;payment-service即服务在Nacos中注册的名字
predicates:- Path=/pay/** 使用"Path Route Predicate Factory",规则为/pay开头的任意URI
最后一步,在启动类上增加@EnableDiscoveryClient注解
@SpringBootApplication @EnableDiscoveryClient public class GatewayApplication {
除了Path Route Predicate Factory,Gateway还支持多种设置方式:
类型 | 示例 |
After | After=2017-01-20T17:42:47.789-07:00[America/Denver] |
Before | Before=2017-01-20T17:42:47.789-07:00[America/Denver] |
Between | 2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver] |
Cookie | Cookie=chocolate, ch.p |
Header | Header=X-Request-Id, \d+ |
Host | Host=**.somehost.org |
Method | Method=GET |
Path | Path=/foo/{segment} |
Query | Query=baz |
RemoteAddr | RemoteAddr=192.168.1.1/24 |
二、限流
Gateway通过内置的RequestRateLimiter过滤器实现限流,使用令牌桶算法,借助Redis保存中间数据。用户可通过自定义KeyResolver设置限流维度,例如:
对请求的目标URL进行限流
对来源IP进行限流
特定用户进行限流
本例针对来源IP限流。
添加Redis依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>
在application.yml中添加Redis配置:
server: port: 8084 spring: redis: host: 127.0.0.1 port: 6379
SpringBoot自动配置的RedisTemplate生成的key中会包含特殊字符,所以创建一个RedisTemplate替换
@Configuration public class RedisConfiguration { @Bean("redisTemplate") public RedisTemplate redisTemplate(@Value("${spring.redis.host}") String host, @Value("${spring.redis.port}") int port) { RedisTemplate redisTemplate = new RedisTemplate(); RedisSerializer stringRedisSerializer = new StringRedisSerializer(); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); redisTemplate.setKeySerializer(stringRedisSerializer); redisTemplate.setHashKeySerializer(stringRedisSerializer); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setConnectionFactory(standaloneConnectionFactory(host, port)); return redisTemplate; } protected JedisConnectionFactory standaloneConnectionFactory(String host, int port) { RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); redisStandaloneConfiguration.setHostName(host); redisStandaloneConfiguration.setPort(port); return new JedisConnectionFactory(redisStandaloneConfiguration); } }
自定义KeyResolver
@Configuration public class RateLimiterConfiguration { @Bean(value = "ipKeyResolver") public KeyResolver ipKeyResolver() { return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()); } }
最后一步,在bootstrap.yml的payment-router路由中加入限流过滤器
... routes: - id: payment-router uri: lb://payment-service predicates: - Path=/pay/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 1 redis-rate-limiter.burstCapacity: 5 key-resolver: '#{@ipKeyResolver}'
其中令牌桶容量redis-rate-limiter.burstCapacity设置为5,即1秒内最大请求通行数为5个,令牌桶填充速率redis-rate-limiter.replenishRate设置为1。
三、熔断
网关是所有请求的入口,如果部分后端服务延时严重,则可能导致大量请求堆积在网关上,拖垮网关进而瘫痪整个系统。这就需要对响应慢的服务做超时快速失败处理,即熔断。
添加hystrix依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> </dependency>
在bootstrap.yml中添加默认过滤器
spring: ... cloud: ... gateway: discovery: locator: enabled: true default-filters: - name: Hystrix args: name : default fallbackUri: 'forward:/defaultFallback' ... hystrix: command: default: execution: isolation: strategy: SEMAPHORE thread: timeoutInMilliseconds: 2000
创建降级处理FallbackController.java
@RestController public class FallbackController { @RequestMapping("/defaultFallback") public Map defaultFallback() { Map map = new HashMap<>(); map.put("code", 1); map.put("message", "服务异常"); return map; } }
在Nacos后台中把payment-service-dev.properties的sleep值修改为2000模拟服务延时效果,然后测试
四、日志
在引入网关后,通常会把每个服务都要做的工作,诸如日志、安全验证等转移到网关处理以减少重复开发。
1 加入log4j2
这里使用log4j2作为日志组件,首先添加log4j2的依赖并排除SpringBoot默认日志组件的依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> </exclusions> </dependency>
在resources目录下创建log4j2-spring.xml
<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN" monitorInterval="1800"> <properties> <property name="LOG_HOME">D:/Logs/gateway</property> <property name="REQUEST_FILE_NAME">request</property> <property name="INFO_FILE_NAME">info</property> </properties> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" /> </Console> <RollingRandomAccessFile name="info-log" fileName="${LOG_HOME}/${INFO_FILE_NAME}.log" filePattern="${LOG_HOME}/$${date:yyyy-MM}/${INFO_FILE_NAME}-%d{yyyy-MM-dd}-%i.log"> <PatternLayout pattern="%date{yyyy-MM-dd HH:mm:ss.SSS} %level [%thread][%file:%line] - %msg%n"/> <Policies> <TimeBasedTriggeringPolicy/> <SizeBasedTriggeringPolicy size="100 MB"/> </Policies> <DefaultRolloverStrategy max="100"/> </RollingRandomAccessFile> <RollingRandomAccessFile name="request-log" fileName="${LOG_HOME}/${REQUEST_FILE_NAME}.log" filePattern="${LOG_HOME}/$${date:yyyy-MM}/${REQUEST_FILE_NAME}-%d{yyyy-MM-dd}-%i.log"> <PatternLayout pattern="%date{yyyy-MM-dd HH:mm:ss.SSS} %level [%thread][%file:%line] - %msg%n"/> <Policies> <TimeBasedTriggeringPolicy/> <SizeBasedTriggeringPolicy size="200 MB"/> </Policies> <DefaultRolloverStrategy max="200"/> </RollingRandomAccessFile> </Appenders> <Loggers> <Root level="info"> <AppenderRef ref="info-log" /> </Root> <Logger name="request" level="info" additivity="false"> <AppenderRef ref="request-log"/> </Logger> <Logger name="org.springframework"> <AppenderRef ref="Console" /> </Logger> </Loggers> </Configuration>
在application.yml中增加配置告知log4j2文件路径
logging: config: classpath:log4j2-spring.xml
2 获取POST的Body
记录日志时通常关注请求URI、Method、QueryString、POST请求的Body、响应信息和来源IP等。对于Spring Cloud Gateway这其中的POST请求的Body获取比较复杂,这里添加一个全局过滤器预先获取并存入请求的Attributes中。
CachePostBodyFilter
@Component public class CachePostBodyFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest serverHttpRequest = exchange.getRequest(); String method = serverHttpRequest.getMethodValue(); if("POST".equalsIgnoreCase(method)) { ServerRequest serverRequest = new DefaultServerRequest(exchange); Mono<String> bodyToMono = serverRequest.bodyToMono(String.class); return bodyToMono.flatMap(body -> { exchange.getAttributes().put("cachedRequestBody", body); ServerHttpRequest newRequest = new ServerHttpRequestDecorator(serverHttpRequest) { @Override public HttpHeaders getHeaders() { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.putAll(super.getHeaders()); httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); return httpHeaders; } @Override public Flux<DataBuffer> getBody() { NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory(new UnpooledByteBufAllocator(false)); DataBuffer bodyDataBuffer = nettyDataBufferFactory.wrap(body.getBytes()); return Flux.just(bodyDataBuffer); } }; return chain.filter(exchange.mutate().request(newRequest).build()); }); } return chain.filter(exchange); } @Override public int getOrder() { return -21; } }
3 记录日志
接下来再创建一个过滤器用于记录日志
@Component public class LogFilter implements GlobalFilter, Ordered { static final Logger logger = LogManager.getLogger("request"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { StringBuilder logBuilder = new StringBuilder(); ServerHttpRequest serverHttpRequest = exchange.getRequest(); String method = serverHttpRequest.getMethodValue().toUpperCase(); logBuilder.append(method).append(",").append(serverHttpRequest.getURI()); if("POST".equals(method)) { String body = exchange.getAttributeOrDefault("cachedRequestBody", ""); if(StringUtils.isNotBlank(body)) { logBuilder.append(",body=").append(body); } } ServerHttpResponse serverHttpResponse = exchange.getResponse(); DataBufferFactory bufferFactory = serverHttpResponse.bufferFactory(); ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(serverHttpResponse) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { if (body instanceof Flux) { Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body; return super.writeWith(fluxBody.map(dataBuffer -> { byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer); String resp = new String(content, Charset.forName("UTF-8")); logBuilder.append(",resp=").append(resp); logger.info(logBuilder.toString()); byte[] uppedContent = new String(content, Charset.forName("UTF-8")).getBytes(); return bufferFactory.wrap(uppedContent); })); } return super.writeWith(body); } }; return chain.filter(exchange.mutate().response(decoratedResponse).build()); } @Override public int getOrder() { return -20; } }
五、鉴权
对请求的安全验证方案视各自项目需求而定,没有固定的做法,这里仅演示检查签名的处理。规则是:对除sign外所有请求参数按字典顺序排序后组成key1=value1&key2=value2的字符串,然后计算MD5码并与sign参数值比较,一致即认为通过。
这里面同样要处理QueryString和POST方法的Body,因此和日志过滤器合并为在一起
@Component public class AuthAndLogFilter implements GlobalFilter, Ordered { static final Logger logger = LogManager.getLogger("request"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest serverHttpRequest = exchange.getRequest(); ServerHttpResponse serverHttpResponse = exchange.getResponse(); StringBuilder logBuilder = new StringBuilder(); Map<String, String> params = parseRequest(exchange, logBuilder); boolean r = checkSignature(params, serverHttpRequest); if(!r) { Map map = new HashMap<>(); map.put("code", 2); map.put("message", "签名验证失败"); String resp = JSON.toJSONString(map); logBuilder.append(",resp=").append(resp); logger.info(logBuilder.toString()); DataBuffer bodyDataBuffer = serverHttpResponse.bufferFactory().wrap(resp.getBytes()); serverHttpResponse.getHeaders().add("Content-Type", "text/plain;charset=UTF-8"); return serverHttpResponse.writeWith(Mono.just(bodyDataBuffer)); } DataBufferFactory bufferFactory = serverHttpResponse.bufferFactory(); ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(serverHttpResponse) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { if (body instanceof Flux) { Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body; return super.writeWith(fluxBody.map(dataBuffer -> { byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer); String resp = new String(content, Charset.forName("UTF-8")); logBuilder.append(",resp=").append(resp); logger.info(logBuilder.toString()); byte[] uppedContent = new String(content, Charset.forName("UTF-8")).getBytes(); return bufferFactory.wrap(uppedContent); })); } return super.writeWith(body); } }; return chain.filter(exchange.mutate().response(decoratedResponse).build()); } private Map<String, String> parseRequest(ServerWebExchange exchange, StringBuilder logBuilder) { ServerHttpRequest serverHttpRequest = exchange.getRequest(); String method = serverHttpRequest.getMethodValue().toUpperCase(); logBuilder.append(method).append(",").append(serverHttpRequest.getURI()); MultiValueMap<String, String> query = serverHttpRequest.getQueryParams(); Map<String, String> params = new HashMap<>(); query.forEach((k, v) -> { params.put(k, v.get(0)); }); if("POST".equals(method)) { String body = exchange.getAttributeOrDefault("cachedRequestBody", ""); if(StringUtils.isNotBlank(body)) { logBuilder.append(",body=").append(body); String[] kvArray = body.split("&"); for (String kv : kvArray) { if (kv.indexOf("=") >= 0) { String k = kv.split("=")[0]; String v = kv.split("=")[1]; if(!params.containsKey(k)) { try { params.put(k, URLDecoder.decode(v, "UTF-8")); } catch (UnsupportedEncodingException e) { } } } } } } return params; } private boolean checkSignature(Map<String, String> params, ServerHttpRequest serverHttpRequest) { String sign = params.get("sign"); if(StringUtils.isBlank(sign)) { return false; } //检查签名 Map<String, String> sorted = new TreeMap<>(); params.forEach( (k, v) -> { if(!"sign".equals(k)) { sorted.put(k, v); } }); StringBuilder builder = new StringBuilder(); sorted.forEach((k, v) -> { builder.append(k).append("=").append(v).append("&"); }); String value = builder.toString(); value = value.substring(0, value.length() - 1); if(!sign.equalsIgnoreCase(MD5Utils.MD5(value))) { return false; } return true; } @Override public int getOrder() { return -20; } }
测试
A:无签名
B:带签名GET请求
C:POST请求