Spring框架
Spring Framework
Spring Framework主要包括几个模块:
- 支持IoC和AOP的容器;
- 支持JDBC和ORM的数据访问模块;
- 支持声明式事务的模块;
- 支持基于Servlet的MVC开发;
- 支持基于Reactive的Web开发;
- 以及集成JMS、JavaMail、JMX、缓存等其他模块。
IoC容器
容器,一种为某种特定组件的运行提供必要支持的一个软件环境
Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境
Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程
Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等
IoC原理
如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。
因此,核心问题是:
- 谁负责创建组件?
- 谁负责根据依赖关系组装组件?
- 销毁时,如何按依赖顺序正确销毁?
解决这一问题的核心方案就是IoC。
传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制;
在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,
public class BookService {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
}
它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且,由IoC容器负责管理组件的生命周期。
配置Ioc容器
IoC容器要负责实例化所有的组件,因此,有必要告诉容器如何创建组件,以及各组件的依赖关系,可通过 xml 配置实现
<beans>
<bean id="dataSource" class="HikariDataSource" />
<bean id="bookService" class="BookService">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="userService" class="UserService">
<property name="dataSource" ref="dataSource" />
</bean>
</beans>
IoC容器创建3个JavaBean组件,并把id为 dataSource
的组件通过属性dataSource
(即调用 setDataSource()
方法)注入到另外两个组件中。
在Spring的IoC容器中,我们把所有组件统称为JavaBean,即配置一个组件就是配置一个Bean
依赖注入方式(构造方法)
public class BookService {
private DataSource dataSource;
public BookService(DataSource dataSource) {
this.dataSource = dataSource;
}
}
装配Bean
Maven创建工程并引入spring-context
依赖:
- org.springframework:spring-context:6.0.0
以编写简单 UserService
为例
public class UserService {
private MailService mailService;
public void setMailService(MailService mailService) {
this.mailService = mailService;
}
...
}
application.xml
resources下特定配置文件,Spring的IoC容器应该如何创建并组装Bean:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="userService" class="com.itranswarp.learnjava.service.UserService">
<property name="mailService" ref="mailService" />
</bean>
<bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
</beans>
- 每个
<bean ...>
都有一个id
标识,相当于Bean的唯一ID; - 在
userService
Bean中,通过<property name="..." ref="..." />
注入了另一个Bean; - Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。
如果注入的不是Bean,而是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="autoCommit" value="true" />
</bean>
创建IoC容器
main()
方法创建一个Spring的IoC容器实例,然后加载配置文件
public class Main {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}
从ApplicationContext
中我们可以根据Bean的类型获取Bean的引用:
UserService userService = context.getBean(UserService.class);
Annotation配置
通过 @component 注解方式,不需要XML,让Spring自动扫描Bean并组装它们
// MailService
package com.itranswarp.learnjava.service;
import org.springframework.stereotype.Component;
@Component
public class MailService {
...
}
// UserService
package com.itranswarp.learnjava.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class UserService {
@Autowired
MailService mailService;
...
// 或 set 方法、构造方法中
public UserService(@Autowired MailService mailService) {
this.mailService = mailService;
}
}
编写 AppConfig
类(AppConfig.java)启动容器
import com.itranswarp.learnjava.service.User;
import com.itranswarp.learnjava.service.UserService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = "com.itranswarp.learnjava.service")
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}
@Configuration
标注表示它是一个配置类,因为我们创建 ApplicationContext
时,使用的实现类是 AnnotationConfigApplicationContext
,必须传入一个标注了@Configuration
的类名
@ComponentScan
标注容器,自动搜索当前类所在的包以及子包,把所有标注为@Component
的Bean自动创建出来,并根据 @Autowired
进行装配
通常,启动配置 AppConfig
位于自定义的顶层包,其他Bean按类别放入子包
定制Bean
Scope
通过 @component
标记的Bean为单例类型(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)
获取到的Bean总是同一个实例。
还有一种Bean,我们每次调用 getBean(Class)
,容器都返回一个新的实例,Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的 @Scope
注解:
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // @Scope("prototype")
public class MailSession {
...
}
注入List
可将相同类型的Bean注入 List
或数组,常见于一系列接口相同,不同实现类的Bean:
定义一个验证接口
public interface Validator {
void validate(String email, String password, String name);
}
声明3个Validator
对用户参数进行验证:
@Component
public class EmailValidator implements Validator {
public void validate(String email, String password, String name) {
if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
throw new IllegalArgumentException("invalid email: " + email);
}
}
}
@Component
public class PasswordValidator implements Validator {
public void validate(String email, String password, String name) {
...
}
}
@Component
public class NameValidator implements Validator {
public void validate(String email, String password, String name) {
...
}
}
注入为 validators
@Component
public class Validators {
@Autowired
List<Validator> validators;
public void validate(String email, String password, String name) {
for (var validator : this.validators) {
validator.validate(email, password, name);
}
}
}
List<Validator> validators
,Spring会自动把所有类型为Validator的Bean装配为一个List注入进来
指定List
中Bean的顺序,可以加上@Order
注解:
@Component
@Order(1)
public class EmailValidator implements Validator {
...
}
可选注入
常用于Spring如果没有找到对应类型的Bean,指定默认值
@Component
public class MailService {
@Autowired(required = false) // 核心代码
ZoneId zoneId = ZoneId.systemDefault();
...
}
第三方Bean
如果一个Bean不在我们自己的package管理之内,如何创建它
在 @Configuration
类中编写一个Java方法创建并返回它,给方法标记一个 @Bean
注解
@Configuration
@ComponentScan
public class AppConfig {
// 创建一个Bean:
@Bean
ZoneId createZoneId() {
return ZoneId.of("Z"); // 手动创建返回
}
}
初始化和销毁
如果一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。
在容器关闭时,有时候还需要清理资源(关闭连接池等)。
通常会定义一个 init()
方法进行初始化,定义一个 shutdown()
方法进行清理
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();
@PostConstruct // 注入完成时调用
public void init() {
System.out.println("Init mail service with zoneId = " + this.zoneId);
}
@PreDestroy // 销毁时
public void shutdown() {
System.out.println("Shutdown mail service");
}
}
使用别名
默认情况下,对一种类型的Bean,容器只创建一个实例
如果需要对一种类型的Bean创建多个实例。如同时连接多个数据库,就必须创建多个 DataSource
实例
如果直接在 @Configuration
类中创建了多个同类型的Bean,会报NoUniqueBeanDefinitionException
异常,尝试设置别名:
@Configuration
@ComponentScan
public class AppConfig {
@Primary // 指定为主要Bean
@Bean("z")
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}
@Bean
@Qualifier("utc8")
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}
@Bean("name")
指定别名,也可以用 @Bean
+@Qualifier("name")
指定别名,注入使用:
@Component
public class MailService {
@Autowired(required = false)
@Qualifier("z") // 指定注入名称为"z"的ZoneId
ZoneId zoneId = ZoneId.systemDefault();
...
}
@Primary
指定的主bean,则默认注入的即主数据源,无需别名指定名称也可
FactoryBean
遵循工厂模式,定义一个工创建真正的Bean
@Component
public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {
String zone = "Z";
@Override
public ZoneId getObject() throws Exception {
return ZoneId.of(zone);
}
@Override
public Class<?> getObjectType() {
return ZoneId.class;
}
}
使用Resource
使用Spring容器时,我们可以把“文件”注入进来,方便程序读取
org.springframework.core.io.Resource
支持将一个文件作为 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"));
}
}
}
注入配置
读取配置文件:常用的配置方法是以 key=value
的形式写在 .properties
文件中
Spring容器提供了一个更简单的 @PropertySource
来自动读取配置文件:
@Configuration
@ComponentScan
@PropertySource("app.properties") // 表示读取classpath的app.properties
public class AppConfig {
@Value("${app.zone:Z}")
String zoneId;
@Bean
ZoneId createZoneId() {
return ZoneId.of(zoneId);
}
}
"${app.zone}"
表示读取key为app.zone
的value,如果key不存在,启动将报错;"${app.zone:Z}"
表示读取key为app.zone
的value,但如果key不存在,就使用默认值Z
。
或者 @Bean
手动创建时,传入到构造方法参数
@Bean
ZoneId createZoneId(@Value("${app.zone:Z}") String zoneId) {
return ZoneId.of(zoneId);
}
另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置:
@Component
public class SmtpConfig {
@Value("${smtp.host}")
private String host;
@Value("${smtp.port:25}")
private int port;
public String getHost() {
return host;
}
public int getPort() {
return port;
}
}
条件装配
Spring为应用程序准备了Profile这一概念,用来表示不同的环境。如分别定义开发、测试和生产这3个环境:
- native
- test
- production
@Profile
根据注解 @Profile
来决定是否创建。例如:
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Profile("!test")
ZoneId createZoneId() {
return ZoneId.systemDefault();
}
@Bean
@Profile("test")
ZoneId createZoneIdForTest() {
return ZoneId.of("America/New_York");
}
}
在运行程序时,加上JVM参数 -Dspring.profiles.active=test
就可以指定以 test
环境启动;Spring允许指定多个Profile:-Dspring.profiles.active=test,master
可以表示 test
环境,并使用 master
分支代码
要指定满足多个Profile条件,可以这样写:
@Bean
@Profile({ "test", "master" }) // 满足test或master
ZoneId createZoneId() {
...
}
@Conditional
支持自定义条件逻辑,如:
@Component
@Conditional(OnSmtpEnvCondition.class) // 条件实现类
public class SmtpMailService implements MailService {
...
}
public class OnSmtpEnvCondition implements Condition {
// 注意此处是对 Condition 实现
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return "true".equalsIgnoreCase(System.getenv("smtp"));
}
}
AOP
即面向切面编程(Aspect Oriented Programming)
思考业务模型,如安全检查、日志、事务等代码,它们会重复出现在每个业务方法中
加入AOP的视角来编写上述业务,可以依次实现:
- 核心逻辑,即:BookService
- 切面逻辑,即:权限检查的Aspect、日志的Aspect、事务的Aspect
原理
如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService
的引用,当调用bookService.createBook()
时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理;
最简单的方式是运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。
Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。
AOP本质是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。
AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。
另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。
装配AOP
通过Maven引入Spring对AOP的支持:
- org.springframework:spring-aspects:6.0.0
上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单:
@Aspect
@Component // 作为一个bean
public class LoggingAspect {
// 在执行UserService的每个方法前执行:
@Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}
// 在执行MailService的每个方法前后执行:
@Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
System.err.println("[Around] start " + pjp.getSignature());
Object retVal = pjp.proceed(); // 连接点执行目标方法
System.err.println("[Around] done " + pjp.getSignature());
return retVal;
}
}
定义 @Before
,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService
的每个public
方法前执行doAccessCheck()
代码。
定义 @Around
,它和@Before
不同,@Around
可以决定是否执行目标方法,因此,我们在doLogging()
内部先打印日志,再调用方法,最后打印日志后返回结果。
@Configuration
类加上一个 @EnableAspectJAutoProxy
注解:
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
...
}
IoC容器看到这个注解,就会自动查找带有@Aspect
的Bean,然后根据每个方法的@Before
、@Around
等注解把AOP注入到特定的Bean中。
拦截器类型
-
@Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
-
@After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
-
@AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
-
@AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
-
@Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。
注解装配
通过复杂的 execution(* xxx.Xyz.*(..))
AOP规则装配到指定Bean的指定方法前后,常导致后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配
为 bean标注 @Transactional
,使被装配的Bean清楚知道自己被安排,如
@Component
// @Transactional 表明所有public被启用事务
public class UserService {
...
}
public class UserService {
// 有事务:
@Transactional
public User createUser(String name) {
...
}
// 无事务:
public boolean isValidName(String name) {
...
}
// 有事务:
@Transactional
public void updateUser(User user) {
...
}
}
@Transactional
注解用于声明一个方法或类应该在事务中执行。Spring 使用 AOP 来实现事务管理,这意味着它会在方法执行的适当时间点自动开始和结束事务
注解的方式实现AOP装配
第一步,定义注解
// 定义一个性能监控注解
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
String value(); // 后续通过.value()访问注解参数值
}
第二步,在需要被监控的关键方法上标注注解
@Component
public class UserService {
// 监控register()方法性能:
@MetricTime("register")
public User register(String email, String password, String name) {
...
}
...
}
第三步,定义切面(Aspect),将切面与注解建立联系
@Aspect
@Component
public class MetricAspect {
// 建立切面与注解联系:符合条件的目标方法是带有@MetricTime注解的方法
@Around("@annotation(metricTime)")
public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
String name = metricTime.value();
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long t = System.currentTimeMillis() - start;
// 写入日志或发送至JMX:
System.err.println("[Metrics] " + name + ": " + t + "ms");
}
}
}
AOP避坑指南
AOP就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。(基于你的类生成一个代理类代替参与运行)
因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题,如
- 访问被注入的Bean时,总是调用方法而非直接访问字段;
- 编写Bean时,如果可能会被代理,就不要编写
public final
方法。
访问数据库
Java定义了程序访问数据库的标准接口JDBC;JDBC虽然简单,但代码比较繁琐。Spring为了简化数据库访问,主要做了以下几点工作:
- 提供了简化的访问JDBC的模板类,不必手动释放资源;
- 提供了一个统一的DAO类以实现Data Access Object模式;
- 把
SQLException
封装为DataAccessException
,这个异常是一个RuntimeException
,并且让我们能区分SQL异常的原因,例如,DuplicateKeyException
表示违反了一个唯一约束; - 能方便地集成Hibernate、JPA和MyBatis这些数据库访问框架。
使用JDBC
Java程序使用JDBC接口访问关系数据库的时候,需要以下几步:
- 创建全局
DataSource
实例,表示数据库连接池; - 在需要读写数据库的方法内部,按如下步骤访问数据库:
- 从全局
DataSource
实例获取Connection
实例; - 通过
Connection
实例创建PreparedStatement
实例; - 执行SQL语句,如果是查询,则通过
ResultSet
读取结果集,如果是修改,则获得int
结果。
- 从全局
正确编写JDBC代码的关键是使用 try ... finally
释放资源,涉及到事务的代码需要正确提交或回滚事务。
在Spring使用JDBC,首先我们通过IoC容器创建并管理一个DataSource
实例;
Spring提供了一个 JdbcTemplate
,可以方便地让我们操作JDBC,因此,通常情况下,我们会实例化一个 JdbcTemplate
。顾名思义,这个类主要使用了 Template模式。
AppConfig
@Configuration
@ComponentScan(basePackages = "org.example")
@PropertySource("jdbc.properties")
public class AppConfig {
@Value("${jdbc.url}")
String jdbcUrl;
@Value("${jdbc.username}")
String jdbcUsername;
@Value("${jdbc.password}")
String jdbcPassword;
@Bean
DataSource createDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(jdbcUsername);
config.setPassword(jdbcPassword);
config.addDataSourceProperty("autoCommit", "true");
config.addDataSourceProperty("connectionTimeout", "5");
config.addDataSourceProperty("idleTimeout", "60");
return new HikariDataSource(config);
}
@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
在上述配置中:
- 通过
@PropertySource("jdbc.properties")
读取数据库配置文件; - 通过
@Value("${jdbc.url}")
注入配置文件的相关配置; - 创建一个 DataSource 实例,它的实际类型是
HikariDataSource
,创建时需要用到注入的配置; - 创建一个 JdbcTemplate 实例,它需要注入
DataSource
,这是通过方法参数完成注入的;
HSQLDB配置文件
HSQLDB写一个配置文件 jdbc.properties
:
# 数据库文件名
jdbc.url=jdbc:hsqldb:file:testdb
# Hsqldb默认用户名,口令是空字符串
jdbc.username=sa
jdbc.password=
数据库表初始化
@Component
public class DatabaseInitializer {
@Autowired
JdbcTemplate jdbcTemplate;
@PostConstruct
public void init() {
jdbcTemplate.update("CREATE TABLE IF NOT EXISTS user ("
+ "id BIGINT IDENTITY NOT NULL PRIMARY KEY,"
+ "email VARCHAR(100) NOT NULL,"
+ "password VARCHAR(100) NOT NULL,"
+ "NAME VARCHAR(100) NOT NULL,"
+ "UNIQUE(EMAIL)");
}
}
JdbcTemplate使用
Spring提供的 JdbcTemplate
采用Template模式,提供了一系列以回调为特点的工具方法,目的是避免繁琐的 try...catch
语句
首先,T execute(ConnectionCallback<T> action)
方法,它提供了Jdbc的Connection
供我们使用:
public User getUserById(long id) {
// 注意传入的是 ConnectionCallback:
return jdbcTemplate.execute((Connection conn) -> {
// 可以直接使用 conn 实例,不要释放它,回调结束后 JdbcTemplate 自动释放:
// 在内部手动创建的 PreparedStatement、ResultSet 必须用try(...)释放:
try (var ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
ps.setObject(1, id);
try (var rs = ps.executeQuery()) { // result
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
}
});
}
上面优点是,允许获取 Connection,然后做任何基于 Connection 的操作
另一种是从表达式,T execute(String sql, PreparedStatementCallback<T> action)
public User getUserByName(String name) {
// 需要传入SQL语句,以及PreparedStatementCallback:
return jdbcTemplate.execute("SELECT * FROM users WHERE name = ?", (PreparedStatement ps) -> {
// PreparedStatement实例已经由JdbcTemplate创建,并在回调后自动释放:
ps.setObject(1, name);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
}
throw new RuntimeException("user not found by id.");
}
});
}
最后是通过 jdbcTemplate.queryForObject
传递 sql 参数,访问结果集 T queryForObject(String sql, RowMapper<T> rowMapper, Object... args)
public User getUserByEmail(String email) {
// 传入 SQL,参数和RowMapper 实例:
return jdbcTemplate.queryForObject("SELECT * FROM users WHERE email = ?",
(ResultSet rs, int rowNum) -> { // queryForObject 仅返回一项
// 将 ResultSet 的当前行映射为一个 JavaBean:
return new User( // new User object:
rs.getLong("id"), // id
rs.getString("email"), // email
rs.getString("password"), // password
rs.getString("name")); // name
},
email); // 传递参数
}
RowMapper
不一定返回 JavaBean,根据实际 sql语句 返回任何 Java 对象:
public long getUsers() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", (ResultSet rs, int rowNum) -> {
// SELECT COUNT(*)查询只有一列,取第一列数据:
return rs.getLong(1);
});
}
如果返回多行记录,而不是一行,可以用 query()
方法:
public List<User> getUsers(int pageIndex) {
int limit = 100;
int offset = limit * (pageIndex - 1);
return jdbcTemplate.query("SELECT * FROM users LIMIT ? OFFSET ?",
new BeanPropertyRowMapper<>(User.class),
limit, offset);
}
对于各种查询,如果设计表结构时,能够和 JavaBean 属性一一对应,那么直接使用 BeanPropertyRowMapper
如果表结构和 JavaBean 不一致,可借助指定 别名
SELECT id, email,
office_address AS workAddress,
name FROM users WHERE email = ?
// 表的列名是office_address,而JavaBean属性是workAddress
对于插入、更新和删除等操作,需要使用 update
方法:
public void updateUser(User user) {
// 传入SQL,SQL参数,返回更新的行数:
if (1 != jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId())) {
throw new RuntimeException("User not found by id");
}
// 成功操作
}
在插入(Insert)操作,获取插入后的自增值,JdbcTemplate
提供了 KeyHolder
来简化这一操作:
public User register(String email, String password, String name) {
// 创建一个KeyHolder:
KeyHolder holder = new GeneratedKeyHolder();
if (1 != jdbcTemplate.update(
// 参数1:PreparedStatementCreator
(conn) -> {
// 创建PreparedStatement时,必须指定RETURN_GENERATED_KEYS:
var ps = conn.prepareStatement("INSERT INTO users(email, password, name) VALUES(?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setObject(1, email);
ps.setObject(2, password);
ps.setObject(3, name);
return ps;
},
// 参数2:KeyHolder
holder)
) {
throw new RuntimeException("Insert failed.");
}
// 从KeyHolder中获取返回的自增值:
return new User(holder.getKey().longValue(), email, password, name);
}
声明式事务
Spring提供 PlatformTransactionManager
实现事务管理器,事务由 TransactionStatus
表示,手写如下:
TransactionStatus tx = null;
try {
// 开启事务:
tx = txManager.getTransaction(new DefaultTransactionDefinition());
// 相关JDBC操作:
jdbcTemplate.update("...");
jdbcTemplate.update("...");
// 提交事务:
txManager.commit(tx);
} catch (RuntimeException e) {
// 回滚事务:
txManager.rollback(tx);
throw e;
}
AppConfig
中,需再定义 PlatformTransactionManager
对应的Bean,它的实际类型是 DataSourceTransactionManager
:
@Configuration
@ComponentScan
@PropertySource("jdbc.properties")
public class AppConfig {
...
@Bean
PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
声明式事务
除了上述定义的 PlatformTransactionManager
,追加 @EnableTransactionManagement
就可以启用声明式事务:
@Configuration
@ComponentScan
@EnableTransactionManagement // 启用声明式
@PropertySource("jdbc.properties")
public class AppConfig {
...
}
对需要事务支持的方法,无需手写,加一个 @Transactional
注解:
@Component
public class UserService {
// 此public方法自动具有事务支持: 无需手动
@Transactional
public User register(String email, String password, String name) {
...
}
}
回滚事务
事务中报错 RuntimeException
,Spring的声明式事务将自动回滚;
在一个事务方法中,如果程序判断需要主动回滚事务,只需抛出 RuntimeException
:
@Transactional
public buyProducts(long productId, int num) {
...
if (store < num) {
// 库存不够,购买失败:
throw new IllegalArgumentException("No enough products");
}
...
}
如果要针对多种 Exception 回滚事务,只需在 @Transactional
注解中写出来:
@Transactional(rollbackFor = { RuntimeException.class, IOException.class })
public buyProducts(long productId, int num) throws IOException {
...
}
实际业务可通过异常体系从 RuntimeException
派生更好地回滚
public class BusinessException extends RuntimeException {
...
}
public class LoginException extends BusinessException {
...
}
public class PaymentException extends BusinessException {
...
}
事务边界与传播
对于简单的声明式事务,事务边界即事务方法开始与结束
@Component
public class UserService {
@Transactional
public User register(String email, String password, String name) { // 事务开始
...
} // 事务结束
}
事务方法内部嵌套时,事务边界
@Component
public class UserService {
@Autowired
BonusService bonusService;
@Transactional
public User register(String email, String password, String name) {
// 插入用户记录:
User user = jdbcTemplate.insert("...");
// 增加100积分:
bonusService.addBonus(user.id, 100); // 嵌套的Transactional方法
}
}
事务传播
Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。
DAO
传统的多层应用程序中,通常是Web层调用业务层,业务层调用数据访问层。业务层负责处理各种业务逻辑,而数据访问层只负责对数据进行增删改查;
Spring 实现数据访问层就是用 JdbcTemplate
实现对数据库的操作。
编写数据访问层的时候,使用DAO模式(即Data Access Object),基本实现如下:
public class UserDao {
@Autowired
JdbcTemplate jdbcTemplate;
User getById(long id) {
...
}
List<User> getUsers(int page) {
...
}
User createUser(User user) {
...
}
User updateUser(User user) {
...
}
void deleteUser(User user) {
...
}
}
Spring 提供了 JdbcDaoSupport
,用于简化DAO实现,以及对样板代码提取为通用方法
对
如下面通过抽象一个 AbstractDao
,使子类支持调用 getJdbcTemplate()
public abstract class AbstractDao extends JdbcDaoSupport {
@Autowired
private JdbcTemplate jdbcTemplate;
@PostConstruct
public void init() {
super.setJdbcTemplate(jdbcTemplate);
}
}
@Component
@Transactional
public class UserDao extends AbstractDao {
public User getById(long id) {
return getJdbcTemplate().queryForObject(
"SELECT * FROM users WHERE id = ?",
new BeanPropertyRowMapper<>(User.class),
id
);
}
...
}
再如下面:AbstractDao
改成泛型,并实现 getById()
,getAll()
,deleteById()
这样的通用方法:
public abstract class AbstractDao<T> extends JdbcDaoSupport {
private String table;
private Class<T> entityClass;
private RowMapper<T> rowMapper;
public AbstractDao() {
// 获取当前类型的泛型类型:
this.entityClass = getParameterizedType();
this.table = this.entityClass.getSimpleName().toLowerCase() + "s";
this.rowMapper = new BeanPropertyRowMapper<>(entityClass);
}
public T getById(long id) {
return getJdbcTemplate().queryForObject("SELECT * FROM " + table + " WHERE id = ?", this.rowMapper, id);
}
public List<T> getAll(int pageIndex) {
int limit = 100;
int offset = limit * (pageIndex - 1);
return getJdbcTemplate().query("SELECT * FROM " + table + " LIMIT ? OFFSET ?",
new Object[] { limit, offset },
this.rowMapper);
}
public void deleteById(long id) {
getJdbcTemplate().update("DELETE FROM " + table + " WHERE id = ?", id);
}
...
}
此时子类使用:
@Component
@Transactional
public class UserDao extends AbstractDao<User> {
// 已经有了:
// User getById(long)
// List<User> getAll(int)
// void deleteById(long)
}
@Component
@Transactional
public class BookDao extends AbstractDao<Book> {
// 已经有了:
// Book getById(long)
// List<Book> getAll(int)
// void deleteById(long)
}
Hibernate
JdbcTemplate
使用中一个关键是 List<T> query(String, RowMapper, Object...)
。RowMapper
的作用就是把 ResultSet
的一行记录映射为 Java Bean;
这种把关系数据库的表记录映射为Java对象的过程就是ORM:Object-Relational Mapping。ORM既可以把记录转换成Java对象,也可以把Java对象转换为行记录。
使用 JdbcTemplate
配合 RowMapper
可以看作是最原始的ORM。如果要实现更自动化的ORM,可以选择成熟的ORM框架,例如Hibernate。
如何集成
Hibernate作为ORM框架,它可以替代 JdbcTemplate
,但Hibernate仍需JDBC驱动,所以,我们需要引入JDBC驱动、连接池,以及Hibernate本身。在Maven中,我们加入以下依赖项:
- org.springframework:spring-context:6.0.0
- org.springframework:spring-orm:6.0.0
- jakarta.annotation:jakarta.annotation-api:2.1.1
- jakarta.persistence:jakarta.persistence-api:3.1.0
- org.hibernate:hibernate-core:6.1.4.Final
- com.zaxxer:HikariCP:5.0.1
- org.hsqldb:hsqldb:2.7.1
AppConfig
中,我们仍然需要创建 DataSource
、引入JDBC配置文件,以及启用声明式事务:
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() {
...
}
}
启用Hibernate,我们要创建一个LocalSessionFactoryBean
:
public class AppConfig {
@Bean
LocalSessionFactoryBean createSessionFactory(@Autowired DataSource dataSource) {
var props = new Properties();
props.setProperty("hibernate.hbm2ddl.auto", "update"); // 生产环境不要使用
props.setProperty("hibernate.dialect", "org.hibernate.dialect.HSQLDialect");
props.setProperty("hibernate.show_sql", "true");
// Properties持有Hibernate初始化SessionFactory时用到的所有设置
// 参数详见 https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#configurations
var sessionFactoryBean = new LocalSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
// 扫描指定的package获取所有entity class:
sessionFactoryBean.setPackagesToScan("com.itranswarp.learnjava.entity");
sessionFactoryBean.setHibernateProperties(props);
return sessionFactoryBean;
}
}
LocalSessionFactoryBean
是一个 FactoryBean
,它会再自动创建一个SessionFactory
在Hibernate中,Session
是封装了一个JDBC Connection
的实例,而 SessionFactory
是封装了JDBC DataSource
的实例,即 SessionFactory
持有连接池,每次需要操作数据库时候,SessionFactory
创建一个新的 Session
,相当于从连接池获取到一个新的 Connection
。
SessionFactory
就是 Hibernate 提供的最核心的一个对象,但LocalSessionFactoryBean
是Spring提供的为了让我们方便创建 SessionFactory
的类。
public class AppConfig {
@Bean
PlatformTransactionManager createTxManager(@Autowired SessionFactory sessionFactory) {
return new HibernateTransactionManager(sessionFactory);
}
}
HibernateTransactionManager
是配合Hibernate使用声明式事务所必须的;
考察如下的数据库表:
CREATE TABLE user
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(100) NOT NULL,
password VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
createdAt BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`));
JavaBean表示如下:
public class User {
private Long id;
private String email;
private String password;
private String name;
private Long createdAt;
// getters and setters
...
}
映射关系十分易懂,但我们需要添加注解告诉Hibernate如何把 User
类映射到表记录:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
public Long getId() { ... }
@Column(nullable = false, unique = true, length = 100)
public String getEmail() { ... }
@Column(nullable = false, length = 100)
public String getPassword() { ... }
@Column(nullable = false, length = 100)
public String getName() { ... }
@Column(nullable = false, updatable = false)
public Long getCreatedAt() { ... }
}
@Entity
标记一个JavaBean被用于映射
默认情况下,映射的表名是 user
,如果实际的表名不同,例如实际表名是 users
,可以追加一个 @Table(name="users")
表示:
@Entity
@Table(name="users)
public class User {
...
}
@Column()
标识每个属性到数据库列的映射,nullable
指示列是否允许为 NULL
,updatable
指示该列是否允许被用在 UPDATE
语句,length
指示 String
类型的列的长度(如果没有指定,默认是 255
)。
对于主键,还需要用 @Id
标识,自增主键再追加一个 @GeneratedValue
,以便Hibernate能读取到自增主键的值。
对于每个表,通常我们会统一使用一种主键生成机制,并添加createdAt
表示创建时间,updatedAt
表示修改时间等通用字段。
重复定义的通用字段,我们可以把它们提到一个抽象类中:
@MappedSuperclass
public abstract class AbstractEntity {
private Long id;
private Long createdAt;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
public Long getId() { ... }
@Column(nullable = false, updatable = false)
public Long getCreatedAt() { ... }
@Transient
public ZonedDateTime getCreatedDateTime() {
return Instant.ofEpochMilli(this.createdAt).atZone(ZoneId.systemDefault());
}
@PrePersist
public void preInsert() {
setCreatedAt(System.currentTimeMillis());
}
}
类似User
、Book
这样的用于ORM的Java Bean,我们通常称之为Entity Bean。
最后,我们来看看如果对user
表进行增删改查。因为使用了Hibernate,因此,我们要做的,实际上是对User
这个JavaBean进行“增删改查”。我们编写一个UserService
,注入SessionFactory
:
@Component
@Transactional
public class UserService {
@Autowired
SessionFactory sessionFactory;
}
MyBatis
介于全自动ORM如Hibernate和手写全部如JdbcTemplate之间,还有一种半自动的ORM,它只负责把ResultSet自动映射到Java Bean,或者自动填充Java Bean参数,但仍需自己写出SQL。MyBatis 就是这样一种半自动化ORM框架:
如何集成
- org.mybatis:mybatis:3.5.11
- org.mybatis:mybatis-spring:3.0.0
创建DataSource
@Configuration
@ComponentScan
@EnableTransactionManagement
@PropertySource("jdbc.properties")
public class AppConfig {
@Bean
DataSource createDataSource() { ... }
}
Hibernate的 SessionFactory
与 Session
,MyBatis与之对应的是 SqlSessionFactory
和 SqlSession
,分别相当于 DataSource
与 Connection
同理,MyBatis的核心就是创建 SqlSessionFactory
:
@Bean
SqlSessionFactoryBean createSqlSessionFactoryBean(@Autowired DataSource dataSource) {
var sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
return sqlSessionFactoryBean;
}
因为MyBatis可以直接使用Spring管理的声明式事务,因此,创建事务管理器和使用JDBC是一样的:
@Bean
PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
MyBatis使用Mapper来实现映射,而且Mapper必须是接口。我们以 User
类为例,在User
类和 users
表之间映射的 UserMapper
编写如下:
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
User getById(@Param("id") long id);
}
注意:这里的Mapper不是 JdbcTemplate
的 RowMapper
的概念,它是定义访问 users
表的接口方法。比如我们定义了一个 User getById(long)
的主键查询方法,不仅要定义接口方法本身,还要明确写出查询的SQL,这里用注解 @Select
标记。SQL语句的任何参数,都与方法参数按名称对应。例如,方法参数id的名字通过注解 @Param()
标记为 id
,则SQL语句里将来替换的占位符就是 #{id}
;
MyBatis执行查询后,将根据方法的返回类型自动把ResultSet的每一行转换为User实例,转换规则当然是按列名和属性名对应。如果列名和属性名不同,最简单的方式是编写SELECT语句的别名:
-- 列名是created_time,属性名是createdAt:
SELECT id, name, email, created_time AS createdAt FROM users
插入
@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);
如果users
表的id
是自增主键,那么,我们在SQL中不传入id
,但希望获取插入后的主键,需要再加一个@Options
注解:
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
@Insert("INSERT INTO users (email, password, name, createdAt) VALUES (#{user.email}, #{user.password}, #{user.name}, #{user.createdAt})")
void insert(@Param("user") User user);
更新与删除
@Update("UPDATE users SET name = #{user.name}, createdAt = #{user.createdAt} WHERE id = #{user.id}")
void update(@Param("user") User user);
@Delete("DELETE FROM users WHERE id = #{id}")
void deleteById(@Param("id") long id);
有了UserMapper
接口,还需要对应的实现类才能真正执行这些数据库操作的方法。虽然可以自己写实现类,但我们除了编写 UserMapper
接口外,还有 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;
}
}
业务逻辑主要就是通过 XxxMapper
定义的数据库方法来访问数据库。
开发web应用
Servlet是JavaEE中Web开发的基础,包括:
- Servlet规范定义了几种标准组件:Servlet、JSP、Filter和Listener;
- Servlet的标准组件总是运行在Servlet容器中,如Tomcat、Jetty、WebLogic等。
直接使用Servlet进行Web开发好比直接在JDBC上操作数据库,比较繁琐,更好的方法是在Servlet基础上封装MVC框架,基于MVC开发Web应用,大部分时候,不需要接触Servlet API,开发省时省力。
Spring MVC
标准的Servlet组件:
- Servlet:能处理HTTP请求并将HTTP响应返回;
- JSP:一种嵌套Java代码的HTML,将被编译为Servlet;
- Filter:能过滤指定的URL以实现拦截功能;
- Listener:监听指定的事件,如ServletContext、HttpSession的创建和销毁。
Spring提供的是一个IoC容器,所有的Bean,包括Controller,都在Spring IoC容器中被初始化,而Servlet容器由JavaEE服务器提供(如Tomcat),Servlet容器对Spring一无所知,他们之间到底依靠什么进行联系,又是以何种顺序初始化的?
在理解上述问题之前,我们先把基于Spring MVC开发的项目结构搭建起来。首先创建基于Web的Maven工程,引入如下依赖:
- org.springframework:spring-context:6.0.0
- org.springframework:spring-webmvc:6.0.0
- org.springframework:spring-jdbc:6.0.0
- jakarta.annotation:jakarta.annotation-api:2.1.1
- io.pebbletemplates:pebble-spring6:3.2.0
- ch.qos.logback:logback-core:1.4.4
- ch.qos.logback:logback-classic:1.4.4
- com.zaxxer:HikariCP:5.0.1
- org.hsqldb:hsqldb:2.7.0
以及provided
依赖:
- org.apache.tomcat.embed:tomcat-embed-core:10.1.1
- org.apache.tomcat.embed:tomcat-embed-jasper:10.1.1
一个标准的 Maven Web 工程目录如下:
logback.xml
:Logback的默认查找的配置文件
AppConfig
@EnableWebMvc
启用Spring MVC
@Configuration
@ComponentScan
@EnableWebMvc // 启用Spring MVC
@EnableTransactionManagement
@PropertySource("classpath:/jdbc.properties")
public class AppConfig {
...
}
除了创建 DataSource
、JdbcTemplate
、PlatformTransactionManager
外,AppConfig
需要额外创建几个用于Spring MVC的Bean:
WebMvcConfigurer
@Bean
WebMvcConfigurer createWebMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("/static/");
}
};
}
WebMvcConfigurer
并不是必须的,这里创建一个默认的 WebMvcConfigurer
覆写addResourceHandlers()
,目的是让Spring MVC自动处理静态文件,并且映射路径为/static/**
ViewResolver
@Bean
ViewResolver createViewResolver(@Autowired ServletContext servletContext){
PebbleEngine engine = new PebbleEngine.Builder().autoEscaping(true)
.cacheActive(false)
.loader(new Servlet5Loader(servletContext))
.build();
ViewResolver viewResolver = new PebbleViewResolver(engine);
viewResolver.setPrefix("/WEB-INF/templates/");
viewResolver.setSuffix("");
return viewResolver;
}
Spring MVC允许集成任何模板引擎,使用哪个模板引擎,就实例化一个对应的 ViewResolver
;上述配置使用Pebble引擎,指定模板文件存放在 /WEB-INF/templates/
目录下,并通过指定 prefix
和 suffix
来确定如何查找 View
@Controller
Controller必须标记为 @Controller
:
@Controller
public class UserController {
@Autowired
UserService userService;
@GetMapping("/")
public ModelAndView index(){
......
}
}
普通 Java 应用程序通过 main 方法创建 Spring 容器实例
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(AppConfig.class);
}
Web应用程序总是由Servlet容器创建,其中Spring容器如何创建:
web.xml
中配置Spring MVC提供的DispatcherServlet
<?xml version="1.0"?>
<web-app>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.itranswarp.learnjava.AppConfig</param-value>
</init-param>
<load-on-startup>0</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>
AppConfig
main()
方法启动嵌入式Tomcat:
public static void main(String[] args) throws Exception {
Tomcat tomcat = new Tomcat();
tomcat.setPort(Integer.getInteger("port", 8080));
tomcat.getConnector();
Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
WebResourceRoot resources = new StandardRoot(ctx);
resources.addPreResources(
new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
ctx.setResources(resources);
tomcat.start();
tomcat.getServer().await();
}
编写Controller
一个方法对应一个HTTP请求路径,@GetMapping
或 @PostMapping
表示GET或POST请求
需要接收的HTTP参数以@RequestParam()
标注,可以设置默认值。如果方法参数需要传入HttpServletRequest
、HttpServletResponse
或者HttpSession
,直接添加这个类型的参数即可,Spring MVC会自动按类型传入。
@PostMapping("/signin")
public ModelAndView doSignin(
@RequestParam("email") String email,
@RequestParam("password") String password,
HttpSession session) {
...
return new ModelAndView("signin.html"); // 仅View,没有Model(第2个参数)
}
redirect:/profile
返回重定向页面
如果在方法内部直接操作HttpServletResponse
发送响应,返回null
表示无需进一步处理:
public ModelAndView download(HttpServletResponse response) {
byte[] data = ...
response.setContentType("application/octet-stream");
OutputStream output = response.getOutputStream();
output.write(data);
output.flush();
return null;
}
@RequestMapping
对 url 进行分组
@Controller
@RequestMapping("/user")
public class UserController {
// 此时实际URL映射是/user/profile
@GetMapping("/profile")
public ModelAndView profile() {
...
}
}
@RequestBody
将请求体参数
REST
Spring MVC开发Web应用程序的主要工作就是编写Controller逻辑;
在Web应用中,除了需要使用MVC给用户显示页面外,还有一类API接口,我们称之为REST,通常输入输出都是JSON,便于第三方调用或者使用页面JavaScript与之交互;
Controller中处理JSON:
@PostMapping(value = "/rest",
consumes = "application/json;charset=UTF-8",
produces = "application/json;charset=UTF-8")
@ResponseBody
public String rest(@RequestBody User user) {
return "{\"restSupport\":true}";
}
Maven工程需要加入 Jackson
依赖:com.fasterxml.jackson.core:jackson-databind:2.14.0
@ResponseBody
表示返回的String
无需额外处理,直接作为输出内容写入HttpServletResponse
;
输入的JSON则根据注解@RequestBody
直接被Spring反序列化为 User
这个JavaBean。
@RestController
使用 @RestController
替代 @Controller
,每个方法自动变成API接口方法。编写ApiController
如下:
@RestController // 自动转换方法
@RequestMapping("/api")
public class ApiController {
@Autowired
UserService userService;
@GetMapping("/users")
public List<User> users() {
return userService.getUsers();
}
@GetMapping("/users/{id}")
public User user(@PathVariable("id") long id) {
return userService.getUserById(id);
}
@PostMapping("/signin")
public Map<String, Object> signin(@RequestBody SignInRequest signinRequest) {
try {
User user = userService.signin(signinRequest.email, signinRequest.password);
return Map.of("user", user);
} catch (Exception e) {
return Map.of("error", "SIGNIN_FAILED", "message", e.getMessage());
}
}
}
User
能被正确地序列化为JSON,但暴露了password
属性,简单的方法是直接在User
的password
属性定义处加 上@JsonIgnore
表示JSON完全忽略该属性:
public class User {
...
@JsonIgnore
public String getPassword() {
return password;
}
...
}
这会导致所有情况JSON password 输入输出无效,无法读写;另一种是设置只写权限:
public class User {
...
@JsonProperty(access = Access.WRITE_ONLY)
public String getPassword() {
return password;
}
...
}
集成Filter
如果在Spring Mvc 中使用EncodingFilter,在全局范围类给 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>
如果允许用户使用Basic模式进行用户验证,即在HTTP请求中添加头Authorization: Basic email:password
;尝试编写 AuthFilter
实现:
@Component
public class AuthFilter implements Filter {
@Autowired
UserService userService;
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
// 获取Authorization头:
String authHeader = req.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Basic ")) {
// 从Header中提取email和password:
String email = prefixFrom(authHeader);
String password = suffixFrom(authHeader);
// 登录:
User user = userService.signin(email, password);
// 放入Session:
req.getSession().setAttribute(UserController.KEY_USER, user);
}
// 继续处理请求:
chain.doFilter(request, response);
}
}
直接在 web.xml
中声明 AuthFilter
,AuthFilter
的实例将由Servlet容器而不是Spring容器初始化,因此,@Autowire
根本不生效;
DelegatingFilterProxy
让Servlet容器实例化的Filter,间接引用Spring容器实例化的AuthFilter
。Spring MVC提供了一个 DelegatingFilterProxy
:
<web-app>
<filter>
<filter-name>authFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>authFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
...
</web-app>
- Servlet容器从
web.xml
中读取配置,实例化DelegatingFilterProxy
,注意命名是authFilter
; - Spring容器通过扫描
@Component
实例化AuthFilter
。
Interceptor
Filter组件实际上并不知道后续内部处理是通过Spring MVC提供的DispatcherServlet
还是其他Servlet组件,因为Filter是Servlet规范定义的标准组件,它可以应用在任何基于Servlet的程序中。
与此不同,Spring MVC提供的一种功能类似Filter的拦截器:Interceptor。和Filter相比,Interceptor拦截范围不是后续整个处理流程,而是仅针对Controller拦截:
使用Interceptor的好处是Interceptor本身是Spring管理的Bean,因此注入任意Bean都非常简单。此外,可以应用多个Interceptor,并通过简单的@Order
指定顺序。我们先写一个LoggerInterceptor
:
@Order(1)
@Component
public class LoggerInterceptor implements HandlerInterceptor {
final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.info("preHandle {}...", request.getRequestURI());
if (request.getParameter("debug") != null) {
PrintWriter pw = response.getWriter();
pw.write("<p>DEBUG MODE</p>");
pw.flush();
return false;
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.info("postHandle {}.", request.getRequestURI());
if (modelAndView != null) {
modelAndView.addObject("__time__", LocalDateTime.now());
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
logger.info("afterCompletion {}: exception = {}", request.getRequestURI(), ex);
}
}
一个Interceptor必须实现HandlerInterceptor
接口,可以选择实现preHandle()
、postHandle()
和afterCompletion()
方法。preHandle()
是Controller方法调用前执行,postHandle()
是Controller方法正常返回后执行,而afterCompletion()
无论Controller方法是否抛异常都会执行,参数ex
就是Controller方法抛出的异常(未抛出异常是null
)。
最后,要让拦截器生效,我们在WebMvcConfigurer
中注册所有的Interceptor:
@Bean
WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
return new WebMvcConfigurer() {
public void addInterceptors(InterceptorRegistry registry) {
for (var interceptor : interceptors) {
registry.addInterceptor(interceptor);
}
}
...
};
}
处理异常
在Controller中,Spring MVC还允许定义基于@ExceptionHandler
注解的异常处理方法。我们来看具体的示例代码:
@ExceptionHandler
@Controller
public class UserController {
@ExceptionHandler(RuntimeException.class)
public ModelAndView handleUnknowException(Exception ex) {
return new ModelAndView("500.html", Map.of("error", ex.getClass().getSimpleName(), "message", ex.getMessage()));
}
...
}
异常处理方法没有固定的方法签名,可以传入Exception
、HttpServletRequest
等,返回值可以是void
,也可以是ModelAndView
,上述代码通过@ExceptionHandler(RuntimeException.class)
表示当发生RuntimeException
的时候,就自动调用此方法处理。
CORS
@CrossOrigin
使用@CrossOrigin
注解,可以在@RestController
的class级别或方法级别定义一个@CrossOrigin
,例如:
@CrossOrigin(origins = "http://local.liaoxuefeng.com:8080")
@RestController
@RequestMapping("/api")
public class ApiController {
...
}
CorsRegistry
在WebMvcConfigurer
中定义一个全局CORS配置,下面是一个示例:
@Bean
WebMvcConfigurer createWebMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://local.liaoxuefeng.com:8080")
.allowedMethods("GET", "POST")
.maxAge(3600);
// 可以继续添加其他URL规则:
// registry.addMapping("/rest/v2/**")...
}
};
}
这种方式可以创建一个全局CORS配置,如果仔细地设计URL结构,那么可以一目了然地看到各个URL的CORS规则,推荐使用这种方式配置CORS。