在微服務架構中,網關的職責包括路由、鑒權、限流、日志、監控、灰度發布等,目前主流的方案有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請求

