SpringBoot
SpringBoot 是目前流行的微服务框架,其目的是用来简化新 Spring 应用的初始化搭建以及开发过程。SpringBoot 提供了很多核心功能,比如自动化配置 starter(启动器)简化 Maven 配置、内嵌 Servlet 容器、应用监控等功能,让我们可以快速构建企业级应用程序。
SpringBoot 入门
脚手架
软件工程中的脚手架是用来快速搭建一个小的可用的应用程序的骨架,将开发过程中要用到的工具,环境都配置好,同时生成必要的模板代码。Spring Initializr 是创建 Spring Boot 的脚手架,IDEA 内置了此工具,可以帮助我们快速创建项目。
Spring Initializr 脚手架的 Web 地址。
阿里云脚手架。
项目中的.mvn
、mvnw.cmd
、HELP.md
、mvnw
可以删除。
在application.properties
配置文件中配置server.port
可以解决可能出现的 8080 端口冲突的问题。
starter 启动器
starter 是一组依赖描述,应用中包含 starter,可以获取 Spring 相关技术的一站式的依赖和版本。不必复制、粘贴代码,通过 starter 能够快速启动并运行项目。包含了依赖和版本、传递依赖和版本、配置类、配置项。
父项目
默认的 SpringBoot 项目继承了 SpringBoot 父项目,该父项目包含了 SpringBoot 相关的依赖管理和版本管理,直接继承该父项目可以直接使用 SpringBoot 相对应的依赖和版本。如果不继承父项目,想要让项目继承自己的父项目,可以通过以下配置获得相关依赖:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Core
核心注解
SpringBoot 核心注解:@SpringBootApplication,作用在 main 方法所在的类之上。
/**
* SpringBootApplication是一个复合注解
* 包含了以下三个注解:
* @SpringBootConfiguration
* 该注解包含了Configuration注解的功能,有这个注解的类就是配置类
* @EnableAutoConfiguration
* 该注解可以开启自动配置,可以将Spring和第三方库中的对象创建好,注入到容器中
* @ComponentScan
* 自动扫描器,SpringBoot约定扫描启动类所在的根包,故我们的类必须写在根包下
*/
@SpringBootApplication
public class Lession06PackageApplication {
public static void main(String[] args) {
// run方法第一个参数是源类,也就是配置类
// 从源类开始创建各种对象并注入到容器之中
// 该方法的返回值是一个容器对象ApplicationContext
SpringApplication.run(Lession06PackageApplication.class, args);
}
}
SpringBoot 的 jar 文件和普通 jar 文件的区别
项目 | SpringBoot jar | 普通 jar |
---|---|---|
目录 | BOOT-INF:应用的 class 和依赖 jar、META-INF:清单、org.springframework.boot.loader:spring-boot-loader 模块类 | META-INF:清单、class 的文件夹:jar 中的所有类 |
BOOT-INF | class:应用的类、lib:应用的依赖 | 没有BOOT-INF |
spring-boot-loader | 执行 jar 的 SpringBoot 类 | 没有此部分 |
可执行 | 能 | 否 |
外部化配置
应用程序的配置文件:SpringBoot 允许在代码之外,提供应用程序运行的数据, 以便在不同的环境中使用相同的应用程序代码,避免硬编码,提供系统的灵活性。项目中常使用 properties 和 yaml 文件进行配置,其次是命令行参数。
配置文件的名称为application
,如果 properties 和 yml 类型都存在,优先加载 properties。不过,考虑到阅读性,我们更推荐使用 yml 格式的配置文件。
properties 配置
在 properties 文件中指定 key 和 value:
app.owner=zhangsan
app.password=040809
利用注解使用 properties 中的内容:
@Service
public class SomeService {
// 使用@Value注入值
@Value("${app.owner}")
private String name;
@Value("${app.password}")
private String password;
// ${key:默认值},找不到key就使用默认值给属性赋值
@Value("${app.port:8080}")
private Integer port;
public void printValue() {
System.out.println(name + " " + password + " " + port);
}
}
yml 扁平化
在 yml 文件中也可以指定配置的 key 和 value:
# 编写配置项,格式 -> key: 值
# 通过换行表示层级关系,这种也叫yml扁平化
app:
name: Lession06-package
owner: zhangsan
password: 040809
server:
port: 8023
注意,如果 yml 和 properties 文件同时存在的话,会优先加载 properties 文件的内容。
Environment
Environment 是外部化的抽象,是多种数据来源的集合。从中可以读取 application 配置文件、yml 文件、环境变量,系统属性。使用方式在 Bean 中注入 Environment,调用它的 getProperty(key)
方法。也就是说,如果我们要读取的配置文件散落在很多地方,那么我们只需要使用 Environment 来读取即可,相当于 Environment 帮助我们把这些散落的文件组织到一起了。
示例代码:
@Service
public class ReadConfig {
// 注入环境对象
@Autowired
private Environment environment;
public void print() {
// 判断某个key是否存在
if(environment.containsProperty("app.owner")) {
System.out.println(environment.getProperty("app.owner"));
}
else {
System.out.println("key不存在");
}
// 读取key的值,转为期望类型,同时提供默认值
Integer password = environment.getProperty("app.password", Integer.class, 666);
System.out.println(password);
}
}
组织多文件
大型集成的第三方框架,中间件比较多。每个框架的配置细节相对复杂,如果都将配置到一个 application 文件中,那么会导致文件的内容非常庞大,不易于阅读。所以,我们要将每个框架都独立出来一个配置文件,最后将多个文件集中到一个 application 文件中。
# 导入其他的配置文件,多个文件利用逗号分割
spring:
config:
import: conf/db.yml, conf/redis.yml
多环境配置
软件开发中常提到环境这个概念,影响软件运行的因素就叫环境,例如 ip、用户名和密码、端口、配置文件的路径等。Spring Profiles 表示环境,Profiles 有助于隔离应用程序配置,并使得它们在某些环境中可用。SpringBoot 规定环境文件名称为application-{profile}.properties(yml)
,其中 profile 表示自定义的环境名称,通常我们用 dev 表示开发环境,test 表示测试环境,prod 表示生产环境,feature 表示特性。在加载的时候,是配置文件和环境文件一起加载的。
环境配置文件示例:
myapp:
memo: 开发环境的配置文件
# 指定环境名称
spring:
config:
activate:
on-profile: dev
在 application 中使用对应的环境配置文件:
# 导入其他的配置文件,多个文件利用逗号分割
spring:
config:
import: conf/db.yml, conf/redis.yml
# 激活环境配置文件
profiles:
active: dev
绑定 Bean
当我们使用 @Value 绑定属性值的时候,一次性只能绑定单个值,比较不方便。SpringBoot 提供了另一种属性的方法,将多个配置项绑定到 Bean 的属性,提供强类型的 Bean。
基本原则:标准的 JavaBean 有无参数构造方法,包含属性的访问器,配合 @ConfigurationProperties 注解一起使用,Bean 的 static 属性不支持。
SpringBoot 中大量使用绑定 Bean 与 @ConfigurationProperties,提供对框架的定制参数。项目中要使用的数据如果是可变的,推荐在 yml 或 properties 中提供,这样可以让代码具有更加大的灵活性。
@ConfigurationProperties 注解能够配置多个简单类型属性,同时支持 Map、List、数组类型,对属性还能验证基本格式。
绑定简单类型数据
假设 yml 文件中的内容是这样的:
app:
name: Lession06-package
owner: zhangsan
password: 040809
我们使用 @ConfigurationProperties 注解来绑定 Bean:
@Configuration(proxyBeanMethods = false) // 默认创建的是代理对象,但如果我们不需要,关掉反而可以提高性能
@ConfigurationProperties(prefix = "app") // 指定前缀,即相同的开始关键字
public class AppBean {
// key的名称与属性名相同,框架会调用set方法给其赋值,属性不可以用static修饰
private String name;
private String owner;
private String password;
// getter and setter
}
嵌套 Bean
有些时候我们需要在 Bean 中包含其他 Bean 作为属性,将配置文件中的配置项绑定到 Bean 以及引用类型的成员。
例如有配置文件如下:
app:
name: Lession06-package
owner: zhangsan
password: 040809
port: 9090
# 嵌套的Security类
security:
username: root
password: 123456
接下来我们使用嵌套 Bean 为属性赋值:
@Configuration(proxyBeanMethods = false)
@ConfigurationProperties(prefix = "app")
public class NestAppBean {
// NestAppBean的自身原本属性
private String name;
private String owner;
private Integer port;
// 内部嵌套了另一个bean
private Security security;
// getter and setter
}
扫描注解
要想让 @ConfigurationProperties 所绑定的 Bean 起作用,我们还需要是用 @EnableConfigurationProperties 或 @ConfigurationPropertiesScan。这些注解是专门寻找 @ConfigurationProperties 注解的,将它的对象注入到 Spring 容器。在启动类上使用扫描注解:
// @EnableConfigurationProperties({NestAppBean.class})
@ConfigurationPropertiesScan({"cn.hnu.springboot.pkg.pk6"})
@SpringBootApplication
public class Lession06PackageApplication {
public static void main(String[] args) {
SpringApplication.run(Lession06PackageApplication.class, args);
}
}
绑定第三方对象
有些时候,我们嵌套 Bean 中使用的 Bean 不是我们自己定义的,无源代码,但是我们需要在配置文件中提供属性。此时 @ConfigurationProperties 结合 @Bean 一起在方法上面使用可以解决这个问题。
比如现在有一个 Security 类是第三方库中的类,现在要提供它的 username 和 password 属性值。
在本项目的 application 配置文件中配置:
# 第三方库对象,没有源代码
security:
username: common
password: 1234567
创建配置类,注入属性值:
@Configuration
public class ApplicationConfig {
// 创建Bean对象,属性来自配置文件
@ConfigurationProperties(prefix = "security")
// 标记为Bean的创建方法
@Bean
public Security createSecurity() {
return new Security();
}
}
接下来可以使用对象:
// 属性自动注入
@Autowired
Security security;
@Test
void testApplicationConfig() {
// Security security = applicationConfig.createSecurity();
// System.out.println(security);
System.out.println(security);
}
集合的绑定
Map、List、Array 都能提供配置数据。
保存数据的 Bean:
public class User {
private String name;
private int age;
private String gender;
// getter and setter
}
public class MyServer {
private String title;
private String ip;
// getter and setter
}
// 使用ConfigurationProperties进行配置文件属性赋值,主要在启动类上要进行包扫描
@ConfigurationProperties
public class CollectionConfig {
private List<MyServer> servers;
private Map<String, User> users;
private String[] names;
// getter and setter
}
application 配置文件中编写属性配置:
# 配置集合
# 数组和List集合的配置格式是一样的,使用 - 表示一个成员
names:
- lisi
- zhangsan
servers:
- title: 华北服务器
ip: 202.12.9.7
- title: 西南服务器
ip: 106.23.32.11
# Map集合的成员配置需要指定key和value
users:
user1: # 指定key为user1
name: zhangsan
age: 22
gender: 男
user2:
name: lisi
age: 21
gender: 女
指定数据源文件
一般我们使用 application 作为配置文件,但是,我们通常希望有一个特定的文件配置各个 Bean 的属性值来作为我们的数据来源。在类上使用 @PropertySource 可以指定 properties 文件(注意不是 yaml 文件,该注解并不支持这种类型的文件解析)作为数据来源。该注解与 @Configuration 一同使用:
@Configuration
@PropertySource("classpath:/groupInfo.properties") // 指定类路径下的资源
@ConfigurationProperties(prefix = "group")
public class Group {
private String name;
private String leader;
private Integer members;
// setter and getter
}
创建对象的三种方式
将对象注入到 Spring 容器,可以通过如下方式:
- 传统的 XML 配置文件。
- Java Config 技术,通过 @Configuration 和 @Bean。
- 创建对象的注解:@Component、@Controller、@Service、@Repository。
SpringBoot 不推荐使用 XML 配置文件的方式,自动配置已经解决了大部分 xml 中的配置工作了。如果需要 xml 提供 bean 的声明,@ImportResource 加载 xml 注册 Bean。
// 指定xml配置文件
@ImportResource(locations = "classpath:/applicationContext.xml")
@SpringBootApplication
public class Lession06PackageApplication {
public static void main(String[] args) {
ConfigurableAppicationContext run = SpringApplication.run(Lession06PackageApplication.class, args);
// 获取bean对象
Person bean = run.getBean(Person.class);
}
}
AOP
面向切面编程,可以在保持原本代码不变的情况下,给原有的业务逻辑添加二维的功能,对于扩展功能十分有利,Spring 的事务功能就是在 AOP 的基础上去实现的。
SpringBoot 中使用 AOP 需要先引入对应依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
AOP 示例:
// 目标对象
@Service("someService")
public class SomeServiceImpl implements SomeService {
@Override
public void query(Integer id) {
System.out.println("query");
}
@Override
public void save(String name, Integer age) {
System.out.println("save " + name + " " + age);
}
}
// 切面类
@Component
@Aspect
public class LogAspect {
// 在类中定义功能增强的方法
@Around("execution(public void cn.hnu.springboot.lession08.aop.service.*.*(..))")
public void sysLog(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println(new Date());
joinPoint.proceed();
System.out.println("功能增强完毕");
}
}
注解配置切点
在 Spring Boot 中使用 AOP 时,可以通过自定义注解来定义切点,进而实现特定的业务逻辑。下面是如何通过注解来进行切点配置的步骤:
首先,需要定义一个自定义注解来标识需要切入的目标方法。比如,创建一个 @LogExecutionTime
注解:
@Target(ElementType.METHOD) // 该注解用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时可用
public @interface LogExecutionTime {
}
接着,创建一个切面类,该类中的方法可以作为通知(Advice)处理业务逻辑。可以在切面类中使用 @Around
或 @Before
、@After
等注解来实现切点逻辑。
在切面类中,使用 @annotation
来指定切点为标注了特定注解的方法。比如,使用 @LogExecutionTime
注解的方法:
@Aspect
@Component
public class LogExecutionTimeAspect {
@Around("@annotation(com.example.demo.annotation.LogExecutionTime)") // 切入点,指定注解类型
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis(); // 记录方法开始时间
Object proceed = joinPoint.proceed(); // 执行方法
long executionTime = System.currentTimeMillis() - start; // 计算方法执行时间
System.out.println(joinPoint.getSignature() + " executed in " + executionTime + "ms");
return proceed;
}
}
最后,在需要切入的目标方法上使用刚才创建的 @LogExecutionTime
注解:
@RestController
public class MyController {
@LogExecutionTime // 标注该方法需要切面处理
@GetMapping("/test")
public String testMethod() {
// 业务逻辑
return "Hello, World!";
}
}
当然,需要确保在 Spring Boot 应用中启用了 AOP 支持:
@SpringBootApplication
@EnableAspectJAutoProxy // 启用AOP
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
自动配置
启用 autoconfigure(自动配置),框架尝试猜测要使用的 Bean,从类路径中查找 xxx.jar
,创建这个 jar 中某些需要的 Bean。例如我们使用 MyBatis 访问数据,从我们项目的类路径中寻找 mybatis.jar
,进一步创建 SqlSessionFactory,还需要 DataSource 数据源对象,尝试连接数据。这些工作交给XXXAutoConfiguration
类,这些就是自动配置类。在 spring-boot-autoconfigure-3.0.2.jar
定义了很多的XxXAutoConfiguration
类。第三方框架的 starter 里面包含了自己的 XXXAutoConfiguration
类。
例如,在和 Mybatis 框架进行整合的时候,就提供了MybatisAutoConfiguration
自动配置类,该类提供了SqlSessionFactory
用于创建 SqlSession,还提供了SqlSessionTemplate
用于执行 sql 语句,还有MapperFactoryBean
用于创建 Dao 接口的代理对象。
MyBatis
MyBatis 需要的依赖项有:
- Lombok
- MyBatis Framework
- MySQL Driver
DataSource
DataSource 在 application 配置文件中以 spring.datasource.*
作为配置项。DataSourceProperties 是数据源的配置类,更多配置参考这个类的属性。
# 配置数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=MySQL:040809
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/big-event?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
username: root
password: MySQL:040809
除此之外还需要添加 mapper 配置文件扫描、自动驼峰映射、起别名和日志信息:
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.type-aliases-package=cn.hnu.springboot.lession10.mybatis.pojo
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis:
mapper-locations: classpath:mappers/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
type-aliases-package: cn.hnu.springboot.bigevent.model
启动类配置:
@SpringBootApplication
// 启动类需要利用MapperScan扫描mapper接口所在的包
@MapperScan(basePackages = "cn.hnu.springboot.lession10.mybatis.mapper")
public class Lession10MybatisApplication {
public static void main(String[] args) {
SpringApplication.run(Lession10MybatisApplication.class, args);
}
}
在使用的时候直接利用 @Autowired 对 mapper 接口进行注入即可,无需手动调用 SqlSession 去创建 mapper 的动态代理类。
SqlProvider
MyBatis 提供了 SQL 提供者的功能,将 SQL 以方法的形式定义在单独的类中。Mapper 接口通过引用 SQL 提供者中的方法名称,表示要执行的 SQL。
SQL 提供者有四类 @SelectProvider,@InsertProvider,@UpdateProvider,@DeleteProvider。
编写 SQL 提供者类:
public class SqlProvider {
// 定义静态方法
public static String selectCar() {
return "select * from t_car where id = #{id}";
}
}
使用 SQL 提供者:
public interface CarMapper {
// Sql提供者
// type填入提供者类的字节码文件,method填入提供者类的方法
@SelectProvider(type = SqlProvider.class, method = "selectCar")
Car selectById2(Integer id);
}
一(多)对一
MyBatis 支持一对一、一对多、多对多的查询。XML 文件和注解都能实现关系的操作。我们使用注解表示上述的关系:**@One 表示一对一、@Many 表示一对多**。
// 课程Mapper
public interface ClazzMapper {
// 查询
@Select("select * from t_clazz where cid = #{cid}")
Clazz getClazzById(Integer cid);
}
// 学生Mapper
public interface StudentMapper {
// 查询
@Select("select * from t_stu where sid = #{sid}")
@Results({
@Result(id = true, column = "sid", property = "sid"),
@Result(column = "sname", property = "sname"),
@Result(column = "cid", property = "clazz",
// 使用One进行分步查询,同时也支持懒加载
one=@One(select = "cn.hnu.springboot.lession10.mybatis.mapper.ClazzMapper.getClazzById", fetchType = FetchType.LAZY))
})
Student getStudentById(int sid);
}
一对多
// 学生Mapper
public interface StudentMapper {
// 查询
@Select("select * from t_stu where cid = #{cid}")
List<Student> getStusById(int cid);
}
// 课程Mapper
public interface ClazzMapper {
// 查询
@Select("select * from t_clazz where cid = #{cid}")
@Results({
@Result(id = true, column = "cid", property = "cid"),
@Result(column = "cname", property = "cname"),
@Result(column = "cid", property = "stus",
// 使用many进行分步查询
many = @Many(select = "cn.hnu.springboot.lession10.mybatis.mapper.StudentMapper.getStusById", fetchType = FetchType.LAZY))
})
ClazzStus getClazzStusById(Integer cid);
}
常用设置和自动配置
MyBatis 框架在 SpringBoot 中的自动配置类为:MybatisAutoConfiguration。
除了之前我们说过的可以直接在 properties 文件中进行 MyBatis 的配置之外,也可以使用 MyBatis 的默认 xml 配置,然后再指定到 properties 文件中,如下指定:
mybatis.config-location=classpath:/mybatis-config.xml
连接池
HikariCP 连接池,MySQL 配置提示。
jdbcUrl=jdbc:mysql://localhost:3306/simpsons
username=test
password=test
dataSource.cachePrepStmts=true
dataSource.prepStmtCacheSize=250
dataSource.prepStmtCacheSqlLimit=2048
dataSource.useServerPrepStmts=true
dataSource.useLocalSessionState=true
dataSource.rewriteBatchedStatements=true
dataSource.cacheResultSetMetadata=true
dataSource.cacheServerConfiguration=true
dataSource.elideSetAutoCommits=true
dataSource.maintainTimeStats=false
在 SpringBoot 的 application 配置文件中,使用下述代码配置连接池:
# 默认连接池
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
完整实例如下:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql//localhost:3306/news_system
username: root
password: MySQL:040809
type: com.zaxxer.hikari.HikariDataSource
hikari:
auto-commit: true
maximum-pool-size: 10
minimum-idle: 10
# 获取连接时,检测语句
connection-test-query: select 1
connection-timeout: 20000
# 其他属性
data-source-properties:
cachePreStmts: true
dataSource.cachePreStmtst: true
dataSource.preStmtCacheSize: 250
dataSource.preStmtCacheSqlLimit: 2048
dataSource.useServerPrepStmts: true
声明式事务
Spring 框架的事务管理是通过 Spring 面向切面编程实现的,事务使用的是环绕通知(TransactionInterceptor)。Spring 团队建议将 @Transactional 注解注释到具体类(以及具体类的方法),而不是注释接口,这样可以降低代码的耦合度。
@Service("accountService")
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
@Transactional(rollbackFor = Exception.class) // 设置事务注解,碰到Exception时回滚
public int transform(String fromAccount, String toAccount, int money) {
Account fromAct = accountMapper.getByName(fromAccount);
if (fromAct.getMoney() < money) {
throw new RuntimeException("账户余额不足");
}
Account toAct = accountMapper.getByName(toAccount);
// 转账
fromAct.setMoney(fromAct.getMoney() - money);
int count = accountMapper.modifyById(fromAct);
toAct.setMoney(toAct.getMoney() + money);
count += accountMapper.modifyById(toAct);
return count;
}
}
@EnableTransactionManagement // 可选,不加也可以开启事务管理器
@SpringBootApplication
@MapperScan(basePackages = "cn.hnu.springboot.lession11transaction.mapper")
public class Lession11TransactionApplication {
public static void main(String[] args) {
SpringApplication.run(Lession11TransactionApplication.class, args);
}
}
无效事务
跨方法调用事务
Spring 事务处理是 AOP 的环绕通知,只有通过代理对象调用具有事务的方法才能生效。类中有 A方法,调用带有事务的 B 方法。调用 A方法事务无效。当然 protected, private 方法默认是没有事务功能的。
@Service("accountService")
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
// @Transactional(propagation = Propagation.REQUIRED)
// 如果不加入这个事务传播行为,那么这个myTransForm方法没办法开启事务
public int myTransForm(String fromAccount, String toAccount, int money) {
return transform(fromAccount, toAccount, money);
}
@Override
@Transactional(rollbackFor = Exception.class) // 设置事务注解,碰到Exception时回滚
public int transform(String fromAccount, String toAccount, int money) {
//...
}
}
新线程调用事务
方法在线程中运行,在同一线程中方法具有事务功能,新的线程中的代码事务无效。
@Service("accountService")
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
public AtomicInteger theadTransForm(String fromAccount, String toAccount, int money) {
System.out.println("父线程:" + Thread.currentThread().getName());
AtomicInteger count = new AtomicInteger();
// 开启了一个新的线程,该线程是新线程,调用的方法不会开启事务
Thread thread = new Thread(()->{
count.addAndGet(transform(fromAccount, toAccount, money));
});
thread.start();
return count;
}
@Override
@Transactional(rollbackFor = Exception.class) // 设置事务注解,碰到Exception时回滚
public int transform(String fromAccount, String toAccount, int money) {
//...
}
}
Web
SpringBoot 可以创建两种类型的 Web 应用:
- 基于 Servlet 体系的 Spring Web MVC 应用。
- 使用 spring-boot-starter-webflux 模块来构建响应式,非阻塞的 Web 应用程序。
Web 应用需要的依赖项有:
- Lombok
- Spring Web
- Thymeleaf
基础使用如下:
@Controller // 声明为Controller
public class QuickController {
// 指定访问url
@RequestMapping("/exam/quick")
// 导入的是springframework.ui.Model,用于存储数据,把数据放在request作用域
public String quick(Model model) {
// 处理参数数据
model.addAttribute("title", "Web开发");
model.addAttribute("time", new Date());
// 指定视图,显示数据
return "quick";
}
}
thymeleaf 拿取 request 作用域的参数语法:
<div th:text="${attribute_name}"></div>
视图
上面的例子以 Html 文件作为视图,可以编写复杂的交互的页面,CSS 美化数据。除了带有页面的数据,还有一种只需要数据的视图。比如手机应用 app,app 的数据来自服务器应用处理结果。app 内的数据显示和服务器无关,只需要数据就可以了。主流方式是服务器返回 json 格式数据给手机 app 应用。我们可以通过原始的HttpServletResponse 应该数据给请求方。借助 Spring MVC 能够无感知的处理 json。这种视图我们称为 json 视图。
@Controller
public class JsonViewController {
// 响应json串
@RequestMapping("/exam/json")
public void responseJson(HttpServletResponse response) throws IOException {
String json = "{\"name\":\"lisi\",\"age\":18}";
// 应答,通过HttpServletResponse输出
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(json);
}
// SpringMVC支持控制器返回对象,由框架将要使用的对象转为json后输出
@RequestMapping("/exam/userJson")
@ResponseBody // 使用@ResponseBody将数据以json格式写出
public User getUserJson() {
User user = new User();
user.setUsername("zhangsan");
user.setPassword("123");
return user;
}
}
favicon
favicon.ico 是网站的缩略标志,可以显示在浏览器标签、地址栏左边和收藏夹,是展示网站个性的 logo 标志。可以利用这个网站快速生成。
- 将生成的 favicon.ico 拷贝到项目的
resources/static/
目录。 - 在视图的
<head>
部分加入<link rel="icon" href="../static/favicon.ico" type="image/x-icon/">
。
路径
SpringMVC 支持多种策略,匹配请求路径到控制器方法。分别为:AntPathMatcher、PathPatternParser。从 SpringBoot3 开始,推荐使用 PathPatternParser,比之前 AntPathMathcer 提升 6-8 倍的吞吐量。
配置如下:
spring.mvc.pathmatch.matching-strategy=path_pattern_parser
让我们看一下 PathPatternParser 中有关 uri 的定义:
?
:一个字符。*
:0 或多个字符。在一个路径段中匹配字符。**
:匹配 0 个或多个路径段,相当于是所有。- 正则表达式:支持正则表达式。
RESTFul 的支持路径变量:
{变量名}
:路径占位符。{myname:[a-z]+}
:正则匹配 a-z 的多个字面,路径变量名称为 myname。(@PathVariable("myname")
){*myname}
:匹配多个路径一直到 uri 的结尾。
示例如下:
@GetMapping("/file/t?st.html")
// http://localhost:8080/file/test.html
// http://localhost:8080/file/teest.html 该地址匹配不成功,因为?只能匹配单个字符
@GetMapping("/images/*.gifs")
/*
以下几个url都满足要求
http://localhost:8080/images/user.gifs
http://localhost:8080/images/cat.gifs
http://localhost:8080/images/.gifs
http://localhost:8080/images/gif/header.gif 该地址匹配不成功,因为*不能包括段落
*/
@GetMapping("/pic/**")
/*
以下几个url都满足要求,**适合多段落匹配
http://localhost:8080/pic/p1.gif
http://localhost:8080/pic/2024/p1.gif
http://localhost:8080/pic/user
http://localhost:8080/pic/
*/
@GetMapping("/order/{*id}")
// 匹配/order开始的所有请求,id表示order后面直到路径结束的所有内容
// 可以结合@PathVariable将id的内容拿出来
// http://localhost:8080/order/1001 id=/1001
// http://localhost:8080/order/1001/2024-05-01 id=/1001/2024-05-01
// 注意"/order/{*id}/{*date}"是无效的,{*..}后面不能再有匹配规则了
@GetMapping("/pages/{fname:\\w+}.log")
// :\\w+正则匹配,xxx.log
// http://localhost:8080/pages/req.log
接收请求参数
接收参数方式:
- 请求参数与形参一一对应,适用于简单类型。
- 对象类型,控制器方法形参是对象,请求的多个参数名与属性名相对应。
- @RequestParam 注解,进行请求参数和方法参数的映射。
- @RequestBody,接受前端传递的 json 格式参数。
- HttpServletRequest 使用 request 对象接受参数。
- @RequestHeader,从请求 header 中获取某项值。
解析参数需要的值,SpringMVC 中专门有个接口来干这个事情,这个接口就是:HandlerMethodArgumentResolver
,中文称呼:处理器方法参数解析器,说白了就是解析请求得到 Controller 方法的参数的值。
@RestController
public class ParameterController {
// 简单参数直接接收
@RequestMapping("/exam/param/p1")
// http://localhost:8023/exam/param/p1?name=zhangsan&age=18&gender=男
public String parameterTest1(String name, Integer age, String gender) {
return "接受参数: name = " + name + ", age = " + age + ", gender = " + gender;
}
// 利用pojo类接收参数
@RequestMapping("/exam/param/p2")
// http://localhost:8023/exam/param/p2?username=zhangsan&password=123
public String parameterTest2(User user) {
return user.toString();
}
// 利用原生servlet接收参数
@RequestMapping("/exam/param/p3")
// http://localhost:8023/exam/param/p3?name=zhangsan&password=123
public String parameterTest3(HttpServletRequest request) {
String name = request.getParameter("name");
String password = request.getParameter("password");
return name + " " + password;
}
// 进行方法参数和请求参数的映射
@RequestMapping("/exam/param/p4")
// http://localhost:8023/exam/param/p4?user_password=123456
// required = false意味着这个参数可选,如果没有,默认值是defaultValue的值
public String parameterTest4(@RequestParam(value = "user_name", required = false, defaultValue = "zhangsan") String name,
@RequestParam("user_password") String password) {
return name + " " + password;
}
// 接收请求头中的参数值
@RequestMapping("/exam/param/p5")
// http://localhost:8023/exam/param/p5
public String parameterTest5(@RequestHeader("Accept") String accept) {
return accept;
}
// 接收前端传递过来的json串(请求体数据)
@RequestMapping("/exam/param/p6")
// http://localhost:8023/exam/param/p6
// Content-Type: application/json
//
// {"username":"张三", "password":123}
public String parameterTest6(@RequestBody User user) {
return user.toString();
}
// 使用Reader也可以读取请求体中的数据
@RequestMapping("/exam/param/p7")
public String parameterTest7(Reader reader) {
StringBuffer buffer = new StringBuffer();
try (BufferedReader br = new BufferedReader(reader)) {
String line;
while ((line = br.readLine()) != null) {
buffer.append(line);
}
} catch (IOException e) {
e.printStackTrace();
}
return buffer.toString();
}
// 数组作为形参,接收多个参数值
@RequestMapping("/exam/param/p8")
// http://localhost:8023/exam/param/p8?ids=1&ids=2&ids=3
public String parameterTest8(Integer[] ids) {
return Arrays.toString(ids);
}
}
BeanValidation
服务器端程序,Controller 在方法接受了参数,这些参数是由用户提供的,使用之前必须校验参数是我们需要的吗,值是否在允许的范围内,是否符合业务的要求。
BeanValidation 是提供数据验证 JSR-303 的一个子规范,为 JavaBean 验证定义了相应的元数据模型和 API,其中,hibernate-validator 是一个比较有名的实现。
- @Valid:适用于
@RequestBody
参数,可以用于参数级别、属性级别和方法返回值的校验。 - @Validated:Spring 提供的注解,用于控制器类或方法参数上,用于校验路径变量和请求参数(如
@RequestParam
的场景)。
BeanValidation 内置的 Constraint:
Constraint | 详细信息 |
---|---|
@Null | 必须为null |
@NotNull | 必须非null |
@NotBlank | 字符串必须非null和非空字符串 |
@AssertTrue | 必须为true |
@AssertFalse | 必须为false |
@Min(value) | 必须是一个数字,值必须大于等于指定最小值 |
@Max(value) | 必须是一个数字,值必须小于等于指定最大值 |
@DecimalMin(value) | 必须是一个数字,值必须大于等于指定最小值 |
@DecimalMax(value) | 必须是一个数字,值必须小于等于指定最大值 |
@Size(min, max) | 值必须在指定范围内,一般用于注释集合等 |
@Digits(integer, fraction) | 值必须在指定范围内 |
@Past | 必须是一个过去的日期 |
@Futuret | 必须是一个将来的日期 |
@Pattern(value) | 必须符合指定的正则表达式 |
hibernate-validator 附加的 constraint:
Constraint | 详细信息 |
---|---|
必须是电子邮箱地址 | |
@Length | 字符串的大小必须在指定范围内 |
@NotEmpty | 被注释的字符串必须非空 |
@Range(min, max) | 被注释的元素必须在合适的范围内 |
@URL | 被注释的字符串为URL |
普通验证
需要先加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
在类的变量名上直接使用注解进行规则校验:
public class User {
@NotNull(message = "用户名不能为空")
@Pattern(regexp = "[a-zA-Z0-9_-]{4,16}")
String username;
@NotNull(message = "密码不能为空")
@Pattern(regexp = "\\S*(?=\\S{6,})(?=\\S*\\d)(?=\\S*[A-Z])(?=\\S*[a-z])(?=\\S*[!@#$%^&*? ])\\S*")
String password;
}
接着在 Controller 上使用 @Validated 注解进行规则验证:
@RestController
public class UserController {
// 加上@Validated验证Bean,利用BindingResult获取Bean的验证结果
@RequestMapping("/exam/user/add")
public Map<String, Object> addUser(@Validated @RequestBody User user,
BindingResult bindingResult) {
Map<String, Object> map = new HashMap<>();
// 获取没有通过验证的结果
if (bindingResult.hasErrors()) {
// 进行对应处理
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
for (int i = 0; i < fieldErrors.size(); i++) {
FieldError field = fieldErrors.get(i);
// 将出错的属性和出错原因放入map集合中
map.put(field.getField() + "-" + i, field.getDefaultMessage());
}
}
return map;
}
}
或者,直接在方法的参数上使用校验用的注解,然后把 @Validated 注解标注在控制器上也是可以的。
分组验证
现在碰到一个问题:假设 User 当中有一个 id 属性,这个属性在进行用户插入的时候应该为空,而进行用户修改的时候应该非空,如果直接注解作用在 id 上就会产生矛盾了,这个时候就需要进行分组验证(所谓的组实际上就是一个空接口)。
如果某个校验项没有指定分组,默认属于 Default 分组。且分组之间可以继承,如果A extends B
,那么 A 中就拥有 B 的校验项。
使用的时候在 @Validated 注解后面标注是哪个组就好了,示例:@Validated({User.addGroup.class})
。
import jakarta.validation.groups.Default;
public class User {
// 新增组
public static interface addGroup extends Default {};
// 修改组
public static interface updateGroup extends Default {};
@NotNull(message = "主键在编辑时必须有值", groups = {updateGroup.class})
@Min(value = 1, message = "id要大于0", groups = {updateGroup.class})
@Null(message = "主键在插入时需为空", groups = {addGroup.class})
Integer id;
@NotBlank // 默认分组
String name;
}
自定义验证
已有的注解不能满足所有的校验需求的时候,特殊的情况需要自定义校验(自定义校验注解)。步骤如下:
- 自定义注解 State。
- 自定义校验数据的类 StateValidation 实现 ConstrainValidator 接口。
- 在需要校验的地方使用自定义注解。
// 元注解,用来标识本注解可以抽取到文档中
@Documented
// 元注解,用来标识本注解可以作用在哪些地方
@Target({ElementType.FIELD})
// 元注解,用来标识本注解在哪个阶段会被保留,我们这里保留到运行阶段
@Retention(RetentionPolicy.RUNTIME)
// 用来指定谁给注解提供校验规则
@Constraint(validatedBy = {StateValidation.class})
public @interface State {
// 用来提供校验失败后的信息
String message() default "state参数的值只能是 已发布 或者 草稿";
// 指定分组
Class<?>[] groups() default {};
// 负载,用于获取到state注解的附加信息
Class<? extends Payload>[] payload() default {};
}
// 泛型的第一个参数指将来给哪个注解提供校验规则,第二个参数指校验的数据类型
public class StateValidation implements ConstraintValidator<State, String> {
// 提供校验规则,value就是将来要校验的数据,如果返回false则校验不通过
@Override
public boolean isValid(String value,
ConstraintValidatorContext context) {
if (value == null) return false;
return value.equals("已发布") || value.equals("草稿");
}
}
ValidationAutoConfiguration
ValidationAutoConfiguration 自动配置类,创建了 LocalValidatorFactoryBean 对象,当有 class 路径中有hibernate.validator。能够创建 hiberate 的约束验证实现对象。@ConditionalOnResource(resources = "classpath:META-INF/services/jakarta.validation.spi.ValidationProvider")
自定义状态码
使用 ResponseEntity 自定义状态码:
@RestController
public class UserController {
@RequestMapping("/exam/user/json")
public ResponseEntity<User> returnEntity() {
User user = new User();
user.setId(1);
user.setUsername("admin");
user.setPassword("123456");
// 可以自定义状态码
ResponseEntity<User> response = new ResponseEntity<>(user, HttpStatus.OK);
return response;
}
}
SpringMVC 请求流程
Spring MVC 框架是基于 Servlet 技术的。以请求为驱动,围绕 Servlet 设计的。Spring MVC 处理用户请求与访问一个 Servlet 是类似的,请求发送给 Servlet,执行 doService 方法,最后响应结果给浏览器完成一次请求处理。
DispatcherServlet 是核心对象,称为中央调度器(前端控制器 Front Controller)。负责接收所有对 Controller 的请求,调用开发者的 Controller 处理业务逻辑,将 Controller 方法的返回值经过视图处理响应给浏览器。
DispatcherServlet 作为 SpringMVC 中的 C,职责:
- 是一个门面,接收请求,控制请求的处理过程。所有请求都必须有 DispatcherServlet 控制。SpringMVC 对外的入口。可以看做门面设计模式。
- 访问其他的控制器,这些控制器处理业务逻辑。
- 创建合适的视图,将 2 中得到业务结果放到视图,响应给用户。
- 解耦了其他组件,所有组件只与 DispatcherServlet 交互,彼此之间没有关联。
- 实现 ApplictionContextAware,每个 DispatcherServlet 都拥自己的 WebApplicationContext,它继承了ApplicationContext(意味着 DispatcherServlet 也可以看作一个容器,可以访问到各种 Bean)。WebApplicationContext 包含了Web 相关的 Bean 对象,比如开发人员注释 @Controller 的类,视图解析器,视图对象等等。DispatcherServlet 访问容器中 Bean 对象。
- Servlet + Spring IoC 组合。
Web 配置
服务器配置
# 服务器端口号
server.port=8080
# 上下文访问路径
server.servlet.context-path=/
# request,response字符编码
server.servlet.encoding.charset=utf-8
# 强制request,response设置charset字符编码
server.servlet.encoding.force=true
# 日志路径
server.tomcat.accesslog.directory=D:/logs
# 启用访问日志
server.tomcat.accesslog.enabled=true
# 日志文件名前缀
server.tomcat.accesslog.prefix=access_log
# 日志文件日期时间
server.tomcat.accesslog.file-date-format=.yyyy-MM-dd
# 日志文件名称后缀
server.tomcat.accesslog.suffix=.log
# post请求内容最大值,默认2M
server.tomcat.max-http-form-post-size=2000000
# 服务器最大连接数
server.tomcat.max-connections=8192
DispatcherServlet 配置
# 配置访问路径
spring.mvc.servlet.path=/course
# servlet加载顺序,越小创建时间越早
spring.mvc.servlet.load-on-startup=0
# 时间格式,可以在接收请求参数时使用
spring.mvc.format.date-time=yyyy-MM-dd HH:mm:ss
// 设置时间格式后接收时间
@RequestMapping("/exam/date")
@ResponseBody
// http://localhost:8023/exam/date?date=2024-05-02 19:18:22
public String date(LocalDateTime date) {
return "时间: " + date;
}
// 如果不去使用spring.mvc.format.date-time,也可以使用@DateTimeFormat来指定日期格式
@RequestMapping("exam/date")
@ResponseBody
public String date(@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime date) {
return "时间: " + date;
}
HttpServlet 的创建
注解方式创建
和 JavaWeb 中的操作方式一样,使用注解 @WebServlet 指定映射路径。
@WebServlet(urlPatterns = "/helloServlet", name = "HelloServlet")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req,
HttpServletResponse resp)
throws ServletException, IOException {
resp.setContentType("text/html;charset=utf-8");
resp.getWriter().println("<h1>Hello World</h1>");
}
}
不过还需要在启动类上增加 @ServletComponentScan 注解进行包扫描,注册 Servlet。
// servlet扫描器,可以扫描servlet,filter,listener
@ServletComponentScan("cn.hnu.springboot.lession13.web")
@SpringBootApplication
public class Lession13ServletFilterApplication {
public static void main(String[] args) {
SpringApplication.run(Lession13ServletFilterApplication.class, args);
}
}
编码方式创建
首先,Serlvet 的创建和 JavaWeb 中的操作一致,不过不再需要注解了:
public class LoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req,
HttpServletResponse resp)
throws ServletException, IOException {
resp.setContentType("text/html;charset=utf-8");
resp.getWriter().println("<h1>Login Servlet</h1>");
}
}
其次,在编码方式创建中,我们需要创建一个配置类来注册添加 Serlvet:
@Configuration
public class WebAppConfig {
@Bean
public ServletRegistrationBean addSerlvet() {
// 创建ServletRegistrationBean,登录一个或多个Servlet
ServletRegistrationBean registrationBean = new ServletRegistrationBean();
// 添加Serlvet
registrationBean.setServlet(new LoginServlet());
// 指定映射路径
registrationBean.addUrlMappings("/login");
// 指定创建时间
registrationBean.setLoadOnStartup(1);
// 返回ServletRegistrationBean
return registrationBean;
}
}
Filter 的创建
Fiter 对象使用频率比较高,比如记录日志,权限验证,敏感字符过滤等等。Web 框架中包含内置的 Filter,SpringMVC 中也包含较多的内置 Filter,比如 CommonsRequestLoggingFilter,CorsFilter,FormContentFilter…
注解方式创建
注解方式创建过滤器,和 JavaWeb 中的操作一致,注意还需要在启动类上加上 @ServletComponentScan 注解进行扫描。
// 利用注解指定要过滤的路径
@WebFilter(urlPatterns = "/*")
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain)
throws IOException, ServletException {
// 转换一下servlet的类型
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 执行操作
String uri = request.getRequestURI();
System.out.println("过滤器执行了,uri: " + uri);
// 放行
filterChain.doFilter(request, response);
}
}
编码方式创建
原来的 @WebFilter 注解可以去掉,然后在 WebAppConfig 类中添加如下方法:
@Configuration
public class WebAppConfig {
@Bean
public ServletRegistrationBean addSerlvet() {
//...
}
@Bean
public FilterRegistrationBean addFilter() {
// 登录Filter对象
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
// 添加Filter
registrationBean.setFilter(new LogFilter());
// 指定映射路径
registrationBean.addUrlPatterns("/*");
// 返回FilterRegistrationBean
return registrationBean;
}
}
Filter 的排序
多个 Filter 对象如果要排序,有两种途径:
- 滤器类名称,按字典顺序排列,AuthFilter -> LogFilter。
- FilterRegistrationBean 登记 Filter,设置 order 顺序,数值越小,先执行。
利用第二种方法进行排序:
@Configuration
public class WebAppConfig {
@Bean
public ServletRegistrationBean addSerlvet() {
//...
}
@Bean
public FilterRegistrationBean addLogFilter() {
// 登录Filter对象
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
// 添加Filter
registrationBean.setFilter(new LogFilter());
// 指定映射路径
registrationBean.addUrlPatterns("/*");
// 设置顺序
registrationBean.setOrder(1);
// 返回FilterRegistrationBean
return registrationBean;
}
@Bean
public FilterRegistrationBean addAuthFilter() {
// 登录Filter对象
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
// 添加Filter
registrationBean.setFilter(new AuthFilter());
// 指定映射路径
registrationBean.addUrlPatterns("/*");
// 设置顺序
registrationBean.setOrder(2);
// 返回FilterRegistrationBean
return registrationBean;
}
}
使用框架内置的 Filter
SpringBoot 中有许多已经定义好的 Filter,这些 Filter 实现了一些功能,如果我们需要使用他们。可以像自己的Filter一样,通过 FilterRegistrationBean 注册Filter对象。
假设现在我们想记录每个请求的日志,那么 CommonsRequestLoggingFilter 就能完成简单的请求记录。
@Configuration
public class WebAppConfig {
@Bean
public ServletRegistrationBean addSerlvet() {
//...
}
@Bean
public FilterRegistrationBean addFilter() {
//...
}
// 登记框架内置的Filter
@Bean
public FilterRegistrationBean addCommonLogFilter() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
// 创建框架内置的Filter对象
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
// 记录请求的url地址
filter.setIncludeQueryString(true);
// 登记Filter
registrationBean.setFilter(filter);
// 添加映射路径
registrationBean.addUrlPatterns("/*");
// 排序
registrationBean.setOrder(1);
// 返回registrationBean
return registrationBean;
}
}
使用这个过滤器时还需要把日志的级别设置成 debug 级别:
logging.level.web=debug
Listener 的创建
Listener 平时用的比较少,这里不作详细介绍。如果想要创建 Listener,可以继承 HttpSessionListener,并使用 @WebListener 注解进行标记。另一种方式是使用 ServletListenerRegistrationBean 登记 Listener 对象。
WebMvcConfigurer 配置
WebMvcConfigurer 作为配置类是,采用 JavaBean 的形式来代替传统的 xml 配置文件形式进行针对框架个性化定制,就是 SpringMVC XML 配置文件的 JavaConfig(编码)实现方式。自定义 Interceptor,ViewResolver,MessageConverter。WebMvcConfigurer 就是JavaConfig 形式的 Spring MVC 的配置文件。
WebMvcConfigurer 是一个接口,需要自定义某个对象,实现接口并覆盖某个方法。SpringBoot 的自动配置已经设置了很多默认行为,而在一些情况下,我们可能想要对默认配置进行扩展或修改,这个时候,就可以用上 WebMvcConfigurer。
页面跳转控制器
SpringBoot 中使用页面视图,比如 Thymeleaf。要跳转显示某个页面,必须通过 Controller 对象。也就是我们需要创建一个 Controller,转发到一个视图才行。如果我们现在仅仅只需要显示页面,可以无需这个 Controller。addViewControllers()
完成从请求到视图跳转。
@Configuration
public class MvcSettings implements WebMvcConfigurer {
// 页面跳转控制器,从请求直达视图页面
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// addViewController指定请求路径,setViewName指定视图名称
registry.addViewController("/").setViewName("index");
}
}
数据格式化
Formatter<T>
是数据转换接口,一种数据类型转换为另一种数据类型。与Formatter<T>
功能类型的还有Converter<S,T>
。本节我们研究应用更加广泛的Formatter<T>
。
Formatter<T>
只能将 String 类型转换为其他数据类型,但是在 Web 应用上更广,因为 Web 请求的所有参数都是字符串类型。我们需要把参数转换为其他数据类型来进行处理。
Spring 中内置了Formatter<T>
:
- DateFormatter:String 和 Date 之间的解析和格式化。
- InetAddressFormatter:String 和 InetAddress 之间的解析和格式化。
- PercentStyleFormatter:String 和 Number 之间的解析和格式化,带货币符合。
- NumberFormat:String 和 Number 之间的解析与格式化。
当上述内置的功能无法实现我们的要求时,我们可以通过Formatter<T>
这个扩展点来帮助我们实现我们自己想要的格式转换:
public interface Formatter<T> extends Printer<T>, Parser<T> {}
// Formatter<T>是一个组合接口,没有自己的方法,需要继承Printer<T>和Parser<T>
// Printer<T>用于将T类型转为String,格式化输出
// Parser<T>用于将String类型转换为T对象
一些和硬件打交道的项目,数据格式往往不是我们平常见到的那样,可能是一串1111;2222;333,NF;4;561
,接下来我们模拟一下如何接收这种数据格式:
首先,我们需要创建对应的 pojo 类:
public class DeviceInfo {
private String item1;
private String item2;
private String item3;
private String item4;
private String item5;
}
其次,实现Formatter<T>
接口,进行方法的重写:
public class DeviceFormatter implements Formatter<DeviceInfo> {
// parse是将String转为T对象
@Override
public DeviceInfo parse(String text, Locale locale) throws ParseException {
DeviceInfo deviceInfo = null;
// 利用spring框架提供的工具类判断是否有值
if (StringUtils.hasLength(text)) {
String[] split = text.split(";");
deviceInfo = new DeviceInfo();
deviceInfo.setItem1(split[0]);
deviceInfo.setItem2(split[1]);
deviceInfo.setItem3(split[2]);
deviceInfo.setItem4(split[3]);
deviceInfo.setItem5(split[4]);
}
return deviceInfo;
}
// print是将T对象转为String
@Override
public String print(DeviceInfo object, Locale locale) {
StringJoiner joiner = new StringJoiner("#");
joiner.add(object.getItem1());
joiner.add(object.getItem2());
joiner.add(object.getItem3());
joiner.add(object.getItem4());
joiner.add(object.getItem5());
return joiner.toString();
}
}
然后,告诉 Spring 框架有这么一个转换器,也就是进行转换器的注册:
@Configuration
public class MvcSettings implements WebMvcConfigurer {
// 数据格式转换器
@Override
public void addFormatters(FormatterRegistry registry) {
// 添加转换器
registry.addFormatter(new DeviceFormatter());
}
}
然后直接接收参数就好:
@Controller
public class DeviceController {
@RequestMapping("/exam/formatter")
@ResponseBody
// 直接利用pojo类接收即可
// http://localhost:8023/exam/formatter?deviceInfo=1111;2222;333,NF;4;561
public String addDeviceInfo(DeviceInfo deviceInfo) {
return deviceInfo.toString();
}
}
拦截器
Handlerlnterceptor 接口和它的实现类称为拦截器,是 SpringMVC 的一种对象。拦截器是 SpringMVC 框架的对象,与Servlet无关。拦截器能够预先处理发给 Controller 的请求。可以决定请求是否被 Controller 处理。用户请求是先由 DispatcherServlet 接收后,在 Controller 之前执行的拦截器对象。根据拦截器的特点,类似权限验证,记录日志,过滤字符,登录 token 处理都可以使用拦截器。
拦截器可以深入到方法级别的控制,提供对 Spring 上下文中 bean 的访问能力,允许更精细的控制请求处理流程。
现在我们使用拦截器对某个用户进行行为限制:只能查看,不能修改和删除。
准备 Controller:
@RestController
public class ArticleController {
@RequestMapping("/article/add")
public String addArticle() {
return "发布新文章";
}
@RequestMapping("/article/edit")
public String editArticle() {
return "修改文章";
}
@RequestMapping("/article/query")
public String queryArticle() {
return "查询文章";
}
}
设计拦截器:
@Component
public class AuthInterceptor implements HandlerInterceptor {
// preHandle,在控制器方法执行前执行
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
System.out.println("AuthInterceptor拦截器执行了");
// 获取登录用户
String user = request.getParameter("user");
// 获取请求uri地址
String uri = request.getRequestURI();
// 判断用户和操作
if ("zhangsan".equals(user) && (
uri.startsWith("/article/add") ||
uri.startsWith("/article/edit"))) {
return false;
}
return true;
}
}
注册拦截器:
@Configuration
public class MvcSettings implements WebMvcConfigurer {
@Autowired
private AuthInterceptor interceptor;
// 拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor)
.addPathPatterns("/article/**") // 拦截哪些地址
.excludePathPatterns("/article/query"); // 不拦截哪些地址
}
}
上述是单个拦截器的情况,接下来假设我们还需要一个拦截器来进行身份拦截,这种情况下就是多个拦截器,就涉及到拦截器的排序问题。
再来一个拦截器:
public class LoginInterceptor implements HandlerInterceptor {
private List<String> permitUser = new ArrayList<String>();
public LoginInterceptor() {
Collections.addAll(permitUser, "zhangsan", "lisi", "admin");
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
System.out.println("LoginInterceptor执行了");
// 获取登录用户并进行判断
String user = request.getParameter("user");
return StringUtils.hasLength(user) && permitUser.contains(user);
}
}
拦截器排序:
@Configuration
public class MvcSettings implements WebMvcConfigurer {
// 拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**") //拦截所有请求
.excludePathPatterns("/article/query")
.order(1); // 排序,登录拦截器在第一位
// 权限拦截器
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/article/**") // 拦截哪些地址
.excludePathPatterns("/article/query") // 不拦截哪些地址
.order(2); // 拦截器顺序
}
}
文件上传解析器
上传文件首先想到的就是 Apache Commons FileUpload,这个库使用非常广泛。但是在 SpringBoot3 版本中已经不能使用了。代替他的是 SpringBoot 中自己的文件上传实现。
SpringBoot上传文件现在变得非常简单。提供了封装好的处理上传文件的接口 MultipartResolver,用于解析上传文件的请求,他的内部实现类 StandardServletMultipartResolver。(底层使用的是 Servlet 的 Part 接口实现)之前常用的 CommonsMultipartResolver 不可用了。CommonsMultipartResolver 是使用Apache Commons FileUpload 库时的处理类。
StandardServletMultipartResolver 内部封装了读取 POST 请求体的请求数据,也就是文件内容。我们现在只需要在 Controller 的方法加入形参 @RequestParam MultipartFile。MultipartFile 表示上传的文件,提供了方便的方法保存文件到磁盘。
MultipartFile API 如下:
方法 | 作用 |
---|---|
getName() |
参数名称(updfile) |
getOriginalFilename() |
上传文件原始名称 |
isEmpty() |
上传文件是否为空 |
getSize() |
上传文件的字节大小 |
getInputStream() |
文件的 InputStream,可用于读取部件内容 |
transferTo(File dest) |
保存上传文件到目标 dest |
前端页面:
<div style="margin-left: 200px">
<h3>上传文件</h3>
<form method="post" action="/upload" enctype="multipart/form-data">
选择文件:<input type="file" name="upfile" value="上传"/><br>
<input type="submit" name="submit" value="确定"/>
</form>
</div>
后端控制器:
@Controller
public class UploadFileController {
// 上传文件
@PostMapping("/upload")
public String upload(@RequestParam("upfile") MultipartFile multipartFile,
HttpSession session) throws Exception {
System.out.println("开始处理上传文件");
String finalFileName = null;
if (!multipartFile.isEmpty()) {
// 获取文件名
String fileName = multipartFile.getOriginalFilename();
// 截取后缀名,注意从后方开始截取,防止文件名本身出现.
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 获取随机uuid
String uuid = UUID.randomUUID().toString();
// 拼凑文件名
finalFileName = uuid + suffix;
}
if (finalFileName == null) {
throw new RuntimeException("文件不存在");
}
// 获取路径
ServletContext servletContext = session.getServletContext();
String path = servletContext.getRealPath("multipartFile");
// 创建上传路径文件
File file = new File(path);
if (!file.exists()) {
file.mkdir();
}
finalFileName = path + File.separator + finalFileName;
// 上传
multipartFile.transferTo(new File(finalFileName));
// 重定向视图,防止重复上传
return "redirect:/success";
}
}
SpringBoot 中默认单个文件最大支持 1M,一次请求最大 10M。如果要改变默认值,需要修改配置项:
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
# 文件超过file-size-threshold时,直接写入磁盘,不在内存处理
spring.servlet.multipart.file-size-threshold=0KB
如果要实现多个文件上传,只需要在前端多做几个 input 按钮(name 属性要保持相同),后端把 MultipartFile 改成一个数组即可。
异常处理
在 Controller 处理请求过程中发生了异常,DispatcherServlet 将异常处理委托给异常处理器(处理异常的类)。实现 HandlerExceptionResolver 接口的都是异常处理类。
异常处理器
项目的异常一般集中处理,定义全局异常处理器。在结合框架提供的注解,诸如:@ExceptionHandler,@ControllerAdvice(控制器增强,给 Controller 增加异常处理功能),@RestControllerAdvice 一起完成异常的处理。@ControllerAdvice 与 @RestControllerAdvice 区别在于:@RestControllerAdvice 加了 @RepsonseBody。
前端页面和控制器如下:
<body>
<form method="post" action="/divide">
输入第一个数:<input type="text" name="number1"><br>
输入第二个数:<input type="text" name="number2"><br>
<input type="submit" value="确定">
</form>
</body>
@RestController
public class NumberController {
@PostMapping("/divide")
public String divide(Integer number1, Integer number2) {
return String.valueOf(number1 / number2);
}
}
接下来我们处理除以 0 的情况,建议在 @ExceptionHandler 注解后添加具体的异常类,而不是异常的父类,提高匹配准确度:
// @RestControllerAdvice是包括了@ResponseBody的
// 但是@ControllerAdvice灵活性相对更高点
@ControllerAdvice
public class GlobalExceptionHandler {
// 定义方法处理数学异常
@ExceptionHandler({ArithmeticException.class}) // 指定算数异常类
public String handlerArithmeticException(ArithmeticException e,
Model model) {
String error = e.getMessage();
model.addAttribute("ArithmeticException", error);
return "error"; // 返回视图
}
// 这个异常处理器做兜底,不至于其他异常没办法处理,匹配的话会优先匹配上面的处理器
@ExceptionHandler({Exception.class})
public String handlerDefaultException(Exception e,
Model model) {
String error = e.getMessage();
model.addAttribute("Exception", error);
return "error";
}
}
Tomcat 异常处理
上述介绍的 @ExceptionHandler 只能解决控制器方法抛出的异常(连带 Service 和 Dao 层也算),对于非控制器抛出的异常,@ExceptionHandler 无能为力。(类似于 Filter 抛出的异常,此时 @ExceptionHandler 就无能为力了,因为 @ExceptionHandler 是控制器处理请求的阶段,而 Filter 的过滤阶段比其更早)所以,我们需要一个更上层的异常处理器,来帮助我们捕获并处理这些异常。
使用 BasicErrorController 来返回异常信息:
public class MyBasicErrorController extends BasicErrorController {
public MyBasicErrorController(ErrorAttributes errorAttributes,
ErrorProperties errorProperties) {
super(errorAttributes, errorProperties);
}
@Override
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
System.out.println("重写了error");
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
String message = body.get("error")
+ " in path: " + body.get("path")
+ ", exception: " + body.get("exception");
ObjectMapper objectMapper = new ObjectMapper();
Result<Object> error = Result.error(message);
Map<String, Object> result = objectMapper.convertValue(error, Map.class);
return new ResponseEntity<>(result, getStatus(request));
}
}
然后,注册到 WebConfig 当中:
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 使用BasicErrorController捕获Tomcat异常
@Bean
public MyBasicErrorController basicErrorController() {
ErrorProperties errorProperties = new ErrorProperties();
errorProperties.setIncludeException(true); // 包含异常信息
return new MyBasicErrorController(
new DefaultErrorAttributes(),
errorProperties
);
}
}
BeanValidation 异常处理
使用 JSR-303 验证参数时,我们是在 Controller 方法,声明 BindingResul 对象获取校验结果。Controller 的方法很多,每个方法都加入 BindingResult 处理检验参数比较繁琐。校验参数失败抛出异常给框架,异常处理器能够捕获到 MethodArgumentNotValidException,它是 BindException 的子类。接下来我们演示一下如何利用异常处理器处理 BeanValidation 的异常
准备 Order 类:
public class Order {
@NotBlank(message = "订单不能为空")
private String name;
@NotNull(message = "数量不能为空")
@Range(min = 1, max = 99, message = "订单商品数量在{min}到{max}之间")
private Integer amount;
@NotNull(message = "用户不能为空")
@Min(value = 1, message = "用户id从{value}开始")
private Integer userId;
}
接下来的 Controller 只需要对要检查的参数加上 @Validated 注解即可:
@RestController
public class OrderController {
@PostMapping("/order/new")
public String createOrder(@Validated @RequestBody Order order) {
return order.toString();
}
}
最后在异常处理器上针对 MethodArgumentNotValidException 进行异常处理:
@ControllerAdvice
public class GlobalExceptionHandler {
// 处理JSR303验证参数的异常
@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseBody
public Map<String, Object> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e,
Model model) {
Map<String, Object> map = new HashMap<>();
// 获取异常结果
BindingResult bindingResult = e.getBindingResult();
if (bindingResult.hasErrors()) {
List<FieldError> errors = bindingResult.getFieldErrors();
for (int i = 0; i < errors.size(); i++) {
FieldError fieldError = errors.get(i);
map.put(fieldError.getField() + "-" + i, fieldError.getDefaultMessage());
}
}
return map;
}
// 上述异常处理只是一个演示,是不规范的,规范处理需要使用ProblemDetail
}
完整异常处理简单示例如下:
/**
* 处理BeanValidation异常,用于@RequestBody校验失败的情况
* @param e MethodArgumentNotValidException
* @return 响应字段异常信息
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
public Result<String> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
StringBuilder builder = new StringBuilder();
BindingResult bindingResult = e.getBindingResult();
if (bindingResult.hasErrors()) {
bindingResult.getFieldErrors().forEach(fieldError ->
builder.append(fieldError.getField()).append(": ")
.append(fieldError.getDefaultMessage()).append(";\n")
);
}
return Result.error(builder.toString());
}
/**
* 处理BeanValidation异常,用于@RequestParam、@PathVariable校验失败的情况
* @param ex ConstraintViolationException
* @return 响应字段异常信息
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result<String> handleConstraintViolationException(ConstraintViolationException ex) {
StringBuilder builder = new StringBuilder();
ex.getConstraintViolations().forEach(violation -> {
String fieldName = violation.getPropertyPath().toString();
String errorMessage = violation.getMessage();
builder.append(fieldName).append(": ")
.append(errorMessage).append(";\n");
});
return Result.error(builder.toString());
}
/**
* 处理BeanValidation异常,用于处理在表单或@RequestParam参数的绑定过程中,格式校验失败的场景。
* @param e BindException
* @return 响应字段异常信息
*/
@ExceptionHandler(BindException.class)
public Result<String> handleBindException(BindException e) {
StringBuilder builder = new StringBuilder();
e.getBindingResult().getFieldErrors().forEach(fieldError ->
builder.append(fieldError.getField()).append(": ")
.append(fieldError.getDefaultMessage()).append(";\n")
);
return Result.error(builder.toString());
}
/**
* 处理BeanValidation异常,用于处理当请求中的参数类型与方法参数的期望类型不匹配的情况
* @param e MethodArgumentTypeMismatchException
* @return 响应异常字段信息
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<String> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
StringBuilder builder = new StringBuilder();
builder.append("参数: ").append(e.getName()).append(" 类型不匹配, 期待类型: ")
.append(e.getRequiredType().getName()).append(", 实际输入: ")
.append(e.getValue()).append(";\n");
return Result.error(builder.toString());
}
ProblemDetail
如果不作特定的异常处理,SpringBoot 也有默认的异常反馈,但是默认的异常反馈内容比较单一,包含 Http Status Code,时间,异常信息。但具体异常原因没有体现。这次 SpringBoot3 对错误信息增强了,使用的类是 ProblemDetail。
标准字段 | 描述 | 必须 |
---|---|---|
type | 标识错误类型的uri | 可认为是 |
title | 问题类型的简短描述 | 否 |
detail | 错误信息的详细描述 | 否 |
instance | 特定故障实例的uri | 否 |
status | 状态码 | 否 |
除了上述字段,还可以由用户自己自定义字段,丰富对应答结果的说明。
以下几个类,都直接或者间接地包含了 ProblemDetail,我们在进行异常处理的时候,可以返回这些类:
- ProblemDetail 类:封装标准字段和扩展字段的简单对象。
- ErrorResponse:错误应答类,完整的 RFC 7807 错误响应的表示,包括 status、headers 和 RFC 7807 格式的ProblemDetail 正文。
- ErrorResponseException:ErrorResponse 接口一个实现,可以作为一个方便的基类。扩展自定义的错误处理类。
- ResponseEntityExceptionHandler:它处理所有 SpringMVC 异常,与 @ControllerAdvice 一起使用。
ProblemDetail 基础使用如下,先准备 Book 类:
public record BookRecord(String isbn, String name, String author) {}
// BookContainer用来包含所有的书本类数据
// 记得要在启动类上进行包扫描,不然没办法读取配置文件中的数据集
@ConfigurationProperties(prefix = "product")
public class BookContainer {
private List<BookRecord> books;
}
product:
books:
- isbn: B001
name: java
author: lisi
- isbn: B002
name: tomcat
author: zhangsan
- isbn: B003
name: jvm
author: wangwu
准备自定义异常:
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException() {}
public BookNotFoundException(String message) {
super(message);
}
}
controller 用来接收请求:
@RestController
public class BookController {
@Autowired
private BookContainer bookContainer;
// 根据isbn查询图书,如果没有查到,抛出异常
@GetMapping("/book")
public BookRecord getBook(String isbn) {
Optional<BookRecord> bookOption = bookContainer.getBooks().stream().filter(book ->
book.isbn().equals(isbn)
).findFirst();
if (bookOption.isEmpty()) {
throw new BookNotFoundException(isbn + "没有此图书");
}
return bookOption.get();
}
}
异常处理器处理异常:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail bookNotFound(BookNotFoundException e) {
// 使用ProblemDetail处理异常
ProblemDetail problemDetail = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
// type:异常类型,应该是一个uri,通过uri找到解决问题的途径
problemDetail.setType(URI.create("/api/problem/notFound"));
// title:异常信息描述
problemDetail.setTitle("图书异常");
return problemDetail;
}
}
ProblemDetail 自定义字段
修改异常处理方法,增加 ProblemDetail 自定义字段,自定义字段以Map<String,Object>
存储,调用setProperty(name,value)
将自定义字段添加到 ProblemDetail 对象。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail bookNotFound(BookNotFoundException e) {
// 使用ProblemDetail处理异常
ProblemDetail problemDetail = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
// type:异常类型,应该是一个uri,通过uri找到解决问题的途径
problemDetail.setType(URI.create("/api/problem/notFound"));
// title:异常信息描述
problemDetail.setTitle("图书异常");
// 添加自定义字段
problemDetail.setProperty("时间", Instant.now());
problemDetail.setProperty("客服", "100886");
return problemDetail;
}
}
ErrorResponse
SpringBoot 识别 ErrorResponse 类型作为异常的应答结果。可以直接使用 ErrorResponse 作为异常处理方法的返回值,ErrorResponseException 是 ErrorResponse 的基本实现类。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ErrorResponse bookNotFound(BookNotFoundException e) {
// 使用ErrorResponse处理异常
ErrorResponse error = new ErrorResponseException(HttpStatus.NOT_FOUND, e);
return error;
}
}
扩展 ErrorResponseException
自定义异常可以扩展 ErrorResponseException,SpringMVC 将处理异常并以符合 RFC 7807 的格式返回错误响应。ResponseEntityExceptionHandler 能够处理大部分 SpringMVC 的异常。
由此可以创建自定义异常类,继承 ErrorResponseException,剩下的交给 SpringMVC 内部自己处理就好。省去了自己的异常处理器,@ExceptionHandler。
// 自定义异常类,让框架内置的异常处理器使用
public class IsbnNotFoundException extends ErrorResponseException {
private static ProblemDetail createProblemDetail(HttpStatus httpStatus,
String detail) {
// 封装字段
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(httpStatus, detail);
// 指定解决方案uri
problemDetail.setType(URI.create("api/problem/notfound"));
problemDetail.setDetail(detail);
problemDetail.setTitle("图书异常");
// 自定义字段
problemDetail.setProperty("严重程度", "低");
problemDetail.setProperty("客服", "100886");
return problemDetail;
}
// 异常类的构造方法
public IsbnNotFoundException(HttpStatus httpStatus, String detail) {
// 调用父类的构造方法
super(httpStatus, createProblemDetail(httpStatus, detail), null);
}
}
此外,还需要在配置文件中添加对 RFC 7087 的支持,并且需要保证没有别的自定义异常处理器存在:
spring.mvc.problemdetails.enabled=true
HttpExchange
远程访问是开发的常用技术,一个应用能够访问其他应用的功能。SpringBoot 提供了多种远程访问的技术。基于 HTTP 协议的远程访问是支付最广泛的。SpringBoot3 提供了新的 HTTP 的访问能力,通过接口简化 HTTP 远程访问,类似 Feign 功能。Spring 包装了底层 HTTP 客户的访问细节。
SpringBoot 中定义接口提供 HTTP 服务。生成的代理对象实现此接口,代理对象实现 HTTP 的远程访问,需要使用 @HttpExchange 和 WebClient 来完成。
WebClient 特性:
我们想要调用其他系统提供的 HTTP 服务,通常可以使用 Spring 提供的 RestTemplate 来访问,RestTemplate 是 Spring 3 中引入的同步阻塞式 HTTP 客户端,因此存在一定性能瓶颈。Spring 官方在 Spring5 中引入了 WebClient 作为非阻塞式 HTTP 客户端。
一个免费的,提供 24h 在线的 Rest Http 服务:点我进去。安装 GsonFormat 插件可以帮助我们快速进行 json 和 bean 的转换。并且,使用 WebClient 时别忘了加载 Spring Reactive Web 依赖。
先准备 java 类:
public class ToDo {
private int userId;
private int id;
private String title;
private boolean completed;
}
再准备 service 接口:
// 一个方法就是一个远程调用
public interface ToDoService {
@GetExchange("/todos/{id}") // 用来访问第三方接口的
ToDo getTodoById(@PathVariable Integer id);
}
准备配置类用于创建代理对象:
@Configuration(proxyBeanMethods = false)
public class HttpConfiguration {
// 创建服务接口的代理对象,基于WebClient
@Bean
public ToDoService requestService() {
WebClient webClient = WebClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com") // 指定基地址
.build();
// 创建代理
HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory
.builderFor(WebClientAdapter.create(webClient)).build();
// 通过工厂创建代理的服务
return proxyFactory.createClient(ToDoService.class);
}
}
使用接口方法:
@SpringBootTest
class Lession18HttpServiceApplicationTests {
// 注入远程服务的代理对象
@Autowired
private ToDoService toDoService;
// 测试访问
@Test
void testQuery() {
ToDo todo1 = toDoService.getTodoById(1);
System.out.println(todo1);
}
}
组合注解
我们还可以搭配多个注解进行组合注解开发:
// 定义基地址
@HttpExchange("https://jsonplaceholder.typicode.com/")
public interface AlbumsService {
@HttpExchange(method = "GET", url = "/albums/{id}")
Albums getById(@PathVariable Integer id);
}
创建代理类:
@Configuration(proxyBeanMethods = false)
public class HttpConfiguration {
// 创建代理
@Bean
public AlbumsService albumsService() {
// 创建WebClient
WebClient webClient = WebClient.create();
// 创建代理工厂
HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory
.builderFor(WebClientAdapter.create(webClient)).build();
// 创建代理服务
return proxyFactory.createClient(AlbumsService.class);
}
}
测试:
@SpringBootTest
class Lession18HttpServiceApplicationTests {
@Autowired
private AlbumsService albumsService;
@Test
void testAlbums() {
Albums albums = albumsService.getById(1);
System.out.println(albums);
}
}
定制 HTTP 请求服务
设置 HTTP 远程的超时时间,异常处理。在创建接口代理对象前,先设置 WebClient 的有关配置。
@Configuration(proxyBeanMethods = false)
public class HttpConfiguration {
// 定制服务
public AlbumsService albumsService() {
// 设置超时时间
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30000) // 连接时间
.doOnConnected(conn -> { // 指定连接对象
conn.addHandlerLast(new ReadTimeoutHandler(10)); // 读超时
conn.addHandlerLast(new WriteTimeoutHandler(10)); // 写超时
});
// 设置异常处理
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient)) // 构建连接器
.defaultStatusHandler(HttpStatusCode::isError, clientResponse -> {// 定制默认错误
System.out.println("WebClient请求异常");
return Mono.error(new RuntimeException("请求异常" + clientResponse.statusCode().value()));
})
.build();
// 创建代理
HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory
.builderFor(WebClientAdapter.create(webClient)).build();
return proxyFactory.createClient(AlbumsService.class);
}
}
Thymeleaf
Thymeleaf 是一个表现层的模板引擎,一般被使用在 Web 环境中,它可以处理 HTML、XML、JS 等文档,简单来说,它可以将 JSP 作为 Java Web 应用的表现层,有能力展示与处理数据。Thymeleaf 可以让表现层的界面节点与程序逻辑被共享,这样的设计,可以让界面设计人员、业务人员与技术人员都参与到项目开发中。
这样,同一个模板文件,既可以使用浏览器直接打开,也可以放到服务器中用来显示数据,并且样式之间基本上不会存在差异,因此界面设计人员与程序设计人员可以使用同一个模板文件,来查看静态与动态数据的效果。
Thymeleaf 作为视图展示模型数据,用于和用户交互操作。JSP 的代替技术。比较适合做管理系统,是一种易于学习,掌握的。我们通过几个示例掌握 Thymeleaf 基础应用。
变量表达式和链接表达式
表达式 | 作用 | 例子 |
---|---|---|
${...} |
变量表达式,可用于获取后台传过来的值 | <p th:text="${username}">中国</p> |
@{...} |
链接网址表达式 | th:href="@{/css/home.css}" |
利用控制器往 request 作用域放入数据:
@Controller
public class ThymeleafController {
@GetMapping("/exp")
public String exp(Model model) {
model.addAttribute("name", "zhangsan");
model.addAttribute("address", "hnu");
return "exp";
}
}
Thymeleaf 使用变量表达式展示数据:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:text="${name}"></div>
<div th:text="${address}"></div>
</body>
</html>
使用链接网址表达式传递参数,格式为(key1=value1,key2=value2...)
:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a th:href="@{/link(id=111,name=lisi)}">link链接,有参数</a>
</body>
</html>
if-for
表达式 | 作用 | 例子 |
---|---|---|
th:if="boolean表达式" |
当条件满足时,显示代码片段,反之不显示 | <div th:if="10>2">显示内容</div> |
<tr th:each="成员遍历:${表达式}"><td th:text=${成员}></td></tr> |
处理循环 | 见下方代码块 |
@Controller
public class ThymeleafController {
@GetMapping("/if-for")
public String ifFor(Model model) {
// 增加单个简单值
model.addAttribute("login", true);
// 增加单个对象
User user = new User();
user.setId(1001);
user.setAge(20);
user.setName("李四");
model.addAttribute("user", user);
// 增加多个对象
List<User> list = new ArrayList<>();
Collections.addAll(list, new User(1002, "zhangsan", 20),
new User(1003, "wangwu", 21));
model.addAttribute("users", list);
return "base";
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h3>if-for</h3>
<div th:if="10>2">10大于2</div>
<div th:if="${login}">用户已经登录</div>
<div th:if="${user.getAge()}>18">用户已经成年</div>
<br>
<h3>循环</h3>
<table border="1px">
<tr>
<th>id</th>
<th>name</th>
<th>age</th>
</tr>
<tr th:each="u:${users}">
<td th:text="${u.getId()}"></td>
<td th:text="${u.getName()}"></td>
<td th:text="${u.getAge()}"></td>
</tr>
</table>
</body>
</html>
默认配置
# 视图前缀
spring.thymeleaf.prefix=classpath:/templates/
# 视图后缀
spring.thymeleaf.suffix=.html
AOT 和 GraalVM
提升性能的技术
JIT (just in time)是现在 JVM 提高执行速度的技术,JVM 执行 Java 字节码,并将经常执行的代码编译为本机代码。这称为实时(JIT)编译。
当 JVM 发现某个方法或优码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后 JIT 会把“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。
JVM 根据执行期间收集的分析信息决定 JIT 编译哪些代码。JIT 编译器速度很快,但是 Java 程序非常大,以至于JIT 需要很长时间才能完全预热。不经常使用的 Java 方法可能根本不会被编译。
特点:在程序执行时,边运行代码边编译。JIT编译需要时间开销,空间开销,只有对执行频繁的代码才值得编译。
AOT(Ahead-of-Time Compilation),预编译(提前编译)它在 JEP-295 中描述,并在 Java9 中作为实验性功能添加。
AOT 是提升 Java 程序性能的一种方法,特别是提供 JVM 的启动时间。在启动虚拟机之前,将 Java 类编译为本机代码。改进小型和大型 Java 应用程序的启动时间。
总的来讲,AOT 是静态的,提升了应用启动时间,让 JVM 加载编译后的本机代码。而 JIT 是动态的,提升的是应用程序执行的性能。(现在主要还是使用 JIT,但是 Spring 框架提供了对 AOT 的支持)
Native Image
Native Image:原生镜像(本机镜像)。本机映像是一种预先将 Java 代码编译为独立可执行文件的技术,称为本机映像(原生镜像)。镜像是用于执行的文件。
原生镜像文件内容包括应用程序类、来自其依赖项的类、运行时库类和来自 JDK 的静态链接本机代码(二进制文件可以直接运行,不需要额外安装JDK),本机映像运行在 GraalVM 上,具有更快的启动时间和更低的运行时内存开销。(通常原生镜像文件的大小是原文件的几十倍甚至几百倍)
在 AOT 模式下,编译器在构建项目期间执行所有编译工作,这里的主要想法是将所有的”繁重工作”——昂贵的计算——转移到构建时间。也就是把项目都要执行的所有东西都准备好,具体执行的类,文件等。最后执行这个准备好的文件,此时应用能够快速启动。减少内存,cpu 开销(无需运行时的 JIT 的编译)。因为所有东西都是预先计算和预先编译好的。
Native Image Builder
Native Image Builder(镜像构建器):是一个实用程序,用于处理应用程序的所有类及其依赖项,包括来自 JDK 的类。它静态地分析这些数据以确定在应用程序执行期间可以访问哪些类和方法。然后,它预先将可到达的代码和数据编译为特定操作系统和体系结构的本机可执行文件。
flowchart TD id1(AOT) id2(Native Image) id3(Native Image Builder) id1 --使用镜像文件--> id2 id3 --生成Native Image文件--> id2
GraalVM
GraalVM 是一个高性能 JDK 发行版,旨在加速用 Java 和其他 JVM 语言编写的应用程序,同时支持 JavaScript、Ruby、Python 和许多其他流行语言。GraalVM 的多语言功能可以在单个应用程序中混合多种编程语言,同时消除外语调用成本。GraalVM 是支持多语言的虚拟机。(也就是说,使用 go 语言编写高并发模块,使用 java 语言编写健壮性更强的模块等,这些模块都可以直接在 GraalVM 上跑)
GraalVM 是 OpenJDK 的替代方案,包含一个名为 native image 的工具,支持预先(ahead-of-time,AOT)编译。GraalVM 执行 native image 文件启动速度更快,使用的 CPU 和内存更少,并且磁盘大小更小。这使得 Java 在云中更具竞争力。
目前,AOT 的重点是允许使用 GraalVM 将 Spring 应用程序部署为本机映像。SpringBoot3 中使用 GraalVM 方案提供 Native Image 支持。
GraalVM 的 native image 工具将 Java 字节码作为输入,输出一个本地可执行文件。为了做到这一点,该工具对字节码进行静态分析。在分析过程中,该工具寻找你的应用程序实际使用的所有代码,并消除一切不必要的东西。native image 是封闭式的静态分析和编译,不支持 class 的动态加载,程序运行所需要的多有依赖项均在静态分析阶段完成。
Swagger
使用 Swagger 你只需要按照它的规范去定义接口及接口相关的信息,就可以做到生成接口文档,以及在线接口调试页面。官网戳我。
Knife4j 是为 Java MVC 框架集成 Swagger 生成 Api 文档的增强解决方案。
使用方式
需要导入依赖:
<!-- springdoc依赖 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<!-- knife4j依赖 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
添加 yaml 配置:
springdoc:
api-docs:
# 是否开启文档功能
enabled: true
# swagger后端请求地址
path: /v3/api-docs
swagger-ui:
# 是否开启ui功能
enabled: true
# 自定义swagger前端请求路径
path: /swagger-ui.html
# 包扫描路径
packages-to-scan: com.sky.controller
# 请求参数使用对象包装时以分散的参数生成api文档
default-flat-param-object: true
knife4j:
# 开启增强配置
enable: true
# 开启生产环境屏蔽(如果是生产环境,需要把下面配置设置true)
# production: true
setting:
language: zh_cn
配置类设置:
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("xx项目-API文档")
.version("1.0")
.description("提供管理相关接口文档"));
}
}
配置好后启动后端服务,访问 localhost:${port}/doc.html
即可。
常用注解
注解 | 说明 |
---|---|
@Tag | 作用在类上,例如 Controller,表示对类的说明 |
@Schema | 用在类上以及属性上,用于描述信息 |
@Operation | 用在方法上,说明方法的用途、作用 |
HttpClient
HttpClient 是 Apache Jakarta Common 下的子项目,可以用来提供高效的、最新的、功能丰富的支持 HTTP 协议的客户端编程工具包,并且它支持 HTTP 协议最新的版本和建议。
要使用需要导入依赖(阿里云的 OSS 中封装了这个依赖,如果项目中有使用阿里云 OSS 服务,那么可以不用导入):
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
发送请求步骤:
- 创建 HttpClient 对象。
- 创建 Http 请求对象。
- 调用 HttpClient 的 execute 方法发送请求。
示例代码:
// 发送get请求
@Test
public void testGet() throws IOException {
// 1.创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
// 2.创建请求对象
HttpGet httpGet = new HttpGet("http://localhost:8023/user/shop/status");
// 3.发送请求
CloseableHttpResponse response = httpClient.execute(httpGet);
// 4.解析数据
// 获取响应状态码
int statusCode = response.getStatusLine().getStatusCode();
System.out.println(statusCode);
// 获取响应体
HttpEntity entity = response.getEntity();
// 解析响应体对象
String body = EntityUtils.toString(entity);
System.out.println(body);
// 关闭资源
response.close();
httpClient.close();
}
// 发送post请求
@Test
public void testPost() throws Exception {
// 创建httpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
// 创建请求对象
HttpPost httpPost = new HttpPost("http://localhost:8023/admin/employee/login");
// 设置请求体参数
JSONObject jsonObject = new JSONObject();
jsonObject.put("username", "admin");
jsonObject.put("password", "123456");
// 装载参数
StringEntity entity = new StringEntity(jsonObject.toString());
entity.setContentEncoding("UTF-8"); // 指定编码方式
entity.setContentType("application/json"); // 设置数据格式
httpPost.setEntity(entity);
// 发送请求
CloseableHttpResponse response = httpClient.execute(httpPost);
// 解析结果
int statusCode = response.getStatusLine().getStatusCode();
System.out.println(statusCode);
HttpEntity responseEntity = response.getEntity();
String body = EntityUtils.toString(responseEntity);
System.out.println(body);
// 关闭资源
response.close();
httpClient.close();
}
Spring Cache
Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。
Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现,例如:
- EHCache
- Caffeine
- Redis
使用时需要导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.7.3</version>
</dependency>
常用注解:
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,通常加在启动类上 |
@Cacheable | 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据。如果没有,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
示例(上述注解一般写在 Controller 中):
@PostMapping
// key的生成: cacheNames::key
@CachePut(cacheNames = "userCache", key="#userDTO.id")
/*
spEL的语法比较灵活,上述key也可以写成:
#result.id (对象导航,拿取返回值的id)
#p0.id (方法第一个参数的id)
#a0.id (同上)
#root.args[0].id (同上)
*/
public User save(@RequestBody UserDTO userDTO) {
// ...
return user;
}
@GetMapping
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
// ...
return user;
}
@DeleteMapping
@CacheEvict(cacheNames = "userCache", key = "#id") // 删一个
public void deleteById(Long id) {
// ...
}
@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache", allEntries = true) // 删所有
public void deleteAll() {
// ...
}
Spring Task
Spring Task 是 Spring 框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑。是一个定时任务框架,用于定时自动执行某段 Java 代码。
应用场景:
- 信用卡每月还款提醒。
- 银行贷款每月还款提醒。
- 火车售票系统处理未支付订单。
- 入职纪念日发送通知。
cron 表达式
cron 表达式其实就是一个字符串,通过 cron 表达式可以定义任务触发的时间。构成规则:分为 6 或 7 个域,由空格分隔开,每个域代表一个含义。每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)。
示例:
秒 | 分钟 | 小时 | 日 | 月 | 周 | 年 | 对应时间 |
---|---|---|---|---|---|---|---|
0 | 0 | 9 | 12 | 10 | ? | 2022 | 2022 年 10 月 12 日上午 9 点整 |
注意:“日” 和 “周” 没有唯一的对应关系,所以一般二者间选一个填,另一个用 ?
表示。
cron 表达式在线生成器。
基础使用
自动类添加注解 @EnableScheduling 开启任务调度。
自定义定时任务类:可以创建一个 task 包专门用来存放定时任务类:
/** * 自定义定时任务类 */ @Component // 定时任务类也需要交给容器管理 @Slf4j public class MyTask { @Scheduled(cron = "0/5 * * * * ? ") // 每5秒执行一次 public void executeTask() { // 方法无需返回值,方法名自定 log.info("定时任务执行: {}", LocalDateTime.now()); } }
WebSocket
WebSocket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接(长连接),并进行双向数据传输。
HTTP 协议和 WebSocket 协议对比:
- HTTP 是短连接。
- WebSocket 是长连接。
- HTTP 通信是单向的,基于请求响应模式。
- WebSocket 支持双向通信。
- HTTP 和 WebSocket 底层都是 TCP 连接。
WebSocket 应用场景:
- 视频弹幕。
- 网页聊天。
- 体育实况更新。
- 股票基金报价实时更新。
基本使用
maven 坐标:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
浏览器使用 ws://localhost:8080/path
来向后端发送请求进行长连接:
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket Demo</title>
</head>
<body>
<input id="text" type="text" />
<button onclick="send()">发送消息</button>
<button onclick="closeWebSocket()">关闭连接</button>
<div id="message">
</div>
</body>
<script type="text/javascript">
var websocket = null;
var clientId = Math.random().toString(36).substr(2);
// 判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
// 连接WebSocket节点
websocket = new WebSocket("ws://localhost:8080/ws/"+clientId);
}
else{
alert('Not support websocket')
}
// 连接发生错误的回调方法
websocket.onerror = function(){
setMessageInnerHTML("error");
};
// 连接成功建立的回调方法
websocket.onopen = function(){
setMessageInnerHTML("连接成功");
}
// 接收到消息的回调方法
websocket.onmessage = function(event){
setMessageInnerHTML(event.data);
}
// 连接关闭的回调方法
websocket.onclose = function(){
setMessageInnerHTML("close");
}
// 监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();
}
// 将消息显示在网页上
function setMessageInnerHTML(innerHTML){
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
// 发送消息
function send(){
var message = document.getElementById('text').value;
websocket.send(message);
}
// 关闭连接
function closeWebSocket() {
websocket.close();
}
</script>
</html>
服务端首先创建配置类:
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
再创建长连接服务 WebSocketServer:
/**
* WebSocket服务
*/
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
// 存放会话对象
private static Map<String, Session> sessionMap = new HashMap();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
System.out.println("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
System.out.println("收到来自客户端:" + sid + "的信息:" + message);
}
/**
* 连接关闭调用的方法
*
* @param sid
*/
@OnClose
public void onClose(@PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}
/**
* 群发
*
* @param message
*/
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
客户端和服务端在传输数据的时候,可以同样把数据格式约定为 JSON。
Apache POI
Apache POI 是一个处理 Miscrosoft Office 各种文件格式的开源项目。简单来说就是,我们可以使用 POI 在 Java 程序中对 Miscrosoft Office 各种文件进行读写操作。一般情况下,POI 都是用于操作 Excel 文件。
Apache POI 的应用场景:
- 银行网银系统导出交易明细。
- 各种业务系统导出 Excel 报表。
- 批量导入业务数据。
基本使用
使用前需要导入 maven 坐标:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
POI 的基本操作如下:
// 通过POI创建文件并写入文件内容
public static void write() throws IOException {
// 在内存中创建excel工作表
XSSFWorkbook excel = new XSSFWorkbook();
// 创建Sheet表单
XSSFSheet sheet = excel.createSheet("InfoSheet");
// 在Sheet中操作行对象
XSSFRow row = sheet.createRow(0);// 先获取行对象,编号从0开始
// 行对象里操作单元格
XSSFCell cell = row.createCell(0);// 单元格也是从0开始
// 往单元格中写入内容
cell.setCellValue("姓名");
row.createCell(1).setCellValue("年龄"); // 链式编程
// 把内存中的文件写入磁盘中
FileOutputStream out = new FileOutputStream("D:\\HNU\\info.xlsx");
excel.write(out); // 使用excel对象的方法写出
// 关闭资源
out.close();
excel.close();
}
// 通过POI读取excel文件
public static void read() throws IOException {
FileInputStream input = new FileInputStream("D:\\HNU\\info.xlsx");
// 创建工作表
XSSFWorkbook excel = new XSSFWorkbook(input); // 读取磁盘上指定路径的excel文件
// 读取Sheet表单
XSSFSheet sheet = excel.getSheet("InfoSheet");
// 读取数据
int lastRowNum = sheet.getLastRowNum(); // 先获取有数据的最后的行号
for (int i = 0; i <= lastRowNum; ++i) {
// 获取每一行
XSSFRow row = sheet.getRow(i);
// 获得单元格
XSSFCell cell = row.getCell(0);
// 读取数据
String value1 = cell.getStringCellValue();
String value2 = row.getCell(1).getStringCellValue();
System.out.println(value1 + " " + value2);
}
// 关闭资源
input.close();
excel.close();
}
为了操作方便,我们可以在自己的电脑上提前创建好 excel 报表,并设置好相对应的模板,到时候只需要利用 POI 往里面填充数据即可:
/**
* 导出运营数据报表
* @param response 使用HttpServletResponse可以把文件写回浏览器
*/
@Override
public void exportBusinessData(HttpServletResponse response) {
// ...业务代码
// 使用POI写入excel
InputStream input = this.getClass()
.getClassLoader()
.getResourceAsStream("template/运营数据报表模板.xlsx"); // 读取resources/template目录下的模板文件
try {
// 获取excel
XSSFWorkbook excel = new XSSFWorkbook(input);
// ...业务代码
// 通过输出流将excel文件下载到客户端浏览器
ServletOutputStream out = response.getOutputStream();
excel.write(out);
// 关闭资源
out.close();
excel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
前后端分离总结
数据响应
在后端接口的开发中,我们常常要以某种特定的格式来返回数据,一般格式如下:
名称 | 类型 | 默认值 | 是否必须 | 备注 | 其他信息 |
---|---|---|---|---|---|
code | number | 必须 | 响应码, 0-成功, 1-失败 | ||
message | string | 非必须 | 提示信息 | ||
data | object | 非必须 | 返回的数据 |
数据示例如下:
{
"code": 0,
"message": "操作成功",
"data": null
}
{
"code": 0,
"message": "操作成功",
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiaWQiOjUsInVzZXJuYW1lIjoid2FuZ2JhIn0sImV4cCI6MTY5MzcxNTk3OH0.pE_RATcoF7Nm9KEp9eC3CzcBbKWAFOL0IsuMNjnZ95M"
}
{
"code": 0,
"message": "操作成功",
"data": {
"id": 5,
"username": "wangba",
"nickname": "",
"email": "",
"userPic": "",
"createTime": "2023-09-02 22:21:31",
"updateTime": "2023-09-02 22:21:31"
}
}
在 SpringBoot 中,我们可以利用 @ResponseBody 这个注解来直接向前端响应 json 数据,为了响应方便,我们将要返回的数据封装成一个类,这个类叫 Result 类,用于数据的响应:
package cn.hnu.springboot.bigevent.model;
//统一响应结果
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code; // 业务状态码 0-成功 1-失败
private String message; // 提示信息
private T data; // 响应数据
// 快速返回操作成功响应结果(带响应数据)
public static <E> Result<E> success(E data) {
return new Result<>(0, "操作成功", data);
}
// 快速返回操作成功响应结果
public static Result success() {
return new Result(0, "操作成功", null);
}
public static Result error(String message) {
return new Result(1, message, null);
}
}
然后,我们的控制器就可以利用 @RestController 注解返回 Result 了:
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
@PostMapping("/register")
public Result register(String username, String password) {
// 查询用户,如果被占用,不能注册成功
User user = userService.findByUserName(username);
if (user != null) {
return Result.error("用户名已被占用");
}
userService.register(username, password);
return Result.success();
}
}
工具类
Md5Util
一定要注意,用户注册的时候,要把密码加密后再存入数据库!!!
public class Md5Util {
/**
* 默认的密码字符串组合,用来将字节转换成 16 进制表示的字符,apache校验下载的文件的正确性用的就是默认的这个组合
*/
protected static char hexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
protected static MessageDigest messagedigest = null;
static {
try {
messagedigest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException nsaex) {
System.err.println(Md5Util.class.getName() + "初始化失败,MessageDigest不支持MD5Util。");
nsaex.printStackTrace();
}
}
/**
* 生成字符串的md5校验值
*
* @param s
* @return
*/
public static String getMD5String(String s) {
return getMD5String(s.getBytes());
}
/**
* 判断字符串的md5校验码是否与一个已知的md5码相匹配
*
* @param password 要校验的字符串
* @param md5PwdStr 已知的md5校验码
* @return
*/
public static boolean checkPassword(String password, String md5PwdStr) {
String s = getMD5String(password);
return s.equals(md5PwdStr);
}
/**
* 获取MD5加密后的字符串
* @param bytes
* @return
*/
public static String getMD5String(byte[] bytes) {
messagedigest.update(bytes);
return bufferToHex(messagedigest.digest());
}
private static String bufferToHex(byte bytes[]) {
return bufferToHex(bytes, 0, bytes.length);
}
private static String bufferToHex(byte bytes[], int m, int n) {
StringBuffer stringbuffer = new StringBuffer(2 * n);
int k = m + n;
for (int l = m; l < k; l++) {
appendHexPair(bytes[l], stringbuffer);
}
return stringbuffer.toString();
}
private static void appendHexPair(byte bt, StringBuffer stringbuffer) {
char c0 = hexDigits[(bt & 0xf0) >> 4];// 取字节中高 4 位的数字转换, >>>
// 为逻辑右移,将符号位一起右移,此处未发现两种符号有何不同
char c1 = hexDigits[bt & 0xf];// 取字节中低 4 位的数字转换
stringbuffer.append(c0);
stringbuffer.append(c1);
}
}
也可以调用 SpringBoot 提供的 DigestUtils.md5DigestAsHex(password.getBytes())
来加密。
JwtUtil
JWT(Json Web Token) 令牌用于用户登录认证,令牌就是一段字符串,承载业务数据,减少后续请求查询数据库的次数。并且,令牌还可以防止数据篡改,保障信息的合法和有效性。
JWT 定义了一种简洁的、自包含的格式,用于通信双方以 json 数据格式安全的传输信息,其组成为:
- 第一部分:Header(头),记录令牌类型、签名算法等。例如:
{"alg":"HS256","type":"JWT"}
。 - 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。一般存放一些业务数据,例如:
{"id":"1","username":"Tom"}
。 - 第三部分:Signature(签名),防止 token 被篡改、确保安全性。是将 header、payload 加入指定密钥,通过指定签名算法计算得来。
最后,JWT 会被 Base64(一种编码方式,不是用于加密的,是公开的,所以不要把敏感信息放在有效载荷中) 转换为 64 中可被打印的字符表示,以提高 token 的适用性。
JwtUtil 使用前需要先包含依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
public class JwtUtil {
// 加密密钥
private static final String KEY = "hnuxcc21";
// 接收业务数据,生成token并返回
public static String genToken(Map<String, Object> claims) {
return JWT.create()
.withClaim("claims", claims) // 添加载荷
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12)) // 设置token有效时间,这里我们设置为12小时
.sign(Algorithm.HMAC256(KEY)); // 指定加密算法和加密密钥
}
// 接收token,验证token,并返回业务数据
public static Map<String, Object> parseToken(String token) {
return JWT.require(Algorithm.HMAC256(KEY)) // 申请jwt验证器,需要指定算法和密钥
.build() // 构建验证器
.verify(token) // 验证token,并生成一个解析后的jwt对象
.getClaim("claims") // 在jwt对象中得到所有载荷
.asMap(); // 以map的方式返回
}
}
在用户登录的时候,系统应该向用户下发 jwt 令牌,用户在后续的请求操作中,可以携带这个 jwt 令牌进行登录状态的验证:
@PostMapping("/login")
public Result login(@Pattern(regexp = "^\\S{5,16}$") String username,
@Pattern(regexp = "^\\S{5,16}$") String password) {
// 根据用户名查询用户
User user = userService.findByUserName(username);
if (user == null) {
return Result.error("用户名出错!不存在该用户");
}
// 判断密码是否正确
if (!Md5Util.getMD5String(password).equals(user.getPassword())) {
return Result.error("密码错误");
}
// 验证成功我们把用户的id和用户名称放入jwt令牌的有效载荷中
Map<String, Object> claims = new HashMap<>();
claims.put("id", user.getId());
claims.put("username", user.getUsername());
// 得到token并返回
return Result.success(JwtUtil.genToken(claims));
}
jwt 令牌的验证可以使用拦截器,对于每个访问请求进行访问验证(不要忘了在 WebConfig 中进行拦截器的注册,拦截器对用户的登录和注册要放行):
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 令牌验证,从请求头中获得token
String token = request.getHeader("Authorization");
// 解析token
try {
Map<String, Object> claims = JwtUtil.parseToken(token);
// 把claims放入ThreadLocal中,ThreadLocalUtil见下节解析
ThreadLocalUtil.set(claims);
// 解析成功则放行
return true;
} catch (Exception e) {
// 解析失败设置响应状态码为401
response.setStatus(401);
// 不放行
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 在请求完成之后需要清空ThreadLocal当中的数据
ThreadLocalUtil.remove();
}
}
ThreadLocalUtil
写在前面:用户在登录完成之后,在调用类似于响应用户基本信息这类接口的时候,我们常常需要获得其 token 令牌然后进行验证,这里有个小技巧,可以使用 @JsonIgnore 注解把用户的密码进行 json 转换忽略,这样子在响应的时候 json 串就不会携带密码了:
import com.fasterxml.jackson.annotation.JsonIgnore;
public class User {
private Integer id;
private String username;
@JsonIgnore // 使用JsonIgnore注解对密码字段进行屏蔽
private String password;
private String nickname;
private String email;
private String userPic;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
响应回来的 json 格式如下:
{
"code": 0,
"message": "操作成功",
"data": {
"id": 1,
"username": "wanglaoban",
"nickname": "",
"email": "",
"userPic": "",
"createTime": "2024-05-07T17:21:40",
"updateTime": "2024-05-07T17:21:40"
}
}
接下来进入这个 ThreadLocal 工具类的正题:
还是以调用用户信息来举例,我们调用用户信息,需要知晓用户的 id 和用户名,这些信息从我们的 token
令牌中可以获取到。但是,问题是每一次获取,都需要使用接收 token 并解析,代码复用性不高。所以,我们使用 ThreadLocal 来共享线程变量,使得我们的 token 获取变得更加容易。
/**
* ThreadLocal 工具类
*/
@SuppressWarnings("all")
public class ThreadLocalUtil {
// 提供ThreadLocal对象
private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();
// 根据键获取值
public static <T> T get(){
return (T) THREAD_LOCAL.get();
}
// 存储键值对
public static void set(Object value){
THREAD_LOCAL.set(value);
}
// 清除ThreadLocal防止内存泄漏
public static void remove(){
THREAD_LOCAL.remove();
}
}
最后不要忘记,在拦截器中重写 afterCompletion 方法,在请求结束后释放 ThreadLocal 当中的 jwt 令牌!!!>(现在高并发场景下会使用线程池技术,线程池的使用会让线程进行复用,如果不释放 jwt 令牌,就极有可能导致下一次请求可以获取上一次请求的 jwt 令牌的情况!!!)
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
//...
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 在请求完成之后需要清空ThreadLocal当中的数据
ThreadLocalUtil.remove();
}
}
HttpClientUtil
/**
* Http工具类
*/
public class HttpClientUtil {
static final int TIMEOUT_MSEC = 5 * 1000;
/**
* 发送GET方式请求
* @param url
* @param paramMap
* @return
*/
public static String doGet(String url,Map<String,String> paramMap){
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
String result = "";
CloseableHttpResponse response = null;
try{
URIBuilder builder = new URIBuilder(url);
if(paramMap != null){
for (String key : paramMap.keySet()) {
builder.addParameter(key,paramMap.get(key));
}
}
URI uri = builder.build();
//创建GET请求
HttpGet httpGet = new HttpGet(uri);
//发送请求
response = httpClient.execute(httpGet);
//判断响应状态
if(response.getStatusLine().getStatusCode() == 200){
result = EntityUtils.toString(response.getEntity(),"UTF-8");
}
}catch (Exception e){
e.printStackTrace();
}finally {
try {
response.close();
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return result;
}
/**
* 发送POST方式请求
* @param url
* @param paramMap
* @return
* @throws IOException
*/
public static String doPost(String url, Map<String, String> paramMap) throws IOException {
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
String resultString = "";
try {
// 创建Http Post请求
HttpPost httpPost = new HttpPost(url);
// 创建参数列表
if (paramMap != null) {
List<NameValuePair> paramList = new ArrayList();
for (Map.Entry<String, String> param : paramMap.entrySet()) {
paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
}
// 模拟表单
UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
httpPost.setEntity(entity);
}
httpPost.setConfig(builderRequestConfig());
// 执行http请求
response = httpClient.execute(httpPost);
resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
} catch (Exception e) {
throw e;
} finally {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return resultString;
}
/**
* 发送POST方式请求
* @param url
* @param paramMap
* @return
* @throws IOException
*/
public static String doPost4Json(String url, Map<String, String> paramMap) throws IOException {
// 创建Httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
String resultString = "";
try {
// 创建Http Post请求
HttpPost httpPost = new HttpPost(url);
if (paramMap != null) {
//构造json格式数据
JSONObject jsonObject = new JSONObject();
for (Map.Entry<String, String> param : paramMap.entrySet()) {
jsonObject.put(param.getKey(),param.getValue());
}
StringEntity entity = new StringEntity(jsonObject.toString(),"utf-8");
//设置请求编码
entity.setContentEncoding("utf-8");
//设置数据类型
entity.setContentType("application/json");
httpPost.setEntity(entity);
}
httpPost.setConfig(builderRequestConfig());
// 执行http请求
response = httpClient.execute(httpPost);
resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
} catch (Exception e) {
throw e;
} finally {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return resultString;
}
private static RequestConfig builderRequestConfig() {
return RequestConfig.custom()
.setConnectTimeout(TIMEOUT_MSEC)
.setConnectionRequestTimeout(TIMEOUT_MSEC)
.setSocketTimeout(TIMEOUT_MSEC).build();
}
}
数据格式转换
在后端利用 DQL 语句把数据查询出来并封装好以 json 格式返回给前端时,有些时候我们需要更改数据的格式。就比如对日期格式的规范化处理,这里我们可以使用一个注解 @JsonFormat 来处理日期的格式化返回:
public class Category {
private Integer id; // 主键ID
@NotBlank
private String categoryName; // 分类名称
@NotBlank
private String categoryAlias; // 分类别名
@JsonIgnore
private Integer createUser; // 创建人ID
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;// 创建时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;// 更新时间
}
分页查询
以文章查询为例,前端会传递四个参数:pageNum, pageSize, categoryId, state
,其中,后两个参数是非必须的,前面两个参数是必须的。涉及到分页,我们可以使用 PageHelper 来帮助我们进行管理。
首先要引入依赖,注意这里是针对于 SpringBoot 的依赖:
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
一般,分页对于返回的数据格式也是有要求的,一般返回一个 PageBean:
public class PageBean <T> {
private Long total; // 整个表总条数
private List<T> items;// 当前页数据集合
}
在 Controller 中,我们添加注解标注非必须的参数:
@GetMapping
public Result<PageBean<Article>> list(Integer pageNum,
Integer pageSize,
@RequestParam(required = false) Integer categoryId,
@RequestParam(required = false) String state) {
PageBean<Article> pb = articleService.list(pageNum, pageSize, categoryId, state);
return Result.success(pb);
}
在 Service 中,我们利用 PageHelper 插件进行处理:
@Override
public PageBean<Article> list(Integer pageNum, Integer pageSize,
Integer categoryId, String state) {
// 创建PageBean对象
PageBean<Article> pb = new PageBean<>();
// 开启分页查询
PageHelper.startPage(pageNum, pageSize);
// 当前已登录用户id
Map<String, Object> claims = ThreadLocalUtil.get();
// 完成查询
List<Article> list = articleMapper.list(categoryId, state, (Integer) claims.get("id"));
// Page中提供了方法,可以获取PageHelper分页查询后得到的总记录数和当前页数据
Page<Article> page = (Page<Article>) list;
// 返回结果对象
pb.setTotal(page.getTotal());
pb.setItems(page.getResult());
return pb;
}
最后,在 Mapper 实现中,我们结合 mybatis 的动态 SQL 处理条件查询:
<select id="list" resultType="Article">
select *
from article
<where>
create_user = #{userId}
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
<if test="state != null">
and state = #{state}
</if>
</where>
</select>
文件上传
我们可以把文件图片等信息存储到 OSS(对象存储)上,使用云服务可以更加方便我们维护数据。
如果要想使用第三方服务,通用思路如下:
- 准备工作:包括但不限于在要使用云服务的供应商上注册账号,并获取相关服务等。
- 参照官方 SDK,编写入门程序:SDK(Software Development Kit),是软件开发工具包,包括辅助软件开发的依赖(jar 包)、代码演示等,都可以叫做 SDK。
- 集成使用。
提供 yml 配置属性类:
@Component // 可以从配置文件读取数据
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
yml 配置:
sky:
alioss:
endpoint: oss-cn-******.com
access-key-id: ******
access-key-secret: ******
bucket-name: ******
根据上述官方 SDK 文档,编写工具类:
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求。
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
// 文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder);
// 可以将网址返回给前端
return stringBuilder.toString();
}
}
编写配置类对工具类的属性进行赋值:
/**
* 配置类,用于创建AliOssUtil对象
*/
@Configuration
@Slf4j
public class OssConfiguration {
@Bean
@ConditionalOnMissingBean // 保证容器只有一个Util对象
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
return new AliOssUtil(aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}
最后是 Controller 的编写:
@RestController
@RequestMapping("/admin/common")
@Slf4j
public class CommonController {
@Resource
private AliOssUtil aliOssUtil;
@PostMapping("/upload")
@Operation(summary = "文件上传")
public Result<String> upload(MultipartFile file) {
try {
// 原始文件名
String originalFilename = file.getOriginalFilename();
// 截取后缀
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
// 新文件名
String objectName = UUID.randomUUID() + extension;
// 上传文件并返回访问地址
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败: {}", e.getMessage());
throw new RuntimeException(e);
}
}
}
redis 优化用户登录
回顾一下我们之前的用户登录操作:首先,浏览器向后端服务器发送登录请求,服务器在向前端响应数据的时候会下发一个 Jwt 令牌。除了登录和注册操作之外,其他所有操作在执行之前都会有一个拦截器,用来检测用户是否携带 Jwt 令牌以及令牌是否失效。如果没有携带令牌或者令牌失效,则报出响应错误,反之则正常响应。
但是有一个问题,就是当用户在第一次登录后获取到 Jwt 令牌,接着马上修改密码再重新登录,此时系统会重新下发一次 Jwt 令牌,但是之前第一次登录获取到的令牌此时还是可以使用的。要解决这个问题,就需要用到 redis 了,我们需要在系统下发令牌的时候,同时把令牌存入 redis 中,每一次进行拦截验证的时候,比对一下传过来的令牌是否和 redis 中的令牌一致,一致才放行。如果修改了密码,就把 redis 中的令牌删除掉,防止令牌验证出错。
// UserController中
@PostMapping("/login")
public Result login(@Pattern(regexp = "^\\S{5,16}$") String username,
@Pattern(regexp = "^\\S{5,16}$") String password) {
// 根据用户名查询用户
User user = userService.findByUserName(username);
if (user == null) {
return Result.error("用户名出错!不存在该用户");
}
// 判断密码是否正确
if (!Md5Util.getMD5String(password).equals(user.getPassword())) {
return Result.error("密码错误");
}
// 验证成功我们把用户的id和用户名称放入jwt令牌的有效载荷中
Map<String, Object> claims = new HashMap<>();
claims.put("id", user.getId());
claims.put("username", user.getUsername());
String token = JwtUtil.genToken(claims);
// 把token存入redis中,此处token同时作为键和值,并且要设置过期时间
stringRedisTemplate.opsForValue().set(token, token, 12, TimeUnit.HOURS);
// 得到token并返回
return Result.success(token);
}
@PatchMapping("/updatePwd")
// 从参数中利用RequestHeader获取token
public Result updatePwd(@RequestBody Map<String, String> params,
@RequestHeader("Authorization") String token) {
// 校验参数
String oldPwd = params.get("old_pwd");
String newPwd = params.get("new_pwd");
String rePwd = params.get("re_pwd");
if (!StringUtils.hasLength(oldPwd) ||
!StringUtils.hasLength(newPwd) ||
!StringUtils.hasLength(rePwd)) {
return Result.error("缺少必要参数");
}
Map<String, Object> claims = ThreadLocalUtil.get();
User user = userService.findByUserName((String) claims.get("username"));
if (!user.getPassword().equals(Md5Util.getMD5String(oldPwd))) {
return Result.error("原密码错误");
}
if (!newPwd.equals(rePwd)) {
return Result.error("新密码两次输入不一致");
}
if (!newPwd.matches("^\\S{5,16}$")) {
String regex = "^\\S{5,16}$";
return Result.error("新密码需要匹配正则表达式" + regex);
}
// 调用service完成密码更新
userService.updatePwd(newPwd);
// 删除token
stringRedisTemplate.opsForValue().getOperations().delete(token);
return Result.success();
}
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 令牌验证,从请求头中获得token
String token = request.getHeader("Authorization");
// 解析token
try {
// 从Redis中获取相同的token
String redisToken = stringRedisTemplate.opsForValue().get(token);
// 获取不到,则说明token过期
if (redisToken == null) {
throw new RuntimeException();
}
Map<String, Object> claims = JwtUtil.parseToken(token);
// 把claims放入ThreadLocal中
ThreadLocalUtil.set(claims);
// 解析成功则放行
return true;
} catch (Exception e) {
// 解析失败设置响应状态码为401
response.setStatus(401);
// 不放行
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 在请求完成之后需要清空ThreadLocal当中的数据
ThreadLocalUtil.remove();
}
}
公共字段的处理
在开发中,一些实体类可能具有如下字段:createTime
,updateTime
,createUser
,updateUser
。这些字段需要我们在新增或修改实体类的时候进行更新。
多个实体类的新增和修改都需要去修改这些字段值,如果直接编码会造成代码冗余、重复。所以,我们可以利用 Spring 框架的 AOP 功能,帮助我们更好地实现交叉业务:
自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法。
/** * 数据库操作类型,更新或插入 */ public enum OperationType { UPDATE, INSERT }
/** * 自定义注解,用来标识某个方法需要进行功能字段自动填充处理 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AutoFill { // 数据库操作类型 OperationType value(); }
自定义切面类 AutoFillAspect,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值。
@Component @Aspect @Slf4j public class AutoFillAspect { // 通用切入点 @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)") public void autoFillPointCut() {} /** * 前置通知,为公共字段赋值 */ @Before("autoFillPointCut()") public void autoFill(JoinPoint joinPoint) { log.info("开始进行公共字段自动填充..."); // 获取当前被拦截的方法上的数据库操作类型 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 获取方法签名 AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class); // 获取注解 OperationType operationType = autoFill.value(); // 获取注解中value的值 // 获取方法中的参数,默认要新增或修改的实体类放在第一位 Object[] args = joinPoint.getArgs(); if (args == null || args.length == 0) { return; } Object entity = args[0]; // 准备要赋值的数据 LocalDateTime time = LocalDateTime.now(); Long id = BaseContext.getCurrentId(); // 根据OperationType类型来进行赋值 try { Method setUpdateTime = entity.getClass() .getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = entity.getClass() .getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); // 通过反射为对象赋值 setUpdateTime.invoke(entity, time); setUpdateUser.invoke(entity, id); } catch (Exception e) { throw new RuntimeException(e); } if (operationType == OperationType.INSERT) { try { Method setCreateTime = entity.getClass() .getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class); Method setCreateUser = entity.getClass() .getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class); // 通过反射为对象赋值 setCreateTime.invoke(entity, time); setCreateUser.invoke(entity, id); } catch (Exception e) { throw new RuntimeException(e); } } } }
在 Mapper 的方法上加入 AutoFill 注解。
@Mapper public interface EmployeeMapper { /** * 插入员工数据 * @param employee */ @Insert("insert into employee " + "values (null, #{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}," + "#{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})") @AutoFill(value = OperationType.INSERT) void insert(Employee employee); /** * 动态修改员工属性 * @param employee */ @AutoFill(value = OperationType.UPDATE) void update(Employee employee); }
微信小程序登录
小程序前端调用 wx.login()
方法获取 code 后,后端需要调用微信官方接口,结合 code 获取对应的用户信息数据,调用接口时,访问的请求路径:
// GET https://api.weixin.qq.com/sns/jscode2session
请求时携带的参数为:
- appid:小程序 appId。
- secret:小程序 appSecret。
- js_code:登录时调用
wx.login()
获取的 code。 - grant_type:授权类型,此处只需填写 authorization_code。
Contoller:
@RestController
@RequestMapping("/user/user")
@Tag(name = "C端用户相关接口", description = "UserController")
@Slf4j
public class UserController {
@Resource
private UserService userService;
@Resource
private JwtProperties jwtProperties; // yml配置文件配置属性
/**
* 微信登录
* @param userLoginDTO
* @return
*/
@PostMapping("/login")
@Operation(summary = "微信登录")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) {
// 调用后端接口返回对应openid并封装进User中
User user = userService.wxLogin(userLoginDTO);
// 生成jwt令牌
Map<String, Object> claims = new HashMap<>();
// 记录用户id
claims.put(JwtClaimsConstant.USER_ID, user.getId());
String token = JwtUtil.createJWT(
jwtProperties.getUserSecretKey(), // jwt加密密钥,自己在配置文件中设置
jwtProperties.getUserTtl(), // jwt过期时间,同样也是在配置文件中设置
claims
);
// 封装VO对象
UserLoginVO userLoginVO = UserLoginVO.builder()
.id(user.getId())
.openid(user.getOpenid())
.token(token)
.build();
return Result.success(userLoginVO);
}
}
ServiceImpl:
@Service
public class UserServiceImpl implements UserService {
// 微信服务接口地址
public static final String WX_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";
@Resource
private WeChatProperties weChatProperties; // yml微信接口相关配置属性
@Resource
private UserMapper userMapper;
/**
* 微信登录
* @param userLoginDTO
* @return
*/
@Override
public User wxLogin(UserLoginDTO userLoginDTO) {
// 调用微信服务器接口,获取openId
Map<String, String> map = new HashMap<>();
// 设置请求参数
map.put("appid", weChatProperties.getAppid());
map.put("secret", weChatProperties.getSecret());
map.put("js_code", userLoginDTO.getCode());
map.put("grant_type", "authorization_code");
// 发起请求,并解析出openid
String json = HttpClientUtil.doGet(WX_LOGIN, map);
JSONObject jsonObject = JSON.parseObject(json);
String openid = jsonObject.getString("openid");
if (openid == null) {
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}
// 判断当前用户是否为新用户
User user = userMapper.getByOpenId(openid);
if (user == null) {
// 用户为空,是新用户,需要自动注册
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build(); // 这里只需要简单的赋值即可,其他信息等到用户登录后再做补充
// insert方法无需做AutoFill处理,但是需要插入后自动返回主键
userMapper.insert(user);
}
return user;
}
}
最后,不要忘记注册一个专门处理微信登录的拦截器,并注册到 Spring 容器中。
微信支付
前往官方指引请戳我。
小程序微信支付时序图如下:
其中,后端请求的微信下单接口(JSAPI 下单)如下:
// POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
提交的参数如下:
{
"mchid": "商户号",
"out_trade_no": "后端业务系统生成的订单号",
"appid": "小程序appid",
"description": "描述",
"notify_url": "支付回调通知URL, 一般是我们业务系统的访问地址",
"amount": {
"total": "具体金额",
"currency": "币种"
},
"payer": {
"openid": "付款用户的openid"
}
}
前端调起微信支付使用的是 wx.requestPayment()
方法。
SpringBoot 项目部署
在 maven 构建工具中,在项目生命周期中使用package
命令打包项目。在编译好的target
目录下就可以找到打包好的 jar 包了。
接下来,就是把 jar 包发送到服务器上了。在服务器上使用java -jar [jar包名]
运行 jar 包(要求服务器必须要有 jre 环境)。这样就可以部署成功了(也可以尝试部署到本机上)。