Zuul 网关实现动态路由和灰度发布

前言

本文将介绍在 SpringCloud 项目中,通过 Eureka + Zuul 实现网关动态路由和网关灰度发布。Zuul 实现网关灰度发布的核心思路是:通过配置中心(如数据库、Nacos、ZooKeeper 等)控制是否开启灰度发布,然后在 Zuul 的 Filter 中根据请求路径或请求参数(如 version),再结合 Ribbon 负载均衡策略按标识筛选服务实例,最终基于 Eureka metadata(如 current / newest)将流量按规则路由到新旧服务,并通过按比例逐步放量(如 1% → 10% → 50% → 100%)实现可控放量,支持随时调整和快速回滚。

提示

  • 网关动态路由:网关根据数据库、配置中心的数据变化,动态更新路由规则(如上线新服务、下线旧服务、修改路由转发路径),无需重启网关服务即可实时生效。
  • 网关灰度发布:网关根据一定策略(如用户 ID、请求参数、随机百分比等)将部分流量路由到新版本服务,实现逐步放量发布与风险控制。

Zuul 使用案例

  • 在实际生产环境中,通常结合网关动态路由 + 灰度发布机制来完成服务上线与版本迭代。
  • 首先,对于新服务上线的场景,只需要在网关中通过动态路由配置好请求路径与服务实例的映射关系,无需重启网关即可实时生效。这样,当请求到达网关时,就可以直接被转发到新部署的服务,实现快速接入。
  • 其次,对于已有服务的版本迭代,可以采用灰度发布策略。将新版本仅部署在少量机器上,并通过配置界面开启该服务的灰度发布功能。此时,Zuul 的 Filter 会根据预设规则(如百分比例、用户特征等),将一小部分流量导入到新版本实例,其余流量仍然走老版本。
  • 在灰度阶段,可以重点观察新版本在真实流量下的运行情况,例如功能是否正常、性能是否稳定等。
  • 当灰度发布验证通过后,将新版本统一标记为 current,并将新版本部署到全部机器上,同时关闭灰度发布功能。此时,网关会恢复默认的负载均衡策略,将流量均匀分发到所有服务实例,实现平滑升级。

项目版本说明

组件版本说明
SpringBoot1.5.13.RELEASE
SpringCloudEdgware.SR3
Zuul1.3.0
MySQL5.7.26

项目目录结构

数据库初始化

这里使用 MySQL 来存储网关 API 路由规则和网关灰度发布配置信息,也可以使用 ZooKeeper、Apollo、Nacos、Redis 等来替代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 创建数据库
CREATE DATABASE zuul_gateway DEFAULT CHARACTER SET utf8mb4;

-- 创建网关灰度发布配置表
CREATE TABLE `gateway_gray_release_config` (
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`service_id` VARCHAR(255) DEFAULT NULL COMMENT '服务ID(注册中心中的服务名)',
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径(如 /api/**)',
`enable_gray_release` TINYINT(11) DEFAULT NULL COMMENT '是否开启灰度发布(1-开启,0-关闭)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网关灰度发布配置表';

-- 创建网关API路由配置表
CREATE TABLE `gateway_api_route` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`path` VARCHAR(255) NOT NULL COMMENT '路由路径,如 /api/**',
`service_id` VARCHAR(128) DEFAULT NULL COMMENT '服务ID(注册中心中的服务名)',
`url` VARCHAR(512) DEFAULT NULL COMMENT '直连URL(与service_id二选一)',
`strip_prefix` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否去除前缀(1-是,0-否)',
`retryable` TINYINT(1) DEFAULT NULL COMMENT '是否支持重试(1-是,0-否)',
`enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用(1-启用,0-禁用)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网关API路由配置表';
1
2
3
4
5
6
7
8
-- 插入测试数据
INSERT INTO zuul_gateway.gateway_api_route (id,`path`,service_id,url,strip_prefix,retryable,enabled,create_time,update_time) VALUES
('1','/inventory/**','inventory-service','/inventory-service/inventory',1,1,1,'2018-05-18 20:31:31.0','2018-05-18 20:46:14.0');

-- 插入测试数据
INSERT INTO zuul_gateway.gateway_gray_release_config (service_id,`path`,enable_gray_release) VALUES
('inventory-service','/inventory/deduct',1),
('inventory-service','/inventory/increase',0);

基础案例代码

创建父模块

创建 Maven 父模块,配置好模块需要的父级依赖,目的是为了更方便管理与简化配置,具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<properties>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.13.RELEASE</version>
</parent>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Edgware.SR3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

创建库存模块

库存 API 模块
POM 配置
1
2
3
4
5
6
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.springframework.web.bind.annotation.PathVariable;

public interface InventoryApi {

/**
* 增加库存
*/
String increaseStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock);

/**
* 扣减库存
*/
String deductStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock);

}
库存服务模块一

库存服务模块一,用于模拟旧版本的库存服务;往 Eureka 注册服务时,带的 Metadata 内容为 version: current

POM 配置
1
2
3
4
5
6
7
8
9
10
11
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>com.clay.gateway</groupId>
<artifactId>inventory-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
YML 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
port: 8082

spring:
application:
name: inventory-service

eureka:
instance:
hostname: localhost
metadata-map:
version: current # 用于网关灰度发布
client:
serviceUrl:
defaultZone: http://127.0.0.1:8761/eureka
registryFetchIntervalSeconds: 3
leaseRenewalIntervalInSeconds: 3
核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import com.clay.inventory.api.InventoryApi;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/inventory")
public class InventoryController implements InventoryApi {

@Override
@RequestMapping(value = "/increase/{productId}/{stock}", method = RequestMethod.POST)
public String increaseStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock) {
System.out.println("库存服务一,对商品【productId=" + productId + "】增加库存:" + stock);
return "{'msg': 'success'}";
}

@Override
@RequestMapping(value = "/deduct/{productId}/{stock}", method = RequestMethod.POST)
public String deductStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock) {
System.out.println("库存服务一,对商品【productId=" + productId + "】扣减库存:" + stock);
return "{'msg': 'success'}";
}

}
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableEurekaClient
public class InventoryApplication {

public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}

}
库存服务模块二

库存服务模块二,用于模拟新版本的库存服务;往 Eureka 注册服务时,带的 Metadata 内容为 version: newest

POM 配置
1
2
3
4
5
6
7
8
9
10
11
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>com.clay.gateway</groupId>
<artifactId>inventory-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
YML 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
port: 8083

spring:
application:
name: inventory-service

eureka:
instance:
hostname: localhost
metadata-map:
version: newest # 用于网关灰度发布
client:
serviceUrl:
defaultZone: http://127.0.0.1:8761/eureka
registryFetchIntervalSeconds: 3
leaseRenewalIntervalInSeconds: 3
核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import com.clay.inventory.api.InventoryApi;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/inventory")
public class InventoryController implements InventoryApi {

@Override
@RequestMapping(value = "/increase/{productId}/{stock}", method = RequestMethod.POST)
public String increaseStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock) {
System.out.println("库存服务二,对商品【productId=" + productId + "】增加库存:" + stock);
return "{'msg': 'success'}";
}

@Override
@RequestMapping(value = "/deduct/{productId}/{stock}", method = RequestMethod.POST)
public String deductStock(@PathVariable("productId") Long productId, @PathVariable("stock") Long stock) {
System.out.println("库存服务二,对商品【productId=" + productId + "】扣减库存:" + stock);
return "{'msg': 'success'}";
}

}
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableEurekaClient
public class InventoryApplication {

public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}

}

创建网关模块

POM 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- 在 Ribbon 从 Eureka 拉取服务列表时,增加过滤能力(比如按版本、标签筛选择服务实例),从而实现灰度路由 -->
<dependency>
<groupId>io.jmnarloch</groupId>
<artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
<version>2.1.0</version>
</dependency>
</dependencies>

特别注意

第三方依赖 ribbon-discovery-filter-spring-cloud-starter 的作用是:在 Ribbon 从 Eureka 拉取服务列表时,增加过滤能力(比如按版本、标签筛选择服务实例),从而实现灰度路由。Zuul + Eureka 实现灰度发布不是必须要依赖该包,它只是对 Ribbon 实例筛选的封装。在生产环境中,还可以通过自定义 Zuul Filter + Ribbon 负载均衡策略 + Eureka metadata 实现更灵活的灰度发布控制,详细的案例代码可以参考 这里

YML 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
server:
port: 9000

spring:
application:
name: zuul-gateway
datasource:
url: jdbc:mysql://localhost:3306/zuul_gateway?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf-8
username: root
password: BitCopt@MySQL_2019
driver-class-name: com.mysql.jdbc.Driver

eureka:
instance:
hostname: localhost
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
registryFetchIntervalSeconds: 3
leaseRenewalIntervalInSeconds: 3

zuul:
retryable: true

ribbon:
eager-load:
enabled: true
ConnectTimeout: 1000
ReadTimeout: 1000
OkToRetryOnAllOperations: true
MaxAutoRetries: 1
MaxAutoRetriesNextServer: 1

hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 10000
基础代码
GatewayApiRoute
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import java.io.Serializable;

/**
* 网关API路由规则
*/
public class GatewayApiRoute implements Serializable {

private String id;
private String path;
private String serviceId;
private String url;
private int stripPrefix = 1;
private int retryable;
private int enabled;

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public String getPath() {
return path;
}

public void setPath(String path) {
this.path = path;
}

public String getServiceId() {
return serviceId;
}

public void setServiceId(String serviceId) {
this.serviceId = serviceId;
}

public String getUrl() {
return url;
}

public void setUrl(String url) {
this.url = url;
}

public int getStripPrefix() {
return stripPrefix;
}

public void setStripPrefix(int stripPrefix) {
this.stripPrefix = stripPrefix;
}

public int getRetryable() {
return retryable;
}

public void setRetryable(int retryable) {
this.retryable = retryable;
}

public int getEnabled() {
return enabled;
}

public void setEnabled(int enabled) {
this.enabled = enabled;
}
}
GrayReleaseConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.io.Serializable;

/**
* 网关灰度发布配置规则
*/
public class GrayReleaseConfig implements Serializable {

private int id;
private String serviceId;
private String path;
private int enableGrayRelease;

public int getId() {
return id;
}

public void setId(int id) {
this.id = id;
}

public String getServiceId() {
return serviceId;
}

public void setServiceId(String serviceId) {
this.serviceId = serviceId;
}

public String getPath() {
return path;
}

public void setPath(String path) {
this.path = path;
}

public int getEnableGrayRelease() {
return enableGrayRelease;
}

public void setEnableGrayRelease(int enableGrayRelease) {
this.enableGrayRelease = enableGrayRelease;
}

}
核心代码
RefreshApiRouteTask
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.RoutesRefreshedEvent;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
* 定时刷新网关API路由规则
*/
@Component
@Configuration
@EnableScheduling
public class RefreshApiRouteTask {

@Autowired
private ApplicationEventPublisher publisher;

@Autowired
private RouteLocator routeLocator;

@Scheduled(fixedRate = 30 * 1000)
private void refreshRoute() {
RoutesRefreshedEvent routesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
publisher.publishEvent(routesRefreshedEvent);
}

}
GrayReleaseConfigManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* 定时刷新网关灰度发布配置规则
*/
@Component
@Configuration
@EnableScheduling
public class GrayReleaseConfigManager {

private Map<String, GrayReleaseConfig> grayReleaseConfigs = new ConcurrentHashMap<String, GrayReleaseConfig>();

@Autowired
private JdbcTemplate jdbcTemplate;

@Scheduled(fixedRate = 30 * 1000)
private void refreshRoute() {
List<GrayReleaseConfig> results = jdbcTemplate.query(
"select * from gateway_gray_release_config",
new BeanPropertyRowMapper<>(GrayReleaseConfig.class));

for (GrayReleaseConfig grayReleaseConfig : results) {
grayReleaseConfigs.put(grayReleaseConfig.getPath(), grayReleaseConfig);
}
}

public Map<String, GrayReleaseConfig> getGrayReleaseConfigs() {
return grayReleaseConfigs;
}

}
DynamicRouteLocator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import org.springframework.beans.BeanUtils;
import org.springframework.cloud.netflix.zuul.filters.RefreshableRouteLocator;
import org.springframework.cloud.netflix.zuul.filters.SimpleRouteLocator;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.StringUtils;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* 动态路由加载器
*/
public class DynamicRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator {

private JdbcTemplate jdbcTemplate;

private ZuulProperties properties;

public DynamicRouteLocator(String servletPath, ZuulProperties properties) {
super(servletPath, properties);
this.properties = properties;
}

public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

@Override
public void refresh() {
doRefresh();
}

/**
* 加载路由规则
*/
@Override
protected Map<String, ZuulProperties.ZuulRoute> locateRoutes() {
LinkedHashMap<String, ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<>();
// 加载application.yml中的路由规则
routesMap.putAll(super.locateRoutes());
// 加载数据库中的路由规则
routesMap.putAll(locateRoutesFromDB());

// 统一处理一下路由path的格式,统一以 / 为前缀
LinkedHashMap<String, ZuulProperties.ZuulRoute> values = new LinkedHashMap<>();
for (Map.Entry<String, ZuulProperties.ZuulRoute> entry : routesMap.entrySet()) {
String path = entry.getKey();
if (!path.startsWith("/")) {
path = "/" + path;
}
if (StringUtils.hasText(this.properties.getPrefix())) {
path = this.properties.getPrefix() + path;
if (!path.startsWith("/")) {
path = "/" + path;
}
}
values.put(path, entry.getValue());
}

return values;
}

/**
* 加载数据库中的路由规则
*/
private Map<String, ZuulProperties.ZuulRoute> locateRoutesFromDB() {
Map<String, ZuulProperties.ZuulRoute> routes = new LinkedHashMap<>();

List<GatewayApiRoute> results = jdbcTemplate.query(
"select * from gateway_api_route where enabled = true ",
new BeanPropertyRowMapper<>(GatewayApiRoute.class));

for (GatewayApiRoute result : results) {
if (StringUtils.isEmpty(result.getPath())) {
continue;
}
if (StringUtils.isEmpty(result.getServiceId()) && StringUtils.isEmpty(result.getUrl())) {
continue;
}
ZuulProperties.ZuulRoute zuulRoute = new ZuulProperties.ZuulRoute();
try {
BeanUtils.copyProperties(result, zuulRoute);
routes.put(zuulRoute.getPath(), zuulRoute);
} catch (Exception e) {
e.printStackTrace();
}
}

return routes;
}

}
DynamicRouteConfiguration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

/**
* 动态路由配置
*/
@Configuration
public class DynamicRouteConfiguration {

@Autowired
private ZuulProperties zuulProperties;

@Autowired
private ServerProperties server;

@Autowired
private JdbcTemplate jdbcTemplate;

@Bean
public DynamicRouteLocator routeLocator() {
DynamicRouteLocator routeLocator = new DynamicRouteLocator(
this.server.getServletPrefix(), this.zuulProperties);
routeLocator.setJdbcTemplate(jdbcTemplate);
return routeLocator;
}

}
GrayReleaseFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import io.jmnarloch.spring.cloud.ribbon.support.RibbonFilterContextHolder;
import org.apache.commons.lang.StringUtils;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Random;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_DECORATION_FILTER_ORDER;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

/**
* 灰度发布过滤器
*/
@Configuration
public class GrayReleaseFilter extends ZuulFilter {

@Resource
private GrayReleaseConfigManager grayReleaseConfigManager;

@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER - 1;
}

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();

// 获取当前请求的 URI,比如 http://localhost:9000/order/get?xxxx
String requestURI = request.getRequestURI();

Map<String, GrayReleaseConfig> grayReleaseConfigs = grayReleaseConfigManager.getGrayReleaseConfigs();
for (String path : grayReleaseConfigs.keySet()) {
// 匹配 URI,获取对应的网关灰度发布配置
if (requestURI.contains(path)) {
GrayReleaseConfig grayReleaseConfig = grayReleaseConfigs.get(path);
if (grayReleaseConfig.getEnableGrayRelease() == 1) {
System.out.println("启用灰度发布功能, URI : " + requestURI);
return true;
}
}
}

System.out.println("不启用灰度发布功能, URI : " + requestURI);

// 不启用灰度发布时,默认会将请求轮询分发到对应的多个服务,包括标记为 newest 的服务(新服务)
// 不启用灰度发布时,如果希望将请求只转发到标记为 current 的服务(旧服务),可以取消注释以下代码,但生产环境比较少这么做
// RibbonFilterContextHolder.getCurrentContext().add("version", "current");

return false;
}

/**
* 只有 shouldFilter() 返回 true 后,才会执行 run()
*/
@Override
public Object run() {
activeGrayRelease();
// randomGrayRelease();
return null;
}

/**
* 随机触发灰度发布
*/
void randomGrayRelease() {
// 定制 Ribbon 的负载均衡策略,比如:灰度的概率为 10%
Random random = new Random();
int percent = random.nextInt(100); // [0, 99]
if (percent < 10) {
RibbonFilterContextHolder.getCurrentContext().add("version", "newest");
} else {
RibbonFilterContextHolder.getCurrentContext().add("version", "current");
}
}

/**
* 根据请求参数主动触发灰度发布
*/
void activeGrayRelease() {
// 定制 Ribbon 的负载均衡策略,比如:根据请求参数决定是否灰度
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
String gray = request.getParameter("gray");
if (StringUtils.isNotBlank(gray) && "true".equals(gray)) {
RibbonFilterContextHolder.getCurrentContext().add("version", "newest");
} else {
RibbonFilterContextHolder.getCurrentContext().add("version", "current");
}
}

}
ZuulGatewayApplication
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
public class ZuulGatewayApplication {

public static void main(String[] args) {
SpringApplication.run(ZuulGatewayApplication.class, args);
}

}

创建注册中心模块

POM 配置
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka-server</artifactId>
</dependency>
YML 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 8761
eureka:
instance:
hostname: localhost
leaseExpirationDurationInSeconds: 5
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://127.0.0.1:8761/eureka/
server:
enableSelfPreservation: false
responseCacheUpdateIntervalMs: 3000
evictionIntervalTimerInMs: 3000
核心代码
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableEurekaServer
public class EurekaServer {

public static void main(String[] args) {
SpringApplication.run(EurekaServer.class, args);
}

}

网关扩展代码

在上述的 Zuul 网关模块中,第三方依赖 ribbon-discovery-filter-spring-cloud-starter 的作用是:在 Ribbon 从 Eureka 拉取服务列表时,增加过滤能力(比如按版本、标签筛选择服务实例),从而实现灰度路由。Zuul + Eureka 实现灰度发布不是必须要依赖该包,它只是对 Ribbon 实例筛选的封装。在生产环境中,还可以通过自定义 Zuul Filter + Ribbon 负载均衡策略 + Eureka metadata 实现更灵活的灰度发布控制。这里在上述 Zuul 网关模块的代码基础上,移除对 ribbon-discovery-filter-spring-cloud-starter 依赖,手动实现网关灰度发布功能。Zuul 网关模块更改后的核心代码和配置文件如下:

POM 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>

YML 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
server:
port: 9000

spring:
application:
name: zuul-gateway
datasource:
url: jdbc:mysql://localhost:3306/zuul_gateway?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf-8
username: root
password: BitCopt@MySQL_2019
driver-class-name: com.mysql.jdbc.Driver

eureka:
instance:
hostname: localhost
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
registryFetchIntervalSeconds: 3
leaseRenewalIntervalInSeconds: 3

zuul:
retryable: true

ribbon:
eager-load:
enabled: true
ConnectTimeout: 1000
ReadTimeout: 1000
OkToRetryOnAllOperations: true
MaxAutoRetries: 1
MaxAutoRetriesNextServer: 1

# 为特定的服务指定Ribbon负载均衡策略
inventory-service:
ribbon:
NFLoadBalancerRuleClassName: com.clay.zuul.gateway.RibbonGrayMetadataRule

hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 10000

核心代码

  • Zuul 过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import org.apache.commons.lang.StringUtils;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Random;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_DECORATION_FILTER_ORDER;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

/**
* 灰度发布过滤器
*/
@Configuration
public class GrayReleaseFilter extends ZuulFilter {

@Resource
private GrayReleaseConfigManager grayReleaseConfigManager;

@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER - 1;
}

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();

// 获取当前请求的 URI,比如 http://localhost:9000/order/get?xxxx
String requestURI = request.getRequestURI();

Map<String, GrayReleaseConfig> grayReleaseConfigs = grayReleaseConfigManager.getGrayReleaseConfigs();
for (String path : grayReleaseConfigs.keySet()) {
// 匹配 URI,获取对应的网关灰度发布配置
if (requestURI.contains(path)) {
GrayReleaseConfig grayReleaseConfig = grayReleaseConfigs.get(path);
if (grayReleaseConfig.getEnableGrayRelease() == 1) {
System.out.println("启用灰度发布功能, URI : " + requestURI);
return true;
}
}
}

System.out.println("不启用灰度发布功能, URI : " + requestURI);

// 不启用灰度发布时,默认会将请求轮询分发到对应的多个服务,包括标记为 newest 的服务(新服务)
// 不启用灰度发布时,如果希望将请求只转发到标记为 current 的服务(旧服务),可以取消注释以下代码,但生产环境比较少这么做
// RibbonFilterContextHolder.getCurrentContext().add("version", "current");

return false;
}

/**
* 只有 shouldFilter() 返回 true 后,才会执行 run()
*/
@Override
public Object run() {
activeGrayRelease();
// randomGrayRelease();
return null;
}

/**
* 随机触发灰度发布
*/
void randomGrayRelease() {
// 定制 Ribbon 的负载均衡策略,比如:灰度的概率为 10%
RequestContext ctx = RequestContext.getCurrentContext();
Random random = new Random();
int percent = random.nextInt(100); // [0, 99]
if (percent < 10) {
ctx.addZuulRequestHeader("version", "newest");
} else {
ctx.addZuulRequestHeader("version", "current");
}
}

/**
* 根据请求参数主动触发灰度发布
*/
void activeGrayRelease() {
// 定制 Ribbon 的负载均衡策略,比如:根据请求参数决定是否灰度
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
String gray = request.getParameter("gray");
if (StringUtils.isNotBlank(gray) && "true".equals(gray)) {
ctx.addZuulRequestHeader("version", "newest");
} else {
ctx.addZuulRequestHeader("version", "current");
}
}

}
  • Ribbon 负载均衡策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import com.netflix.niws.loadbalancer.DiscoveryEnabledServer;
import com.netflix.zuul.context.RequestContext;
import org.apache.commons.lang.StringUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
* Ribbon 负载均衡策略
*/
public class RibbonGrayMetadataRule extends AbstractLoadBalancerRule {

/**
* 必须有无参构造方法
*/
public RibbonGrayMetadataRule() {

}

@Override
public Server choose(Object key) {
ILoadBalancer lb = getLoadBalancer();
List<Server> servers = lb.getAllServers();

// 获取当前请求上下文
RequestContext ctx = RequestContext.getCurrentContext();
String version = ctx.getZuulRequestHeaders().get("version");

// 匹配的服务实例列表
List<Server> matched = new ArrayList<>();
for (Server server : servers) {
if (server instanceof DiscoveryEnabledServer) {
DiscoveryEnabledServer ds = (DiscoveryEnabledServer) server;
String metaVersion = ds.getInstanceInfo().getMetadata().get("version");
if (StringUtils.isNotBlank(version) && version.equals(metaVersion)) {
matched.add(server);
}
}
}

// Fallback 处理
if (matched.isEmpty()) {
return servers.get(new Random().nextInt(servers.size()));
}

return matched.get(new Random().nextInt(matched.size()));
}

@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {

}
}

测试案例代码

动态路由测试

  • (1) 按顺序依次启动 eureka-serverzuul-gatewayinventory-serviceinventory-service2 模块

  • (2) 在网关 API 路由配置表中,添加 Zuul 的路由映射规则,如下图所示:

  • (3) 通过 Zuul 网关调用增加库存的接口:curl -X POST http://127.0.0.1:9000/inventory-service/inventory/increase/23/5

  • (4) 如果接口可以正常返回 {'msg': 'success'} 结果,则说明网关的动态路由功能生效了,也就是可以从数据库中动态加载网关 API 路由规则

灰度发布测试

  • (1) 按顺序依次启动 eureka-serverzuul-gatewayinventory-serviceinventory-service2 模块

  • (2) 在网关灰度发布配置表中,将增加库存的接口配置为不启用灰度发布,而扣减库存的接口配置为启用灰度发布,如下图所示:

  • (3) 通过 Zuul 网关调用增加库存的接口:curl -X POST http://127.0.0.1:9000/inventory-service/inventory/increase/23/5

    • 由于增加库存的接口配置了不启用灰度发布,因此 Zuul 网关会将请求轮询分发给两个库存服务
  • (4) 通过 Zuul 网关调用扣减库存的接口(带 gray 参数):curl -X POST http://127.0.0.1:9000/inventory-service/inventory/deduct/23/5?gray=false

    • 由于扣减库存的接口配置了启用灰度发布,且 gray 参数的值为 false,因此 Zuul 网关只会将请求轮询分发给库存服务一(旧服务,带 version: current 标签)
  • (5) 通过 Zuul 网关调用扣减库存的接口(带 gray 参数):curl -X POST http://127.0.0.1:9000/inventory-service/inventory/deduct/23/5?gray=true

    • 由于扣减库存的接口配置了启用灰度发布,且 gray 参数的值为 true,因此 Zuul 网关只会将请求轮询分发给库存服务二(新服务,带 version: newest 标签)

下载案例代码

本文完整的案例代码可以直接从 GitHub 下载得到。

其他代码下载

Zuul 实现网关动态路由和网关灰度发布,不依赖第三方包 ribbon-discovery-filter-spring-cloud-starter 的案例代码可以从这里 GitHub 下载得到。