前言 本文将介绍在 SpringCloud 项目中,通过 Eureka + Zuul 实现网关动态路由和网关灰度发布。Zuul 实现网关灰度发布的核心思路是:通过配置中心(如数据库、Nacos、ZooKeeper 等)控制是否开启灰度发布,然后在 Zuul 的 Filter 中根据请求路径或请求参数(如 version),再结合 Ribbon 负载均衡策略按标识筛选服务实例,最终基于 Eureka metadata(如 current / newest)将流量按规则路由到新旧服务,并通过按比例逐步放量(如 1% → 10% → 50% → 100%)实现可控放量,支持随时调整和快速回滚。
提示
网关动态路由:网关根据数据库、配置中心的数据变化,动态更新路由规则(如上线新服务、下线旧服务、修改路由转发路径),无需重启网关服务即可实时生效。 网关灰度发布:网关根据一定策略(如用户 ID、请求参数、随机百分比等)将部分流量路由到新版本服务,实现逐步放量发布与风险控制。 Zuul 使用案例 在实际生产环境中,通常结合网关动态路由 + 灰度发布机制来完成服务上线与版本迭代。 首先,对于新服务上线的场景,只需要在网关中通过动态路由配置好请求路径与服务实例的映射关系,无需重启网关即可实时生效。这样,当请求到达网关时,就可以直接被转发到新部署的服务,实现快速接入。 其次,对于已有服务的版本迭代,可以采用灰度发布策略。将新版本仅部署在少量机器上,并通过配置界面开启该服务的灰度发布功能。此时,Zuul 的 Filter 会根据预设规则(如百分比例、用户特征等),将一小部分流量导入到新版本实例,其余流量仍然走老版本。 在灰度阶段,可以重点观察新版本在真实流量下的运行情况,例如功能是否正常、性能是否稳定等。 当灰度发布验证通过后,将新版本统一标记为 current,并将新版本部署到全部机器上,同时关闭灰度发布功能。此时,网关会恢复默认的负载均衡策略,将流量均匀分发到所有服务实例,实现平滑升级。 项目版本说明 组件 版本 说明 SpringBoot 1.5.13.RELEASESpringCloud Edgware.SR3Zuul 1.3.0MySQL 5.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= '网关灰度发布配置表' ; 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 > <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;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;@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<>(); routesMap.putAll(super .locateRoutes()); routesMap.putAll(locateRoutesFromDB()); 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(); String requestURI = request.getRequestURI(); Map<String, GrayReleaseConfig> grayReleaseConfigs = grayReleaseConfigManager.getGrayReleaseConfigs(); for (String path : grayReleaseConfigs.keySet()) { 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); return false ; } @Override public Object run () { activeGrayRelease(); return null ; } void randomGrayRelease () { Random random = new Random(); int percent = random.nextInt(100 ); if (percent < 10 ) { RibbonFilterContextHolder.getCurrentContext().add("version" , "newest" ); } else { RibbonFilterContextHolder.getCurrentContext().add("version" , "current" ); } } void activeGrayRelease () { 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 inventory-service: ribbon: NFLoadBalancerRuleClassName: com.clay.zuul.gateway.RibbonGrayMetadataRule hystrix: command: default: execution: isolation: thread: timeoutInMilliseconds: 10000
核心代码 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(); String requestURI = request.getRequestURI(); Map<String, GrayReleaseConfig> grayReleaseConfigs = grayReleaseConfigManager.getGrayReleaseConfigs(); for (String path : grayReleaseConfigs.keySet()) { 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); return false ; } @Override public Object run () { activeGrayRelease(); return null ; } void randomGrayRelease () { RequestContext ctx = RequestContext.getCurrentContext(); Random random = new Random(); int percent = random.nextInt(100 ); if (percent < 10 ) { ctx.addZuulRequestHeader("version" , "newest" ); } else { ctx.addZuulRequestHeader("version" , "current" ); } } void activeGrayRelease () { 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" ); } } }
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;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); } } } 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-server 、zuul-gateway、inventory-service、inventory-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-server 、zuul-gateway、inventory-service、inventory-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 下载得到。