Spring 并且 SiteMesh 错误页面未修饰(跳过主要过滤器)

Spring and SiteMesh Error Page is not decorated (skips main filters)

几天来我一直在为一个相当荒谬的问题而苦苦挣扎: 我正在进行的项目正在使用 Spring MVC 和 FreeMarker 作为模板。

这是 运行 在 Tomcat 容器上(使用 Cargo 在本地测试)。

我正在处理的问题有在标准化错误页面中实现统一行为的简要说明,但涵盖了可能遇到的各种类型的错误。 (后端服务冒出异常,权限不足,http错误等)

目前,结果如下(含图):

目前我们正在使用 Spring 配置 servlet 处理,因此 web.xml 非常稀疏:

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">

<!--
 This application uses the config of the mapping by Spring MVC
 This is why you will not see servlet declarations here

 The web app is defined in 
 - butler.SpringWebInit
 - butler.SpringWebConfig
 -->

    <context-param>
        <description>Escape HTML form data by default when using Spring tags</description>
        <param-name>defaultHtmlEscape</param-name>
        <param-value>true</param-value>
    </context-param>

<!-- Disabling welcome list file for Tomcat, handling it in Spring MVC -->
    <welcome-file-list>
        <welcome-file/>
    </welcome-file-list>

<!-- Generic Error redirection, allows for handling in Spring MVC -->
    <error-page>
        <location>/http-error</location>
        <!-- Was originally just "/error" it was changed for internal forwarding/proxying/redirection attempts -->
    </error-page>
</web-app>

配置由 SpringWebInit.java 处理,我没有对其进行任何修改:

SpringWebInit.java

/**
 * Automatically loaded by class org.springframework.web.SpringServletContainerInitializer
 * 
 * @see http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#mvc-container-config
 * 
 *      According to {@link AbstractSecurityWebApplicationInitializer}, this class should be
 *      annotated with a Order so that it is loaded before {@link SpringSecurityInit}
 */
@Order(0)
public class SpringWebInit extends AbstractAnnotationConfigDispatcherServletInitializer implements InitializingBean {
  private final Logger LOG = LoggerFactory.getLogger(getClass());

  @Override
  public void afterPropertiesSet() throws Exception {
    LOG.info("DispatcherServlet loaded");
  }

  @Override
  protected Class<?>[] getServletConfigClasses() {
    return null; // returning null, getRootConfigClasses() will handle this as well
  }

  @Override
  protected String[] getServletMappings() {
    return new String[] {"/**"}; // Spring MVC should handle everything
  }

  @Override
  protected Class<?>[] getRootConfigClasses() {
    return new Class[] {SpringWebConfig.class, SpringSecurityConfig.class};
  }

  @Override
  protected Filter[] getServletFilters() {
    CharacterEncodingFilter characterEncodingFilter =
        new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true);
    return new Filter[] {characterEncodingFilter, new SiteMeshFilter()};
  }

}

依次加载 Freemarker 和 Sitemesh 的各种配置:

SpringWebConfig.java

@EnableWebMvc
@Configuration
@PropertySource("classpath:/butler-init.properties")
@ComponentScan({"butler"})
class SpringWebConfig extends WebMvcConfigurerAdapter implements InitializingBean {
  private final Logger LOG = LoggerFactory.getLogger(getClass());

  @Autowired
  LoggedInUserService loggedInUserService;

  @Override
  public void afterPropertiesSet() throws Exception {
    LOG.info("Web Mvc Configurer loaded");
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(userHeaderInterceptor());
  }

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/static/**").addResourceLocations("/static/").setCacheControl(
        CacheControl.maxAge(30, TimeUnit.MINUTES).noTransform().cachePublic().mustRevalidate());
  }

  @Bean
  FreeMarkerViewResolver viewResolver() throws TemplateException {
    FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
    resolver.setCache(/*true*/false); // Set to false for debugging
    resolver.setPrefix("");
    resolver.setSuffix(".ftlh");
    resolver.setRequestContextAttribute("rContext");
    resolver.setContentType("text/html;charset=UTF-8");

    DefaultObjectWrapper wrapper =
        new DefaultObjectWrapperBuilder(freemarker.template.Configuration.getVersion()).build();
    Map<String, Object> attrs = new HashMap<>();
    attrs.put("loggedInUserService", wrapper.wrap(loggedInUserService));
    resolver.setAttributesMap(attrs);

    return resolver;
  }

  @Bean
  FreeMarkerConfigurer freeMarkerConfig() {
    Properties freeMarkerVariables = new Properties();
    // http://freemarker.org/docs/pgui_config_incompatible_improvements.html
    // http://freemarker.org/docs/pgui_config_outputformatsautoesc.html
    freeMarkerVariables.put(freemarker.template.Configuration.INCOMPATIBLE_IMPROVEMENTS_KEY,
        freemarker.template.Configuration.getVersion().toString());

    FreeMarkerConfigurer freeMarkerConfigurer = new FreeMarkerConfigurer();
    freeMarkerConfigurer.setDefaultEncoding("UTF-8");
    freeMarkerConfigurer.setTemplateLoaderPath("/WEB-INF/mvc/view/ftl/");
    freeMarkerConfigurer.setFreemarkerSettings(freeMarkerVariables);
    return freeMarkerConfigurer;
  }

  @Bean
  UserHeaderInterceptor userHeaderInterceptor() {
    return new UserHeaderInterceptor();
  }

  @Bean
  static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
    return new PropertySourcesPlaceholderConfigurer();
  }
}

SiteMeshFilter.java

public class SiteMeshFilter extends ConfigurableSiteMeshFilter {

  @Override
  protected void applyCustomConfiguration(SiteMeshFilterBuilder builder) {    

    // Don't use decorator REST api pages
    builder.addExcludedPath("/api/*");

    builder.addDecoratorPath("/*", Views.DECORATOR_HEADER_FOOTER);
    builder.setIncludeErrorPages(true);
  }
}

最后,进入问题的核心,错误处理是通过 DefaultControllerAdvice.java 和 ErrorController.java 本身的组合来处理的,DefaultControllerAdvice.java 提供拦截异常的规则,而 ErrorController.java 本身负责处理映射最后,消息处理(显示有关错误的信息,根据错误类型进行调整等)

默认ControllerAdvice.java

@ControllerAdvice(annotations = Controller.class)
class DefaultControllerAdvice {

  private static String EXCEPTION = "butlerexception";

  @ExceptionHandler(ServiceException.class)
  public String exceptionHandler(ServiceException se, Model model) {
    model.addAttribute(EXCEPTION, se.getMessage());
    return Views.ERROR;
  }

  @ExceptionHandler(PermissionException.class)
  public String exceptionHandler(PermissionException pe, Model model) {
    model.addAttribute(EXCEPTION, "Incorrect Permissions");
    return Views.ERROR;
  }

  /*@ResponseStatus(HttpStatus.NOT_FOUND)
  @ExceptionHandler(IOException.class)
  public String exceptionHandler(Model model) { // Trying another way of intercepting 404 errors
    model.addAttribute(EXCEPTION, "HTTP Error: 404");
    return Views.ERROR;
  }*/
}

ErrorController.java

@Controller
class ErrorController extends AbstractController {

  @Autowired
  private LoggedInUserService loggedInUserService;

  @RequestMapping(path="error",method = {GET,POST}) // Normal Error Controller, Returns fully decorated page without issue for Exceptions and normal requests.
  public String error(RedirectAttributes redirectAttributes, HttpServletResponse response,Model model) {
    //if (redirectAttributes.containsAttribute("errorCode")) { // Trying to invisibly use redirection
    //  Map<String, ?> redirAttribs = redirectAttributes.getFlashAttributes();
    //  model.addAttribute("butlerexception", "HTTP Error: "+redirAttribs.get("errorCode"));
    //} else {
    model.addAttribute("butlerexception", "Error");
    //}
    return ERROR;
  }

  @RequestMapping("/http-error") // Created to test HTTP requests being proxied via ServiceExceptions, Redirections, etc...
  public String httpError(/*RedirectAttributes redirectAttributes,*/ HttpServletResponse response, HttpServletRequest request, Model model){
    model.addAttribute("butlerexception", "HTTP Error: " + response.getStatus());

    //throw new ServiceException("HTTP Error: " + response.getStatus()); // Trying to piggyback off Exception handling

    //redirectAttributes.addFlashAttribute("errorCode", response.getStatus()); // Trying to invisibly use redirection
    //redirectAttributes.addFlashAttribute("originalURL",request.getRequestURL());
    return /*"redirect:"+*/ERROR;
  }
}

到目前为止,我已经尝试过:

此外,从调试日志中,我可以看到来自 Spring 安全的过滤器被正常触发,但涉及装饰网站的过滤器(对于登录请求和匿名请求)无法触发 HTTP仅错误。

目前的一个限制因素是我无法在 web.xml 中对系统进行全面的定义(因为这里和 Spring 文档中的许多解决方案似乎都需要)在此阶段不会对开发造成过度干扰。 (我也无权进行这样的更改(初级))

为方便起见,目前我尝试过的一些解决方案:

此时我真的不确定还能尝试什么,我到底错过了什么?

编辑:事实证明这是 SiteMesh 中与触发 .setContentType(...) 有关的错误,通过在 sitemesh 之后再次设置 contentType 以触发装饰来解决该错误:Bug report with description and solution

这变成了一个两部分的问题,首先,SiteMesh3 对错误页面的处理意味着它认为它已经处理了所有过滤器,即使错误导致装饰器被跳过。 (expanded upon in this issue on github)

第二部分是当 SpringMVC 调用 .setContentType(...).

时,SiteMesh3 似乎只缓冲页面进行装饰

这是错误的,因为 Spring 只会在具有未定义内容类型的元素上触发它,而错误甚至在到达 Spring 之前就已经定义了它们的内容类型。 (expanded upon by my lead in this issue)

我的领导设法解决了这个问题,方法是在触发 .setContentType(...) 的 SiteMesh 之后添加一个过滤器,并强制 SiteMesh 缓冲页面以进行装饰。

有点重,因为这意味着每个请求都设置了两次内容类型,但它确实有效。


编辑:最初在这里有一条注释要求不要投票以避免收到我的领导找到的解决方案的代表,但发现一个博客 post 解释说自我回答不会赚钱代表 - 欢呼!

解决方案 1:

检查您是否已禁用 属性 spring.resources.add-mappings=false。启用它可以解决问题。但在我的例子中,启用它完全删除了自定义错误页面。

解决方案 2:

根据对 github 问题 https://github.com/sitemesh/sitemesh3/issues/25 的评论,在您的 SiteMeshFilter 中声明自定义选择器:

public class SiteMeshFilter extends ConfigurableSiteMeshFilter {

    @Override
    protected void applyCustomConfiguration(SiteMeshFilterBuilder builder) {
        builder.setCustomSelector(new CustomBasicSelector());
    }
    
    private static class CustomBasicSelector extends BasicSelector {
        private static final String ALREADY_APPLIED_KEY = BasicSelector.class.getName() + ".APPLIED_ONCE";
        public CustomBasicSelector() {
            super(true, "text/html");
        }
        
        protected boolean filterAlreadyAppliedForRequest(HttpServletRequest request) {
            if (request.getDispatcherType().equals(DispatcherType.ERROR)) {
                if (Boolean.TRUE.equals(request.getAttribute(ALREADY_APPLIED_KEY + ".ERROR"))) {
                    return true;
                } else {
                    request.setAttribute(ALREADY_APPLIED_KEY + ".ERROR", true);
                    return false;
                }
            }
            return super.filterAlreadyAppliedForRequest(request);
        }
    }
}