从 Zuul 迁移到 Spring Cloud Gateway:一步步实现服务网关的升级
- 迁移前的准备工作
- 迁移步骤详解
- 第一步:查看源码
- 第二步:启动类迁移
- 第三步:引入 Gateway 依赖
- 第四步 编写bootstrap.yaml
- 第五步:替换路由配置
- 第六步:迁移过滤器逻辑
- 第七步:测试与调优
- 迁移过程中常见问题及解决方案
- 真实问题:
- **注意事项:Nginx 转发配置的调整**
- **问题背景**
- **解决方法**
- 总结
公司的项目之前使用的是Zuul,然后使用的是以前传下来的jar包,JDK1.8,spring1.*,都是比较老了,然后因为这些原因,要把Zuul替换成Gateway。
本文将详细介绍如何从 Zuul 迁移到 Gateway。
迁移前的准备工作
在开始迁移之前,需要做好以下准备:
-
确认现有的 Zuul 配置
收集 Zuul 的路由配置、过滤器逻辑和插件依赖。 -
学习 Gateway 的基本概念
熟悉 Gateway 的核心概念,例如:- Route(路由)
- Predicate(断言)
- Filter(过滤器)
-
确保系统支持响应式编程模型
检查项目中的依赖库和代码是否与 Spring WebFlux 的非阻塞模型兼容。 -
升级到支持 Gateway 的 Spring Boot 版本
确保 Spring Boot 版本 >= 2.1。
迁移步骤详解
第一步:查看源码
由于项目使用的是预先打包好的 Jar 文件,源码不可直接查看,因此需要通过反编译工具提取代码。我使用的是 jd-gui 工具,界面如图所示:
从反编译的结果可以看到,代码量相对简单,主要包含两个部分:启动类和核心过滤器。相对比较容易。
第二步:启动类迁移
原 Zuul 启动类:
@EnableZuulProxy
@SpringBootApplication
public class ZuulServerApplication {
public static void main(String[] args) {
(new SpringApplicationBuilder(ZuulServerApplication.class))
.web(true)
.run(args);
}
@Bean
public PathRewriteHeaderFilter customAddHeaderFilter(RouteLocator routeLocator) {
return new PathRewriteHeaderFilter(routeLocator);
}
}
迁移后的 Gateway 启动类:
@SpringBootApplication
@EnableDiscoveryClient
@ComponentScan(basePackages = {"com.aspire.gateway.gatewayservice"})
public class GatewayServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServiceApplication.class, args);
}
}
- Spring Boot 2.x 后,
@EnableZuulProxy
不再需要,Gateway 默认支持路由功能。 - 由于项目的特殊需求,需要添加
@ComponentScan
手动指定 Bean 扫描路径,确保组件能够被正确加载。 - 因为spring2之后的版本不需要再显示指定Gateway了,其实理论上只需要一个
SpringBootApplication
就够了,其他其实都不用。但是我这里不知道为啥,扫描不到我的bean,所以我就写了扫描当前启动类。@ComponentScan(basePackages = {"com.aspire.gateway.gatewayservice"})
这里你可以换成自己的扫描包路径。
第三步:引入 Gateway 依赖
在 pom.xml
中移除 Zuul 相关依赖,替换为 Gateway 依赖:
以下是我使用的版本控制,就是这些版本之间是兼容的,我使用的也是这些版本。
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-cloud.version>2024.0.0</spring-cloud.version> <!-- Spring Cloud 2024.x -->
<spring-cloud-alibaba.version>2022.0.0.0-RC2</spring-cloud-alibaba.version> <!-- Spring Cloud Alibaba 对应版本 -->
<keycloak.version>22.0.4</keycloak.version> <!-- 非必须,我的项目需要,你不用就删掉 -->
</properties>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
这一步主要就是导入你的依赖嘛。
第四步 编写bootstrap.yaml
这一块里面其实主要就是你的nacos的配置文件,反正我用的是nacos,因为Zuul是网关嘛,Gateway也是网关,然后你实际的服务和网关都是要在同一个服务发现下面的,我之前是eureka,现在是nacos,所以要在这里说明的。
spring:
main:
allow-circular-references: true
allow-bean-definition-overriding: true
application:
name: rbac-gateway
cloud:
nacos:
username: ${ENV_CONFIG_USERNAME:nacos}
password: ${ENV_CONFIG_PASSWORD:}
server-addr: ${ENV_CONFIG_IP:10.*.*.*}:${ENV_CONFIG_PORT:*}
# Nacos 服务发现配置
discovery:
enabled: true # 启用服务发现
service: ${spring.application.name} # 使用应用名作为服务名
server-addr: ${ENV_CONFIG_IP:*}:${ENV_CONFIG_PORT:*}
namespace: ${NAMESPACE:*}
#group: ${spring.cloud.nacos.discovery.group:*}
group: *
metadata:
version: v1
env: prod
# Nacos 配置中心配置
config:
enabled: true
server-addr: ${ENV_CONFIG_IP:*}:${ENV_CONFIG_PORT:*}
# group: ${spring.cloud.nacos.discovery.group:*}
group: *
namespace: ${NAMESPACE:*}
file-extension: yml
shared-configs:
- data-id: ${CONFIG_DATA_ID:ms-gateway.yml}
group: *
refresh: true
timeout: 600000
config-long-poll-timeout: 5000
config-retry-time: 2000
max-retry: 3
refresh-enabled: true
第五步:替换路由配置
将 Zuul 的 application.yml
配置迁移为 Gateway 的路由配置。这一块实际上就比较复杂了,因为他们之间的切换还是很麻烦的,所以我这里是直接使用AI帮我替换的,你也可以这样。
反正差不多样子就是如下吧。直接让AI帮你替换,然后你看一眼就行了。我反正是这么搞的,然后也没啥问题。
Zuul 配置:
zuul:
#
semaphore:
max-semaphores: 1000
servlet-path: /
host:
connect-timeout-millis: 60000
socket-timeout-millis: 60000
#
routes:
smartdata-check:
path: /smartCheck
service-id: rbac
strip-prefix: false
smartdata-token-init:
path: /v1/smartdata/token
service-id: rbac
strip-prefix: false
composite-roles:
path: /v1/roles/**
service-id: rbac
strip-prefix: false
Gateway 配置:
spring:
cloud:
gateway:
routes:
- id: sso
uri: lb://rbac
predicates:
- Path=/v1/alerts/sso/**
- id: smartdata-check
uri: lb://rbac
predicates:
- Path=/smartCheck
- id: smartdata-token-init
uri: lb://rbac
predicates:
- Path=/v1/smartdata/token
- id: composite-roles
uri: lb://rbac
predicates:
- Path=/v1/roles/**
其实没有全局过滤器,已经可以用了,就是网关服务已经是可以用了。到这里其实就已经结束了。服务能用。不看后面也行,我为什么要替换呢,因为我想完美迁移。
第六步:迁移过滤器逻辑
Zuul 使用过滤器机制来处理请求,而 Gateway 则使用过滤器工厂。这一块就比较复杂了,也是我花的最多时间的一步了。
原本的Zuul 过滤器:
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.representations.AccessToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.web.util.UrlPathHelper;
public class PathRewriteHeaderFilter extends ZuulFilter {
private static final Logger log = LoggerFactory.getLogger(com.migu.tsg.microservice.zuul.PathRewriteHeaderFilter.class);
private RouteLocator routeLocator;
private final UrlPathHelper urlPathHelper = new UrlPathHelper();
private static final String EMPLOYEE_TYPE = "employeeType";
private static final String ORG_ACCOUNT = "head_orgAccount";
private static final String IS_ADMIN = "head_isAdmin";
private static final String IS_SUPERUSER = "head_isSuperUser";
private static final String USER_NAME = "head_userName";
private static final String FALSE = "false";
private static final String TRUE = "true";
private static final String ADMIN = "admin";
private static final String ROOT = "root";
private static final Integer SIX = Integer.valueOf(6);
private static final String COLON = ":";
public PathRewriteHeaderFilter() {}
public PathRewriteHeaderFilter(RouteLocator routeLocator) {
this.routeLocator = routeLocator;
}
public int filterOrder() {
return SIX.intValue();
}
public String filterType() {
return "pre";
}
public boolean shouldFilter() {
return true;
}
public Object run() {
RequestContext requestContext = RequestContext.getCurrentContext();
String requestURI = this.urlPathHelper.getPathWithinApplication(requestContext.getRequest());
Route route = this.routeLocator.getMatchingRoute(requestURI);
try {
if (route != null) {
String location = route.getLocation();
log.info("location: {}", location);
if (location != null) {
HttpServletRequest request = requestContext.getRequest();
KeycloakSecurityContext securityContext = (KeycloakSecurityContext)request.getAttribute(KeycloakSecurityContext.class.getName());
handleRewriteHeader(securityContext, requestContext);
if (location.startsWith("http:") || location.startsWith("https:"))
log.info("forward url is : " + location);
}
}
} catch (Exception e) {
requestContext.set("error.status_code", Integer.valueOf(500));
requestContext.set("error.message", e.getCause());
requestContext.set("error.exception", e);
}
return null;
}
private void handleRewriteHeader(KeycloakSecurityContext securityContext, RequestContext requestContext) {
log.info("keycloak securityContext = {}", securityContext);
if (securityContext == null)
return;
AccessToken token = securityContext.getToken();
Map<String, Object> otherClaims = token.getOtherClaims();
log.info("keycloak token = {}, otherClaims = {}", token, otherClaims);
String employeeType = (String)otherClaims.get("employeeType");
String userName = (String)otherClaims.get("userName");
String orgAccount = "";
String isSupperUser = "false";
String isAdmin = "false";
if (employeeType.equals("root")) {
isSupperUser = "true";
orgAccount = userName;
}
if (employeeType.equals("admin")) {
isAdmin = "true";
orgAccount = userName;
}
if (!employeeType.equals("root") && !employeeType.equals("admin")) {
int index = employeeType.indexOf(".") + 1;
orgAccount = employeeType.substring(index, employeeType.length());
}
log.info("employeeType: {}, head_userName: {}, head_isSuperUser: {}, head_orgAccount: {}, head_isAdmin: {}", new Object[] { employeeType, userName, isSupperUser, orgAccount, isAdmin });
requestContext.addZuulRequestHeader("head_userName", userName);
requestContext.addZuulRequestHeader("head_isSuperUser", isSupperUser);
requestContext.addZuulRequestHeader("head_orgAccount", orgAccount);
requestContext.addZuulRequestHeader("head_isAdmin", isAdmin);
}
}
但是我是想完美的等量替换,所以这里就把原本的过滤器也给拿过来了。可以看到是少了一些东西了,因为原本的方法有很多东西是用不到的,我就把那些东西给删掉了。只保留了用到的东西
反正我测下来,是没啥问题,反正就是实现起来差别真的很大,首先是extends ZuulFilter
不用了,改成了GlobalFilter
,然后里面的实现也从public Object run()
变成了public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
,然后具体的实现逻辑也变了,这一块呢,就是自己琢磨着改吧。每一个过滤器都不一样,反正大体逻辑就是实现的方法不一样了,然后重写的方法不一样了。这两个是最主要的。
- 实现的接口不一样
- 重写的方法不一样
其实主要把握这两个就行,里面就是具体的代码逻辑了。
替换后的Gateway 全局过滤器:
@Component
@Order(6) // 这里使用 @Order 注解来设置过滤器顺序
public class PathRewriteHeaderFilter implements GlobalFilter {
private static final Logger log = LoggerFactory.getLogger(PathRewriteHeaderFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
try {
String requestURI = exchange.getRequest().getURI().getPath();
log.info("Request URI: {}", requestURI);
// 在此处你可以获取并处理 Keycloak 的 securityContext 和 token
KeycloakSecurityContext securityContext = exchange.getAttribute(KeycloakSecurityContext.class.getName());
if (securityContext != null) {
AccessToken token = securityContext.getToken();
Map<String, Object> otherClaims = token.getOtherClaims();
log.info("keycloak token = {}, otherClaims = {}", token, otherClaims);
String employeeType = (String) otherClaims.get("employeeType");
String userName = (String) otherClaims.get("userName");
String orgAccount = "";
String isSuperUser = "false";
String isAdmin = "false";
if ("root".equals(employeeType)) {
isSuperUser = "true";
orgAccount = userName;
} else if ("admin".equals(employeeType)) {
isAdmin = "true";
orgAccount = userName;
} else {
int index = employeeType.indexOf(".") + 1;
orgAccount = employeeType.substring(index);
}
log.info("employeeType: {}, head_userName: {}, head_isSuperUser: {}, head_orgAccount: {}, head_isAdmin: {}",
employeeType, userName, isSuperUser, orgAccount, isAdmin);
exchange.getRequest().mutate()
.header("head_userName", userName)
.header("head_isSuperUser", isSuperUser)
.header("head_orgAccount", orgAccount)
.header("head_isAdmin", isAdmin)
.build();
}