ModelAndView:如何将处理结果返回给前端?
- 11. ModelAndView:如何将处理结果返回给前端?
-
- 一、先用一个请求看清总流程
- 二、DispatcherServlet:先执行,再决定是否渲染视图
- 三、HandlerAdapter:返回值在这里分流
-
- `@ResponseBody` 做了什么?
- 四、为什么要单独设计 HttpMessageConverter?
- 五、为什么还要再拆一个 ObjectMapper?
- 六、Java 对象是怎么变成浏览器数据的?
- 七、用 `/test7` 再串一次
- 八、职责拆分总结
教程:https://github.com/YaleGuo/minis 极客时间:手把手带你写一个 MiniSpring
前言:本章的实现内容其实很简单,无非就是将对象转换成 JSON 字符串,写入 HttpServletResponse,再经过 HTTP 传输到浏览器。真正值得关注的并不是这几行代码,而是源码背后的设计思路。重点再感受一下源码设计思路!职责单一、解耦彻底、扩展性强。解耦!解耦!解耦!
11. ModelAndView:如何将处理结果返回给前端?
上一节解决的是“请求参数怎么变成 Java 对象”,这一节反过来解决:
Controller 方法执行完以后,Java 返回值怎么变成浏览器能收到的 HTTP 响应?
当前 MiniSpring 支持两条返回路径:
Controller 返回值
|
v
RequestMappingHandlerAdapter
|
+– 方法有 @ResponseBody
| |
| v
| HttpMessageConverter
| |
| v
| ObjectMapper -> JSON 字符串 -> HTTP 响应体
|
+– 方法没有 @ResponseBody
|
v
ModelAndView -> ViewResolver -> View.render(…)
这一节主要讲现代前后端分离项目常用的第一条路径,也就是把 Java 对象转换成 JSON 后写入响应体。ModelAndView 和 JSP 渲染只保留必要说明,不展开讲。
一、先用一个请求看清总流程
项目里的测试接口如下:
@RequestMapping("/test7")
@ResponseBody
public UserForm doTest7(UserForm user) {
// 用于观察:参数绑定后的对象仍然可以在 Controller 中继续修改。
user.setName(user.getName() + "—");
user.setBirthday(new Date());
return user;
}
发送请求:
http://localhost:8080/test7?name=Tom&age=18&birthday=2026-06-01
完整链路是:
浏览器发送 HTTP 请求
↓
Tomcat 创建 HttpServletRequest 和 HttpServletResponse
↓
DispatcherServlet 根据 URL 找到 HandlerMethod
↓
RequestMappingHandlerAdapter 绑定 UserForm 参数
↓
反射调用 doTest7(user)
↓
得到 UserForm 返回值
↓
发现方法上有 @ResponseBody
↓
HttpMessageConverter 设置响应类型和字符编码
↓
ObjectMapper 把 UserForm 序列化成 JSON 字符串
↓
PrintWriter 把字符写入 HttpServletResponse
↓
Servlet 容器按 UTF-8 编码成字节并通过网络发送
↓
浏览器或 Axios 收到 JSON
注意,doTest7 中重新设置了 birthday,所以请求里的 2026-06-01 会被服务器当天日期覆盖。例如服务器当天是 2026-06-09,当前极简序列化器可能返回:
{"name":"Tom—","age":"18","birthday":"2026-06-09"}
这里的 "age":"18" 也是当前教学版 DefaultObjectMapper 的限制:它把非空字段统一写成了 JSON 字符串。真实项目使用 Jackson 时,数字通常会保留为数字:
{"name":"Tom—","age":18,"birthday":"2026-06-09"}
二、DispatcherServlet:先执行,再决定是否渲染视图
请求入口仍然是 DispatcherServlet.doDispatch(…):
protected void doDispatch(HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 第一步:根据 URL 找到对应的 Controller 方法。
HandlerMethod handlerMethod = this.handlerMapping.getHandler(request);
if (handlerMethod == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 第二步:绑定参数、调用 Controller,并处理方法返回值。
ModelAndView mv = this.handlerAdapter.handle(
request, response, handlerMethod);
// @ResponseBody 分支已经把结果写入 response,不需要再渲染页面。
if (mv == null) {
return;
}
// 没有 @ResponseBody 时,才进入页面渲染流程。
render(request, response, mv);
}
这里最容易误解的是:
if (mv == null) {
return;
}
mv == null 不一定表示 Controller 没有返回数据。对于 @ResponseBody 方法,数据已经由 HttpMessageConverter 写进 HttpServletResponse,所以这里只是告诉 DispatcherServlet:
响应已经处理完了,不要再走 ModelAndView 的视图渲染流程。
这也解释了为什么接口返回的是 JSON,却仍然能在代码中看到 ModelAndView:当前 HandlerAdapter 用 ModelAndView 作为统一返回类型,JSON 分支写完响应后返回 null,页面分支才真正返回 ModelAndView。
三、HandlerAdapter:返回值在这里分流
真正决定“返回 JSON 还是渲染页面”的代码在 RequestMappingHandlerAdapter:
@Override
public ModelAndView handle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
return handleInternal(request, response, (HandlerMethod) handler);
}
private ModelAndView handleInternal(HttpServletRequest request,
HttpServletResponse response,
HandlerMethod handler) throws Exception {
Method method = handler.getMethod();
Object bean = handler.getBean();
// 先完成参数绑定,再反射调用 Controller。
Object result = invokeHandlerMethod(
request, response, handler, method, bean);
// 必须先判断 @ResponseBody。
// 即使返回值是 String,它也应该写入响应体,而不是被当成视图名。
if (method.isAnnotationPresent(ResponseBody.class)) {
this.messageConverter.write(result, response);
return null;
}
// 页面返回路径:Controller 可以直接返回 ModelAndView。
if (result instanceof ModelAndView) {
return (ModelAndView) result;
}
// 没有 @ResponseBody 的 String 返回值会被当成逻辑视图名。
if (result instanceof String) {
return new ModelAndView((String) result);
}
// 当前教学版本暂不支持其他返回值处理方式。
return null;
}
返回规则可以整理成一张表:
| @ResponseBody + UserForm | 对象序列化为 JSON,直接写入响应体 |
| @ResponseBody + String | 字符串序列化后写入响应体 |
| 无 @ResponseBody + ModelAndView | 进入视图渲染流程 |
| 无 @ResponseBody + String | 把字符串当作逻辑视图名 |
| 无 @ResponseBody + 其他类型 | 当前版本无法处理,返回 null |
@ResponseBody 做了什么?
它只是一个运行时标记:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResponseBody {
}
因为保留策略是 RUNTIME,所以框架执行 Controller 时可以通过反射判断:
method.isAnnotationPresent(ResponseBody.class)
当前 MiniSpring 没有实现 @RestController。因此,要让方法返回值进入响应体,必须明确添加 @ResponseBody。
四、为什么要单独设计 HttpMessageConverter?
如果直接在 HandlerAdapter 里拼 JSON、设置响应头、获取 Writer,代码当然也能运行,但 HandlerAdapter 会同时负责:
职责会越来越乱。所以这里抽出 HttpMessageConverter 接口:
public interface HttpMessageConverter {
/**
* 把 Controller 返回值转换后写入 HTTP 响应。
*/
void write(Object obj, HttpServletResponse response) throws IOException;
}
RequestMappingHandlerAdapter 只负责决定:
这个返回值是不是响应体?
至于响应体最终写成 JSON、XML 还是纯文本,由具体的 HttpMessageConverter 决定。
默认实现如下:
public class DefaultHttpMessageConverter
implements HttpMessageConverter {
// JSON 的标准媒体类型是 application/json。
private String defaultContentType = "application/json";
private String defaultCharacterEncoding = "UTF-8";
private ObjectMapper objectMapper = new DefaultObjectMapper();
public ObjectMapper getObjectMapper() {
return objectMapper;
}
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void write(Object obj,
HttpServletResponse response) throws IOException {
// 必须在获取 Writer 之前确定响应类型和字符编码。
response.setContentType(defaultContentType);
response.setCharacterEncoding(defaultCharacterEncoding);
writeInternal(obj, response);
// 提交响应缓冲区,确保结果被发送出去。
response.flushBuffer();
}
private void writeInternal(Object obj,
HttpServletResponse response) throws IOException {
// 第一步:Java 对象 -> JSON 字符串。
String json = this.objectMapper.writeValuesAsString(obj);
// 第二步:把字符写入响应;最终的字符到字节转换由 Servlet 容器完成。
PrintWriter writer = response.getWriter();
writer.write(json);
}
}
项目未提交代码中的默认值如果还是 text/json;charset=UTF-8,建议改成标准写法 application/json,并通过 setCharacterEncoding("UTF-8") 单独设置编码。
HttpMessageConverter 负责的是 HTTP 层:
设置 Content-Type
设置字符编码
调用 ObjectMapper
获取 response.getWriter()
写入并提交响应
它不应该关心 UserForm 有哪些字段,也不应该自己用反射遍历对象。
五、为什么还要再拆一个 ObjectMapper?
ObjectMapper 负责的是对象序列化:
public interface ObjectMapper {
void setDateFormat(String dateFormat);
void setDecimalFormat(String decimalFormat);
/**
* 把 Java 对象转换成 JSON 字符串。
*/
String writeValuesAsString(Object obj);
}
两个接口的职责并不一样:
RequestMappingHandlerAdapter
|
v
HttpMessageConverter
负责 HTTP 响应格式、编码和输出
|
v
ObjectMapper
负责 Java 对象到 JSON 字符串的转换
这样拆分以后:
- 想把 JSON 换成 XML,可以更换 HttpMessageConverter;
- 想把手写序列化换成 Jackson,可以更换 ObjectMapper;
- HandlerAdapter 不需要知道具体序列化细节。
当前项目中的 DefaultObjectMapper 是一个教学用的极简实现:
public class DefaultObjectMapper implements ObjectMapper {
private DateTimeFormatter datetimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
private DecimalFormat decimalFormatter =
new DecimalFormat("#,##0.00");
@Override
public void setDateFormat(String dateFormat) {
this.datetimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
}
@Override
public void setDecimalFormat(String decimalFormat) {
this.decimalFormatter = new DecimalFormat(decimalFormat);
}
@Override
public String writeValuesAsString(Object obj) {
if (obj == null) {
return "null";
}
if (obj instanceof String) {
return "\\"" + escape((String) obj) + "\\"";
}
StringBuilder json = new StringBuilder("{");
Field[] fields = obj.getClass().getDeclaredFields();
boolean first = true;
for (Field field : fields) {
// static 字段属于类,不属于当前对象,不写入 JSON。
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
field.setAccessible(true);
Object value = getFieldValue(field, obj);
if (!first) {
json.append(",");
}
json.append("\\"").append(field.getName()).append("\\":");
if (value == null) {
json.append("null");
} else {
// 当前教学版本把所有非空字段都按字符串输出。
json.append("\\"").append(escape(formatValue(value))).append("\\"");
}
first = false;
}
json.append("}");
return json.toString();
}
private Object getFieldValue(Field field, Object obj) {
try {
return field.get(obj);
} catch (IllegalAccessException e) {
throw new IllegalStateException("Cannot read field: " + field.getName(), e);
}
}
private String formatValue(Object value) {
if (value == null) {
return null;
}
if (value instanceof Date) {
LocalDate localDate = ((Date) value).toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate();
return localDate.format(this.datetimeFormatter);
}
if (value instanceof BigDecimal
|| value instanceof Double
|| value instanceof Float) {
return this.decimalFormatter.format(value);
}
return value.toString();
}
private String escape(String text) {
return text.replace("\\\\", "\\\\\\\\").replace("\\"", "\\\\\\"");
}
}
这段代码的核心过程只有四步:
拿到对象类型
↓
反射遍历字段
↓
读取并格式化字段值
↓
拼成 JSON 字符串
不过要明确:这不是完整的 JSON 库。它目前不完善的地方包括:
- 数字和布尔值也会被加上双引号;
- 不支持集合、数组和嵌套对象;
- 字符串转义规则不完整;
- 没有处理循环引用;
- 没有 getter、字段忽略等配置能力。
因此它适合用来学习“对象如何被序列化”,不适合直接用于生产环境。真实 Spring Boot 项目通常由 Jackson 完成这部分工作;当前 MiniSpring 项目并没有引入 Jackson。
六、Java 对象是怎么变成浏览器数据的?
Controller 返回的:
UserForm user
是 JVM 进程里的对象。变量 user 保存的对象引用只在当前 JVM 内存中有意义,浏览器既不能访问服务器堆内存,也不认识 com.chenhai.test.UserForm,所以不能直接传 Java 对象。
服务端必须先把对象序列化成双方约定的格式。前后端分离项目最常见的是 JSON:
JVM 中的 UserForm 对象
↓ 序列化
JSON 字符串
↓ 字符编码
UTF-8 字节
↓ HTTP/TCP
浏览器
↓ JSON 解析
JavaScript 对象
核心输出代码如下:
PrintWriter writer = response.getWriter();
writer.write(json);
严格来说,PrintWriter 接收的是字符。Servlet 容器再根据 UTF-8 把字符编码成响应体字节,最后通过网络发送:
JSON String
↓
PrintWriter 写入字符
↓
Servlet 容器根据 UTF-8 编码
↓
响应体字节
↓
Socket / TCP 发送
HTTP 响应还要用 Content-Type 告诉客户端如何理解这些字节:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{"name":"Tom—","age":"18","birthday":"2026-06-09"}
- application/json 表示响应体采用 JSON 格式;
- charset=UTF-8 表示文本字符按 UTF-8 编码。
| application/json | 按 JSON 数据处理 |
| text/html | 按 HTML 页面处理 |
| image/png | 按 PNG 图片处理 |
| application/pdf | 按 PDF 文件处理 |
所有响应最终都会传输字节,但图片等二进制数据不应该使用 PrintWriter,而应该使用 OutputStream 写入原始字节。
还要注意,当前 MiniSpring 并没有根据请求头 Accept 做内容协商。它的规则很直接:
方法有 @ResponseBody
↓
固定使用 DefaultHttpMessageConverter
↓
返回 JSON
所以不是“后端自动知道前端想要 JSON”,而是服务端通过 @ResponseBody 和默认转换器明确选择了 JSON。
浏览器开发者工具会把 JSON 格式化展示。Axios 收到 JSON 后,会把它解析成 JavaScript 值,概念上类似:
const data = JSON.parse(
'{"name":"Tom—","age":"18","birthday":"2026-06-09"}'
);
console.log(data.name); // Tom—
所以 Vue 最终使用的是 JavaScript 对象,不是服务器里的 Java 对象:
Java 对象
↓ DefaultObjectMapper
JSON 文本
↓ HttpServletResponse
HTTP 响应字节
↓ Axios 解析
JavaScript 对象
↓
Vue 渲染
七、用 /test7 再串一次
八、职责拆分总结
这一节新增的几个部件,各自只负责一件事:
| DispatcherServlet | 组织整个请求分发流程 |
| RequestMappingHandlerAdapter | 绑定参数、调用 Controller、判断返回值走哪条路径 |
| @ResponseBody | 标记方法返回值要写入响应体 |
| HttpMessageConverter | 定义“把返回值写入 HTTP 响应”的能力 |
| DefaultHttpMessageConverter | 设置 JSON 响应头并写入响应体 |
| ObjectMapper | 定义“对象序列化成字符串”的能力 |
| DefaultObjectMapper | 用反射实现教学版 JSON 序列化 |
| ModelAndView | 保存页面渲染所需的模型和视图信息 |
最核心的调用链是:
Controller 返回 Java 对象
↓
RequestMappingHandlerAdapter 判断 @ResponseBody
↓
HttpMessageConverter 处理 HTTP 响应
↓
ObjectMapper 把对象序列化成 JSON
↓
PrintWriter 写入字符
↓
Servlet 容器编码为字节并发送
↓
浏览器或 Axios 解析 JSON
这一节真正要掌握的不是手写 JSON,而是返回值处理的职责拆分:
HandlerAdapter 决定怎么处理返回值,HttpMessageConverter 负责怎么写 HTTP 响应,ObjectMapper 负责怎么把对象变成 JSON。



