Java笔记二十四——Spring开发

Java笔记二十四——Spring开发Spring是一个支持快速开发JavaEE应用程序的框架。它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成,可以说是开发JavaEE应用程序的必备。在SpringFramework(最核心的Spring框架)基础上,又诞生了SpringBoot、SpringCloud、SpringData、SpringSecurity等一系列基于SpringFramework的项目。SpringFrameworkIoC容器容器是一种为某种特定组件的运行提供必要支持的一个软件环

大家好,又见面了,我是你们的朋友全栈君。

Spring是一个支持快速开发Java EE应用程序的框架。它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成,可以说是开发Java EE应用程序的必备。
在Spring Framework(最核心的Spring框架)基础上,又诞生了Spring Boot、Spring Cloud、Spring Data、Spring Security等一系列基于Spring Framework的项目。

Spring Framework

在这里插入图片描述

IoC容器

容器是一种为某种特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。
使用容器运行组件,除了提供一个组件运行环境之外,容器还提供了许多底层服务。例如,Servlet容器底层实现了TCP连接,解析HTTP协议等非常复杂的服务,如果没有容器来提供这些服务,就无法编写像Servlet这样代码简单,功能强大的组件。
Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等。

IoC原理

IoC全称Inversion of Control,直译为控制反转。

如果一个系统有大量的组件(例如在线书店,得维护图书查询、用户服务、数据库组件等等),其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。
核心问题是:

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

解决这一问题的核心方案就是IoC。
传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制,例如:
CartServlet(购物车)创建了BookService(查书),在创建BookService的过程中,又创建了DataSource(数据库服务)组件。这种模式的缺点是,一个组件如果要使用另一个组件,必须先知道如何正确地创建它。
在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,例如,BookService自己并不会创建DataSource,而是等待外部通过setDataSource()方法来注入一个DataSource:

public class BookService { 
   
    private DataSource dataSource;

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

不直接new一个DataSource,而是注入一个DataSource,好处:
在这里插入图片描述
因此,IoC又称为依赖注入(DI:Dependency Injection),它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且,由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>

把id为dataSource的组件通过属性dataSource(即调用setDataSource()方法)注入到另外两个组件中。
在Spring的IoC容器中,所有组件统称为JavaBean,即配置一个组件就是配置一个Bean。

依赖注入方式

依赖注入可以通过set()方法实现。依赖注入也可以通过构造方法实现。
很多Java类都具有带参数的构造方法,如果把BookService改造为通过构造方法注入,实现代码如下:

public class BookService { 
   
    private DataSource dataSource;

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

Spring的IoC容器同时支持属性注入和构造方法注入,并允许混合使用。

无侵入容器

在设计上,Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:

  • 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
  • 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。

装配Bean

使用IoC容器,使用装配好的Bean,看用户注册登录的例子:整个工程的结构如下:
在这里插入图片描述
首先用Maven创建工程并引入spring-context依赖:

<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.itranswarp.learnjava</groupId>
    <artifactId>spring-ioc-appcontext</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

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

        <spring.version>5.2.3.RELEASE</spring.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
    </dependencies>
</project>

编写一个MailService,用于在用户登录和注册成功后发送邮件通知:

public class MailService {
    private ZoneId zoneId = ZoneId.systemDefault();

    public void setZoneId(ZoneId zoneId) {
        this.zoneId = zoneId;
    }

    public String getTime() {
        return ZonedDateTime.now(this.zoneId).format(DateTimeFormatter.ISO_ZONED_DATE_TIME);
    }

    public void sendLoginMail(User user) {
        System.err.println(String.format("Hi, %s! You are logged in at %s", user.getName(), getTime()));
    }

    public void sendRegistrationMail(User user) {
        System.err.println(String.format("Welcome, %s!", user.getName()));

    }
}

再编写一个UserService,实现用户注册和登录:

public class UserService { 
   
    private MailService mailService;
	//UserService通过setMailService()注入了一个MailService。
    public void setMailService(MailService mailService) { 
   
        this.mailService = mailService;
    }

    private List<User> users = new ArrayList<>(List.of( // users:
            new User(1, "bob@example.com", "password", "Bob"), // bob
            new User(2, "alice@example.com", "password", "Alice"), // alice
            new User(3, "tom@example.com", "password", "Tom"))); // tom

    public User login(String email, String password) { 
   
        for (User user : users) { 
   
            if (user.getEmail().equalsIgnoreCase(email) && user.getPassword().equals(password)) { 
   
                mailService.sendLoginMail(user);
                return user;
            }
        }
        throw new RuntimeException("login failed.");
    }

    public User getUser(long id) { 
   
        return this.users.stream().filter(user -> user.getId() == id).findFirst().orElseThrow();
    }

    public User register(String email, String password, String name) { 
   
        users.forEach((user) -> { 
   
            if (user.getEmail().equalsIgnoreCase(email)) { 
   
                throw new RuntimeException("email exist.");
            }
        });
        //创建用户需要ID(+1),email,password,name
        User user = new User(users.stream().mapToLong(u -> u.getId()).max().getAsLong() + 1, email, password, name);
        users.add(user);
        mailService.sendRegistrationMail(user);
        return user;
    }
}

编写一个特定的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.itranswarp.learnjava.service.UserService">
        <property name="mailService" ref="mailService" />
    </bean>

    <bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
</beans>

只关注两个<bean …>的配置:
在这里插入图片描述
把上述XML配置文件用Java代码写出来,就像这样:

UserService userService = new UserService();
MailService mailService = new MailService();
userService.setMailService(mailService);

只不过Spring容器是通过读取XML文件后使用反射完成的。
如果注入的不是Bean,而是boolean、int、String这样的数据类型,则通过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="maximumPoolSize" value="10" />
    <property name="autoCommit" value="true" />
</bean>

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

// 获取Bean:不再通过new的方式
UserService userService = context.getBean(UserService.class);
// 正常调用:
User user = userService.login("bob@example.com", "password");

完整的main()方法如下:

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

Spring容器就是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。
在这里插入图片描述

使用Annotation配置

使用Spring的IoC容器,就是通过XML这样的配置文件把Bean的依赖关系描述出来然后让容器创建并且装配Bean。容器初始化完毕后,直接从容器中获取Bean并使用。优点是Bean一目了然,依赖关系清晰,缺点是写起来繁琐,增加新组件就要配置XML。更简单的方式:使用Annotation配置,完全不需要XML,让Spring自动扫描Bean并组装它们。

删除XML配置文件,然后,给UserService和MailService添加几个注解。首先给MailService添加一个@Component注解:

@Component
public class MailService { 
   
    ...
}

@Component注解就相当于定义了一个Bean,它有一个可选的名称,默认是mailService,即小写开头的类名。
然后给UserService添加一个@Component注解和一个@Autowired注解:

@Component
public class UserService { 
   
    @Autowired
    MailService mailService;

    ...
}

使用@Autowired就相当于把指定类型的Bean注入到指定的字段中。和XML配置相比,@Autowired大幅简化了注入,因为它不但可以写在set()方法上,还可以直接写在字段上,甚至可以写在构造方法中:

@Component
public class UserService { 
   
    MailService mailService;

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

一般把@Autowired写在字段上,通常使用package权限的字段,便于测试。
最后,编写一个AppConfig类启动容器:

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

除了main()方法外,AppConfig标注了@Configuration,表示它是一个配置类,因为我们创建ApplicationContext时:ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);使用的实现类是AnnotationConfigApplicationContext,必须传入一个标注了@Configuration的类名。
此外,AppConfig还标注了**@ComponentScan**,它告诉容器,自动搜索当前类所在的包以及子包,把所有标注为@Component的Bean自动创建出来,并根据@Autowired进行装配。
在这里插入图片描述
在这里插入图片描述
使用@ComponentScan非常方便,但是,我们也要特别注意包的层次结构。通常来说,启动配置AppConfig位于自定义的顶层包(例如com.itranswarp.learnjava),其他Bean按类别放入子包。

定制Bean

Scope

对于Spring容器来说,当我们把一个Bean标记为@Component后,它就会自动为我们创建一个单例(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。例如,注册用户时,我们要对email、password和name这3个变量进行验证。为了便于扩展,我们先定义验证接口:

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) { 
   
        if (!password.matches("^.{6,20}$")) { 
   
            throw new IllegalArgumentException("invalid password");
        }
    }
}

@Component
public class NameValidator implements Validator { 
   
    public void validate(String email, String password, String name) { 
   
        if (name == null || name.isBlank() || name.length() > 20) { 
   
            throw new IllegalArgumentException("invalid name: " + 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);
        }
    }
}

注意到Validators被注入了一个List<Validator>,Spring会自动把所有类型为Validator的Bean装配为一个List注入进来,每新增一个Validator类型,就自动被Spring装配到Validators中。
Spring是通过扫描classpath获取到所有的Bean,而List是有序的,要指定List中Bean的顺序,可以加上@Order注解:

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

@Component
@Order(2)
public class PasswordValidator implements Validator { 
   
    ...
}

@Component
@Order(3)
public class NameValidator implements Validator { 
   
    ...
}

可选注入

标记了一个@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类中编写一个Java方法创建并返回它,注意给方法标记一个@Bean注解:

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

Spring对标记为@Bean的方法只调用一次,因此返回的Bean仍然是单例。

初始化和销毁

一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。通常会定义一个init()方法进行初始化,定义一个shutdown()方法进行清理,然后,引入JSR-250定义的Annotation:

<dependency>
    <groupId>javax.annotation</groupId>
    <artifactId>javax.annotation-api</artifactId>
    <version>1.3.2</version>
</dependency>

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

在这里插入图片描述
而销毁时,容器会首先调用标记有**@PreDestroy的shutdown()**方法。

Spring只根据Annotation查找无参数方法,对方法名不作要求。

使用别名

默认情况下,对一种类型的Bean,容器只创建一个实例。有些时候需要对一种类型的Bean创建多个实例。例如,同时连接多个数据库,就必须创建多个DataSource实例。
如果在@Configuration类中创建了多个同类型的Bean,需要给每个Bean添加不同的名字:

@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(“name”)指定别名,也可以用@Bean+@Qualifier(“name”)指定别名。存在多个同类型的Bean时,注入时要指定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。这种方式也很常用。例如,对于主从两个数据源,通常将主数据源定义为@Primary。

使用FactoryBean

Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。
用工厂模式创建Bean需要实现FactoryBean接口。观察下面的代码:

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

当一个Bean实现了FactoryBean接口后,Spring会先实例化这个工厂,然后调用getObject()创建真正的Bean。getObjectType()可以指定创建的Bean的类型,指定类型不一定与实际类型一致,可以是接口或抽象类。
因此,如果定义了一个FactoryBean,要注意Spring创建的Bean实际上是这个FactoryBean的getObject()方法返回的Bean。为了和普通Bean区分,我们通常都以XxxFactoryBean命名。
在这里插入图片描述

使用Resource

在Java程序中,经常会读取配置文件、资源文件等。使用Spring容器时,可以把“文件”注入进来,方便程序读取。
例如,AppService需要读取logo.txt这个文件,为了定位文件,打开InputStream需要编写复杂的代码。
但是Spring提供了一个org.springframework.core.io.Resource(注意不是javax.annotation.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()就可以获取到输入流,避免了自己搜索文件的代码。

也可以直接指定文件的路径(使用classpath是最简单的方式),例如:
@Value("file:/path/to/logo.txt") private Resource resource;
使用Maven的标准目录结构,所有资源文件放入src/main/resources即可。

注入配置

在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value的形式写在.properties文件中。
例如,MailService根据配置的app.zone=Asia/Shanghai来决定使用哪个时区。要读取配置文件,我们可以使用上一节讲到的Resource来读取位于classpath下的一个app.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正常注入。
在这里插入图片描述
还可以把注入的注解写到方法参数中:

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

先使用@PropertySource读取配置文件,然后通过@Value以${key:defaultValue}的形式注入,可以极大地简化读取配置的麻烦。
另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig:

@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()方法。一个Class名为SmtpConfig的Bean,它在Spring容器中的默认名称就是smtpConfig,除非用@Qualifier指定了名称。

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

使用条件装配

开发应用程序时,会使用开发环境,例如,使用内存数据库以便快速启动。而运行在生产环境时,我们会使用生产环境,例如,使用MySQL数据库。如果应用程序可以根据自身的环境做一些适配,无疑会更加灵活。
Spring为应用程序准备了Profile这一概念,用来表示不同的环境。例如,分别定义开发、测试和生产这3个环境:native、test、production。
创建某个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参数-Dspring.profiles.active=test就可以指定以test环境启动。
实际上,Spring允许指定多个Profile,例如:-Dspring.profiles.active=test,master可以表示test环境,并使用master分支代码。
要满足多个Profile条件,可以这样写:

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

使用Conditional

除了根据@Profile条件来决定是否创建某个Bean外,Spring还可以根据@Conditional决定是否创建某个Bean。
例如,我们对SmtpMailService添加如下注解:

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

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

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 { 
   
    ...
}

以文件存储为例,假设需要保存用户上传的头像,并返回存储路径,在本地开发运行时,总是存储到文件:

@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "file", matchIfMissing = true)
public class FileUploader implements Uploader { 
   
    ...
}

在生产环境运行时,我们会把文件存储到类似AWS S3上:

@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "s3")
public class S3Uploader implements Uploader { 
   
    ...
}

其他需要存储的服务则注入Uploader:

@Component
public class UserImageService { 
   
    @Autowired
    Uploader uploader;
}

当应用程序检测到配置文件存在app.storage=s3时,自动使用S3Uploader,如果存在配置app.storage=file,或者配置app.storage不存在,则使用FileUploader。
可见,使用条件注解,能更灵活地装配Bean。

使用AOP

AOP是Aspect Oriented Programming,即面向切面编程。
OOP:Object Oriented Programming,OOP:面向对象编程的模式。
对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。

考察业务模型可以发现,BookService关系的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。

一种可行的方式是使用Proxy模式,将某个功能,例如,权限检查,放入Proxy中。这种方式的缺点是比较麻烦,必须先抽取接口,然后,针对每个方法实现Proxy。

另一种方法是,既然SecurityCheckBookService的代码都是标准的Proxy样板代码,不如把权限检查视作一种切面(Aspect),把日志、事务也视为切面,然后,以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。
在这里插入图片描述
然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来,就不必编写复杂而冗长的Proxy模式。

AOP原理

AOP需要解决如何把切面织入到核心逻辑中。如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。
在这里插入图片描述
最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。

AOP技术看上去比较神秘,但实际上,它本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。

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

装配AOP

AOP本质上只是一种代理模式的实现方式,在Spring的容器中实现AOP特别方便。
以UserService和MailService为例,这两个属于核心业务逻辑,给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:

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

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>${ 
   spring.version}</version>
</dependency>

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。
然后,我们定义一个LoggingAspect:

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

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

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

在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。

拦截器类型

在这里插入图片描述
小结
在Spring容器中使用AOP非常简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处触发并执行。
Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。

使用注解装配AOP

使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(…))语法来定义应该如何装配AOP。在实际项目中,这种写法很少使用。假设写了一个SecurityAspect:

@Aspect
@Component
public class SecurityAspect { 
   
    @Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")
    public void check() { 
   
        if (SecurityContext.getCurrentUser() == null) { 
   
            throw new RuntimeException("check failed");
        }
    }
}

基本能实现无差别全覆盖,即某个包下面的所有Bean的所有方法都会被这个check()方法拦截。
用方法名前缀进行拦截:——这种方法很不可取

@Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable { 
   
    // 对update开头的方法切换数据源:
    String old = setCurrentDataSource("master");
    Object retVal = pjp.proceed();
    restoreCurrentDataSource(old);
    return retVal;
}

在使用AOP时,要注意到虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且,后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。
使用AOP时,被装配的Bean最好自己能清清楚楚地知道自己被安排了。例如,Spring提供的@Transactional就是一个非常好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional:

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

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

    // 有事务:
    @Transactional
    public void updateUser(User user) { 
   
        ...
    }
}

或者直接在class级别注解,表示“所有public方法都被安排了”:

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

通过@Transactional,某个方法是否启用了事务就一清二楚了。因此,装配AOP的时候,使用注解是最好的方式。
以一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:

@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不是MetricTime),我们通过它获取性能监控的名称。

有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。
在这里插入图片描述

AOP避坑指南(没学好)

无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。
Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。
在这里插入图片描述
在这里插入图片描述

访问数据库

使用JDBC

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

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

正确编写JDBC代码的关键是使用try … finally释放资源,涉及到事务的代码需要正确提交或回滚事务。
在Spring使用JDBC,首先通过IoC容器创建并管理一个DataSource实例,然后使用JdbcTemplate操作JDBC。因此,通常情况下,会实例化一个JdbcTemplate。

编写示例代码或者测试代码时,我们强烈推荐使用HSQLDB这个数据库,它是一个用Java编写的关系数据库,可以以内存模式或者文件模式运行,本身只有一个jar包,非常适合演示代码或者测试代码。

以实际工程为例,先创建Maven工程spring-data-jdbc,然后引入以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>5.2.0.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>5.2.0.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>javax.annotation</groupId>
        <artifactId>javax.annotation-api</artifactId>
        <version>1.3.2</version>
    </dependency>
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>3.4.2</version>
    </dependency>
    <dependency>
        <groupId>org.hsqldb</groupId>
        <artifactId>hsqldb</artifactId>
        <version>2.5.0</version>
    </dependency>
</dependencies>

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

在上述配置中:

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

最后,针对HSQLDB写一个配置文件jdbc.properties:

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

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

可以通过HSQLDB自带的工具来初始化数据库表,写一个Bean,在Spring容器启动时自动创建一个users表:

@Component
public class DatabaseInitializer { 
   
    @Autowired
    JdbcTemplate jdbcTemplate;

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

准备工作都已完毕。只需要在需要访问数据库的Bean中,注入JdbcTemplate即可:

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

JdbcTemplate的用法

Spring提供的JdbcTemplate采用Template模式,提供了一系列以回调为特点的工具方法,目的是避免繁琐的try…catch语句。
以具体的示例来说明JdbcTemplate的用法。
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( // 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.");
        }
    });
}

T queryForObject(String sql, Object[] args, RowMapper<T> rowMapper)方法:

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

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

RowMapper不一定返回JavaBean,实际上它可以返回任何Java对象。例如,使用SELECT COUNT(*)查询时,可以返回Long:

public long getUsers() { 
   
    return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", null, (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 Object[] { 
    limit, offset },
            new BeanPropertyRowMapper<>(User.class));
}

query()方法传入的参数仍然是SQL、SQL参数以及RowMapper实例。直接使用Spring提供的BeanPropertyRowMapper。如果数据库表的结构恰好和JavaBean的属性名称一致,那么BeanPropertyRowMapper就可以直接把一行记录按列名转换为JavaBean。

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

public void updateUser(User user) { 
   
    // 传入SQL,SQL参数,返回更新的行数:
    if (1 != jdbcTemplate.update("UPDATE user 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还有许多重载方法。JdbcTemplate只是对JDBC操作的一个简单封装,它的目的是尽量减少手动编写try(resource) {…}的代码,对于查询,主要通过RowMapper实现了JDBC结果集到Java对象的转换。
在这里插入图片描述
实际上我们使用最多的仍然是各种查询。如果在设计表结构的时候,能够和JavaBean的属性一一对应,那么直接使用BeanPropertyRowMapper就很方便。如果表结构和JavaBean不一致怎么办?那就需要稍微改写一下查询,使结果集的结构和JavaBean保持一致。
例如,表的列名是office_address,而JavaBean属性是workAddress,就需要指定别名,改写查询如下:SELECT id, email, office_address AS workAddress, name FROM users WHERE email = ?

使用声明式事务

如果要在Spring中操作事务,没必要手写JDBC事务,可以使用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实现:

public class UserService$$EnhancerBySpringCGLIB extends UserService { 
   
    UserService target = ...
    PlatformTransactionManager txManager = ...

    public User register(String email, String password, String name) { 
   
        TransactionStatus tx = null;
        try { 
   
            tx = txManager.getTransaction(new DefaultTransactionDefinition());
            target.register(email, password, name);
            txManager.commit(tx);
        } catch (RuntimeException e) { 
   
            txManager.rollback(tx);
            throw e;
        }
    }
    ...
}

声明了@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的声明式事务正常工作:

public class BusinessException extends RuntimeException { 
   
    ...
}

public class LoginException extends BusinessException { 
   
    ...
}

public class PaymentException extends BusinessException { 
   
    ...
}

事务边界

在使用事务的时候,明确事务边界非常重要。对于声明式事务,例如,下面的register()方法:

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

它的事务边界就是register()方法开始和结束。
类似的,一个负责给用户增加积分的addBonus()方法:

@Component
public class BonusService { 
   
    @Transactional
    public void addBonus(long userId, int bonus) { 
    // 事务开始
       ...
    } // 事务结束
}

它的事务边界就是addBonus()方法开始和结束。

在现实世界中,问题总是要复杂一点点。用户注册后,能自动获得100积分,因此,实际代码如下:

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

事务传播

假设用户注册的入口是RegisterController,它本身没有事务,仅仅是调用UserService.register()这个事务方法:

@Controller
public class RegisterController { 
   
    @Autowired
    UserService userService;

    @PostMapping("/register")
    public ModelAndView doRegister(HttpServletRequest req) { 
   
        String email = req.getParameter("email");
        String password = req.getParameter("password");
        String name = req.getParameter("name");
        User user = userService.register(email, password, name);
        return ...
    }
}

因此,UserService.register()这个事务方法的起始和结束,就是事务的范围。
在UserService.register()这个事务方法内,调用BonusService.addBonus(),期待的事务行为是什么:

@Transactional
public User register(String email, String password, String name) { 
   
    // 事务已开启:
    User user = jdbcTemplate.insert("...");
    // ???:
    bonusService.addBonus(user.id, 100);
} // 事务结束

对于大多数业务来说,我们期待BonusService.addBonus()的调用,和UserService.register()应当融合在一起,它的行为应该如下:

UserService.register()已经开启了一个事务,那么在内部调用BonusService.addBonus()时,BonusService.addBonus()方法就没必要再开启一个新事务,直接加入到BonusService.register()的事务里就好了。
其实就相当于:

  • UserService.register()先执行了一条INSERT语句:INSERT INTO users …
  • BonusService.addBonus()再执行一条INSERT语句:INSERT INTO bonus …

因此,Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。
观察UserService.register()方法,它在RegisterController中执行,因为RegisterController没有事务,因此,UserService.register()方法会自动创建一个新事务。
在UserService.register()方法内部,调用BonusService.addBonus()方法时,因为BonusService.addBonus()检测到当前已经有事务了,因此,它会加入到当前事务中执行。
因此,整个业务流程的事务边界就清晰了:它只有一个事务,并且范围就是UserService.register()方法。

Spring总是把JDBC相关的Connection和TransactionStatus实例绑定到ThreadLocal。如果一个事务方法从ThreadLocal未取到事务,那么它会打开一个新的JDBC连接,同时开启一个新的事务,否则,它就直接使用从ThreadLocal获取的JDBC连接以及TransactionStatus。事务只能在当前线程传播,无法跨线程传播。

使用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没什么复杂的,核心代码就是持有一个JdbcTemplate:

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,还得自己想个办法:

@Component
@Transactional
public class UserDao extends JdbcDaoSupport { 
   
    @Autowired
    JdbcTemplate jdbcTemplate;

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

使用传统的XML配置,并不需要编写@Autowired JdbcTemplate jdbcTemplate,但是考虑到现在基本上是使用注解的方式,我们可以编写一个AbstractDao,专门负责注入JdbcTemplate:

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

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

这样,子类的代码就非常干净,可以直接调用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);
    }
    ...
}

这样,每个子类就自动获得了这些通用方法:

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

DAO模式就是一个简单的数据访问模式,是否使用DAO,根据实际情况决定,很多时候,直接在Service层操作数据库也是完全没有问题的。

集成Hibernate

使用JdbcTemplate的时候,我们用得最多的方法就是List query(String sql, Object[] args, RowMapper rowMapper)。这个RowMapper的作用就是把ResultSet的一行记录映射为Java Bean。

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

使用JdbcTemplate配合RowMapper可以看作是最原始的ORM。如果要实现更自动化的ORM,可以选择成熟的ORM框架,例如Hibernate。
如何在Spring中集成Hibernate: Hibernate作为ORM框架,它可以替代JdbcTemplate,但Hibernate仍然需要JDBC驱动,所以,我们需要引入JDBC驱动、连接池,以及Hibernate本身。在Maven中,我们加入以下依赖项:

<!-- JDBC驱动,这里使用HSQLDB -->
<dependency>
    <groupId>org.hsqldb</groupId>
    <artifactId>hsqldb</artifactId>
    <version>2.5.0</version>
</dependency>

<!-- JDBC连接池 -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.4.2</version>
</dependency>

<!-- Hibernate -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.4.2.Final</version>
</dependency>

<!-- Spring Context和Spring ORM -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.2.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-orm</artifactId>
    <version>5.2.0.RELEASE</version>
</dependency>

在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");
        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的类。

注意到上面创建LocalSessionFactoryBean的代码,首先用Properties持有Hibernate初始化SessionFactory时用到的所有设置,常用的设置请参考Hibernate文档,这里我们只定义了3个设置:

  • hibernate.hbm2ddl.auto=update:表示自动创建数据库的表结构,注意不要在生产环境中启用;
  • hibernate.dialect=org.hibernate.dialect.HSQLDialect:指示Hibernate使用的数据库是HSQLDB。Hibernate使用一种HQL的- 查询语句,它和SQL类似,但真正在“翻译”成SQL时,会根据设定的数据库“方言”来生成针对数据库优化的SQL;
  • hibernate.show_sql=true:让Hibernate打印执行的SQL,这对于调试非常有用,我们可以方便地看到Hibernate生成的SQL语句是否符合我们的预期。

除了设置DataSource和Properties之外,注意到setPackagesToScan()我们传入了一个package名称,它指示Hibernate扫描这个包下面的所有Java类,自动找出能映射为数据库表记录的JavaBean。后面我们会仔细讨论如何编写符合Hibernate要求的JavaBean。
紧接着,我们还需要创建HibernateTemplate以及HibernateTransactionManager:

public class AppConfig { 
   
    @Bean
    HibernateTemplate createHibernateTemplate(@Autowired SessionFactory sessionFactory) { 
   
        return new HibernateTemplate(sessionFactory);
    }

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

这两个Bean的创建都十分简单。HibernateTransactionManager是配合Hibernate使用声明式事务所必须的,而HibernateTemplate则是Spring为了便于使用Hibernate提供的工具类,不是非用不可,但推荐使用以简化代码。

所有的配置都定义完毕,看看如何将数据库表结构映射为Java对象。
考察如下的数据库表:

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

其中,id是自增主键,email、password、name是VARCHAR类型,email带唯一索引以确保唯一性,createdAt存储整型类型的时间戳。用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() { 
    ... }
}

如果一个JavaBean被用于映射,我们就标记一个@Entity。默认情况下,映射的表名是user,如果实际的表名不同,例如实际表名是users,可以追加一个@Table(name=“users”)表示。
每个属性到数据库列的映射用@Column()标识,nullable指示列是否允许为NULL,updatable指示该列是否允许被用在UPDATE语句,length指示String类型的列的长度(如果没有指定,默认是255)。
对于主键,还需要用@Id标识,自增主键再追加一个@GeneratedValue,以便Hibernate能读取到自增主键的值。
使用Hibernate时,不要使用基本类型的属性,总是使用包装类型,如Long或Integer。
使用Spring集成Hibernate,配合JPA注解,无需任何额外的XML配置。
类似User、Book这样的用于ORM的Java Bean,我们通常称之为Entity Bean。

如果对user表进行增删改查。因为使用了Hibernate,因此,我们要做的,实际上是对User这个JavaBean进行“增删改查”。我们编写一个UserService,注入HibernateTemplate以便简化代码:

@Component
@Transactional
public class UserService { 
   
    @Autowired
    HibernateTemplate hibernateTemplate;
}

Insert操作

要持久化一个User实例,我们只需调用save()方法。以register()方法为例,代码如下:

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

Delete操作

删除一个User相当于从表中删除对应的记录。注意Hibernate总是用id来删除记录,因此,要正确设置User的id属性才能正常删除记录:

public boolean deleteUser(Long id) { 
   
    User user = hibernateTemplate.get(User.class, id);
    if (user != null) { 
   
        hibernateTemplate.delete(user);
        return true;
    }
    return false;
}

通过主键删除记录时,一个常见的用法是先根据主键加载该记录,再删除。load()和get()都可以根据主键加载记录,它们的区别在于,当记录不存在时,get()返回null,而load()抛出异常。

Update操作

更新记录相当于先更新User的指定属性,然后调用update()方法:

public void updateUser(Long id, String name) { 
   
    User user = hibernateTemplate.load(User.class, id);
    user.setName(name);
    hibernateTemplate.update(user);
}

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

大部分方法都是各种各样的查询。根据id查询可以直接调用load()或get(),如果要使用条件查询,有3种方法。
假设我们想执行以下查询:SELECT * FROM user WHERE email = ? AND password = ?

使用Example查询

第一种方法是使用findByExample(),给出一个User实例,Hibernate把该实例所有非null的属性拼成WHERE条件:

public User login(String email, String password) { 
   
    User example = new User();
    example.setEmail(email);
    example.setPassword(password);
    List<User> list = hibernateTemplate.findByExample(example);
    return list.isEmpty() ? null : list.get(0);
}

因为example实例只有email和password两个属性为非null,所以最终生成的WHERE语句就是WHERE email = ? AND password = ?。

如果把User的createdAt的类型从Long改为long,findByExample()的查询将出问题,原因在于example实例的long类型字段有了默认值0,导致Hibernate最终生成的WHERE语句变成了WHERE email = ? AND password = ? AND createdAt = 0。显然,额外的查询条件将导致错误的查询结果。
使用findByExample()时,注意基本类型字段总是会加入到WHERE条件!

使用Criteria查询

实现如下:

public User login(String email, String password) { 
   
    DetachedCriteria criteria = DetachedCriteria.forClass(User.class);
    criteria.add(Restrictions.eq("email", email))
            .add(Restrictions.eq("password", password));
    List<User> list = (List<User>) hibernateTemplate.findByCriteria(criteria);
    return list.isEmpty() ? null : list.get(0);
}

DetachedCriteria使用链式语句来添加多个AND条件。和findByExample()相比,findByCriteria()可以组装出更灵活的WHERE条件,例如:SELECT * FROM user WHERE (email = ? OR name = ?) AND password = ?
上述查询没法用findByExample()实现,但用Criteria查询可以实现如下:

DetachedCriteria criteria = DetachedCriteria.forClass(User.class);
criteria.add(
    Restrictions.and(
        Restrictions.or(
            Restrictions.eq("email", email),
            Restrictions.eq("name", email)
        ),
		Restrictions.eq("password", password)
    )
);

只要组织好Restrictions的嵌套关系,Criteria查询可以实现任意复杂的查询。

使用HQL查询

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

List<User> list = (List<User>) hibernateTemplate.find("FROM User WHERE email=? AND password=?", email, password);

和SQL相比,HQL使用类名和属性名,由Hibernate自动转换为实际的表名和列名。详细的HQL语法可以参考Hibernate文档。

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

@NamedQueries(
    @NamedQuery(
        // 查询名称:
        name = "login",
        // 查询语句:
        query = "SELECT u FROM User u WHERE u.email=?0 AND u.password=?1"
    )
)
@Entity
public class User extends AbstractEntity { 
   
    ...
}

引入的NamedQuery是javax.persistence.NamedQuery,它和直接传入HQL有点不同的是,占位符使用?0、?1,并且索引是从0开始的。使用NamedQuery只需要引入查询名和参数:

public User login(String email, String password) { 
   
    List<User> list = (List<User>) hibernateTemplate.findByNamedQuery("login", email, password);
    return list.isEmpty() ? null : list.get(0);
}

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

使用Hibernate原生接口

使用Hibernate的原生接口实际上总是从SessionFactory出发,它通常用全局变量存储,在HibernateTemplate中以成员变量被注入。有了SessionFactory,使用Hibernate用法如下:

void operation() { 
   
    Session session = null;
    boolean isNew = false;
    // 获取当前Session或者打开新的Session:
    try { 
   
        session = this.sessionFactory.getCurrentSession();
    } catch (HibernateException e) { 
   
        session = this.sessionFactory.openSession();
        isNew = true;
    }
    // 操作Session:
    try { 
   
        User user = session.load(User.class, 123L);
    }
    finally { 
   
        // 关闭新打开的Session:
        if (isNew) { 
   
            session.close();
        }
    }
}

在这里插入图片描述

集成JPA

JPA就是JavaEE的一个ORM标准,它的实现其实和Hibernate没啥本质区别,但是用户如果使用JPA,那么引用的就是javax.persistence这个“标准”包,而不是org.hibernate这样的第三方包。因为JPA只是接口,所以,还需要选择一个实现产品,跟JDBC接口和MySQL驱动一个道理。

使用JPA时也完全可以选择Hibernate作为底层实现,但也可以选择其它的JPA提供方,比如EclipseLink。Spring内置了JPA的集成,并支持选择Hibernate或EclipseLink作为实现。这里以主流的Hibernate作为JPA实现为例子,演示JPA的基本用法。
在这里插入图片描述
然后,在AppConfig中启用声明式事务管理,创建DataSource:

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

使用Hibernate时,需要创建一个LocalSessionFactoryBean,并让它再自动创建一个SessionFactory。使用JPA也是类似的,需要创建一个LocalContainerEntityManagerFactoryBean,并让它再自动创建一个EntityManagerFactory:

@Bean
LocalContainerEntityManagerFactoryBean createEntityManagerFactory(@Autowired DataSource dataSource) { 
   
    var entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
    // 设置DataSource:
    entityManagerFactoryBean.setDataSource(dataSource);
    // 扫描指定的package获取所有entity class:
    entityManagerFactoryBean.setPackagesToScan("com.itranswarp.learnjava.entity");
    // 指定JPA的提供商是Hibernate:
    JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
    entityManagerFactoryBean.setJpaVendorAdapter(vendorAdapter);
    // 设定特定提供商自己的配置:
    var props = new Properties();
    props.setProperty("hibernate.hbm2ddl.auto", "update");
    props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
    props.setProperty("hibernate.show_sql", "true");
    entityManagerFactoryBean.setJpaProperties(props);
    return entityManagerFactoryBean;
}

除了需要注入DataSource和设定自动扫描的package外,还需要指定JPA的提供商,这里使用Spring提供的一个HibernateJpaVendorAdapter,最后,针对Hibernate自己需要的配置,以Properties的形式注入。
最后还需要实例化一个JpaTransactionManager,以实现声明式事务:

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

使用Spring+Hibernate作为JPA实现,无需任何配置文件。
UserService为例,除了标注@Component和@Transactional外,我们需要注入一个EntityManager,但是不要使用Autowired,而是@PersistenceContext:

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

在这里插入图片描述
SessionFactory和EntityManagerFactory相当于DataSource,Session和EntityManager相当于Connection。每次需要访问数据库的时候,需要获取新的Session和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;
}

建议写JPQL查询,它的语法和HQL基本差不多:

public User getUserByEmail(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()) { 
   
        throw new RuntimeException("User not found by email.");
    }
    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("p", password);
    List<User> list = query.getResultList();
    return list.isEmpty() ? null : list.get(0);
}

NamedQuery通过注解标注在User类上:

@NamedQueries(
    @NamedQuery(
        name = "login",
        query = "SELECT u FROM User u WHERE u.email=:e AND u.password=:p"
    )
)
@Entity
public class User { 
   
    ...
}

对数据库进行增删改的操作,可以分别使用persist()、remove()和merge()方法,参数均为Entity Bean本身,使用非常简单.
在这里插入图片描述

集成MyBatis

使用Hibernate或JPA操作数据库时,这类ORM干的主要工作就是把ResultSet的每一行变成Java Bean,或者把Java Bean自动转换到INSERT或UPDATE语句的参数中,从而实现ORM。

而ORM框架之所以知道如何把行数据映射到Java Bean,是因为在Java Bean的属性上给了足够的注解作为元数据,ORM框架获取Java Bean的注解后,就知道如何进行双向映射。

那么,ORM框架是如何跟踪Java Bean的修改,以便在update()操作中更新必要的属性?

答案是使用Proxy模式,从ORM框架读取的User实例实际上并不是User类,而是代理类,代理类继承自User类,但针对每个setter方法做了覆写:

public class UserProxy extends User { 
   
    boolean _isNameChanged;

    public void setName(String name) { 
   
        super.setName(name);
        _isNameChanged = true;
    }
}

这样,代理类可以跟踪到每个属性的变化。
针对一对多或多对一关系时,代理类可以直接通过getter方法查询数据库:

public class UserProxy extends User { 
   
    Session _session;
    boolean _isNameChanged;

    public void setName(String name) { 
   
        super.setName(name);
        _isNameChanged = true;
    }

    /** * 获取User对象关联的Address对象: */
    public Address getAddress() { 
   
        Query q = _session.createQuery("from Address where userId = :userId");
        q.setParameter("userId", this.getId());
        List<Address> list = query.list();
        return list.isEmpty() ? null : list(0);
    }
}

为了实现这样的查询,UserProxy必须保存Hibernate的当前Session。但是,当事务提交后,Session自动关闭,此时再获取getAddress()将无法访问数据库,或者获取的不是事务一致的数据。因此,ORM框架总是引入了Attached/Detached状态,表示当前此Java Bean到底是在Session的范围内,还是脱离了Session变成了一个“游离”对象。
此外,Hibernate和JPA为了实现兼容多种数据库,使用HQL或JPQL查询,经过一道转换,变成特定数据库的SQL,理论上这样可以做到无缝切换数据库,但这一层自动转换除了少许的性能开销外,给SQL级别的优化带来了麻烦。

ORM框架通常提供了缓存,并且还分为一级缓存和二级缓存。一级缓存是指在一个Session范围内的缓存,常见的情景是根据主键查询时,两次查询可以返回同一实例:

User user1 = session.load(User.class, 123);
User user2 = session.load(User.class, 123);

二级缓存是指跨Session的缓存,一般默认关闭,需要手动配置。二级缓存极大的增加了数据的不一致性,原因在于SQL非常灵活,常常会导致意外的更新。例如:

// 线程1读取:
User user1 = session1.load(User.class, 123);
...
// 一段时间后,线程2读取:
User user2 = session2.load(User.class, 123);

当二级缓存生效的时候,两个线程读取的User实例是一样的,但是,数据库对应的行记录完全可能被修改,例如:

-- 给老用户增加100积分:
UPDATE users SET bonus = bonus + 100 WHERE createdAt <= ?

ORM无法判断id=123的用户是否受该UPDATE语句影响。考虑到数据库通常会支持多个应用程序,此UPDATE语句可能由其他进程执行,ORM框架就更不知道了。这种ORM框架称之为全自动ORM框架。

对比Spring提供的JdbcTemplate,它和ORM框架相比,主要有几点差别:

  • 1、查询后需要手动提供Mapper实例以便把ResultSet的每一行变为Java对象;
  • 2、增删改操作所需的参数列表,需要手动传入,即把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.4
  • org.mybatis:mybatis-spring:2.0.4

先创建DataSource是必不可少的:

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

再回顾一下Hibernate和JPA的SessionFactory与EntityManagerFactory,MyBatis与之对应的是SqlSessionFactory和SqlSession:
在这里插入图片描述
ORM的设计套路都是类似的。使用MyBatis的核心就是创建SqlSessionFactory,这里需要创建的是SqlSessionFactoryBean:

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

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

执行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注解:

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

keyProperty和keyColumn分别指出JavaBean的属性和数据库的主键列名。
执行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接口,还需要对应的实现类才能真正执行这些数据库操作的方法。虽然可以自己写实现类,但我们除了编写UserMapper接口外,还有BookMapper、BonusMapper……一个一个写太麻烦,因此,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;
    }
}

XML配置

在Spring中集成MyBatis的方式,只需要用到注解,并没有任何XML配置文件。MyBatis也允许使用XML配置映射关系和SQL语句,例如,更新User时根据属性值构造动态SQL:

<update id="updateUser">
  UPDATE users SET
  <set>
    <if test="user.name != null"> name = #{user.name} </if>
    <if test="user.hobby != null"> hobby = #{user.hobby} </if>
    <if test="user.summary != null"> summary = #{user.summary} </if>
  </set>
  WHERE id = #{user.id}
</update>

编写XML配置的优点是可以组装出动态SQL,并且把所有SQL操作集中在一起。缺点是配置起来太繁琐,调用方法时如果想查看SQL还需要定位到XML配置中。

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

MyBatis是一个半自动化的ORM框架,需要手写SQL语句,没有自动加载一对多或多对一关系的功能

设计ORM

ORM,是建立在JDBC的基础上,通过ResultSet到JavaBean的映射,实现各种查询。有自动跟踪Entity修改的全自动化ORM如Hibernate和JPA,需要为每个Entity创建代理,也有完全自己映射,连INSERT和UPDATE语句都需要手动编写的MyBatis,但没有任何透明的Proxy。

查询是涉及到数据库使用最广泛的操作,需要最大的灵活性。各种ORM解决方案各不相同,Hibernate和JPA自己实现了HQL和JPQL查询语法,用以生成最终的SQL,而MyBatis则完全手写,每增加一个查询都需要先编写SQL并增加接口方法。

还有一种Hibernate和JPA支持的Criteria查询,用Hibernate写出来类似:

DetachedCriteria criteria = DetachedCriteria.forClass(User.class);
criteria.add(Restrictions.eq("email", email))
        .add(Restrictions.eq("password", password));
List<User> list = (List<User>) hibernateTemplate.findByCriteria(criteria);

上述Criteria查询写法复杂,但和JPA相比,还是小巫见大巫了:

var cb = em.getCriteriaBuilder();
CriteriaQuery<User> q = cb.createQuery(User.class);
Root<User> r = q.from(User.class);
q.where(cb.equal(r.get("email"), cb.parameter(String.class, "e")));
TypedQuery<User> query = em.createQuery(q);
query.setParameter("e", email);
List<User> list = query.getResultList();

是否支持自动读取一对多和多对一关系也是全自动化ORM框架的一个重要功能。

设计ORM接口

准备实现的ORM并不想要全自动ORM那种自动读取一对多和多对一关系的功能,也不想给Entity加上复杂的状态,因此,对于Entity来说,它就是纯粹的JavaBean,没有任何Proxy。
此外,ORM要兼顾易用性和适用性。易用性是指能覆盖95%的应用场景,但总有一些复杂的SQL,很难用ORM去自动生成,因此,也要给出原生的JDBC接口,能支持5%的特殊需求。
设计的接口要易于编写,并使用流式API便于阅读。为了配合编译器检查,还应该支持泛型,避免强制转型。
以User类为例,设计的查询接口如下:

// 按主键查询: SELECT * FROM users WHERE id = ?
User u = db.get(User.class, 123);

// 条件查询唯一记录: SELECT * FROM users WHERE email = ? AND password = ?
User u = db.from(User.class)
           .where("email=? AND password=?", "bob@example.com", "bob123")
           .unique();

// 条件查询多条记录: SELECT * FROM users WHERE id < ? ORDER BY email LIMIT ?, ?
List<User> us = db.from(User.class)
                  .where("id < ?", 1000)
                  .orderBy("email")
                  .limit(0, 10)
                  .list();

// 查询特定列: SELECT id, name FROM users WHERE email = ?
User u = db.select("id", "name")
           .from(User.class)
           .where("email = ?", "bob@example.com")
           .unique();

流式API便于阅读,也非常容易推导出最终生成的SQL。
对于插入、更新和删除操作,就相对比较简单:

// 插入User:
db.insert(user);

// 按主键更新更新User:
db.update(user);

// 按主键删除User:
db.delete(User.class, 123);

对于Entity来说,通常一个表对应一个。手动列出所有Entity是非常麻烦的,一定要传入package自动扫描。
最后,ORM总是需要元数据才能知道如何映射。不想编写复杂的XML配置,也没必要自己去定义一套规则,直接使用JPA的注解就行。

实现ORM

不需要从JDBC底层开始编写,最好能直接使用Spring的声明式事务。实际上,可以设计一个全局DbTemplate,它注入了Spring的JdbcTemplate,涉及到数据库操作时,全部通过JdbcTemplate完成,自然天生支持Spring的声明式事务,因为这个ORM只是在JdbcTemplate的基础上做了一层封装。

在AppConfig中,初始化所有Bean如下:

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

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

    @Bean
    DbTemplate createDbTemplate(@Autowired JdbcTemplate jdbcTemplate) { 
   
        return new DbTemplate(jdbcTemplate, "com.itranswarp.learnjava.entity");
    }

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

编写业务逻辑,例如UserService,写出来像这样:

@Component
@Transactional
public class UserService { 
   
    @Autowired
    DbTemplate db;

    public User getUserById(long id) { 
   
        return db.get(User.class, id);
    }

    public User getUserByEmail(String email) { 
   
        return db.from(User.class)
                 .where("email = ?", email)
                 .unique();
    }

    public List<User> getUsers(int pageIndex) { 
   
        int pageSize = 100;
        return db.from(User.class)
                 .orderBy("id")
                 .limit((pageIndex - 1) * pageSize, pageSize)
                 .list();
    }

    public User register(String email, String password, String name) { 
   
        User user = new User();
        user.setEmail(email);
        user.setPassword(password);
        user.setName(name);
        user.setCreatedAt(System.currentTimeMillis());
        db.insert(user);
        return user;
    }
    ...
}

实现这个DbTemplate:

public class DbTemplate { 
   
    private JdbcTemplate jdbcTemplate;

    // 保存Entity Class到Mapper的映射:
    private Map<Class<?>, Mapper<?>> classMapping;

    public <T> T fetch(Class<T> clazz, Object id) { 
   
        Mapper<T> mapper = getMapper(clazz);
        List<T> list = (List<T>) jdbcTemplate.query(mapper.selectSQL, new Object[] { 
    id }, mapper.rowMapper);
        if (list.isEmpty()) { 
   
            return null;
        }
        return list.get(0);
    }

    public <T> T get(Class<T> clazz, Object id) { 
   
        ...
    }

    public <T> void insert(T bean) { 
   
        ...
    }

    public <T> void update(T bean) { 
   
        ...
    }

    public <T> void delete(Class<T> clazz, Object id) { 
   
        ...
    }
}

实现链式API的核心代码是第一步从DbTemplate调用select()或from()时实例化一个CriteriaQuery实例,并在后续的链式调用中设置它的字段:

public class DbTemplate { 
   
    ...
    public Select select(String... selectFields) { 
   
        return new Select(new Criteria(this), selectFields);
    }

    public <T> From<T> from(Class<T> entityClass) { 
   
        Mapper<T> mapper = getMapper(entityClass);
        return new From<>(new Criteria<>(this), mapper);
    }
}

以此定义Select、From、Where、OrderBy、Limit等。在From中可以设置Class类型、表名等:

public final class From<T> extends CriteriaQuery<T> { 
   
    From(Criteria<T> criteria, Mapper<T> mapper) { 
   
        super(criteria);
        // from可以设置class、tableName:
        this.criteria.mapper = mapper;
        this.criteria.clazz = mapper.entityClass;
        this.criteria.table = mapper.tableName;
    }

    public Where<T> where(String clause, Object... args) { 
   
        return new Where<>(this.criteria, clause, args);
    }
}

在Where中可以设置条件参数:

public final class Where<T> extends CriteriaQuery<T> { 
   
    Where(Criteria<T> criteria, String clause, Object... params) { 
   
        super(criteria);
        this.criteria.where = clause;
        this.criteria.whereParams = new ArrayList<>();
        // add:
        for (Object param : params) { 
   
            this.criteria.whereParams.add(param);
        }
    }
}

链式调用的尽头是调用list()返回一组结果,调用unique()返回唯一结果,调用first()返回首个结果。
需要复杂查询的时候,总是可以使用JdbcTemplate执行任意复杂的SQL。
ORM框架就是自动映射数据库表结构到JavaBean的工具,设计并实现一个简单高效的ORM框架并不困难。

开发Web应用

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

Spring虽然都可以集成任何Web框架,但是,Spring本身也开发了一个MVC框架,就叫Spring MVC。这个MVC框架设计得足够优秀以至于不想再费劲去集成类似Struts这样的框架了。

使用Spring MVC

Servlet容器,以及标准的Servlet组件:
在这里插入图片描述
Servlet容器为每个Web应用程序自动创建一个唯一的ServletContext实例,这个实例就代表了Web应用程序本身。
Spring提供的是一个IoC容器,所有的Bean,包括Controller,都在Spring IoC容器中被初始化,而Servlet容器由JavaEE服务器提供(如Tomcat),Servlet容器和Spring之前如何联系?
先把基于Spring MVC开发的项目结构搭建起来。首先创建基于Web的Maven工程,引入如下依赖:
在这里插入图片描述
这个标准的Maven Web工程目录结构如下:
在这里插入图片描述
src/main/webapp是标准web目录,WEB-INF存放web.xml,编译的class,第三方jar,以及不允许浏览器直接访问的View模版,static目录存放所有静态文件。

在src/main/resources目录中存放的是Java程序读取的classpath资源文件,除了JDBC的配置文件jdbc.properties外,又新增了一个logback.xml,这是Logback的默认查找的配置文件:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
	<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
			<Pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</Pattern>
		</layout>
	</appender>

	<logger name="com.itranswarp.learnjava" level="info" additivity="false">
		<appender-ref ref="STDOUT" />
	</logger>

	<root level="info">
		<appender-ref ref="STDOUT" />
	</root>
</configuration>

上面给出了一个写入到标准输出的Logback配置,可以基于上述配置添加写入到文件的配置。
在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/**。

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

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

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

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

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

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

普通的Java应用程序,可以通过main()方法可以很简单地创建一个Spring容器的实例:

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

而Web应用容器总是由Servlet容器创建,在Web应用中启动Spring容器有很多种方法,可以通过Listener启动,也可以通过Servlet启动,可以使用XML配置,也可以使用注解配置。介绍一种最简单的启动Spring容器的方式。在web.xml中配置Spring MVC提供的DispatcherServlet:

<!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" "http://java.sun.com/dtd/web-app_2_3.dtd" >

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

初始化参数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();
}

上述Web应用程序就是使用Spring MVC时的一个最小启动功能集。由于使用了JDBC和数据库,用户的注册、登录信息会被持久化。

编写Controller

有了Web应用程序的最基本的结构,重点就可以放在如何编写Controller上。Spring MVC对Controller没有固定的要求,也不需要实现特定的接口。以UserController为例,编写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() { 
   
        ...
    }
}

实际方法的URL映射总是前缀+路径,这种形式还可以有效避免不小心导致的重复的URL映射。
Spring MVC允许我们编写既简单又灵活的Controller实现。
在这里插入图片描述
启动后,浏览器发出的HTTP请求全部由DispatcherServlet接收,并根据配置转发到指定Controller的指定方法处理。

使用REST

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

直接在Controller中处理JSON是可以的,因为Spring MVC的@GetMapping和@PostMapping都支持指定输入和输出的格式。如果我们想接收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.11.0

注意到@PostMapping使用consumes声明能接收的类型,使用produces声明输出的类型,并且额外加了@ResponseBody表示返回的String无需额外处理,直接作为输出内容写入HttpServletResponse。输入的JSON则根据注解@RequestBody直接被Spring反序列化为User这个JavaBean。

直接用Spring的Controller配合一大堆注解写REST太麻烦了,Spring还额外提供了一个@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());
        }
    }

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

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

如果要允许输入password,但不允许输出password,即在JSON序列化和反序列化时,允许写属性,禁用读属性,可以更精细地控制如下:

public class User { 
   
    ...

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

    ...
}

同样的,可以使用@JsonProperty(access = Access.READ_ONLY)允许输出,不允许输入。
在这里插入图片描述

集成Filter

在Spring MVC中,DispatcherServlet只需要固定配置到web.xml中,剩下的工作主要是专注于编写Controller。

Servlet默认按非UTF-8编码读取参数。解决注册时输入乱码问题——>使用一个EncodingFilter,在全局范围类给HttpServletRequest和HttpServletResponse强制设置为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>

这种Filter和我们业务关系不大,注意到CharacterEncodingFilter其实和Spring的IoC容器没有任何关系,两者均互不知晓对方的存在,配置十分简单。

允许用户使用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);
    }
}

在Spring中创建的这个AuthFilter是一个普通Bean,Servlet容器并不知道,所以它不会起作用。
如果我们直接在web.xml中声明这个AuthFilter,注意到AuthFilter的实例将由Servlet容器而不是Spring容器初始化,因此,@Autowire根本不生效,用于登录的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>

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

DelegatingFilterProxy将请求代理给AuthFilter,核心代码如下:

public class DelegatingFilterProxy implements Filter { 
   
    private Filter delegate;
    public void doFilter(...) throws ... { 
   
        if (delegate == null) { 
   
            delegate = findBeanFromSpringContainer();
        }
        delegate.doFilter(req, resp, chain);
    }
}

这就是一个代理模式的简单应用,它们之间的引用关系如下:
在这里插入图片描述
小结
当一个Filter作为Spring容器管理的Bean存在时,可以通过DelegatingFilterProxy间接地引用它并使其生效。

使用Interceptor

在Web程序中,Filter由Servlet容器管理,它在Spring MVC的Web应用程序中作用范围如下:
在这里插入图片描述
虚线框就是Filter2的拦截范围,Filter组件实际上并不知道后续内部处理是通过Spring MVC提供的DispatcherServlet还是其他Servlet组件,因为Filter是Servlet规范定义的标准组件,它可以应用在任何基于Servlet的程序中。
只基于Spring MVC开发应用程序,还可以使用Spring MVC提供的一种功能类似Filter的拦截器:Interceptor。和Filter相比,Interceptor拦截范围不是后续整个处理流程,而是仅针对Controller拦截:
在这里插入图片描述

@Controller
public class Controller1 { 
   
    @GetMapping("/path/to/hello")
    ModelAndView hello() { 
   
        ...
    }
}

Interceptor的拦截范围其实就是Controller方法,它实际上就相当于基于AOP的方法拦截。Interceptor只拦截Controller方法,所以要注意,返回ModelAndView后,后续对View的渲染就脱离了Interceptor的拦截范围。
使用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)。
在preHandle()中,也可以直接处理响应,然后返回false表示无需调用Controller方法继续处理了,通常在认证或者安全检查失败时直接返回错误响应。在postHandle()中,因为捕获了Controller方法返回的ModelAndView,所以可以继续往ModelAndView里添加一些通用数据,很多页面需要的全局数据如Copyright信息等都可以放到这里,无需在每个Controller方法中重复添加。

再继续添加一个AuthInterceptor,用于替代先前使用AuthFilter进行Basic认证的功能:

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

如果拦截器没有生效,请检查是否忘了在WebMvcConfigurer中注册。

处理异常

在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 Error或404 Not Found。
可以编写多个错误处理方法,每个方法针对特定的异常。例如,处理LoginException使得页面可以自动跳转到登录页。

使用ExceptionHandler时,要注意它仅作用于当前的Controller,即ControllerA中定义的一个ExceptionHandler方法对ControllerB不起作用。如果我们有很多Controller,每个Controller都需要处理一些通用的异常,例如LoginException,避免重复代码:可以写一个父类,所有exception继承它,避免重复。

处理CORS

在开发REST应用时,很多时候,是通过页面的JavaScript和后端的REST API交互。
在JavaScript与REST交互的时候,有很多安全限制。默认情况下,浏览器按同源策略放行JavaScript调用API,即:
在这里插入图片描述
同源要求域名要完全相同(a.com和www.a.com不同),协议要相同(http和https不同),端口要相同 。

在域名a.com页面的JavaScript要调用B站b.com的API——CORS,全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。如果A站的JavaScript访问B站API的时候,B站能够返回响应头Access-Control-Allow-Origin: http://a.com,那么,浏览器就允许A站的JavaScript访问B站的API。

使用Spring的@RestController开发REST应用时,同样会面对跨域问题。如果允许指定的网站通过页面JavaScript访问这些REST API,就必须正确地设置CORS。设置CORS的方法:

使用@CrossOrigin

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

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

上述定义在ApiController处的@CrossOrigin指定了只允许来自local.liaoxuefeng.com跨域访问,允许多个域访问需要写成数组形式,例如origins = {“http://a.com”, “https://www.b.com”})。如果要允许任何域访问,写成origins = “*”即可。

如果有多个REST Controller都需要使用CORS,那么,每个Controller都必须标注@CrossOrigin注解。

使用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规则。

使用CorsFilter

用Spring提供的CorsFilter,在集成Filter中介绍了将Spring容器内置的Bean暴露为Servlet容器的Filter的方法,这种配置方式需要修改web.xml,也比较繁琐,不推荐。

国际化

在开发应用程序的时候,经常会遇到支持多语言的需求,这种支持多语言的功能称之为国际化。还有针对特定地区的本地化功能,本地化是指根据地区调整类似姓名、日期的显示等。
在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的时候,要把各种语言的资源文件提出来,这样,不同的用户访问同一个页面时,显示的语言就是不同的。

Spring MVC应用程序中实现国际化:

获取Locale

实现国际化的第一步是获取到用户的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:

@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;
  • 日文,Locale是ja_JP,文件名必须为messages_ja_JP.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,#}

创建MessageSource

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

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

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

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

ResourceBundleMessageSource会自动根据主文件名自动把所有相关语言的资源文件都读进来。
Spring容器会创建不只一个MessageSource实例,这里创建的这个MessageSource是专门给页面国际化使用的,因此命名为i18n,不会与其它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) { 
   
            // 解析用户的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) { 
   
    PebbleEngine engine = new PebbleEngine.Builder()
            .autoEscaping(true)
            .cacheActive(false)
            .loader(new ServletLoader(servletContext))
            // 添加扩展:
            .extension(createExtension(messageSource))
            .build();
    PebbleViewResolver 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

需要允许用户手动切换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规范开始添加了异步支持,允许对一个请求进行异步处理。

Spring MVC中如何实现对请求进行异步处理的逻辑?首先建立一个Web工程,然后编辑web.xml文件如下:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1">
    <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.itranswarp.learnjava.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主要有几点不同:

  • 不能再使用的DTD声明,必须用新的支持Servlet 3.1规范的XSD声明
  • 对DispatcherServlet的配置多了一个,默认值是false,必须明确写成true,这样Servlet容器才会支持async处理。

在Controller中编写async处理逻辑。以ApiController为例,演示如何异步处理请求。
第一种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()写入一个错误结果。

使用Filter

使用async模式(异步)处理请求时,原有的Filter也可以工作,但必须在web.xml中添加<async-supported>并设置为true。用两个Filter:SyncFilter和AsyncFilter分别测试:

<web-app ...>
    ...
    <filter>
        <filter-name>sync-filter</filter-name>
        <filter-class>com.itranswarp.learnjava.web.SyncFilter</filter-class>
    </filter>

    <filter>
        <filter-name>async-filter</filter-name>
        <filter-class>com.itranswarp.learnjava.web.AsyncFilter</filter-class>
        <async-supported>true</async-supported>
    </filter>

    <filter-mapping>
        <filter-name>sync-filter</filter-name>
        <url-pattern>/api/version</url-pattern>
    </filter-mapping>

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

一个声明为支持的Filter既可以过滤async处理请求,也可以过滤正常的同步处理请求,如果一个普通的Filter遇到async请求时,会直接报错,因此,务必注意普通Filter的<url-pattern>不要匹配async请求路径。

在实际使用时,经常用到的就是DeferredResult,因为返回DeferredResult时,可以设置超时、正常结果和错误结果,易于编写比较灵活的逻辑。
使用async异步处理响应时,在另一个异步线程中的事务和Controller方法中执行的事务不是同一个事务,在Controller中绑定的ThreadLocal信息也无法在异步线程中获取。
小结:在Spring MVC中异步处理请求需要正确配置web.xml,并返回Callable或DeferredResult对象。

使用WebSocket

WebSocket是一种基于HTTP的长链接技术。传统的HTTP协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高。

因为HTTP本身是基于TCP连接的,所以,WebSocket在HTTP协议的基础上做了一个简单的升级,即建立TCP连接后,浏览器发送请求时,附带几个头:表示客户端希望升级连接,变成长连接的WebSocket:

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

服务器返回升级成功的响应:

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的支持。首先需要在pom.xml中加入以下依赖:

  • org.apache.tomcat.embed:tomcat-embed-websocket:9.0.26
  • org.springframework:spring-websocket:5.2.0.RELEASE

第一项是嵌入式Tomcat支持WebSocket的组件,第二项是Spring封装的支持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。

处理WebSocket连接

和处理普通HTTP请求不同,没法用一个方法处理一个URL。Spring提供了TextWebSocketHandlerBinaryWebSocketHandler分别处理文本消息和二进制消息,这里选择文本消息作为聊天室的协议,因此,ChatHandler需要继承自TextWebSocketHandler:

@Component
public class ChatHandler extends 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);
}

发送的消息是序列化后的JSON,可以用ChatMessage表示:

public class ChatMessage { 
   
	public long timestamp;
	public String name;
    public String text;
}

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

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

在这里插入图片描述

集成第三方组件

在这里插入图片描述

集成JavaMail

在服务器端,主要以发送邮件为主,例如在注册成功、登录时、购物付款后通知用户,基本上不会遇到接收用户邮件的情况,只讨论如何在Spring中发送邮件。
在Spring中,发送邮件最终也是需要JavaMail,Spring只对JavaMail做了一点简单的封装,目的是简化代码。为了在Spring中集成JavaMail,在pom.xml中添加以下依赖:

  • org.springframework:spring-context-support:5.2.0.RELEASE
  • javax.mail:javax.mail-api:1.6.2
  • com.sun.mail:javax.mail:1.6.2
    以及其他Web相关依赖。

希望用户在注册成功后能收到注册邮件,先定义一个JavaMailSender的Bean:

@Bean
JavaMailSender createJavaMailSender(
        // smtp.properties:
        @Value("${smtp.host}") String host,
        @Value("${smtp.port}") int port,
        @Value("${smtp.auth}") String auth,
        @Value("${smtp.username}") String username,
        @Value("${smtp.password}") String password,
        @Value("${smtp.debug:true}") String debug)
{ 
   
    var mailSender = new JavaMailSenderImpl();
    mailSender.setHost(host);
    mailSender.setPort(port);
    mailSender.setUsername(username);
    mailSender.setPassword(password);
    Properties props = mailSender.getJavaMailProperties();
    props.put("mail.transport.protocol", "smtp");
    props.put("mail.smtp.auth", auth);
    if (port == 587) { 
   
        props.put("mail.smtp.starttls.enable", "true");
    }
    if (port == 465) { 
   
        props.put("mail.smtp.socketFactory.port", "465");
        props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
    }
    props.put("mail.debug", debug);
    return mailSender;
}

JavaMailSender接口的实现类是JavaMailSenderImpl,初始化时,传入的参数与JavaMail是完全一致的。
另外注意到需要注入的属性是从smtp.properties中读取的,因此,AppConfig导入的就不止一个.properties文件,可以导入多个:

@Configuration
@ComponentScan
@EnableWebMvc
@EnableTransactionManagement
@PropertySource({ 
    "classpath:/jdbc.properties", "classpath:/smtp.properties" })
public class AppConfig { 
   
    ...
}

下一步是封装一个MailService,并定义sendRegistrationMail()方法:

@Component
public class MailService { 
   
    @Value("${smtp.from}")
    String from;

    @Autowired
    JavaMailSender mailSender;

    public void sendRegistrationMail(User user) { 
   
        try { 
   
            MimeMessage mimeMessage = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8");
            helper.setFrom(from);
            helper.setTo(user.getEmail());
            helper.setSubject("Welcome to Java course!");
            String html = String.format("<p>Hi, %s,</p><p>Welcome to Java course!</p><p>Sent at %s</p>", user.getName(), LocalDateTime.now());
            helper.setText(html, true);
            mailSender.send(mimeMessage);
        } catch (MessagingException e) { 
   
            throw new RuntimeException(e);
        }
    }
}

MimeMessage是JavaMail的邮件对象,而MimeMessageHelper是Spring提供的用于简化设置MimeMessage的类,比如设置HTML邮件就可以直接调用setText(String text, boolean html)方法,而不必再调用比较繁琐的JavaMail接口方法。

最后一步是调用JavaMailSender.send()方法把邮件发送出去。

在MVC的某个Controller方法中,当用户注册成功后,就启动一个新线程来异步发送邮件:

User user = userService.register(email, password, name);
logger.info("user registered: {}", user.getEmail());
// send registration mail:
new Thread(() -> { 
   
    mailService.sendRegistrationMail(user);
}).start();

因为发送邮件是一种耗时的任务,从几秒到几分钟不等,异步发送是保证页面能快速显示的必要措施。这里直接启动了一个新的线程,但实际上还有更优化的方法。

集成JMS

JMS即Java Message Service,是JavaEE的消息服务接口。JMS主要有两个版本:1.1和2.0。2.0和1.1相比,主要是简化了收发消息的代码。
所谓消息服务,就是两个进程之间,通过消息服务器传递消息:(比如生产者消费者)
在这里插入图片描述
使用消息服务,而不是直接调用对方的API,好处是:

  • 双方各自无需知晓对方的存在,消息可以异步处理,因为消息服务器会在Consumer离线的时候自动缓存消息;
  • 如果Producer发送的消息频率高于Consumer的处理能力,消息可以积压在消息服务器,不至于压垮Consumer;
  • 通过一个消息服务器,可以连接多个Producer和多个Consumer。

消息服务在各类应用程序中非常有用,所以JavaEE专门定义了JMS规范。JMS是一组接口定义,要使用JMS,还需要选择一个具体的JMS产品。常用的JMS服务器有开源的ActiveMQ,商业服务器如WebLogic、WebSphere等也内置了JMS支持。这里选择开源的ActiveMQ作为JMS服务器,在开发JMS之前必须首先安装ActiveMQ(安装配置略)。

在编写JMS代码之前,首先得理解JMS的消息模型。JMS把生产消息的一方称为Producer,处理消息的一方称为Consumer。有两种类型的消息通道,一种是Queue:
在这里插入图片描述
一种是Topic:
在这里插入图片描述
区别在于,Queue是一种一对一的通道,如果Consumer离线无法处理消息时,Queue会把消息存起来,等Consumer再次连接的时候发给它。设定了持久化机制的Queue不会丢失消息。如果有多个Consumer接入同一个Queue,那么它们等效于以集群方式处理消息,例如,发送方发送的消息是A,B,C,D,E,F,两个Consumer可能分别收到A,C,E和B,D,F,即每个消息只会交给其中一个Consumer处理。

Topic则是一种一对多通道。一个Producer发出的消息,会被多个Consumer同时收到,即每个Consumer都会收到一份完整的消息流。如果消息服务器不存储Topic消息,那么离线的Consumer会丢失部分离线时期的消息,如果消息服务器存储了Topic消息,那么离线的Consumer可以收到自上次离线时刻开始后产生的所有消息。JMS规范通过Consumer指定一个持久化订阅可以在上线后收取所有离线期间的消息,如果指定的是非持久化订阅,那么离线期间的消息会全部丢失。

如果一个Topic的消息全部都持久化了,并且只有一个Consumer,那么它和Queue其实是一样的。实际上,很多消息服务器内部都只有Topic类型的消息架构,Queue可以通过Topic“模拟”出来。
无论是Queue还是Topic,对Producer没有什么要求。多个Producer也可以写入同一个Queue或者Topic,此时消息服务器内部会自动排序确保消息总是有序的。

以上是消息服务的基本模型。具体到某个消息服务器时,Producer和Consumer通常是通过TCP连接消息服务器,在编写JMS程序时,又会遇到ConnectionFactory、Connection、Session等概念,其实这和JDBC连接是类似的:

  • ConnectionFactory:代表一个到消息服务器的连接池,类似JDBC的DataSource;
  • Connection:代表一个到消息服务器的连接,类似JDBC的Connection;
  • Session:代表一个经过认证后的连接会话;
  • Message:代表一个消息对象。

在JMS 1.1中,发送消息的典型代码如下:

try { 
   
    Connection connection = null;
    try { 
   
        // 创建连接:
        connection = connectionFactory.createConnection();
        // 创建会话:
        Session session = connection.createSession(false,Session.AUTO_ACKNOWLEDGE);
        // 创建一个Producer并关联到某个Queue:
        MessageProducer messageProducer = session.createProducer(queue);
        // 创建一个文本消息:
        TextMessage textMessage = session.createTextMessage(text);
        // 发送消息:
        messageProducer.send(textMessage);
    } finally { 
   
        // 关闭连接:
        if (connection != null) { 
   
            connection.close();
        }
    }
} catch (JMSException ex) { 
   
    // 处理JMS异常
}

JMS 2.0改进了一些API接口,发送消息变得更简单:

try (JMSContext context = connectionFactory.createContext()) { 
   
    context.createProducer().send(queue, text);
}

JMSContext实现了AutoCloseable接口,可以使用try(resource)语法,代码更简单。

开始开发JMS应用,首先在pom.xml中添加如下依赖:

  • org.springframework:spring-jms:5.2.0.RELEASE
  • javax.jms:javax.jms-api:2.0.1
  • org.apache.activemq:artemis-jms-client:2.13.0
  • io.netty:netty-handler-proxy:4.1.45.Final

在AppConfig中,通过@EnableJms让Spring自动扫描JMS相关的Bean,并加载JMS配置文件jms.properties:

@Configuration
@ComponentScan
@EnableWebMvc
@EnableJms // 启用JMS
@EnableTransactionManagement
@PropertySource({ 
    "classpath:/jdbc.properties", "classpath:/jms.properties" })
public class AppConfig { 
   
    ...
}

首先要创建的Bean是ConnectionFactory,即连接消息服务器的连接池:

@Bean
ConnectionFactory createJMSConnectionFactory(
    @Value("${jms.uri:tcp://localhost:61616}") String uri,
    @Value("${jms.username:admin}") String username,
    @Value("${jms.password:password}") String password)
{ 
   
    return new ActiveMQJMSConnectionFactory(uri, username, password);
}

因为使用的消息服务器是ActiveMQ Artemis,所以ConnectionFactory的实现类就是消息服务器提供的ActiveMQJMSConnectionFactory,它需要的参数均由配置文件读取后传入,并设置了默认值。

再创建一个JmsTemplate,它是Spring提供的一个工具类,和JdbcTemplate类似,可以简化发送消息的代码:

@Bean
JmsTemplate createJmsTemplate(@Autowired ConnectionFactory connectionFactory) { 
   
    return new JmsTemplate(connectionFactory);
}

下一步创建JmsListenerContainerFactory

@Bean("jmsListenerContainerFactory")
DefaultJmsListenerContainerFactory createJmsListenerContainerFactory(@Autowired ConnectionFactory connectionFactory) { 
   
    var factory = new DefaultJmsListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    return factory;
}

除了必须指定Bean的名称为jmsListenerContainerFactory外,这个Bean的作用是处理和Consumer相关的Bean。先跳过它的原理,继续编写MessagingService来发送消息:

@Component
public class MessagingService { 
   
    @Autowired ObjectMapper objectMapper;
    @Autowired JmsTemplate jmsTemplate;

    public void sendMailMessage(MailMessage msg) throws Exception { 
   
        String text = objectMapper.writeValueAsString(msg);
        jmsTemplate.send("jms/queue/mail", new MessageCreator() { 
   
            public Message createMessage(Session session) throws JMSException { 
   
                return session.createTextMessage(text);
            }
        });
    }
}

在这里插入图片描述
最常用的是发送基于JSON的文本消息,上述代码通过JmsTemplate创建一个TextMessage并发送到名称为jms/queue/mail的Queue。
注意:Artemis消息服务器默认配置下会自动创建Queue,因此不必手动创建一个名为jms/queue/mail的Queue,但不是所有的消息服务器都会自动创建Queue,生产环境的消息服务器通常会关闭自动创建功能,需要手动创建Queue。

MailMessage是自己定义的一个JavaBean,真正的JMS消息是创建的TextMessage,它的内容是JSON。
当用户注册成功后,就调用MessagingService.sendMailMessage()发送一条JMS消息。

如何处理消息,即编写Consumer。可以创建另一个Java进程来处理消息,但对于简单的Web程序来说没有必要,直接在同一个Web应用中接收并处理消息即可。处理消息的核心代码是编写一个Bean,并在处理方法上标注@JmsListener:

@Component
public class MailMessageListener { 
   
    final Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired ObjectMapper objectMapper;
    @Autowired MailService mailService;

    @JmsListener(destination = "jms/queue/mail", concurrency = "10")
    public void onMailMessageReceived(Message message) throws Exception { 
   
        logger.info("received message: " + message);
        if (message instanceof TextMessage) { 
   
            String text = ((TextMessage) message).getText();
            MailMessage mm = objectMapper.readValue(text, MailMessage.class);
            mailService.sendRegistrationMail(mm);
        } else { 
   
            logger.error("unable to process non-text message!");
        }
    }
}

注意到@JmsListener指定了Queue的名称,凡是发到此Queue的消息都会被这个onMailMessageReceived()方法处理,方法参数是JMS的Message接口,通过强制转型为TextMessage并提取JSON,反序列化后获得自定义的JavaBean,也就获得了发送邮件所需的所有信息。

Spring处理JMS消息的流程是什么?如果直接调用JMS的API来处理消息,那么编写的代码大致如下:

// 创建JMS连接:
Connection connection = connectionFactory.createConnection();
// 创建会话:
Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
// 创建一个Consumer:
MessageConsumer consumer = session.createConsumer(queue);
// 为Consumer指定一个消息处理器:
consumer.setMessageListener(new MessageListener() { 
    
    public void onMessage(Message message) { 
   
        // 在此处理消息... 
    }
});
// 启动接收消息的循环:
connection.start();

自己编写的MailMessageListener.onMailMessageReceived()相当于消息处理器:

consumer.setMessageListener(new MessageListener() { 
    
    public void onMessage(Message message) { 
   
        mailMessageListener.onMailMessageReceived(message); 
    }
});

Spring根据AppConfig的注解@EnableJms自动扫描带有@JmsListener的Bean方法,并为其创建一个MessageListener把它包装起来。
注意到前面还创建了一个JmsListenerContainerFactory的Bean,它的作用就是为每个MessageListener创建MessageConsumer并启动消息接收循环。
再注意到@JmsListener还有一个concurrency参数,10表示可以最多同时并发处理10个消息,5-10表示并发处理的线程可以在5~10之间调整。
因此,Spring在通过MessageListener接收到消息后,并不是直接调用mailMessageListener.onMailMessageReceived(),而是用线程池调用,因此,要时刻牢记,onMailMessageReceived()方法可能被多线程并发执行,一定要保证线程安全。

总结一下Spring接收消息的步骤:
通过JmsListenerContainerFactory配合@EnableJms扫描所有@JmsListener方法,自动创建MessageConsumer、MessageListener以及线程池,启动消息循环接收处理消息,最终由自己编写的@JmsListener方法处理消息,可能会由多线程同时并发处理。

使用Scheduler

在很多应用程序中,经常需要执行定时任务。例如,每天或每月给用户发送账户汇总报表,定期检查并发送系统状态报告,等等。Java标准库本身就提供了定时执行任务的功能。在Spring中,使用定时任务更简单,不需要手写线程池相关代码,只需要两个注解即可。以实际代码为例,建立工程spring-integration-schedule,无需额外的依赖,可以直接在AppConfig中加上@EnableScheduling就开启了定时任务的支持:

@Configuration
@ComponentScan
@EnableWebMvc
@EnableScheduling
@EnableTransactionManagement
@PropertySource({ 
    "classpath:/jdbc.properties", "classpath:/task.properties" })
public class AppConfig { 
   
    ...
}

直接在一个Bean中编写一个public void无参数方法,然后加上@Scheduled注解:

@Component
public class TaskService { 
   
    final Logger logger = LoggerFactory.getLogger(getClass());

    @Scheduled(initialDelay = 60_000, fixedRate = 60_000)
    public void checkSystemStatusEveryMinute() { 
   
        logger.info("Start check system status...");
    }
}

上述注解指定了启动延迟60秒,并以60秒的间隔执行任务。除了可以使用fixedRate外,还可以使用fixedDelay。
实际开发中会遇到一个问题,因为Java的注解全部是常量,写死了fixedDelay=30000,如果根据实际情况要改成60秒可以把定时任务的配置放到配置文件中,例如task.properties:task.checkDiskSpace=30000
这样就可以随时修改配置文件而无需动代码。但是在代码中,需要用fixedDelayString取代fixedDelay:

@Component
public class TaskService { 
   
    ...

    @Scheduled(initialDelay = 30_000, fixedDelayString = "${task.checkDiskSpace:30000}")
    public void checkDiskSpaceEveryMinute() { 
   
        logger.info("Start check disk space...");
    }
}

注解参数fixedDelayString是一个属性占位符,并配有默认值30000,Spring在处理@Scheduled注解时,如果遇到String,会根据占位符自动用配置项替换,这样就可以灵活地修改定时任务的配置。
此外,fixedDelayString还可以使用更易读的Duration,例如:@Scheduled(initialDelay = 30_000, fixedDelayString = "${task.checkDiskSpace:PT2M30S}")
以字符串PT2M30S表示的Duration就是2分30秒,多个@Scheduled方法完全可以放到一个Bean中,这样便于统一管理各类定时任务。

使用Cron任务

还有一类定时任务,它不是简单的重复执行,而是按时间触发,称为Cron任务,例如:每天凌晨2:15执行报表任务;
每个工作日12:00执行特定任务……
Cron源自Unix/Linux系统自带的crond守护进程,以一个简洁的表达式定义任务触发时间。在Spring中,也可以使用Cron表达式来执行Cron任务,在Spring中,它的格式是:秒 分 小时 天 月份 星期 年
年是可以忽略的,通常不写。每天凌晨2:15执行的Cron表达式就是:0 15 2 * * *
每个工作日12:00执行的Cron表达式就是:0 0 12 * * MON-FRI
每个月1号,2号,3号和10号12:00执行的Cron表达式就是:0 0 12 1-3,10 * *
在Spring中,定义一个每天凌晨2:15执行的任务:

@Component
public class TaskService { 
   
    ...

    @Scheduled(cron = "${task.report:0 15 2 * * *}")
    public void cronDailyReport() { 
   
        logger.info("Start daily report task...");
    }
}

Cron任务同样可以使用属性占位符,这样修改起来更加方便。
Cron表达式还可以表达每10分钟执行,例如:0 */10 * * * *这样,在每个小时的0:00,10:00,20:00,30:00,40:00,50:00均会执行任务,实际上它可以取代fixedRate类型的定时任务。

集成Quartz

在Spring中使用定时任务和Cron任务都十分简单,但是要注意到,这些任务的调度都是在每个JVM进程中的。如果在本机启动两个进程,或者在多台机器上启动应用,这些进程的定时任务和Cron任务都是独立运行的,互不影响。

如果一些定时任务要以集群的方式运行,例如每天23:00执行检查任务,只需要集群中的一台运行即可,这个时候,可以考虑使用Quartz。

Quartz可以配置一个JDBC数据源,以便存储所有的任务调度计划以及任务执行状态。也可以使用内存来调度任务,但这样配置就和使用Spring的调度没啥区别了,额外集成Quartz的意义就不大。

Quartz的JDBC配置比较复杂,Spring对其也有一定的支持。要详细了解Quartz的集成,请参考Spring的文档。

集成JMX

JMX是Java Management Extensions,它是一个Java平台的管理和监控接口。为什么要搞JMX呢?因为在所有的应用程序中,对运行中的程序进行监控都是非常重要的,Java应用程序也不例外。我们肯定希望知道Java应用程序当前的状态,例如,占用了多少内存,分配了多少内存,当前有多少活动线程,有多少休眠线程等等。如何获取这些信息呢?
为了标准化管理和监控,Java平台使用JMX作为管理和监控的标准接口,任何程序,只要按JMX规范访问这个接口,就可以获取所有管理与监控信息。

实际上,常用的运维监控如Zabbix、Nagios等工具对JVM本身的监控都是通过JMX获取的信息。
因为JMX是一个标准接口,不但可以用于管理JVM,还可以管理应用程序自身。下图是JMX的架构:
在这里插入图片描述
JMX把所有被管理的资源都称为MBean(Managed Bean),这些MBean全部由MBeanServer管理,如果要访问MBean,可以通过MBeanServer对外提供的访问接口,例如通过RMI或HTTP访问。

注意到使用JMX不需要安装任何额外组件,也不需要第三方库,因为MBeanServer已经内置在JavaSE标准库中了。JavaSE还提供了一个jconsole程序,用于通过RMI连接到MBeanServer,这样就可以管理整个Java进程。

除了JVM会把自身的各种资源以MBean注册到JMX中,我们自己的配置、监控信息也可以作为MBean注册到JMX,这样,管理程序就可以直接控制我们暴露的MBean。因此,应用程序使用JMX,只需要两步:
1、编写MBean提供管理接口和监控数据;
2、注册MBean。
在Spring应用程序中,使用JMX只需要一步:编写MBean提供管理接口和监控数据。

第二步注册的过程由Spring自动完成。以实际工程为例,首先在AppConfig中加上@EnableMBeanExport注解,告诉Spring自动注册MBean:

@Configuration
@ComponentScan
@EnableWebMvc
@EnableMBeanExport // 自动注册MBean
@EnableTransactionManagement
@PropertySource({ 
    "classpath:/jdbc.properties" })
public class AppConfig { 
   
    ...
}

剩下的全部工作就是编写MBean。以实际问题为例,假设希望给应用程序添加一个IP黑名单功能,凡是在黑名单中的IP禁止访问,传统的做法是定义一个配置文件,启动的时候读取。如果要修改黑名单怎么办?修改配置文件,然后重启应用程序。
但是每次都重启应用程序实在是太麻烦了,能不能不重启应用程序?可以自己写一个定时读取配置文件的功能,检测到文件改动时自动重新读取。上述需求本质上是在应用程序运行期间对参数、配置等进行热更新并要求尽快生效。如果以JMX的方式实现,我们不必自己编写自动重新读取等任何代码,只需要提供一个符合JMX标准的MBean来存储配置即可。
还是以IP黑名单为例,JMX的MBean通常以MBean结尾,因此我们遵循标准命名规范,首先编写一个BlacklistMBean:

public class BlacklistMBean { 
   
    private Set<String> ips = new HashSet<>();

    public String[] getBlacklist() { 
   
        return ips.toArray(String[]::new);
    }

    public void addBlacklist(String ip) { 
   
        ips.add(ip);
    }

    public void removeBlacklist(String ip) { 
   
        ips.remove(ip);
    }

    public boolean shouldBlock(String ip) { 
   
        return ips.contains(ip);
    }
}

使用JMX的客户端来实时热更新这个MBean,所以要给它加上一些注解,让Spring能根据注解自动把相关方法注册到MBeanServer中:

@Component
@ManagedResource(objectName = "sample:name=blacklist", description = "Blacklist of IP addresses")
public class BlacklistMBean { 
   
    private Set<String> ips = new HashSet<>();

    @ManagedAttribute(description = "Get IP addresses in blacklist")
    public String[] getBlacklist() { 
   
        return ips.toArray(String[]::new);
    }

    @ManagedOperation
    @ManagedOperationParameter(name = "ip", description = "Target IP address that will be added to blacklist")
    public void addBlacklist(String ip) { 
   
        ips.add(ip);
    }

    @ManagedOperation
    @ManagedOperationParameter(name = "ip", description = "Target IP address that will be removed from blacklist")
    public void removeBlacklist(String ip) { 
   
        ips.remove(ip);
    }

    public boolean shouldBlock(String ip) { 
   
        return ips.contains(ip);
    }
}

BlacklistMBean首先是一个标准的Spring管理的Bean,其次,添加了@ManagedResource表示这是一个MBean,将要被注册到JMX。objectName指定了这个MBean的名字,通常以company:name=Xxx来分类MBean。
对于属性,使用@ManagedAttribute注解标注。上述MBean只有get属性,没有set属性,说明这是一个只读属性。
对于操作,使用@ManagedOperation注解标准。上述MBean定义了两个操作:addBlacklist()和removeBlacklist(),其他方法如shouldBlock()不会被暴露给JMX。
使用MBean和普通Bean是完全一样的。例如,我们在BlacklistInterceptor对IP进行黑名单拦截:

@Order(1)
@Component
public class BlacklistInterceptor implements HandlerInterceptor { 
   
    final Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    BlacklistMBean blacklistMBean;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception { 
   
        String ip = request.getRemoteAddr();
        logger.info("check ip address {}...", ip);
        // 是否在黑名单中:
        if (blacklistMBean.shouldBlock(ip)) { 
   
            logger.warn("will block ip {} for it is in blacklist.", ip);
            // 发送403错误响应:
            response.sendError(403);
            return false;
        }
        return true;
    }
}

下一步就是正常启动Web应用程序,不要关闭它,打开另一个命令行窗口,输入jconsole启动JavaSE自带的一个JMX客户端:
在这里插入图片描述
通过jconsole连接到一个Java进程最简单的方法是直接在Local Process中找到正在运行的AppConfig,点击Connect即可连接到我们当前正在运行的Web应用,在jconsole中可直接看到内存、CPU等资源的监控。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/145937.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

  • android gallery当前view变大,GitHub – hutcwp/GalleryViewDemo: 实现画廊效果(中间放大两边缩小)无限循环向左滑向右滑,Viewpager和Recycl…「建议收藏」

    android gallery当前view变大,GitHub – hutcwp/GalleryViewDemo: 实现画廊效果(中间放大两边缩小)无限循环向左滑向右滑,Viewpager和Recycl…「建议收藏」GalleryViewDemo导包implementation’com.android.support:design:28.0.0’implementation’com.android.support:recyclerview-v7:28.0.0’一个是ViewPager所在包,另一个是RecyclerView所在包RecyclerView实现GalleryView效果可(伪无限)无限左滑右滑…

  • Mysql8.0 mysqldump 报错:Unknown table ‘COLUMN_STATISTICS‘ in information_schema (1109)

    Mysql8.0 mysqldump 报错:Unknown table ‘COLUMN_STATISTICS‘ in information_schema (1109)在mysql8.0之前的mysqldump命令格式为:mysqldump–host=&lt;server&gt;–port=&lt;port&gt;–user&lt;user&gt;–passworddatabase&gt;dump_file_pathmysql8.0版本此命令会报错Unknowntable’COLUMN_STATISTICS’ini…

  • linux卸载pycharm_彻底卸载pycharm

    linux卸载pycharm_彻底卸载pycharm1.查看配置信息位置首先在解压的pycharm-2020.2.1文件夹中,查看Install-Linux-tar.txt,找到配置信息的位置(下图中蓝色标识)。2.卸载安装文件首先找到安装文件所在的目录,cd切换至其目录,然后sudorm-rfpycharm-2020.2.13.删除配置信息依次cd切换至Pycharm2020.2的位置,然后rm删除掉该用户使用记录,即能实现完全卸载。…

  • 小米手机计算机无法归零,小米体脂秤不归零怎么调

    1、放平体重秤秤角。电子体重秤是比较敏感的,所以先确认四个秤脚是否摆放平稳、否有悬空、否有杂物,另外选择坚实的地面;2、移动后数据稳定再称重。第一次称完下来等数据稳定归零后再进行第二次测量;3、关闭忽略30秒之内的称重数据功能。小米体重秤有个选项,会自动忽略30秒之内的称重数据,也就是说前一个人刚称重完,30秒内另一个人站上去,得不到准确的数据。可以选择关闭该功能或者等30秒后再称重;4、重新绑定…

  • java深拷贝和浅拷贝_java数组copyof

    java深拷贝和浅拷贝_java数组copyof实现拷贝有几点:1)实现Cloneable接口2)重写Object类中的clone方法,并将可见性从protect改为public3)克隆需要调用super.clone(),也就是Object的实现方法浅拷贝和深拷贝的区别:浅拷贝是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。深拷贝不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。以下代码说明了浅拷…

  • Spring基础学习-任务调度TaskScheduler

    Spring基础学习-任务调度TaskScheduler某些时候我们可能需要在某些固定的时间或者是间隔一定的时间连续执行一些任务,如每天凌晨自动跑一些批次/心跳检测等。Spring通过使用TaskScheduler来完成这些功能。本文目录:1Trigger1.1CronTrigger1.2PeriodicTrigger2TaskScheduler接口简介2.1接口简介2.2TaskScheduler的实现类2.2…

    2022年10月11日

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号