Skip to content

11 ModelAndView :如何将处理结果返回给前端?

你好,我是郭屹。今天我们继续手写MiniSpring。这也是MVC内容的最后一节。

上节课,我们对HTTP请求传入的参数进行了自动绑定,并调用了目标方法。我们再看一下整个MVC的流程,现在就到最后一步了,也就是把返回数据回传给前端进行渲染。

图片

调用目标方法得到返回值之后,我们有两条路可以返回给前端。第一,返回的是简单的纯数据,第二,返回的是一个页面。

最近几年,第一种情况渐渐成为主流,也就是我们常说的“前后端分离”,后端处理完成后,只是把数据返回给前端,由前端自行渲染界面效果。比如前端用React或者Vue.js自行组织界面表达,这些前端脚本只需要从后端service拿到返回的数据就可以了。

第二种情况,由后端controller根据某种规则拿到一个页面,把数据整合进去,然后整个回传给前端浏览器,典型的技术就是JSP。这条路前些年是主流,最近几年渐渐不流行了。

我们手写MiniSpring的目的是深入理解Spring框架,剖析它的程序结构,所以作为学习的对象,这两种情况我们都会分析到。

处理返回数据

和绑定传入的参数相对,处理返回数据是反向的,也就是说,要从后端把方法得到的返回值(一个Java对象)按照某种字符串格式回传给前端。我们以这个@ResponseBody注解为例,来分析一下。

先定义一个接口,增加一个功能,让controller返回给前端的字符流数据可以进行格式转换。

package com.minis.web;

import java.io.IOException;
import javax.servlet.http.HttpServletResponse;

public interface HttpMessageConverter {
    void write(Object obj, HttpServletResponse response) throws IOException;
}

我们这里给一个默认的实现——DefaultHttpMessageConverter,把Object转成JSON串。

package com.minis.web;

import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.http.HttpServletResponse;

public class DefaultHttpMessageConverter implements HttpMessageConverter {
    String defaultContentType = "text/json;charset=UTF-8";
    String defaultCharacterEncoding = "UTF-8";
    ObjectMapper objectMapper;

    public ObjectMapper getObjectMapper() {
        return objectMapper;
    }
    public void setObjectMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    public void write(Object obj, HttpServletResponse response) throws IOException {
        response.setContentType(defaultContentType);
        response.setCharacterEncoding(defaultCharacterEncoding);
        writeInternal(obj, response);
        response.flushBuffer();
    }
    private void writeInternal(Object obj, HttpServletResponse response) throws IOException{
        String sJsonStr = this.objectMapper.writeValuesAsString(obj);
        PrintWriter pw = response.getWriter();
        pw.write(sJsonStr);
    }
}

这个message converter很简单,就是给response写字符串,用到的工具是ObjectMapper。我们就重点看看这个mapper是怎么做的。

定义一个接口ObjectMapper。

package com.minis.web;
public interface ObjectMapper {
    void setDateFormat(String dateFormat);
    void setDecimalFormat(String decimalFormat);
    String writeValuesAsString(Object obj);
}

最重要的接口方法就是writeValuesAsString(),将对象转成字符串。

我们给一个默认的实现——DefaultObjectMapper,在writeValuesAsString中拼JSON串。

package com.minis.web;

import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class DefaultObjectMapper implements ObjectMapper{
    String dateFormat = "yyyy-MM-dd";
    DateTimeFormatter datetimeFormatter = DateTimeFormatter.ofPattern(dateFormat);

    String decimalFormat = "#,##0.00";
    DecimalFormat decimalFormatter = new DecimalFormat(decimalFormat);

    public DefaultObjectMapper() {
    }

    @Override
    public void setDateFormat(String dateFormat) {
        this.dateFormat = dateFormat;
        this.datetimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
    }

    @Override
    public void setDecimalFormat(String decimalFormat) {
        this.decimalFormat = decimalFormat;
        this.decimalFormatter = new DecimalFormat(decimalFormat);
    }
    public String writeValuesAsString(Object obj) {
        String sJsonStr = "{";
        Class<?> clz = obj.getClass();

        Field[] fields = clz.getDeclaredFields();
        //对返回对象中的每一个属性进行格式转换
        for (Field field : fields) {
            String sField = "";
            Object value = null;
            Class<?> type = null;
            String name = field.getName();
            String strValue = "";
            field.setAccessible(true);
            value = field.get(obj);
            type = field.getType();

            //针对不同的数据类型进行格式转换
            if (value instanceof Date) {
                LocalDate localDate = ((Date)value).toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
                strValue = localDate.format(this.datetimeFormatter);
            }
            else if (value instanceof BigDecimal || value instanceof Double || value instanceof Float){
                strValue = this.decimalFormatter.format(value);
            }
            else {
                strValue = value.toString();
            }

            //拼接Json串
            if (sJsonStr.equals("{")) {
                sField = "\"" + name + "\":\"" + strValue + "\"";
            }
            else {
                sField = ",\"" + name + "\":\"" + strValue + "\"";
            }

            sJsonStr += sField;
        }
        sJsonStr += "}";
        return sJsonStr;
    }
}

实际转换过程用到了LocalDate和DecimalFormatter。从上述代码中也可以看出,目前为止,我们也只支持Date、Number和String三种类型。你自己可以考虑扩展到更多的数据类型。

那么我们在哪个地方用这个工具来处理返回的数据呢?其实跟绑定参数一样,数据返回之前,也是要经过方法调用。所以我们还是要回到RequestMappingHandlerAdapter这个类,增加一个属性messageConverter,通过它来转换数据。

程序变成了这个样子。

    public class RequestMappingHandlerAdapter implements HandlerAdapter {
        private WebBindingInitializer webBindingInitializer = null;
        private HttpMessageConverter messageConverter = null;

现在既有传入的webBingingInitializer,也有传出的messageConverter。

在关键方法invokeHandlerMethod()里增加对@ResponseBody的处理,也就是调用messageConverter.write()把方法返回值转换成字符串。

    protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
        HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
        ... ...
        if (invocableMethod.isAnnotationPresent(ResponseBody.class)){ //ResponseBody
            this.messageConverter.write(returnObj, response);
        }
        ... ...
    }

同样的webBindingInitializer和messageConverter都可以通过配置注入。

    <bean id="handlerAdapter" class="com.minis.web.servlet.RequestMappingHandlerAdapter">
     <property type="com.minis.web.HttpMessageConverter" name="messageConverter" ref="messageConverter"/>
     <property type="com.minis.web.WebBindingInitializer" name="webBindingInitializer" ref="webBindingInitializer"/>
    </bean>

    <bean id="webBindingInitializer" class="com.test.DateInitializer" />

    <bean id="messageConverter" class="com.minis.web.DefaultHttpMessageConverter">
     <property type="com.minis.web.ObjectMapper" name="objectMapper" ref="objectMapper"/>
    </bean>
    <bean id="objectMapper" class="com.minis.web.DefaultObjectMapper" >
     <property type="String" name="dateFormat" value="yyyy/MM/dd"/>
     <property type="String" name="decimalFormat" value="###.##"/>
    </bean>

最后在DispatcherServlet里,通过getBean获取handlerAdapter,当然这里需要约定一个名字,整个过程就连起来了。

    protected void initHandlerAdapters(WebApplicationContext wac) {
        this.handlerAdapter = (HandlerAdapter) wac.getBean(HANDLER_ADAPTER_BEAN_NAME);
    }

测试的客户程序HelloWorldBean修改如下:

    @RequestMapping("/test7")
    @ResponseBody
    public User doTest7(User user) {
        user.setName(user.getName() + "---");
        user.setBirthday(new Date());
        return user;
    }

程序里面声明了一个注解@ResponseBody,程序中返回的是对象User,框架处理的时候用message converter将其转换成JSON字符串返回。

到这里,我们就知道MVC是如何把方法返回对象自动转换成response字符串的了。我们在调用目标方法后,通过messageConverter进行转换,它要分别转换每一种数据类型的格式,同时格式可以由用户自己指定。

ModelAndView

调用完目标方法,得到返回值,把数据按照指定格式转换好之后,就该处理它们,并把它们送到前端去了。我们用一个统一的结构,包装调用方法之后返回的数据,以及需要启动的前端页面,这个结构就是ModelAndView,我们看下它的定义。

package com.minis.web.servlet;

import java.util.HashMap;
import java.util.Map;

public class ModelAndView {
    private Object view;
    private Map<String, Object> model = new HashMap<>();

    public ModelAndView() {
    }
    public ModelAndView(String viewName) {
        this.view = viewName;
    }
    public ModelAndView(View view) {
        this.view = view;
    }
    public ModelAndView(String viewName, Map<String, ?> modelData) {
        this.view = viewName;
        if (modelData != null) {
            addAllAttributes(modelData);
        }
    }
    public ModelAndView(View view, Map<String, ?> model) {
        this.view = view;
        if (model != null) {
            addAllAttributes(model);
        }
    }
    public ModelAndView(String viewName, String modelName, Object modelObject) {
        this.view = viewName;
        addObject(modelName, modelObject);
    }
    public ModelAndView(View view, String modelName, Object modelObject) {
        this.view = view;
        addObject(modelName, modelObject);
    }
    public void setViewName(String viewName) {
        this.view = viewName;
    }
    public String getViewName() {
        return (this.view instanceof String ? (String) this.view : null);
    }
    public void setView(View view) {
        this.view = view;
    }
    public View getView() {
        return (this.view instanceof View ? (View) this.view : null);
    }
    public boolean hasView() {
        return (this.view != null);
    }
    public boolean isReference() {
        return (this.view instanceof String);
    }
    public Map<String, Object> getModel() {
        return this.model;
    }
    private void addAllAttributes(Map<String, ?> modelData) {
        if (modelData != null) {
            model.putAll(modelData);
        }
    }
    public void addAttribute(String attributeName, Object attributeValue) {
        model.put(attributeName, attributeValue);
    }
    public ModelAndView addObject(String attributeName, Object attributeValue) {
        addAttribute(attributeName, attributeValue);
        return this;
    }
}

这个类里面定义了Model和View,分别代表返回的数据以及前端表示,我们这里就是指JSP。

有了这个结构,我们回头看调用目标方法之后返回的那段代码,把类RequestMappingHandlerAdapter的方法invokeHandlerMethod()返回值改为ModelAndView。

protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
                HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
    ModelAndView mav = null;
    //如果是ResponseBody注解,仅仅返回值,则转换数据格式后直接写到response
    if (invocableMethod.isAnnotationPresent(ResponseBody.class)){ //ResponseBody
            this.messageConverter.write(returnObj, response);
    }
    else { //返回的是前端页面
        if (returnObj instanceof ModelAndView) {
            mav = (ModelAndView)returnObj;
        }
        else if(returnObj instanceof String) { //字符串也认为是前端页面
            String sTarget = (String)returnObj;
            mav = new ModelAndView();
            mav.setViewName(sTarget);
        }
    }

    return mav;
}

通过上面这段代码我们可以知道,调用方法返回的时候,我们处理了三种情况。

  1. 如果声明返回的是ResponseBody,那就用MessageConvert把结果转换一下,之后直接写回response。
  2. 如果声明返回的是ModelAndView,那就把结果包装成一个ModelAndView对象返回。
  3. 如果声明返回的是字符串,就以这个字符串为目标,最后还是包装成ModelAndView返回。

View

到这里,调用方法就返回了。不过事情还没完,之后我们就把注意力转移到MVC环节的最后一部分:View层。View,顾名思义,就是负责前端界面展示的部件,当然它最主要的功能就是,把数据按照一定格式显示并输出到前端界面上,因此可以抽象出它的核心方法render(),我们可以看下View接口的定义。

package com.minis.web.servlet;

import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface View {
    void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception;
    default String getContentType() {
        return null;
    }
    void setContentType(String contentType);
    void setUrl(String url);
    String getUrl();
    void setRequestContextAttribute(String requestContextAttribute);
    String getRequestContextAttribute();
}

这个render()方法的思路很简单,就是获取HTTP请求的request和response,以及中间产生的业务数据Model,最后写到response里面。request和response是HTTP访问时由服务器创建的,ModelAndView是由我们的MiniSpring创建的。

准备好数据之后,我们以JSP为例,来看看怎么把结果显示在前端界面上。其实,这跟我们自己手工写JSP是一样的,先设置属性值,然后把请求转发(forward)出去,就像下面我给出的这几行代码。

    request.setAttribute(key1, value1);
    request.setAttribute(key2, value2);
    request.getRequestDispatcher(url).forward(request, response);

照此办理,DispatcherServlet的doDispatch()方法调用目标方法后,可以通过一个render()来渲染这个JSP,你可以看一下doDispatch()相关代码。

    HandlerAdapter ha = this.handlerAdapter;
    mv = ha.handle(processedRequest, response, handlerMethod);
    render(processedRequest, response, mv);

这个render()方法可以考虑这样实现。

    //用jsp 进行render
    protected void render( HttpServletRequest request, HttpServletResponse response,ModelAndView mv) throws Exception {
        //获取model,写到request的Attribute中:
        Map<String, Object> modelMap = mv.getModel();
        for (Map.Entry<String, Object> e : modelMap.entrySet()) {
            request.setAttribute(e.getKey(),e.getValue());
        }
        //输出到目标JSP
        String sTarget = mv.getViewName();
        String sPath = "/" + sTarget + ".jsp";
        request.getRequestDispatcher(sPath).forward(request, response);
    }

我们看到了,程序从Model里获取数据,并将其作为属性值写到request的attribute里,然后获取页面路径,再显示出来,跟手工写JSP过程一样,简明有效。

但是上面的程序有两个问题,一是这个程序是怎么找到显示目标View的呢?上面的例子,我们是写了一个固定的路径/xxxx.jsp,但实际上这些应该是可以让用户自己来配置的,不应该写死在代码中。二是拿到View后,直接用的是request的forward()方法,这只对JSP有效,没办法扩展到别的页面,比如说Excel、PDF。所以上面的render()是需要改造的。

先解决第一个问题,怎么找到需要显示的目标View? 这里又得引出了一个新的部件ViewResolver,由它来根据某个规则或者是用户配置来确定View在哪里,下面是它的定义。

package com.minis.web.servlet;

public interface ViewResolver {
    View resolveViewName(String viewName) throws Exception;
}

这个ViewResolver就是根据View的名字找到实际的View,有了这个ViewResolver,就不用写死JSP路径,而是可以通过resolveViewName()方法来获取一个View。拿到目标View之后,我们把实际渲染的功能交给View自己完成。我们把程序改成下面这个样子。

    protected void render( HttpServletRequest request, HttpServletResponse response,ModelAndView mv) throws Exception {
        String sTarget = mv.getViewName();
        Map<String, Object> modelMap = mv.getModel();
        View view = resolveViewName(sTarget, modelMap, request);
        view.render(modelMap, request, response);
    }

在MiniSpring里,我们提供一个InternalResourceViewResolver,作为启动JSP的默认实现,它是这样定位到显示目标View的。

package com.minis.web.servlet.view;

import com.minis.web.servlet.View;
import com.minis.web.servlet.ViewResolver;

public class InternalResourceViewResolver implements ViewResolver{
    private Class<?> viewClass = null;
    private String viewClassName = "";
    private String prefix = "";
    private String suffix = "";
    private String contentType;

    public InternalResourceViewResolver() {
        if (getViewClass() == null) {
            setViewClass(JstlView.class);
        }
    }

    public void setViewClassName(String viewClassName) {
        this.viewClassName = viewClassName;
        Class<?> clz = null;
        try {
            clz = Class.forName(viewClassName);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        setViewClass(clz);
    }

    protected String getViewClassName() {
        return this.viewClassName;
    }
    public void setViewClass(Class<?> viewClass) {
        this.viewClass = viewClass;
    }
    protected Class<?> getViewClass() {
        return this.viewClass;
    }
    public void setPrefix(String prefix) {
        this.prefix = (prefix != null ? prefix : "");
    }
    protected String getPrefix() {
        return this.prefix;
    }
    public void setSuffix(String suffix) {
        this.suffix = (suffix != null ? suffix : "");
    }
    protected String getSuffix() {
        return this.suffix;
    }
    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
    protected String getContentType() {
        return this.contentType;
    }

    @Override
    public View resolveViewName(String viewName) throws Exception {
        return buildView(viewName);
    }

    protected View buildView(String viewName) throws Exception {
        Class<?> viewClass = getViewClass();

        View view = (View) viewClass.newInstance();
        view.setUrl(getPrefix() + viewName + getSuffix());

        String contentType = getContentType();
        view.setContentType(contentType);

        return view;
    }
}

从代码里可以知道,它先创建View实例,通过配置生成URL定位到显示目标,然后设置ContentType。这个过程也跟我们手工写JSP是一样的。通过这个resolver,就解决了第一个问题,框架会根据配置从/jsp/路径下拿到xxxx.jsp页面。

对于第二个问题,DispatcherServlet是不应该负责实际的渲染工作的,它只负责控制流程,并不知道如何渲染前端,这些工作由具体的View实现类来完成。所以我们不再把request forward()这样的代码写到DispatcherServlet里,而是写到View的render()方法中。

MiniSpring也提供了一个默认的实现:JstlView。

package com.minis.web.servlet.view;

import java.util.Map;
import java.util.Map.Entry;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.minis.web.servlet.View;

public class JstlView implements View{
    public static final String DEFAULT_CONTENT_TYPE = "text/html;charset=ISO-8859-1";
    private String contentType = DEFAULT_CONTENT_TYPE;
    private String requestContextAttribute;
    private String beanName;
    private String url;

    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
    public String getContentType() {
        return this.contentType;
    }
    public void setRequestContextAttribute(String requestContextAttribute) {
        this.requestContextAttribute = requestContextAttribute;
    }
    public String getRequestContextAttribute() {
        return this.requestContextAttribute;
    }
    public void setBeanName(String beanName) {
        this.beanName = beanName;
    }
    public String getBeanName() {
        return this.beanName;
    }
    public void setUrl(String url) {
        this.url = url;
    }
    public String getUrl() {
        return this.url;
    }
    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        for (Entry<String, ?> e : model.entrySet()) {
            request.setAttribute(e.getKey(),e.getValue());
        }
        request.getRequestDispatcher(getUrl()).forward(request, response);
    }
}

从代码里可以看到,程序其实还是一样的,因为要完成的任务是一样的,只不过现在这个代码移到了View这个位置。但是这个位置的移动,就让前端的渲染工作解耦了,DispatcherServlet不负责渲染了,我们可以由此扩展到多种前端,如Excel、PDF等等。

然后,对于InternalResourceViewResolver和JstlView,我们可以再次利用IoC容器机制通过配置进行注入。

    <bean id="viewResolver" class="com.minis.web.servlet.view.InternalResourceViewResolver" >
     <property type="String" name="viewClassName" value="com.minis.web.servlet.view.JstlView" />
     <property type="String" name="prefix" value="/jsp/" />
     <property type="String" name="suffix" value=".jsp" />
    </bean>

当DispatcherServlet初始化的时候,根据配置获取实际的ViewResolver和View。

整个过程就完美结束了。

小结

这节课,我们重点探讨了MVC调用目标方法之后的处理过程,如何自动转换数据、如何找到指定的View、如何去渲染页面。我们可以看到,作为一个框架,我们没有规定数据要如何转换格式,而是交给了MessageConverter去做;我们也没有规定如何找到这些目标页面,而是交给了ViewResolver去做;我们同样没有规定如何去渲染前端界面,而是通过View这个接口去做。我们可以自由地实现具体的场景。

这里,我们的重点并不是去看具体代码如何实现,而是要学习Spring框架如何分解这些工作,把专门的事情交给专门的部件去完成。虽然现在已经不流行JSP,我们不用特地去学习它,但是把这些部件解耦的框架思想,却是值得我们好好琢磨的。

完整源代码参见: https://github.com/YaleGuo/minis

课后题

学完这节课,我也给你留一道思考题。现在我们返回的数据只支持Date、Number和String三种类型,如何扩展到更多的数据类型?现在也只支持JSP,如何扩展到别的前端?欢迎你在留言区和我交流讨论,也欢迎你把这节课分享给需要的朋友。我们下节课见!