Spring Boot 中配置单页应用

本文撰写时的 Spring 版本: Spring Boot 2.1.0.RELEASE

有的时候我们不得不把一个单页应用(例如 react-router)与 Spring-Boot 后端一起打包. 但是这样就会有一个问题, 一旦用户不在首页刷新页面, 就会看到一个 404 画面, 因为服务端并没有把请求转向 index.html.

所以我们通过一些配置, 让 Spring 在找不到对应的资源文件的情况下, 将请求统统转向到 index.html, 这样用户就可以前端路由了.

我们很容易想到让 Spring 找不到资源文件时抛出一个异常然后我们配置一个 ControllerAdvice, 于是我们搜到了这么一条配置

spring.mvc.throw-exception-if-no-handler-found=true

但是我们很快就发现, 实际上 NoHandlerException 永远不会被抛出.

我们来看一下 Spring 是如何路由请求的, 我们打开 DispatcherServlet 的源码

@Nullable
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    if (this.handlerMappings != null) {
        for (HandlerMapping mapping : this.handlerMappings) {
            HandlerExecutionChain handler = mapping.getHandler(request);
            if (handler != null) {
                return handler;
            }
        }
    }
    return null;
}

默认情况下, HandlerMapping 有七个, 分别为

SimpleUrlHandlerMapping
WebMvcEndpointHandlerMapping
ControllerEndpointHandlerMapping
RequestMappingHandlerMapping
BeanNameUrlHandlerMapping
WelcomePageHandlerMapping
SimpleUrlHandlerMapping

第一个 SimpleUrlHandlerMapping 用于处理 /favicon.ico 这就是为什么目录里没有 icon, 也会有默认的站点图标.

而最下面的第二个 SimpleUrlHandlerMapping 用于处理资源文件, 而资源文件都是 lazy loading 的, 并不是像 HomePage(index.html)(硬编码) 或者 favicon.ico(硬编码) 那样是一开始就加载好的. 所以第二个 SimpleUrlHandlerMappinggetHandler 方法永远都会有一个指向所有资源文件路径的 HandlerExecutionChain 被返回回来.

因此即使目标资源文件不存在, 我们的 Handler 也不是 null, 永远也不会有 NoHandlerException 被抛出.

而在一些解决方案中, 是把默认的 ResourceHandler 关掉,

spring.resources.add-mappings=false

这样最后一个 HandlerMapping 就不会有了, 异常就会被抛出了.

但是这么做的话, 我们就要面对另一个更大的问题, 没有了默认的 ResourceHandler, 我们的资源文件就不能被正常读取, 我们需要自己编写额外写代码来让他们能被读取, 这完全是重复编码.

所以正确的解决方案只能是让最后一个 SimpleUrlHandlerMapping 在找不到目标资源的情况下, 读取 index.html 交给访问者.

我们通过一个自定义的 ResourceHandler 来实现它

@Configuration
open class SinglePageApplicationWebMvcConfiguration(
        private val resourceProperties: ResourceProperties
) : WebMvcConfigurer {
    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        registry.addResourceHandler("/**")
                .addResourceLocations(*resourceProperties.staticLocations)
                .resourceChain(true)
                .addResolver(object : PathResourceResolver() {
                    override fun getResource(resourcePath: String, location: Resource): Resource? =
                            super.getResource(resourcePath, location) ?: super.getResource("index.html", location)
                })
    }
}

注意, spring.resources.static-locations 并非是硬编码的, 而是在配置文件中可以修改的, 所以我们要从配置文件中得到它.

ResourceHandler 将监听 /** 地址(注意, RequestMappingHandlerMapping 一定是先于最后一个 SimpleUrlHandlerMapping 被执行的, 所以访问 RestFul API 的请求不会进入 ResourceHandler), 当目标资源不存在时, 将返回 index.html, 如果 index.html 也不存在, 将产生 404.

addResourceLocations 添加的资源位置, 会让 Resolver 在每个资源位置都被轮询一次, 所以不会因为用户额外添加了 static-location 而导致错误.

resourceChain(true) 设置了资源位置缓存, 例如本次访问了 index.js 并在 /public 位置被找到, 那么下次将直接从该位置读取该资源文件, Resolver 不会被执行.

这样, 我们就在不重写 Spring 默认逻辑的情况下将所有未识别的访问引导到 index.html, 从而完成了单页应用的配置.

接下去, 用户将在前端被路由, 从而看到正确的页面.