Spring 源码解析之ViewResolver

1 ViewResolver类功能解析

1.1 ViewResolver

Interface to be implemented by objects that can resolve views by name. View state doesn't change during the running of the application, so implementations are free to cache views. Implementations are encouraged to support internationalization, i.e. localized view resolutio(ViewResolver 可以被实现着通过name解析成相应的view)

1.2 AbstractCachingViewResolver

Convenient base class for ViewResolver implementations. Caches View objects once resolved: This means that view resolution won't be a performance problem, no matter how costly initial view retrieval is.(AbstractCachingViewResolverViewResolver的一个子类实现,可以缓存view对象。缓存视图对象一旦解决:这意味着视图不会解决性能问题,无论多么昂贵的初始视图检索) 从下面代码看来,AbstractCachingViewResolver只提供了View的缓存,不提供view的创建createView(viewName, locale);


//核心方法 通过名字解析成相应的view
@Override
    public View resolveViewName(String viewName, Locale locale) throws Exception {
    //判断是否Cacheview,默认是Cache的
        if (!isCache()) {
            return createView(viewName, locale);
        }
        else {
      //先获取cacheKey
            Object cacheKey = getCacheKey(viewName, locale);
      //从fast Cache获取View
            View view = this.viewAccessCache.get(cacheKey);
            if (view == null) {
        //如果没有那么从同步的Cache中获取
                synchronized (this.viewCreationCache) {
                    view = this.viewCreationCache.get(cacheKey);
          //如果Cache中没有,就进行创建
                    if (view == null) {
                        // Ask the subclass to create the View object.
                        view = createView(viewName, locale);
                        if (view == null && this.cacheUnresolved) {
                            view = UNRESOLVED_VIEW;
                        }
                        if (view != null) {
              //创建后放入两个Cache中
                            this.viewAccessCache.put(cacheKey, view);
                            this.viewCreationCache.put(cacheKey, view);
                            if (logger.isTraceEnabled()) {
                                logger.trace("Cached view [" + cacheKey + "]");
                            }
                        }
                    }
                }
            }
            return (view != UNRESOLVED_VIEW ? view : null);
        }
    }

1.3 UrlBasedViewResolver

Supports AbstractUrlBasedView subclasses like InternalResourceView, VelocityView and FreeMarkerView. The view class for all views generated by this resolver can be specified via the "viewClass" property.(UrlBasedViewResolverInternalResourceView, VelocityView and FreeMarkerView的父类,这里就是为什么在使用FreeMarkerViewResolver的时候要写这个属性<property name="viewClass" value="org.springframework.web.servlet.view.freemarker.FreeMarkerView"/>

提供功能

  1. prefix和suffix的配置 2 redirect:forward:的解析使用,处理RedirectView和InternalResourceView
  2. 提供createView功能

功能分析


  @Override
    protected View createView(String viewName, Locale locale) throws Exception {
        // If this resolver is not supposed to handle the given view,
        // return null to pass on to the next resolver in the chain.
        if (!canHandle(viewName, locale)) {
            return null;
        }

        // Check for special "redirect:" prefix.
    //如果是redirect:开头的viewName,那么就采用RedirectView
        if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
            String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length());
            RedirectView view = new RedirectView(redirectUrl, isRedirectContextRelative(), isRedirectHttp10Compatible());
      //由于是自己new的 所以以viewName为benaname,然后注入一些beanFactory等类
            return applyLifecycleMethods(viewName, view);
        }
        // Check for special "forward:" prefix.
    //如果是forward
        if (viewName.startsWith(FORWARD_URL_PREFIX)) {

            String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
      //直接new一个InternalResourceView
            return new InternalResourceView(forwardUrl);
        }
    //如果不符合redirect: 和forward:开头那么就调用AbstractUrlBasedView的createView
    //上面的调用会继续调用UrlBasedViewResolver的loadView()
        // Else fall back to superclass implementation: calling loadView.
        return super.createView(viewName, locale);
    }

上面请求的调用都基于配置了下面两种viewResolver的情况 ```xml

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass"
              value="org.springframework.web.servlet.view.JstlView"/>
    <property name="prefix" value="/WEB-INF/view/"></property>
    <property name="suffix" value=".jsp"></property>
    <property name="order" value="2"/>
</bean>
>接下来看看调用`UrlBasedViewResolver`的`loadView()`的情况

```java

  @Override
    protected View loadView(String viewName, Locale locale) throws Exception {
    //构建一个AbstractUrlBasedView view
        AbstractUrlBasedView view = buildView(viewName);
        View result = applyLifecycleMethods(viewName, view);
    //这里十分重要,接下来会讲
        return (view.checkResource(locale) ? result : null);
    }
  protected AbstractUrlBasedView buildView(String viewName) throws Exception {
    //这里会根据配置的ViewClass去创建一个`AbstractUrlBasedView`对象,这里可能是jstlView,也有可能是freemarkerView
    //  <property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
    //  <property name="viewClass" value="org.springframework.web.servlet.view.freemarker.FreeMarkerView"/>
        AbstractUrlBasedView view = (AbstractUrlBasedView) BeanUtils.instantiateClass(getViewClass());
    //设置根据前缀和后缀去设置相对路径
        view.setUrl(getPrefix() + viewName + getSuffix());

        String contentType = getContentType();
        if (contentType != null) {
            view.setContentType(contentType);
        }

        view.setRequestContextAttribute(getRequestContextAttribute());
        view.setAttributesMap(getAttributesMap());

        Boolean exposePathVariables = getExposePathVariables();
        if (exposePathVariables != null) {
            view.setExposePathVariables(exposePathVariables);
        }
        Boolean exposeContextBeansAsAttributes = getExposeContextBeansAsAttributes();
    //     true  all Views resolved by this resolver will expose path variables
     //  false - no Views resolved by this resolver will expose path variables
     //  null - individual Views can decide for themselves (this is used by the default)
        if (exposeContextBeansAsAttributes != null) {
            view.setExposeContextBeansAsAttributes(exposeContextBeansAsAttributes);
        }
        String[] exposedContextBeanNames = getExposedContextBeanNames();
        if (exposedContextBeanNames != null) {
            view.setExposedContextBeanNames(exposedContextBeanNames);
        }

        return view;
    }

上文提到了一段代码return (view.checkResource(locale) ? result : null);,这里在使用上有很大的地方需要注意,例如下面的设置。


<bean id="viewResolver"
          class="org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver">
        <property name="viewClass"
                  value="org.springframework.web.servlet.view.freemarker.FreeMarkerView"/>
        <property name="suffix" value=".ftl"/>
        <!--<property name="prefix" value="/WEB-INF/view/front/"></property>-->
        <property name="contentType" value="text/html;charset=utf-8"/>
        <property name="exposeRequestAttributes" value="true"/>
        <property name="exposeSessionAttributes" value="true"/>
        <property name="allowRequestOverride" value="true"/>
        <property name="allowSessionOverride" value="true"/>
        <property name="exposeSpringMacroHelpers" value="true"/>
        <property name="order" value="21"/>
    </bean>

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="viewClass"
                  value="org.springframework.web.servlet.view.JstlView"/>
        <property name="prefix" value="/WEB-INF/view/"></property>
        <property name="suffix" value=".jsp"></property>
        <property name="order" value="2"/>
    </bean>

我将FreeMarkerViewResolver的order设置到21,那么在遍历viewResolver的时候会被排到最后面,理论上来说这样是不会有问题的,loadView()方法的最后一段代码处进行了资源是否存在的判断return (view.checkResource(locale) ? result : null);,但是在实际使用中其实是有问题的,用户不会使用到FreeMarkerViewResolver去解析因为JstlViewcheckResource()方法是永远返回true的,而FreeMarkerView是会去校验资源是否存在的。这块是否是Spring的一个小bug呢?我无从而知,不知道作者的考虑是什么,但是总的来说其实是可以去判断的。可能是做一些优化吧,避免每次都通过流去判断资源文件是否存在。

1.3 AbstractTemplateViewResolver

Abstract base class for template view resolvers, in particular for Velocity and FreeMarker views.(大概就是用来解析template view的,最著名的就是Velocity and FreeMarker)

主要功能

从下面代码来看主要功能,其实重写了UrlBasedViewResolver的buildView方法,然后添加了一些属性设置,这个主要是在构建AbstractTemplateView的子类时候使用到了配置一些属性而已

@Override
protected AbstractUrlBasedView buildView(String viewName) throws Exception {
    AbstractTemplateView view = (AbstractTemplateView) super.buildView(viewName);
    view.setExposeRequestAttributes(this.exposeRequestAttributes);
     /**Set whether HttpServletRequest attributes are allowed to override (hide)
      * controller generated model attributes of the same name. Default is "false",
      * which causes an exception to be thrown if request attributes of the same
      * name as model attributes are found.
     设置HttpServletRequest的attributes是否可以覆盖Controller的model 的attributes 默认是false
    **/
    view.setAllowRequestOverride(this.allowRequestOverride);
    /**
     * Set whether all HttpSession attributes should be added to the
     * model prior to merging with the template. Default is "false".
     */
     // 所有HttpSession的属性设置是否应该加入到模型之前,与模板合并 默认false
    view.setExposeSessionAttributes(this.exposeSessionAttributes);
    /**
     * Set whether HttpSession attributes are allowed to override (hide)
     * controller generated model attributes of the same name. Default is "false",
     * which causes an exception to be thrown if session attributes of the same
     * name as model attributes are found.
     */
    //  HttpSession的属性设置是否允许覆盖(隐藏)控制器生成同名的模型属性。 默认是false
    view.setAllowSessionOverride(this.allowSessionOverride);
    /**
     * Set whether to expose a RequestContext for use by Spring's macro library,
     * under the name "springMacroRequestContext". Default is "true".
     * <p>Currently needed for Spring's Velocity and FreeMarker default macros.
     * Note that this is <i>not</i> required for templates that use HTML
     * forms <i>unless</i> you wish to take advantage of the Spring helper macros.
     * @see #SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE
     */
     // 设置是否公开一个由Spring的宏库使用RequestContext,在名为“springMacroRequestContext”。
    view.setExposeSpringMacroHelpers(this.exposeSpringMacroHelpers);
    return view;
}

1.4 FreeMarkerViewResolver


public class FreeMarkerViewResolver extends AbstractTemplateViewResolver {

    public FreeMarkerViewResolver() {
        setViewClass(requiredViewClass());
    }

    /**
     * Requires {@link FreeMarkerView}.
     */
    @Override
    protected Class<?> requiredViewClass() {
        return FreeMarkerView.class;
    }

}

实际的模板view只是指定了具体的ViewClass,从这里的代码来看其实在配置FreeMarkerViewResolver的时候,如果没有特殊对FreeMarkerView做特殊配置那么<property name="viewClass" value="org.springframework.web.servlet.view.freemarker.FreeMarkerView"/> 这个不需要进行配置。

1.5 VelocityViewResolver

public class VelocityViewResolver extends AbstractTemplateViewResolver {

    private String dateToolAttribute;

    private String numberToolAttribute;
 //toolbox配置文件路径
    private String toolboxConfigLocation;


    public VelocityViewResolver() {
        setViewClass(requiredViewClass());
    }

    /**
     * Requires {@link VelocityView}.
     */
    @Override
    protected Class<?> requiredViewClass() {
        return VelocityView.class;
    }
    @Override
    protected void initApplicationContext() {
        super.initApplicationContext();

        if (this.toolboxConfigLocation != null) {
            if (VelocityView.class == getViewClass()) {
                logger.info("Using VelocityToolboxView instead of default VelocityView " +
                        "due to specified toolboxConfigLocation");
                setViewClass(VelocityToolboxView.class);
            }
            else if (!VelocityToolboxView.class.isAssignableFrom(getViewClass())) {
                throw new IllegalArgumentException(
                        "Given view class [" + getViewClass().getName() +
                        "] is not of type [" + VelocityToolboxView.class.getName() +
                        "], which it needs to be in case of a specified toolboxConfigLocation");
            }
        }
    }


    @Override
    protected AbstractUrlBasedView buildView(String viewName) throws Exception {
        VelocityView view = (VelocityView) super.buildView(viewName);
        /**
         * Set the name of the DateTool helper object to expose in the Velocity context
         * of this view, or {@code null} if not needed. DateTool is part of Velocity Tools 1.0.
     */
     //设置日期函数
        view.setDateToolAttribute(this.dateToolAttribute);
        /**
         * Set the name of the NumberTool helper object to expose in the Velocity context
         * of this view, or {@code null} if not needed. NumberTool is part of Velocity Tools 1.1.
         */
        //数字函数名称
        view.setNumberToolAttribute(this.numberToolAttribute);
        if (this.toolboxConfigLocation != null) {
            ((VelocityToolboxView) view).setToolboxConfigLocation(this.toolboxConfigLocation);
        }
        return view;
    }

}

VelocityViewResolver重写了buildView()方法,增加了两个设置

1.6 VelocityLayoutViewResolver

支持VelocityLayoutView

1.7 ContentNegotiatingViewResolver

这个是一个用来整合所有ViewResolver的类,每当处理完之后,会遍历所有的ViewResolver,然后找到最合适处理的View。理论上来说,这种方式在性能上会有所下降,具体没有测试过对比,只不过for循环10多次和直接找到ViewResolver解析,其实性能影响并不大,可以忽略。


    @Override
    public View resolveViewName(String viewName, Locale locale) throws Exception {
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
        Assert.isInstanceOf(ServletRequestAttributes.class, attrs);
    //获取request的MediaType 集合
        List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
        if (requestedMediaTypes != null) {
            //获取所有符合的条件的view
            List<View> candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes);
            //查找一个最符合的view
            View bestView = getBestView(candidateViews, requestedMediaTypes, attrs);
            if (bestView != null) {
                return bestView;
            }
        }
        if (this.useNotAcceptableStatusCode) {
            if (logger.isDebugEnabled()) {
                logger.debug("No acceptable view found; returning 406 (Not Acceptable) status code");
            }
            return NOT_ACCEPTABLE_VIEW;
        }
        else {
            logger.debug("No acceptable view found; returning null");
            return null;
        }
    }

上述代码是实现ViewResolver的方法,这个类主要是调用了所有的ViewResolver来实现他的功能,可以先来看看getCandidateViews的逻辑部分

private List<View> getCandidateViews(String viewName, Locale locale, List<MediaType> requestedMediaTypes)
            throws Exception {

        List<View> candidateViews = new ArrayList<View>();
        // 遍历所有的viewResolvers
        for (ViewResolver viewResolver : this.viewResolvers) {
            //调用具体的viewResolver 的resolveViewName方法找到view
            View view = viewResolver.resolveViewName(viewName, locale);
            //如果不为null那么就添加进去
            if (view != null) {
                candidateViews.add(view);
            }
            for (MediaType requestedMediaType : requestedMediaTypes) {
                List<String> extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType);
                for (String extension : extensions) {
                    String viewNameWithExtension = viewName + "." + extension;
                    view = viewResolver.resolveViewName(viewNameWithExtension, locale);
                    if (view != null) {
                        candidateViews.add(view);
                    }
                }
            }
        }
        //在使用ContentNegotiatingViewResolver的时候一般来说都会配置defaultViews,从这块就知道为什么需要配置defaultViews
        if (!CollectionUtils.isEmpty(this.defaultViews)) {
            candidateViews.addAll(this.defaultViews);
        }
        return candidateViews;
    }

上面还有一个逻辑,如何找的最合适的view,下面是整个代码的逻辑.

private View getBestView(List<View> candidateViews, List<MediaType> requestedMediaTypes, RequestAttributes attrs) {

        //遍历所有的SmartView SmartView默认是RedirectView
        for (View candidateView : candidateViews) {
            if (candidateView instanceof SmartView) {
                SmartView smartView = (SmartView) candidateView;
                if (smartView.isRedirectView()) {
                    if (logger.isDebugEnabled()) {
                        logger.debug("Returning redirect view [" + candidateView + "]");
                    }
                    return candidateView;
                }
            }
        }
        //如果不存在SmartView那么就找到MediaType最合适的第一个view
        for (MediaType mediaType : requestedMediaTypes) {
            for (View candidateView : candidateViews) {
                if (StringUtils.hasText(candidateView.getContentType())) {
                    MediaType candidateContentType = MediaType.parseMediaType(candidateView.getContentType());
                    if (mediaType.isCompatibleWith(candidateContentType)) {
                        if (logger.isDebugEnabled()) {
                            logger.debug("Returning [" + candidateView + "] based on requested media type '" +
                                    mediaType + "'");
                        }
                        attrs.setAttribute(View.SELECTED_CONTENT_TYPE, mediaType, RequestAttributes.SCOPE_REQUEST);
                        return candidateView;
                    }
                }
            }
        }
        return null;
    }

2 View功能解析

2.1 View

View这个顶级接口只提供了一个渲染的方法,model这里是所有controller里面的参数和request的attributes,只要实现了这个render接口就能够实现模板渲染功能。

public interface View {
    void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception;
}

2.2 AbstractView

Provides support for static attributes, to be made available to the view, with a variety of ways to specify them. Static attributes will be merged with the given dynamic attributes (the model that the controller returned) for each render operation.(这个主要是提供一些静态变量,然后可以合并到具体的view里面进行使用)

protected Map<String, Object> createMergedOutputModel(Map<String, ?> model, HttpServletRequest request,
            HttpServletResponse response) {
        //判断是否需要取path里面的变量        
        @SuppressWarnings("unchecked")
        Map<String, Object> pathVars = (this.exposePathVariables ?
                (Map<String, Object>) request.getAttribute(View.PATH_VARIABLES) : null);

        // Consolidate static and dynamic model attributes.
        int size = this.staticAttributes.size();
        size += (model != null ? model.size() : 0);
        size += (pathVars != null ? pathVars.size() : 0);

        Map<String, Object> mergedModel = new LinkedHashMap<String, Object>(size);
        //合并静态变量
        mergedModel.putAll(this.staticAttributes);
        if (pathVars != null) {
            mergedModel.putAll(pathVars);
        }
        if (model != null) {
            mergedModel.putAll(model);
        }

        // Expose RequestContext?
        if (this.requestContextAttribute != null) {
            mergedModel.put(this.requestContextAttribute, createRequestContext(request, response, mergedModel));
        }
        //保存到mergeModel里面
        return mergedModel;
    }
    //倒入csv变量
    public void setAttributesCSV(String propString) throws IllegalArgumentException {
        if (propString != null) {
            StringTokenizer st = new StringTokenizer(propString, ",");
            while (st.hasMoreTokens()) {
                String tok = st.nextToken();
                int eqIdx = tok.indexOf("=");
                if (eqIdx == -1) {
                    throw new IllegalArgumentException("Expected = in attributes CSV string '" + propString + "'");
                }
                if (eqIdx >= tok.length() - 2) {
                    throw new IllegalArgumentException(
                            "At least 2 characters ([]) required in attributes CSV string '" + propString + "'");
                }
                String name = tok.substring(0, eqIdx);
                String value = tok.substring(eqIdx + 1);

                // Delete first and last characters of value: { and }
                value = value.substring(1);
                value = value.substring(0, value.length() - 1);

                addStaticAttribute(name, value);
            }
        }
    }

这个类没有太多功能,详细就不罗列,主要提供了导入csv和变量的合并,其他地方提供了一些辅助功能

2.3 AbstractUrlBasedView

提供url属性,增加public boolean checkResource(Locale locale)方法判断资源是否存在

2.4 AbstractTemplateView

//主要进行属性merge
@Override
    protected final void renderMergedOutputModel(
            Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
            //把request的属性都merge到model里面去
        if (this.exposeRequestAttributes) {
            for (Enumeration<String> en = request.getAttributeNames(); en.hasMoreElements();) {
                String attribute = en.nextElement();
                if (model.containsKey(attribute) && !this.allowRequestOverride) {
                    throw new ServletException("Cannot expose request attribute '" + attribute +
                        "' because of an existing model object of the same name");
                }
                Object attributeValue = request.getAttribute(attribute);
                if (logger.isDebugEnabled()) {
                    logger.debug("Exposing request attribute '" + attribute +
                            "' with value [" + attributeValue + "] to model");
                }
                model.put(attribute, attributeValue);
            }
        }
        //把session的merge到model里面去
        if (this.exposeSessionAttributes) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                for (Enumeration<String> en = session.getAttributeNames(); en.hasMoreElements();) {
                    String attribute = en.nextElement();
                    if (model.containsKey(attribute) && !this.allowSessionOverride) {
                        throw new ServletException("Cannot expose session attribute '" + attribute +
                            "' because of an existing model object of the same name");
                    }
                    Object attributeValue = session.getAttribute(attribute);
                    if (logger.isDebugEnabled()) {
                        logger.debug("Exposing session attribute '" + attribute +
                                "' with value [" + attributeValue + "] to model");
                    }
                    model.put(attribute, attributeValue);
                }
            }
        }
            //把RequestContext放到model里面去
        if (this.exposeSpringMacroHelpers) {
            if (model.containsKey(SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE)) {
                throw new ServletException(
                        "Cannot expose bind macro helper '" + SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE +
                        "' because of an existing model object of the same name");
            }
            // Expose RequestContext instance for Spring macros.
            model.put(SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE,
                    new RequestContext(request, response, getServletContext(), model));
        }

        applyContentType(response);
        //调用具体 子类的的merge model
        renderMergedTemplateModel(model, request, response);
    }

总的来说AbstractTemplateView 进行了属性的合并。如果还有其他模版类想通过spring来使用,可以直接继承AbstractTemplateView 实现renderMergedTemplateModel()即可,这样可以完全使用spring提供的属性配置

2.5 FreeMarkerView

按照上面的逻辑,FreeMarkerView实现了renderMergedTemplateModel()方法进行初始化

@Override
    protected void renderMergedTemplateModel(
            Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        //目前未实现,留给开发者自定义实现
        exposeHelpers(model, request);
        //实际模板渲染
        doRender(model, request, response);
    }

上述代码调用了doRender(model, request, response);进行数据渲染,下面这段代码我就不加注释了,相信用过FreeMarker的人都知道

protected void doRender(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        // Expose model to JSP tags (as request attributes).
        exposeModelAsRequestAttributes(model, request);
        // Expose all standard FreeMarker hash models.
        SimpleHash fmModel = buildTemplateModel(model, request, response);

        if (logger.isDebugEnabled()) {
            logger.debug("Rendering FreeMarker template [" + getUrl() + "] in FreeMarkerView '" + getBeanName() + "'");
        }
        // Grab the locale-specific version of the template.
        Locale locale = RequestContextUtils.getLocale(request);
        processTemplate(getTemplate(locale), fmModel, response);
    }

    protected void processTemplate(Template template, SimpleHash model, HttpServletResponse response)
                throws IOException, TemplateException {

            template.process(model, response.getWriter());
        }

2.4 InternalResourceView和JstlView

InternalResourceView 继承AbstractUrlBasedView,这个类已经大致实现了JstlView,只不过JstlView增加了一个国际化的功能,相对比较强大,这个view没有什么太多讲的,具体功能是提供了把spring的model变量放入到request的attribute中,然后通过原生Servlet 的RequestDispatcher使用方式进行includeforward

总结

本篇文章大概的介绍了大部分常用到的View和ViewResolver,大致讲了一些可以在实践中用到的知识点如下:

  1. ViewResolver 在order属性的配置上需要注意顺序,jstlView最好放的优先级最低,至于为什么,请再看看这篇文章。
  2. FreeMarkerViewResolver 不需要配置ViewClass,因为是多余的。InternalResourceViewResolver 是需要配置ViewClass。

results matching ""

    No results matching ""