首页 > 经验记录 > 跟踪源码来分析一个 empty file=404 的坑, WebFlux 我要鲨了你呀!

跟踪源码来分析一个 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 状态码响应。具体的代码就不贴了。

           


1 COMMENT

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00