你好,我是郭屹。今天我们继续手写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,我们看下它的定义。
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;
}
通过上面这段代码我们可以知道,调用方法返回的时候,我们处理了三种情况。
到这里,调用方法就返回了。不过事情还没完,之后我们就把注意力转移到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,如何扩展到别的前端?欢迎你在留言区和我交流讨论,也欢迎你把这节课分享给需要的朋友。我们下节课见!