跟踪源码来分析一个 empty file=404 的坑, WebFlux 我要鲨了你呀!
SpringCloudGateway 这东西在现在市面上用的还是不多的,毕竟这框架基于 WebFlux,而WebFlux是 Reactor 模式的架构, 和一般 Java 开发人形成的开发逻辑不太能匹配上, 学习曲线就比较陡峭。
可惜我就偏偏在使用到 SpringCloudGateway 这玩意的时候遇到了个坑, 并且还从Google搜到百度, 从官方文档看到StackOverflow, 都没一个人提到。指不定还有什么其他的坑没被人发现。 在没资料的情况下遇到问题大半只能靠自己看源码了, 最终发现其实和 Spring Cloud Gateway 关系不大, 完全是 WebFlux 的锅。下面就针对我遇到的问题结合其源码来走一道分析流程, 定位问题所在。
环境
- org.springframework.cloud:spring-cloud-starter-gateway:3.0.0
问题描述
这是我在使用 Spring Cloud Gateway 配置静态资源映射时遇到的问题
按照常规配置方法, 网关嘛, 做个路径模板和资源目录的映射就行了, 基本代码如下:
@Bean public RouterFunction<ServerResponse> staticResourceLocator(ResourceLoader resourceLoader) { if (prop.getResourceMapping().isEmpty()) return null; RouterFunctions.Builder builder = RouterFunctions.route(); prop.getResourceMapping().forEach((key, value) -> { logger.info("添加静态资源映射配置: [{}] -> [{}]", key, value); builder.add(RouterFunctions.resources(key, resourceLoader.getResource(value))); }); return builder.build(); }
单纯做个路径映射的反代而已, 将指定路径映射到另外的路径,这是作为网关最基本的需求,各项指标正常是应该的。
可是在某些 特殊情况 下, 你使用 Spring Cloud Gateway 会产生让你意想不到的事情 ( 实际上是 WebFlux 的 API 导致的 )。
那就是我遇到的这种情况:
- 获取为空的( != null )静态文件, 文件本身存在, 但实际上里边没有内容 (长度为零)。 反向代理到这种资源会直接返回404 状态码。
正常的话应该是返回一个 Content-length: 0 的200响应才对, 因为这文件真的存在啊。
无论是直接访问也好,传到阿里云OSS访问也好, 用Nginx在前面代理一层也好,都是我说的这种逻辑。 偏偏SpringCloudGateway 给你整个 404。 我也是不知道该怎么吐槽了。 这已经无法用 feature 来解释了, 我认为这是不合理的。
但在没有资料的情况下还能怎么办呢, 我又想解决这个问题,因为这个情况已经给业务带来了影响, 那我就只能从源码入手了。
前置提示
本片文章为记录向, 不会有名词解释&概念解释。 比如一些 SpringFramework 的相关知识点, 还有 Mono、 Flux 之类和 Reactor 相关的东西。
由于涉及到源码, 还是这种反应式的,会出现一大堆的链式调用。 可能对知识体系不够完善的同学不是很友好…
下面正式开始跟踪流程。
具体分析
我们首先需要确定的是: 请求在网关的哪个部分中断了?
相信熟悉 Spring MVC 架构的同学们应该会清楚 SpringMVC 有个叫 DispatcherServlet 的统一入口存在, 对于 WebFlux 也是一样的, 不过 WebFlux 的入口叫 DispatcherHandler.
DispatcherHandler#handle
方法为 WebFlux 的请求入口, 下面是源码:
@Override public Mono<Void> handle(ServerWebExchange exchange) { if (this.handlerMappings == null) { return createNotFoundError(); } return Flux.fromIterable(this.handlerMappings) .concatMap(mapping -> mapping.getHandler(exchange)) .next() .switchIfEmpty(createNotFoundError()) .flatMap(handler -> invokeHandler(exchange, handler)) .flatMap(result -> handleResult(exchange, result)); }
这里我根据断点后得知了情报: handlerMappings 没有映射到, 所以走了 switchIfEmpty 逻辑, 抛出了 404 错误 (但路径实际上是匹配的, 文件也真实存在)
既然知道了是因为 handlerMappings 没有匹配到, 那就是说所有的 handlerMappings 都遍历了一次, 全部返回的是 Empty Mono.
我们来看下 getHandler 方法源码。 获取静态资源的话, 实际上就是返回一个能获取资源的处理器,最终是委托给了抽象方法 getHandlerInternal () 由子类来实现, 典型的模板方法模式。
@Override public Mono<Object> getHandler(ServerWebExchange exchange) { return getHandlerInternal(exchange).map(handler -> { if (logger.isDebugEnabled()) { logger.debug(exchange.getLogPrefix() + "Mapped to " + handler); } ServerHttpRequest request = exchange.getRequest(); if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) { CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(exchange) : null); CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange); config = (config != null ? config.combine(handlerConfig) : handlerConfig); if (config != null) { config.validateAllowCredentials(); } if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) { return REQUEST_HANDLED_HANDLER; } } return handler; }); } //由子类实现的方法, 获取实际的处理器 protected abstract Mono<?> getHandlerInternal(ServerWebExchange exchange);
根据我断点看的信息,有一个叫做 RouterFunctionMapping 的对象匹配我创建的静态资源路径映射。那么就是说实际上对于我指定的路径的请求, 都会经过 RouterFunctionMapping 此实例。 并且由于我的路径映射是正确的,因为其他的资源均可以正常访问, 只有空文件返回了404而已, 所以问题肯定就出在这里。
看其源码, 他继承了AbstractHandlerMapping, 实现了 getHandlerInternal 方法来提供一个处理器
@Override protected Mono<?> getHandlerInternal(ServerWebExchange exchange) { if (this.routerFunction != null) { ServerRequest request = ServerRequest.create(exchange, this.messageReaders); return this.routerFunction.route(request) //就是这里返回的了 Empty Mono .doOnNext(handler -> setAttributes(exchange.getAttributes(), request, handler)); } else { return Mono.empty(); } }
RouterFunctionMapping 内部维护了一个 RouterFunction 类, 最终走的就是这个 RouterFunction 的route() 方法逻辑。route方法会返回一个 Mono<HandlerFunction<T>>
即一个处理器, 可以通过 ServletRequest 获取指定的响应。
这个类其实就是在配置里使用 RouterFunctions#resources
创建的类, 可以回到最顶上看资源配置的地方。
那么这里就需要点进 RouterFunctions 的源码,看看他这个 resources() 到底是返回了个什么东西
可以很轻松通过其源码得知: 创建时调用 RouterFunctions.resources(String, Resource) 首先通过 resourceLookupFunction() 创建出了一个 PathResourceLookupFunction 对象
public static RouterFunction<ServerResponse> resources(String pattern, Resource location) { return resources(resourceLookupFunction(pattern, location)); } //得到了这个 PathResourceLookupFunction 对象 public static Function<ServerRequest, Mono<Resource>> resourceLookupFunction(String pattern, Resource location) { return new PathResourceLookupFunction(pattern, location); }
然后将得到的 PathResourceLookupFunction 传入方法 resources, 创建出了 ResourcesRouterFunction 对象返回。
public static RouterFunction<ServerResponse> resources(Function<ServerRequest, Mono<Resource>> lookupFunction) { return new ResourcesRouterFunction(lookupFunction); }
ResourcesRouterFunction 是 RouterFunctions 的一个内部类,RouterFunctions.resources 方法就是将他创建并返回给了我们。 而他, 就是 RouterFunctionMapping 里边维护的 routeFunction 了, 也是一切的元凶。所以我们来看看他的源码:
private static class ResourcesRouterFunction extends AbstractRouterFunction<ServerResponse> { private final Function<ServerRequest, Mono<Resource>> lookupFunction; public ResourcesRouterFunction(Function<ServerRequest, Mono<Resource>> lookupFunction) { Assert.notNull(lookupFunction, "Function must not be null"); this.lookupFunction = lookupFunction; } @Override public Mono<HandlerFunction<ServerResponse>> route(ServerRequest request) { //lookupFunction 就是 PathResourceLookupFunction return this.lookupFunction.apply(request).map(ResourceHandlerFunction::new); } @Override public void accept(Visitor visitor) { visitor.resources(this.lookupFunction); } }
这个源码很好懂, 使用到了上面提到了的 PathResourceLookupFunction。而调用 PathResourceLookupFunction#apply 返回的是一个 Mono<Resource> 对象。 也就是我们需要的静态资源对象。至于之后创建的 ResourceHandlerFunction 就不用管了。 我看了他的源码,这个是用来将 Resource 封装成 ServerResponse 的, 根据其内部逻辑, GET 请求是不会返回空Mono的, 有兴趣的可以自己去看, 这里就不贴代码占篇幅了 。
至此。 已经定位到了整条逻辑链最底层的地方了,也就是获取 Resource 的地方。 现在我们只要知道为什么在 PathResourceLookupFunction 这个类上调用 apply() 方法, 会返回一个空的 Mono 对象就行了。
好的这里深入进去:
@Override public Mono<Resource> apply(ServerRequest request) { PathContainer pathContainer = request.requestPath().pathWithinApplication(); if (!this.pattern.matches(pathContainer)) { return Mono.empty(); } pathContainer = this.pattern.extractPathWithinPattern(pathContainer); String path = processPath(pathContainer.value()); if (path.contains("%")) { path = StringUtils.uriDecode(path, StandardCharsets.UTF_8); } if (!StringUtils.hasLength(path) || isInvalidPath(path)) { return Mono.empty(); } //上边都是对路径做校验和处理, 由于我的路径没有问题, 所以必定会走到这 try { //这儿已经创建出了含有资源完整路径的 Resource 实体 Resource resource = this.location.createRelative(path); // 特异点! if (resource.exists() && resource.isReadable() && isResourceUnderLocation(resource)) { return Mono.just(resource); } else { return Mono.empty(); } } catch (IOException ex) { throw new UncheckedIOException(ex); } }
好吧, 到这儿魔法已经解开了。 即上边代码中的特异点。
就是因为这三个判定中有一个返回了false, 导致最终整个方法返回了 Empty Mono。
到这整个分析流程也就差不多了, 根据我去一个个方法的源码进行阅读来看。最终发现是 isReadable 方法会在这种情况(空文件)下返回 false 。
isReadable 定义在 AbstractFileResolvingResource 上
@Override public boolean isReadable() { try { URL url = getURL(); if (ResourceUtils.isFileURL(url)) { // Proceed with file system resolution File file = getFile(); return (file.canRead() && !file.isDirectory()); } else { // Try InputStream resolution for jar resources URLConnection con = url.openConnection(); customizeConnection(con); if (con instanceof HttpURLConnection) { HttpURLConnection httpCon = (HttpURLConnection) con; int code = httpCon.getResponseCode(); if (code != HttpURLConnection.HTTP_OK) { httpCon.disconnect(); return false; } } long contentLength = con.getContentLengthLong(); if (contentLength > 0) { return true; } // 重点!!!! else if (contentLength == 0) { // Empty file or directory -> not considered readable... return false; } else { // Fall back to stream existence: can we open the stream? getInputStream().close(); return true; } } } catch (IOException ex) { return false; } }
结论
就是因为 AbstractFileResolvingResource#isReadable 返回了 false 导致的 PathResourceLookupFunction#apply 判定失败, 从而返回了 Empty Mono。 最终Empty Mono 从调用栈中一直传到顶层 DispatcherHandler , 走了 switchIfEmpty 逻辑, 从而返回404。
如果根据 isReadable 方法中的注释 “Empty file or directory -> not considered readable…” 来看, Resource 的逻辑是没问题的。 那么我将这个问题归根给 PathResourceLookupFunction 的 apply 方法。
所以说, 我真的是佛了。 魔法虽然是解开了, 可我此时只剩下一头雾水。
空文件是不可读。 但是这里对这个点做判定是我完全无法理解的。 作为一个路径映射, 明明文件存在,仅因为其不可读, 就返回 404 (文件不存在), 这怎么想都很奇怪啊???
填坑
毕竟源码我们也不能动, 如果要填这个坑的话, 我们就需要自己定义一个 Function<ServerRequest, Mono<Resource>> 来将一个 ServerRequest 转化为我们需要的 Resource。
因为 RouteFunctions 提供了相应的接口:
public static RouterFunction<ServerResponse> resources(Function<ServerRequest, Mono<Resource>> lookupFunction) { return new ResourcesRouterFunction(lookupFunction); }
我们只需要在进行资源配置的时候使用此方法即可。就直接传入我们自定义的 Function 对象。
这个对象呢, 因为逻辑实际上和 PathResourceLookupFunction 是一样的, 所以直接将其整个源码复制过来, 然后对其进行微调。
我就是这么干的, 最终果不其然完美解决了空文件返回404的问题。
已经可以成功的返回 Content-Length: 0 的 200 状态码响应。具体的代码就不贴了。
2 COMMENTS