mini-spring-course

07|原始MVC:如何通过单一的Servlet拦截请求分派任务?

你好,我是郭屹。从这节课开始,我们开启一个新的部分:MVC。

前面一章,我们实现了一个简单的IoC。麻雀虽小,五脏俱全,相比原生Spring框架而言,我们写的MiniSpring功能简单,但其核心功能已具备。我们会在这个基础上进一步扩展我们的框架。

这一章我们来实现Spring MVC。MVC,全名对应Model(模型)、View(视图)、Controller(控制器)。它的基本流程是:前端发送请求到控制器,控制器寻找对应模型,找到模型后返回结果,渲染视图返回给前端生成页面。这是标准的前端请求数据的模型。实现了MVC之后,我们会把MVC和之前我们已经实现的IoC结合起来,这是我们这一章的整体思路。

图片

这节课我们就开启Spring MVC的第一步,先实现一个原始的MVC。目标是通过一个Controller来拦截用户请求,找到相应的处理类进行逻辑处理,然后将处理的结果发送给客户端。

调整目录

按照惯例,我们还是参照Spring的目录结构来调整。MVC是Web模型,所以我们先调整一下目前的项目结构,采用Web的项目结构。同时,我们还要引入Tomcat服务器以及Tomcat的jar包。

你可以看一下项目目录结构,主要是新增一个和src目录同级的WebContent目录,在这个目录里存储部分前端页面需要的静态资源,还有各项XML配置文件。

src
└── com
 ├── minis
  ├── web
  ├── util
  └── test
WebContent
├── WEB-INF
 ├── lib
 ├── web.xml
 ├── minisMVC-servlet.xml
└── META-INF
 └── MANIFEST.MF

参考Spring MVC,我们定义web.xml和minisMVC-servlet.xml这两个配置文件的内容。

  1. minisMVC-servlet.xml
<?xml version="1.0" encoding="UTF-8" ?>
<beans>
  <bean id="/helloworld" class="com.minis.test.HelloWorldBean" value="doGet"/>
</beans>

  1. web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:web="http://xmlns.jcp.org/xml/ns/javaee"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" id="WebApp_ID">
  <servlet>
    <servlet-name>minisMVC</servlet-name>
    <servlet-class>com.minis.web.DispatcherServlet</servlet-class>
      <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/minisMVC-servlet.xml</param-value>
      </init-param>
      <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>minisMVC</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>
</web-app>

这两个XML文件里,minisMVC-servlet.xml是我们很熟悉的Bean配置,只是把id设置成了一个URL的形式,来匹配后端的程序,访问/helloworld的时候,对应调用HelloWorldBean类里的doGet()方法。

Servlet

接下来我们重点关注web.xml。MVC里有一个核心概念是Servlet,通俗理解成运行在Web服务器上的程序。针对上面的XML配置,我们解读一下里面几个标签的含义。

整个结构就是一个标准的JavaEE结构,我们按照规范解释它,就是当Servlet容器启动的时候,先读取web.xml配置,加载配置文件中的servlet,也就是DispatcherServlet,并规定它拦截所有的HTTP请求,所以它就是控制器。

我们注意到这个控制器DispatcherServlet有一个参数 contextConfigLocation,它配置了控制器要找的逻辑处理类的文件minisMVC-servlet.xml。

      <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/minisMVC-servlet.xml</param-value>
      </init-param>

因此,为了启动这个servlet,我们要提前解析minisMVC-servlet.xml文件。

解析servlet.xml

首先定义实体类MappingValue里的三个属性:uri、clz与method,分别与minisMVC-servlet.xml中标签的属性id、class与value对应。

package com.minis.web;

public class MappingValue {
	String uri;
	public String getUri() {
		return uri;
	}
	public void setUri(String uri) {
		this.uri = uri;
	}
	String clz;
	public String getClz() {
		return clz;
	}
	public void setClz(String clz) {
		this.clz = clz;
	}
	String method;
	public String getMethod() {
		return method;
	}
	public void setMethod(String method) {
		this.method = method;
	}

	public MappingValue(String uri, String clz, String method) {
		this.uri = uri;
		this.clz = clz;
		this.method = method;
	}
}

然后我们定义Resource用来加载配置文件。

package com.minis.web;
import java.util.Iterator;
public interface Resource extends Iterator<Object>{
}

这是具体的实现。

package com.minis.web;

import java.net.URL;
import java.util.Iterator;
import java.util.List;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

public class ClassPathXmlResource implements Resource {
	Document document;
	Element rootElement;
	Iterator<Element> elementIterator;

	public ClassPathXmlResource(URL xmlPath) {
        SAXReader saxReader=new SAXReader();
        try {
			this.document = saxReader.read(xmlPath);
			this.rootElement=document.getRootElement();
			this.elementIterator=this.rootElement.elementIterator();
		} catch (DocumentException e) {
			e.printStackTrace();
		}
	}
	@Override
	public boolean hasNext() {
		return this.elementIterator.hasNext();
	}
	@Override
	public Object next() {
		return this.elementIterator.next();
	}
}

package com.minis.web;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.dom4j.Element;

public class XmlConfigReader {
	public XmlConfigReader() {
	}
	public Map<String,MappingValue> loadConfig(Resource res) {
		Map<String,MappingValue> mappings = new HashMap<>();

        while (res.hasNext()) { //读所有的节点,解析id, class和value
        	Element element = (Element)res.next();
            String beanID=element.attributeValue("id");
            String beanClassName=element.attributeValue("class");
            String beanMethod=element.attributeValue("value");

            mappings.put(beanID, new MappingValue(beanID,beanClassName,beanMethod));
        }

        return mappings;
	}
}

上述几段代码,是不是似曾相识?和我们前一部分编写的解析IoC的配置文件基本没什么差别,通过这些方法就能把XML里配置的Bean加载到内存里了,这里我就不再多说了。

实现MVC的核心启动类DispatcherSevlet

现在项目的搭建和前期准备工作已经完成,我们开始着手实现web.xml中配置的com.minis.web.DispatcherServlet这个MVC的核心启动类,完成URL映射机制。

MVC的基本思路是屏蔽Servlet的概念,让程序员主要写业务逻辑代码。浏览器访问的URL通过映射机制找到实际的业务逻辑方法。按照Servlet规范,可以通过Filter拦截,也可以通过Servlet拦截。MiniSpring的实现过程中,我模仿Spring MVC通过Servlet拦截所有请求,处理映射关系,调用业务逻辑代码,处理返回值回递给浏览器。程序员写的业务逻辑程序,也叫做Bean。

在DispatcherSevlet内,定义了三个Map,分别记录URL对应的MappingValue对象、对应的类和对应的方法。

private Map<String, MappingValue> mappingValues;
private Map<String, Class<?>> mappingClz = new HashMap<>();
private Map<String, Object> mappingObjs = new HashMap<>();

随后实现Servlet初始化方法,初始化主要处理从外部传入的资源,将XML文件内容解析后存入mappingValues内。最后调用Refresh()函数创建Bean,这节课的例子就是HelloWorldBean,这些Bean的类和实例存放在mappingClz和mappingObjs里。

    public void init(ServletConfig config) throws ServletException {
    	super.init(config);

        sContextConfigLocation = config.getInitParameter("contextConfigLocation");
        URL xmlPath = null;
		try {
			xmlPath = this.getServletContext().getResource(sContextConfigLocation);
		} catch (MalformedURLException e) {
			e.printStackTrace();
		}
		Resource rs = new ClassPathXmlResource(xmlPath);
        XmlConfigReader reader = new XmlConfigReader();
        mappingValues = reader.loadConfig(rs);
        Refresh();
    }

下面是Refresh()方法。

//对所有的mappingValues中注册的类进行实例化,默认构造函数
protected void Refresh() {
 	for (Map.Entry<String,MappingValue> entry : mappingValues.entrySet()) {
    	String id = entry.getKey();
    	String className = entry.getValue().getClz();
    	Object obj = null;
    	Class<?> clz = null;
		try {
			clz = Class.forName(className);
			obj = clz.newInstance();
		} catch (Exception e) {
			e.printStackTrace();
		}
		mappingClz.put(id, clz);
    	mappingObjs.put(id, obj);
    }
}

Refresh()就是通过读取mappingValues中的Bean定义,加载类,创建实例。这个方法完成之后,整个DispatcherSevlet就准备好了。

DispatcherSevlet用来处理所有的Web请求,但是目前我们只是简单地实现了Get请求的处理,通过Bean的id获取其对应的类和方法,依赖反射机制进行调用。你可以看一下相关代码。

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String sPath = request.getServletPath(); //获取请求的path
	if (this.mappingValues.get(sPath) == null) {
		return;
	}

    Class<?> clz = this.mappingClz.get(sPath); //获取bean类定义
    Object obj = this.mappingObjs.get(sPath);  //获取bean实例
    String methodName = this.mappingValues.get(sPath).getMethod(); //获取调用方法名
    Object objResult = null;
    try {
        Method method = clz.getMethod(methodName);
        objResult = method.invoke(obj); //方法调用
    } catch (Exception e) {
    }
    //将方法返回值写入response
    response.getWriter().append(objResult.toString());
}

到这里,一个最简单的DispatcherServlet就完成了,DispatcherServlet就是一个普通的Servlet,并不神秘,只要我们有一个Servlet容器,比如Tomcat,它就能跑起来。

这个实现很简陋,调用的方法没有参数,返回值只是String,直接通过response回写。

我们试一个简单的测试类。

package com.minis.test;

public class HelloWorldBean {
	public String doGet() {
		return "hello world!";
	}
	public String doPost() {
		return "hello world!";
	}
}

启动Tomcat,在浏览器内键入localhost:8080/helloworld,就能显示返回结果”hello world for doGet!”。

到这里,我们初步实现了MVC的框架,支持了一个简单的请求由Controller控制器(DispatcherServlet),到底层查找模型结构Model(helloWorldBean),最后返回前端渲染视图View(response.getWriter().append())的过程。

扩展MVC

在这个简陋的模型基础之上,我们一步步扩展,引入@RequestMapping,还会实现ComponentScan,简化配置工作。

简化配置

首先我们来简化XML中的繁琐配置,在minisMVC-servlet.xml里新增和两个标签,分别表示组件配置以及组件的扫描配置。也就是说,扫描一个包,自动配置包内满足条件的类,省去手工配置过程。你可以参考下面的代码。

(minisMVC-servlet.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<components>
    <component-scan base-package="com.minis.test" />
</components>

上述文件将扫描com.minis.test里所有的类文件,加载并实例化它们。

引入@RequestMapping

接下来我们引入@RequestMapping,将 URL 和业务处理类中的某个方法对应起来,这样也就不再需要手工地将映射关系写到XML配置文件里,省去我们的手工配置工作。在Spring框架里, @RequestMapping 注解可支持定义在类上,但我们这里暂时不支持该注解定义在类上,只定义在方法上。我们看一下注解定义。

package com.minis.web;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;

@Target(value={ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
    String value() default "";
}

@RequestMapping定义很简单,现在只有value一个字段,用来接收配置的URL。

有了注解定义,我们就可以动手编程实现了。因为修改了minisMVC-servlet.xml这个文件内的标签结构,因此我们提供一个新类 XmlScanComponentHelper,专门用来解析新定义的标签结构。

package com.minis.web;

import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.Node;
import org.dom4j.io.SAXReader;

public class XmlScanComponentHelper {
    public static List<String> getNodeValue(URL xmlPath) {
        List<String> packages = new ArrayList<>();
        SAXReader saxReader = new SAXReader();
        Document document = null;
        try {
            document = saxReader.read(xmlPath); //加载配置文件
        } catch (DocumentException e) {
            e.printStackTrace();
        }
        Element root = document.getRootElement();
        Iterator it = root.elementIterator();
        while (it.hasNext()) { //得到XML中所有的base-package节点
            Element element = (Element) it.next();
            packages.add(element.attributeValue("base-package"));              }
        return packages;
    }
}

程序也很简单,原有的XmlConfigReadder 、Resource 、MappingValue 和ClassPathXmlResource 不再需要使用,取而代之的是XmlScanComponentHelper ,把扫描到的package 存储在List packages 这个结构里。代码的核心就是获取“base-package”参数值,加载到内存里。

修改 DispatcherServlet

经过上面这些步骤之后,接下来我们需要进一步修改 DispatcherServlet ,因为最终一切的落脚点都在这个类里,这个类承载了所有请求的解析和处理请求的步骤。我们在 DispatcherServlet 里使用下面的数据结构来存储配置。

private List<String> packageNames = new ArrayList<>();
private Map<String,Object> controllerObjs = new HashMap<>();
private List<String> controllerNames = new ArrayList<>();
private Map<String,Class<?>> controllerClasses = new HashMap<>();         private List<String> urlMappingNames = new ArrayList<>();
private Map<String,Object> mappingObjs = new HashMap<>();
private Map<String,Method> mappingMethods = new HashMap<>();

我们看下这些变量的作用。

接下来,Servlet初始化时我们把 minisMVC-servlet.xml 里扫描出来的 package 名称存入 packageNames 列表,初始化方法 init 中增加以下这行代码。

this.packageNames = XmlScanComponentHelper.getNodeValue(xmlPath);

注:原有的与 ClassPathXmlResource 、Resource 相关代码要清除。

我们再将 refresh()方法分成两步:第一步初始化 controller ,第二步则是初始化 URL 映射。

对应的 refresh() 方法进行如下抽象:

protected void refresh() {
    initController(); // 初始化 controller
    initMapping(); // 初始化 url 映射
}

接下来完善initController() ,其主要功能是对扫描到的每一个类进行加载和实例化,与类的名字建立映射关系,分别存在 controllerClasses 和 controllerObjs 这两个map里,类名就是key的值。

protected void initController() {
    //扫描包,获取所有类名
    this.controllerNames = scanPackages(this.packageNames);
    for (String controllerName : this.controllerNames) {
        Object obj = null;
        Class<?> clz = null;
        try {
            clz = Class.forName(controllerName); //加载类
            this.controllerClasses.put(controllerName, clz);
        } catch (Exception e) {
        }
        try {
            obj = clz.newInstance(); //实例化bean
            this.controllerObjs.put(controllerName, obj);
        } catch (Exception e) {
        }
    }

扫描程序是对文件目录的递归处理,最后的结果就是把所有的类文件扫描出来。

private List<String> scanPackages(List<String> packages) {
    List<String> tempControllerNames = new ArrayList<>();
    for (String packageName : packages) {
        tempControllerNames.addAll(scanPackage(packageName));
    }
    return tempControllerNames;
}
private List<String> scanPackage(String packageName) {
    List<String> tempControllerNames = new ArrayList<>();
    URI uri = null;
    //将以.分隔的包名换成以/分隔的uri
    try {
        uri = this.getClass().getResource("/" +
packageName.replaceAll("\\.", "/")).toURI();
    } catch (Exception e) {
    }
    File dir = new File(uri);
    //处理对应的文件目录
    for (File file : dir.listFiles()) { //目录下的文件或者子目录
        if(file.isDirectory()){ //对子目录递归扫描
            scanPackage(packageName+"."+file.getName());
        }else{ //类文件
            String controllerName = packageName +"."
+file.getName().replace(".class", "");
            tempControllerNames.add(controllerName);
        }
    }
    return tempControllerNames;
}

然后完善initMapping() ,功能是初始化 URL 映射,找到使用了注解@RequestMapping 的方法,URL 存放到 urlMappingNames 里,映射的对象存放到 mappingObjs 里,映射的方法存放到 mappingMethods 里。用这个方法取代了过去解析 Bean 得到的映射。

protected void initMapping() {
    for (String controllerName : this.controllerNames) {
        Class<?> clazz = this.controllerClasses.get(controllerName);
             Object obj = this.controllerObjs.get(controllerName);
        Method[] methods = clazz.getDeclaredMethods();
        if (methods != null) {
            for (Method method : methods) {
                //检查所有的方法
                boolean isRequestMapping =
method.isAnnotationPresent(RequestMapping.class);
                if (isRequestMapping) { //有RequestMapping注解
                    String methodName = method.getName();
                    //建立方法名和URL的映射
                    String urlMapping =
method.getAnnotation(RequestMapping.class).value();
                    this.urlMappingNames.add(urlMapping);
                    this.mappingObjs.put(urlMapping, obj);
                    this.mappingMethods.put(urlMapping, method);
                }
            }
        }
    }
}

最后略微调整 doGet() 方法内的代码,去除不再使用的结构。

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String sPath = request.getServletPath();
	if (!this.urlMappingNames.contains(sPath)) {
		return;
	}
    Object obj = null;
    Object objResult = null;
    try {
        Method method = this.mappingMethods.get(sPath);
        obj = this.mappingObjs.get(sPath);
        objResult = method.invoke(obj);
    } catch (Exception e) {
    }
    response.getWriter().append(objResult.toString());
}

修改一下测试类,在com.minis.test.HelloworldBean内的测试方法上,增加@RequestMapping注解。

package com.minis.test;

import com.minis.web.RequestMapping;

public class HelloWorldBean {
    @RequestMapping("/test")
    public String doTest() {
        return "hello world for doGet!";
    }
}

启动Tomcat进行测试,在浏览器输入框内键入:localhost:8080/test。

小结

我们这节课构建了一个DispatcherServlet,它是Tomcat中注册的唯一的Servlet,它承担了所有请求的处理功能。由它来解析请求中的路径与业务类Bean中方法的映射关系,调用Bean的相应方法,返回给response。

这种映射关系的建立,我们一开始是让用户自己在XML配置文件中手动声明,然后我们引入RequestMapping注解,扫描包中的类,检查注解,自动注册映射关系。这样我们初步实现了比较原始的MVC。在这个框架下,应用程序员不用再关心Servlet的使用,他们可以直接建立业务类,加上注解就可以运行。

完整源代码参见 https://github.com/YaleGuo/minis,mvc分支。

课后题

学完这节课,我也给你留一道思考题。我们在MVC中也使用了Bean这个概念,它跟我们以前章节中的Bean是什么关系?欢迎你在留言区与我交流讨论,也欢迎你把这节课分享给需要的朋友。我们下节课见!