教程: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:当前 HandlerAdapterModelAndView 作为统一返回类型,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;
}

返回规则可以整理成一张表:

Controller 写法处理方式
@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 会同时负责:

  1. 参数绑定;
  2. 反射调用 Controller;
  3. 判断返回值类型;
  4. JSON 序列化;
  5. 设置 HTTP 响应;
  6. 输出响应体。

职责会越来越乱。所以这里抽出 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 编码。
Content-Type客户端通常如何处理
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 再串一次

  1. HandlerMapping 根据 /test7 找到 doTest7(UserForm user)
  2. WebDataBindernameagebirthday 绑定到 UserForm
  3. HandlerAdapter 反射调用 Controller,得到 UserForm 返回值。
  4. 方法上有 @ResponseBody,所以结果交给 HttpMessageConverter
  5. ObjectMapper 反射读取字段并生成 JSON 字符串。
  6. PrintWriter 把 JSON 写入响应,Servlet 容器再编码成字节发送。
  7. HandlerAdapter 返回 nullDispatcherServlet 不再执行 render(...)

八、职责拆分总结

这一节新增的几个部件,各自只负责一件事:

部件职责
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。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐