Spring框架

一个支持快速开发Java EE应用程序的框架。提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成

Posted by page on June 1, 2024

Spring框架

Spring官网

Spring Framework

Spring Framework官方文档

Spring Framework主要包括几个模块:

  • 支持IoC和AOP的容器;
  • 支持JDBC和ORM的数据访问模块;
  • 支持声明式事务的模块;
  • 支持基于Servlet的MVC开发;
  • 支持基于Reactive的Web开发;
  • 以及集成JMS、JavaMail、JMX、缓存等其他模块。

IoC容器

容器,一种为某种特定组件的运行提供必要支持的一个软件环境

Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境

Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程

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

IoC原理

如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。

因此,核心问题是:

  1. 谁负责创建组件?
  2. 谁负责根据依赖关系组装组件?
  3. 销毁时,如何按依赖顺序正确销毁?

解决这一问题的核心方案就是IoC。

传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制;

在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,

public class BookService {
    private DataSource dataSource;

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

它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且,由IoC容器负责管理组件的生命周期。

配置Ioc容器

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>

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

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

依赖注入方式(构造方法)

public class BookService {
    private DataSource dataSource;

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

装配Bean

Maven创建工程并引入spring-context依赖:

  • org.springframework:spring-context:6.0.0

以编写简单 UserService 为例

public class UserService {
    private MailService mailService;

    public void setMailService(MailService mailService) {
        this.mailService = mailService;
    }
    ...
}

application.xml

resources下特定配置文件,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.itranswarp.learnjava.service.UserService">
        <property name="mailService" ref="mailService" />
    </bean>
    <bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
</beans>
  • 每个 <bean ...> 都有一个 id 标识,相当于Bean的唯一ID;
  • userService Bean中,通过 <property name="..." ref="..." /> 注入了另一个Bean;
  • Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。

如果注入的不是Bean,而是booleanintString这样的数据类型,则通过value注入,例如,创建一个HikariDataSource

<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="autoCommit" value="true" />
</bean>

创建IoC容器

main() 方法创建一个Spring的IoC容器实例,然后加载配置文件

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中我们可以根据Bean的类型获取Bean的引用:

UserService userService = context.getBean(UserService.class);

Annotation配置

通过 @component 注解方式,不需要XML,让Spring自动扫描Bean并组装它们

// MailService
package com.itranswarp.learnjava.service;
import org.springframework.stereotype.Component;

@Component
public class MailService {
    ...
}

// UserService
package com.itranswarp.learnjava.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class UserService {
 @Autowired
 MailService mailService;
 ...
 // 或 set 方法、构造方法中
 public UserService(@Autowired MailService mailService) {
        this.mailService = mailService;
    }
}

编写 AppConfig 类(AppConfig.java)启动容器

import com.itranswarp.learnjava.service.User;
import com.itranswarp.learnjava.service.UserService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = "com.itranswarp.learnjava.service")
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());
    }
}

@Configuration 标注表示它是一个配置类,因为我们创建 ApplicationContext 时,使用的实现类是 AnnotationConfigApplicationContext,必须传入一个标注了@Configuration 的类名

@ComponentScan 标注容器,自动搜索当前类所在的包以及子包,把所有标注为@Component 的Bean自动创建出来,并根据 @Autowired 进行装配

通常,启动配置 AppConfig 位于自定义的顶层包,其他Bean按类别放入子包

定制Bean

Scope

通过 @component 标记的Bean为单例类型(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)获取到的Bean总是同一个实例。

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

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

注入List

可将相同类型的Bean注入 List 或数组,常见于一系列接口相同,不同实现类的Bean:

定义一个验证接口

public interface Validator {
    void validate(String email, String password, String name);
}

声明3个Validator对用户参数进行验证:

@Component
public class EmailValidator implements Validator {
    public void validate(String email, String password, String name) {
        if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
            throw new IllegalArgumentException("invalid email: " + email);
        }
    }
}

@Component
public class PasswordValidator implements Validator {
    public void validate(String email, String password, String name) {
      ...
    }
}

@Component
public class NameValidator implements Validator {
    public void validate(String email, String password, String name) {
      ...
    }
}

注入为 validators

@Component
public class Validators {
    @Autowired
    List<Validator> validators;

    public void validate(String email, String password, String name) {
        for (var validator : this.validators) {
            validator.validate(email, password, name);
        }
    }
}

List<Validator> validators,Spring会自动把所有类型为Validator的Bean装配为一个List注入进来

指定List中Bean的顺序,可以加上@Order注解:

@Component
@Order(1)
public class EmailValidator implements Validator {
    ...
}

可选注入

常用于Spring如果没有找到对应类型的Bean,指定默认值

@Component
public class MailService {
    @Autowired(required = false) // 核心代码
    ZoneId zoneId = ZoneId.systemDefault();
    ...
}

第三方Bean

如果一个Bean不在我们自己的package管理之内,如何创建它

@Configuration 类中编写一个Java方法创建并返回它,给方法标记一个 @Bean 注解

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

初始化和销毁

如果一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。

在容器关闭时,有时候还需要清理资源(关闭连接池等)。

通常会定义一个 init() 方法进行初始化,定义一个 shutdown() 方法进行清理

@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");
    }
}

使用别名

默认情况下,对一种类型的Bean,容器只创建一个实例

如果需要对一种类型的Bean创建多个实例。如同时连接多个数据库,就必须创建多个 DataSource 实例

如果直接在 @Configuration 类中创建了多个同类型的Bean,会报NoUniqueBeanDefinitionException 异常,尝试设置别名:

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

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

@Bean("name") 指定别名,也可以用 @Bean+@Qualifier("name") 指定别名,注入使用:

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

@Primary 指定的主bean,则默认注入的即主数据源,无需别名指定名称也可

FactoryBean

遵循工厂模式,定义一个工创建真正的Bean

@Component
public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {

    String zone = "Z";

    @Override
    public ZoneId getObject() throws Exception {
        return ZoneId.of(zone);
    }

    @Override
    public Class<?> getObjectType() {
        return ZoneId.class;
    }
}

使用Resource

使用Spring容器时,我们可以把“文件”注入进来,方便程序读取

org.springframework.core.io.Resource 支持将一个文件作为 Stringint 那样使用 @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"));
        }
    }
}

注入配置

读取配置文件:常用的配置方法是以 key=value 的形式写在 .properties 文件中

Spring容器提供了一个更简单的 @PropertySource 来自动读取配置文件:

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

    @Bean
    ZoneId createZoneId() {
        return ZoneId.of(zoneId);
    }
}
  • "${app.zone}"表示读取key为app.zone的value,如果key不存在,启动将报错;
  • "${app.zone:Z}"表示读取key为app.zone的value,但如果key不存在,就使用默认值Z

或者 @Bean 手动创建时,传入到构造方法参数

@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;
    }
}

条件装配

Spring为应用程序准备了Profile这一概念,用来表示不同的环境。如分别定义开发、测试和生产这3个环境:

  • native
  • test
  • production

@Profile

根据注解 @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");
    }
}

在运行程序时,加上JVM参数 -Dspring.profiles.active=test 就可以指定以 test 环境启动;Spring允许指定多个Profile:-Dspring.profiles.active=test,master

可以表示 test 环境,并使用 master 分支代码

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

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

@Conditional

支持自定义条件逻辑,如:

@Component
@Conditional(OnSmtpEnvCondition.class) // 条件实现类
public class SmtpMailService implements MailService {
    ...
}
public class OnSmtpEnvCondition implements Condition {
    // 注意此处是对 Condition 实现
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        return "true".equalsIgnoreCase(System.getenv("smtp"));
    }
}

AOP

即面向切面编程(Aspect Oriented Programming)

思考业务模型,如安全检查、日志、事务等代码,它们会重复出现在每个业务方法中

加入AOP的视角来编写上述业务,可以依次实现:

  1. 核心逻辑,即:BookService
  2. 切面逻辑,即:权限检查的Aspect、日志的Aspect、事务的Aspect

原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理;

最简单的方式是运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。

Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。

AOP本质是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。

AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。

另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。

装配AOP

通过Maven引入Spring对AOP的支持:

  • org.springframework:spring-aspects:6.0.0

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单:

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

    // 在执行MailService的每个方法前后执行:
    @Around("execution(public * com.itranswarp.learnjava.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;
    }
}

定义 @Before,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService的每个public方法前执行doAccessCheck()代码。

定义 @Around,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。

@Configuration 类加上一个 @EnableAspectJAutoProxy 注解:

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

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

拦截器类型

  • @Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;

  • @After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;

  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;

  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;

  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

注解装配

通过复杂的 execution(* xxx.Xyz.*(..)) AOP规则装配到指定Bean的指定方法前后,常导致后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配

为 bean标注 @Transactional,使被装配的Bean清楚知道自己被安排,如

@Component
// @Transactional 表明所有public被启用事务
public class UserService {
    ...
}
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }
    // 无事务:
    public boolean isValidName(String name) {
        ...
    }
    // 有事务:
    @Transactional
    public void updateUser(User user) {
        ...
    }
}

@Transactional 注解用于声明一个方法或类应该在事务中执行。Spring 使用 AOP 来实现事务管理,这意味着它会在方法执行的适当时间点自动开始和结束事务

注解的方式实现AOP装配

第一步,定义注解

// 定义一个性能监控注解
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
    String value(); // 后续通过.value()访问注解参数值
}

第二步,在需要被监控的关键方法上标注注解

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

第三步,定义切面(Aspect),将切面与注解建立联系

@Aspect
@Component
public class MetricAspect {
    // 建立切面与注解联系:符合条件的目标方法是带有@MetricTime注解的方法
    @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");
        }
    }
}

AOP避坑指南

AOP就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。(基于你的类生成一个代理类代替参与运行)

因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题,如

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

访问数据库

Java定义了程序访问数据库的标准接口JDBC;JDBC虽然简单,但代码比较繁琐。Spring为了简化数据库访问,主要做了以下几点工作:

  • 提供了简化的访问JDBC的模板类,不必手动释放资源;
  • 提供了一个统一的DAO类以实现Data Access Object模式;
  • SQLException 封装为 DataAccessException,这个异常是一个 RuntimeException,并且让我们能区分SQL异常的原因,例如,DuplicateKeyException 表示违反了一个唯一约束;
  • 能方便地集成Hibernate、JPA和MyBatis这些数据库访问框架。

使用JDBC

Java程序使用JDBC接口访问关系数据库的时候,需要以下几步:

  • 创建全局 DataSource 实例,表示数据库连接池;
  • 在需要读写数据库的方法内部,按如下步骤访问数据库:
    • 从全局 DataSource 实例获取 Connection 实例;
    • 通过 Connection 实例创建 PreparedStatement 实例;
    • 执行SQL语句,如果是查询,则通过 ResultSet 读取结果集,如果是修改,则获得 int 结果。

正确编写JDBC代码的关键是使用 try ... finally 释放资源,涉及到事务的代码需要正确提交或回滚事务。

在Spring使用JDBC,首先我们通过IoC容器创建并管理一个DataSource实例;

Spring提供了一个 JdbcTemplate,可以方便地让我们操作JDBC,因此,通常情况下,我们会实例化一个 JdbcTemplate。顾名思义,这个类主要使用了 Template模式

AppConfig

@Configuration
@ComponentScan(basePackages = "org.example")
@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);
    }
}

在上述配置中:

  1. 通过 @PropertySource("jdbc.properties") 读取数据库配置文件;
  2. 通过 @Value("${jdbc.url}") 注入配置文件的相关配置;
  3. 创建一个 DataSource 实例,它的实际类型是 HikariDataSource,创建时需要用到注入的配置;
  4. 创建一个 JdbcTemplate 实例,它需要注入 DataSource,这是通过方法参数完成注入的;

HSQLDB配置文件

HSQLDB写一个配置文件 jdbc.properties

# 数据库文件名
jdbc.url=jdbc:hsqldb:file:testdb

# Hsqldb默认用户名,口令是空字符串
jdbc.username=sa
jdbc.password=

数据库表初始化

@Component
public class DatabaseInitializer {
    @Autowired
    JdbcTemplate jdbcTemplate;

    @PostConstruct
    public void init() {
        jdbcTemplate.update("CREATE TABLE IF NOT EXISTS user ("
                + "id BIGINT IDENTITY NOT NULL PRIMARY KEY,"
                + "email VARCHAR(100) NOT NULL,"
                + "password VARCHAR(100) NOT NULL,"
                + "NAME VARCHAR(100) NOT NULL,"
                + "UNIQUE(EMAIL)");
    }
}

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()) { // result
                if (rs.next()) {
                    return new User( // new User object:
                            rs.getLong("id"), // id
                            rs.getString("email"), // email
                            rs.getString("password"), // password
                            rs.getString("name")); // name
                }
                throw new RuntimeException("user not found by id.");
            }
        }
    });
}

上面优点是,允许获取 Connection,然后做任何基于 Connection 的操作

另一种是从表达式,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( // new User object:
                        rs.getLong("id"), // id
                        rs.getString("email"), // email
                        rs.getString("password"), // password
                        rs.getString("name")); // name
            }
            throw new RuntimeException("user not found by id.");
        }
    });
}

最后是通过 jdbcTemplate.queryForObject 传递 sql 参数,访问结果集 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) -> { // queryForObject 仅返回一项
                // 将 ResultSet 的当前行映射为一个 JavaBean:
                return new User( // new User object:
                        rs.getLong("id"), // id
                        rs.getString("email"), // email
                        rs.getString("password"), // password
                        rs.getString("name")); // name
            },
            email); // 传递参数
}

RowMapper 不一定返回 JavaBean,根据实际 sql语句 返回任何 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 属性一一对应,那么直接使用 BeanPropertyRowMapper

如果表结构和 JavaBean 不一致,可借助指定 别名

SELECT id, email,
office_address AS workAddress,
name FROM users WHERE email = ?
// 表的列名是office_address,而JavaBean属性是workAddress

对于插入、更新和删除等操作,需要使用 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);
}

声明式事务

Spring提供 PlatformTransactionManager 实现事务管理器,事务由 TransactionStatus 表示,手写如下:

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;
}

AppConfig中,需再定义 PlatformTransactionManager 对应的Bean,它的实际类型是 DataSourceTransactionManager

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

声明式事务

除了上述定义的 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) {
       ...
    }
}

回滚事务

事务中报错 RuntimeException,Spring的声明式事务将自动回滚;

在一个事务方法中,如果程序判断需要主动回滚事务,只需抛出 RuntimeException

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

如果要针对多种 Exception 回滚事务,只需在 @Transactional 注解中写出来:

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

实际业务可通过异常体系从 RuntimeException 派生更好地回滚

public class BusinessException extends RuntimeException {
    ...
}

public class LoginException extends BusinessException {
    ...
}

public class PaymentException extends BusinessException {
    ...
}

事务边界与传播

对于简单的声明式事务,事务边界即事务方法开始与结束

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

事务方法内部嵌套时,事务边界

@Component
public class UserService {
    @Autowired
    BonusService bonusService;

    @Transactional
    public User register(String email, String password, String name) {
        // 插入用户记录:
        User user = jdbcTemplate.insert("...");
        // 增加100积分:
        bonusService.addBonus(user.id, 100); // 嵌套的Transactional方法
    }
}

事务传播

Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。

DAO

传统的多层应用程序中,通常是Web层调用业务层,业务层调用数据访问层。业务层负责处理各种业务逻辑,而数据访问层只负责对数据进行增删改查;

Spring 实现数据访问层就是用 JdbcTemplate 实现对数据库的操作。

编写数据访问层的时候,使用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实现,以及对样板代码提取为通用方法

如下面通过抽象一个 AbstractDao,使子类支持调用 getJdbcTemplate()

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

    @PostConstruct
    public void init() {
        super.setJdbcTemplate(jdbcTemplate);
    }
}
@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);
    }
    ...
}

此时子类使用:

@Component
@Transactional
public class UserDao extends AbstractDao<User> {
    // 已经有了:
    // User getById(long)
    // List<User> getAll(int)
    // void deleteById(long)
}

@Component
@Transactional
public class BookDao extends AbstractDao<Book> {
    // 已经有了:
    // Book getById(long)
    // List<Book> getAll(int)
    // void deleteById(long)
}

Hibernate

JdbcTemplate 使用中一个关键是 List<T> query(String, RowMapper, Object...)RowMapper 的作用就是把 ResultSet 的一行记录映射为 Java Bean;

这种把关系数据库的表记录映射为Java对象的过程就是ORM:Object-Relational Mapping。ORM既可以把记录转换成Java对象,也可以把Java对象转换为行记录。

使用 JdbcTemplate 配合 RowMapper 可以看作是最原始的ORM。如果要实现更自动化的ORM,可以选择成熟的ORM框架,例如Hibernate

如何集成

Hibernate作为ORM框架,它可以替代 JdbcTemplate,但Hibernate仍需JDBC驱动,所以,我们需要引入JDBC驱动、连接池,以及Hibernate本身。在Maven中,我们加入以下依赖项:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-orm:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • jakarta.persistence:jakarta.persistence-api:3.1.0
  • org.hibernate:hibernate-core:6.1.4.Final
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.1

AppConfig中,我们仍然需要创建 DataSource、引入JDBC配置文件,以及启用声明式事务:

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

启用Hibernate,我们要创建一个LocalSessionFactoryBean

public class AppConfig {
    @Bean
    LocalSessionFactoryBean createSessionFactory(@Autowired DataSource dataSource) {
        var props = new Properties();
        props.setProperty("hibernate.hbm2ddl.auto", "update"); // 生产环境不要使用
        props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
        props.setProperty("hibernate.show_sql", "true");
        // Properties持有Hibernate初始化SessionFactory时用到的所有设置
        // 参数详见 https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#configurations
        var sessionFactoryBean = new LocalSessionFactoryBean();
        sessionFactoryBean.setDataSource(dataSource);
        // 扫描指定的package获取所有entity class:
        sessionFactoryBean.setPackagesToScan("com.itranswarp.learnjava.entity");
        sessionFactoryBean.setHibernateProperties(props);
        return sessionFactoryBean;
    }
}

LocalSessionFactoryBean 是一个 FactoryBean,它会再自动创建一个SessionFactory

在Hibernate中,Session 是封装了一个JDBC Connection 的实例,而 SessionFactory 是封装了JDBC DataSource 的实例,即 SessionFactory 持有连接池,每次需要操作数据库时候,SessionFactory 创建一个新的 Session,相当于从连接池获取到一个新的 Connection

SessionFactory 就是 Hibernate 提供的最核心的一个对象,但LocalSessionFactoryBean 是Spring提供的为了让我们方便创建 SessionFactory 的类。

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

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`));

JavaBean表示如下:

public class User {
    private Long id;
    private String email;
    private String password;
    private String name;
    private Long createdAt;
    // getters and setters
    ...
}

映射关系十分易懂,但我们需要添加注解告诉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() { ... }
}

@Entity 标记一个JavaBean被用于映射

默认情况下,映射的表名是 user,如果实际的表名不同,例如实际表名是 users,可以追加一个 @Table(name="users")表示:

@Entity
@Table(name="users)
public class User {
    ...
}

@Column() 标识每个属性到数据库列的映射,nullable 指示列是否允许为 NULLupdatable 指示该列是否允许被用在 UPDATE 语句,length 指示 String 类型的列的长度(如果没有指定,默认是 255 )。

对于主键,还需要用 @Id 标识,自增主键再追加一个 @GeneratedValue,以便Hibernate能读取到自增主键的值。

对于每个表,通常我们会统一使用一种主键生成机制,并添加createdAt表示创建时间,updatedAt表示修改时间等通用字段。

重复定义的通用字段,我们可以把它们提到一个抽象类中:

@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());
    }
}

类似UserBook这样的用于ORM的Java Bean,我们通常称之为Entity Bean。

最后,我们来看看如果对user表进行增删改查。因为使用了Hibernate,因此,我们要做的,实际上是对User这个JavaBean进行“增删改查”。我们编写一个UserService,注入SessionFactory

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

MyBatis

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

如何集成

  • org.mybatis:mybatis:3.5.11
  • org.mybatis:mybatis-spring:3.0.0

创建DataSource

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

Hibernate的 SessionFactorySession,MyBatis与之对应的是 SqlSessionFactorySqlSession,分别相当于 DataSourceConnection

同理,MyBatis的核心就是创建 SqlSessionFactory

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

因为MyBatis可以直接使用Spring管理的声明式事务,因此,创建事务管理器和使用JDBC是一样的:

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

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

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

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

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

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

插入

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

如果users表的id是自增主键,那么,我们在SQL中不传入id,但希望获取插入后的主键,需要再加一个@Options注解:

@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("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接口,还需要对应的实现类才能真正执行这些数据库操作的方法。虽然可以自己写实现类,但我们除了编写 UserMapper 接口外,还有 BookMapperBonusMapper 且每一个对应实现类;

因此,MyBatis提供了 MapperFactoryBean 来自动创建所有Mapper的实现类。可以用一个简单的注解来启用它:

@MapperScan("com.itranswarp.learnjava.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;
    }
}

业务逻辑主要就是通过 XxxMapper 定义的数据库方法来访问数据库。

开发web应用

Servlet是JavaEE中Web开发的基础,包括:

  1. Servlet规范定义了几种标准组件:Servlet、JSP、Filter和Listener;
  2. Servlet的标准组件总是运行在Servlet容器中,如Tomcat、Jetty、WebLogic等。

直接使用Servlet进行Web开发好比直接在JDBC上操作数据库,比较繁琐,更好的方法是在Servlet基础上封装MVC框架,基于MVC开发Web应用,大部分时候,不需要接触Servlet API,开发省时省力。

Spring MVC

标准的Servlet组件:

  • Servlet:能处理HTTP请求并将HTTP响应返回;
  • JSP:一种嵌套Java代码的HTML,将被编译为Servlet;
  • Filter:能过滤指定的URL以实现拦截功能;
  • Listener:监听指定的事件,如ServletContext、HttpSession的创建和销毁。

Spring提供的是一个IoC容器,所有的Bean,包括Controller,都在Spring IoC容器中被初始化,而Servlet容器由JavaEE服务器提供(如Tomcat),Servlet容器对Spring一无所知,他们之间到底依靠什么进行联系,又是以何种顺序初始化的?

在理解上述问题之前,我们先把基于Spring MVC开发的项目结构搭建起来。首先创建基于Web的Maven工程,引入如下依赖:

  • org.springframework:spring-context:6.0.0
  • org.springframework:spring-webmvc:6.0.0
  • org.springframework:spring-jdbc:6.0.0
  • jakarta.annotation:jakarta.annotation-api:2.1.1
  • io.pebbletemplates:pebble-spring6:3.2.0
  • ch.qos.logback:logback-core:1.4.4
  • ch.qos.logback:logback-classic:1.4.4
  • com.zaxxer:HikariCP:5.0.1
  • org.hsqldb:hsqldb:2.7.0

以及provided依赖:

  • org.apache.tomcat.embed:tomcat-embed-core:10.1.1
  • org.apache.tomcat.embed:tomcat-embed-jasper:10.1.1

一个标准的 Maven Web 工程目录如下:

logback.xml:Logback的默认查找的配置文件

AppConfig

@EnableWebMvc 启用Spring MVC

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

除了创建 DataSourceJdbcTemplatePlatformTransactionManager外,AppConfig 需要额外创建几个用于Spring MVC的Bean:

WebMvcConfigurer

@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
ViewResolver createViewResolver(@Autowired ServletContext servletContext){
    PebbleEngine  engine = new PebbleEngine.Builder().autoEscaping(true)
            .cacheActive(false)
            .loader(new Servlet5Loader(servletContext))
            .build();
    ViewResolver viewResolver = new PebbleViewResolver(engine);
    viewResolver.setPrefix("/WEB-INF/templates/");
    viewResolver.setSuffix("");
    return viewResolver;
 }

Spring MVC允许集成任何模板引擎,使用哪个模板引擎,就实例化一个对应的 ViewResolver;上述配置使用Pebble引擎,指定模板文件存放在 /WEB-INF/templates/ 目录下,并通过指定 prefixsuffix 来确定如何查找 View

@Controller

Controller必须标记为 @Controller

@Controller  
public class UserController {  
    @Autowired
    UserService userService;  

    @GetMapping("/")  
    public ModelAndView index(){  
      ......
    }  
}

普通 Java 应用程序通过 main 方法创建 Spring 容器实例

public static void main(String[] args) {
    var context = new AnnotationConfigApplicationContext(AppConfig.class);
}

Web应用程序总是由Servlet容器创建,其中Spring容器如何创建:

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.itranswarp.learnjava.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>

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

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

需要接收的HTTP参数以@RequestParam()标注,可以设置默认值。如果方法参数需要传入HttpServletRequestHttpServletResponse或者HttpSession,直接添加这个类型的参数即可,Spring MVC会自动按类型传入。

@PostMapping("/signin")
public ModelAndView doSignin(
  @RequestParam("email") String email,
  @RequestParam("password") String password,
  HttpSession session) {
    ...
    return new ModelAndView("signin.html"); // 仅View,没有Model(第2个参数)
}

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;
}

@RequestMapping

对 url 进行分组

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

@RequestBody

将请求体参数

REST

Spring MVC开发Web应用程序的主要工作就是编写Controller逻辑;

在Web应用中,除了需要使用MVC给用户显示页面外,还有一类API接口,我们称之为REST,通常输入输出都是JSON,便于第三方调用或者使用页面JavaScript与之交互;

Controller中处理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

@ResponseBody 表示返回的String无需额外处理,直接作为输出内容写入HttpServletResponse

输入的JSON则根据注解@RequestBody直接被Spring反序列化为 User 这个JavaBean。

@RestController

使用 @RestController 替代 @Controller ,每个方法自动变成API接口方法。编写ApiController如下:

@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());
        }
    }
}

User 能被正确地序列化为JSON,但暴露了password属性,简单的方法是直接在Userpassword属性定义处加 上@JsonIgnore 表示JSON完全忽略该属性:

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

这会导致所有情况JSON password 输入输出无效,无法读写;另一种是设置只写权限:

public class User {
    ...

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

    ...
}

集成Filter

如果在Spring Mvc 中使用EncodingFilter,在全局范围类给 HttpServletRequestHttpServletResponse 强制设置为UTF-8编码;

自己编写一个EncodingFilter,也可以直接使用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>

如果允许用户使用Basic模式进行用户验证,即在HTTP请求中添加头Authorization: Basic email:password;尝试编写 AuthFilter 实现:

@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);
    }
}

直接在 web.xml 中声明 AuthFilterAuthFilter 的实例将由Servlet容器而不是Spring容器初始化,因此,@Autowire根本不生效;

DelegatingFilterProxy

让Servlet容器实例化的Filter,间接引用Spring容器实例化的AuthFilter。Spring MVC提供了一个 DelegatingFilterProxy

<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>
  1. Servlet容器从web.xml中读取配置,实例化DelegatingFilterProxy,注意命名是authFilter
  2. Spring容器通过扫描@Component实例化AuthFilter

Interceptor

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

与此不同,Spring MVC提供的一种功能类似Filter的拦截器:Interceptor。和Filter相比,Interceptor拦截范围不是后续整个处理流程,而是仅针对Controller拦截:

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

@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)。

最后,要让拦截器生效,我们在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注解的异常处理方法。我们来看具体的示例代码:

@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()));
    }
    ...
}

异常处理方法没有固定的方法签名,可以传入ExceptionHttpServletRequest等,返回值可以是void,也可以是ModelAndView,上述代码通过@ExceptionHandler(RuntimeException.class)表示当发生RuntimeException的时候,就自动调用此方法处理。

CORS

@CrossOrigin

使用@CrossOrigin注解,可以在@RestController的class级别或方法级别定义一个@CrossOrigin,例如:

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

CorsRegistry

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。