本文代码已整理上传
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 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_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/ 下
商品页
商品列表
code
name
price
<#list data as item>
${item.code?html}
${item.name!?html}
${item.price!}
#list>
运行项目,
首先浏览器 访问 http://localhost:8080/cat/goods/list
, 不加任何后缀效果如下:
返回的是 freemarker的 html 视图
然后访问 http://localhost:8080/cat/goods/list.jon
, 加上.json 后缀
成功返回json数据!
源码解析
我们在配置ContentNegotiatingViewResolver的bean时候自动注入了 两个参数: ContentNegotiationManager 和 List
List
接触过 spring mvc 注解配置的同学知道, spring 提供了WebMvcConfigurer接口供使用者自定义spring mvc 配置,
其实spring mvc有自己的一个配置类DelegatingWebMvcConfiguration
来收集用户自定义配置,并提供一些默认配置
看到没, 那个 setConfigurers 方法就是用来收集自定义配置的, 而这个本身也是个配置类, 继承了WebMvcConfigurationSupport类, spring mvc的各种初始化 就是从这里开始的
我们需要的参数ContentNegotiationManager就是定义在WebMvcConfigurationSupport里的
而且 当在 classpath 下有 jackson 存在就会添加 json 拓展名映射,
jaxb存在就添加 xml拓展名映射, 很智能
这样,项目启动阶段就结束了,接下来分析运行阶段,
ContentNegotiatingViewResolver本身也ViewResolver, 我们还定义FreeMarkerViewResolver,两者同时存在, 为什么一定先执行的是ContentNegotiatingViewResolver?
如果之前配置过多视图共存(volecity, jsp, freemarker)的同学会知道, ViewResolver是有 order属性的, 执行的先后是按照order的顺序来的, order越小越先执行,
看下两个类中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 requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
if (requestedMediaTypes != null) {
List 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 getCandidateViews(String viewName, Locale locale, List requestedMediaTypes)
throws Exception {
List candidateViews = new ArrayList();
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
candidateViews.add(view);
}
for (MediaType requestedMediaType : requestedMediaTypes) {
List 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 candidateViews, List 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 数据提交, 从而使用不同的参数处理器 来接收参数