spring mvc 拓展 -- 同一url根据不同后缀返回不同的视图

本文代码已整理上传 github

前言
现在新的web项目基本都是前后分离的了, 但是我之前一个项目组页面全部是用的freemarker模版, 
没有前后分离, 然后项目也想前端分离, 
所以就有了这个需求, 尽量不该或者少改后端的代码,来同时适应前端代码, 同时尽量兼容之前的freemarker模版。

然后就开始找解决方案, 找到了spring mvc的ContentNegotiatingViewResolver,
结合项目的使用, 可以做到同一个url不加后缀就返回之前的html页面, 加了.json 后缀就返回json数据

下面演示下具体的使用效果, 结合我上一篇文章搭建的web工程作为示例springweb

配置ContentNegotiatingViewResolver

在使用spring mvc时, 在注册requestMapping的时候, 除了会注册写在controller注解上的url,还会注册url.*到requestMapping, 所以url后面加什么拓展名都能映射到 原controller 上

ContentNegotiatingViewResolver 可以做到根据url后面不同的拓展名来返回不同的视图, 当然还可以根据 mediaType, formmat 参数 进行判断, 这里只演示根据拓展名的。

接着上篇文章中的 SpringMvcConfig 加入下面的配置

     /**
     * 配置多视图解析器
     *
     * @param manager       manager 会自动构建,configureContentNegotiation可以进行配置
     * @param viewResolvers 当前项目的 viewResolver, (此时会包含上面配置的 freemarkerViewResolver)
     * @return ContentNegotiatingViewResolver
     * @see WebMvcConfigurerAdapter#configureContentNegotiation(org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer)
     */
    @Bean
    public ContentNegotiatingViewResolver contentNegotiatingViewResolver(ContentNegotiationManager manager, List<ViewResolver> viewResolvers) {

        ContentNegotiatingViewResolver viewResolver = new ContentNegotiatingViewResolver();
        viewResolver.setContentNegotiationManager(manager);

        // 设置默认view, default view 每次都会添加到 真正可用的视图列表中, json视图没有对应的ViewResolver
        View jackson2JsonView = new MappingJackson2JsonView();
        viewResolver.setDefaultViews(Collections.singletonList(jackson2JsonView));

        viewResolver.setViewResolvers(viewResolvers);
        return viewResolver;
    }

就是这么简单, 现在你的项目就可以根据拓展名返回不同的视图了

PS: json 视图是将model中的数据通过 jackson(默认)序列化返回

运行效果

新建一个实体类, 叫Goods
    public class Goods implements Serializable {

    private static final long serialVersionUID = -5018788390786034623L;

    public Goods(String code, String name, Double price) {
        this.code = code;
        this.name = name;
        this.price = price;
    }

    private Long id; // 商品编码
    private String code; // 编码
    private String name; // 品名
    private Double price; // 售价

}
新建一个controller, 叫GoodsController
    @Controller
    @RequestMapping("/goods")
    public class GoodsController {
    
        private static final List<Goods> GOODS_LIST = new ArrayList<>();
    
        static {
            GOODS_LIST.add(new Goods("998765", "哇哈哈矿泉水", 2.0));
            GOODS_LIST.add(new Goods("568925", "蒙牛真果粒", 4.7));
        }
    
        @RequestMapping("/list")
        public String list(GoodsCondition condition, Model model) {
            model.addAttribute("data", GOODS_LIST);
            return "goods";
        }
    }

这个controller的写法是我们项目的一般写法, 返回String的视图名称, 将页面需要的数据放到model中, 一般都是 data.

视图模版文件, goods.ftl, 放到 /resources/templates/ 下
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>商品页</title>
</head>
<body>
<h3>商品列表</h3>
<hr>
<table>
    <thead>
    <tr>
        <td style="width: 100px">code</td>
        <td style="width: 150px">name</td>
        <td style="width: 100px">price</td>
    </tr>
    </thead>
    <tbody>
    <#list data as item>
    <tr>
        <td>${item.code?html}</td>
        <td>${item.name!?html}</td>
        <td>${item.price!}</td>
    </tr>
    </#list>
    </tbody>
</table>

</body>
</html>

运行项目,
首先浏览器 访问 http://localhost:8080/cat/goods/list, 不加任何后缀效果如下:

clipboard.png

返回的是 freemarker的 html 视图

然后访问 http://localhost:8080/cat/goods/list.jon, 加上.json 后缀

clipboard.png

成功返回json数据!

源码解析

我们在配置ContentNegotiatingViewResolver的bean时候自动注入了 两个参数: ContentNegotiationManager 和 List<ViewResolver>

List<ViewResolver> 我们自己就配置了一个 freemarkerViewResolver 所以这个参数能注入, 没有问题, 那么 ContentNegotiationManager 是怎么来的?

接触过 spring mvc 注解配置的同学知道, spring 提供了WebMvcConfigurer接口供使用者自定义spring mvc 配置,
其实spring mvc有自己的一个配置类DelegatingWebMvcConfiguration来收集用户自定义配置,并提供一些默认配置

clipboard.png

看到没, 那个 setConfigurers 方法就是用来收集自定义配置的, 而这个本身也是个配置类, 继承了WebMvcConfigurationSupport类, spring mvc的各种初始化 就是从这里开始的

我们需要的参数ContentNegotiationManager就是定义在WebMvcConfigurationSupport里的

clipboard.png

而且 当在 classpath 下有 jackson 存在就会添加 json 拓展名映射,
jaxb存在就添加 xml拓展名映射, 很智能

这样,项目启动阶段就结束了,接下来分析运行阶段,

ContentNegotiatingViewResolver本身也ViewResolver, 我们还定义FreeMarkerViewResolver,两者同时存在, 为什么一定先执行的是ContentNegotiatingViewResolver?

如果之前配置过多视图共存(volecity, jsp, freemarker)的同学会知道, ViewResolver是有 order属性的, 执行的先后是按照order的顺序来的, order越小越先执行,

clipboard.png

看下两个类中order的定义, ContentNegotiatingViewResolver默认是最高优先级的,所以当一个请求走到返回视图阶段, 先执行的是ContentNegotiatingViewResolver

那ContentNegotiatingViewResolver做了什么事情呢? 先上源码

    @Override
    public View resolveViewName(String viewName, Locale locale) throws Exception {
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
        Assert.state(attrs instanceof ServletRequestAttributes, "No current ServletRequestAttributes");
        List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
        if (requestedMediaTypes != null) {
            List<View> candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes);
            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;
        }
    }

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

        List<View> candidateViews = new ArrayList<View>();
        for (ViewResolver viewResolver : this.viewResolvers) {
            View view = viewResolver.resolveViewName(viewName, locale);
            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);
                    }
                }
            }
        }
        if (!CollectionUtils.isEmpty(this.defaultViews)) {
            candidateViews.addAll(this.defaultViews);
        }
        return candidateViews;
    }

    private View getBestView(List<View> candidateViews, List<MediaType> requestedMediaTypes, RequestAttributes attrs) {
        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;
                }
            }
        }
        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;
    }

上面这段就是ContentNegotiatingViewResolver如何处理视图的逻辑
requestedMediaTypes 就是拿到根据请求拿到需要的 MediaType,
然后就是根据 requestedMediaTypes 找到所有可用的 View,
我们在配置ContentNegotiatingViewResolver里设置的 default view
每次都会出现在 candidateViews 中, 但是是在最后加上的。
然后就是在 candidateViews 中找到 bestView 返回

当我们不加后缀时,无法根据后缀映射 MediaType, 所以requestedMediaTypes就是
candidateViews 就是freemarker的 view 加上设置的 默认的 json view.
在getBestView的时候 requestedMediaTypes 和 freemarkerView的content-type: text/html匹配, 所以就返回了freemarkerView。

当加了.json后缀, 根据之前的拓展名和MediaType的映射, requestedMediaTypes是 application/json
candidateViews 依然是freemarker的 view 加上设置的 默认的 json view.
但是这次freemarkerView的content-type不匹配, 而是和json view的 application/json 匹配, 所以返回 json 视图。

后续

这样新的前端项目在访问url加上.json后缀就能拿到json数据了,
但是还有一个问题, 就是在接收请求参数时, 老的项目全是 form表单提交, 但是新的前端项目需要以json格式提交, 如果在controller中的方法中 都加上 @RequestBody 注解, 工作量不少, 而且不兼容form表单提交, 所以这样不可行。

下一篇讲下controller接收参数时如何自动判断是 form 提交还是 json 数据提交, 从而使用不同的参数处理器 来接收参数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值