故事发生在那一天:

刚想上床睡觉,室友突然问了一个问题:
“为什么我在资源目录下创建了error.html,使用重定向可以访问到,如果用其他名字就无法访问?”
这个问题把我问住了,既然遇到了疑问,那就上网搜一下,结果。。。没能搜到我想要的答案……
平时学习大部分看的是狂神的视频,可能是看多了,我竟然会想着学狂神自己研究一下底层原理(实在是想不开,咸鱼他不香吗?)
(此处安利一波狂神!B站搜遇见狂神说,wx公众号搜:狂神说)

第一次看底层源码,可能讲的有点乱,可以直接拉到最后面看总结

正文开始

当我们发送一个错误的请求路径时,会出现下图情况

springboot oom报错_java


通过断点调试,发现错误是由 spring boot 的 BasicErrorControlle 的 errorHtml 方法进行处理

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
	// 省略其他代码
    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
        // 获取状态码
		HttpStatus status = getStatus(request);
        // 获取 map 类型的只读视图
		Map<String, Object> model = 
            Collections.unmodifiableMap( getErrorAttributes( request, 
                                    getErrorAttributeOptions(request,MediaType.TEXT_HTML) ) );
        // 设置响应中的状态码
		response.setStatus(status.value());
        // 设置跳转视图
		ModelAndView modelAndView = resolveErrorView(request, response, status, model);
		return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
	}
}

很快哦,它先获取请求状态 status
HttpStatus status = getStatus(request); 来都来了就看一下getStatus源码吧

protected HttpStatus getStatus(HttpServletRequest request) {
        // 获取状态码
		Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
		if (statusCode == null) {
            // statusCode 为空时,返回的是 INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
			return HttpStatus.INTERNAL_SERVER_ERROR;
		}
		try {
            // statusCode 不为空时,进行判断
			return HttpStatus.valueOf(statusCode);
		}
		catch (Exception ex) {
			return HttpStatus.INTERNAL_SERVER_ERROR;
		}
}

可以看到,statusCode 非空时,来到枚举类 HttpStatus 的 valueOf() 方法,获取请求状态码

public enum HttpStatus {
    /**
	 * Return the enum constant of this type with the specified numeric value.
	 * @param statusCode the numeric value of the enum to be returned
	 * @return the enum constant with the specified numeric value
	 * @throws IllegalArgumentException if this enum has no constant for the specified numeric value
	 */
	public static HttpStatus valueOf(int statusCode) {
        
		HttpStatus status = resolve(statusCode);
		if (status == null) {
			throw new IllegalArgumentException("No matching constant for [" + statusCode + "]");
		}
		return status;
	}
    
    /**
	 * Resolve the given status code to an {@code HttpStatus}, if possible.
	 * @param statusCode the HTTP status code (potentially non-standard)
	 * @return the corresponding {@code HttpStatus}, or {@code null} if not found
	 * @since 5.0
	 */
	@Nullable
	public static HttpStatus resolve(int statusCode) {
        // values() 在 stackoverflow 上查到该方法返回所有枚举常量的数组, 它是隐式定义的(由编译器生成)
		for (HttpStatus status : values()) {
			if (status.value == statusCode) {
				return status;
			}
		}
		return null;
	}
}

上面已经获取到请求的状态码了,接下来来到这个

Map<String, Object> model = 
  // 返回一个 map的只读的视图  
  Collections.unmodifiableMap( 
    	getErrorAttributes( request, getErrorAttributeOptions(request,MediaType.TEXT_HTML) ) 
	);

我们看看 getErrorAttributes 是干啥的先

/**
	 * Returns a {@link Map} of the error attributes.
	 * @param request the source request
	 * @param includeStackTrace if stack trace elements should be included
	 * @return the error attributes
	 * {@link #getErrorAttributes(HttpServletRequest, ErrorAttributeOptions)}
	 */
    protected Map<String,Object> getErrorAttributes(
        HttpServletRequest request, ErrorAttributeOptions options){
            WebRequest webRequest = new ServletWebRequest(request);
            return this.errorAttributes.getErrorAttributes(webRequest, options);
    }

我们继续一步一步进去看源码,可以发现该方法返回了 错误属性的 map,可以用作错误页面的 model , 或者以 @ResponseBody 返回(源代码注释是这么写的or returned as a {@link ResponseBody @ResponseBody}.)。

继续查看 Collections.unmodifiableMap 的源码

public class Collections {
/**
     * Returns an unmodifiable view of the specified map.  This method
     * allows modules to provide users with "read-only" access to internal
     * maps.  Query operations on the returned map "read through"
     * to the specified map, and attempts to modify the returned
     * map, whether direct or via its collection views, result in an
     * <tt>UnsupportedOperationException</tt>.<p>
     *
     *
     * The returned map will be serializable if the specified map
     * is serializable.
     *
     * @param <K> the class of the map keys
     * @param <V> the class of the map values
     * @param  m the map for which an unmodifiable view is to be returned.
     * @return an unmodifiable view of the specified map.
     */
     public static <K,V> Map<K,V> unmodifiableMap(Map<? extends K, ? extends V> m) {
         return new UnmodifiableMap<>(m);
     }
    
     private static class UnmodifiableMap<K,V> implements Map<K,V>, Serializable {
        private static final long serialVersionUID = -1034234728574286014L;
        private final Map<? extends K, ? extends V> m;
        UnmodifiableMap(Map<? extends K, ? extends V> m) {
            if (m==null)
                throw new NullPointerException();
            this.m = m;
        }
     }
}

看注释我们可以知道拿到了一个map的只读视图。但是,这个map到底是装了啥?看注释也是一脸懵逼,不过利用 Debugger 可以看到

springboot oom报错_spring_02


原来这个map装了时间戳(timestamp),请求的状态(status),错误信息(error),请求报文(message),请求的相对路径(path),看到这个是不是有点头绪了?没有的话继续往下看

接下来是设置响应的 status

response.setStatus(status.value());

到这里,我们已经有了 请求(request),响应(response),状态码(status),map的只读视图(model),继续走~

/**
	 * Resolve any specific error views. By default this method delegates to
	 * {@link ErrorViewResolver ErrorViewResolvers}.
	 * @param request the request
	 * @param response the response
	 * @param status the HTTP status
	 * @param model the suggested model
	 * @return a specific {@link ModelAndView} or {@code null} if the default should be
	 * used
	 * @since 1.4.0
	 */
	protected ModelAndView resolveErrorView(HttpServletRequest request, HttpServletResponse response, 
                                            HttpStatus status, Map<String, Object> model) {
		for (ErrorViewResolver resolver : this.errorViewResolvers) {
            // 解析指定详细信息的错误页面
			ModelAndView modelAndView = resolver.resolveErrorView(request, status, model);
			if (modelAndView != null) {
				return modelAndView;
			}
		}
		return null;
	}

进来 spring 写的默认的错误视图解析器 (DefaultErrorViewResolver)康康

public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, 
                                     Map<String, Object> model) {
    	// 视图解析
		ModelAndView modelAndView = resolve(String.valueOf(status.value()), model);
		if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) {
			modelAndView = resolve(SERIES_VIEWS.get(status.series()), model);
		}
		return modelAndView;
}

private ModelAndView resolve(String viewName, Map<String, Object> model) {
    	
		String errorViewName = "error/" + viewName;
    	// 获取可用于渲染给定视图的 provider(这里不知道应该翻译为啥,翻译为提供程序感觉太奇怪了)。
		TemplateAvailabilityProvider provider = this
            .templateAvailabilityProviders
            .getProvider(errorViewName,this.applicationContext);
		if (provider != null) {
			return new ModelAndView(errorViewName, model);
		}
		return resolveResource(errorViewName, model);
	}

看到上面这行代码了吗? return new ModelAndView(errorViewName, model); 再看看 ModelAndView 的构造方法

public ModelAndView(String viewName, @Nullable Map<String, ?> model) {
		// 跳转的视图名称
    	this.view = viewName;
    	// 用 model 封装发送到前端的数据
		if (model != null) {
			getModelMap().addAllAttributes(model);
		}
}

所以,前面的 map 类型的只读视图的作用懂了吧。还不明白我们来看这个图

springboot oom报错_java_03


咱们看一下 BasicErrorControlle 的 errorHtml 方法的返回

return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);

如果 modelAndView 为空时,spring 创建了一个新的modelAndView,可以看到 new ModelAndView(“error”, model);

视图名称是 error

发现问题了吗?这个新创建的错误视图的名称跟前面的不一样,前面的 errorViewName"error/" + viewName;,

这后者的 errorViewNameerror,对比发现,后者少了一个目录,也就是我们在 Templates 下的 error.html (如果你有创建的话)

问题来了,什么时候它会是空的呢?咱们继续看DefaultErrorViewResolver源码

// 这个方法中主要是解析错误请求路径时跳转的视图和请求内容
public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, 
                                     Map<String, Object> model) {
    	// 视图解析
		ModelAndView modelAndView = resolve(String.valueOf(status.value()), model);
		if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) {
			modelAndView = resolve(SERIES_VIEWS.get(status.series()), model);
		}
		return modelAndView;
}

// 这个方法主要是通过 errorViewName 来判断要跳转的视图是否存在,存在则返回一个对象,否则返回空
private ModelAndView resolve(String viewName, Map<String, Object> model) {
		String errorViewName = "error/" + viewName;
    	// 获取可用于渲染给定视图的 provider(这里不知道应该翻译为啥,机器翻译为提供程序,感觉太奇怪了)。
		TemplateAvailabilityProvider provider = this
            .templateAvailabilityProviders
            .getProvider(errorViewName,this.applicationContext);
		if (provider != null) {
			return new ModelAndView(errorViewName, model);
		}
		return resolveResource(errorViewName, model);
	}

我们先来看一下 provider 的查找策略

// 获取渲染给定视图的 provider

	/**
	 * Get the provider that can be used to render the given view.
	 * @param view the view to render
	 * @param environment the environment
	 * @param classLoader the class loader
	 * @param resourceLoader the resource loader
	 * @return a {@link TemplateAvailabilityProvider} or null
	 */
	public TemplateAvailabilityProvider getProvider(String view, Environment environment,
                                                    ClassLoader classLoader,ResourceLoader resourceLoader) {
		Assert.notNull(view, "View must not be null");
		Assert.notNull(environment, "Environment must not be null");
		Assert.notNull(classLoader, "ClassLoader must not be null");
		Assert.notNull(resourceLoader, "ResourceLoader must not be null");
        // 获取 spring.template.provider.cache 的值( 热部署使用 ,默认值是false)
        // Map from view name resolve template view, synchronized when accessed.
		Boolean useCache = environment.getProperty("spring.template.provider.cache", Boolean.class, true);
        if (!useCache) {
            // 使用热部署的查找策略
			return findProvider(view, environment, classLoader, resourceLoader);
		}
        // 未开启热部署,通过视图名查找对应的 provider 
		TemplateAvailabilityProvider provider = this.resolved.get(view);
        // 如果不存在,采用热部署的查找策略
		if (provider == null) {
			synchronized (this.cache) {
				provider = findProvider(view, environment, classLoader, resourceLoader);
				provider = (provider != null) ? provider : NONE;
				this.resolved.put(view, provider);
				this.cache.put(view, provider);
			}
		}
        
       // provider 如果不是NoTemplateAvailabilityProvider(),则说明热部署查找策略成功找到
		return (provider != NONE) ? provider : null;
	}

// 热部署开启时的查找策略如下
	private TemplateAvailabilityProvider findProvider(String view, Environment environment, 
                                             ClassLoader classLoader,ResourceLoader resourceLoader) {
		for (TemplateAvailabilityProvider candidate : this.providers) {
            // 查找是否存在
			if (candidate.isTemplateAvailable(view, environment, classLoader, resourceLoader)) {
				return candidate;
			}
		}
		return null;
	}

	@Override
	public boolean isTemplateAvailable(String view, Environment environment,
                                       ClassLoader classLoader,ResourceLoader resourceLoader) {
		// 是否存在 SpringTemplateEngine 
        if (ClassUtils.isPresent("org.thymeleaf.spring5.SpringTemplateEngine", classLoader)) {
			String prefix = environment
                .getProperty("spring.thymeleaf.prefix", ThymeleafProperties.DEFAULT_PREFIX);
			String suffix = environment
                .getProperty("spring.thymeleaf.suffix", ThymeleafProperties.DEFAULT_SUFFIX);
			// 拼接视图名称并在资源目录下查找
            return resourceLoader.getResource(prefix + view + suffix).exists();
		}
		return false;
	}

由上述分析可知,

① 当我们开启热部署的时候,spring会优先使用 findProvider() 方法去查找要跳转的视图

② 如果我们没有开启热部署,spring会从 Map<String, TemplateAvailabilityProvider> 对象里面去查找是否存在 key 值为 view 的 provider,当查找不到时,使用热部署的查找策略,依旧查找不到时,将其赋为 new NoTemplateAvailabilityProvider();并在TemplateAvailabilityProviders 中的 List providers 和 Map<String, TemplateAvailabilityProvider> cache 中保存 key 值 为 view 的 provider

最后进行判断,provider 是否是 NoTemplateAvailabilityProvider() 类型,或者说是 TemplateAvailabilityProviders 的 NONE 属性的值,如果是,说明热部署查找策略成功找到,此时执行如下代码

if (provider != null) {
    // 返回存有找到的视图和请求内容的 ModelAndView 对象
	return new ModelAndView(errorViewName, model);
}

否则,执行

return resolveResource(errorViewName, model);

看 resolveResources 代码

private ModelAndView resolveResource(String viewName, Map<String, Object> model) {
		for (String location : this.resourceProperties.getStaticLocations()) {
			try {
                // 调用 ResourceLoader 方法
				Resource resource = this.applicationContext.getResource(location);
				// 调用 ClassPathResource 方法
                resource = resource.createRelative(viewName + ".html");
				if (resource.exists()) {
					return new ModelAndView(new HtmlResourceView(resource), model);
				}
			}
			catch (Exception ex) {
			}
		}
		return null;
	}

this.resourceProperties.getStaticLocations() 这里面存放的是静态资源目录,如下图

springboot oom报错_spring_04


spring 在确定完路径后,会创建相对此资源的资源,resource = resource.createRelative(viewName + ".html");

@Override
	public Resource createRelative(String relativePath) {
        // 将相对路径应用到给定的Java资源路径,文件夹以分隔符“/”分离
		String pathToUse = StringUtils.applyRelativePath(this.path, relativePath);
        // 生成资源
		return (this.clazz != null ? new ClassPathResource(pathToUse, this.clazz) :
				new ClassPathResource(pathToUse, this.classLoader));
	}

// ClassPathResource 的构造方法
// this.clazz : the class to load resources with, if any
// this.classLoader : the class loader to load the resource with, if any
// 该类主要是实现类路径资源的资源,使用指定的类加载器或给定的类去加载资源
public ClassPathResource(String path, @Nullable ClassLoader classLoader) {
		Assert.notNull(path, "Path must not be null");
		String pathToUse = StringUtils.cleanPath(path);
		if (pathToUse.startsWith("/")) {
			pathToUse = pathToUse.substring(1);
		}
		this.path = pathToUse;
		this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
	}

生成的资源如下:

springboot oom报错_java_05


(其他静态资源目录类似,此处不再展示)

静态资源创建好后就进行判断该资源是否存在,
resource.exists(),实际上调用的是 ClassPathResource 的 resolveURL() 方法

// ClassPathResource
	// 此方法实现检查资源URL的解析。
	@Override
	public boolean exists() {
		return (resolveURL() != null);
	}

	// 解析底层类路径资源的URL。
	@Nullable
	protected URL resolveURL() {
		if (this.clazz != null) {
            // 通过当前类的getResource方法查找,该方法通过指定的name去查找
			return this.clazz.getResource(this.path);
		}
		else if (this.classLoader != null) {
            // 通过要加载的类加载器去查找资源
			return this.classLoader.getResource(this.path);
		}
		else {
            // 从用于装入类的搜索路径中查找指定名称的资源。
            // 这个方法通过系统类装入器(参见{@link #getSystemClassLoader()})来定位资源。
			return ClassLoader.getSystemResource(this.path);
		}
	}

// Class.java
/* 
	Finds a resource with a given name.
	The rules for searching resources associated with a given class are implemented by the defining
	{@linkplain ClassLoader class loader} of the class. 
	This method delegates to this object's class loader. 
    If this object was loaded by the bootstrap class loader, the method delegates to
    {@link ClassLoader#getSystemResource}.
*/
public java.net.URL getResource(String name) {
        name = resolveName(name);
        ClassLoader cl = getClassLoader0();
        if (cl==null) {
            // A system class.
            return ClassLoader.getSystemResource(name);
        }
        return cl.getResource(name);
    }

// ClassLoader
	// 通过给定的名字查找资源路径
	// parent 是类加载器 用于委托的父类装入器
	public URL getResource(String name) {
        URL url;
        if (parent != null) {
            // 父类的类加载器存在
            url = parent.getResource(name);
        } else {
            // Find resources from the VM's built-in classloader.
            // 从 Java 虚拟机的内置类加载器查找资源
            url = getBootstrapResource(name);
        }
        if (url == null) {
            // 查找资源,方法返回的是null
            url = findResource(name);
        }
        return url;
    }

由上述代码可以知道,spring会通过类和类加载器的特定方法(如 getResource() )去查找静态资源,如果存在则返回url,否则返回空。

当以 viewName + ".html"创建的资源存在时,则 return new ModelAndView(new HtmlResourceView(resource), model);

否则返回 return null;

回过头来看 resolveErrorView

protected ModelAndView resolveErrorView(HttpServletRequest request, HttpServletResponse response,
                                        HttpStatus status,Map<String, Object> model) {
		for (ErrorViewResolver resolver : this.errorViewResolvers) {
            // 此处的返回结果就是上面分析的结果
            // 当资源不存在时,返回空
            // 存在时,返回对应目录下的资源和响应内容的 ModelAndView 对象
			ModelAndView modelAndView = resolver.resolveErrorView(request, status, model);
			if (modelAndView != null) {
				return modelAndView;
			}
		}
		return null;
	}

终于来到了最后这一步

return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);

如果查找的静态资源存在,则返回对应的 ModelAndView 对象,否则直接返回 new ModelAndView("error", model);

后者在前面讲过了,此处不再重复讲述。重点看前者,它的路径到底可以是啥?

还记得这个图吗?

springboot oom报错_ide_06


还有这个

springboot oom报错_java_07


errorViewName = "error/" + viewName;,viewName 也就是请求的状态码(如 404,500 ……)

所以呢,完整的路径应该是这个

classpath:/META-INF/resources/error/viewName


classpath:/resources/error/viewName (如果用了 thyme leaf模板引擎,resources 相当于 templates ,因为我们修改了静态资源的访问路径)


classpath:/static/error/viewName


classpath:/public/error/viewName

到这里,我们的分析就结束了~~

总结

简而言之,当请求路径出现错误时,spring 的 BasicErrorController 会进行处理,最后跳转的页面的路径有以下情况

classpath:/META-INF/resources/error/viewName

classpath:/resources/error/viewName  
(如果用了 thymeleaf 模板引擎,resources 相当于 templates ,因为我们修改了静态资源的访问路径spring.thymeleaf.prefix=classpath:/templates/)

classpath:/static/error/viewName(.html)

classpath:/public/error/viewName(.html)
    
classpath:/error(.html)
viewName的命名需要按照官方给的命名标准(如果没有自定义错误解析的话)
viewName 需换为 4xx / 5xx 
比如 404 500