java学习笔记之基础:web开发、Spring框架

web 开发

核心概念:
‌- JavaSE(Java Standard Edition)‌作为 Java 平台的标准版,提供核心语言功能(如基础语法、集合框架、I/O 操作等)和 JVM 运行环境,主要用于开发桌面应用或命令行工具。它是所有 Java 版本的基础。
‌- JavaEE(Java Enterprise Edition)‌基于 JavaSE 扩展的企业版,针对分布式、高并发场景提供企业级服务(如 Servlet、JSP、EJB 等),适用于开发电商平台、金融系统等复杂应用。其技术栈(如 Spring 框架)实际依赖 JDK 而非独立的 JavaEE 开发包。

  • JDK(Java Development Kit)‌是开发工具包,包含 JRE(运行环境)及编译器(javac)、调试工具等,用于编译和开发 Java 程序。JDK 本身不区分 SE/EE/ME 版本,但不同场景下需搭配对应的 API 库。例如企业开发需额外引入 JavaEE 规范库(如 Jakarta EE)。
  • JVM(Java Virtual Machine)‌,执行 Java 字节码(.class 文件),实现“一次编译,到处运行” 。与平台相关(如 Windows/Linux 的 JVM 实现不同),负责内存管理、垃圾回收等。
  • JRE(Java Runtime Environment)‌,‌由 JVM + 基础类库(如 java.lang、java.util 等)组成‌。仅支持运行已编译的 Java 程序,不包含开发工具(如编译器)。

我们可以把 JavaEE 看作是在 JavaSE 的基础上开发的一系列基于服务器的组件、API 标准和通用架构。JavaEE 最核心的组件就是基于 Servlet 标准的 Web 服务器,开发者编写的应用程序是基于 Servlet API 并运行在 Web 服务器内部的。

web 应用通常采用 Browser/Server 模式,简称 BS 架构。客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取 Web 页面,并把 Web 页面展示给用户即可。Web 页面具有极强的交互性,使用 HTML 编写,而 HTML 具备超强的表现力,并且服务器端升级后,客户端无需任何部署就可以使用到新的版本,因此 BS 架构升级非常容易。在 Web 应用中,浏览器和服务器之间的传输协议是 HTTP。对于 Browser 来说,请求页面的流程如下:与服务器建立 TCP 连接;发送 HTTP 请求;收取 HTTP 响应,然后把网页在浏览器中显示出来。

浏览器发送的 HTTP 请求如下:

GET / HTTP/1.1
Host: www.sina.com.cn
User-Agent: Mozilla/5.0 xxx
Accept: */*
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8

第一行表示使用 GET 请求 HTTP/1.1 协议获取路径为 / 的资源。从第二行开始,每行都是以 Header: Value 形式表示的 HTTP 头,比较常用的 HTTP Header 包括:

  • Host: 表示请求的主机名,
  • User-Agent: 标识客户端本身
  • Accept:表示浏览器能接收的资源类型,如 text/,image/ 或者 / 表示所有;
  • Accept-Language:表示浏览器偏好的语言,服务器可以据此返回不同语言的网页;
  • Accept-Encoding:表示浏览器可以支持的压缩类型,例如 gzip, deflate, br。

服务器的响应如下:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 21932
Content-Encoding: gzip
Cache-Control: max-age=300

<html>...网页数据...

服务器响应的第一行总是协议+空格+响应代码+空格+响应文本。响应代码 2xx 表示成功,3xx 表示重定向,4xx 表示客户端引发的错误,5xx 表示服务器端引发的错误。常见的响应代码有:

  • 200 OK:表示成功;
  • 301 Moved Permanently:表示该 URL 已经永久重定向;
  • 302 Found:表示该 URL 需要临时重定向;
  • 304 Not Modified:表示该资源没有修改,客户端可以使用本地缓存的版本;
  • 400 Bad Request:表示客户端发送了一个错误的请求,例如参数无效;
  • 401 Unauthorized:表示客户端因为身份未验证而不允许访问该 URL;
  • 403 Forbidden:表示服务器因为权限问题拒绝了客户端的请求;
  • 404 Not Found:表示客户端请求了一个不存在的资源;
  • 500 Internal Server Error:表示服务器处理时内部出错,例如因为无法连接数据库;
  • 503 Service Unavailable:表示服务器此刻暂时无法处理请求。

从第二行开始,服务器每一行均返回一个 HTTP 头。服务器经常返回的 HTTP Header 包括:

  • Content-Type:表示该响应内容的类型,例如 text/html,image/jpeg;
  • Content-Length:表示该响应内容的长度(字节数);
  • Content-Encoding:表示该响应压缩算法,例如 gzip;
  • Cache-Control:指示客户端应如何缓存,例如 max-age=300 表示可以最多缓存 300 秒。

HTTP 请求和响应都由 HTTP Header 和 HTTP Body 构成,其中 HTTP Header 每行都以 \r\n 结束。如果遇到两个连续的 \r\n,那么后面就是 HTTP Body。浏览器读取 HTTP Body,并根据 Header 信息中指示的 Content-Type、Content-Encoding 等解压后显示网页、图像或其他内容。通常浏览器获取的第一个资源是 HTML 网页,在网页中如果嵌入了 JavaScript、CSS、图片、视频等其他资源,浏览器会根据资源的 URL 再次向服务器请求对应的资源。

HTTP 目前有多个版本,1.0 是早期版本,浏览器每次建立 TCP 连接后,只发送一个 HTTP 请求并接收一个 HTTP 响应,然后就关闭 TCP 连接。由于创建 TCP 连接本身就需要消耗一定的时间,因此 HTTP 1.1 允许浏览器和服务器在同一个 TCP 连接上反复发送、接收多个 HTTP 请求和响应,这样就大大提高了传输效率。HTTP 2.0 可以支持浏览器同时发出多个请求,但每个请求需要唯一标识,服务器可以不按请求的顺序返回多个响应,由浏览器自己把收到的响应和请求对应起来。HTTP 3.0 为了进一步提高速度,将抛弃 TCP 协议,改为使用无需创建连接的 UDP 协议,目前 HTTP 3.0 仍然处于实验阶段。

Servlet

我们使用 JavaEE 提供的 Servlet API 编写 Servlet 来处理客户端 HTTP 请求并生成动态 Web 内容,把处理 TCP 连接、解析 HTTP 协议这些底层工作交给 Web 服务器处理。

// WebServlet 注解表示这是一个 Servlet,并映射到地址 / :
@WebServlet(urlPatterns = "/")
public class HelloServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 设置响应类型:
        resp.setContentType("text/html");
        // 获取输出流:
        PrintWriter pw = resp.getWriter();
        // 写入响应:
        pw.write("<h1>Hello, world!</h1>");
        // flush 强制输出:
        pw.flush();
    }
}

Servlet 总是继承自 HttpServlet,然后覆写 doGet()doPost() 方法。doGet() 方法传入 HttpServletRequest 和 HttpServletResponse 两个对象,分别代表 HTTP 请求和响应。使用 Servlet API 时并不直接与底层 TCP 交互也不需要解析 HTTP 协议,HttpServletRequest 和 HttpServletResponse 就已经封装好了请求和响应。以发送响应为例,我们只需要设置正确的响应类型,然后获取 PrintWriter,写入响应即可。

Servlet API 是一个 jar 包,我们需要通过 Maven 来引入它,才能正常编译。编写 pom.xml 文件如下:

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example.demo</groupId>
    <artifactId>web-servlet-hello</artifactId>
    <packaging>war</packaging>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>5.0.0</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>hello</finalName>
    </build>
</project>

这个 pom.xml 的打包类型不是 jar 而是 war,表示 Java Web Application Archive。引入的 Servlet API 的 <scope> 指定为 provided,表示编译时使用,但不会打包到 .war 文件中,因为 Web 服务器已经提供了 Servlet API 相关的 jar 包。运行 Maven 命令 mvn clean package,在 target 目录下得到一个 hello.war 文件,这个文件就是我们编译打包后的 Web 应用程序。

4.0 及之前的 servlet-api 由 Oracle 官方维护,引入的依赖项是 javax.servlet:javax.servlet-api,编写代码时引入的包名为:import javax.servlet.*; 而 5.0 及以后的 servlet-api 由 Eclipse 开源社区维护,引入的依赖项是 jakarta.servlet:jakarta.servlet-api,编写代码时引入的包名为:import jakarta.servlet.*

普通的 Java 程序是通过启动 JVM,然后执行 main() 方法开始运行。但是 Web 应用程序有所不同,我们无法直接运行 war 文件,必须先启动 Web 服务器,再由 Web 服务器加载我们编写的 HelloServlet,这样就可以让 HelloServlet 处理浏览器发送的请求。支持 Servlet API 的常用 Web 服务器有:由 Apache 开发的开源免费服务器 Tomcat ;由 Eclipse 开发的开源免费服务器 Jetty ;开源的全功能 JavaEE 服务器 GlassFish 。无论使用哪个服务器,只要它支持 Servlet API,war 包就可以在上面运行。使用最广泛的是 Tomcat 服务器。要运行 hello.war,首先要下载 Tomcat 服务器解压后,把 hello.war 复制到 Tomcat 的 webapps 目录下,然后切换到 bin 目录,执行 startup.sh 或 startup.bat 启动 Tomcat 服务器。在浏览器输入 http://localhost:8080/hello/ 即可看到 HelloServlet 的输出。文件名为 ROOT.war 的应用程序将作为默认应用,启动后直接访问 http://localhost:8080/ 即可。

实际上类似 Tomcat 这样的服务器也是 Java 编写的,启动 Tomcat 服务器实际上是启动 Java 虚拟机,执行 Tomcat 的 main() 方法,然后由 Tomcat 负责加载我们的 .war 文件,并为每个 Servlet 类创建一个实例,最后以多线程的模式来处理 HTTP 请求。如果 Tomcat 服务器收到的请求路径是 /hello/ ,就转发到 HelloServlet 并传入 HttpServletRequest 和 HttpServletResponse 两个对象。

因为 Servlet 并不是直接运行,而是由 Web 服务器加载后创建实例运行,所以类似 Tomcat 这样的 Web 服务器也称为 Servlet 容器。由于 Servlet 版本分为 <=4.0 和 >=5.0 两种,要根据使用的 Servlet 版本选择正确的 Tomcat 版本。使用 Servlet<=4.0 时,选择 Tomcat 9.x 或更低版本;使用 Servlet>=5.0 时,选择 Tomcat 10.x 或更高版本。

在 Servlet 容器中运行的 Servlet 具有如下特点:无法在代码中直接通过 new 创建 Servlet 实例,必须由 Servlet 容器自动创建 Servlet 实例;Servlet 容器只会给每个 Servlet 类创建唯一实例;Servlet 容器会使用多线程执行 doGet()doPost() 方法。因此在 Servlet 中定义的实例变量会被多个线程同时访问,要注意线程安全。HttpServletRequest 和 HttpServletResponse 实例是由 Servlet 容器传入的局部变量,它们只能被当前线程访问,不存在多个线程访问的问题。在 doGet()doPost() 方法中,如果使用了 ThreadLocal 但没有清理,那么它的状态很可能会影响到下次的某个请求,因为 Servlet 容器很可能用线程池实现线程复用。

Servlet 开发

完整的 Web 应用程序的开发流程如下:编写 Servlet;打包为 war 文件;war 文件复制到 Tomcat 的 webapps 目录下;启动 Tomcat。如果我们想在 IDE 中断点调试,还需要打开 Tomcat 的远程调试端口并且连接上去。因此我们需要一种简单可靠能直接在 IDE 中启动并调试 webapp 的方法。因为 Tomcat 实际上也是一个 Java 程序,我们可以把 Tomcat 的 jar 包引入进来,然后自己编写一个 main() 方法,先启动 Tomcat,然后让它加载我们的 webapp 就行。

新建一个 web-servlet-embedded 工程,编写 pom.xml 如下:

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example.demo</groupId>
    <artifactId>web-servlet-embedded</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <java.version>17</java.version>
        <tomcat.version>10.1.1</tomcat.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
            <version>${tomcat.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <version>${tomcat.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>

<packaging> 类型仍然为 war,引入依赖 tomcat-embed-core 和 tomcat-embed-jasper,引入的 Tomcat 版本 <tomcat.version> 为 10.1.1。不必引入 Servlet API,因为引入 Tomcat 依赖后自动引入了 Servlet API。然后编写一个 main() 方法,启动 Tomcat 服务器:

public class Main {
    public static void main(String[] args) throws Exception {
        // 启动 Tomcat:
        Tomcat tomcat = new Tomcat();
        tomcat.setPort(Integer.getInteger("port", 8080));
        tomcat.getConnector();
        // 创建 webapp:
        Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
        WebResourceRoot resources = new StandardRoot(ctx);
        resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
        ctx.setResources(resources);
        tomcat.start();
        tomcat.getServer().await();
    }
}

直接运行 main() 方法,即可启动嵌入式 Tomcat 服务器,然后通过预设的 tomcat.addWebapp("", new File("src/main/webapp"),Tomcat 会自动加载当前工程作为根 webapp,可直接在浏览器访问 http://localhost:8080/ 。通过 main() 方法启动 Tomcat 服务器并加载我们自己的 webapp 有如下好处:启动简单,无需下载 Tomcat 或安装任何 IDE 插件;调试方便,可在 IDE 中使用断点调试;使用 Maven 创建 war 包后,也可以正常部署到独立的 Tomcat 服务器中。

Web App 是由一个或多个 Servlet 组成,每个 Servlet 通过注解说明自己能处理的路径。HelloServlet 能处理 /hello 这个路径的请求。要处理 GET 请求,我们要覆写 doGet() 方法。要处理 POST 请求,就需要覆写 doPost() 方法。

@WebServlet(urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        ...
    }
}

一个 Webapp 可以有多个 Servlet,分别映射不同的路径。

@WebServlet(urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
    ...
}

@WebServlet(urlPatterns = "/")
public class IndexServlet extends HttpServlet {
    ...
}

浏览器发出的 HTTP 请求总是由 Web Server 先接收,然后根据 Servlet 配置的映射,不同的路径转发到不同的 Servlet。这种根据路径转发的功能我们一般称为 dispatch。映射到 / 的 IndexServlet 比较特殊,它实际上会接收所有未匹配的路径,相当于 /*

HttpServletRequest

HttpServletRequest 封装了一个 HTTP 请求,我们通过 HttpServletRequest 提供的接口方法可以拿到 HTTP 请求的几乎全部信息,常用的方法有:

  • getMethod():返回请求方法,例如,"GET","POST";
  • getRequestURI():返回请求路径,但不包括请求参数,例如,"/hello";
  • getQueryString():返回请求参数,例如,"name=Bob&a=1&b=2";
  • getParameter(name):返回请求参数,GET 请求从 URL 读取参数,POST 请求从 Body 中读取参数;
  • getContentType():获取请求 Body 的类型,例如,"application/x-www-form-urlencoded";
  • getContextPath():获取当前 Webapp 挂载的路径,对于 ROOT 来说,总是返回空字符串"";
  • getCookies():返回请求携带的所有 Cookie;
  • getHeader(name):获取指定的 Header,对 Header 名称不区分大小写;
  • getHeaderNames():返回所有 Header 名称;
  • getInputStream():如果该请求带有 HTTP Body,该方法将打开一个输入流用于读取 Body;
  • getReader():和 getInputStream()类似,但打开的是 Reader;
  • getRemoteAddr():返回客户端的 IP 地址;
  • getScheme():返回协议类型,例如,"http","https";

此外HttpServletRequest 还有两个方法:setAttribute()getAttribute(),可以给当前 HttpServletRequest 对象附加多个 Key-Value,相当于把 HttpServletRequest 当作 Map<String, Object> 使用。

HttpServletResponse

HttpServletResponse 封装了一个 HTTP 响应。由于 HTTP 响应必须先发送 Header,再发送 Body,所以操作 HttpServletResponse 对象时,必须先调用设置 Header 的方法,最后调用发送 Body 的方法。

常用的设置 Header 的方法有:

  • setStatus(sc):设置响应代码,默认是 200;
  • setContentType(type):设置 Body 的类型,例如,"text/html";
  • setCharacterEncoding(charset):设置字符编码,例如,"UTF-8";
  • setHeader(name, value):设置一个 Header 的值;
  • addCookie(cookie):给响应添加一个 Cookie;
  • addHeader(name, value):给响应添加一个 Header,因为 HTTP 协议允许有多个相同的 Header;

写入响应时需要通过 getOutputStream() 获取写入流,或者通过 getWriter() 获取字符流,二者只能获取其中一个。写入响应前无需设置 setContentLength(),因为底层服务器会根据写入的字节数自动设置。如果写入的数据量很小,实际上会先写入缓冲区,如果写入的数据量很大,服务器会自动采用 Chunked 编码让浏览器能识别数据结束符而不需要设置 Content-Length 头。写入完毕后必须调用 flush() ,因为大部分 Web 服务器都基于 HTTP/1.1 协议,会复用 TCP 连接。如果没有调用 flush(),将导致缓冲区的内容无法及时发送到客户端。此外写入完毕后千万不要调用 close(),原因是因为会复用 TCP 连接,如果关闭写入流,将关闭 TCP 连接,使得 Web 服务器无法复用此 TCP 连接。

有了 HttpServletRequest 和 HttpServletResponse 这两个高级接口,我们就不需要直接处理 HTTP 协议。具体的实现类是由各服务器提供的,而我们编写的 Web 应用程序只关心接口方法,并不需要关心具体实现的子类。

Servlet 多线程模型

一个 Servlet 类在服务器中只有一个实例,但对于每个 HTTP 请求,Web 服务器会使用多线程执行请求。因此一个 Servlet 的 doGet()doPost() 等处理请求的方法是多线程并发执行的。如果 Servlet 中定义了字段,要注意多线程并发访问的问题:

public class HelloServlet extends HttpServlet {
    private Map<String, String> map = new ConcurrentHashMap<>();

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 读写 map 字段是多线程并发的:
        this.map.put(key, value);
    }

}

对于每个请求,Web 服务器会创建唯一的 HttpServletRequest 和 HttpServletResponse 实例,因此 HttpServletRequest 和 HttpServletResponse 实例只有在当前处理线程中有效,它们总是局部变量,不存在多线程共享的问题。

重定向与转发

重定向是指当浏览器请求一个 URL 时,服务器返回一个重定向指令,告诉浏览器地址已经变了,麻烦使用新的 URL 再重新发送新请求。

@WebServlet(urlPatterns = "/hi")
public class RedirectServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 构造重定向的路径:
        String name = req.getParameter("name");
        String redirectToUrl = "/hello" + (name == null ? "" : "?name=" + name);
        // 发送重定向响应:
        resp.sendRedirect(redirectToUrl);
    }
}

如果浏览器发送 GET /hi 请求,RedirectServlet 将处理此请求。由于 RedirectServlet 在内部发送了重定向响应,因此浏览器会收到如下响应:

HTTP/1.1 302 Found
Location: /hello

当浏览器收到 302 响应后,它会立刻根据 Location 的指示发送一个新的 GET /hello 请求,这个过程就是重定向。观察浏览器的网络请求,可以看到两次 HTTP 请求,并且浏览器的地址栏路径自动更新为 /hello。

重定向有两种:302 临时重定向,301 永久重定向。两者的区别是,如果服务器发送 301 永久重定向响应,浏览器会缓存 /hi 到 /hello 这个重定向的关联,下次请求 /hi 的时候,浏览器就直接发送 /hello 请求了。如果要实现 301 永久重定向,可以这么写:

resp.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); // 301
resp.setHeader("Location", "/hello");

Forward 是指内部转发。当一个 Servlet 处理请求的时候,它可以决定自己不继续处理,而是转发给另一个 Servlet 处理。

@WebServlet(urlPatterns = "/morning")
public class ForwardServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        req.getRequestDispatcher("/hello").forward(req, resp);
    }
}

ForwardServlet 在收到请求后,它并不自己发送响应,而是把请求和响应都转发给路径为 /hello 的 Servlet。后续请求的处理实际上是由 HelloServlet 完成的。这种处理方式称为转发 Forward。转发和重定向的区别在于,转发是在 Web 服务器内部完成的,对浏览器来说,它只发出了一个 HTTP 请求。使用转发的时候,浏览器的地址栏路径仍然是 /morning,浏览器并不知道该请求在 Web 服务器内部实际上做了一次转发。

在 Web 应用程序中,我们经常要跟踪用户身份。因为 HTTP 协议是一个无状态协议,即 Web 应用程序无法区分收到的两个 HTTP 请求是否是同一个浏览器发出的。为了跟踪用户状态,服务器可以向浏览器分配一个唯一 ID,并以 Cookie 的形式发送到浏览器,浏览器在后续访问时总是附带此 Cookie,这样服务器就可以识别用户身份。我们把这种基于唯一 ID 识别用户身份的机制称为 Session。每个用户第一次访问服务器后,会自动获得一个 Session ID。如果用户在一段时间内没有访问服务器,那么 Session 会自动失效,下次即使带着上次分配的 Session ID 访问,服务器也认为这是一个新用户,会分配新的 Session ID。

JavaEE 的 Servlet 机制内建了对 Session 的支持。我们以登录为例,当一个用户登录成功后,我们就可以把这个用户的名字放入一个 HttpSession 对象,以便后续访问其他页面的时候,能直接从 HttpSession 取出用户名:

@WebServlet(urlPatterns = "/signin")
public class SignInServlet extends HttpServlet {
    // 模拟一个数据库:
    private Map<String, String> users = Map.of("bob", "bob123", "alice", "alice123", "tom", "tomcat");

    // GET 请求时显示登录页:
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/html");
        PrintWriter pw = resp.getWriter();
        pw.write("<h1>Sign In</h1>");
        pw.write("<form action=\"/signin\" method=\"post\">");
        pw.write("<p>Username: <input name=\"username\"></p>");
        pw.write("<p>Password: <input name=\"password\" type=\"password\"></p>");
        pw.write("<p><button type=\"submit\">Sign In</button> <a href=\"/\">Cancel</a></p>");
        pw.write("</form>");
        pw.flush();
    }

    // POST请求时处理用户登录:
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String name = req.getParameter("username");
        String password = req.getParameter("password");
        String expectedPassword = users.get(name.toLowerCase());
        if (expectedPassword != null && expectedPassword.equals(password)) {
            // 登录成功:
            req.getSession().setAttribute("user", name);
            resp.sendRedirect("/");
        } else {
            resp.sendError(HttpServletResponse.SC_FORBIDDEN);
        }
    }
}

在 IndexServlet 中,可以从 HttpSession 取出用户名:

@WebServlet(urlPatterns = "/")
public class IndexServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 从 HttpSession 获取当前用户名:
        String user = (String) req.getSession().getAttribute("user");
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        resp.setHeader("X-Powered-By", "JavaEE Servlet");
        PrintWriter pw = resp.getWriter();
        pw.write("<h1>Welcome, " + (user != null ? user : "Guest") + "</h1>");
        if (user == null) {
            // 未登录,显示登录链接:
            pw.write("<p><a href=\"/signin\">Sign In</a></p>");
        } else {
            // 已登录,显示登出链接:
            pw.write("<p><a href=\"/signout\">Sign Out</a></p>");
        }
        pw.flush();
    }
}

如果用户已登录,可以通过访问 /signout 登出。登出逻辑就是从 HttpSession 中移除用户相关信息:

@WebServlet(urlPatterns = "/signout")
public class SignOutServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 从HttpSession移除用户名:
        req.getSession().removeAttribute("user");
        resp.sendRedirect("/");
    }
}

对于 Web 应用程序来说,我们总是通过 HttpSession 这个高级接口访问当前 Session。如果要深入理解 Session 原理,可以认为 Web 服务器在内存中自动维护了一个 Session ID 到 HttpSession 的映射表。而服务器识别 Session 的关键就是依靠一个名为 JSESSIONID 的 Cookie。在 Servlet 中第一次调用 req.getSession() 时,Servlet 容器自动创建一个 Session ID,然后通过一个名为 JSESSIONID 的 Cookie 发送给浏览器。JSESSIONID 是由 Servlet 容器自动创建的,目的是维护一个浏览器会话,它和我们的登录逻辑没有关系;登录和登出的业务逻辑是我们自己根据 HttpSession 是否存在一个 "user" 的 Key 判断的,登出后 Session ID 并不会改变;即使没有登录功能,仍然可以使用 HttpSession 追踪用户,例如放入一些用户配置信息等。

使用 Session 时,由于服务器把所有用户的 Session 都存储在内存中。如果遇到内存不足的情况,就需要把部分不活动的 Session 序列化到磁盘上,这会大大降低服务器的运行效率,因此放入 Session 的对象要小。在使用多台服务器构成集群时,使用 Session 会遇到一些额外的问题。通常多台服务器集群使用反向代理作为网站入口。如果多台 Web Server 采用无状态集群,会造成一个用户从 Web Server 1 登录后,如果后续请求被转发到 Web Server 2 或 3,那么用户看到的仍然是未登录状态。要解决这个问题,方案一是在所有 Web Server 之间进行 Session 复制,但这样会严重消耗网络带宽,并且每个 Web Server 的内存均存储所有用户的 Session,内存使用率很低。另一个方案是采用粘滞会话机制,即反向代理在转发请求的时候,总是根据 JSESSIONID 的值判断,相同的 JSESSIONID 总是转发到固定的 Web Server,但这需要反向代理的支持。无论采用何种方案,使用 Session 机制,会使得 Web Server 的集群很难扩展,因此 Session 适用于中小型 Web 应用程序。对于大型 Web 应用程序来说,通常需要避免使用 Session 机制。

Servlet 提供的 HttpSession 本质上就是通过一个名为 JSESSIONID 的 Cookie 来跟踪用户会话的。除了这个名称外,其他名称的 Cookie 我们可以任意使用。如果我们想要设置一个 Cookie 记录用户选择的语言,可以编写一个 LanguageServlet:

@WebServlet(urlPatterns = "/pref")
public class LanguageServlet extends HttpServlet {

    private static final Set<String> LANGUAGES = Set.of("en", "zh");

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String lang = req.getParameter("lang");
        if (LANGUAGES.contains(lang)) {
            // 创建一个新的 Cookie:
            Cookie cookie = new Cookie("lang", lang);
            // 该 Cookie 生效的路径范围:
            cookie.setPath("/");
            // 该 Cookie 有效期:
            cookie.setMaxAge(8640000); // 8640000秒=100天
            // 将该 Cookie 添加到响应:
            resp.addCookie(cookie);
        }
        resp.sendRedirect("/");
    }
}

创建一个新 Cookie 时,除了指定名称和值以外,通常需要设置 setPath("/"),浏览器根据此前缀决定是否发送 Cookie。如果一个 Cookie 调用了 setPath("/user/"),那么浏览器只有在请求以 /user/ 开头的路径时才会附加此 Cookie。通过 setMaxAge() 设置 Cookie 的有效期,单位为秒。最后通过 resp.addCookie() 把它添加到响应。如果访问的是 https 网页,还需要调用 setSecure(true),否则浏览器不会发送该 Cookie。

浏览器在请求某个 URL 时,是否携带指定的 Cookie,取决于 Cookie 是否满足以下所有要求:URL 前缀是设置 Cookie 时的 Path;Cookie 在有效期内;Cookie 设置了 secure 时必须以 https 访问。

读取 Cookie 主要依靠遍历 HttpServletRequest 附带的所有 Cookie:

private String parseLanguageFromCookie(HttpServletRequest req) {
    // 获取请求附带的所有 Cookie:
    Cookie[] cookies = req.getCookies();
    // 如果获取到 Cookie:
    if (cookies != null) {
        // 循环每个 Cookie:
        for (Cookie cookie : cookies) {
            // 如果 Cookie 名称为 lang:
            if (cookie.getName().equals("lang")) {
                // 返回 Cookie 的值:
                return cookie.getValue();
            }
        }
    }
    // 返回默认值:
    return "en";
}

JSP 开发

JSP 是 Java Server Pages 的缩写,JSP 文件必须放到 /src/main/webapp 下,文件名必须以 .jsp 结尾。整个 JSP 的内容实际上是一个 HTML,但可以包含特殊的标签:包含在 <%----%> 之间的是 JSP 的注释,它们会被完全忽略;包含在 <%%> 之间的是 Java 代码,可以编写任意 Java 代码;<%= xxx %> 可以快捷输出一个变量的值。JSP 页面内置了几个变量:out 表示 HttpServletResponse 的 PrintWriter;session 表示当前 HttpSession 对象;request 表示 HttpServletRequest 对象。这几个变量可以直接使用。

<html>
  <head>
    <title>Hello World - JSP</title>
  </head>
  <body>
    <%-- JSP Comment --%>
    <h1>Hello World!</h1>
    <p>
      <% out.println("Your IP address is "); %>
      <span style="color:red"> <%= request.getRemoteAddr() %> </span>
    </p>
  </body>
</html>

访问 JSP 页面时,直接指定完整路径:http://localhost:8080/hello.jsp。

JSP 本质上就是一个 Servlet,只不过无需配置映射路径,Web Server 会根据路径查找对应的 .jsp 文件,如果找到了就自动编译成 Servlet 再执行。在服务器运行过程中,如果修改了 JSP 的内容,那么服务器会自动重新编译。

此外使用 page 指令可以引入 Java 类:<%@ page import="java.util.*" %>,这样后续的 Java 代码可以引用简单类名而不是完整类名。使用 include 指令可以引入另一个 JSP 文件:<%@ include file="header.jsp"%>

MVC 开发

Servlet 适合编写 Java 代码,实现各种复杂的业务逻辑,但不适合输出复杂的 HTML;JSP 适合编写 HTML,并在其中插入动态内容,但不适合编写复杂的 Java 代码。可以将两者结合起来,发挥各自的优点,避免各自的缺点。

在 UserServlet 中,我们可以从数据库读取 User、School 等信息,把读取到的 JavaBean 先放到 HttpServletRequest 中,再通过 forward() 传给 user.jsp 处理:

@WebServlet(urlPatterns = "/user")
public class UserServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 假装从数据库读取:
        School school = new School("No.1 Middle School", "101 South Street");
        User user = new User(123, "Bob", school);
        // 放入 Reques t中:
        req.setAttribute("user", user);
        // forward 给 user.jsp:
        req.getRequestDispatcher("/WEB-INF/user.jsp").forward(req, resp);
    }
}

在 user.jsp 中我们只负责展示相关 JavaBean 的信息,不需要编写访问数据库等复杂逻辑:

<%@ page import="com.example.demo.bean.*"%> <% User user = (User) request.getAttribute("user"); %>
<html>
  <head>
    <title>Hello World - JSP</title>
  </head>
  <body>
    <h1>Hello <%= user.name %>!</h1>
    <p>
      School Name:
      <span style="color:red"> <%= user.school.name %> </span>
    </p>
    <p>
      School Address:
      <span style="color:red"> <%= user.school.address %> </span>
    </p>
  </body>
</html>

UserServlet 把需要展示的 User 放入 HttpServletRequest 中,然后传递给 JSP。一个请求对应一个 HttpServletRequest,处理完该请求后 HttpServletRequest 实例将被丢弃。把 user.jsp 放到 /WEB-INF/ 目录下,是因为 WEB-INF 是一个特殊目录,Web Server 会阻止浏览器对 WEB-INF 目录下任何资源的访问,这样就防止用户通过 /user.jsp 路径直接访问到 JSP 页面;JSP 页面首先从 request 变量获取 User 实例,然后在页面中直接输出。我们在浏览器访问 http://localhost:8080/user,请求首先由 UserServlet 处理,然后交给 user.jsp 渲染。

我们把 UserServlet 看作业务逻辑处理,把 User 看作模型,把 user.jsp 看作渲染,这种设计模式通常被称为 MVC:Model-View-Controller,即 UserServlet 作为控制器 Controller ,User 作为模型 Model ,user.jsp 作为视图 View 。使用 MVC 模式的好处是,Controller 专注于业务流程处理,把 Model 的结果传递给 View,View 负责把 Model 给“渲染”出来。这样三者职责明确且开发更简单,因为开发 Controller 时无需关注页面,开发 View 时无需关心如何创建 Model。Model 可以是一个 JavaBean,也可以是一个包含多个对象的 Map。MVC 模式广泛地应用在 Web 页面和传统的桌面程序中。

Spring

Spring 是一个支持快速开发 Java EE 应用程序的框架。它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成。Spring Framework 主要包括几个模块:支持 IoC 和 AOP 的容器;支持 JDBC 和 ORM 的数据访问模块;支持声明式事务的模块;支持基于 Servlet 的 MVC 开发;支持基于 Reactive 的 Web 开发;以及集成 JMS、JavaMail、JMX、缓存等其他模块。

Spring 版本 6.x 和 Spring 5.x 有以下不同:

Spring 5.x Spring 6.x
JDK 版本 >= 1.8 >= 17
Tomcat 版本 9.x 10.x
Annotation 包 javax.annotation j jakarta.annotation
Servlet 包 javax.servlet jakarta.servlet
JMS 包 javax.jms jakarta.jms
JavaMail 包 javax.mail jakarta.mail

Spring IoC 容器

容器是一种为某种特定组件的运行提供必要支持的软件环境。例如 Tomcat 就是一个 Servlet 容器,它可以为 Servlet 的运行提供运行环境。通常来说使用容器运行组件,除了提供组件运行环境之外,容器还提供了许多底层服务。例如 Servlet 容器底层实现了 TCP 连接、解析 HTTP 协议等非常复杂的服务。

Spring 的核心就是提供了一个 IoC 容器,它可以管理所有轻量级的 JavaBean 组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP 支持,以及建立在 AOP 基础上的声明式事务服务等。

IoC 全称 Inversion of Control,直译为控制反转。对象的创建控制权由程序自身转移到外部容器,这种思想称为控制反转。IoC 又称为依赖注入 DI:Dependency Injection ,它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且由 IoC 容器负责管理组件的生命周期。

public class BookService {
    private DataSource dataSource;

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }
}

因为 IoC 容器负责实例化所有的组件,因此有必要告诉容器如何创建组件以及各组件的依赖关系。一种最简单的配置是通过 XML 文件来实现,例如:

<beans>
    <bean id="dataSource" class="HikariDataSource" />
    <bean id="bookService" class="BookService">
        <property name="dataSource" ref="dataSource" />
    </bean>
    <bean id="userService" class="UserService">
        <property name="dataSource" ref="dataSource" />
    </bean>
</beans>

XML 配置文件指示 IoC 容器创建 3 个 JavaBean 组件,并把 id 为 dataSource 的组件通过属性 dataSource(即调用 setDataSource() 方法)注入到另外两个组件中。

在 Spring 的 IoC 容器中,我们把所有组件统称为 JavaBean,配置一个组件就是配置一个 Bean。

依赖注入方式

依赖注入可以通过 set() 方法实现,也可以通过构造方法实现。Spring 的 IoC 容器同时支持属性注入和构造方法注入,并允许混合使用。

public class BookService {
    private DataSource dataSource;

    public BookService(DataSource dataSource) {
        this.dataSource = dataSource;
    }
}

在设计上 Spring 的 IoC 容器是一个高度可扩展的无侵入容器。所谓无侵入是指应用程序的组件无需实现 Spring 的特定接口,或者说组件根本不知道自己在 Spring 的容器中运行。这种无侵入的设计有以下好处:应用程序组件既可以在 Spring 的 IoC 容器中运行,也可以自己编写代码自行组装配置;测试的时候并不依赖 Spring 容器,可单独进行测试,大大提高了开发效率。

装配 Bean

编写 application.xml 配置文件,告诉 Spring 的 IoC 容器应该如何创建并组装 Bean:

<?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
        https://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="userService" class="com.example.demo.service.UserService">
        <property name="mailService" ref="mailService" />
    </bean>

    <bean id="mailService" class="com.example.demo.service.MailService" />
</beans>

配置文件中 XML Schema 相关的部分格式是固定的。每个 <bean ...> 都有一个 id 标识,相当于 Bean 的唯一 ID。在 userService Bean 中,通过<property name="..." ref="..." /> 注入了另一个 Bean。Bean 的顺序不重要,Spring 根据依赖关系会自动正确初始化。

Spring 容器是通过读取 XML 文件后使用反射完成的。如果注入的不是 Bean,而是 boolean、int、String 这样的数据类型,则通过 value 注入:

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
    <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test" />
    <property name="username" value="root" />
    <property name="password" value="password" />
    <property name="maximumPoolSize" value="10" />
    <property name="autoCommit" value="true" />
</bean>

我们需要创建一个 Spring 的 IoC 容器实例,然后加载配置文件,让 Spring 容器为我们创建并装配好配置文件中指定的所有 Bean:ApplicationContext context = new ClassPathXmlApplicationContext("application.xml"); 。接下来我们就可以从 Spring 容器中“取出”装配好的 Bean 然后使用它:

public class Main {
    public static void main(String[] args) {
        ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
        UserService userService = context.getBean(UserService.class);
        User user = userService.login("bob@example.com", "password");
        System.out.println(user.getName());
    }
}

ApplicationContext 是一个接口,它有很多实现类,这里我们选择 ClassPathXmlApplicationContext,表示它会自动从 classpath 中查找指定的 XML 配置文件。获得了 ApplicationContext 的实例,就获得了 IoC 容器的引用。从 ApplicationContext 中我们可以根据 Bean 的 ID 获取 Bean,但更多的时候我们根据 Bean 的类型获取 Bean 的引用:UserService userService = context.getBean(UserService.class);

Spring 还提供另一种 IoC 容器叫 BeanFactory,使用方式和 ApplicationContext 类似:

BeanFactory factory = new XmlBeanFactory(new ClassPathResource("application.xml"));
MailService mailService = factory.getBean(MailService.class);

BeanFactory 和 ApplicationContext 的区别在于,BeanFactory 的实现是按需创建,即第一次获取 Bean 时才创建这个 Bean,而 ApplicationContext 会一次性创建所有的 Bean。实际上 ApplicationContext 接口是从 BeanFactory 接口继承而来的,并且 ApplicationContext 提供了一些额外的功能,包括国际化支持、事件和通知机制等。通常情况下我们总是使用 ApplicationContext,很少会考虑使用 BeanFactory。

Spring 核心注解

使用 Spring 的 IoC 容器实际上就是通过类 XML 配置文件把 Bean 的依赖关系描述出来,然后让容器来创建并装配 Bean。一旦容器初始化完毕,我们就直接从容器中获取 Bean 使用它们。使用 XML 配置的优点是所有的 Bean 都能一目了然地列出来,并通过配置注入能直观地看到每个 Bean 的依赖。它的缺点是写起来非常繁琐,每增加一个组件,就必须把新的 Bean 配置到 XML 中。

使用 Annotation 配置是更简单的配置方式,可以完全不需要 XML,让 Spring 自动扫描 Bean 并组装它们。

@Component
public class UserService {
    @Autowired
    MailService mailService;

    ...
}

@Component 注解就相当于定义了一个 Bean,名称默认是 userService,即小写开头的类名。@Autowired 注解就相当于把指定类型的 Bean 注入到指定的字段中。和 XML 配置相比,@Autowired 大幅简化了注入,因为它不但可以写在 set() 方法上,还可以直接写在字段上,甚至可以写在构造方法中。我们一般把 @Autowired 写在字段上,通常使用 package 权限的字段,便于测试。

@Component
public class UserService {
    MailService mailService;

    public UserService(@Autowired MailService mailService) {
        this.mailService = mailService;
    }
    ...
}

AppConfig 标注 @Configuration,表示它是一个配置类。我们创建 ApplicationContext 时:ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); 使用的实现类是 AnnotationConfigApplicationContext,必须传入一个标注了 @Configuration 的类名。此外 AppConfig 还标注了 @ComponentScan,它告诉容器自动搜索当前类所在的包以及子包,把所有标注为 @Component 的 Bean 自动创建出来,并根据 @Autowired 进行装配。

@Configuration
@ComponentScan
public class AppConfig {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        UserService userService = context.getBean(UserService.class);
        User user = userService.login("bob@example.com", "password");
        System.out.println(user.getName());
    }
}

使用 Annotation 配合自动扫描能大幅简化 Spring 的配置,我们只需要保证:每个 Bean 被标注为 @Component 并正确使用 @Autowired 注入;配置类被标注为 @Configuration@ComponentScan;所有 Bean 均在指定包以及子包内。使用 @ComponentScan 非常方便,但是要特别注意包的层次结构。通常来说启动配置 AppConfig 位于自定义的顶层包,其他 Bean 按类别放入子包。工程结构如下:

spring-ioc-annoconfig
├── pom.xml
└── src
└── main
└── java
└── com
└── example
└── demo
├── AppConfig.java
└── service
├── MailService.java
└── UserService.java

对于 Spring 容器来说,当我们把一个 Bean 标记为 @Component 后,它就会自动为我们创建一个单例,即容器初始化时创建 Bean,容器关闭前销毁 Bean,在容器运行期间调用 getBean(Class) 获取到的 Bean 总是同一个实例。

还有一种 Bean,我们每次调用 getBean(Class),容器都返回一个新的实例,这种 Bean 称为 Prototype 原型 ,它的生命周期和单例不同。声明一个 Prototype 的 Bean 时,需要添加一个额外的 @Scope 注解:

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 
public class MailSession {
    ...
}

注入 List

有些时候,我们会有一系列接口相同不同实现类的 Bean。

public interface PaymentProcessor {
    void process();
}

@Component
public class CreditCardProcessor implements PaymentProcessor {
    @Override
    public void process() {
        System.out.println("信用卡支付处理");
    }
}

@Component
public class PayPalProcessor implements PaymentProcessor {
    @Override
    public void process() {
        System.out.println("PayPal支付处理");
    }
}

注入 List 到服务类:

@Service
public class PaymentService {
    @Autowired
    private List<PaymentProcessor> processors;  // 注入所有实现类的 Bean

    public void executeAll() {
        processors.forEach(PaymentProcessor::process);
    }
}

Spring会自动把所有类型为 PaymentProcessor 的 Bean 装配为一个 List 注入进来,这样一来每新增一个 PaymentProcessor 类型,就自动被 Spring 装配到 processors 中了,非常方便。

可选注入

默认情况下当我们标记了一个 @Autowired 后,Spring 如果没有找到对应类型的 Bean,它会抛出 NoSuchBeanDefinitionException 异常。可以给 @Autowired 增加一个 required = false 的参数。这个参数告诉 Spring 容器,如果找到一个类型为 ZoneId 的 Bean 就注入,如果找不到就忽略。这种方式非常适合有定义就使用定义,没有就使用默认值的情况。

@Component
public class MailService {
    @Autowired(required = false)
    ZoneId zoneId = ZoneId.systemDefault();
    ...
}

创建第三方 Bean

如果一个 Bean 不在我们自己的 package 管理之内,例如 ZoneId,可以在 @Configuration 类中编写一个标记 @Bean 注解的 Java 方法创建并返回它。Spring 对标记为 @Bean 的方法只调用一次,因此返回的 Bean 仍然是单例。

@Configuration
@ComponentScan
public class AppConfig {
    // 创建一个 Bean:
    @Bean
    ZoneId createZoneId() {
        return ZoneId.of("Z");
    }
}

初始化和销毁

有的 Bean 在注入必要的依赖后需要进行初始化,在容器关闭时需清理资源。我们通常会定义一个 init() 方法进行初始化,定义一个 shutdown() 方法进行清理,然后引入 JSR-250 定义的 Annotation:jakarta.annotation:jakarta.annotation-api:2.1.1。在 Bean 的初始化和清理方法上标记 @PostConstruct@PreDestroy

@Component
public class MailService {
    @Autowired(required = false)
    ZoneId zoneId = ZoneId.systemDefault();

    @PostConstruct
    public void init() {
        System.out.println("Init mail service with zoneId = " + this.zoneId);
    }

    @PreDestroy
    public void shutdown() {
        System.out.println("Shutdown mail service");
    }
}

Spring 容器会对上述 Bean 做如下初始化流程:调用构造方法创建 MailService 实例;根据 @Autowired 进行注入;调用标记有 @PostConstructinit() 方法进行初始化;而销毁时,容器会首先调用标记有 @PreDestroyshutdown() 方法。

使用别名

默认情况下对一种类型的 Bean,容器只创建一个实例。但有些时候,我们需要对一种类型的 Bean 创建多个实例。例如同时连接多个数据库,就必须创建多个 DataSource 实例。如果在 @Configuration 类中创建多个同类型的 Bean,需要给每个 Bean 添加不同的名字; 可以用 @Bean("name") 指定别名,也可以用 @Bean+@Qualifier("name") 指定别名。

@Configuration
@ComponentScan
public class AppConfig {
    @Bean("z")
    ZoneId createZoneOfZ() {
        return ZoneId.of("Z");
    }

    @Bean
    @Qualifier("utc8")
    ZoneId createZoneOfUTC8() {
        return ZoneId.of("UTC+08:00");
    }
}

注入时要指定 Bean 的名称:

@Component
public class MailService {
	@Autowired(required = false)
	@Qualifier("z") // 指定注入名称为 "z" 的 ZoneId
	ZoneId zoneId = ZoneId.systemDefault();
    ...
}

还有一种方法是把其中某个 Bean 指定为 @Primary

@Configuration
@ComponentScan
public class AppConfig {
    @Bean
    @Primary // 指定为主要 Bean
    @Qualifier("z")
    ZoneId createZoneOfZ() {
        return ZoneId.of("Z");
    }

    @Bean
    @Qualifier("utc8")
    ZoneId createZoneOfUTC8() {
        return ZoneId.of("UTC+08:00");
    }
}

这样在注入时,如果没有指出 Bean 的名字,Spring 会注入标记有 @Primary 的 Bean。这种方式也很常用。

使用 Resource

在 Java 程序中,我们经常会读取配置文件、资源文件等。使用 Spring 容器时,我们也可以把“文件”注入进来,方便程序读取。Spring 提供了 org.springframework.core.io.Resource 可以像 String、int 一样使用 @Value 注入:

@Component
public class AppService {
    @Value("classpath:/logo.txt")
    private Resource resource;

    private String logo;

    @PostConstruct
    public void init() throws IOException {
        try (var reader = new BufferedReader(
                new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
            this.logo = reader.lines().collect(Collectors.joining("\n"));
        }
    }
}

注入 Resource 最常用的方式是通过 classpath,即类似 classpath:/logo.txt 表示在 classpath 中搜索 logo.txt 文件,然后我们直接调用 Resource.getInputStream() 就可以获取到输入流,避免了自己搜索文件的代码。也可以直接指定文件的路径:

@Value("file:/path/to/logo.txt")
private Resource resource;

但使用 classpath 是最简单的方式。使用 Maven 的标准目录结构,所有资源文件放入 src/main/resources 即可。

注入配置

在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以 key=value 的形式写在 .properties 文件中。Spring 容器提供了 @PropertySource 来自动读取配置文件。我们只需要在 @Configuration 配置类上再添加一个注解:

@Configuration
@ComponentScan
@PropertySource("app.properties") // 表示读取 classpath 的 app.properties
public class AppConfig {
    @Value("${app.zone:Z}")
    String zoneId;

    @Bean
    ZoneId createZoneId() {
        return ZoneId.of(zoneId);
    }
}

Spring 容器看到 @PropertySource("app.properties") 注解后自动读取这个配置文件,然后我们使用 @Value 正常注入。注入的字符串语法:${app.zone} 表示读取 key 为 app.zone 的 value,如果 key 不存在,启动将报错;${app.zone:Z} 表示读取 key 为 app.zone 的 value,但如果 key 不存在,就使用默认值 Z。

还可以把注入的注解写到方法参数中:

@Bean
ZoneId createZoneId(@Value("${app.zone:Z}") String zoneId) {
    return ZoneId.of(zoneId);
}

另一种注入配置的方式是先通过一个简单的 JavaBean 持有所有的配置:

@Component
public class SmtpConfig {
    @Value("${smtp.host}")
    private String host;

    @Value("${smtp.port:25}")
    private int port;

    public String getHost() {
        return host;
    }

    public int getPort() {
        return port;
    }
}

然后在需要读取的地方,使用 #{smtpConfig.host} 注入:

@Component
public class MailService {
    @Value("#{smtpConfig.host}")
    private String smtpHost;

    @Value("#{smtpConfig.port}")
    private int smtpPort;
}

#{} 注入语法和 ${key} 不同的是,#{} 表示从 JavaBean 读取属性。"#{smtpConfig.host}" 的意思是从名称为 smtpConfig 的 Bean 读取 host 属性,即调用 getHost() 方法。 SmtpConfig Bean 在 Spring 容器中的默认名称就是 smtpConfig,除非用 @Qualifier 指定了名称。

使用一个独立的 JavaBean 持有所有属性,然后在其他 Bean 中以 #{bean.property} 注入的好处是,多个 Bean 都可以引用同一个 Bean 的某个属性。如果 SmtpConfig 决定从数据库中读取相关配置项,那么 MailService 注入的 @Value("#{smtpConfig.host}") 仍然可以不修改正常运行。

使用条件装配

Spring 为应用程序准备了 Profile 用来表示不同的环境。创建某个 Bean 时,Spring 容器可以根据注解 @Profile 来决定是否创建。

@Configuration
@ComponentScan
public class AppConfig {
    @Bean
    @Profile("!test")
    ZoneId createZoneId() {
        return ZoneId.systemDefault();
    }

    @Bean
    @Profile("test")
    ZoneId createZoneIdForTest() {
        return ZoneId.of("America/New_York");
    }
}

如果当前的 Profile 设置为 test,则 Spring 容器会调用 createZoneIdForTest() 创建 ZoneId,否则调用 createZoneId() 创建 ZoneId。@Profile("!test") 表示非 test 环境。

在运行程序时,加上 JVM 参数 --spring.profiles.active=test 就可以指定以 test 环境启动。实际上 Spring 允许指定多个 Profile,例如:
-spring.profiles.active=test,master,可以表示 test 环境,并使用 master 分支代码。

要满足多个 Profile 条件,可以这样写:

@Bean
@Profile({ "test", "master" }) // 满足 test 或 master
ZoneId createZoneId() {
    ...
}

使用 Conditional

Spring 还可以根据 @Conditional 决定是否创建某个 Bean。

@Component
@Conditional(OnSmtpEnvCondition.class)
public class SmtpMailService implements MailService {
    ...
}

如果满足 OnSmtpEnvCondition 的条件,才会创建 SmtpMailService 这个 Bean。

public class OnSmtpEnvCondition implements Condition {
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        return "true".equalsIgnoreCase(System.getenv("smtp"));
    }
}

OnSmtpEnvCondition 的条件是存在环境变量 smtp 值为 true。这样我们就可以通过环境变量来控制是否创建 SmtpMailService。Spring 只提供了 @Conditional 注解,具体判断逻辑还需要我们自己实现。Spring Boot 提供了更多使用起来更简单的条件注解,例如如果配置文件中存在 app.smtp=true,则创建 MailService:

@Component
@ConditionalOnProperty(name="app.smtp", havingValue="true")
public class MailService {
    ...
}

如果当前 classpath 中存在类 javax.mail.Transport,则创建 MailService:

@Component
@ConditionalOnClass(name = "javax.mail.Transport")
public class MailService {
    ...
}

Spring AOP

AOP,Aspect Oriented Programming,即面向切面编程。AOP 把系统分解为不同的关注点,或者称之为切面 Aspect 。Spring 的 AOP 实现是基于 JVM 的动态代理,让我们把一些常用功能如权限检查、日志、事务等从每个业务方法中剥离出来。

在Java平台上,对于AOP的织入有3种方式:

  • 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ 就扩展了 Java 编译器,使用关键字 aspect 来实现织入;
  • 类加载器:在目标类被装载到 JVM 时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  • 运行期:目标对象和切面都是普通 Java 类,通过 JVM 的动态代理功能或者第三方库实现运行期动态织入。

最简单的方式是第三种,Spring 的 AOP 实现就是基于 JVM 的动态代理。

AOP 对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数也是固定的。另一些特定问题,如日志就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用 AOP 实现日志,我们只能输出固定格式的日志,因此使用 AOP 时必须适合特定的场景。

通过 Maven 引入 Spring 对 AOP 的支持:org.springframework:spring-aspects:6.0.0 。此依赖会自动引入 AspectJ,使用 AspectJ 实现 AOP 。

@Aspect
@Component
public class LoggingAspect {
    // 在执行 UserService 的每个方法前执行:
    @Before("execution(public * com.example.demo.service.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }

    // 在执行 MailService 的每个方法前后执行:
    @Around("execution(public * com.example.demo.service.MailService.*(..))")
    public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
        System.err.println("[Around] start " + pjp.getSignature());
        Object retVal = pjp.proceed();
        System.err.println("[Around] done " + pjp.getSignature());
        return retVal;
    }
}

在 LoggingAspect 类的声明处,除了用 @Component 表示它本身也是一个 Bean 外,我们再加上 @Aspect 注解,表示它的 @Before 标注的方法需要注入到 UserService 的每个 public 方法执行前,@Around 标注的方法需要注入到 MailService 的每个 public 方法执行前后。

紧接着我们需要给 @Configuration 类加上一个 @EnableAspectJAutoProxy 注解:

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
    ...
}

Spring 的 IoC 容器看到这个注解,就会自动查找带有 @Aspect 的 Bean,然后根据每个方法的 @Before@Around 等注解把 AOP 注入到特定的 Bean 中。

使用 AOP 非常简单,一共需要三步:定义执行方法,并在方法上通过 AspectJ 的注解告诉 Spring 应该在何处调用此方法;标记 @Component@Aspect;在 @Configuration 类上标注 @EnableAspectJAutoProxy

拦截器类型

拦截器有以下类型:

  • @Before:先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
  • @After:先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
  • @AfterReturning:和 @After 不同的是,只有当目标代码正常返回时,才执行拦截器代码;
  • @AfterThrowing:和 @After 不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

使用注解装配 AOP

使用 AspectJ 的注解并配合一个复杂的 execution(* xxx.Xyz.*(..)) 语法来定义应该如何装配 AOP的写法其实很少使用。我们在使用 AOP 时,虽然 Spring 容器可以把指定的方法通过 AOP 规则装配到指定的 Bean 的指定方法前后,但是自动装配会因为不恰当的范围容易导致意想不到的结果,即很多不需要 AOP 代理的 Bean 也被自动代理了,并且后续新增的 Bean,如果不清楚现有的 AOP 装配规则,容易被强迫装配。

装配 AOP 的时候,使用注解是最好的方式。通过 @Transactional,某个方法是否启用了事务就一清二楚了。

@Component
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }

    // 无事务:
    public boolean isValidName(String name) {
        ...
    }
}

或者直接在 class 级别注解,表示所有 public 方法都启用了事务:

@Component
@Transactional
public class UserService {
    ...
}

自定义注解:

@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
    String value();
}

在需要被监控的关键方法上标注该注解:

@Component
public class UserService {
    // 监控 register() 方法性能:
    @MetricTime("register")
    public User register(String email, String password, String name) {
        ...
    }
    ...
}

然后我们定义 MetricAspect:

@Aspect
@Component
public class MetricAspect {
    @Around("@annotation(metricTime)")
    public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
        String name = metricTime.value();
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long t = System.currentTimeMillis() - start;
            // 写入日志或发送至JMX:
            System.err.println("[Metrics] " + name + ": " + t + "ms");
        }
    }
}

metric() 方法标注了 @Around("@annotation(metricTime)"),它的意思是符合条件的目标方法是带有 @MetricTime 注解的方法。 metric() 方法参数类型是 MetricTime,我们通过它获取性能监控的名称。有了 @MetricTime 注解,再配合 MetricAspect,任何 Bean只要方法标注了@MetricTime 注解,就可以自动实现性能监控。

AOP 避坑指南

无论是使用 AspectJ 语法,还是配合 Annotation,使用 AOP 实际上就是让 Spring 自动为我们创建一个 Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此 AOP 本质上就是一个代理模式。正确使用 AOP,我们需要一个避坑指南:访问被注入的 Bean 时,总是调用方法而非直接访问字段;编写 Bean 时,如果可能会被代理,就不要编写 public final 方法。这样才能保证有没有 AOP,代码都能正常工作。

Spring 访问数据库

Java 程序访问数据库的标准接口 JDBC 的实现方式非常简洁,即:Java 标准库定义接口,各数据库厂商以“驱动”的形式实现接口。应用程序要使用哪个数据库,就把该数据库厂商的驱动以 jar 包形式引入进来,同时自身仅使用 JDBC 接口,编译期并不需要特定厂商的驱动。

使用 JDBC 虽然简单,但代码比较繁琐。Spring 为了简化数据库访问,主要做了以下几点工作:提供了简化的访问 JDBC 的模板类,不必手动释放资源;提供了一个统一的 DAO 类以实现 Data Access Object 模式;把 SQLException 封装为 DataAccessException,这个异常是一个 RuntimeException,并且让我们能区分 SQL 异常的原因,例如 DuplicateKeyException 表示违反了一个唯一约束;能方便地集成 Hibernate、JPA 和 MyBatis 这些数据库访问框架。

使用 JDBC

在 Spring 中使用 JDBC,首先通过 IoC 容器创建并管理一个 DataSource 实例,然后使用 Spring 提供的 JdbcTemplate 操作 JDBC。

在 AppConfig 中,我们需要创建以下几个必须的 Bean:

@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {

    @Value("${jdbc.url}")
    String jdbcUrl;

    @Value("${jdbc.username}")
    String jdbcUsername;

    @Value("${jdbc.password}")
    String jdbcPassword;

    @Bean
    DataSource createDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(jdbcUrl);
        config.setUsername(jdbcUsername);
        config.setPassword(jdbcPassword);
        config.addDataSourceProperty("autoCommit", "true");
        config.addDataSourceProperty("connectionTimeout", "5");
        config.addDataSourceProperty("idleTimeout", "60");
        return new HikariDataSource(config);
    }

    @Bean
    JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

我们只需要在需要访问数据库的 Bean 中,注入 JdbcTemplate 即可:

@Component
public class UserService {
    @Autowired
    JdbcTemplate jdbcTemplate;
    ...
}

Spring 提供的 JdbcTemplate 采用 Template 模式,提供了一系列以回调为特点的工具方法,目的是避免繁琐的 try...catch 语句。

T execute(ConnectionCallback<T> action) 传入的回调方法提供了 Jdbc 的 Connection 供我们使用:

public User getUserById(long id) {
    // 注意传入的是 ConnectionCallback:
    return jdbcTemplate.execute((Connection conn) -> {
        // 可以直接使用 conn 实例,不要释放它,回调结束后 JdbcTemplate 自动释放:
        // 在内部手动创建的 PreparedStatement、ResultSet 必须用 try(...) 释放:
        try (var ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
            ps.setObject(1, id);
            try (var rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new User( 
                            rs.getLong("id"),
                            rs.getString("email"),
                            rs.getString("password"),
                            rs.getString("name"));
                }
                throw new RuntimeException("user not found by id.");
            }
        }
    });
}

T execute(String sql, PreparedStatementCallback<T> action) 的用法:

public User getUserByName(String name) {
    // 需要传入 SQL 语句,以及 PreparedStatementCallback:
    return jdbcTemplate.execute("SELECT * FROM users WHERE name = ?", (PreparedStatement ps) -> {
        // PreparedStatement 实例已经由 JdbcTemplate 创建,并在回调后自动释放:
        ps.setObject(1, name);
        try (var rs = ps.executeQuery()) {
            if (rs.next()) {
                return new User( 
                        rs.getLong("id"),
                        rs.getString("email"),
                        rs.getString("password"),
                        rs.getString("name"));
            }
            throw new RuntimeException("user not found by id.");
        }
    });
}

T queryForObject(String sql, RowMapper<T> rowMapper, Object... args) 用法:

public User getUserByEmail(String email) {
    // 传入 SQL,参数和 RowMapper 实例:
    return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email = ?",
            (ResultSet rs, int rowNum) -> {
                // 将 ResultSet 的当前行映射为一个 JavaBean:
                return new User( 
                        rs.getLong("id"),
                        rs.getString("email"),
                        rs.getString("password"),
                        rs.getString("name"));
            },
            email);
}

queryForObject() 方法中,传入 SQL 以及 SQL 参数后,JdbcTemplate 会自动创建 PreparedStatement,自动执行查询并返回 ResultSet,我们提供的 RowMapper 需要做的事情就是把 ResultSet 的当前行映射成一个 JavaBean 并返回。整个过程中,使用 Connection、PreparedStatement 和 ResultSet 都不需要我们手动管理。

RowMapper 不一定返回 JavaBean,实际上它可以返回任何 Java 对象。

public long getUsers() {
    return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", (ResultSet rs, int rowNum) -> {
        // SELECT COUNT(*) 查询只有一列,取第一列数据:
        return rs.getLong(1);
    });
}

如果我们期望返回多行记录而不是一行,可以用 query() 方法:

public List<User> getUsers(int pageIndex) {
    int limit = 100;
    int offset = limit * (pageIndex - 1);
    return jdbcTemplate.query("SELECT * FROM users LIMIT ? OFFSET ?",
            new BeanPropertyRowMapper<>(User.class),
            limit, offset);
}

如果数据库表的结构恰好和 JavaBean 的属性名称一致,那么 Spring 提供的 BeanPropertyRowMapper 就可以直接把记录按列名转换为 JavaBean。

如果我们执行的不是查询,而是插入、更新和删除操作,那么需要使用 update() 方法:

public void updateUser(User user) {
    // 传入 SQL,SQL 参数,返回更新的行数:
    if (1 != jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId())) {
        throw new RuntimeException("User not found by id");
    }
}

INSERT 操作获取插入后的自增值。JdbcTemplate 提供了一个 KeyHolder 来简化这一操作:

public User register(String email, String password, String name) {
    // 创建一个 KeyHolder:
    KeyHolder holder = new GeneratedKeyHolder();
    if (1 != jdbcTemplate.update(
        // 参数1: PreparedStatementCreator
        (conn) -> {
            // 创建PreparedStatement时,必须指定RETURN_GENERATED_KEYS:
            var ps = conn.prepareStatement("INSERT INTO users(email, password, name) VALUES(?, ?, ?)",
                    Statement.RETURN_GENERATED_KEYS);
            ps.setObject(1, email);
            ps.setObject(2, password);
            ps.setObject(3, name);
            return ps;
        },
        // 参数2: KeyHolder
        holder)
    ) {
        throw new RuntimeException("Insert failed.");
    }
    // 从 KeyHolder 中获取返回的自增值:
    return new User(holder.getKey().longValue(), email, password, name);
}

JdbcTemplate 只是对 JDBC 操作的一个简单封装,它的目的是尽量减少手动编写 try(resource) {...} 的代码,对于查询主要通过 RowMapper 实现了 JDBC 结果集到 Java 对象的转换。

总结一下 JdbcTemplate 的用法:针对简单查询,优选 query()queryForObject();针对更新操作,优选 update();任何复杂的操作,最终也可以通过 execute(ConnectionCallback) 实现。

实际上我们使用最多的仍然是各种查询。如果表结构和 JavaBean 的属性一一对应,那么直接使用 BeanPropertyRowMapper 就很方便。如果表结构和 JavaBean 不一致,就需要稍微改写一下查询,使结果集的结构和 JavaBean 保持一致。例如表的列名是 office_address,而 JavaBean 属性是 workAddress,就需要指定别名改写查询如下:SELECT id, email, office_address AS workAddress, name FROM users WHERE email = ?

使用声明式事务

Spring 提供了高级接口来操作事务。Spring 提供了 PlatformTransactionManager 来表示事务管理器,所有的事务都由它负责管理。而事务由 TransactionStatus 表示。如果手写事务代码,使用 try...catch 如下:

TransactionStatus tx = null;
try {
    // 开启事务:
    tx = txManager.getTransaction(new DefaultTransactionDefinition());
    // 相关 JDBC 操作:
    jdbcTemplate.update("...");
    jdbcTemplate.update("...");
    // 提交事务:
    txManager.commit(tx);
} catch (RuntimeException e) {
    // 回滚事务:
    txManager.rollback(tx);
    throw e;
}

Spring 抽象出 PlatformTransactionManager 和 TransactionStatus 的原因是 JavaEE 除了提供 JDBC 事务外,它还支持分布式事务 JTA,Java Transaction API 。分布式事务是指多个数据源(比如多个数据库,多个消息系统)要在分布式环境下实现事务。分布式事务实现起来非常复杂,简单地说就是通过一个分布式事务管理器实现两阶段提交,但本身数据库事务就不快,基于数据库事务实现的分布式事务就慢得难以忍受,所以使用率不高。

Spring 为了同时支持 JDBC 和 JTA 两种事务模型,就抽象出 PlatformTransactionManager。因为我们的代码只需要 JDBC 事务,因此在 AppConfig 中,需要再定义一个 PlatformTransactionManager 对应的 Bean,它的实际类型是 DataSourceTransactionManager:

@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {
    ...
    @Bean
    PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

使用编程的方式使用 Spring 事务仍然比较繁琐,更好的方式是通过声明式事务来实现。使用声明式事务非常简单,除了在 AppConfig 中追加一个上述定义的 PlatformTransactionManager 外,再加一个 @EnableTransactionManagement 就可以启用声明式事务:

@Configuration
@ComponentScan
@EnableTransactionManagement // 启用声明式
@PropertySource("jdbc.properties")
public class AppConfig {
    ...
}

对需要事务支持的方法,加一个 @Transactional 注解:

@Component
public class UserService {
    // 此public方法自动具有事务支持:
    @Transactional
    public User register(String email, String password, String name) {
       ...
    }
}

或者更简单一点,直接在 Bean 的 class 处加上,表示所有 public 方法都具有事务支持:

@Component
@Transactional
public class UserService {
    ...
}

Spring 对声明式事务开启事务支持的原理仍然是 AOP 代理,即通过自动创建 Bean 的 Proxy 实现。声明了 @EnableTransactionManagement 后,不必额外添加 @EnableAspectJAutoProxy。

默认情况下如果发生了 RuntimeException,Spring 的声明式事务将自动回滚。在一个事务方法中,如果程序判断需要回滚事务,只需抛出 RuntimeException。

@Transactional
public buyProducts(long productId, int num) {
    ...
    if (store < num) {
        // 库存不够,购买失败:
        throw new IllegalArgumentException("No enough products");
    }
    ...
}

如果要针对在编译时必须被显式处理的异常类型 Checked Exception 回滚事务,需要在 @Transactional 注解中写出来:

@Transactional(rollbackFor = {RuntimeException.class, IOException.class})
public buyProducts(long productId, int num) throws IOException {
    ...
}

上述代码表示在抛出 RuntimeException 或 IOException 时,事务将回滚。强烈建议业务异常体系从 RuntimeException 派生,这样就不必声明任何特殊异常即可让 Spring 的声明式事务正常工作:

在使用事务的时候,明确事务边界非常重要。对于声明式事务,例如下面的 register() 方法的事务边界就是 register() 方法开始和结束。

@Component
public class UserService {
    @Transactional
    public User register(String email, String password, String name) { // 事务开始
       ...
    } // 事务结束
}
事务传播

在Java中,‌事务传播是指当一个事务方法被另一个事务方法调用时,事务如何在不同方法间传递和协调的规则。Spring 的声明式事务为事务传播定义了几个级别,默认传播级别就是 REQUIRED,它的意思是如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。Spring 使用声明式事务,最终也是通过执行 JDBC 事务来实现功能的。Spring 总是把 JDBC 相关的 Connection 和 TransactionStatus 实例绑定到 ThreadLocal。如果一个事务方法从 ThreadLocal 未取到事务,那么它会打开一个新的 JDBC 连接,同时开启一个新的事务,否则它就直接使用从 ThreadLocal 获取的 JDBC 连接以及 TransactionStatus。

因此事务能正确传播的前提是,方法调用是在一个线程内才行。

@Transactional
public User register(String email, String password, String name) { // BEGIN TX-A
    User user = jdbcTemplate.insert("...");
    new Thread(() -> {
        // BEGIN TX-B:
        bonusService.addBonus(user.id, 100);
        // END TX-B
    }).start();
} // END TX-A

在另一个线程中调用 BonusService.addBonus(),它根本获取不到当前事务,因此 UserService.register()BonusService.addBonus() 两个方法,将分别开启两个完全独立的事务。换句话说,事务只能在当前线程传播,无法跨线程传播。

DAO

在传统的多层应用程序中,通常是 Web 层调用业务层,业务层调用数据访问层。业务层负责处理各种业务逻辑,而数据访问层只负责对数据进行增删改查。实现数据访问层就是用 JdbcTemplate 实现对数据库的操作。编写数据访问层的时候,可以使用 DAO 模式。DAO 即 Data Access Object ,实现起来基本如下:

public class UserDao {

    @Autowired
    JdbcTemplate jdbcTemplate;

    User getById(long id) {
        ...
    }

    List<User> getUsers(int page) {
        ...
    }

    User createUser(User user) {
        ...
    }

    User updateUser(User user) {
        ...
    }

    void deleteUser(User user) {
        ...
    }
}

Spring 提供了一个 JdbcDaoSupport 类,用于简化 DAO 的实现。这个 JdbcDaoSupport 核心代码如下:

public abstract class JdbcDaoSupport extends DaoSupport {

    private JdbcTemplate jdbcTemplate;

    public final void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
        initTemplateConfig();
    }

    public final JdbcTemplate getJdbcTemplate() {
        return this.jdbcTemplate;
    }

    ...
}

子类从 JdbcDaoSupport 继承后,可以随时调用 getJdbcTemplate() 获得 JdbcTemplate 的实例。因为 JdbcDaoSupport 的 jdbcTemplate 字段没有标记 @Autowired,所以子类想要注入 JdbcTemplate,可以编写一个 AbstractDao,专门负责注入 JdbcTemplate:

public abstract class AbstractDao extends JdbcDaoSupport {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @PostConstruct
    public void init() {
        super.setJdbcTemplate(jdbcTemplate);
    }
}

子类从 AbstractDao 继承,可以直接调用 getJdbcTemplate()

@Component
@Transactional
public class UserDao extends AbstractDao {
    public User getById(long id) {
        return getJdbcTemplate().queryForObject(
                "SELECT * FROM users WHERE id = ?",
                new BeanPropertyRowMapper<>(User.class),
                id
        );
    }
    ...
}

也可以把 AbstractDao 改成泛型,并实现 getById()getAll()deleteById() 这样的通用方法:

public abstract class AbstractDao<T> extends JdbcDaoSupport {
    private String table;
    private Class<T> entityClass;
    private RowMapper<T> rowMapper;

    public AbstractDao() {
        // 获取当前类型的泛型类型:
        this.entityClass = getParameterizedType();
        this.table = this.entityClass.getSimpleName().toLowerCase() + "s";
        this.rowMapper = new BeanPropertyRowMapper<>(entityClass);
    }

    public T getById(long id) {
        return getJdbcTemplate().queryForObject("SELECT * FROM " + table + " WHERE id = ?", this.rowMapper, id);
    }

    public List<T> getAll(int pageIndex) {
        int limit = 100;
        int offset = limit * (pageIndex - 1);
        return getJdbcTemplate().query("SELECT * FROM " + table + " LIMIT ? OFFSET ?",
                new Object[] { limit, offset },
                this.rowMapper);
    }

    public void deleteById(long id) {
        getJdbcTemplate().update("DELETE FROM " + table + " WHERE id = ?", id);
    }
    ...
}

这样每个子类就自动获得了这些通用方法。DAO 模式是一个简单的数据访问模式,是否使用 DAO,根据实际情况决定。因为很多时候,直接在 Service 层操作数据库也是完全没有问题的。

集成 Hibernate

把关系数据库的表记录映射为 Java 对象的过程是 ORM:Object-Relational Mapping。ORM 既可以把记录转换成 Java 对象,也可以把 Java 对象转换为行记录。使用 JdbcTemplate 配合 RowMapper 可以看作是最原始的 ORM。如果要实现更自动化的 ORM,可以选择成熟的 ORM 框架,例如 Hibernate。

Hibernate 作为 ORM 框架,它可以替代 JdbcTemplate,但 Hibernate 仍然需要 JDBC 驱动,所以我们需要引入 JDBC 驱动、连接池以及 Hibernate。在 AppConfig 中,我们仍然需要创建 DataSource、引入 JDBC 配置文件以及启用声明式事务。为了启用 Hibernate,我们需要创建 LocalSessionFactoryBean 和 PlatformTransactionManager:

@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
    @Bean
    DataSource createDataSource() {
        ...
    }

    @Bean
    LocalSessionFactoryBean createSessionFactory(@Autowired DataSource dataSource) {
        var props = new Properties();
        props.setProperty("hibernate.hbm2ddl.auto", "update"); // 自动创建数据库的表结构,生产环境不要使用
        props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect"); // 指示 Hibernate 使用的数据库是 HSQLDB
        props.setProperty("hibernate.show_sql", "true"); //  Hibernate 打印执行的 SQL,对于调试非常有用
        var sessionFactoryBean = new LocalSessionFactoryBean();
        sessionFactoryBean.setDataSource(dataSource);
        // 扫描指定的 package 获取所有 entity class:
        sessionFactoryBean.setPackagesToScan("com.example.demo.entity");
        sessionFactoryBean.setHibernateProperties(props);
        return sessionFactoryBean;
    }

    @Bean
    PlatformTransactionManager createTxManager(@Autowired SessionFactory sessionFactory) {
        return new HibernateTransactionManager(sessionFactory);
    }
}

LocalSessionFactoryBean 是一个 FactoryBean,它会再自动创建一个 SessionFactory,在 Hibernate 中,Session 是封装了一个 JDBC Connection 的实例,而 SessionFactory 是封装了 JDBC DataSource 的实例,即 SessionFactory 持有连接池。每次需要操作数据库的时候,SessionFactory 创建一个新的 Session,相当于从连接池获取到一个新的 Connection。SessionFactory 是 Hibernate 提供的最核心的一个对象,LocalSessionFactoryBean 是 Spring 提供的为了让我们方便创建 SessionFactory 的类。

除了设置 DataSource 和 Properties 之外,setPackagesToScan() 传入了一个 package 名称,它指示 Hibernate 扫描这个包下面的所有 Java 类,自动找出能映射为数据库表记录的 JavaBean。

HibernateTransactionManager 是配合 Hibernate 使用声明式事务所必须的。

有如下的数据库表:

CREATE TABLE user
    id BIGINT NOT NULL AUTO_INCREMENT,
    email VARCHAR(100) NOT NULL,
    password VARCHAR(100) NOT NULL,
    name VARCHAR(100) NOT NULL,
    createdAt BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `email` (`email`)
);

我们需要添加一些注解来告诉 Hibernate 如何把 User 类映射到表记录

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(nullable = false, updatable = false)
    public Long getId() { ... }

    @Column(nullable = false, unique = true, length = 100)
    public String getEmail() { ... }

    @Column(nullable = false, length = 100)
    public String getPassword() { ... }

    @Column(nullable = false, length = 100)
    public String getName() { ... }

    @Column(nullable = false, updatable = false)
    public Long getCreatedAt() { ... }
}

如果一个 JavaBean 被用于映射,我们就标记一个 @Entity。默认情况下,映射的表名是 user。如果实际的表名不同,例如实际表名是 users,可以追加一个@Table(name="users") 表示。每个属性到数据库列的映射用 @Column() 标识,nullable 指示列是否允许为 NULL,updatable 指示该列是否允许被用在 UPDATE 语句,length 指示 String 类型的列的长度(如果没有指定,默认是 255)。对于主键,还需要用 @Id 标识,自增主键再追加一个 @GeneratedValue ,以便 Hibernate 能读取到自增主键的值。主键 id 定义的类型不是 long,而是 Long。这是因为 Hibernate 如果检测到主键为 null,就不会在 INSERT 语句中指定主键的值,而是返回由数据库生成的自增值,否则 Hibernate 认为我们的程序指定了主键的值,会在 INSERT 语句中直接列出。long 型字段总是具有默认值 0,因此每次插入的主键值总是 0,导致除第一次外后续插入都将失败。作为映射使用的 JavaBean,所有属性都使用包装类型而不是基本类型。

我们可以把 id、createdAt 通用字段提到一个抽象类中:

@MappedSuperclass
public abstract class AbstractEntity {

    private Long id;
    private Long createdAt;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(nullable = false, updatable = false)
    public Long getId() { ... }

    @Column(nullable = false, updatable = false)
    public Long getCreatedAt() { ... }

    @Transient
    public ZonedDateTime getCreatedDateTime() {
        return Instant.ofEpochMilli(this.createdAt).atZone(ZoneId.systemDefault());
    }

    @PrePersist
    public void preInsert() {
        setCreatedAt(System.currentTimeMillis());
    }
}

对于 AbstractEntity 来说,我们要标注一个 @MappedSuperclass 表示它用于继承。@Transient 标识的方法返回一个“虚拟”的属性。因为 getCreatedDateTime() 是计算得出的属性,而不是从数据库表读出的值,因此必须要标注 @Transient,否则 Hibernate 会尝试从数据库读取名为 createdDateTime 这个不存在的字段从而出错。@PrePersist 标识的方法表示 JavaBean 持久化到数据库之前,Hibernate 会先执行该方法,这样我们就可以自动设置好 createdAt 属性。

有了 AbstractEntity,我们就可以大幅简化 User。类似 User、Book 这样的用于 ORM 的 Java Bean,我们通常称之为 Entity Bean。

@Entity
public class User extends AbstractEntity {

    @Column(nullable = false, unique = true, length = 100)
    public String getEmail() { ... }

    @Column(nullable = false, length = 100)
    public String getPassword() { ... }

    @Column(nullable = false, length = 100)
    public String getName() { ... }
}

使用了 Hibernate,对 user 表进行增删改查实际上是对 User 这个 JavaBean 进行“增删改查”。我们编写一个 UserService,注入 SessionFactory。要持久化一个 User 实例,我们只需调用 persist() 方法。删除一个 User 相当于从表中删除对应的记录。注意 Hibernate 总是用 id 来删除记录,因此要正确设置 User 的 id 属性才能正常删除记录。
通过主键删除记录时,一个常见的用法是先根据主键加载该记录再删除。注意到当记录不存在时,load() 返回 null。更新记录相当于先更新 User 的指定属性,然后调用 merge() 方法。

@Component
@Transactional
public class UserService {
    @Autowired
    SessionFactory sessionFactory;

    public User register(String email, String password, String name) {
        // 创建一个 User 对象:
        User user = new User();
        // 设置好各个属性:
        user.setEmail(email);
        user.setPassword(password);
        user.setName(name);
        // 不要设置 id,因为使用了自增主键
        // 保存到数据库:
        sessionFactory.getCurrentSession().persist(user);
        // 现在已经自动获得了id:
        System.out.println(user.getId());
        return user;
   }

    public boolean deleteUser(Long id) {
        User user = sessionFactory.getCurrentSession().byId(User.class).load(id);
        if (user != null) {
            sessionFactory.getCurrentSession().remove(user);
            return true;
        }
        return false;
    }

    public void updateUser(Long id, String name) {
        User user = sessionFactory.getCurrentSession().byId(User.class).load(id);
        user.setName(name);
        sessionFactory.getCurrentSession().merge(user);
    }
}

我们在定义 User 时,对有的属性标注了 @Column(updatable=false)。Hibernate 在更新记录时,它只会把 @Column(updatable=true) 的属性加入到 UPDATE 语句中,这样可以提供一层额外的安全性,即如果不小心修改了 User 的 email、createdAt 等属性,执行 update() 时并不会更新对应的数据库列。这个功能是 Hibernate 提供的,如果绕过 Hibernate 直接通过 JDBC 执行 UPDATE 语句仍然可以更新数据库的任意列的值。

使用 HQL 查询

一种常用的查询是直接编写 Hibernate 内置的 HQL 查询:

List<User> list = sessionFactory.getCurrentSession()
        .createQuery("from User u where u.email = ?1 and u.password = ?2", User.class)
        .setParameter(1, email).setParameter(2, password)
        .list();

除了可以直接传入 HQL 字符串外,Hibernate 还可以使用一种 NamedQuery。它给查询起个名字,然后保存在注解中。使用 NamedQuery 时,我们要先在 User 类标注:

@NamedQueries(
    @NamedQuery(
        name = "login", // 查询名称:
        query = "SELECT u FROM User u WHERE u.email = :e AND u.password = :pwd" // 查询语句:
    )
)

@Entity
public class User extends AbstractEntity {
    ...
}

注意到引入的 NamedQuery 是 jakarta.persistence.NamedQuery,它和直接传入 HQL 有点不同的是,占位符使用 :e:pwd。使用 NamedQuery 只需要引入查询名和参数:

public User login(String email, String password) {
    List<User> list = sessionFactory.getCurrentSession()
        .createNamedQuery("login", User.class) // 创建 NamedQuery
        .setParameter("e", email) // 绑定 e 参数
        .setParameter("pwd", password) // 绑定 pwd 参数
        .list();
    return list.isEmpty() ? null : list.get(0);
}

直接写 HQL 和使用 NamedQuery 各有优劣。前者可以在代码中直观地看到查询语句,后者可以在 User 类统一管理所有相关查询。

集成 JPA

Java Persistence API,JPA 是 JavaEE 的一个 ORM 标准,是接口。使用 JPA 时可以选择 Hibernate 作为底层实现,也可以选择其它的 JPA 提供方。Hibernate 既提供了它自己的接口,也提供了 JPA 接口,我们用 JPA 接口就相当于通过 JPA 操作 Hibernate。

在 AppConfig 中启用声明式事务管理,创建 DataSource。创建 LocalContainerEntityManagerFactoryBean,注入 DataSource,设定自动扫描的 package,指定 JPA 的提供商,并让它再自动创建 EntityManagerFactory。以 Properties 的形式注入 Hibernate 需要的配置。实例化 JpaTransactionManager 以实现声明式事务。

@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
    @Bean
    DataSource createDataSource() { ... }

    @Bean
    public LocalContainerEntityManagerFactoryBean createEntityManagerFactory(@Autowired DataSource dataSource) {
        var emFactory = new LocalContainerEntityManagerFactoryBean();
        // 注入 DataSource:
        emFactory.setDataSource(dataSource);
        // 扫描指定的 package 获取所有 entity class:
        emFactory.setPackagesToScan(AbstractEntity.class.getPackageName());
        // 使用 Hibernate 作为 JPA 实现:
        emFactory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        // 其他配置项:
        var props = new Properties();
        props.setProperty("hibernate.hbm2ddl.auto", "update"); // 生产环境不要使用
        props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
        props.setProperty("hibernate.show_sql", "true");
        emFactory.setJpaProperties(props);
        return emFactory;
    }

    @Bean
    PlatformTransactionManager createTxManager(@Autowired EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }
}

为 UserService 为注入 EntityManager,但是不使用 Autowired,而是 @PersistenceContext

@Component
@Transactional
public class UserService {
    @PersistenceContext
    EntityManager em;
}

Spring 遇到标注了 @PersistenceContext 的 EntityManager 会自动注入代理,该代理会在必要的时候自动打开 EntityManager。多线程引用的 EntityManager 虽然是同一个代理类,但该代理类内部针对不同线程会创建不同的 EntityManager 实例。标注了 @PersistenceContext 的 EntityManager 可以被多线程安全地共享。

在 UserService 的每个业务方法里,直接使用 EntityManager 就很方便。

public User getUserById(long id) {
    User user = this.em.find(User.class, id);
    if (user == null) {
        throw new RuntimeException("User not found by id: " + id);
    }
    return user;
}

与 HQL 查询类似,JPA 使用 JPQL 查询,它的语法和 HQL 基本差不多:

public User fetchUserByEmail(String email) {
    // JPQL 查询:
    TypedQuery<User> query = em.createQuery("SELECT u FROM User u WHERE u.email = :e", User.class);
    query.setParameter("e", email);
    List<User> list = query.getResultList();
    if (list.isEmpty()) {
        return null;
    }
    return list.get(0);
}

JPA 也支持 NamedQuery,即先给查询起个名字,再按名字创建查询:

public User login(String email, String password) {
    TypedQuery<User> query = em.createNamedQuery("login", User.class);
    query.setParameter("e", email);
    query.setParameter("pwd", password);
    List<User> list = query.getResultList();
    return list.isEmpty() ? null : list.get(0);
}

对数据库进行增删改的操作,可以分别使用 persist()、remove() 和 merge() 方法。

集成 MyBatis

使用 Hibernate 或 JPA 操作数据库时,这类 ORM 的主要工作就是把 ResultSet 的每一行变成 Java Bean,或者把 Java Bean 自动转换到 INSERT 或 UPDATE 语句的参数中,从而实现 ORM。而 ORM 框架之所以知道如何把行数据映射到 Java Bean,是因为我们在 Java Bean 的属性上给了足够的注解作为元数据,ORM 框架获取 Java Bean 的注解后,就知道如何进行双向映射。

ORM 框架使用 Proxy 模式跟踪 Java Bean 的修改,以便在 update() 操作中更新必要的属性。从 ORM 框架读取的 User 实例实际上并不是 User 类,而是代理类。代理类继承自 User 类,但针对每个 setter 方法做了覆写,这样代理类可以跟踪到每个属性的变化。针对一对多或多对一关系时,代理类可以直接通过 getter 方法查询数据库。

为了实现这样的查询,UserProxy 必须保存 Hibernate 的当前 Session。但是当事务提交后,Session 自动关闭,此时再获取对象属性将无法访问数据库,或者获取的不是事务一致的数据。因此 ORM 框架总是引入了 Attached/Detached 状态,表示当前此 Java Bean 到底是在 Session 的范围内,还是脱离了 Session 变成了一个“游离”对象。这种隐式状态使得普通 Java Bean 的生命周期变得复杂。

此外 Hibernate 和 JPA 为了实现兼容多种数据库,它使用 HQL 或 JPQL 查询,经过一道转换,变成特定数据库的 SQL,理论上这样可以做到无缝切换数据库,但这一层自动转换除了少许的性能开销外,给 SQL 级别的优化也带来了麻烦。

ORM 框架通常提供了缓存,并且还分为一级缓存和二级缓存。一级缓存是指在一个 Session 范围内的缓存,常见的情景是根据主键查询时,两次查询可以返回同一实例。二级缓存是指跨 Session 的缓存,一般默认关闭,需要手动配置。二级缓存极大的增加了数据的不一致性,原因在于 SQL 非常灵活,常常会导致意外的更新。

我们把这种 ORM 框架称之为全自动 ORM 框架。

对比 Spring 提供的 JdbcTemplate,它和 ORM 框架相比,主要有几点差别:查询后需要手动提供 Mapper 实例以便把 ResultSet 的每一行变为 Java 对象;增删改操作所需的参数列表,需要手动传入,即把 User 实例变为 [user.id, user.name, user.email] 这样的列表,比较麻烦。但是 JdbcTemplate 的优势在于它的确定性:即每次读取操作一定是数据库操作而不是缓存,所执行的 SQL 是完全确定的,缺点就是代码比较繁琐,构造 INSERT INTO users VALUES (?,?,?) 更是复杂。

所以介于全自动 ORM 如 Hibernate 和手写全部如 JdbcTemplate 之间,还有一种半自动的 ORM,它只负责把 ResultSet 自动映射到 Java Bean,或者自动填充 Java Bean 参数,仍需自己写出 SQL。MyBatis 就是这样一种半自动化 ORM 框架。

在 Spring 中集成 MyBatis,首先要引入 MyBatis 本身,其次由于 Spring 并没有像 Hibernate 那样内置对 MyBatis 的集成,所以我们需要再引入 MyBatis 官方自己开发的一个与 Spring 集成的库。org.mybatis:mybatis:3.5.11 org.mybatis:mybatis-spring:3.0.0

使用 MyBatis 的核心就是创建 SqlSessionFactory,这里我们需要创建的是 SqlSessionFactoryBean。因为 MyBatis 可以直接使用 Spring 管理的声明式事务,因此创建事务管理器和使用 JDBC 是一样的:

@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
    @Bean
    DataSource createDataSource() { ... }

    @Bean
    SqlSessionFactoryBean createSqlSessionFactoryBean(@Autowired DataSource dataSource) {
        var sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        return sqlSessionFactoryBean;
    }

    @Bean
    PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

和 Hibernate 不同的是,MyBatis 使用 Mapper 来实现映射,而且 Mapper 必须是接口。我们以 User 类为例,在 User 类和 users 表之间映射的 UserMapper 编写如下:

public interface UserMapper {
	@Select("SELECT * FROM users WHERE id = #{id}")
	User getById(@Param("id") long id);
}

这里的 Mapper 不是 JdbcTemplate 的 RowMapper 的概念,它是定义访问 users 表的接口方法。我们定义了一个 User getById(long) 的主键查询方法,不仅要定义接口方法本身,还要明确写出查询的 SQL,这里用注解 @Select 标记。SQL 语句的任何参数,都与方法参数按名称对应。例如方法参数 id 的名字通过注解 @Param() 标记为 id,则 SQL 语句里将来替换的占位符就是 #{id}

如果有多个参数,那么每个参数命名后直接在 SQL 中写出对应的占位符即可:

@Select("SELECT * FROM users LIMIT #{offset}, #{maxResults}")
List<User> getAll(@Param("offset") int offset, @Param("maxResults") int maxResults);

MyBatis 执行查询后,将根据方法的返回类型自动把 ResultSet 的每一行转换为 User 实例,转换规则是按列名和属性名对应。如果列名和属性名不同,最简单的方式是编写 SELECT 语句的别名

-- 列名是created_time,属性名是createdAt:
SELECT id, name, email, created_time AS createdAt FROM users

执行 INSERT 语句就稍微麻烦点,因为我们希望传入 User 实例,因此定义的方法接口与 @Insert 注解如下:

@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);

上述方法传入的参数名称是 user,参数类型是 User 类,在 SQL 中引用的时候,以 #{obj.property} 的方式写占位符。和 Hibernate 这样的全自动化 ORM 相比,MyBatis 必须写出完整的 INSERT 语句。

如果 users 表的 id 是自增主键,那么我们在 SQL 中不传入 id,但希望获取插入后的主键,需要再加一个 @Options 注解。keyProperty 和 keyColumn 分别指出 JavaBean 的属性和数据库的主键列名。

@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);

执行 UPDATE 和 DELETE 语句相对比较简单:

@Update("UPDATE users SET name = #{user.name}, createdAt = #{user.createdAt} WHERE id = #{user.id}")
void update(@Param("user") User user);

@Delete("DELETE FROM users WHERE id = #{id}")
void deleteById(@Param("id") long id);

有了 UserMapper 接口,还需要对应的实现类才能真正执行这些数据库操作的方法。MyBatis 提供了一个 MapperFactoryBean 来自动创建所有 Mapper 的实现类,可以用一个简单的注解来启用它:

@MapperScan("com.example.demo.mapper")
...其他注解...
public class AppConfig {
    ...
}

有了@MapperScan,就可以让 MyBatis 自动扫描指定包的所有 Mapper 并创建实现类。在真正的业务逻辑中,我们可以直接注入:

@Component
@Transactional
public class UserService {
    // 注入UserMapper:
    @Autowired
    UserMapper userMapper;

    public User getUserById(long id) {
        // 调用Mapper方法:
        User user = userMapper.getById(id);
        if (user == null) {
            throw new RuntimeException("User not found by id.");
        }
        return user;
    }
}

使用 MyBatis 最大的问题是所有 SQL 都需要全部手写,优点是执行的 SQL 就是我们自己写的 SQL,对 SQL 进行优化非常简单,也可以编写任意复杂的 SQL,或者使用数据库的特定语法,但切换数据库可能就不太容易。好消息是大部分项目并没有切换数据库的需求,完全可以针对某个数据库编写尽可能优化的 SQL。

Spring MVC

标准的 Spring Maven Web 工程目录结构如下:
spring-web-mvc
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── example
│ └── demo
│ ├── AppConfig.java
│ ├── DatabaseInitializer.java
│ ├── entity
│ │ └── User.java
│ ├── service
│ │ └── UserService.java
│ └── web
│ └── UserController.java
├── resources
│ ├── jdbc.properties
│ └── logback.xml
└── webapp
├── WEB-INF
│ ├── templates
│ │ ├── _base.html
│ │ ├── index.html
│ │ ├── profile.html
│ │ ├── register.html
│ │ └── signin.html
│ └── web.xml
└── static
├── css
│ └── bootstrap.css
└── js
└── jquery.js

src/main/webapp 是标准 web 目录,WEB-INF 存放 web.xml,编译的 class,第三方 jar,以及不允许浏览器直接访问的 View 模版,static 目录存放所有静态文件。

src/main/resources 目录中存放的是 Java 程序读取的 classpath 资源文件。

src/main/java中 目录中存放的是我们编写的 Java 代码。

配置 Spring MVC

和普通 Spring 配置一样,我们编写 AppConfig 后,加上 @EnableWebMvc 注解,就“激活”了 Spring MVC:

@Configuration
@ComponentScan
@EnableWebMvc // 启用Spring MVC
@EnableTransactionManagement
@PropertySource("classpath:/jdbc.properties")
public class AppConfig {
    ...
}

除了创建 DataSource、JdbcTemplate、PlatformTransactionManager 外,AppConfig 需要额外创建几个用于 Spring MVC 的 Bean:

@Bean
WebMvcConfigurer createWebMvcConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addResourceHandlers(ResourceHandlerRegistry registry) {
            registry.addResourceHandler("/static/**").addResourceLocations("/static/");
        }
    };
}

WebMvcConfigurer 并不是必须的,但我们在这里创建一个默认的 WebMvcConfigurer,只覆写 addResourceHandlers(),目的是让 Spring MVC 自动处理静态文件,并且映射路径为 /static/**

ViewResolver 是须要创建的 Bean ,因为 Spring MVC 允许集成任何模板引擎,使用哪个模板引擎,就实例化一个对应的 ViewResolver:

@Bean
ViewResolver createViewResolver(@Autowired ServletContext servletContext) {
    var engine = new PebbleEngine.Builder().autoEscaping(true)
            // cache:
            .cacheActive(false)
            // loader:
            .loader(new Servlet5Loader(servletContext))
            .build();
    var viewResolver = new PebbleViewResolver(engine);
    viewResolver.setPrefix("/WEB-INF/templates/");
    viewResolver.setSuffix("");
    return viewResolver;
}

ViewResolver 通过指定 prefix 和 suffix 来确定如何查找 View。上述配置使用 Pebble 引擎,指定模板文件存放在 /WEB-INF/templates/ 目录下。

剩下的 Bean 都是普通的 @Component,但 Controller 必须标记为 @Controller,例如:

// Controller 使用 @Controller 标记而不是 @Component:
@Controller
public class UserController {
    // 正常使用 @Autowired 注入:
    @Autowired
    UserService userService;

    // 处理一个URL映射:
    @GetMapping("/")
    public ModelAndView index() {
        ...
    }
    ...
}

如果是普通的 Java 应用程序,我们通过 main() 方法可以很简单地创建一个 Spring 容器的实例:var context = new AnnotationConfigApplicationContext(AppConfig.class);。但 Web 应用程序总是由 Servlet 容器创建。在 Web 应用中启动 Spring 容器有很多种方法,可以通过 Listener 启动,也可以通过 Servlet 启动,可以使用 XML 配置,也可以使用注解配置。

我们在 web.xml 中配置 Spring MVC 提供的 DispatcherServlet:

<?xml version="1.0"?>
<web-app>
    <servlet>
        <servlet-name>dispatcher</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.demo.AppConfig</param-value>
        </init-param>
        <load-on-startup>0</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>dispatcher</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
</web-app>

初始化参数 contextClass 指定使用注解配置的 AnnotationConfigWebApplicationContext,配置文件的位置参数 contextConfigLocation 指向 AppConfig 的完整类名,最后把这个 Servlet 映射到 /*,即处理所有 URL。

有了这个配置,Servlet 容器会首先初始化 Spring MVC 的 DispatcherServlet,在 DispatcherServlet 启动时,它根据配置 AppConfig 创建了一个类型是 WebApplicationContext 的 IoC 容器,完成所有 Bean 的初始化,并将容器绑到 ServletContext 上。因为 DispatcherServlet 持有 IoC 容器,能从 IoC 容器中获取所有 @Controller 的 Bean,DispatcherServlet 接收到所有 HTTP 请求后,根据 Controller 方法配置的路径,就可以正确地把请求转发到指定方法,并根据返回的 ModelAndView 决定如何渲染页面。

最后,我们在 AppConfig 中通过 main() 方法启动嵌入式 Tomcat:

public static void main(String[] args) throws Exception {
    Tomcat tomcat = new Tomcat();
    tomcat.setPort(Integer.getInteger("port", 8080));
    tomcat.getConnector();
    Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
    WebResourceRoot resources = new StandardRoot(ctx);
    resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
    ctx.setResources(resources);
    tomcat.start();
    tomcat.getServer().await();
}

编写 Controller

Spring MVC 对 Controller 没有固定的要求,也不需要实现特定的接口。Controller 总是标记 @Controller 而不是 @Component

@Controller
public class UserController {
    ...
}

一个方法对应一个 HTTP 请求路径,用 @GetMapping@PostMapping 表示 GET 或 POST 请求:

@PostMapping("/signin")
public ModelAndView doSignin(
        @RequestParam("email") String email,
        @RequestParam("password") String password,
        HttpSession session) {
    ...
}

需要接收的 HTTP 参数以 @RequestParam() 标注,可以设置默认值。如果方法参数需要传入 HttpServletRequest、HttpServletResponse 或者 HttpSession,直接添加这个类型的参数即可,Spring MVC 会自动按类型传入。返回的 ModelAndView 通常包含 View 的路径和一个 Map 作为 Model,但也可以没有 Model:

return new ModelAndView("signin.html"); // 仅 View,没有 Model

返回重定向时既可以写 new ModelAndView("redirect:/signin"),也可以直接返回 String:

public String index() {
    if (...) {
        return "redirect:/signin";
    } else {
        return "redirect:/profile";
    }
}

如果在方法内部直接操作 HttpServletResponse 发送响应,返回 null 表示无需进一步处理:

public ModelAndView download(HttpServletResponse response) {
    byte[] data = ...
    response.setContentType("application/octet-stream");
    OutputStream output = response.getOutputStream();
    output.write(data);
    output.flush();
    return null;
}

对 URL 进行分组,每组对应一个 Controller 是一种很好的组织形式,并可以在 Controller 的 class 定义出添加 URL 前缀,例如:

@Controller
@RequestMapping("/user")
public class UserController {
    // 注意实际 URL 映射是 /user/profile
    @GetMapping("/profile")
    public ModelAndView profile() {
        ...
    }

    // 注意实际 URL 映射是 /user/changePassword
    @GetMapping("/changePassword")
    public ModelAndView changePassword() {
        ...
    }
}

使用 Spring MVC 时,整个 Web 应用程序按如下顺序启动:启动 Tomcat 服务器;Tomcat 读取 web.xml 并初始化 DispatcherServlet;
DispatcherServlet 创建 IoC 容器并自动注册到 ServletContext 中。启动后,浏览器发出的 HTTP 请求全部由 DispatcherServlet 接收,并根据配置转发到指定 Controller 的指定方法处理。

使用 REST

使用 Spring MVC 开发 Web 应用程序的主要工作就是编写 Controller 逻辑。在 Web 应用中,除了需要使用 MVC 给用户显示页面外,还有一类 API 接口,我们称之为 REST,通常输入输出都是 JSON,便于第三方调用或者使用页面 JavaScript 与之交互。

如果我们想接收 JSON,输出 JSON,那么可以这样写:

@PostMapping(value = "/rest",consumes = "application/json;charset=UTF-8",produces = "application/json;charset=UTF-8")
@ResponseBody
public String rest(@RequestBody User user) {
    return "{\"restSupport\":true}";
}

对应的 Maven 工程需要加入 Jackson 这个依赖:com.fasterxml.jackson.core:jackson-databind:2.14.0@PostMapping 使用 consumes 声明能接收的类型,使用 produces 声明输出的类型,并且额外加了 @ResponseBody 表示返回的 String 无需额外处理,直接作为输出内容写入 HttpServletResponse。输入的 JSON 则根据注解 @RequestBody 直接被 Spring 反序列化为 User 这个 JavaBean。

Spring 额外提供了一个 @RestController 注解,使用 @RestController 替代 @Controller 后,每个方法自动变成 API 接口方法。

@RestController
@RequestMapping("/api")
public class ApiController {
    @Autowired
    UserService userService;

    @GetMapping("/users")
    public List<User> users() {
        return userService.getUsers();
    }

    @GetMapping("/users/{id}")
    public User user(@PathVariable("id") long id) {
        return userService.getUserById(id);
    }

    @PostMapping("/signin")
    public Map<String, Object> signin(@RequestBody SignInRequest signinRequest) {
        try {
            User user = userService.signin(signinRequest.email, signinRequest.password);
            return Map.of("user", user);
        } catch (Exception e) {
            return Map.of("error", "SIGNIN_FAILED", "message", e.getMessage());
        }
    }

    public static class SignInRequest {
        public String email;
        public String password;
    }
}

编写 REST 接口只需要定义 @RestController,然后每个方法都是一个 API 接口,输入和输出只要能被 Jackson 序列化或反序列化为 JSON 就没有问题。

要避免输出 password 属性,可以把 User 复制到另一个 UserBean 对象,该对象只持有必要的属性,但这样做比较繁琐。简单的方法是直接在 User 的 password 属性定义处加上 @JsonIgnore 表示完全忽略该属性:

public class User {
    ...

    @JsonIgnore
    public String getPassword() {
        return password;
    }

    ...
}

但是这样一来,如果写一个 register(User user) 方法,那么该方法的 User 对象也拿不到注册时用户传入的密码了。如果要允许输入 password,但不允许输出 password,即在 JSON 序列化和反序列化时,允许写属性,禁用读属性,可以更精细地控制如下:

public class User {

    @JsonProperty(access = Access.WRITE_ONLY)
    public String getPassword() {
        return password;
    }

    ...
}

同样的,可以使用 @JsonProperty(access = Access.READ_ONLY) 允许输出,不允许输入。

集成 Filter

使用 Spring MVC 自带的一个 CharacterEncodingFilter。配置 Filter 时,只需在 web.xml 中声明即可:

<web-app>
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    ...
</web-app>

自定义 Filter 允许用户使用 Basic 模式进行用户验证,即在 HTTP 请求中添加头 Authorization: Basic email:password

@Component
public class AuthFilter implements Filter {
    @Autowired
    UserService userService;

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        // 获取 Authorization 头:
        String authHeader = req.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Basic ")) {
            // 从 Header 中提取 email 和 password:
            String email = prefixFrom(authHeader);
            String password = suffixFrom(authHeader);
            // 登录:
            User user = userService.signin(email, password);
            // 放入 Session:
            req.getSession().setAttribute(UserController.KEY_USER, user);
        }
        // 继续处理请求:
        chain.doFilter(request, response);
    }
}

AuthFilter 的实例将由 Servlet 容器而不是 Spring 容器初始化,因此 @Autowired 根本不生效,用于登录的 UserService 成员变量永远是 null。Spring MVC 提供了一个 DelegatingFilterProxy,让 Servlet 容器实例化的 Filter,间接引用 Spring 容器实例化的 AuthFilter:

<web-app>
    <filter>
        <filter-name>authFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>authFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    ...
</web-app>

实现原理:Servlet 容器从 web.xml 中读取配置,实例化 DelegatingFilterProxy,命名为 authFilter;Spring 容器通过扫描 @Component 实例化 AuthFilter。当 DelegatingFilterProxy 生效后,它会自动查找注册在 ServletContext 上的 Spring 容器,再试图从容器中查找名为 authFilter 的 Bean,也就是我们用 @Component 声明的 AuthFilter。

如果在 web.xml 中配置的 Filter 名字和 Spring 容器的 Bean 的名字不一致,那么需要指定 Bean 的名字:

<filter>
    <filter-name>basicAuthFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <!-- 指定Bean的名字 -->
    <init-param>
        <param-name>targetBeanName</param-name>
        <param-value>authFilter</param-value>
    </init-param>
</filter>

实际应用时尽量保持名字一致,以减少不必要的配置。

使用 Interceptor

Web 程序使用的 Filter 由 Servlet 容器管理。Filter 组件实际上并不知道后续内部处理是通过 Spring MVC 提供的 DispatcherServlet 还是其他 Servlet 组件,因为 Filter 是 Servlet 规范定义的标准组件,它可以应用在任何基于 Servlet 的程序中。

如果只基于 Spring MVC 开发应用程序,还可以使用 Spring MVC 提供的一种功能类似 Filter 的拦截器:Interceptor。和 Filter 相比,Interceptor 拦截范围不是后续整个处理流程,而是仅针对 Controller 拦截。Interceptor 的拦截范围其实就是 Controller 方法,它实际上就相当于基于 AOP 的方法拦截。因为 Interceptor 只拦截 Controller 方法,所以要注意返回 ModelAndView 并渲染后,后续处理就脱离了 Interceptor 的拦截范围。

使用 Interceptor 的好处是 Interceptor 本身是 Spring 管理的 Bean,因此注入任意 Bean 都非常简单。此外可以应用多个 Interceptor,并通过简单的 @Order 指定顺序。

@Order(1)
@Component
public class LoggerInterceptor implements HandlerInterceptor {

    final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        logger.info("preHandle {}...", request.getRequestURI());
        if (request.getParameter("debug") != null) {
            PrintWriter pw = response.getWriter();
            pw.write("<p>DEBUG MODE</p>");
            pw.flush();
            return false;
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        logger.info("postHandle {}.", request.getRequestURI());
        if (modelAndView != null) {
            modelAndView.addObject("__time__", LocalDateTime.now());
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        logger.info("afterCompletion {}: exception = {}", request.getRequestURI(), ex);
    }
}

Interceptor 必须实现 HandlerInterceptor 接口,可以选择实现 preHandle()postHandle()afterCompletion() 方法。preHandle() 是 Controller 方法调用前执行,postHandle() 是 Controller 方法正常返回后执行,而 afterCompletion() 无论 Controller 方法是否抛异常都会执行,参数 ex 就是 Controller 方法抛出的异常(未抛出异常是 null)。在 preHandle() 中,也可以直接处理响应,然后返回 false 表示无需调用 Controller 方法继续处理了,通常在认证或者安全检查失败时直接返回错误响应。在 postHandle() 中,因为捕获了 Controller 方法返回的 ModelAndView,所以可以继续往 ModelAndView 里添加一些通用数据,很多页面需要的全局数据都可以放到这里,无需在每个 Controller 方法中重复添加。

@Order(2)
@Component
public class AuthInterceptor implements HandlerInterceptor {

    final Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        logger.info("pre authenticate {}...", request.getRequestURI());
        try {
            authenticateByHeader(request);
        } catch (RuntimeException e) {
            logger.warn("login by authorization header failed.", e);
        }
        return true;
    }

    private void authenticateByHeader(HttpServletRequest req) {
        String authHeader = req.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Basic ")) {
            logger.info("try authenticate by authorization header...");
            String up = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8);
            int pos = up.indexOf(':');
            if (pos > 0) {
                String email = URLDecoder.decode(up.substring(0, pos), StandardCharsets.UTF_8);
                String password = URLDecoder.decode(up.substring(pos + 1), StandardCharsets.UTF_8);
                User user = userService.signin(email, password);
                req.getSession().setAttribute(UserController.KEY_USER, user);
                logger.info("user {} login by authorization header ok.", email);
            }
        }
    }

}

AuthInterceptor 是由 Spring 容器直接管理的,因此注入 UserService 非常方便。要让拦截器生效,我们在 WebMvcConfigurer 中注册所有的 Interceptor:

@Bean
WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
    return new WebMvcConfigurer() {
        public void addInterceptors(InterceptorRegistry registry) {
            for (var interceptor : interceptors) {
                registry.addInterceptor(interceptor);
            }
        }
        ...
    };
}

处理异常

在 Controller 中,Spring MVC 还允许定义基于 @ExceptionHandler 注解的异常处理方法。

@Controller
public class UserController {
    @ExceptionHandler(RuntimeException.class)
    public ModelAndView handleUnknowException(Exception ex) {
        return new ModelAndView("500.html", Map.of("error", ex.getClass().getSimpleName(), "message", ex.getMessage()));
    }
    ...
}

异常处理方法没有固定的方法签名,可以传入 Exception、HttpServletRequest 等,返回值可以是 void,也可以是 ModelAndView。上述代码通过 @ExceptionHandler(RuntimeException.class) 表示当发生 RuntimeException 的时候,就自动调用此方法处理。返回一个新的 ModelAndView,这样在应用程序内部如果发生了预料之外的异常,可以给用户显示一个出错页面,而不是简单的 500 Internal Server Error404 Not Found

可以编写多个错误处理方法,每个方法针对特定的异常。例如处理 LoginException 使得页面可以自动跳转到登录页。使用 ExceptionHandler 时,要注意它仅作用于当前的 Controller,即 ControllerA 中定义的一个 ExceptionHandler 方法对 ControllerB 不起作用。

处理 CORS

在 JavaScript 与 REST 交互的时候,浏览器按同源策略放行 JavaScript 调用 API。同源要求域名要完全相同(a.com 和www.a.com不同),协议要相同(http和https不同),端口要相同 。如果 A 站的 JavaScript 访问 B 站 API 的时候,B 站能够返回响应头 Access-Control-Allow-Origin: http://a.com,那么浏览器就允许 A 站的 JavaScript 访问 B 站的API。注跨域访问能否成功,取决于 B 站是否愿意给 A 站返回一个正确的 Access-Control-Allow-Origin 响应头,所以决定权永远在提供 API 的服务方手中。

第一种方法是使用 @CrossOrigin 注解,可以在 @RestController 的 class 级别或方法级别定义一个 @CrossOrigin

@CrossOrigin(origins = "http://example.com")
@RestController
@RequestMapping("/api")
public class ApiController {
    ...
}

ApiController 的 @CrossOrigin 指定了只允许来自 example.com 的跨域访问,允许多个域访问需要写成数组形式,origins = {"http://a.com", "https://www.b.com"}。如果要允许任何域访问,写成 origins = "*"即可。如果有多个 REST Controller 都需要使用 CORS,那么,每个 Controller 都必须标注 @CrossOrigin 注解。

第二种方法是在 WebMvcConfigurer 中定义一个全局 CORS 配置:

@Bean
WebMvcConfigurer createWebMvcConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                    .allowedOrigins("http://local.liaoxuefeng.com:8080")
                    .allowedMethods("GET", "POST")
                    .maxAge(3600);
            // 可以继续添加其他 URL 规则:
            // registry.addMapping("/rest/v2/**")...
        }
    };
}

这种方式可以创建一个全局 CORS 配置,如果仔细地设计 URL 结构,那么可以一目了然地看到各个 URL 的 CORS 规则,推荐使用这种方式配置 CORS。

国际化

在开发应用程序的时候,经常会遇到支持多语言的需求,这种支持多语言的功能称之为国际化,英文是 internationalization,缩写为 i18n。还有针对特定地区的本地化功能,英文是 localization,缩写为 L10n,本地化是指根据地区调整类似姓名、日期的显示等。

在 Java 中,支持多语言和本地化是通过 MessageFormat 配合 Locale 实现的:

// MessageFormat
import java.text.MessageFormat;
import java.util.Locale;

public class Time {
    public static void main(String[] args) {
        double price = 123.5;
        int number = 10;
        Object[] arguments = { price, number };
        MessageFormat mfUS = new MessageFormat("Pay {0,number,currency} for {1} books.", Locale.US);
        System.out.println(mfUS.format(arguments));
        MessageFormat mfZH = new MessageFormat("{1}本书一共{0,number,currency}。", Locale.CHINA);
        System.out.println(mfZH.format(arguments));
    }
}

对于 Web 应用程序,要实现国际化功能,主要是渲染 View 的时候,要把各种语言的资源文件提出来,这样不同的用户访问同一个页面时,显示的语言就是不同的。

实现国际化的第一步是获取到用户的 Locale。在 Web 应用程序中,HTTP 规范规定了浏览器会在请求中携带 Accept-Language 头,用来指示用户浏览器设定的语言顺序,如 Accept-Language: zh-CN,zh;q=0.8,en;q=0.2。上述 HTTP 请求头表示优先选择简体中文,其次选择中文,最后选择英文。q 表示权重,解析后我们可获得一个根据优先级排序的语言列表,把它转换为 Java 的 Locale,即获得了用户的 Locale。大多数框架通常只返回权重最高的 Locale。

Spring MVC 通过 LocaleResolver 来自动从 HttpServletRequest 中获取 Locale。有多种 LocaleResolver 的实现类,其中最常用的是 CookieLocaleResolver:

@Primary
@Bean
LocaleResolver createLocaleResolver() {
    var clr = new CookieLocaleResolver();
    clr.setDefaultLocale(Locale.ENGLISH);
    clr.setDefaultTimeZone(TimeZone.getDefault());
    return clr;
}

CookieLocaleResolver 从 HttpServletRequest 中获取 Locale 时,首先根据一个特定的 Cookie 判断是否指定了 Locale,如果没有就从 HTTP 头获取,如果还没有就返回默认的 Locale。当用户第一次访问网站时,CookieLocaleResolver 只能从 HTTP 头获取 Locale,即使用浏览器的默认语言。通常网站也允许用户自己选择语言,此时 CookieLocaleResolver 就会把用户选择的语言存放到 Cookie 中,下一次访问时,就会返回用户上次选择的语言而不是浏览器默认语言。

第二步是把写死在模板中的字符串以资源文件的方式存储在外部。对于多语言,主文件名如果命名为 messages,那么资源文件必须按如下方式命名并放入 classpath 中:默认语言文件名必须为 messages.properties;简体中文,Locale 是 zh_CN,文件名必须为 messages_zh_CN.properties;...

每个资源文件都有相同的 key,例如默认语言是英文,文件 messages.properties 内容如下:

language.select=Language
home=Home
signin=Sign In
copyright=Copyright©{0,number,#}

文件 messages_zh_CN.properties 内容如下:

language.select=语言
home=首页
signin=登录
copyright=版权所有©{0,number,#}

第三步是创建一个 Spring 提供的 MessageSource 实例,它自动读取所有的 .properties 文件,并提供一个统一接口来实现“翻译”:

// code, arguments, locale:
String text = messageSource.getMessage("signin", null, locale);

其中 signin 是我们在 .properties 文件中定义的 key,第二个参数是 Object[] 数组作为格式化时传入的参数,最后一个参数就是获取的用户 Locale 实例。

创建 MessageSource , ResourceBundleMessageSource 会自动根据主文件名自动把所有相关语言的资源文件都读进来。

@Bean("i18n")
MessageSource createMessageSource() {
    var messageSource = new ResourceBundleMessageSource();
    // 指定文件是 UTF-8 编码:
    messageSource.setDefaultEncoding("UTF-8");
    // 指定主文件名:
    messageSource.setBasename("messages");
    return messageSource;
}

要在 View 中使用 MessageSource 加上 Locale 输出多语言,我们通过编写一个 MvcInterceptor,把相关资源注入到 ModelAndView 中:

@Component
public class MvcInterceptor implements HandlerInterceptor {
    @Autowired
    LocaleResolver localeResolver;

    // 注意注入的 MessageSource 名称是 i18n:
    @Autowired
    @Qualifier("i18n")
    MessageSource messageSource;

    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView != null // 返回了 ModelAndView
            && modelAndView.getViewName() != null // 设置了 View
            && !modelAndView.getViewName().startsWith("redirect:") // 不是重定向
        ) {
            // 解析用户的 Locale:
            Locale locale = localeResolver.resolveLocale(request);
            // 放入 Model:
            modelAndView.addObject("__messageSource__", messageSource);
            modelAndView.addObject("__locale__", locale);
        }
    }
}

不要忘了在 WebMvcConfigurer 中注册 MvcInterceptor。

现在就可以在 View 中调用 MessageSource.getMessage() 方法来实现多语言:<a href="/signin">{{ __messageSource__.getMessage('signin', null, __locale__) }}</a>

使用 View 时,要根据每个特定的 View 引擎定制国际化函数。在 Pebble 中,我们可以封装一个国际化函数,名称就是下划线 _,改造一下创建 ViewResolver 的代码:

@Bean
ViewResolver createViewResolver(@Autowired ServletContext servletContext, @Autowired @Qualifier("i18n") MessageSource messageSource) {
    var engine = new PebbleEngine.Builder()
            .autoEscaping(true)
            .cacheActive(false)
            .loader(new Servlet5Loader(servletContext))
            // 添加扩展:
            .extension(createExtension(messageSource))
            .build();
    var viewResolver = new PebbleViewResolver();
    viewResolver.setPrefix("/WEB-INF/templates/");
    viewResolver.setSuffix("");
    viewResolver.setPebbleEngine(engine);
    return viewResolver;
}

private Extension createExtension(MessageSource messageSource) {
    return new AbstractExtension() {
        @Override
        public Map<String, Function> getFunctions() {
            return Map.of("_", new Function() {
                public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
                    String key = (String) args.get("0");
                    List<Object> arguments = this.extractArguments(args);
                    Locale locale = (Locale) context.getVariable("__locale__");
                    return messageSource.getMessage(key, arguments.toArray(), "???" + key + "???", locale);
                }
                private List<Object> extractArguments(Map<String, Object> args) {
                    int i = 1;
                    List<Object> arguments = new ArrayList<>();
                    while (args.containsKey(String.valueOf(i))) {
                        Object param = args.get(String.valueOf(i));
                        arguments.add(param);
                        i++;
                    }
                    return arguments;
                }
                public List<String> getArgumentNames() {
                    return null;
                }
            });
        }
    };
}

这样我们可以把多语言页面改写为:<a href="/signin">{{ _('signin') }}</a>。如果是带参数的多语言,需要把参数传进去:<h5>{{ _('copyright', 2020) }}</h5>。使用其它 View 引擎时,也应当根据引擎接口实现更方便的语法。

最后我们需要允许用户手动切换 Locale,编写一个 LocaleController 来实现该功能:

@Controller
public class LocaleController {
    final Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    LocaleResolver localeResolver;

    @GetMapping("/locale/{lo}")
    public String setLocale(@PathVariable("lo") String lo, HttpServletRequest request, HttpServletResponse response) {
        // 根据传入的 lo 创建 Locale 实例:
        Locale locale = null;
        int pos = lo.indexOf('_');
        if (pos > 0) {
            String lang = lo.substring(0, pos);
            String country = lo.substring(pos + 1);
            locale = new Locale(lang, country);
        } else {
            locale = new Locale(lo);
        }
        // 设定此 Locale:
        localeResolver.setLocale(request, response, locale);
        logger.info("locale is set to {}.", locale);
        // 刷新页面:
        String referer = request.getHeader("Referer");
        return "redirect:" + (referer == null ? "/" : referer);
    }
}

在页面设计中,通常在右上角给用户提供一个语言选择列表。

异步处理

在 Servlet 模型中,每个请求都是由某个线程处理,然后将响应写入 IO 流,发送给客户端。从开始处理请求,到写入响应完成,都是在同一个线程中处理的。实现 Servlet 容器的时候,只要每处理一个请求,就创建一个新线程处理它,就能保证正确实现了 Servlet 线程模型。在实际产品中,例如 Tomcat,总是通过线程池来处理请求,它仍然符合一个请求从头到尾都由某一个线程处理。这种线程模型非常重要,因为 Spring 的 JDBC 事务是基于 ThreadLocal 实现的。此外很多安全认证也是基于 ThreadLocal 实现的,可以保证在处理请求的过程中,各个线程互不影响。

但是如果一个请求处理的时间较长,这种基于线程池的同步模型很快就会把所有线程耗尽,导致服务器无法响应新的请求。如果把长时间处理的请求改为异步处理,那么线程池的利用率就会大大提高。Servlet 从 3.0 规范开始添加了异步支持,允许对一个请求进行异步处理。

配置 web.xml 实现对请求进行异步处理:

<web-app>
    <display-name>Archetype Created Web Application</display-name>

    <servlet>
        <servlet-name>dispatcher</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.demo.AppConfig</param-value>
        </init-param>
        <load-on-startup>0</load-on-startup>
        <async-supported>true</async-supported>
    </servlet>

    <servlet-mapping>
        <servlet-name>dispatcher</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
</web-app>

和前面普通的 MVC 程序相比,这个 web.xml 主要对 DispatcherServlet 的配置多了一个 <async-supported>,默认值是 false,必须明确写成 true,这样 Servlet 容器才会支持 async 处理。

下一步就是在 Controller 中编写 async 处理逻辑。

第一种 async 处理方式是返回一个 Callable,Spring MVC 自动把返回的 Callable 放入线程池执行,等待结果返回后再写入响应:

@GetMapping("/users")
public Callable<List<User>> users() {
    return () -> {
        // 模拟3秒耗时:
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
        }
        return userService.getUsers();
    };
}

第二种 async 处理方式是返回一个 DeferredResult 对象,然后在另一个线程中,设置此对象的值并写入响应:

@GetMapping("/users/{id}")
public DeferredResult<User> user(@PathVariable("id") long id) {
    DeferredResult<User> result = new DeferredResult<>(3000L); // 3秒超时
    new Thread(() -> {
        // 等待1秒:
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        try {
            User user = userService.getUserById(id);
            // 设置正常结果并由 Spring MVC 写入 Response:
            result.setResult(user);
        } catch (Exception e) {
            // 设置错误结果并由 Spring MVC 写入 Response:
            result.setErrorResult(Map.of("error", e.getClass().getSimpleName(), "message", e.getMessage()));
        }
    }).start();
    return result;
}

使用 DeferredResult 时,可以设置超时,超时会自动返回超时错误响应。在另一个线程中,可以调用 setResult() 写入结果,也可以调用 setErrorResult() 写入一个错误结果。在实际使用时,经常用到的就是 DeferredResult,因为返回 DeferredResult 时,可以设置超时、正常结果和错误结果,易于编写比较灵活的逻辑。

当我们使用 async 模式处理请求时,原有的 Filter 也可以工作,但我们必须在 web.xml 中添加<async-supported>并设置为 true。一个声明为支持 <async-supported> 的 Filter 既可以过滤 async 处理请求,也可以过滤正常的同步处理请求,而未声明 <async-supported> 的 Filter 无法支持 async 请求,如果一个普通的 Filter 遇到 async 请求时,会直接报错,因此普通 Filter 的 <url-pattern> 不要匹配 async 请求路径。

使用 async 异步处理响应时,要时刻牢记在另一个异步线程中的事务和 Controller 方法中执行的事务不是同一个事务,在 Controller 中绑定的 ThreadLocal 信息也无法在异步线程中获取。

此外 Servlet 3.0 规范添加的异步支持是针对同步模型打了一个“补丁”,虽然可以异步处理请求,但高并发异步请求时,它的处理效率并不高,因为这种异步模型并没有用到真正的“原生”异步。Java 标准库提供了封装操作系统的异步 IO 包 java.nio,是真正的多路复用 IO 模型,可以用少量线程支持大量并发。使用 NIO 编程复杂度比同步 IO 高很多,因此我们很少直接使用 NIO。大部分需要高性能异步 IO 的应用程序会选择 Netty 这样的框架,它基于 NIO 提供了更易于使用的 API,方便开发异步应用程序。

使用 WebSocket

WebSocket 是一种基于 HTTP 的长链接技术。传统的 HTTP 协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果需要定期给浏览器推送数据,或者不定期给浏览器推送数据,基于 HTTP 协议实现这类需求,只能依靠浏览器的 JavaScript 定时轮询,效率很低且实时性不高。因为 HTTP 本身是基于 TCP 连接的,所以 WebSocket 在 HTTP 协议的基础上做了一个简单的升级,即建立 TCP 连接后,浏览器发送请求时,附带几个头:

GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade

表示客户端希望升级连接,变成长连接的 WebSocket,服务器返回升级成功的响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

收到成功响应后表示 WebSocket “握手”成功,这样代表 WebSocket 的这个 TCP 连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可随时向服务器推送消息。双方推送的消息既可以是文本消息,也可以是二进制消息。一般来说,绝大部分应用程序会推送基于 JSON 的文本消息。

现代浏览器都已经支持 WebSocket 协议,服务器则需要底层框架支持。Java 的 Servlet 规范从 3.1 开始支持 WebSocket,所以必须选择支持 Servlet 3.1 或更高规范的 Servlet 容器才能支持 WebSocket。最新版本的 Tomcat、Jetty 等开源服务器均支持 WebSocket。

Spring MVC 实现对 WebSocket 的支持,我们需要在 AppConfig 中加入 Spring Web 对 WebSocket 的配置,此处我们需要创建一个 WebSocketConfigurer 实例:

@Bean
WebSocketConfigurer createWebSocketConfigurer(@Autowired ChatHandler chatHandler,@Autowired ChatHandshakeInterceptor chatInterceptor)
{
    return new WebSocketConfigurer() {
        public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
            // 把 URL 与指定的 WebSocketHandler 关联,可关联多个:
            registry.addHandler(chatHandler, "/chat").addInterceptors(chatInterceptor);
        }
    };
}

此实例在内部通过 WebSocketHandlerRegistry 注册能处理 WebSocket 的 WebSocketHandler 以及可选的 WebSocket 拦截器 HandshakeInterceptor。浏览器连接到 WebSocket 的 URL 是 /chat。

和处理普通 HTTP 请求不同,WebSocket 没法用一个方法处理一个 URL。Spring 提供了 TextWebSocketHandler 和 BinaryWebSocketHandler 分别处理文本消息和二进制消息,这里我们选择文本消息作为聊天室的协议,因此 ChatHandler 需要继承自 TextWebSocketHandler。当浏览器请求一个 WebSocket 连接后,如果成功建立连接,Spring 会自动调用 afterConnectionEstablished() 方法,任何原因导致 WebSocket 连接中断时,Spring 会自动调用 afterConnectionClosed() 方法,因此覆写这两个方法即可处理连接成功和结束后的业务逻辑。

@Component
public class ChatHandler extends TextWebSocketHandler {
    // 保存所有 Client 的 WebSocket 会话实例:
    private Map<String, WebSocketSession> clients = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 新会话根据 ID 放入 Map:
        clients.put(session.getId(), session);
        session.getAttributes().put("name", "Guest1");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        clients.remove(session.getId());
    }
}

每个 WebSocket 会话以 WebSocketSession 表示,且已分配唯一 ID。和 WebSocket 相关的数据,例如用户名称等,均可放入关联的 getAttributes()中。用实例变量 clients 持有当前所有的 WebSocketSession 是为了广播,即向所有用户推送同一消息时,可以这么写:

String json = ...
TextMessage message = new TextMessage(json);
for (String id : clients.keySet()) {
    WebSocketSession session = clients.get(id);
    session.sendMessage(message);
}

每收到一个用户的消息后,我们就需要广播给所有用户:

@Component
public class ChatHandler extends TextWebSocketHandler {
    ...
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String s = message.getPayload();
        String r = ... // 根据输入消息构造待发送消息
        broadcastMessage(r); // 推送给所有用户
    }
}

如果要推送给指定的几个用户,那就需要在 clients 中根据条件查找出某些 WebSocketSession,然后发送消息。

我们在注册 WebSocket 时还传入了一个 ChatHandshakeInterceptor,这个类实际上可以从 HttpSessionHandshakeInterceptor 继承,它的主要作用是在 WebSocket 建立连接后,把 HttpSession 的一些属性复制到 WebSocketSession,例如用户的登录信息等:

@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    public ChatHandshakeInterceptor() {
        // 指定从 HttpSession 复制属性到 WebSocketSession:
        super(List.of(UserController.KEY_USER));
    }
}

这样在 ChatHandler 中,可以从 WebSocketSession.getAttributes() 中获取到复制过来的属性。

JavaScript 逻辑:

// 创建 WebSocket 连接:
var ws = new WebSocket("ws://" + location.host + "/chat");
// 连接成功时:
ws.addEventListener("open", function (event) {
  console.log("websocket connected.");
});
// 收到消息时:
ws.addEventListener("message", function (event) {
  console.log("message: " + event.data);
  var msgs = JSON.parse(event.data);
  // TODO:
});
// 连接关闭时:
ws.addEventListener("close", function () {
  console.log("websocket closed.");
});
// 绑定到全局变量:
window.chatWs = ws;

用户可以在连接成功后任何时候给服务器发送消息:

var inputText = "Hello, WebSocket.";
window.chatWs.send(JSON.stringify({ text: inputText }));

最后连调浏览器和服务器端,如果一切无误,可以开多个不同的浏览器测试 WebSocket 的推送和广播。

和异步处理类似,Servlet 的线程模型并不适合大规模的长链接。基于 NIO 的 Netty 等框架更适合处理 WebSocket 长链接。

posted @ 2022-11-19 09:52  carol2014  阅读(83)  评论(0)    收藏  举报