本文原载于我的博客,地址:https://blog.guoziyang.top/archives/58/
面试官:用过SpringMVC吗?
我:用过啊(内心OS:不就是Controller之类的吗)
面试官:那可以说一下当一个请求到来时,SpringMVC的处理流程吗?
我:……
真悲伤!
背一万遍概念不如自己亲手写一遍!这里,我带着大家实现一个简单的SpringMVC框架,帮助大家理解SpringMVC的处理流程,使得大家在面试中再遇到类似问题就可以侃侃而谈了。
这次实现的这个SpringMVC,依赖于我们上一篇文章(手撸一个Spring IOC容器——渐进式实现)中的Spring框架,如果还没有看过上篇文章的同学可以去学习一下。
本文项目的完整代码在Github上,地址:https://github.com/CN-GuoZiyang/My-Spring-IOC
要实现我们自己的框架,就必须对原版框架的处理流程了解得清晰透彻,一张图总结:
我们都知道,SpringMVC是基于Java的Servelt技术实现的,那么我们就需要导入Servlet的支持包,将这个项目改造成为一个Web项目。
在Maven中添加如下依赖:
<dependencies>
<dependency>
<groupId>javax.servletgroupId>
<artifactId>javax.servlet-apiartifactId>
<version>4.0.1version>
<scope>providedscope>
dependency>
dependencies>
并且在项目的根目录下建立一个web文件夹,再在web文件夹下建立WEB-INF文件夹,再在其中新建web.xml
文件。这就是这个web项目的配置文件,即Servlet的配置文件。内容如下:
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee" xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<servlet>
<servlet-name>MySpringMVCservlet-name>
<servlet-class>top.guoziyang.springframework.web.DispatcherServletservlet-class>
<init-param>
<param-name>contextConfigLocationparam-name>
<param-value>application.propertiesparam-value>
init-param>
<load-on-startup>1load-on-startup>
servlet>
<servlet-mapping>
<servlet-name>MySpringMVCservlet-name>
<url-pattern>/*url-pattern>
servlet-mapping>
web-app>
这里和SpringMVC的处理流程一样,就是新建了一个类DispatcherServlet
注册为Servlet,并且设置这个Servlet处理所有的URL请求。
在这里我们配置了一个参数contextConfigLocation
,参数的值为application.properties
。这个文件作为我们的SpringMVC的配置文件,我们的SpringMVC并不需要太多配置,只需要知道Controller的扫描路径就可以了。在resources文件夹下新建这个文件,里面我只写了一行:
scanPackage=top.guoziyang.main.controller
表示我的所有的Controller都会放在top.guoziyang.main.controller
包及其子包下,到时候启动时SpringMVC会去扫描这个包。
接着我们去定义三个注解:@Controller
、@RequestMapping
和@RequestParam
,用过SpringMVC的人应该都知道这三个注解是干嘛的,我就不多说了。
package top.guoziyang.springframework.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {}
package top.guoziyang.springframework.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String value() default "";
}
package top.guoziyang.springframework.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestParam {
String value();
}
接着我们就需要实现在配置文件里写的DispatcherServlet
类,这个类需要继承HttpServlet
类,才是一个可被使用的Servlet。
这个类需要重写父类中的三个主要的方法,init()
、doGet()
和doPost()
方法。
init方法如下:
@Override
public void init(ServletConfig config) {
try {
xmlApplicationContext = new ClassPathXmlApplicationContext("application-annotation.xml");
} catch (Exception e) {
e.printStackTrace();
}
doLoadConfig(config.getInitParameter("contextConfigLocation"));
doScanner(properties.getProperty("scanPackage"));
doInstance();
initHandlerMapping();
}
注意这里首先初始化了一个Spring容器。
init方法主要的功能就是读取配置文件,接着扫描目标包下所有的Controller,最后实例化所有的Controller,并且绑定URL路由。对应上面的8、9、10和11行。其中第八行和第九行是把包中所有的类都扫描出来后,存储在classNames这个List里。
doInstance()的实现很简单,如下:
private void doInstance() {
if (classNames.isEmpty()) {
return;
}
for (String className : classNames) {
try {
//把类搞出来,反射来实例化(只有加@Controller需要实例化)
Class clazz = Class.forName(className);
if (clazz.isAnnotationPresent(Controller.class)) {
classes.add(clazz);
BeanDefinition definition = new BeanDefinition();
definition.setSingleton(true);
definition.setBeanClassName(clazz.getName());
xmlApplicationContext.addNewBeanDefinition(clazz.getName(), definition);
}
} catch (Exception e) {
e.printStackTrace();
}
}
try {
xmlApplicationContext.refreshBeanFactory();
} catch (Exception e) {
e.printStackTrace();
}
}
主要就是把上一步中包下的所有类遍历一下,找到加上了Controller注解的类,添加到Spring容器里就行了。
这里有人就会问了,唉我Spring容器已经初始化完成了,怎么还能往里添加Bean呢?原理很简单,我们手动刷新下不就行了。这里给XmlApplicationContext类添加了一个refreshBeanFactory()方法,手动刷新Bean的配置,如果遇到没有初始化的(刚添加进去的)就会初始化。方法实现非常简单:
public void refreshBeanFactory() throws Exception {
prepareBeanFactory((AbstractBeanFactory) beanFactory);
}
注意这里我们还把符合条件的类(Controller)放在了classes里,这是一个HashSet,后续在绑定URL的时候要用。
在initHandlerMapping()方法中,我们将扫描对应的Controller,找出某个URL应当由哪个类的哪个方法进行处理。如下:
private void initHandlerMapping() {
if (classes.isEmpty()) return;
try {
for (Class<?> clazz : classes) {
String baseUrl = "";
if (clazz.isAnnotationPresent(RequestMapping.class)) {
baseUrl = clazz.getAnnotation(RequestMapping.class).value();
}
Method[] methods = clazz.getMethods();
for (Method method : methods) {
if (!method.isAnnotationPresent(RequestMapping.class)) continue;
String url = method.getAnnotation(RequestMapping.class).value();
url = (baseUrl + "/" + url).replaceAll("/+", "/");
handlerMapping.put(url, method);
controllerMap.put(url, xmlApplicationContext.getBean(clazz));
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
由于我们已经把符合条件的Controller都放在了classes中,只要遍历这个Set就行了。对每个类遍历方法,获取RequestMapping这个注解的值,并且拼接出完整的URL,将URL与方法的映射存储在handlerMapping这个map中,将URL与类的映射存储在controllerMap中。
那么最终,一个请求到来时,是到达doGet()和doPost()方法的。我们自己实现一个doDispatch()方法来进行自定义处理。
doDispatch()方法首先需要分离出请求的URL和请求参数,找到对应的方法后通过反射调用。如下:
public void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (handlerMapping.isEmpty()) return;
String url = request.getRequestURI();
String contextPath = request.getContextPath();
url = url.replace(contextPath, "").replaceAll("/+", "/");
if (!handlerMapping.containsKey(url)) {
response.getWriter().write("404 NOT FOUND!");
return;
}
Method method = handlerMapping.get(url);
Class<?>[] parameterTypes = method.getParameterTypes();
Map<String, String[]> parameterMap = request.getParameterMap();
Object[] paramValues = new Object[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++) {
String requestParam = parameterTypes[i].getSimpleName();
if (requestParam.equals("HttpServletRequest")) {
paramValues[i] = request;
continue;
}
if (requestParam.equals("HttpServletResponse")) {
paramValues[i] = response;
continue;
}
if (requestParam.equals("String")) {
for (Map.Entry<String, String[]> param : parameterMap.entrySet()) {
String value = Arrays.toString(param.getValue()).replaceAll("\\[|\\]", "").replaceAll(",\\s", ",");
paramValues[i] = value;
}
}
}
method.invoke(controllerMap.get(url), paramValues);
}
反射调用方法传参的方式,是通过一个Object数组的方式传入参数的,按照方法定义参数的顺序,将值存放在数组中,在反射调用时将数组传入即可。
这里我们写了一个Controller:
@Controller
@RequestMapping("/test")
public class TestController {
@Autowired
private HelloWorldService helloWorldService;
@RequestMapping("/test1")
public void test1(HttpServletRequest request, HttpServletResponse response,
@RequestParam("param") String param) {
try {
String text = helloWorldService.getString();
response.getWriter().write(text + " and the param is " + param);
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里我们同时还注入了一个对象,HelloWorldService,来看一看和Spring的耦合是否成功。这个test1方法还需要传入一个参数param,用于测试传参。
当把项目通过Tomcat启动在8080端口后,访问http://localhost:8080/test1?param=abc,出现如下结果:
Hello world and the param is abc
成功!