Soul 网关源码阅读番外篇(一) HTTP 参数请求错误
简介
在 Soul 网关 2.2.1 版本源码阅读中,遇到了 HTTP 请求加上参数返回 404 的错误,此篇文章基于此进行探索
Bug 复现
相关环境配置
首先把代码拉下来,然后切换到 2.2.1 版本,命令大致如下:
# 加速拉取git clone https://github.com.cnpmjs.org/lw1243925457/soul.git
# 切换到2.2.1版本git fetch origin 2.2.1:2.2.1git checkout 2.2.1
复制代码
如果之前运行过 Soul 网关的,需要清理下数据库,这里删除原来的 soul 数据库,让 2.2.1 版本自己重新建立一个
# 使用docker启动mysqldocker run --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -d mysql:latest
# 重启,需要删除soul数据库,然后让程序自己重建docker restart mysql# 使用命令登录,删除原来的数据库docker exec -ti mysql mysql -u root -p> drop database soul;
复制代码
Soul——Admin 启动
修改 Soul-admin 模块下的配置文件:soul-admin --> application-local.yml
修改 mysql 用户和密码: root root
修改链接配置:jdbc:mysql://localhost:3306/soul?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&&useSSL=false
启动 soul-admin --> SoulAdminBootstrap
如果出现 SelectorTypeEnum 相关的错误,请切换到 jdk8
启动 Soul-Bootstrap
启动 soul-bootstrap --> SoulBootstrapApplication
启动 HTTP test
首先右键 soul-test 根目录下的 pom.xml,选择 add as maven project,导入工程 可能会出现依赖错误,将其版本替换为 2.2.1,大致如下:
<dependency> <groupId>org.dromara</groupId> <artifactId>soul-spring-boot-starter-client-springmvc</artifactId> <version>2.2.1</version> </dependency>
复制代码
启动 soul-test --> soul-test-http --> SoulTestHttpApplication
请求复现
访问管理界面: http://localhost:9095/ ,查看插件列表 --> divide ,表现正常
访问问题链接: http://localhost:9195/http/order/findById?id=1 ,可以看到出现了 404
{ "timestamp": "2021-01-18T02:18:19.557+0000", "path": "/", "status": 404, "error": "Not Found", "message": null, "requestId": "84752141"}
复制代码
直接访问: http://localhost:8187/order/findById?id=11 ,正常的
{ "id": "11", "name": "hello world findById"}
复制代码
OK,到这问题基本复现,下面开始 debug
源码 Debug
查看日志进行切入
根据老哥的提示,我们也看到了这个问题请求的相关日志,大致如下
o.d.soul.plugin.base.AbstractSoulPlugin : divide selector success match , selector name :/httpo.d.soul.plugin.base.AbstractSoulPlugin : divide rule success match ,rule name :/http/order/findByIdo.d.s.plugin.httpclient.WebClientPlugin : you request,The resulting urlPath is :http://192.168.101.104:8187?id=1111
复制代码
最后一句 urlpath 非常的诡异,完整路径不对。我们就直接看下这个类: WebClientPlugin
# WebClientPlugin public Mono<Void> execute(final ServerWebExchange exchange, final SoulPluginChain chain) { final SoulContext soulContext = exchange.getAttribute(Constants.CONTEXT); assert soulContext != null; # 在这里debug看到取出来的路径是: http://192.168.101.104:8187?id=1111 String urlPath = exchange.getAttribute(Constants.HTTP_URL); if (StringUtils.isEmpty(urlPath)) { Object error = SoulResultWarp.error(SoulResultEnum.CANNOT_FIND_URL.getCode(), SoulResultEnum.CANNOT_FIND_URL.getMsg(), null); return WebFluxResultUtils.result(exchange, error); } long timeout = (long) Optional.ofNullable(exchange.getAttribute(Constants.HTTP_TIME_OUT)).orElse(3000L); log.info("you request,The resulting urlPath is :{}", urlPath); HttpMethod method = HttpMethod.valueOf(exchange.getRequest().getMethodValue()); WebClient.RequestBodySpec requestBodySpec = webClient.method(method).uri(urlPath); return handleRequestBody(requestBodySpec, exchange, timeout, chain); }
复制代码
在上面这个类中,可以看到就是单纯取路径,我们需要跟踪这个路径的来源
Divide 查看
在前面几篇分析中,我们知道 divide plugin 是进行路由配置,并写入真实路径到 exchange 中的,我们去 DividePlugin 看看
# DividePlugin protected Mono<Void> doExecute(final ServerWebExchange exchange, final SoulPluginChain chain, final SelectorData selector, final RuleData rule) { final SoulContext soulContext = exchange.getAttribute(Constants.CONTEXT); assert soulContext != null; final DivideRuleHandle ruleHandle = GsonUtils.getInstance().fromJson(rule.getHandle(), DivideRuleHandle.class); final List<DivideUpstream> upstreamList = UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId()); if (CollectionUtils.isEmpty(upstreamList)) { LOGGER.error("divide upstream configuration error:{}", rule.toString()); Object error = SoulResultWarp.error(SoulResultEnum.CANNOT_FIND_URL.getCode(), SoulResultEnum.CANNOT_FIND_URL.getMsg(), null); return WebFluxResultUtils.result(exchange, error); } final String ip = Objects.requireNonNull(exchange.getRequest().getRemoteAddress()).getAddress().getHostAddress(); DivideUpstream divideUpstream = LoadBalanceUtils.selector(upstreamList, ruleHandle.getLoadBalance(), ip); if (Objects.isNull(divideUpstream)) { LOGGER.error("divide has no upstream"); Object error = SoulResultWarp.error(SoulResultEnum.CANNOT_FIND_URL.getCode(), SoulResultEnum.CANNOT_FIND_URL.getMsg(), null); return WebFluxResultUtils.result(exchange, error); } //设置一下 http url : http://192.168.101.104:8187 String domain = buildDomain(divideUpstream); // 在这设置realURL,进去看看这个函数 String realURL = buildRealURL(domain, soulContext, exchange); // 放入exchange中 exchange.getAttributes().put(Constants.HTTP_URL, realURL); exchange.getAttributes().put(Constants.HTTP_TIME_OUT, ruleHandle.getTimeout()); return chain.execute(exchange); }
private String buildRealURL(final String domain, final SoulContext soulContext, final ServerWebExchange exchange) { String path = domain; // 在这取url,但通过debug发现,它确实是null final String rewriteURI = (String) exchange.getAttributes().get(Constants.REWRITE_URI); if (StringUtils.isNoneBlank(rewriteURI)) { path = path + rewriteURI; } else { // 然后又进到这进行取,发现也是null final String realUrl = soulContext.getRealUrl(); if (StringUtils.isNoneBlank(realUrl)) { path = path + realUrl; } } String query = exchange.getRequest().getURI().getQuery(); if (StringUtils.isNoneBlank(query)) { return path + "?" + query; } return path; }
复制代码
在上面的分析中,发现取出来的都是 null,而且没有看到 url 的设置之类的操作,divide 竟然也是单纯的取值
URL 设置探索
那我们需要继续探索 url 的是怎么设置进去的,通过上面的分析,目前有两者设置 url 的方式,如下面两段代码:
final String rewriteURI = (String) exchange.getAttributes().get(Constants.REWRITE_URI);final String realUrl = soulContext.getRealUrl();
复制代码
exchange.getAttributes().get(Constants.REWRITE_URI) 方式探索
我们类比响应的设置方式,可以得到第一种 URL 设置的方式大致如下:
exchange.getAttributes().put(Constants.CLIENT_RESPONSE_RESULT_TYPE, ResultEnum.SUCCESS.getName());
// 可以得到放Constants.REWRITE_URI的大致代码如下:exchange.getAttributes().put(Constants.REWRITE_URI
复制代码
然后使用全局搜索:ctrl+shift+r ,exchange.getAttributes().put(Constants.REWRITE_URI
# RewritePlugin protected Mono<Void> doExecute(final ServerWebExchange exchange, final SoulPluginChain chain, final SelectorData selector, final RuleData rule) { String handle = rule.getHandle(); final RewriteHandle rewriteHandle = GsonUtils.getInstance().fromJson(handle, RewriteHandle.class); if (Objects.isNull(rewriteHandle) || StringUtils.isBlank(rewriteHandle.getRewriteURI())) { log.error("uri rewrite rule can not configuration:{}", handle); return chain.execute(exchange); } exchange.getAttributes().put(Constants.REWRITE_URI, rewriteHandle.getRewriteURI()); return chain.execute(exchange); }
复制代码
搜索到唯一一处有这个代码的类: RewritePlugin ,然后我们打断点,然后并不能进入这个逻辑,查看控制台,它是关闭的。那就先放着,看第二种设置方式
soulContext.getRealUrl() 的设置探索
运用类比,我们可以猜测设置的代码应该是: soulContext.setRealUrl
我们进行搜索,也成功的找到了唯一的一处代码,在类 DefaultSoulContextBuilder 中,大致如下:
# DefaultSoulContextBuilder private void setSoulContextByHttp(final SoulContext soulContext, final String path) { String contextPath = "/"; String[] splitList = StringUtils.split(path, "/"); if (splitList.length != 0) { contextPath = contextPath.concat(splitList[0]); } String realUrl = path.substring(contextPath.length()); soulContext.setContextPath(contextPath); soulContext.setModule(contextPath); soulContext.setMethod(realUrl); // 设置url soulContext.setRealUrl(realUrl); }
复制代码
我们在这个函数上打上断点,然而非常不幸的是,也没有进入。瞬间头上???????,这是怎么肥事啊,都没设置
不抛弃不放弃,咋继续。看到 realURL 是从 path 来的,我们继续往上追求其来源,发现调用的是同一个类的下面这个函数 transform ,再上一层是 build
# DefaultSoulContextBuilder private SoulContext transform(final ServerHttpRequest request, final MetaData metaData) { final String appKey = request.getHeaders().getFirst(Constants.APP_KEY); final String sign = request.getHeaders().getFirst(Constants.SIGN); final String timestamp = request.getHeaders().getFirst(Constants.TIMESTAMP); SoulContext soulContext = new SoulContext(); String path = request.getURI().getPath(); soulContext.setPath(path); if (Objects.nonNull(metaData) && metaData.getEnabled()) { if (RpcTypeEnum.SPRING_CLOUD.getName().equals(metaData.getRpcType())) { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(metaData.getRpcType()); } else { setSoulContextByDubbo(soulContext, metaData); } } else { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(RpcTypeEnum.HTTP.getName()); } soulContext.setAppKey(appKey); soulContext.setSign(sign); soulContext.setTimestamp(timestamp); soulContext.setStartDateTime(LocalDateTime.now()); Optional.ofNullable(request.getMethod()).ifPresent(httpMethod -> soulContext.setHttpMethod(httpMethod.name())); return soulContext; }
public SoulContext build(final ServerWebExchange exchange) { final ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); MetaData metaData = MetaDataCache.getInstance().obtain(path); if (Objects.nonNull(metaData) && metaData.getEnabled()) { exchange.getAttributes().put(Constants.META_DATA, metaData); } return transform(request, metaData); }
复制代码
在 build 函数上打上断点,感谢老天,成功进入,通过调用栈发现,竟然是熟悉的 GlobalPlugin 进行调用的
# GlobalPlugin public Mono<Void> execute(final ServerWebExchange exchange, final SoulPluginChain chain) { final ServerHttpRequest request = exchange.getRequest(); final HttpHeaders headers = request.getHeaders(); final String upgrade = headers.getFirst("Upgrade"); SoulContext soulContext; if (StringUtils.isBlank(upgrade) || !"websocket".equals(upgrade)) { soulContext = builder.build(exchange); } else { final MultiValueMap<String, String> queryParams = request.getQueryParams(); soulContext = transformMap(queryParams); } exchange.getAttributes().put(Constants.CONTEXT, soulContext); return chain.execute(exchange); }
复制代码
在下面的函数打上端口,逐步 debug。在下面注释的地方可以看到:我们的是 HTTP 请求,但竟然走到 Dubbo 的逻辑里面去,这非常的不对劲
private SoulContext transform(final ServerHttpRequest request, final MetaData metaData) { // http://127.0.0.1:9195/http/order/findById?id=1111 final String appKey = request.getHeaders().getFirst(Constants.APP_KEY); final String sign = request.getHeaders().getFirst(Constants.SIGN); final String timestamp = request.getHeaders().getFirst(Constants.TIMESTAMP); SoulContext soulContext = new SoulContext(); String path = request.getURI().getPath(); soulContext.setPath(path); // 下面这个就神了,判断直接进到了setSoulContextByDubbo if (Objects.nonNull(metaData) && metaData.getEnabled()) { if (RpcTypeEnum.SPRING_CLOUD.getName().equals(metaData.getRpcType())) { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(metaData.getRpcType()); } else { // 应该是进到HTTP的,估计就这出错了 setSoulContextByDubbo(soulContext, metaData); } } else { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(RpcTypeEnum.HTTP.getName()); } soulContext.setAppKey(appKey); soulContext.setSign(sign); soulContext.setTimestamp(timestamp); soulContext.setStartDateTime(LocalDateTime.now()); Optional.ofNullable(request.getMethod()).ifPresent(httpMethod -> soulContext.setHttpMethod(httpMethod.name())); return soulContext; }
复制代码
我们使用下面的 diff 工具,看看最新版本的代码和目前版本有什么区别:
通过上图我们可以发现,最新版本中进行了更严谨的判断,并将默认的请求类型设置为了 HTTP,这样再新版本代码中,就能走 HTTP 的处理逻辑
我们将代码修改一下,将 HTTP 设置为默认处理,代码大致如下:
private SoulContext transform(final ServerHttpRequest request, final MetaData metaData) { final String appKey = request.getHeaders().getFirst(Constants.APP_KEY); final String sign = request.getHeaders().getFirst(Constants.SIGN); final String timestamp = request.getHeaders().getFirst(Constants.TIMESTAMP); SoulContext soulContext = new SoulContext(); String path = request.getURI().getPath(); soulContext.setPath(path); if (Objects.nonNull(metaData) && metaData.getEnabled()) { if (RpcTypeEnum.SPRING_CLOUD.getName().equals(metaData.getRpcType())) { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(metaData.getRpcType()); } else if (RpcTypeEnum.DUBBO.getName().equals(metaData.getRpcType())) { setSoulContextByDubbo(soulContext, metaData); } else { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(RpcTypeEnum.HTTP.getName()); } } else { setSoulContextByHttp(soulContext, path); soulContext.setRpcType(RpcTypeEnum.HTTP.getName()); } soulContext.setAppKey(appKey); soulContext.setSign(sign); soulContext.setTimestamp(timestamp); soulContext.setStartDateTime(LocalDateTime.now()); Optional.ofNullable(request.getMethod()).ifPresent(httpMethod -> soulContext.setHttpMethod(httpMethod.name())); return soulContext; }
复制代码
重启,发送请求: http://127.0.0.1:9195/http/order/findById?id=1111 ,OK,非常感人的成功了
{ "id": "1111", "name": "hello world findById"}
复制代码
到这,我们成功的定位并修复了这个错误(虽然没有啥用,但开心啊)
总结
本篇文章中对 Soul 网关 2.2.1 版本中 HTTP 请求出现 404 的错误进行了详细的分析
通过上面的分析可以看出,在 2.2.1 中,不是 Spring cloud 的 HTTP 请求,都会发生错误,这个 bug 还是有点厉害的
还认识到了 GlobalPlugin 这个插件的重要作用,不仅设置了类型,还设置了真实的后端服务器路径,可以说这个插件很核心。rewrite 插件也有设置路径这个功能
又有了新的认识,更新下我们请求处理图:
Soul 网关源码分析文章列表
Github
掘金
评论