在两年前刚知道Web开发的时候, 对于MVC中的三个词还不是很理解, 尤其是Model, 哪里有一个叫做模型的对象呢?
后来学了Java以及JSP技术, 知道了请求与响应在Web容器中的传递, 知道了没有一个所谓的Model对象, 数据可以附在请求或者响应上或者容器中, 然后在渲染视图的时候从相应的对象中取出来. 然后才知道,
其实Model是一种抽象, 就是在处理一次请求并返回一个响应的一个业务处理过程中, 返回响应所需要的数据对象.
在之前知道了不加@ResponseBody
的控制器可以返回一个ModelAndView对象, 也可以返回一个字符串. 现在就来看看如何操作模型数据, 即如何将模型数据暴露给视图.
- 将模型数据暴露给视图的渠道
- ModelAndView
- @ModelAttribute
- Map和Model对象
- @SessionAtrributes和@ModelAttribute
- Servlet API
将模型数据暴露给视图的渠道
所谓将模型数据暴露给视图的渠道, 就是在渲染视图的时候, 从哪里获取所需要的数据. 详细一点的说, 就是DispatcherServlet解析完视图名称, 找到视图对象之后, 从哪里获取数据来渲染视图.
从哪里获取视图, 对于编写Web应用的我们, 也就意味着在使用Spring框架的时候, 生成数据的时候将其存放在哪里, 就好比JSP中可以在请求, 响应, Session, 容器中设置一个键值对用来存放JSP视图需要的数据,
Spring框架也有一些特定的渠道:
- 存放在
ModelAndView
对象中, 当一个控制器方法返回这个对象的时候, 向这个对象中添加的键值对都会添加到模型上.
@ModelAttribute
, 这个注解可以标注在控制器方法或者控制器方法参数上, 有不同的作用, 但总体来说, 这个注解用于特定情况下向模型放入数据. 后边会详述
Map
和Model
对象, 如果一个控制器方法的参数org.springframework.ui.Model/ModelMap
或者java.util.Map
对象,
在方法中可以通过这个参数访问和修改模型数据.
@SessionAtrributes
, 这个注解和@ModelAttribute
有些类似, 用于固定向Session中放入数据.
- 使用Servlet原生API, 按照JSP的方式放入数据, 这些数据也会被模型拿到.
凡是"添加到模型上的数据", 都可以在JSP视图中或者其他的模板引擎比如Thymeleaf中, 使用对应的键名取出来, 从而完成将模型数据传递给视图的工作. 下边就来一个一个看一下这些对象或者注解.
ModelAndView
对于传统的Web开发, 也就是通过视图渲染数据, 在Spring中推荐使用这个对象, 因为无论是名称还是使用的逻辑上边都非常简单明了.
ModelAndView对象的主要方法如下:
ModelAndView addObject(String attributeName, @Nullable Object attributeValue)
, 添加一个属性名称和对应的值
ModelAndView addAllObjects(@Nullable Map<String, ?> modelMap)
,
将一个modelMap对象中的所有键值对都添加到ModelAndView对象上
void setView(@Nullable View view)
, 设置一个视图对象, 如果不是采用解析视图名称方式, 而是直接创建视图对象的话, 就可以使用该方法,
让DispatcherServlet调用渲染器去渲染指定的视图.
void setViewName(@Nullable String viewName)
, 设置一个视图名称, 用于给视图解析器进行解析, 从而得到正确的视图文件地址.
void clear()
, 清除所有的模型数据和setView()方法设置的视图.
这个使用方法在前边已经多次使用了, 正常情况下推荐使用ModelAndView对象来作为返回值.
@ModelAttribute
这个注解的核心作用, 就是向模型中放入数据. 有两种使用方式.
注解控制器方法的参数
第一种方式是将其注解到控制器方法的参数之前, 需要给注解传一个属性名称, 会以这个属性名将参数放入到模型对象中.
比如我们之前那个通过表单映射到User对象的控制器方法:
@RequestMapping("/usercreate")
public ModelAndView createUser(User user) {
System.out.println(user);
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", user);
return modelAndView;
}
现在将其修改成:
@RequestMapping("/usercreate")
public String createUser(@ModelAttribute("modelUser") User user) {
System.out.println(user);
return "index";
}
对应的index.jsp中加上一个判断:
<c:if test="${modelUser!=null}">
<p>测试模型信息: ${modelUser}</p>
</c:if>
再用表单映射的方式去访问, 可以看到, 虽然没有显式的将user对象设置的模型上, index.jsp却渲染出了modelUser键对应的值, 也就是user对象的信息.
在这个控制器方法中, 会先使用请求中的信息映射到user对象, 然后>@ModelAttribute("modelUser")
会以modelUser为键, 将user对象放入到模型中. 在最终渲染的时候,
将这个数据交给视图进行渲染. 对于JSP文件来说, 实际存放user对象的地点是ServletRequest的属性列表中.
所以JSP文件才可以直接使用${modelUser}
.
还需要注意的是, 一个参数只能使用一个Spring的注解, 不能同时使用多个注解.
注解控制器方法
第二种方式是将@ModelAttribute
注解在控制器方法上, 被注解的方法, 会在这个控制器类其他所有的映射了路径的方法调用之前被调用, 然后将方法的返回值添加到模型中.
这种使用方式通常用来固定向模型添加数据.
比如我们现在编写一个固定向页面添加当前时间的功能, 即用户每次访问, 都在页面显示当前的时间. 那么可以每次在用户访问的时候, 都生成当前时间, 然后放入到模型数据中, 交给页面进行渲染:
@Controller
@RequestMapping("/model")
public class ModelController {
@RequestMapping("/nodata")
public ModelAndView noData() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
return modelAndView;
}
@RequestMapping("/view")
public ModelAndView testModelAndView() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("test","这是测试信息");
modelAndView.setViewName("index");
return modelAndView;
}
@ModelAttribute("time")
public String getCurrentTimeString() {
LocalDateTime localDateTime = LocalDateTime.now();
return localDateTime.toString();
}
}
然后在JSP中加上:
<p>当前时间是: ${time}</p>
之后只要访问/model/nodata
或者/model/view
路径, 页面中都会显示当前的时间. 如果请求没有使用到ModelController这个控制器,
比如访问/usercreate, 则被code>@ModelAttribute注解的方法不会执行, 页面中也就获取不到时间.
所以相比于之前的ModelAndView对象, 这个@ModelAttribute更像是专门向模型中存放数据的功能.
控制器方法注解和参数注解的属性名称相同
这里还有一点要注意的是, 如果我们有这样一个类:
@Controller
public class BasicController {
@ModelAttribute("modelUser")
public User getUser() {
User user = new User();
user.setAge(10);
return user;
}
@RequestMapping("/usercreate")
public String createUser(@ModelAttribute("modelUser") User user) {
System.out.println(user);
return "index";
}
}
控制器方法上的注解中的属性名称, 和参数的注解中的属性名称一致, 都是modelUser, 这会发生什么情况呢? 做个实验可以知道:
访问 http://localhost:8080/usercreate?userName=cony&address.detail=zhr
页面显示 User{userName='cony', age=10, address=Address{detail='zhr'}}
访问 http://localhost:8080/usercreate?userName=cony&address.detail=zhr&age=6
页面显示 User{userName='cony', age=6, address=Address{detail='zhr'}}
这背后的机制是, 被@ModelAttribute("modelUser")
注解的控制器方法依然会先于其他具体的处理方法运行,
将一个仅仅只设置了age=10的User对象以modelUser键名设置到模型中.
之后控制器方法运行, 这时候并不是直接用请求数据装填user对象再覆盖掉modelUser, 而是会先把模型中的那个modelUser键名对应的user对象和从请求中获取的数据进行组合, 并且来自请求的数据优先级更高,
组合并填充完成user对象后, 同时将其更新到模型上, 并且作为请求的参数.
所以可以看到, 在请求中没有附带age=6的条件时, 模型中的modelUser对应的user对象是一个二者组合之后的结果. 在请求附带了age=6的时候, 由getUser()返回的user对象的age=10的属性,
被来自HTTP请求的age=6给覆盖了.
一般来说, @ModelAttribute
主要用来完成像上边的第二种情况的工作, 即某些控制器会固定的放入一些数据的功能, 如果是为了向模型上设置数据, 一般不太会使用@ModelAttribute
.
Map和Model对象
这两个对象, 实际上就是把Spring ModelAndView的内部机制进一步暴露的操作方法.
查看ModelAndView的源码, 部分代码如下:
public class ModelAndView {
@Nullable
private ModelMap model;
public ModelAndView(String viewName, @Nullable Map<String, ?> model) {
this.view = viewName;
if (model != null) {
this.getModelMap().addAllAttributes(model);
}
}
public ModelMap getModelMap() {
if (this.model == null) {
this.model = new ModelMap();
}
return this.model;
}
public Map<String, Object> getModel() {
return this.getModelMap();
}
public ModelAndView addObject(Object attributeValue) {
this.getModelMap().addAttribute(attributeValue);
return this;
}
}
看了上边这些代码, 你会发现, 其实ModelAndView内部实际使用一个ModelMap对象来存放模型数据. 再点开ModelMap, 就会发现:
public class ModelMap extends LinkedHashMap<String, Object> {
......
}
原来ModelMap就是一个Map对象, 怪不得ModelAndView的构造器里可以直接传一个Map对象进来, 会将其属性都设置到ModelMap上, 二者其实都是Map类型.
Spring在每次调用控制器方法之前, 都会针对这次请求-响应处理创建一个隐含的模型对象, 其实可以认为其就是当前请求-响应对应的ModelAndView对象中的这个ModelMap对象.
只要Spring检测到处理方法的入参是Map或者Model类型, Spring就会将ModelMap的引用传递给控制器方法, 在方法中就可以操作当前请求-响应对应的模型数据.
给刚才向模型内添加时间的控制器再编写一个例子:
@Controller
@RequestMapping("/model")
public class ModelController {
@ModelAttribute("time")
public String getCurrentTimeString() {
LocalDateTime localDateTime = LocalDateTime.now();
return localDateTime.toString();
}
@RequestMapping("/map")
public String testModelMap(Map<String, Object> map) {
System.out.println("模型数据是: " + map);
System.out.println("修改时间");
map.replace("time", "新修改的时间");
return "index";
}
}
访问/model/map, 会发现控制台先打印出了模型数据是: {time=2020-02-15T23:57:47.739611400}
. 很显然这是模型中存放的键值对. 之后将time键给改掉了.
结果最后JSP中显示出来的结果是: "当前时间是: 新修改的时间".
这说明只要传入的参数是这两个类型, 就等于可以直接操作模型数据了.
这里需要说明的是, org.springframework.ui.Model是一个接口, Spring包中实现了这个接口的有ModelMap, ExtendedModelMap等类, 而Map是Java的常用接口, 也有很多实现类.
控制器能够接受的参数只要是这两个接口的实现类就可以, 因此实际能传入的类型有很多种, 像例子中传入Map接口类型使用多态是可以的, 如果传具体类型, 一般使用ModelMap类型, 语义比较明确.
@SessionAtrributes
这个注解要结合之前的@ModelAttribute
来看, 不然会让人有点搞不清楚. 所以先说原理, 详细的流程如下:
- 在进入控制器之前, Spring创建一个隐含的模型对象
- 调用标注了@ModelAttribute的控制器方法, 将方法返回值添加到模型中
- 查看Session中是否存在@SessionAttributes中指定的属性名称的属性, 如果有, 将其也添加到模型中. 如果模型中已经有同名的属性, 这一步会将其覆盖
- 然后执行@ModelAttribute("xxx")注解的参数,然后有如下情况:
- 如果xxx已经在模型内存在, 则会向上边一样根据请求中的数据组装或者覆盖xxx属性, 并且会更新模型中的xxx属性. 如果xxx不存在于模型中, 则转到下一步.
- 如果xxx与@SessionAttributes("xxx")同名, 则会尝试从Session中获取xxx属性, 将其填充入参对象, 此后依然是合并或者覆盖, 并且会将该属性保存到Session中. 如果找不到, 则抛出HttpSessionRequiredException异常.
- 如果xxx也不是@SessionAttributes中的名称, 则只会用请求消息来填充该参数, 然后将其设置到模型上.
这个注解只能标注在控制器类上, 不能标注在方法上. 此外支持多个属性名称, 以及types用来指定类型.
这个方法看流程是有点懵逼的, 但其实只要记住, 其核心都是在控制器处理的参数中. 但如果是第一次访问并生成Session中的数据, 则需要想办法给Session放进数据, 这一般就是使用@SessionAttributes和@ModelAttribute搭配使用.
看一个复杂一点但是我觉得已经说得非常清楚的例子, 就是经典的登录过程, 在一个表单里进行POST数据, 然后将用户设置到Session中, 看看其他控制器是否能够访问.
先编写一个想在页面上展示已经登录的用户的控制器:
@Controller
@SessionAttributes("user")
public class BasicController {
@RequestMapping("/test")
public String home(@ModelAttribute("user") User user) {
return "index";
}
}
根据上边的分析, 由于这个控制器中的模型没有叫做user的对象, 所以会到Session中去寻找同名属性, 如果我们刚启动服务器的时候, 立刻来访问这个/test路径, 按照上边的分析, 会得到一个异常. 如果在模型和Session中任意能够找到叫做user的属性, 都会被更新到模型中.
如果我们在Session上设置了user属性, 再访问这个页面, 应该就没有异常了.
编写index.jsp如下:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>主页</title>
<link rel="stylesheet" type="text/css" href="/css/style.css">
</head>
<body>
<h1>主页</h1>
<p>当前登录的用户是${user}</p>
</body>
</html>
这个JSP意图就是显示当前登录的用户. 现在启动服务器, 访问这个路径, 结果发现果然有异常.
我们来编写另外一个用于向Session中添加用户的登录表单及控制器:
@Controller
@RequestMapping("/user")
@SessionAttributes("user")
public class LoginController {
@RequestMapping("/form")
public String login() {
return "login";
}
//#1
//每次访问控制器都生成一个新的User对象放入到模型中
@ModelAttribute("user")
public User generateUser() {
return new User();
}
//#2
@PostMapping("/login")
public String validateUser(@ModelAttribute("user") User user) {
System.out.println("方法的入参对象是: " + user);
return "redirect:/test";
}
#3
@GetMapping("/logout")
public String logout(SessionStatus sessionStatus) {
sessionStatus.setComplete();
return "redirect:/test";
}
}
1号方法, 也就是@ModelAttribute
注解的方法是一定需要的, 因为validateUser的入参不是需要Session中的user, 就是需要模型中的user. 既然其他地方都没有在Session中设置user, 这里必须先给模型里设置一个空的user. 否则#2就会报异常.
#2的入参就会到模型和Session中寻找, 在模型中找到, 就不会到Session中寻找, 之后会将空的user与请求数据进行合并, 并将合并后的user放入到session以及更新到模型中.
之后我们重定向到刚才的/test路径. 因为是重定向, 很显然, 模型数据一定是带不过去的, 只有Session中的数据才可以.
3号方法清除Session中的所有数据.
login.jsp编写如下:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>输入用户信息</title>
<link rel="stylesheet" type="text/css" href="/css/style.css">
</head>
<body>
<h1>用户登录</h1>
<form action="${pageContext.request.contextPath}/user/login" method="post">
<label for="">
用户名 <input type="text" name="userName">
</label>
<label for="">
年龄 <input type="number" name="age">
</label>
<label for="">
住址<input type="text" name="address.detail"></label>
<button type="submit">提交</button>
</form>
</body>
</html>
重新启动服务器, 现在按照如下方式来访问:
- 访问
http://localhost:8080/test
, 得到500错误, 异常是org.springframework.web.HttpSessionRequiredException: Expected session attribute 'user'
- 访问
http://localhost:8080/user/form
, 填写表单并提交.
- 提交表单之后, 自动重定向到
http://localhost:8080/test
, 页面中出现了用户信息.
- 之后反复刷新
http://localhost:8080/test
, 都是当前用户信息.不会再报错.
- 一旦访问
http://localhost:8080/user/logout
, 立刻又报500错误, 和最初访问这个地址一样, 这是因为清除了所有Session中的信息.
这里模型使用了重定向, 而且跨越两个控制器, 很显然模型数据是无法互相传递的, 只有通过Session. 控制器的3号方法说明确实将user对象设置到了Session中, 一旦清除就会重新报错.
总结一下就是一个前提, 两个操作:
- 前提: 两个注解同时使用, 注解的属性名称相同, 这样才能协同控制Session数据.
- 第一个操作, 向Session中放入数据: 负责想要往Session上设置数据的控制器, 一定要有一个@ModelAttribute注解的方法先把要设置的数据对象给制造出来, 之后通过控制器方法中被@ModelAttribute注解的参数, 可以对这个数据对象进行加工. 加工完毕之后, 这个数据对象就会存在于Session中和模型中.
- 第二个操作, 从Session中取出数据: 想要从Session中取出数据的控制器, 只需要确保访问到这个控制器之前, Session中已经存在数据对象, 直接在方法入参中使用
@ModelAttribute
就可以将其存到模型中用于页面渲染.
所以用这两个注解也可以实现@Session操作, 就是注意有点绕, 不过用习惯了其实也相当不错.
Servlet API
这一段就简单多了, 一般来说, 模型数据会被我们设置到HttpServletRequest或者HttpSession对象上, 这个语义还是比较明确的. 来看看例子.
@Controller
@RequestMapping("/session")
public class SessionController {
@RequestMapping("/add")
public String addSessionData(HttpServletRequest request, HttpSession session) {
request.setAttribute("setinrequest", "cony");
session.setAttribute("setinsession", "owl");
return "redirect:/session/find";
}
@RequestMapping("/add2")
public String addSessionData2(HttpServletRequest request, HttpSession session) {
request.setAttribute("setinrequest", "cony");
session.setAttribute("setinsession", "owl");
return "forward:/session/find";
}
@RequestMapping("/find")
public ModelAndView testFindDataFromSesssion(HttpServletRequest request, HttpSession session) {
System.out.println("从request中获取: "+request.getAttribute("setinrequest"));
System.out.println("从session中获取: "+session.getAttribute("setinsession"));
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("setbycontroller", "kiki");
modelAndView.setViewName("index");
return modelAndView;
}
}
第一个控制器方法使用原生的request对象和session对象, 分别设置了两个属性, 然后重定向到路径 /session/find. 第二个控制器方法的区别是进行转发.
很显然, 有JSP的经验就知道, 第一个是重定向, 所以设置在请求中的数据会丢失, 第二个是请求转发, 设置在请求中的数据不会丢失. 而Session只要不过期, 数据都可以访问到.
总的来说, 如果想完全遵守Spring的语义, 推荐的方式就是显式返回ModelAndView对象, 将模型数据设置在ModelAndView对象中.
如果是想在多个请求中共享数据, 可以考虑使用@SessionAttributes
和@ModelAttribute
搭配使用.