时间:2017-12-09

Web 开发哪家强, 牧童遥指 Java Servlet。

Servlet 容器提供了 HTTP 连接管理,请求解析,响应构造等逻辑, 开发者只需要实现一个 Servlet 方法,可能只有几行代码,即可提供一个 HTTP 服务。

大学时开始接触 Java Servlet 和 Spring,算起来已有 10 年。 那时候 Spring 还是 2.0/2.5 版本,Maven 还没有出现。 这些年来 Java 和 Spring 都有许多长足的进步,Vert.x 等新框架也开始流行。 那时候的学习经验和技术栈,却一直受用到现在。 理解这些技术的思想和设计,有助于我们开发优秀的程序,做出正确决策。 本文尝试从传统 Java Servlet 开发讲起,梳理这些年来 Spring Java Web 开发的演变和进步,也是自我学习知识体系的梳理和重温。 本文示例代码在 github 上 java-web-dev

Java Servlet 快速开发

Java Servlet 是 JavaEE 的一部分, 是拿来卖钱的商业产品。 同时也有 tomcat, jetty 等许多成熟流行的免费开源实现。

如前文所说,Servlet 容器实现了 HTTP 服务基础框架, 最终的 HTTP 业务逻辑定义为 Servlet 接口,即 javax.servlet-api,由应用开发者实现。 如一个最简单的 HTTP 服务只需实现 HttpServletdoGet() 方法即可,只需几行代码。

public class HelloServlet extends HttpServlet {

	private static final long serialVersionUID = 1L;

	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		String name = req.getParameter("name");
		resp.setContentType("text/plain");
		PrintWriter out = resp.getWriter();
		out.printf("Hello Servlet %s\n", name);
	}
	
}
  • Java Servlet 采用面向接口编程设计,应用容器解耦,应用开发时只需要依赖 javax.servlet-api,不需要依赖具体容器。
  • HttpServletservice() 方法将不同的 HTTP 请求类型拆分到不同的方法,如 doGet(), doPost() 等,默认实现为返回错误。 如果希望 GET, POST 返回相同的结果, 可提供一个公共方法让两者直接调用, JSP 即是这样做的。
  • Servlet 方法返回后 Servlet 容器会自动构造响应返回给客户端。 不需要调 out.flush(),其表示立即将当前响应发送到客户端,将导致使用 chunked 传输编码。

一个 Java webapp 可打包为一个文件夹(或 war 包),结构如下:

webapp/
├── WEB-INF/
│   ├── classes/
│   ├── lib/
│   └── web.xml
└── index.html
  • 这个文件夹下的文件通常作为静态资源访问(tomcat 下把应用根目录叫做 docBase)。
  • WEB-INF 是个特殊的目录, 不能对外直接访问,这个目录下包含应用配置,类(classes)和依赖库(lib)等文件。 javax.servlet-api 等依赖库由容器提供,不需要打包到应用,Maven 中使用 provided scope 标识。
  • web.xml 是 Java webapp 核心配置文件,在传统 Java webapp 中必不可少,包含 webapp 配置和 Listener,Servlet,Filter 等配置。
  • Maven 项目默认使用 src/main/webapp 作为应用根目录,包含静态资源和配置等。 打包应用时只需要在此目录(Maven 将其拷贝到打包目录)下添加类和依赖库即可。

上述 Servlet 配置示例 web.xml 如下:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.1" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xml="http://www.w3.org/XML/1998/namespace"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd ">

	<servlet>
		<servlet-name>hello</servlet-name>
		<servlet-class>com.example.javawebdev.HelloServlet</servlet-class>
	</servlet>
	<servlet-mapping>
		<servlet-name>hello</servlet-name>
		<url-pattern>/hello</url-pattern>
	</servlet-mapping>

</web-app>
  • 创建一个名为 hello 的 Servlet, 映射 URL /hello 由此 Servlet 处理。

  • Servlet 通常是单例,可使用 <init-param> 进行简单 name/value 对配置,Servlet.init(ServletConfig config) 方法可获取配置。

  • url-pattern 只支持 4 种匹配模式:

    1. 路径前缀匹配, 如 /api/*
    2. 扩展名匹配, 如 *.do
    3. 精确匹配。指定完整匹配路径,注意必需包含前缀 /
    4. 默认(备选)匹配 /, 其他映射都不匹配时匹配此映射。注意与 /* 不一样,其为前缀匹配,表示匹配全部。
  • 无法精确匹配根路径,/ 会被识别为默认匹配。可使用 <welcome-file-list> 配置根路径默认访问的页面。

至此,HelloServlet 示例程序即开发完毕,代码见 tag servlet

如何运行呢?

  • 早期计算机资源十分珍贵,为了灵活性和共享节约资源,Servlet 容器被设计为可部署多个 webapp。

  • 一个 webapp 对应一个 ServletContext, 通过不同的 URL 前缀(叫做 context path)访问。 如 tomcat webapps 目录下一个子目录或 war 包即为一个应用,tomcat 启动后默认自动部署应用。 目录或 war 包名即为 context path,特殊命名 ROOT 表示直接以根路径访问。

  • 每个 webapp 有一个独立的 ClassLoader,不同应用之间的类和依赖库相互隔离。

打包应用,拷贝到 tomcat webapps 目录下命名为 ROOT,启动 tomcat 即自动完成应用部署。

访问应用测试结果如下:

$ curl localhost:8080/hello
Hello Servlet null

$ curl localhost:8080/hello?name=java
Hello Servlet java

这是传统的部署 webapp 的方式,需要先打包应用,但线上应用部署频率不高,长久以来都在采用这种部署方式。 开发测试环境需要频繁修改代码重新部署时,就显得极为不方便了。

嵌入式 tomcat

如何更方便的部署 webapp?

  • 许多 IDE 插件尝试自动化重新打包部署的过程, 然而都做的不好用。

  • tomcat 7.0 有个 virtual webapp 的功能(tomcat 8.5 貌似没看到相关文档?), 旨在不打包应用的情况下直接部署 webapp,然而需要书写 context 配置文件,特别是构造 classpath,难以直接使用。 eclipse run-jetty-run 插件使用类似原理开发,相对好用一些。

然而这些方法还有一些缺点:

  • 容器 jar 包不由 IDE 管理, 需要调试容器内部代码时比较麻烦。
  • 容器是外部化的,需要额外维护开发环境与线上环境 Servlet 容器版本与配置一致,避免环境不一致导致问题。 前面提到 Servlet 设计使用 API 将容器与应用解耦,标准应用可以不关心容器环境,但保持环境一致更加万无一失。 标准化仍然有重要意义,方便更新、迁移容器环境。
  • 即使容器版本一致,部署方式不同也是环境不一致的一个风险,如开发插件构造的 classpath 与容器启动 webapp 的 classpath 可能有差异或 jar 包顺序不同。

为了解决这些问题,另一种思路是,不再把容器当做一个外部平台,而是当成一个 3 方库,以开发普通 java 应用的方式把 servlet 容器的能力引入进来。 tomcat 和 jetty 都支持这种方式,叫做嵌入式运行。 这样就可以像普通应用一样运行、调试 web 应用,方便不少。 线上也可以使用这种方式运行,从而保证环境一致。

以使用 tomcat 为例, 首先引入 maven 依赖:

	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-tomcat</artifactId>
	</dependency>
	<dependency>
		<groupId>org.apache.tomcat.embed</groupId>
		<artifactId>tomcat-embed-jasper</artifactId>
	</dependency>
  • 我们使用 spring-boot 管理依赖版本。 从开源软件上下游的角度看,3 方软件是 spring-boot 的上游,spring-boot 帮助我们筛选和组织常用软件的版本,并在某种程度上保证其兼容性。 我们只关心需要的依赖,不用关心其版本。
  • spring-boot-starter-tomcat 包含使用嵌入式 tomcat 通常需要的软件包,其包含(依赖)tomcat-embed-core 等。
  • 嵌入式 tomcat(groupId org.apache.tomcat.embed)是对 tomcat 代码的重新组织打包,tomcat-embed-core 是其核心组件的杂合包, 包含 servlet api, catalina engine 等内容。 最简方式使用嵌入式 tomcat 只需要引入这一个依赖即可。
  • spring-boot 已不推荐使用 JSP,但我们按传统方式使用 tomcat 时默认会启用 JSP 支持,所以我们手动引入 JSP 功能需要的依赖 tomcat-embed-jasper
  • tomcat 自己重写了 servlet-api 而没有依赖标准 javax.servlet-api,-可以去掉这个标准依赖避免 API 版本不一致冲突,- 但 spring-boot 帮我们考虑了依赖兼容问题,使用标准 API 包没有问题。 使用 tomcat 7.0 时发现标准 API 包某些注释比 tomcat servlet-api 详细。

启动嵌入式 tomcat 基本代码如下:

public class TomcatBootstrap {

	public static void main(String[] args) throws Exception {
		Tomcat tomcat = new Tomcat();
		tomcat.setBaseDir("target/tomcat");
		tomcat.getHost().setAutoDeploy(false);
		File webapp = new File("src/main/webapp").getAbsoluteFile();
		tomcat.addWebapp("", webapp.getPath());
		tomcat.start();
		tomcat.getServer().await();
	}
	
}
  • tomcat 运行时有两个重要配置,tomcat 安装目录 catalina.home,tomcat 运行实例目录 catalina.base。 嵌入式运行时应该没有安装目录的概念,调用 tomcat.setBaseDir() 设置运行实例目录。
  • 嵌入式运行时通常只部署一个 webapp,调用 tomcat.getHost().setAutoDeploy(false) 关闭自动部署 webapps 目录下的应用。
  • 手动设置要部署的 webapp,tomcat 提供了两个方法。
    • addWebapp() 与传统 tomcat 独立运行时部署 webapp 的行为一致。加载默认 web.xml,默认支持 JSP ,加载 WEB-INF/web.xml
    • addContext() 完全编程化的方式设置 Context。不使用默认 web.xml,默认不支持 JSP,也不会使用 javax.servlet.ServletContainerInitializer。 这里我们使用传统的 addWebapp()
  • webapp 路径,即 docBase,使用相对路径时默认是相对 tomcat webapps 目录,所以我们使用绝对路径设置,这样不用关心 webapps 路径。
  • 设置完成后调用 tomcat.start(),启动 tomcat 部署上述手动添加的应用。
  • tomcat 所有线程都是 daemon 线程,在非 daemon 线程(这里即主线程)调用 tomcat.getServer().await() 等待,避免 JVM 退出。
  • tomcat 默认为每个 webapp 创建一个 ClassLoader,这里 WEB-INF 目录下没有 classes 和 lib 目录, 实际上所有类都是用 tomcat 的 ClassLoader (这里即系统 ClassLoader) 加载的。 嵌入式运行时通常只有一个 webapp,暂不考虑类隔离问题。

单容器部署多应用可以节约共享资源,生产环境通常每个应用需要的机器都不止一台, 一台机器(虚拟机或容器)只部署一个应用没有问题,并且隔离性和运维部署更加方便。

程序启动后可看到 tomcat 日志输出, 应用部署完成后即可成功访问。

嵌入式 tomcat 是否设置了系统属性 catalina.base 呢?修改 HelloServlet 打印系统属性:

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
	String key = req.getParameter("key");
	String value = System.getProperty(key);
	resp.setContentType("text/plain");
	PrintWriter out = resp.getWriter();
	out.printf("Hello Servlet %s\n", value);
}

测试结果:

$ curl localhost:8080/hello?key=catalina.home
Hello Servlet /home/hanyong/workspace/java-web-dev/target/tomcat

$ curl localhost:8080/hello?key=catalina.base
Hello Servlet /home/hanyong/workspace/java-web-dev/target/tomcat

可见是设置了,并且默认 catalina.homecatalina.base 均设置为 baseDir 的绝对路径。

也可以使用 jinfo 查看系统属性,可看到共设置了 3 个 catalina.* 系统属性:

$ jinfo -J-Xmx512m -sysprops "$(pgrep -f com.example.javawebdev.servlet_container.TomcatBootstrap )" | grep catalina
catalina.useNaming = false
catalina.home = /home/hanyong/workspace/java-web-dev/target/tomcat
catalina.base = /home/hanyong/workspace/java-web-dev/target/tomcat

至此, 使用嵌入式 tomcat 开发完成, 代码见 tag tomcat-embed

JSP 动态页面

web 应用最重要的功能之一就是提供 web 页面。 前面提到 webapp 目录下的文件会被作为静态资源访问(除了 WEB-INF 目录)。 或者通过 Servlet 输出动态页面,这是相当变态的,繁琐,手动,易出错,难维护。 JSP 技术为解决这些难题应运而生。

简单来说,JSP 是 HTML 和 java 的结合体。 JSP 页面跟静态资源一样放在 webapp 目录下,访问 JSP 页面时,静态内容原样返回,跟 HTML 一样, 比 HTML 高级的是可以通过 <% %> 标记插入 java 代码产生动态内容。

一个简单 JSP 页面 hello.jsp 示例如下:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page session="false" trimDirectiveWhitespaces="true"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
Hello JSP
<%
String key = request.getParameter("key");
String value = System.getProperty(key);
out.print(value);
%>
</body>
</html>
  • 第 1 行是标准 JSP 标记,说明动态代码语言(即 java),页面输出内容 contentType,页面源码编码。
  • 第 2 行我们修改了两个配置。
    • JSP 默认开启了 session 功能,如果客户端没有 session 会自动生成一个 session。 使用 curl 等访问或测试页面时,每次都没有 session,导致服务器产生大量临时 session,我们在真实项目中遇到过这个问题。 所以对不需要 session 的页面,最好关闭 session 功能。
    • 删除 JSP 标记产生的空白。默认不删除,这样这段 JSP 前两行就会产生两个空行。 经测试这个配置会删除标记后面的空白,但不影响标记前的空白。 这个配置主要是为了减少输出和优化输出内容的排版。
  • 可在 web.xmlweb-app/jsp-config/jsp-property-group 路径下为一组 JSP 设置某些公共属性, 此时必须同时设置 url-pattern 指定影响范围。 注意 这里的配置可能被编译的 JSP 缓存起来,不会自动感知修改,修改这里的公共配置后应清空 tomcat work 文件夹避免缓存。 作为最佳实践,可在每次重启应用时都清空 work 文件夹。
  • JSP 中 request, response, out 等是默认可以访问的对象。
  • 由于经常需要使用 out 输出内容,输出表达式可使用 <%= %> 标记简写。

示例代码见 tag jsp,访问这个 JSP 页面结果如下:

$ curl localhost:8080/hello.jsp?key=user.name
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
Hello JSP
hanyong</body>
</html>

由于 JSP 标记后的空白被删除,</body> 标签与 JSP 动态输出内容出现在同一行。 标记前的空白不受影响,所以 JSP 动态输出内容是换行出现的。

JSP 页面底层也是编译为 servlet 执行,静态内容部分直接输出,java 代码部分直接执行, 非常简单直接,所以 JSP 技术长久以来都是我最钟爱的技术之一。 实际上静态内容不局限于 HTML,可以是任何内容。但输出 HTML 是最主要的场景。

eclipse 可以较好的支持 JSP 编辑。 同样 JSP 编辑器是 HTML 编辑器和 java 编辑器的整合, 编辑 HTML 时具备 HTML 编辑器的功能,编辑 JSP 标记内的 java 代码时具备 java 编辑器的功能。 当然这种整合增加了编辑器实现的复杂度,同时要与已有其他 java 代码整合联动又进一步增加了复杂度, JSP 内编辑和维护 java 代码没有纯 java 源码编辑器好用。 所以,应该尽量减少在 JSP 内写 java 代码。

一些人不建议在 JSP 中写 java 代码,因为:

  • 不好维护(确实)。
  • “不安全”(呵呵)。

一些人建议使用 JSP 标签代替 java 代码,一些人转而使用其它模板渲染框架和技术。

JSP 标签库

JSP 标签库(taglib)是伴随 JSP 出现的技术,参考文档 Using Custom Tags,其有两种用法:

  • 每个标签对应一个 tag 文件,相当于 JSP 模块。在 taglib 标记中配置标签前缀和 tag 文件夹。

    <%@ taglib prefix="app" tagdir="/WEB-INF/tags/"%>
  • 使用 tld (tag library descriptor) 文件定义 tag。在 taglib 标记中配置标签前缀和 taglib URI。

    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>

JSP tag 文件

将 JSP 文件拆分为多个模块,每个模块即为一个 tag,这并不减少在 JSP 中使用 java 代码,但:

  • 更结构化,更灵活。
  • 可复用。

除 JSP tag 文件外还有几种方法可以复用 JSP 代码,参考 Reusing Content in JSP Pages

  • 编译时包含 JSP 片段文件(JSP fragment),其建议扩展名使用 .jspf。语法 <%@ include file="banner.jspf" %>
  • 运行时将请求转发到另一个 JSP 页面,包含其响应结果。语法 <jsp:include page="response.jsp"/>

但 JSP tag 文件更模块化,它像 JavaBean 一样有自己的属性和上下文。它是一种特殊的 JSP 文件,使用扩展名 .tag

hello.jsp 中的输出消息定义为一个 tag hello.tag,代码如下:

<%@ tag language="java" pageEncoding="UTF-8"%>
<%@ tag trimDirectiveWhitespaces="true"%>
<%@ attribute name="value" fragment="false"%>
Hello JSP tag <%= value %>
  • attribute 有个关键配置 fragment
    • 默认为 false,表示普通 java 属性,可在 java 代码直接引用。
    • 设置为 true 时,表示输入是 JSP 片段。 引用 tag 时通过 <jsp:attribute name="header"> 子元素输入。 tag 文件中使用 <jsp:invoke fragment="header"/> 渲染。 使用这个特性可以实现 JSP 布局模板
  • 引用 tag 时可包含 <jsp:body> 子元素输入 JSP 片段,tag 中使用 <jsp:doBody/> 渲染。

修改 hello.jsp 引用 tag,代码如下:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page session="false" trimDirectiveWhitespaces="true"%>
<%@ taglib prefix="app" tagdir="/WEB-INF/tags/"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<%
String key = request.getParameter("key");
request.setAttribute("value", value);
%>
<app:hello value="${value}"/>
</body>
</html>
  • 第 3 行声明使用 taglib,前面已经介绍过。
  • 引入 tag 时为了输入 java 表达式,而不是字符串字面值,需要使用 JSP EL 表达式。
  • EL 表达式引用 request attribute,因此需要将 EL 引用的对象设置 request.setAttribute()
  • 低版本 Servlet 容器和 web.xml 可能默认未启用 EL 表达式,可配置 isELIgnored="false" 开启。

代码见 tag jsp-tag-file,测试结果如下:

$ curl localhost:8080/hello.jsp?key=user.name
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>

Hello JSP tag hanyong</body>
</html>

JSTL 标签库

tld 文件定义了标签属性及其关联的 java 类,而 JSTL 是一组标准标签库, 主流版本是 apache 实现,由于技术变迁,如今已不活跃。 spring-boot 中管理的 JSTL 依赖是 org.eclipse.jetty:jstl,其直接依赖了 apache org.apache.taglibstaglibs-standard-spectaglibs-standard-impl

前面提到使用 tld taglib 时声明前缀和 URI。URI 如何关联 tld 呢?

  • 容器部署 webapp 时自动扫描 classpath /META-INF 下的 tld 文件,其中记录了其关联 URI。 tomcat 中通过 org.apache.jasper.servlet.TldScanner.scanJars() 扫描 tld 文件。 显然这个扫描浪费启动时间,而现在 tld 很少使用,可配置 tomcat 关闭相关扫描,参考 FasterStartUp
  • web.xml 中可通过 jsp-config/taglib 配置 URI 与 tld 文件路径的关联关系。

tld taglib 的主要目的是替换掉 JSP 中的 java 代码,这样可能对编辑器更友好(标签组织比零散任意的 java 代码更结构化?),代码更容易维护。

  • JSP 标签由 JSP 引擎独立解析和处理,不用考虑其与 HTML 标签的组织和关系,可以出现在任意位置,如属性值中。
  • tag 与一个 tag class 关联,在处理 tag 的各个阶段执行一些动作,可参考 apache taglibs 说明
  • tag class 可以访问 pageContext 对象,因此可以用与 Servlet 类似的方式输出内容到页面上,但只影响该标签范围,如 <c:out> 标签。
  • 可使用 <body-content> 配置 tag body 由 JSP 处理(默认值)还是由 tag 自己处理。

JSTL 主要包含一些控制逻辑标签,从个人观点看,没有必要为控制逻辑重新学习一套标签,简单 java 代码就挺好。 而且标签库的代码较晦涩复杂,自定义标签库相对复杂和麻烦。 JSTL 似乎并未有效解决问题,而更复杂的标签库暂未了解。 taglib 还有个显著弱点,他们都是相对静态和独立的代码,无法跟整个应用有机整合起来(?)。 有一套为 JSP 设计的布局框架 Tiles,暂未了解。

那时候还提倡 HTML 要 XML 化,xhtml 和 JSF 声称是 web 世界的未来,现在这些东西都没人听说了,而 HTML 5 开始广泛流行。

现在浏览器端 MVC 框架更流行和适用,服务器端页面模板渲染似乎不再那么重要。

Spring 容器

Servlet 容器创建的 Servlet 或 JSP 等通常是简单的单例对象,将所有逻辑都写在一个 Servlet 中当然不现实。 真实应用通常包含很多模块和组件,包括数据库访问、RPC 调用等,而 Spring IoC 容器(通常叫 ApplicationContext)正是对象创建和组装方面的专家。 就像 servlet-api 使 webapp 和 servlet 容器解耦一样,Spring 也提倡面向接口编程使模块间松耦合。 一个简单直接的想法就是引入 Spring 管理应用组件。

Servlet 如何访问 Spring 管理的组件呢?

  • Servlet 创建或获取 Spring 容器,使用 ApplicationContext API 查询访问相关组件。
  • Servlet 本身也由 Spring 管理,Spring 自动注入其相关依赖。

显然第 2 种方式更加一致和友好。

  • Spring 还定义了自己版本的简化版 HttpServlet 接口,叫做 HttpRequestHandler
  • Servlet 容器使用字符串 name/value 配置 Servlet 的方式不友好,Spring HttpServletBean 也对其进行了增强优化。

    HttpServletBean 自动将 name/value 配置转化为 bean property 并调用相应的 setter 方法,然后执行 initServletBean() 初始化。 这样开发和配置 Servlet 与 bean 类似,更加友好一致,在 Spring 环境下使用更加自然(Spring 下需要显式指定调用 initServletBean() 初始化)。 如果相关 Servlet 依赖了 Spring 功能(如依赖注入)则只能在 Spring 环境下使用。

  • Spring 也支持使用 ServletWrappingController 创建和管理传统 Servlet。

还有两个问题: 1. 谁来创建 Spring 容器呢? 2. Servlet 容器接受的请求如何传递给 Spring 中的 HttpRequestHandler 或 Servlet 呢?

Spring DispatcherServlet 同时解决了这两个问题。 其通常作为一个传统 Servlet 在 Servlet 容器中配置,承接了 Spring 想要处理的所有请求, 同时创建 Spring 容器,然后在 Spring 中重新分发请求。

这里我们看到应用架构上的一个分层,Servlet 容器负责前端接受请求和回写响应,Spring 负责后端应用管理, 同时抢走了 Servlet 创建,请求分发等这些功能,并做的更强更好。 Spring 容器内部可能还需要进行多层应用架构拆分。

修改 web.xml 配置 DispatcherServlet

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.1" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xml="http://www.w3.org/XML/1998/namespace"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd ">

	<servlet>
		<servlet-name>spring</servlet-name>
		<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
		<load-on-startup>1</load-on-startup>
	</servlet>
	<servlet-mapping>
		<servlet-name>spring</servlet-name>
		<url-pattern>/*</url-pattern>
	</servlet-mapping>

</web-app>
  • contextClass 默认为 XmlWebApplicationContext,参考 FrameworkServlet#contextClass
  • XmlWebApplicationContext 默认加载 Spring 配置文件为 webapp 路径 /WEB-INF/<servlet 名>-servlet.xml 文件,如 spring-servlet.xml, 参考 XmlWebApplicationContext#getDefaultConfigLocations()
  • load-on-startup 表示部署 webapp 后即初始化 servlet,默认为延迟初始化。启动应用后应即初始化 servlet 创建 Spring 容器。
  • 可配置 ContextLoaderListener 创建一个 root ApplicationContext 作为 servelt spring 容器的 parent。 root 容器包含业务组件,servlet spring 容器包含 servlet 相关组件,这是一种典型的分层结构。
  • 一个 webapp 可配置多个 DispatcherServlet,划分前端模块。
  • url-pattern 配置 /* 使所有请求都由 spring 处理。

创建 spring 配置文件 spring-servlet.xml 内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

	<bean class="org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor"/>
	
	<bean class="org.springframework.web.servlet.handler.SimpleServletHandlerAdapter"/>
	
	<bean class="com.example.javawebdev.biz.MessageService"></bean>
	
	<bean id="/hello" class="com.example.javawebdev.HelloServlet" init-method="initServletBean"></bean>

</beans>
  • 示例代码中使用了 @Autowired 注解自动注入依赖,创建 AutowiredAnnotationBeanPostProcessor 识别处理此注解。
  • spring http request handler 有多种形式,使用 HandlerAdapter 适配不同形式的 handler。 HttpRequestHandlerAdapter 支持 HttpRequestHandler。 配置 SimpleServletHandlerAdapter 支持 Servlet。
  • Servlet 中的业务逻辑剥离到 MessageService,在 Spring 中配置创建此服务组件。
  • Spring 使用 HandlerMapping 路由请求,默认包含 BeanNameUrlHandlerMapping,即将 bean 名字对应的 URL 路由到此 bean 处理。 还可以配置 SimpleUrlHandlerMapping 按 ant path pattern 路由请求,类似 Servlet 容器中的 url-pattern 但更灵活强大。 这里创建 "/hello" bean 处理此 URL。
  • HelloServlet 是一个 HttpServletBean,需要显式声明执行 initServletBean() 初始化。 Servlet 容器创建 HttpServletBean 时会设置 ServletConfig 并执行 init(), 在 spring 中创建时,没有 ServletConfig,不能执行 init(),应显式指定执行 initServletBean()

MessageService 代码如下,为简单起见此处就不搞接口实现分离了:

public class MessageService {

	public String getMessage(String key) {
		return System.getProperty(key);
	}
	
}

改造 HelloServlet 如下:


public class HelloServlet extends HttpServletBean {

	private static final long serialVersionUID = 1L;
	
	@Autowired
	protected MessageService messageService;

	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		String key = req.getParameter("key");
		String value = messageService.getMessage(key);
		resp.setContentType("text/plain");
		PrintWriter out = resp.getWriter();
		out.printf("Hello Servlet %s\n", value);
	}
	
}
  • 使用 spring 增强的 HttpServletBean 取代 HttpServlet。
  • 使用 @Autowired 注解让 spring 自动注入依赖组件。

至此,使用 spring 开发完成,代码见 tag spring

Spring Web MVC

Spring Web MVC 是最常见的应用场景之一:

  • Model 模型,视图展示的数据。呈现为 Map,key 为字符串,value 通常为普通 java 对象 POJO,一些项目里也叫 VO,表示展示视图所需的数据。
  • View 视图,展示 HTML 页面等响应内容。展示模型提供的数据,通常是某种模板语言,如 JSP 页面等。
  • Controller 控制器,主控逻辑。处理请求,对接业务逻辑,输出模型和视图。是一种 http request handler。

前文提到应尽量减少 JSP 中的 java 代码,MVC 模式就是最佳实践之一。 JSP 中的业务逻辑抽取到控制器,控制器输出直接的展示数据作为模型,JSP 只需要包含简单的数据展示逻辑,主要是简单的分支和循环。 controller 可以有多种形式,最直接的形式是 Controller 接口,其与 HttpRequestHandler 的主要区别是返回类型为 ModelAndView, 这是 Model 对象和 View 对象的组合,MVC 框架将其渲染成响应内容。 应用 Controller 通常应继承 AbstractController。 spring 默认支持使用 JSP 页面作为 View,这是 Servlet 容器默认支持的资源类型,因此叫做 InternalResourceView。 使用 JSP 作为 View 时,Model 注入为 request attribute,因此可直接使用 JSP EL 表达式访问。

前面配置了所有请求都由 spring 处理,spring 默认不能支持 JSP 页面,可手动创建 jsp servlet 支持。 或通过更长的 url-pattern 路径将一个子目录下的 URL 匹配给 Servlet 容器默认的 jsp servlet 处理,web.xml 添加配置如下:

<servlet-mapping>
	<servlet-name>jsp</servlet-name>
	<url-pattern>/WEB-INF/jsp/*</url-pattern>
</servlet-mapping>
  • /WEB-INF/ 下的路径不能直接访问,但 spring 渲染 View 时可转发请求到此路径下。 /WEB-INF/jsp/ 是存放非直接渲染的 JSP 页面的常用子目录。

controller 中可直接创建并返回 View:

	InternalResourceView view = new InternalResourceView("/WEB-INF/jsp/hello.jsp");
	return new ModelAndView(view, "value", value);

为了简化设置 view,spring 又引入了 ViewResolver 的概念, 只需指定 view name 即可自动解析到具体的 View,并且可以做的更加智能, 如 InternalResourceViewResolver 可根据 classpath 中是否有 JSTL 类决定使用 InternalResourceView 还是其扩展的 JstlView

最终 HelloController 示例代码如下:

public class HelloController extends AbstractController {

	@Autowired
	protected MessageService messageService;

	@Override
	protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response)
			throws Exception {
		String key = request.getParameter("key");
		String value = messageService.getMessage(key);
		return new ModelAndView("hello", "value", value);
	}

}

hello.jsp 移动到 /WEB-INF/jsp/ 目录下,更新内容如下:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page session="false" trimDirectiveWhitespaces="true"%>
<%@ taglib prefix="app" tagdir="/WEB-INF/tags/"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<app:hello value="${value}"/>
</body>
</html>
  • 其中 java 代码已经移除,直接使用 EL 表达式访问模型变量。

spring 配置 spring-servlet.xml 添加 bean 配置如下:

<bean class="org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter"/>

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
	<property name="prefix" value="/WEB-INF/jsp/"></property>
	<property name="suffix" value=".jsp"></property>
</bean>

<bean id="/hello" class="com.example.javawebdev.HelloController"></bean>
  • 添加 SimpleControllerHandlerAdapter 支持使用 Controller 作为 http request handler。
  • 配置 InternalResourceViewResolver,有多个 resolver 时其应该作为最后一个,无条件将 view name 转为对应的 jsp 资源。
  • 原 HelloServlet bean 改名,配置 HelloController 处理 "/hello" 请求。

至此,使用 spring webmvc 开发完成,代码见 tag spring-webmvc

Spring 配置外部化

Spring 配置文件包含了应用组件、依赖、组件配置等全部元信息, Spring 配置外部化是将常用的组件配置抽取到独立的外部属性配置文件中。 这样的好处是:

  • 配置项收集到一起,不用关注组件定义等信息,管理维护更简单方便。
  • 应用在不同环境运行时,组件定义都一样,只有配置不一样,只需要使用不同的属性配置文件即可。

使用方式:

  • 传统使用 PropertyPlaceholderConfigurer 组件,这是一个 BeanFactoryPostProcessor,对 bean 配置中的占位符进行替换。
  • Spring 3.1 已不建议使用 PropertyPlaceholderConfigurer,定义了增强版的 PropertySourcesPlaceholderConfigurer, 支持使用多个 PropertySource,支持优先级排序。
  • Spring 3.1 还为 ApplicationContext 定义了 Environment,支持属性值解析。
  • Spring 4.1 还定义了 YamlPropertiesFactoryBean,支持从 YAML 配置文件加载属性配置。

使用 web.xml 定义 DispatcherServlet 时不支持配置 Environment,添加 PropertySourcesPlaceholderConfigurer bean 配置如下:

<bean class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
	<property name="properties">
		<bean class="org.springframework.beans.factory.config.YamlPropertiesFactoryBean">
			<property name="resources">
				<list>
					<value>classpath:application.yml</value>
				</list>
			</property>
		</bean>
	</property>
</bean>
  • 未显式指定 propertySources 时默认会加载 spring Environment 即本地配置。
  • 可以加载指定配置文件,但默认只支持 properties 和 XML 配置文件,不支持 YAML。
  • 可以直接指定 properties,这里我们使用 YamlPropertiesFactoryBean 加载 YAML 配置。
  • spring 在 web 环境默认从 webapp 下加载资源,这里显式指定加载 classpath 资源。

为 MessageService 添加一个配置 prefix,在 application.yml 中配置。 配置 bean 时使用占位符引用配置。

至此,使用 spring 外部化配置开发完毕,代码见 tag spring-placeholder

简化配置:更大粒度的组件

Spring 的组件拆分非常细致,这使我们可以灵活的选择组件,精确的控制细节。 但这也有一些不足,我们需要了解很多组件的细节,配置很多东西,开发多个应用时还会有大量重复的配置编写工作。 如当前一个简单 Hello World 程序已经定义了一堆 bean。 Spring 定义了几种 ApplicationEvent,可添加 ApplicationListener 组件或使用 @EventListener 监听事件。 添加 AppRefreshedListener 打印 bean 列表,可见除了配置文件定义的 bean 外, 还有 environment, lifecycleProcessor 等若干基本功能 bean,当前共 19 个 bean。

一种解决办法是提供更大粒度的组件,按典型用法将若干组件组合成一个更大粒度的组件,减少用户需要关心和配置的组件数量。 Spring 提供了一些 XML 标签来简化配置。 如 Spring 提供了若干个支持注解的标准组件,AutowiredAnnotationBeanPostProcessor 只是其中一个, 使用 <context:annotation-config /> 即可将这些组件全部引入。 使用此标签替换 AutowiredAnnotationBeanPostProcessor 定义,容器中 bean 数量增加到 24 个。

Spring 注解配置

简化配置的另一个办法是使用注解配置。 配置 <context:component-scan> 添加在 classpath 上扫描注解配置 bean 的功能。

<context:component-scan base-package="com.example.javawebdev"></context:component-scan>

类定义上添加组件注解,即可替代配置文件中相关 bean 定义。 如 HelloController 上添加 @Controller("/hello"),表示使用此类定义一个名为 "/hello" 的 bean,其角色为 controller。 也可以使用不指定角色的 @Component 注解。 @Value 注解可使用占位符引用外部属性配置。 注意 ${} 是占用符,#{} 是 Spring 表达式(SpEL),两者不同。

  • AnnotationConfigApplicationContextAnnotationConfigWebApplicationContext 默认会启用注解配置功能,不需要手动配置注解处理组件。 注意后者是 AbstractRefreshableApplicationContext,前者不是, 因为后者使用 contextConfigLocation 指定了入口类,可以 refresh 。
  • 可使用 @ImportResource 注解引用 Spring 组件配置文件。

优点:

  • 一些人认为使用 XML 配置较麻烦,不方便维护。 使用注解可以减少很多 XML 配置,应用配置维护更加简单。

缺点:

  • 使用 @Component 等组件注解需要入侵类代码,相当于硬编码组件定义,使用不够灵活。 常常使用 @ComponentScan 以 package 维度引入组件。
  • 三方库的类不能直接添加 @Component 等组件注解,可使用 @Import 按需引入。 但字段注解仍无法添加(?),需要手动装配。

改造示例代码使用注解,结果见 tag spring-annotation

Spring Java 配置

Spring Java 配置对注解配置进行了扩展和增强:

  • 一些较复杂的配置难以使用注解配置,如示例中的 PropertySourcesPlaceholderConfigurer
  • 一些更复杂的场景使用注解和配置文件都不方便配置,可以直接写 Java 实现,简单自然。

但 Spring 注解和 Java 配置也有一些不足:不能创建内部匿名(或称为 inner、local)bean(?)。 Spring 正常托管内部 bean 生命周期,但可避免 Lifecycle 方法被调用,有时这是个特殊场景用法。

传统的 FactoryBean 也是使用 Java 代码创建 bean,但有几点不同:

  • 一个 FactoryBean 只能创建一个 bean,但可以是原型或单例。
  • FactoryBean 类型会被 spring 特殊对待,不能作为普通 bean 类型使用和匹配。spring 会先将其创建出来,再通过 getObjectType() 看真正的 bean 类型。
  • FactoryBean 创建原型 bean 时,无论原型 bean 是否被实例化 FactoryBean 都会被创建出来,spring 通过其 isSingleton() 检查 bean scope。
  • FactoryBean 创建原型 bean 时,因为 getObject() 没有参数,所以其定义的原型 bean 实例化不能带参数。

Java 配置用法:

  • 使用 @Configuration 定义一个 configuration 组件,其方法上可添加 @Bean 注解。
  • Spring 装配 configuration 组件后,调用其 @Bean 方法创建新的 bean。 从依赖关系上说,其创建的 bean 都依赖它,所以 configuration 组件不要依赖(直接或间接)其创建的组件,避免循环依赖。
  • 使用 Java 配置后可完全消除 Spring 组件配置文件。通常使用一个 @Configuration 类作为创建 ApplicationContext 的入口。

要完全去掉 spring 组件配置文件,可配置 DispatcherServlet 使用 AnnotationConfigWebApplicationContext:

<servlet>
	<servlet-name>spring</servlet-name>
	<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
	<init-param>
		<param-name>contextClass</param-name>
		<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
	</init-param>
	<init-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>com.example.javawebdev.AppContextConfig</param-value>
	</init-param>
	<load-on-startup>1</load-on-startup>
</servlet>
  • 其中 contextConfigLocation 配置了入口类名。

spring-servlet.xml 翻译为 java 配置如下:

@Configuration
@ComponentScan
@Import({
	org.springframework.web.servlet.handler.SimpleServletHandlerAdapter.class,
	org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter.class,
})
public class AppContextConfig {

	@Bean
	public PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer(@Value("classpath:application.yml") Resource resource) {
		PropertySourcesPlaceholderConfigurer config = new PropertySourcesPlaceholderConfigurer();
		YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
		yaml.setResources(resource);
		yaml.afterPropertiesSet();
		config.setProperties(yaml.getObject());
		return config;
	}
	
	@Bean
	public InternalResourceViewResolver internalResourceViewResolver() {
		InternalResourceViewResolver resolver = new InternalResourceViewResolver();
		resolver.setPrefix("/WEB-INF/jsp/");
		resolver.setSuffix(".jsp");
		return resolver;
	}
	
}
  • AnnotationConfigWebApplicationContext 默认启用了注解支持,不需要 <context:annotation-config />
  • <context:component-scan> 替换为 @ComponentScan 注解,@ComponentScan 可自动探测扫描被注解类所在 package,使用更方便简单。
  • SimpleServletHandlerAdapter 等简单 bean 直接使用 @Import 引入即可。
  • 最后两个 bean 使用 @Bean 方法定义。
  • @Value 支持将配置文本转为 java 对象。
  • 前面我们提到使用更大粒度的组件可以简化配置,使用 Java 配置可以更方便的定义和使用更大粒度的组件,并且可以更方便灵活的自定义配置, 如可使用 WebMvcConfigurationSupport@EnableWebMvc 方便的引入和配置 spring webmvc 相关组件。

上例中手动创建初始化了 YamlPropertiesFactoryBean,这不是最佳实践,应交由 spring 管理。 组件配置文件可定义匿名 bean 作为参数,java 配置没有类似功能(?),可定义并实例化原型 bean 作为替代。

  • YamlPropertiesFactoryBean 是特殊 bean,其使用上有一些地方需要注意。
  • PropertySourcesPlaceholderConfigurer 是 BeanFactoryPostProcessor,是基础组件。 为减少创建基础组件的影响,应将其及其必须依赖移动到单独的配置中。
  • 创建基础组件时注解相关 PostProcessor 等还未创建,其及其依赖不能使用相关注解。
  • 相关注意细节参考代码注释。

PropertySourcesPlaceholderConfigurer 及其依赖移动到单独配置,定义如下:

@Configuration
public class AppPlaceholderConfig extends ApplicationObjectSupport {
	
	/**
	 * YAML {@link Properties} 原型 bean。<br/>
	 * 此原型 bean 实例化返回 FactoryBean, spring 进一步处理后得到 Properties。<p/>
	 * 
	 * 返回类型只能使用 Object:
	 * <ul>
	 * 	<li>bean 类型是 Properties,但 Java 代码层面是返回 FactoryBean,返回类型不能用 Properties。</li>
	 * 	<li>此处 FactoryBean 是原型 bean 实例化的结果 bean,不是用 FactoryBean 定义 bean,返回类型不能用 FactoryBean。</li>
	 * 	<li>实际上 spring 会对此方法进行增强,增强后将自动把 FactoryBean 转为 bean,即增强后的返回对象确实是 Properties。</li>
	 * 	<li>注解已经说明此 bean scope 类型,结果 bean FactoryBean 的 <code>isSingleton()</code> 无实际意义(?)。</li>
	 * </ul>
	 */
	@Bean
	@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
	public Object yamlPropertiesPrototype(Resource resource) {
		YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
		yaml.setResources(resource);
		return yaml;
	}

	/**
	 * 实例化一个 {@link #yamlPropertiesPrototype(Resource)} 原型 bean。<p/>
	 * 实现说明:<br/>
	 * <ul>
	 * 	<li>不能使用 {@link org.springframework.beans.factory.annotation.Lookup} 注解。
	 * 此方法被 {@link #propertySourcesPlaceholderConfigurer(Resource)} bean 依赖,
	 * 其为 BeanFactoryPostProcessor,最早被创建,注解相关 PostProcessor 等还未创建,其及其依赖不能使用相关注解。</li>
	 * 	<li>最早调用此方法时,spring 容器功能还未完备,未能充分使用容器组装功能,但仍然使得此 bean 被容器管理。</li>
	 * </ul>
	 */
	public Properties getYamlProperties(Resource resource) {
		Object bean = getApplicationContext().getBean("yamlPropertiesPrototype", resource);
		return (Properties) bean;
	}
	
	/**
	 * 实现说明:<br/>
	 * <ul>
	 * 	<li>此 bean 是 BeanFactoryPostProcessor,最早被创建,spring 容器功能还未完备。</li>
	 * 	<li>作为容器基础设施的功能组件,应不要依赖注解等高级功能,可使用基础容器支持的相关接口等。
	 * 如使用 {@link org.springframework.beans.factory.InitializingBean} 代替 {@link javax.annotation.PostConstruct}。</li>
	 * 	<li>为减少创建基础组件的影响,如其依赖 configuration 组件应避免使用相关注解(测试 <code>@Autowired</code> 注解最终也未生效?),应将其及其必须依赖移动到单独的配置中。</li>
	 * </ul>
	 */
	@Bean
	public PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer(@Value("classpath:application.yml") Resource resource) {
		PropertySourcesPlaceholderConfigurer config = new PropertySourcesPlaceholderConfigurer();
		Properties prop = getYamlProperties(resource);
		config.setProperties(prop);
		return config;
	}

}

现在 spring 容器已经完全使用 java 配置,组件配置文件 spring-servlet.xml 已经没有用了,可删除之。 至此使用 spring java 配置开发完毕,代码见 tag spring-java-config

Servlet Java 配置

在 Spring 推出注解和 Java 配置的时候,Servlet 规范也定义了一些注解和 Java 配置。 当然 Spring 组件定义和装配能力比较 Servlet 容器强大的多,使用也简单的多。 同样 Servlet 容器 Java 配置可以将 web.xml 中的配置转换为 Java 注解和代码,从而使用和维护更加自然方便。

操作 Servlet 容器的 API 是 javax.servlet.ServletContext, Java 配置的主要入口是 javax.servlet.ServletContainerInitializer, 这个接口的实现不是直接操作 ServletContext,而是用 @HandlesTypes 注解声明其处理的类型, Servlet 容器将处理类 Class<?> 集合和 ServletContext 一起传给入口类。 其好像只是一个中转,处理类才是真正干活的。 Spring 定义了一个入口类 SpringServletContainerInitializer,其处理类是 WebApplicationInitializer。 这个入口由 Servlet 容器发起,此时还没有 spring 容器,能做的事情很有限,与在 web.xml 中类似。 Spring 的入口实现也很简单,使用默认构造函数挨个实例化处理类 WebApplicationInitializer, 按优先级排序后挨个传入 ServletContext 进行调用。主要功劳就是按优先级排序。

Spring 中 WebApplicationInitializer 有两个主要实现:

  • AbstractContextLoaderInitializer,创建 ContextLoaderListener 以加载 root ApplicationContext。
  • AbstractAnnotationConfigDispatcherServletInitializer,创建并注册 DispatcherServlet 及对应的 Filter。
  • 后者继承了前者,所以可以可选地完成前者的功能。

web.xml 中配置翻译为 java 代码如下:

public class AppServletContextConfig {

	public static class DispatcherServletInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
		
		@Override
		protected Class<?>[] getRootConfigClasses() {
			// 不创建 root ApplicationContext
			return null;
		}

		protected Class<?>[] servletConfigClasses = { com.example.javawebdev.AppContextConfig.class, };
		
		@Override
		protected Class<?>[] getServletConfigClasses() {
			return servletConfigClasses;
		}

		protected String[] servletMappings = { "/*", };

		@Override
		protected String[] getServletMappings() {
			return servletMappings;
		}

	}
	
	public static class JspServletInitializer implements WebApplicationInitializer {

		public static final String TomcatJspServletName = "jsp";
		
		@Override
		public void onStartup(ServletContext servletContext) throws ServletException {
			ServletRegistration reg = servletContext.getServletRegistration(TomcatJspServletName);
			reg.addMapping("/WEB-INF/jsp/*");
		}
		
	}

}
  • 这里 WebApplicationInitializer 执行顺序不重要,所以没有设置优先级。

之后 web.xml 中的相关代码可以删除。 至此使用 servlet java 配置开发完毕,代码见 servlet-java-config

Spring Boot 快速开发

Spring Boot 帮助我们简单快速的使用 Spring 框架及三方库,提供框架、工具和最佳实践指导。 可以认为 Spring Boot 是对 Spring 框架及三方库的典型用法包装,由于其简单方便,近年来开始快速流行。 前面提到仅仅是引入 spring-boot-dependencies 帮助我们管理依赖,就能省去不少使用三方库时确定版本的麻烦。 新项目可使用 spring-boot-starter-parent 作为 parent,其进一步包含了常用配置和 plugin 版本管理。

Servlet 容器中使用 ContextLoaderListenerDispatcherServlet 管理 ApplicationContext, 非 Servlet 容器中则可使用 Spring Boot 管理,其默认使用注解和 java 配置,指定入口配置类即可,简单用法如下:

public static void main(String[] args) {
	SpringApplication.run(AppContextConfig.class, args);
}

仅仅是这一句简单代码已经帮我们做了不少事情:

  • 创建并管理 Spring 容器。
  • ConfigFileApplicationListener 自动读取属性配置文件。 Spring Boot 应用一般不再使用组件(bean 定义)配置文件,以后我们说配置文件通常特指属性配置文件。
  • LoggingApplicationListener 自动配置日志。

前面提到更大粒度的组件可以简化使用,spring boot 则更进一步:

  • Spring Boot 包含了典型(约定)方式使用 3 方库(或框架)的代码和组件配置。
  • 使用 @EnableAutoConfiguration 开启自动配置后,自动从 classpath 依赖库引入相关组件。 如果不需要相关组件,则应将其从 classpath 上排除。
  • 自动补齐组件。可以自定义某个组件,自动配置只补齐缺失的组件,而不会重新定义已有组件。

如果只需要使用某个依赖库的部分功能,或者希望更好的主动控制,则可以不使用 @EnableAutoConfiguration。 同时可使用 @ImportAutoConfiguration 仅引入特定组件的自动配置。

我们之前使用 ApplicationListener<ContextRefreshedEvent> 在 spring 容器初始化后执行相关动作, spring boot 中可以直接使用 ApplicationRunner 实现类似功能。 传统应用如果只依赖部分 bean,也可以直接在 bean 初始化方法中执行相关动作。

Spring Boot 使用嵌入式 Servlet 容器

Spring Boot 应用通常使用嵌入式 Servlet 容器, 可引入 EmbeddedServletContainerAutoConfiguration 按典型(约定)方式使用, 引入 ServerPropertiesAutoConfiguration 使用 ServerProperties 进行简单配置。 支持 tomcat, jetty, undertow 等容器,spring-boot-starter-web 默认依赖 spring-boot-starter-tomcat,即使用 tomcat。 默认在 tmp 目录下创建临时目录作为 catalina.base,可配置 server.tomcat.basedir 修改。 默认不能直接配置 docBase,但会查找使用 src/main/webapp 等标准路径。

添加如下注解:

@ImportAutoConfiguration({
	org.springframework.boot.autoconfigure.web.EmbeddedServletContainerAutoConfiguration.class,
	org.springframework.boot.autoconfigure.web.ServerPropertiesAutoConfiguration.class,
})

启动应用可看到 Servlet 容器中 HelloServlet 被注册为默认 Servlet,而 DispatcherServlet 没有被注册:

2018-01-06 13:50:28.740  INFO 7335 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean  : Mapping servlet: '/helloServlet' to [/]

打印 bean 信息发现如下信息:

contextAttributes = ... ..., org.apache.tomcat.util.scan.MergedWebXml=<?xml version="1.0" encoding="ISO-8859-1"?>
<web-app version="3.0" xmlns="http://java.sun.com/xml/ns/javaee"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
	http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">
</web-app>

之前在 servlet 容器中使用 java 配置时,其主动调用 ServletContainerInitializer 并传入 ServletContext, 并调用到 WebApplicationInitializer,这样我们才能拿到 ServletContext 对象进行 java 配置。 这个过程在创建 spring 容器之前,相关对象都是简单单例。 spring boot 是先创建 spring 容器,再创建 servlet 容器,包含关系反转。 spring boot 以完全编程的方式使用嵌入式 servlet 容器,不需要并禁用了 ServletContainerInitializer 的行为。 因此 WebApplicationInitializer 没有被调用,从而没有注册 DispatcherServlet,是预期行为。

参考:

作为替代,spring boot 定义了 org.springframework.boot.web.servlet.ServletContextInitializer 接口, 支持在 spring 容器中创建 bean 来配置 servlet 容器,其接口方法定义与 WebApplicationInitializer 一样但使用更加自然友好。 同时定义了其常用实现 ServletRegistrationBean, ServletListenerRegistrationBean, ServletListenerRegistrationBean 等。

小结:

  • servlet 容器 java 配置入口:javax.servlet.ServletContainerInitializer 。
  • spring 实现:org.springframework.web.SpringServletContainerInitializer,转接到处理器接口:WebApplicationInitializer 。
  • spring boot 中的替代增强接口:org.springframework.boot.web.servlet.ServletContextInitializer 。
  • spring boot 实现 WebApplicationInitializer 兼容传统 war 应用:SpringBootServletInitializer 。

使用:

  • 如果希望在普通 servlet 容器和 spring boot 中同时可用,可同时实现 WebApplicationInitializer 和 ServletContextInitializer 接口,方法定义一样。
  • 如果依赖 spring 容器(非简单对象),则只能在 spring boot 中使用,不要实现 WebApplicationInitializer 接口,只实现 ServletContextInitializer 接口。
  • 普通 servlet 容器中只需要定义类,只能创建简单对象。spring boot 需要创建 bean,因此更灵活强大。

spring 容器中有未被注册的 servlet bean 时,spring boot 使用 ServletContextInitializerBeans.addAdaptableBeans 默认自动创建补齐 ServletRegistrationBean 将其注册到 servlet 容器。 servlet 必须注册到 servlet 容器(?),这与普通 servlet 容器中使用 spring 容器不同。 参考 ServletContextInitializerBeans.ServletRegistrationBeanAdapter.createRegistrationBean(String, Servlet, int), 注册规则为 DISPATCHER_SERVLET_NAME (dispatcherServlet) 或只有一个 bean 时注册到 /,否则注册到 / + name + / 。 为避免这种差异,应避免直接使用 servlet(?),可将其包装到 ServletWrappingController。

普通 servlet 容器中使用 DispatcherServlet 创建 spring web 容器。 反之 spring boot 默认在 spring web 容器中配置注册 DispatcherServlet(可使用 DispatcherServletAutoConfiguration)并注入 spring web 容器, 设置 webApplicationContextInjected 为 true。 loadOnStartup 默认为 -1(因为 spring 容器已经加载完成),可通过 WebMvcProperties 设置为 1。

应用添加引入 org.springframework.boot.autoconfigure.web.DispatcherServletAutoConfiguration.DispatcherServletRegistrationConfiguration。 spring 1.5.9.RELEASE 有 bug(?),DispatcherServlet 注册到 / 后,自动注册的 Servlet 只有一个 HelloServlet,依然注册到 /,但貌似没影响(?)。

2018-01-06 16:00:16.112  INFO 8777 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean  : Mapping servlet: 'dispatcherServlet' to [/]
2018-01-06 16:00:16.120  INFO 8777 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean  : Mapping servlet: '/helloServlet' to [/]

嵌入式 tomcat 默认也会注册 default servlet,存在 jsp 相关类时自动注册 jsp servlet,其通过内部 API 添加,因此没有看到 servlet 注册日志。 spring boot 中 dispatcherServlet 注册到默认路径 /,不会影响 jsp servlet 的可用性。

spring boot 中不再使用 AppServletContextConfig,同时可将仅 spring boot 使用的配置移动到单独的 package 和类, 这样应用在传统 servlet 容器中作为 war 部署或作为独立应用运行都可以。

代码见 tag spring-boot

spring boot 应用配置文件

之前我们配置了 YamlPropertiesFactoryBean 来读取 YAML 配置文件。 spring boot 会自动读取配置文件到 spring 容器的 Environment,并且默认支持 YAML 等格式, 而 PropertySourcesPlaceholderConfigurer 默认会使用 Environment,这样其配置可以大大简化,直接作为简单 bean 引入即可。 spring boot 还默认支持 ConfigFileApplicationListener.ACTIVE_PROFILES_PROPERTY ("spring.profiles.active") 配置,可以指定读取额外的 profile 配置文件。

前面我们使用占位符引用属性配置,spring boot 更进一步,提供 @ConfigurationProperties 注解(同时使用 @EnableConfigurationProperties 启用)可直接将属性配置与 bean 字段绑定。 这有几个优势:

  • 整个 bean 属性配置打包,只需要指定属性配置前缀,自动按 bean 字段名绑定,不需要重复指定每个字段。
  • Spring 属性编辑器(eclipse 插件)自动关联 bean 类定义及相关元信息,可提供类型信息、自动补齐、输入校验等便捷实用的功能。

用法:

  • 配置属性通常作为专门的配置 bean,如之前提到的 ServerProperties, WebMvcProperties 等。 这样有两个好处是:(1) 简单易维护。(2) 便于被多个 bean 复用。
  • 应用自定义属性需要生成元信息才能被属性编辑器识别,添加依赖 spring-boot-configuration-processor (可通过 eclipse 插件添加 starter 添加)即可自动生成元信息。 插件添加此依赖默认 scope 为 compileOnly,maven 不支持此 scope(?),可修改为 compile(删除 scope 配置,默认即 compile)。
  • @ConfigurationProperties 不依赖 PropertySourcesPlaceholderConfigurer,彼此独立。配置属性更方便,占位符使用场景更多。

Spring Boot 打包部署

现在 web.xml 已经是空配置文件,spring boot 使用完全编程化方式配置,也不需要此文件。 webapp 目录下剩下的都是静态资源文件,按照 Servlet 3 webjars 规范,静态资源文件(WEB-INF/ 下是 servlet 容器内部静态资源)可以移动到 classpath META-INF/resources/ 目录下。 webapp 根目录(docBase)已经不重要(所以 ServerProperties 未包含 docBase 配置?)。 但目前 eclipse JSP 编辑器仍然是从 webapp 目录定位 taglib 的 tagdir,因此会报错误和警告。 eclipse web 项目可配置 "Deployment Assembly",默认配置 Source "/src/main/webapp" 部署到 Deploy Path "/"。

spring boot 项目可从 war 类型修改为 jar 类型,但 eclipse 将不会自动友好支持 JSP 编辑等 web 特性。 解决办法:

  • 可考虑将 JSP 静态资源抽取到单独的 war 项目,并打包为 webjars(?)。
  • 手动添加相关 web 特性支持,较麻烦且可能有错漏(?)。
    1. 使用 build-helper-maven-plugin 添加 src/main/webapp 为资源文件并设置其打包到 META-INF/resources/(仅非 m2e 环境?)。
    2. eclipse jar 项目转为 Faceted Form。添加 "Dynamic Web Module" 即会出现 "Deployment Assembly" 设置页面。 需要手动设置相关 Facet 版本与项目匹配,如 "Java" 版本匹配,"Dynamic Web Module" 与 servlet 版本匹配(?)。

Spring Boot 也考虑了在 Servlet 容器中作为了普通 war 应用部署的兼容性。 基本办法就是实现 WebApplicationInitializer 接口创建管理 Spring Boot 容器, SpringBootServletInitializer 已经完成了这些工作但默认为抽象类所以不会被启用,这应该是唯一一个需要的 WebApplicationInitializer 。 具体化 SpringBootServletInitializer,引入应用配置入口类即可。 参考文档 Create a deployable war file: https://docs.spring.io/spring-boot/docs/1.5.9.RELEASE/reference/htmlsingle/#howto-create-a-deployable-war-file


@ImportAutoConfiguration({
	org.springframework.boot.autoconfigure.web.EmbeddedServletContainerAutoConfiguration.class,
	org.springframework.boot.autoconfigure.web.ServerPropertiesAutoConfiguration.class,
	org.springframework.boot.autoconfigure.web.DispatcherServletAutoConfiguration.class,
})
@Import({
	com.example.javawebdev.AppContextConfig.class,
})
public class App extends SpringBootServletInitializer {
	
	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
		return builder.sources(App.class);
	}
	
	public static void main(String[] args) {
		SpringApplication.run(App.class, args);
	}
	
}
  • 嵌入式 Servlet 容器等组件是自动配置,在传统 Servlet 容器中部署时不会生效,相关依赖可设置为 provided 。
  • 一些没用的文件和代码可以移除,项目代码结构更加简洁。特别是其他 WebApplicationInitializer 实现必须移除,只能保留 SpringBootServletInitializer 。

至此使用 spring boot 开发完成,代码见 tag spring-boot-war