你好,我是郭屹。
通过上节课的工作,我们就初步实现了一个原始的MVC框架,并引入了@RequestMapping注解,还通过对指定的包进行全局扫描来简化XML文件配置。但是这个MVC框架是独立运行的,它跟我们之前实现的IoC容器还没有什么关系。
那么这节课,我们就把前面实现的IoC容器与MVC结合在一起,使MVC的Controller可以引用容器中的Bean,这样整合成一个大的容器。
IoC容器是一个自我实现的服务器,MVC是要符合Web规范的,不能自己想怎么来就怎么来。为了融合二者,我们有必要了解一下Web规范的内容。在Servlet规范中,服务器启动的时候,会根据web.xml文件来配置。下面我们花点时间详细介绍一下这个配置文件。
这个web.xml文件是Java的Servlet规范中规定的,它里面声明了一个Web应用全部的配置信息。按照规定,每个Java Web应用都必须包含一个web.xml文件,且必须放在WEB-INF路径下。它的顶层根是web-app,指定命名空间和schema规定。通常,我们会在web.xml中配置context-param、Listener、Filter和Servlet等元素。
下面是常见元素的说明。
<display-name></display-name>
声明WEB应用的名字
<description></description>
声明WEB应用的描述信息
<context-param></context-param>
声明应用全局的初始化参数。
<listener></listener>
声明监听器,它在建立、修改和删除会话或servlet环境时得到事件通知。
<filter></filter>
声明一个实现javax.servlet.Filter接口的类。
<filter-mapping></filter-mapping>
声明过滤器的拦截路径。
<servlet></servlet>
声明servlet类。
<servlet-mapping></servlet-mapping>
声明servlet的访问路径,试一个方便访问的URL。
<session-config></session-config>
session有关的配置,超时值。
<error-page></error-page>
在返回特定HTTP状态代码时,或者特定类型的异常被抛出时,能够制定将要显示的页面。
当Servlet服务器如Tomcat启动的时候,要遵守下面的时序。
规范中规定的这个时序,就是我们整合两者的关键所在。
由上述服务器启动过程我们知道,我们把web.xml文件里定义的元素加载过程简单归总一下:先获取全局的参数context-param来创建上下文,之后如果配置文件里定义了Listener,那服务器会先启动它们,之后是Filter,最后是Servlet。因此我们可以利用这个时序,把容器的启动放到Web应用的Listener中。
Spring MVC就是这么设计的,它按照这个规范,用ContextLoaderListener来启动容器。我们也模仿它同样来实现这样一个Listener。
package com.minis.web;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
public class ContextLoaderListener implements ServletContextListener {
public static final String CONFIG_LOCATION_PARAM = "contextConfigLocation";
private WebApplicationContext context;
public ContextLoaderListener() {
}
public ContextLoaderListener(WebApplicationContext context) {
this.context = context;
}
@Override
public void contextDestroyed(ServletContextEvent event) {
}
@Override
public void contextInitialized(ServletContextEvent event) {
initWebApplicationContext(event.getServletContext());
}
private void initWebApplicationContext(ServletContext servletContext) {
String sContextLocation = servletContext.getInitParameter(CONFIG_LOCATION_PARAM);
WebApplicationContext wac = new AnnotationConfigWebApplicationContext(sContextLocation);
wac.setServletContext(servletContext);
this.context = wac;
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
}
}
ContextLoaderListener这个类里,先声明了一个常量CONFIG_LOCATION_PARAM,它的默认值是contextConfigLocation,这是代表配置文件路径的一个变量,也就是IoC容器的配置文件。这也就意味着,Listener期望web.xml里有一个参数用来配置文件路径。我们可以看一下web.xml文件。
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>
com.minis.web.ContextLoaderListener
</listener-class>
</listener>
上面这个文件,定义了这个Listener,还定义了全局参数指定配置文件路径。
ContextLoaderListener这个类里还定义了WebApplicationContext对象,目前还不存在这个类。但通过名字可以知道,WebApplicationContext 是一个上下文接口,应用在Web项目里。我们看看如何定义WebApplicationContext。
package com.minis.web;
import javax.servlet.ServletContext;
import com.minis.context.ApplicationContext;
public interface WebApplicationContext extends ApplicationContext {
String ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE = WebApplicationContext.class.getName() + ".ROOT";
ServletContext getServletContext();
void setServletContext(ServletContext servletContext);
}
可以看出,这个上下文接口指向了Servlet容器本身的上下文ServletContext。
接下来我们继续完善 ContextLoaderListener 这个类, 在初始化的过程中初始化WebApplicationContext, 并把这个上下文放到 servletContext 的 Attribute 某个属性里面。
public void contextInitialized(ServletContextEvent event) {
initWebApplicationContext(event.getServletContext());
}
private void initWebApplicationContext(ServletContext servletContext) {
String sContextLocation =
servletContext.getInitParameter(CONFIG_LOCATION_PARAM);
WebApplicationContext wac = new
AnnotationConfigWebApplicationContext(sContextLocation);
wac.setServletContext(servletContext);
this.context = wac;
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ ATTRIBUTE, this.context);
在这段代码中,通过配置文件参数从web.xml中得到配置文件路径,如applicationContext.xml,然后用这个配置文件创建了AnnotationConfigWebApplicationContext这一对象,我们叫WAC,这就成了新的上下文。然后调用servletContext.setAttribute()方法,按照默认的属性值将WAC设置到servletContext里。这样,AnnotationConfigWebApplicationContext 和 servletContext 就能够互相引用了,很方便。
而这个AnnotationConfigWebApplicationContext又是什么呢?我们看下它的定义。
package com.minis.web;
import javax.servlet.ServletContext;
import com.minis.context.ClassPathXmlApplicationContext;
public class AnnotationConfigWebApplicationContext
extends ClassPathXmlApplicationContext implements WebApplicationContext{
private ServletContext servletContext;
public AnnotationConfigWebApplicationContext(String fileName) {
super(fileName);
}
@Override
public ServletContext getServletContext() {
return this.servletContext;
}
@Override
public void setServletContext(ServletContext servletContext) {
this.servletContext = servletContext;
}
}
由 AnnotationConfigWebApplicationContext 的继承关系可看出,该类其实质就是我们IoC容器中的ClassPathXmlApplicationContext,只是在此基础上增加了 servletContext 的属性,这样就成了一个适用于Web场景的上下文。
我们在这个过程中用到了一个配置文件applicationContext.xml,它是由定义在web.xml里的一个参数指明的。
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>applicationContext.xml</param-value>
</context-param>
这个配置文件就是我们现在的IoC容器的配置文件,主要作用是声明Bean,如:
<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="bbs" class="com.test.service.BaseBaseService">
<property type="com.test.service.AServiceImpl" name="as" ref="aservice"/>
</bean>
<bean id="aservice" class="com.test.service.AServiceImpl">
<constructor-arg type="String" name="name" value="abc"/>
<constructor-arg type="int" name="level" value="3"/>
<property type="String" name="property1" value="Someone says"/>
<property type="String" name="property2" value="Hello World!"/>
<property type="com.test.service.BaseService" name="ref1" ref="baseservice"/>
</bean>
<bean id="baseservice" class="com.test.service.BaseService">
</bean>
</beans>
回顾一下,现在完整的过程是:当Sevlet服务器启动时,Listener会优先启动,读配置文件路径,启动过程中初始化上下文,然后启动IoC容器,这个容器通过refresh()方法加载所管理的Bean对象。这样就实现了Tomcat启动的时候同时启动IoC容器。
好了,到了这一步,IoC容器启动了,我们回来再讨论MVC这边的事情。我们已经知道,在服务器启动的过程中,会注册 Web应用上下文,也就是WAC。 这样方便我们通过属性拿到启动时的 WebApplicationContext 。
this.webApplicationContext = (WebApplicationContext) this.getServletContext().getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION _CONTEXT_ATTRIBUTE);
因此我们改造一下DispatcherServlet这个核心类里的init()方法。
public void init(ServletConfig config) throws ServletException { super.init(config);
this.webApplicationContext = (WebApplicationContext)
this.getServletContext().getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION _CONTEXT_ATTRIBUTE);
sContextConfigLocation = config.getInitParameter("contextConfigLocation");
URL xmlPath = null;
try {
xmlPath = this.getServletContext().getResource(sContextConfigLocation);
} catch (MalformedURLException e) {
e.printStackTrace();
}
this.packageNames = XmlScanComponentHelper.getNodeValue(xmlPath); Refresh();
}
首先在Servlet初始化的时候,从sevletContext里获取属性,拿到Listener启动的时候注册好的WebApplicationContext,然后拿到Servlet配置参数contextConfigLocation,这个参数代表的是配置文件路径,这个时候是我们的MVC用到的配置文件,如minisMVC-servlet.xml,之后再扫描路径下的包,调用refresh()方法加载Bean。这样,DispatcherServlet也就初始化完毕了。
然后是改造initMapping()方法,按照新的办法构建URL和后端程序之间的映射关系:查找使用了注解 @RequestMapping 的方法,将 URL 存放到 urlMappingNames 里,再把映射的对象存放到 mappingObjs 里,映射的方法存放到 mappingMethods 里。用这个方法取代过去解析 Bean 得到的映射,省去了XML文件里的手工配置。你可以看一下相关代码。
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) {
String methodName = method.getName();
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) {
e.printStackTrace();
}
response.getWriter().append(objResult.toString());
}
代码里的这个doGet()方法从请求中获取访问路径,按照路径和后端程序的映射关系,获取到需要调用的对象和方法,调用方法后直接把结果返回给response。
到这里,整合了IoC容器的MVC就完成了。
下面进行测试,我们先看一下Tomcat使用的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">
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>
com.minis.web.ContextLoaderListener
</listener-class>
</listener>
<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>
然后是IoC容器使用的配置文件applicationContext.xml。
<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="bbs" class="com.test.service.BaseBaseService">
<property type="com.test.service.AServiceImpl" name="as" ref="aservice"/>
</bean>
<bean id="aservice" class="com.test.service.AServiceImpl">
<constructor-arg type="String" name="name" value="abc"/>
<constructor-arg type="int" name="level" value="3"/>
<property type="String" name="property1" value="Someone says"/>
<property type="String" name="property2" value="Hello World!"/>
<property type="com.test.service.BaseService" name="ref1" ref="baseservice"/>
</bean>
<bean id="baseservice" class="com.test.service.BaseService">
</bean>
</beans>
MVC扫描的配置文件minisMVC-servlet.xml。
<?xml version="1.0" encoding="UTF-8" ?>
<components>
<component-scan base-package="com.test"/>
</components>
最后,在com.minis.test.HelloworldBean内的测试方法上,增加@RequestMapping注解。
package com.test;
import com.minis.web.RequestMapping;
public class HelloWorldBean {
@RequestMapping("/test")
public String doTest() {
return "hello world for doGet!";
}
}
启动Tomcat进行测试,在浏览器输入框内键入:localhost:8080/test。
注:这个端口号可以自定义,也可依据实际情况在请求路径前增加上下文。
运行成功,学到这里,看到这个结果,你应该很开心吧。
这节课,我们把MVC与IoC整合在了一起。具体过程是这样的:在Tomcat启动的过程中先拿context-param,初始化Listener,在初始化过程中,创建IoC容器构建WAC(WebApplicationContext),加载所管理的Bean对象,并把WAC关联到servlet context里。
然后在DispatcherServlet初始化的时候,从sevletContext里获取属性拿到WAC,放到servlet的属性中,然后拿到Servlet的配置路径参数,之后再扫描路径下的包,调用refresh()方法加载Bean,最后配置url mapping。
我们之所以有办法整合这二者,核心的原因是 Servlet规范中规定的时序,从listerner到filter再到servlet,每一个环节都预留了接口让我们有机会干预,写入我们需要的代码。我们在学习过程中,更重要的是要学习如何构建可扩展体系的思路,在我们自己的软件开发过程中,记住 不要将程序流程固定死,那样没有任何扩展的余地,而应该想着预留出一些接口理清时序,让别人在关节处也可以插入自己的逻辑。
容器是一个框架,之所以叫做框架而不是应用程序,关键就在于这套可扩展的体系,留给其他程序员极大的空间。读Rodd Johnson这些大师的源代码,就像欣赏一本优美的世界名著,每每都会发出“春风大雅能容物,秋水文章不染尘”的赞叹。希望你可以学到其中的精髓。
完整源代码参见 https://github.com/YaleGuo/minis
学完这节课,我也给你留一道思考题。我们看到从Dispatcher 内可访问WebApplicationContext里面管理的Bean,那通过WebApplicationContext 可以访问Dispatcher内管理的Bean吗?欢迎你在留言区和我交流讨论,也欢迎你把这节课分享给需要的朋友。我们下节课见!