8

跟踪源码来分析一个 empty file=404 的坑, WebFlux 我要鲨了你呀!

 3 years ago
source link: https://www.skypyb.com/2021/01/jishu/1729/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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 状态码响应。具体的代码就不贴了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK