Spring数据库
Spring JDBC
环境准备
-
创建Spring项目, 添加以下依赖
H2 Database
: 用于充当嵌入式测试数据库JDBC API
: 用于连接数据库Lombok
: 用于简化pojo的编写
-
然后添加配置文件:
spring.output.ansi.enabled=ALWAYS spring.datasource.username=*********** spring.datasource.password=*********** spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.hikari.maximumPoolSize=5 spring.datasource.hikari.minimumIdle=5 spring.datasource.hikari.idleTimeout=600000 spring.datasource.hikari.connectionTimeout=30000 spring.datasource.hikari.maxLifetime=1800000
-
之后启动测试类
@Test public void connectionBuild() throws SQLException { Connection conn = dataSource.getConnection(); log.warn(dataSource.toString()); log.warn(conn.toString()); conn.close(); }
-
便可以在控制台中看到数据库信息了
2024-03-02 22:12:22.538 WARN 1592035 --- [ main] c.p.database.jdbc.JdbcApplicationTests : HikariDataSource (HikariPool-1) 2024-03-02 22:12:22.538 WARN 1592035 --- [ main] c.p.database.jdbc.JdbcApplicationTests : HikariProxyConnection@1740328397 wrapping com.mysql.cj.jdbc.ConnectionImpl@738d37fc
-
假设没有SpringBoot依赖的话, 需要自己配置以下bean
DataSource
: 用于管理数据源, 由DataSourceAutoConfiguration
配置TransactionManager
: 用于管理事务, 由DataSourceTransactionManagerAutoConfiguration
配置JdbcTempalte
: Spring用于访问数据库的工具类, 由JdbcTemplateAutoConfiguraiotn
配置
-
之后我们可以添加以下两个配置来添加自动初始化
spring.datasource.initialization-mode=embedded
: 配置自动初始化的模式spring.datasource.schema=schema.sql
: 自动初始化的建表脚本spring.datasource.data=data.sql
: 自动初始化的数据脚本
-
多数据源问题
- 通过
@Primary
来指定主要Bean, 进而指定主要的DataSource注入 - exclude掉SpringBoot自带的上述自动装配的数据库相关的Bean, 然后创建自己的
- 通过
数据库连接池
HikariCP
- HikariCP是一个高性能的数据库连接池, 原因在于:
- 大量字节码级别的优化 很多方法通过JavaAssist生成
- 大量小改进, 如使用
FastStatementList
代替ArrayList
, 使用无锁集合ConcurrentBag
使用invokestatic
代替invokevirtual
等
- HikariCP是Spring2.x默认的数据库连接池, 可以通过
spirng.datasource.hikari.*
进行配置
Druid
- Druid是阿里巴巴开源的数据库连接池, 具备强大的监控性能, 并且能防止SQL注入
- 实用功能: 详细的监控/防SQL注入/数据库密码加密等小功能
- Druid扩展:
- 继承
FilterEventAdapter
- 并修改
META-INFO/druid-filter.properties
增加filter配置
- 并修改
- 继承
Spring JDBC
- 使用SpringJDBC: 使用
@Repository
标注Bean - Spring操作JDBC: 使用
JdbcTemplate
, 它与Spring自带的数据库连接池有集成
在Spring中, 可以直接通过@Autowired
获得JdbcTemplate
然后操作数据库
@Test
public void testUpdate() {
log.warn(jdbcTemplate.update("UPDATE foo SET bar = 'a1' WHERE id=1"));
}
@Test
public void testInsert() {
log.warn(jdbcTemplate.update("INSERT INTO foo (bar) VALUES ('c0');"));
}
@Test
public void testSelect() {
log.warn(jdbcTemplate.queryForList("SELECT * FROM foo WHERE id < 3;"));
}
@Test
public void testDelete() {
log.warn(jdbcTemplate.update("DELETE FROM foo WHERE id = 3;"));
}
// 批量插入, 可以一次性插入5条数据, 分别为`batch-1`, `batch-2`到`batch-5`
@Test
public void batchInsert() {
int[] ret = jdbcTemplate.batchUpdate("INSERT INTO foo (bar) VALUES (?);", new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, String.format("batch-%d", i));
}
@Override
public int getBatchSize() {
return 5;
}
});
log.warn(Arrays.toString(ret));
}
Spring事务
- Spring事务提供了一个抽象, 可以支持多种数据源
- 事务定义:
- 传播性(propagation):
- 隔离性(Isolation)
- 超时(Timeout)
- 只读(Read only status)
编程式事务
@Log4j2
@SpringBootTest
public class TransactionTest {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
public void interceptTransactionTest() {
log.info("Before Transaction: {}", getCount());
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(@NonNull TransactionStatus status) {
jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-1')");
// 事务执行中, 值为事务执行前+1
log.info("Count in Transaction: {}", getCount());
status.setRollbackOnly();
}
});
// 因为回滚了, 所以值等于事务执行前的值
log.info("After Transaction: {}", getCount());
}
private long getCount() {
return (long) jdbcTemplate.queryForList("SELECT COUNT(*) AS cnt FROM foo").get(0).get("cnt");
}
}
其输出结果为:
2024-03-07 21:47:44.559 INFO 1707266 --- [ main] c.p.database.jdbc.TransactionTest : Before Transaction: 23
2024-03-07 21:47:44.563 INFO 1707266 --- [ main] c.p.database.jdbc.TransactionTest : Count in Transaction: 24
2024-03-07 21:47:44.565 INFO 1707266 --- [ main] c.p.database.jdbc.TransactionTest : After Transaction: 23
可以看到回滚后Count的值和执行insert
前一样
声明式事务
-
开启注解配置
@EnableTransactionManagement
<tx:annotation-driver/>
-
在开启事务注解支持之后, 就可以添加
@Transactional
来实现声明式事务 -
首先编写一个
Service
类, 包含了数据库操作package com.passnight.database.jdbc; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class FooService { private final JdbcTemplate jdbcTemplate; @Transactional public void insertRecord() { jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-2')"); } public void insertWithRollbackException() throws Exception { jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-3')"); throw new Exception("Unexpected Exception occurred"); } }
-
在加上
Transactional
后insertWithRollbackException
在抛出异常之后事务就会自动回滚了, 下面的例子中不会插入数据@Test @Transactional(rollbackFor = Exception.class) public void shouldRollback() { Assertions.assertThrows(Exception.class, () -> fooService.insertWithRollbackException()); }
-
Spring事务是通过AOP实现的, 只有直接或间接添加了
@Transactional
才能生成事务代理对象, 以下例子中没有添加注解, 因此也不会生成事务代理对象, 自然就不会回滚, 因此会插入数据// 只有被Spring代理的方法会自动回滚 @Test public void shouldNotRollback() { Assertions.assertThrows(Exception.class, () -> fooService.insertWithRollbackException()); }
JDBC 异常
如下图, Spring会将所有的异常转化为DataAccessExceptin
, 他是Spring数据库操作异常的基类1
- Spring对异常的统一本质上是对不同数据库错误码的统一, Spring通过
SQLErrorCodeSQLExceptionTranslator
解析错误码并归类成对应的异常 - ErrorCode的定义开一在
org/springframework/jdbc/support/sql-error-codes.xml
; 也可以自己在Classpaht下变下的sql-error-codes.xml
中覆盖Spring的默认配置
自定义数据库异常映射
如上问所说, 要自定义数据库异常映射主要通过配置sql-error-codes
-
创建自己的数据库访问异常类
package com.passnight.database.jdbc.exception; import org.springframework.dao.DataAccessException; /** * 自定义的数据访问异常类, 对应MySQL的{@code 1062}错误码 * 必须继承{@link org.springframework.dao.DataAccessException}` * 否则无法正常创建Bean, 会抛出{@link IllegalArgumentException} */ public class CustomerDuplicateKeyException extends DataAccessException { public CustomerDuplicateKeyException(String msg) { super(msg); } }
-
在Classpath下添加自己的
sql-error-codes.xml
; 并填写相关信息:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN 2.0//EN" "https://www.springframework.org/dtd/spring-beans-2.0.dtd"> <beans> <bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes"> <property name="databaseProductNames"> <list> <value>MySQL</value> <value>MariaDB</value> </list> </property> <property name="badSqlGrammarCodes"> <value>1054,1064,1146</value> </property> <property name="duplicateKeyCodes"> <value>1062</value> </property> <property name="dataIntegrityViolationCodes"> <value>630,839,840,893,1169,1215,1216,1217,1364,1451,1452,1557</value> </property> <property name="dataAccessResourceFailureCodes"> <value>1</value> </property> <property name="cannotAcquireLockCodes"> <value>1205,3572</value> </property> <property name="deadlockLoserCodes"> <value>1213</value> </property> <!-- 使用Spring JDBC用于扩展的Translator--> <property name="customTranslations"> <!-- 自定义对重复主键的错误码-异常映射--> <bean class="org.springframework.jdbc.support.CustomSQLErrorCodesTranslation"> <property name="errorCodes" value="1062"/> <property name="exceptionClass" value="com.passnight.database.jdbc.exception.CustomerDuplicateKeyException"/> </bean> </property> </bean> </beans>
-
编写测试用例, 断言抛出自定义的异常类
package com.passnight.database.jdbc; import com.passnight.database.jdbc.exception.CustomerDuplicateKeyException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; @SpringBootTest public class ExceptionTest { @Autowired private JdbcTemplate jdbcTemplate; @Test public void duplicateKeyInsert() { Assertions.assertThrows(CustomerDuplicateKeyException.class, () -> jdbcTemplate.update("INSERT INTO foo (id, bar) VALUES (1, 'duplicate key')")); } }
ORM
- ORM概念:
- 在Java代码中, 存储的是对象, 访问的是对象的引用; 而在RDBMS中, 存储的是表, 访问的是行的数据;
- 除此之外, Java对象还存在继承/属性等特性, 这些特性也需要和数据库的表映射
- 因此需要有一个映射将他们关联起来
Hibernate
:- Hibernate是一个开源关系框架, 可以将开发者从95%的数据持久化工作中解放出来
- Hibernate屏蔽了数据库的底层细节提供了统一的访问模式
- JPA为对象关系映射提供了一种基于pojo的持久化模型, 可以屏蔽数据库间的差异, 用于简化数据持久化的工作
- Spring Data JPA是Spring实现的JPA
JPA常用注解
类型 | 常用注解 |
---|---|
实体 | @Entity , @MappedSuperClass , @Table |
列生成 | @Id , @GeneratedValue(strategy, generator) , @SequenceGenerator(name, sequenceName) |
映射 | @Column(name, nulable, length, insertable, updatable) , @joinTable(name) , @JoinColumn(name) |
关系 | @OneToOne , @OneToMany , @ManyToOne , @ManyToMany |
列属性 | @OrderBy |
下面是Spring Data JPA的基本使用
-
首先要定义一个实体类, 并用上述注解标注
package com.passnight.toy.buck.entity; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.Type; import org.hibernate.annotations.UpdateTimestamp; import org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyAmount; import org.joda.money.Money; import javax.persistence.*; import java.util.Date; @Data @Table(name = "t_menu") @Entity @Builder @NoArgsConstructor @AllArgsConstructor public class Coffee { @Id @GeneratedValue private Long id; private String name; @Column @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyAmount", parameters = {@org.hibernate.annotations.Parameter(name = "currencyCode", value = "CNY")}) private Money price; @Column(updatable = false) @CreationTimestamp private Date createTime; @UpdateTimestamp private Date updateTime; }
-
然后配置自动建表及打印SQL:
# 自动创建表 spring.jpa.hibernate.ddl-auto=update # 控制是否打印运行时的SQL语句与参数信息 spring.jpa.properties.hibernate.show_sql=true spring.jpa.properties.hibernate.format_sql=true
-
之后启动应用, 就可以看到表被自动创建了
Hibernate: create table t_coffee_menu ( id bigint not null, create_time datetime(6), name varchar(255), price decimal(19,2), update_time datetime(6), primary key (id) ) engine=InnoDB
Mybatis
- 相比于Spring Data JPA, Mybatis通过在XML文件中编写SQL来实现与Java对象的映射, 因此具有更高的灵活性, 可以编写更复杂的SQL如聚合,窗口函数等, 也更利于SQL的优化
- 常用的注解:
@MapperScan
: 配置扫描的位置@Mapper
: 定义接口
基本使用
-
类似与上面的Coffee, Mybatis无需在类上定义任何注解, 需要通过编写SQL来实现与数据库的交互
-
下面是咖啡对应的数据表
DROP TABLE IF EXISTS t_coffee_menu; CREATE TABLE t_coffee_menu ( id BIGINT PRIMARY KEY NOT NULL AUTO_INCREMENT, name VARCHAR(255), price BIGINT, create_time TIMESTAMP, update_time TIMESTAMP )
-
下面是咖啡对应的实体类:
package com.passnight.springboot.mybatis.entity; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import org.joda.money.Money; import java.io.Serializable; import java.util.Date; @Data @Builder @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) public class Coffee implements Serializable { private Long id; private String name; private Money price; private Date createTime; private Date updateTime; }
-
之后编写一个Mapper, 使用注解方式来编写SQL和结果映射
package com.passnight.springboot.mybatis.mapper; import com.passnight.springboot.mybatis.entity.Coffee; import org.apache.ibatis.annotations.*; @Mapper public interface CoffeeMapper { @Insert("INSERT INTO t_coffee_menu ( name, price, create_time, update_time) VALUES (#{name}, #{price}, NOW(), NOW())") // 添加后可以自动回填id @Options(useGeneratedKeys = true, keyProperty = "id") int save(Coffee coffee); @Select("SELECT id, name, price,create_time, update_time FROM t_coffee_menu WHERE id = #{id}") @Results({ @Result(id = true, column = "id", property = "id"), @Result(column = "create_time", property = "createTime") // 一般不需要自己配置, 配置了`map-underscore-to-camel=true之后可以自动映射java自带的类型 }) Coffee findById(@Param("id") Long id); }
-
因为
Coffee
中的Money
是自定义类型, 需要自己写类型转换器, 数据库中存储金钱分为单位的bigint, 然后映射回Money
对象-
编写
TypeHandler
package com.passnight.springboot.mybatis.handler; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.joda.money.CurrencyUnit; import org.joda.money.Money; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class MoneyTypeHandler extends BaseTypeHandler<Money> { @Override public void setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throws SQLException { ps.setLong(i, parameter.getAmountMinorLong()); } @Override public Money getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseMoney(rs.getLong(columnName)); } @Override public Money getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseMoney(rs.getLong(columnIndex)); } @Override public Money getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseMoney(cs.getLong(columnIndex)); } private Money parseMoney(Long value) { return Money.ofMinor(CurrencyUnit.of("CNY"), value); } }
-
在
application.properties
中配置TypeHandler
扫描路径mybatis.type-handlers-package=com.passnight.springboot.mybatis.handler
-
-
在之后就可以编写测试用例验证了
@SpringBootTest public class CoffeeMapperTest { @Autowired private CoffeeMapper coffeeMapper; @Test public void insertTest() { Coffee coffee = Coffee.builder() .name("Coffee-Name") .price(Money.of(CurrencyUnit.of("CNY"), 10)) .build(); int num = coffeeMapper.save(coffee); // 保存了一条数据 Assertions.assertEquals(1, num); Assertions.assertEquals(coffee, coffeeMapper.findById(coffee.getId()).setCreateTime(null).setUpdateTime(null)); } }
MongoDB
- Mongodb是一款开源的文档型数据库, spring对MongoDB的支持主要是通过
Spring Data MongoDB
这个项目实现的, 类似与jdbc, 该项目也有MongoTemplate
和Repository
的支持\
基本使用
-
创建用户
db.createUser({ user: "test", pwd: "*********", roles: [{ role: "readWrite", db: "test" }], });
-
创建对象
com.passnight.database.mongo.MongoTemplateTest
-
因为
Money
是自定义类型, 所以需要创建Converter进行类型映射package com.passnight.database.mongo.converter; import org.bson.Document; import org.joda.money.CurrencyUnit; import org.joda.money.Money; import org.springframework.core.convert.converter.Converter; public class MoneyReadConverter implements Converter<Document, Money> { @Override public Money convert(Document source) { Document money = (Document) source.get("money"); double amount = Double.parseDouble(money.getString("amount")); String currency = ((Document) money.get("currency")).getString("code"); return Money.of(CurrencyUnit.of(currency), amount); } }
-
为了使用该
Converter
, 需要注册到Spring容器中@Bean public MongoCustomConversions mongoCustomConversions() { return new MongoCustomConversions(Collections.singletonList(new MoneyReadConverter())); }
-
之后就可以通过
MongoTemplate
操作MongoDB了@Log4j2 @SpringBootTest public class MongoTemplateTest { @Autowired private MongoTemplate mongoTemplate; @Test public void saveTest() { Coffee savedCoffee = mongoTemplate.save(Coffee.builder() .name("Mongo-save") .price(Money.of(CurrencyUnit.of("CNY"), 20.0)) .createTime(new Date()) .updateTime(new Date()) .build()); // 打印插入的对象, 并且会回填ID log.warn(savedCoffee); } @Test public void findTest() { List<Coffee> list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class); log.warn("find: {} Coffee", list); } @Test public void updateTest() throws InterruptedException { List<Coffee> list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class); log.warn("find: {} Coffee", list); UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("name").is("Mongo-save")), new Update().set("price", Money.ofMajor(CurrencyUnit.of("CNY"), 30)).currentDate("updateTime"), Coffee.class); log.warn("Modify Count: {}", result.getModifiedCount()); TimeUnit.SECONDS.sleep(1); list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class); log.warn("find: {} Coffee", list); } }
使用Repository
操作MongoDB
-
Mongo Repository有类似于Spring Data Jpa的操作
-
开启Repository操作功能:
@EnableMongoRepositories
-
继承
MongoRepository
类@Repository public interface CoffeeRepository extends MongoRepository<Coffee, String> { List<Coffee> findByName(String name); }
-
使用
Repository
操作MongoDBpackage com.passnight.database.mongo; import com.passnight.database.mongo.entity.Coffee; import com.passnight.database.mongo.repository.CoffeeRepository; import lombok.extern.log4j.Log4j2; import org.joda.money.CurrencyUnit; import org.joda.money.Money; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Sort; import java.util.Date; @Log4j2 @SpringBootTest public class MongoRepositoryTest { @Autowired private CoffeeRepository coffeeRepository; @Test public void findTest() { log.warn(coffeeRepository.findByName("Mongo-save").toString()); } @Test public void insertTest() { log.warn(coffeeRepository.insert(Coffee.builder() .name("MongoRepository-save") .price(Money.of(CurrencyUnit.of("CNY"), 20.0)) .createTime(new Date()) .updateTime(new Date()) .build())); } @Test public void sortTest() { coffeeRepository.findAll(Sort.by("name")) .forEach(log::warn); } @Test public void updateTest() { Coffee coffee = coffeeRepository.findAll() .stream() .findAny() .orElse(null); Assertions.assertNotNull(coffee); coffeeRepository.save(coffee.setName("MongoRepository-update")); coffeeRepository.findAll(Sort.by("name")) .forEach(log::warn); } @Test public void deleteTest() { coffeeRepository.deleteAll(); log.warn(coffeeRepository.findAll(Sort.by("name"))); } }
Redis
- Redis是一款开源的内存KV数据库, 支持多种数据结构
Jedis
-
Jedis是一款简单易用的Java操作Redis的客户端, 它有以下特点
- Jedis不是线程安全的
- 因为Jedis不是线程安全的, 所以一般通过JedisPool获取Jedis实例, 多个线程共享一个Jedis实例
-
配置Jedis连接
@Bean public JedisConnectionFactory redisConnectionFactory() { return new JedisConnectionFactory(); }
-
在有了连接之后就可以通过
RedisTemplate
操作Redis了package com.passnight.database.redis; import com.passnight.database.redis.entity.Coffee; import lombok.extern.log4j.Log4j2; import org.joda.money.CurrencyUnit; import org.joda.money.Money; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; import java.util.Date; @Log4j2 @SpringBootTest public class RedisTemplateTest { @Autowired private RedisTemplate<String, Object> redisTemplate; @Test public void insertTest() { Coffee coffee = Coffee.builder() .name("Redis-save") .price(Money.of(CurrencyUnit.of("CNY"), 20)) .createTime(new Date()) .updateTime(new Date()) .build(); redisTemplate.opsForHash().put("t_coffee_menu", coffee.getName(), coffee.getPrice().getAmountMinorLong()); } @Test public void selectTest() { Coffee coffee = Coffee.builder() .name("Redis-save") .price(Money.of(CurrencyUnit.of("CNY"), 20)) .createTime(new Date()) .updateTime(new Date()) .build(); log.warn(redisTemplate.opsForHash().get("t_coffee_menu", coffee.getName())); } }
缓存
- Spring提供了缓存模块, 可以为Java方法增加缓存,缓存执行结果提高系统运行效率
- Spring Cache支持以下组件提供的缓存:
ConcurrentMap
,EhCache
,Caffeine
,JCache
- 假设在分布式系统中, 不同的节点要有一致的缓存访问, 则可以使用redis等中间件实现
- Spring对Cache的支持主要是通过
org.springframework.cache.Cache
和org.springframework.cache.CacheManager
实现的
基本使用
-
常用注解
注解 功能 @EnableCacheing
开启缓存 @Cacheable
缓存方法的执行结果 @CacheEvict
方法会触发清除缓存操作 @CachePut
刷新缓存, 但依旧执行方法 @Caching
缓存的批量操作 @CacheConfig
缓存配置 -
启动缓存:
@EnableCaching(proxyTargetClass = true)
-
编写带缓存的服务
@Service @RequiredArgsConstructor @CacheConfig(cacheNames = "coffee") public class CoffeeService { private final CoffeeRepository coffeeRepository; public List<Coffee> normalFindAll() { return coffeeRepository.findAll(); } @Cacheable public List<Coffee> findAll() { return coffeeRepository.findAll(); } @CacheEvict public void reloadCoffee() { } }
-
之后我们通过查看SQL的打印次数来判断缓存的使用情况
@Log4j2 @SpringBootTest public class CacheServiceTest { @Autowired private CoffeeService coffeeService; @Test public void normalFindAllTest() { coffeeService.normalFindAll() .forEach(log::warn); } /** * 该测试用例理应值打印一次SQL */ @Test public void findAllTest() { coffeeService.findAll(); coffeeService.findAll(); coffeeService.findAll(); } /** * 该测试用例理应值打印两次SQL; 因为{@code CoffeeService.reloadCoffee()}会清除缓存, 因此之后就要重新从数据库中读取 */ @Test public void reloadTest() { coffeeService.findAll(); coffeeService.findAll(); coffeeService.findAll(); coffeeService.reloadCoffee(); coffeeService.findAll(); coffeeService.findAll(); coffeeService.findAll(); } }
使用Repository
操作Redis
-
常用注解
注解 功能 @RedisHash
实体类, 类似 @Entity
@Id
主键 @Indexed
除了k-v外的二级索引 -
在完成redis template的配置之后, 第一步是配置实体类; 这里配置了
@Indexed
索引后, spring就会创建一个类似于t_coffee_menu:name:RedisRepository-1
的索引, 里面保存有对应的Coffee的id, 用于快速查找@Data @Builder @RedisHash(value = "t_coffee_menu", timeToLive = 60) @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) public class Coffee implements Serializable { @Id private String id; @Indexed private String name; private Money price; private Date createTime; private Date updateTime; }
-
然后配置
Repository
public interface CoffeeRepository extends CrudRepository<Coffee, Long> { Optional<Coffee> findOneByName(String name); }
-
对于自定义类型, 需要自行编写
Converter
进行转换// 写转换器 @WritingConverter public class BytesToMoneyConverter implements Converter<byte[], Money> { @Override public Money convert(@NonNull byte[] source) { String value = new String(source, StandardCharsets.UTF_8); return Money.ofMinor(CurrencyUnit.of("CNY"), Long.parseLong(value)); } } // 读转换器 @ReadingConverter public class MoneyToByteConverter implements Converter<Money, byte[]> { @Override public byte[] convert(Money source) { return Long.toString(source.getAmountMajorLong()).getBytes(StandardCharsets.UTF_8); } }
-
在编写了转换器之后,还要注册
@Bean public RedisCustomConversions redisCustomConversions() { return new RedisCustomConversions(Arrays.asList(new MoneyToByteConverter(), new BytesToMoneyConverter())); }
-
之后就可以执行CRUD了
@SpringBootTest public class CoffeeRepositoryTest { @Autowired CoffeeRepository coffeeRepository; @Test public void insertTest() { coffeeRepository.save(Coffee.builder() .name("RedisRepository-1") .price(Money.of(CurrencyUnit.of("CNY"), 30)) .updateTime(new Date()) .createTime(new Date()) .build()); } @Test public void findTest() { System.out.println(coffeeRepository.findOneByName("RedisRepository-1")); } }
Spring Reactive
- 响应式编程: 响应式编程(反应式编程)是一种面向数据流和变化传播的编程范式
- Operators:
subscribe
: Nothing happens until you “subscribe”Flux[0:N]
:onNext()
,onComplete()
,onError()
Mono[0:1]
:onNext()
,onComplete()
,onError()
- backpressure:
- subscription
onRequest()
,onCancle()
,onDispose()
- scheduler线程调度
- 单线程操作:
immediate()
,single()
,newSingle()
- 线程池操作:
elastic()
,parallel()
,newParallel()
- 单线程操作:
- 错误处理
- 异常处理:
onError()
,onErrorReturn()
,onErrorResume()
- 最终处理:
doOnError
,doFinally
- 异常处理:
基本使用
-
引入依赖
<dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-core</artifactId> </dependency>
-
编写响应式代码
@Test public void firstReactorApplication() { Flux.range(1, 5) .doOnRequest(n -> log.debug("Request: {}", n)) .doOnComplete(() -> log.info("Publisher COMPLETE 1")) .publishOn(Schedulers.elastic()) // 后续代码执行在Schedulers.elastic()线程池当中 .map(n -> { log.debug("Publish {}", n); // int i = 10 / 0; // 创建异常 return n; }) .doOnComplete(() -> log.info("Publisher COMPLETE 2")) .publishOn(Schedulers.single()) // 后续代码在 .onErrorResume(e -> { // 异常恢复 log.warn(e); return Mono.just(-1); }) .subscribe(n -> log.debug("subscribe: {}", n), // 正常路径 log::warn, // 异常路径 () -> log.info("Subscriber COMPLETE"), // finally 路径 s -> s.request(2)); // 背压 }
Reactive Redis
-
Spring对Redis响应式的支持主要是通过
ReactiveRedisConnection
/ReactiveRedisConnectionFactory
和ReactiveRedisTemplate
来支持的, 基本上和同步形态下的使用类似 -
添加依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency>
-
配置template和序列化器, 如果没有序列化器很多对象类型都无法序列化 包括Long类型
@Configuration public class ReactiveRedisConfiguration { @Bean public ReactiveRedisTemplate<String, Object> reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory, RedisSerializationContext<String, Object> redisSerializationContext) { return new ReactiveRedisTemplate<>(factory, redisSerializationContext); } @Bean public RedisSerializationContext<String, Object> redisSerializationContext() { return RedisSerializationContext.<String, Object>newSerializationContext() .key(RedisSerializer.string()) .value(RedisSerializer.json()) .hashKey(RedisSerializer.string()) .hashValue(RedisSerializer.json()) .build(); } }
-
尽管创建实体类并非必须步骤, 但是为了统一场景, 在这里还是创建了一个实体类, 模拟实际的业务场景
@Data @Builder @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) public class Coffee implements Serializable { private String id; private String name; private Long price; }
-
配置完成之后就可以直接通过template操作redis了 因为默认redis连接配置是
localhost:6379
, 所以这里没有配置连接工厂@SpringBootTest @Log4j2 class ReactorRedisApplicationTest { @Autowired private ReactiveRedisTemplate<String, Object> redisTemplate; private final static String TABLE_NAME = "t_coffee_menu"; @Test public void insertTest() throws InterruptedException { // 任务是在Schedulers.single()上执行的 // 因此需要CountDownLatch保证任务完成后再退出主线程 CountDownLatch latch = new CountDownLatch(1); Flux.just(Coffee.builder() .name("Reactive-Redis-save") .price(20L) .build()) .publishOn(Schedulers.single()) .doOnComplete(() -> log.debug("list ok")) .flatMap(coffee -> { log.debug("Try to put coffee: {}", coffee); return redisTemplate.opsForHash().put(TABLE_NAME, coffee.getName(), coffee.getPrice()); }).doOnComplete(() -> log.debug("Hash Put Complete")) .concatWith(redisTemplate.expire(TABLE_NAME, Duration.ofMinutes(1))) .doOnComplete(() -> log.debug("Expire Setting Complete")) .onErrorResume(e -> { log.warn(e); return Mono.just(false); }) .subscribe(log::info, log::warn, latch::countDown); log.info("Start insert Asynchronous"); latch.await(); } @Test public void selectTest() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); redisTemplate.opsForHash() .get(TABLE_NAME, "Reactive-Redis-save") .doOnSuccess(log::debug) .doFinally(o -> latch.countDown()) .subscribe(); latch.await(); } @Test public void deleteTest() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); redisTemplate.opsForHash() .delete(TABLE_NAME) .doOnSuccess(log::debug) .doFinally(o -> latch.countDown()) .subscribe(); latch.await(); } }
Reactive Mongo
-
与reactive redis和阻塞式mongo一样, spring通过了
ReactiveMongoClientFactoryBean
/ReactiveMongoDatabaseFactory
/ReactiveMongoTemplate
提供了对Mongo的响应式支持 -
首先在
application.properties
中配置连接串spring.data.mongodb.uri=mongodb://username:password@host:port/database
-
创建实体类
@Data @NoArgsConstructor @AllArgsConstructor @Builder public class Coffee { private String id; private String name; private Money price; private Date createTime; private Date updateTime; }
-
配置自定义的转化器, 用于转化自定义类型
public class MoneyReadConverter implements Converter<Long, Money> { @Override public Money convert(@NonNull Long aLong) { return Money.ofMinor(CurrencyUnit.of("CNY"), aLong); } } public class MoneyWriteConverter implements Converter<Money, Long> { @Override public Long convert(Money money) { return money.getAmountMinorLong(); } } @Configuration public class MongoDbConfiguration { @Bean public MongoCustomConversions mongoCustomConversions() { return new MongoCustomConversions( Arrays.asList(new MoneyReadConverter(), new MoneyWriteConverter())); } }
-
使用template操作数据库
@Log4j2 @SpringBootTest public class ReactorMongoApplicationTest { @Autowired private ReactiveMongoTemplate mongoTemplate; @Test public void insertTest() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); Coffee coffee = Coffee.builder() .name("Reactive-Mongo-1") .price(Money.of(CurrencyUnit.of("CNY"), 30.0)) .createTime(new Date()) .updateTime(new Date()) .build(); mongoTemplate.insertAll(Collections.singleton(coffee)) .publishOn(Schedulers.elastic()) .doOnNext(c -> log.info("Next: {}", c)) .doOnComplete(() -> log.debug("Complete")) .doFinally(s -> { latch.countDown(); log.info("Finally, {}", s); }) .count() .subscribe(c -> log.info("Insert {} records", c)); latch.await(); } @Test public void updateTest() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); mongoTemplate.updateMulti(Query.query(Criteria.where("name").is("Reactive-Mongo-1")), new Update().set("price", Money.of(CurrencyUnit.of("CNY"), 50.0)), Coffee.class) .doFinally((s) -> { latch.countDown(); log.debug(s); }) .subscribe(log::debug); latch.await(); } @Test public void selectTest() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); mongoTemplate.find(Query.query(Criteria.where("name").is("Reactive-Mongo-1")), Coffee.class) .doOnEach(coffee -> log.debug("Select: {}", coffee)) .count() .subscribe(c -> log.debug("find: {}", c), log::warn, latch::countDown); latch.await(); } }
Reactive RDBMS
- 与nosql类似, spring也提供了对关系型数据库的响应式操作, 主要通过
R2DBC
Reactive Relational Database Connective连接 - Spring对rdbms的支持主要通过了以下几个类实现:
ConnectionFactory
/DatabaseClient
/R2dbcExceptionTranslator
, 支持了连接/查询及异常处理
基本使用
-
在
application.properties
中配置连接串spring.r2dbc.username=***** spring.r2dbc.password=***** spring.r2dbc.url=r2dbcs:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
-
创建并配置类型转换器, 用于类型映射; 注意:
r2dbc
默认对日期的映射是LocalDateTime
, 见org.springframework.data.r2dbc.convert.MappingR2dbcConverter#readValue
, 因此需要添加对应的Converterpublic class DateReadConverter implements Converter<LocalDateTime, Date> { @Override public Date convert(@NonNull LocalDateTime source) { return Date.from(source.atZone(ZoneId.systemDefault()).toInstant()); } } public class DateWriteConverter implements Converter<Date, LocalDateTime> { @Override public LocalDateTime convert(@NonNull Date source) { return source.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); } } public class MoneyReadConverter implements Converter<Long, Money> { @Override public Money convert(@NonNull Long aLong) { return Money.ofMinor(CurrencyUnit.of("CNY"), aLong); } } public class MoneyWriteConverter implements Converter<Money, Long> { @Override public Long convert(Money money) { return money.getAmountMinorLong(); } } @Configuration public class ReactiveMySqlConfiguration { @Bean public R2dbcCustomConversions r2dbcCustomConversions() { return new R2dbcCustomConversions(Arrays.asList( new MoneyReadConverter(), new MoneyWriteConverter(), new DateWriteConverter(), new DateReadConverter())); } }
-
创建实体类
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Coffee { private Long id; private String name; private Money price; private Date createTime; private Date updateTime; }
-
使用
DatabaseClient
对数据库增删查改@Log4j2 @SpringBootTest @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class R2dbcApplicationTests { @Autowired private DatabaseClient client; @Order(3) @Test public void testUpdate() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); client.update() .table("t_coffee_menu") .using(Update.update("price", Money.of(CurrencyUnit.of("CNY"), 20)) .set("update_time", new Date())) .matching(Criteria.where("name").is("R2dbc-DatabaseClient")) .fetch() .rowsUpdated() .subscribe(n -> log.info("Update: {}", n), log::warn, latch::countDown); latch.await(); } @Order(1) @Test public void testInsert() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); Coffee coffee = Coffee.builder() .name("R2dbc-DatabaseClient") .price(Money.of(CurrencyUnit.of("CNY"), 20)) .createTime(new Date()) .updateTime(new Date()) .build(); client.insert() .into("t_coffee_menu") .value("name", coffee.getName()) .value("price", coffee.getPrice()) .value("create_time", coffee.getCreateTime()) .value("update_time", coffee.getUpdateTime()) .then() .doFinally(c -> latch.countDown()) .subscribe(); latch.await(); } @Order(2) @Test public void testSelect() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); client.execute("SELECT id, name, price, create_time, update_time FROM t_coffee_menu") .as(Coffee.class) .fetch() .all() .doOnEach(log::debug) .doFinally(s -> latch.countDown()) .subscribe(c -> log.info("Fetch: {}", c)); latch.await(); } @Order(4) @Test public void testDelete() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); client.delete() .from("t_coffee_menu") .matching(Criteria.where("name").is("R2dbc-DatabaseClient")) .fetch() .rowsUpdated() .subscribe(n -> log.info("Delete: {}", n), log::warn, latch::countDown); latch.await(); } }
Repository使用
-
在开启了
@EnableR2dbcRepositories
之后, 就可以通过ReactiveCrudRepository
来访问数据库了, 基本的使用和jpa类似, 除了返回值都是Mono
或Flux
类型 -
开启r2dbc Repository支持
@EnableR2dbcRepositories @SpringBootApplication public class ReactorRdbmsApplication { public static void main(String[] args) { SpringApplication.run(ReactorRdbmsApplication.class, args); } }
-
在实体类上添加相应的注解
@Data @Table("t_coffee_menu") @Builder @NoArgsConstructor @AllArgsConstructor public class Coffee { @Id private Long id; private String name; private Money price; private Date createTime; private Date updateTime; }
-
继承Repository
public interface CoffeeRepository extends R2dbcRepository<Coffee, Long> { }
-
使用Repository查询数据库
@Log4j2 @SpringBootTest public class R2dbcRepositoryTest { @Autowired private CoffeeRepository coffeeRepository; @Test public void testSelect() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); coffeeRepository.findAll() .doOnEach(log::debug) .doFinally(s -> latch.countDown()) .subscribe(c -> log.info("Fetch: {}", c)); latch.await(); } }
Reactive Web Client
-
类似于同步版的
RestTemplate
, Spring Reactive提供了WebClient
用于以Reactive的方式处理HTTP请求, 其支持以下底层http库- Reactor Netty:
ReactorClientHttpConnector
- Jetty ReactiveStream HttpClient:
jettyClientHttpConnector
- Reactor Netty:
-
WebClient
主要包含以下内容API 功能 WebClient.create()
/WebClient.builder()
创建 WebClient
get()
/post()
/put()
/delete()
/patch()
发起请求 retrieve()
/exchagne
获得结果 onStatus()
处理Http Status bodyToMono()
/bodyToFlux()
应答正文
基本使用
-
Reactive Web Client和RestTemplate的使用非常相似, 同样要配置Money的转化类/不启动Web容器以及Spring只提供了Builder, 配置Money的转化类和Web容器的关闭见RestTemplate基本使用
-
对于WebClient首先要注册到Spring容器当中
@Bean public WebClient webClient(WebClient.Builder builder) { return builder.baseUrl("http://localhost:8080/springboot/mvc").build(); }
-
之后编写一个简单的请求类, 入参是一个
Consumer
@Log4j2 @Service @RequiredArgsConstructor public class CustomerClient { private final WebClient webClient; public void getById(Consumer<Coffee> consumer) throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); webClient.get() .uri("/coffee/{id}", "1") .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(Coffee.class) .doOnError(log::warn) .doFinally(signalType -> latch.countDown()) .subscribeOn(Schedulers.single()) .subscribe(consumer); latch.await(); } }
-
之后传一个打印, 就可以将
Coffee
对象打印出来了@Log4j2 @SpringBootTest public class CustomerClientTest { @Autowired CustomerClient customerClient; @Test public void getByIdTest() throws InterruptedException { customerClient.getById(coffee -> log.info("Subscribe: {}", coffee)); } }
WebFlux
- Spring WebFlux是基于reactive技术之上的基于函数式编程的应用程序, 运行在非阻塞的服务器上
基本使用
-
WebFlux和MVC的使用非常类似, 也是那几个注解, 只是变成了异步, 操作对象变成了
Mono
和Flux
罢了 -
在模仿
Reactive RDBMS
创建了实体类和对应的Repository及Converter之后, 首先是编写非阻塞服务@Service @RequiredArgsConstructor public class CoffeeService { private final CoffeeRepository coffeeRepository; public Flux<Coffee> getByName(String name) { return coffeeRepository.findByName(name); } public Mono<Coffee> getById(Long id) { return coffeeRepository.findById(id); } public Flux<Coffee> getAll() { return coffeeRepository.findAll(); } public Mono<Coffee> save(Coffee newCoffee) { return coffeeRepository.save(newCoffee); } }
-
然后再使用和MVC类似的方式编写响应式Controller
@Log4j2 @RestController @RequiredArgsConstructor @RequestMapping("/coffee") public class CoffeeController { private final CoffeeService coffeeService; @GetMapping(value = "/", params = "!name") public Flux<Coffee> getAll() { return coffeeService.getAll(); } @GetMapping(value = "/", params = "name") public Flux<Coffee> getByName(@RequestParam String name) { return coffeeService.getByName(name); } @GetMapping(value = "/{id}") public Mono<Coffee> getById(@PathVariable Long id) { return coffeeService.getById(id); } @PostMapping("/") public Mono<Coffee> save(@RequestBody Coffee newCoffee) { return coffeeService.save(newCoffee); } }
-
之后就可以使用
WebClient
访问测试@Log4j2 @SpringBootTest @AutoConfigureWebTestClient public class CoffeeControllerTest { @Autowired private WebTestClient webTestClient; @Autowired private CoffeeService coffeeService; @Autowired private ObjectMapper objectMapper; @Test public void getByNameTest() { webTestClient.get() .uri(UriComponentsBuilder.fromPath("/coffee/").queryParam("name", "Coffee-Name").toUriString()) .header(MediaType.APPLICATION_JSON_VALUE) .exchange() .expectStatus().isOk() .returnResult(new ParameterizedTypeReference<List<Coffee>>() { }) .getResponseBody() .publishOn(Schedulers.elastic()) .flatMap(Flux::fromIterable) .doOnEach(log::info) .doOnEach(coffeeSignal -> Assertions.assertEquals("Coffee-Name", Optional.of(coffeeSignal).map(Signal::get).map(Coffee::getName).orElse(""))) .subscribe(); } @Test public void getByIdTest() { webTestClient.get() .uri(UriComponentsBuilder.fromPath("/coffee/{id}").build(1L)) .header(MediaType.APPLICATION_JSON_VALUE) .exchange() .expectStatus().isOk() .returnResult(new ParameterizedTypeReference<Coffee>() { }) .getResponseBody() .doOnEach(log::info) .doOnEach(coffeeSignal -> Assertions.assertEquals(1L, Optional.of(coffeeSignal).map(Signal::get).map(Coffee::getId).orElseThrow(NullPointerException::new))) .subscribe(); } @Test public void getAllTest() { webTestClient.get() .uri(UriComponentsBuilder.fromPath("/coffee/").toUriString()) .header(MediaType.APPLICATION_JSON_VALUE) .exchange() .expectStatus().isOk() .returnResult(String.class) .getResponseBody() // 这里手动序列化, 用`returnResult序列化报错 .<List<Coffee>>handle((string, sink) -> { try { sink.next(objectMapper.readValue(string, new TypeReference<>() { })); } catch (JsonProcessingException e) { sink.error(new RuntimeException(e)); } }) .publishOn(Schedulers.elastic()) .flatMap(Flux::fromIterable) .doOnError(log::warn) .doOnEach(log::info) .subscribe(); } @Test public void saveTest() { Coffee coffee = Coffee.builder() .name("Coffee-Name-Webflux") .price(Money.of(CurrencyUnit.of("CNY"), 10)) .createTime(new Date()) .updateTime(new Date()) .build(); webTestClient.post() .uri(UriComponentsBuilder.fromPath("/coffee/").toUriString()) .contentType(MediaType.APPLICATION_JSON) .bodyValue(coffee) .header(MediaType.APPLICATION_JSON_VALUE) .exchange() .expectStatus().isOk() .returnResult(Coffee.class) .getResponseBody() .publishOn(Schedulers.elastic()) .doOnEach(log::info) .doOnNext(c1 -> coffeeService.getById(coffee.getId()).doOnNext(c2 -> Assertions.assertEquals(coffee, c2)).subscribe()) .doOnEach(log::warn) .subscribe(); } }
Spring Core
Spring AOP
基本概念
基本概念
概念 | 含义 |
---|---|
Aspect | 切面 |
Joint Point | 连接点, 在Spring AOP中代表一次方法的执行 |
Advice | 通知, 在连接点执行的操作 |
Pointcut | 切入点, 表明如何匹配连接点 |
Introduction | 引入, 为现有类型声明额外的方法和属性 |
Target object | 目标对象 |
Aop Proxy | AOP代理对象, 有JDK动态代理和CGLIB代理两种实现方式 |
Weaving | 织入, 连接切面与目标对象或类型创建代理的过程 |
常用注解
注解 | 功能 |
---|---|
@EnableAspectJAutoProxy | 开启AspectJ的支持 |
@Aspect | 声明当前类是一个切面注意: 仅有该注解还不是一个bean, 因此无法注入到IOC容器当中, 因为AspectJ模式也不需要注入到IOC中 |
@Pointcut | 切点 |
@Before | 方法执行前执行 |
@After /@AfterReturning /@AfterThrowing | 方法执行后执行 |
@Around | 环绕执行 |
@Order | 指定执行顺序, 数字越小优先级越高 |
基本使用
-
定义一个切面, 声明切点是
com.passnight.springboot.aop.service
包下的所有方法; 它即需要用**@Acpect
标注表明是一个切面, 还需要用@Component
标注, 以被Spring代理使切面生效**@Log4j2 @Component @Aspect public class FooAspect { /** * 定义Pointcut */ @Pointcut("execution(* com.passnight.springboot.aop.service.*.*(..))") private void pointCutMethod() { } /** * 环绕通知. */ @Around("pointCutMethod()") public Object doAround(ProceedingJoinPoint pjp) throws Throwable { log.debug("环绕通知: 进入方法"); Object o = pjp.proceed(); log.debug("环绕通知: 退出方法"); return o; } /** * 前置通知. * 切点既可以使用方法指定, 也可以直接指定 */ @Before("execution(* com.passnight.springboot.aop.service.*.*(..))") public void doBefore() { log.debug("前置通知"); } /** * 后置通知. * <a href="https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html">官方文档</a>中说 * The name used in the returning attribute must correspond to the name of a parameter in the advice method. When a method execution returns, * the return value is passed to the advice method as the corresponding argument value. */ @AfterReturning(value = "pointCutMethod()", returning = "result") public void doAfterReturning(String result) { log.debug("After Returning, 返回值: {}", result); } /** * 异常通知. * <a href="https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html">官方文档</a>中说 * you want the advice to run only when exceptions of a given type are thrown, and you also often need access to the thrown exception in the advice body */ @AfterThrowing(value = "pointCutMethod()", throwing = "e") public void doAfterThrowing(Exception e) { log.debug("异常通知, 异常: {}", e.getMessage()); } /** * 最终通知. * 类似于{@code finally} */ @After("pointCutMethod()") public void doAfter() { log.debug("After"); } }
-
之后再对应的包下编写一个测试类, 测试几种通知类型
@Log4j2 @Service public class FooService { public void normalMethod() { log.debug("FooService.normalMethod()"); } public String methodWithReturnValue() { log.debug("FooService.methodWithReturnValue()"); return "Return value of FooService.methodWithReturnValue()"; } public String methodWithException() throws Exception { log.debug("FooService.methodWithException()"); throw new Exception("Exception in FooService.methodWithException()"); } @RunningTime public void methodAnnotatedWithRunningTime() { log.debug("FooService.methodAnnotatedWithRunningTime"); } }
-
最后执行测试, 观察打印结果
@SpringBootTest public class FooServiceTest { @Autowired private FooService fooService; @Test public void normalMethodAopTest() { fooService.normalMethod(); } @Test public void methodWithReturnValueAopTest() { fooService.methodWithReturnValue(); } @Test public void methodWithException() { Assertions.assertThrows(Exception.class, () -> fooService.methodWithException()); } @Test public void methodAnnotatedWithRunningTimeTest(){ fooService.methodAnnotatedWithRunningTime(); } }
-
打印顺序大致为如下图, 其中环绕通知在最外围,
@After
类似于finally
语句
Spring容器
he root
WebApplicationContext
typically contains infrastructure beans, such as data repositories and business services that need to be shared across multipleServlet
instances. Those beans are effectively inherited and can be overridden (that is, re-declared) in the Servlet-specific childWebApplicationContext
, which typically contains beans local to the givenServlet
. The following image shows this relationship:2
- 从上图可以看到,Servlet的上下文和Root上下文并不是一个上下文, 它有自己独立的配置类:
AbstractAnnotationConfigDispatcherServletInitializer
, 我们可以通过继承这个类实现单独的配置
Spring父子容器
-
父容器中的Bean可以在子容器中生效, 而子容器中的bean无法再父容器中生效
-
现在创建一个切面, 它在注册到Spring容器后, 可以在目标方法执行结束后打印
Enhanced By AOP
@Slf4j @Aspect public class FooAspect { @AfterReturning("bean(fooService*)") public void printAfter() { log.info("Enhanced By AOP"); } }
-
再创建一个服务, 它可以打印当前的容器, 用于测试
@Log4j2 @RequiredArgsConstructor public class FooService { private final String context; public void hello() { log.info("hello {}", context); } }
-
首先创建一个容器, 它包含了切面类, 并作为父容器
@Configuration @EnableAspectJAutoProxy public class FooConfig { @Bean public FooService fooService1() { return new FooService("foo"); } @Bean public FooService fooService2() { return new FooService("foo"); } @Bean public FooAspect fooAspect() { return new FooAspect(); } }
-
再创建一个子容器, 它的父容器是上述容器
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd"> <aop:aspectj-autoproxy/> <bean id="fooService1" class="com.passnight.springboot.mvc.service.FooService"> <constructor-arg name="context" value="Bar"/> </bean> <!-- <bean id="fooAspect" class="com.passnight.springboot.mvc.acpect.FooAspect"/>--> </beans>
-
然后分别执行父子容器的
FooService
中的hello
方法, 可以看到都被代理了@Test public void parentContextTest() { ApplicationContext fooContext = new AnnotationConfigApplicationContext(FooConfig.class); FooService bean = fooContext.getBean("fooService1", FooService.class); bean.hello(); log.info("=".repeat(100)); ClassPathXmlApplicationContext barContext = new ClassPathXmlApplicationContext( new String[]{"applicationContext.xml"}, fooContext); bean = barContext.getBean("fooService1", FooService.class); bean.hello(); bean = barContext.getBean("fooService2", FooService.class); bean.hello(); }
2024-03-21 22:45:37.118 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo 2024-03-21 22:45:37.123 INFO [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP 2024-03-21 22:45:37.124 INFO [main] com.passnight.springboot.mvc.aop.FooAspectTest#[parentContextTest:21] - ==================================================================================================== 2024-03-21 22:45:37.252 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello Bar 2024-03-21 22:45:37.252 INFO [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP 2024-03-21 22:45:37.252 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo 2024-03-21 22:45:37.252 INFO [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP
-
而假设将切面移到子容器, 则只有子容器中的对象会被代理, 而父容器中的对象不会被代理
@Configuration @EnableAspectJAutoProxy public class FooConfig { @Bean public FooService fooService1() { return new FooService("foo"); } @Bean public FooService fooService2() { return new FooService("foo"); } // @Bean // public FooAspect fooAspect() { // return new FooAspect(); // } }
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd"> <aop:aspectj-autoproxy/> <bean id="fooService1" class="com.passnight.springboot.mvc.service.FooService"> <constructor-arg name="context" value="Bar"/> </bean> <bean id="fooAspect" class="com.passnight.springboot.mvc.acpect.FooAspect"/> </beans>
2024-03-21 22:47:41.092 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo 2024-03-21 22:47:41.095 INFO [main] com.passnight.springboot.mvc.aop.FooAspectTest#[parentContextTest:21] - ==================================================================================================== 2024-03-21 22:47:41.267 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello Bar 2024-03-21 22:47:41.269 INFO [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP 2024-03-21 22:47:41.269 INFO [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo
Spring MVC
-
核心组件:
DispatcherServlet
: SpringMVC的入口ViewResolver
: 视图解析器HandlerExceptionResolver
: 异常解析器MultipartResolver
: MultipartFile解析HandlerMapping
: 请求映射器Controller
: 请求控制器
-
常用注解
注解 功能 @Controller
/@RestController
标注一个类是控制器 @RequestMapping
,@GetMapping
,@PutMapping
,@DeleteMapping
Url映射器 @RequestBody
,@PathVariable
,@RequestParam
,@RequesHeader
,@HttpEntity
请求参数 @ResponseBody
,@ResponseStatus
@ResponseEntity
响应体/响应码
请求处理
基本使用
-
编写实体类和服务类
@Data @Builder @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) public class Coffee implements Serializable { private String id; private String name; private Money price; private Date createTime; private Date updateTime; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CoffeeOrder implements Serializable { private Long id; private String customer; private List<Coffee> coffees; private OrderStatus state; private Date createTime; private Date updateTime; public enum OrderStatus { INIT, PAID, BREWING, BREWED, TAKEN, CANCELLED } } @Service public class CoffeeOrderService { public final static List<CoffeeOrder> coffeeOrderRepository = new ArrayList<>(); public CoffeeOrder createOrder(String customerName, List<Coffee> coffees) { CoffeeOrder coffeeOrder = CoffeeOrder.builder() .coffees(coffees) .customer(customerName) .build(); coffeeOrderRepository.add(coffeeOrder); return coffeeOrder; } } @Service public class CoffeeService { public final static List<Coffee> coffeeRepository = Arrays.asList( Coffee.builder() .name("Controller-Coffee1") .price(Money.of(CurrencyUnit.of("CNY"), 20.0)) .createTime(new Date()) .updateTime(new Date()) .build(), Coffee.builder() .name("Controller-Coffee2") .price(Money.of(CurrencyUnit.of("CNY"), 10.0)) .createTime(new Date()) .updateTime(new Date()) .build()); public List<Coffee> findCoffees() { return Collections.unmodifiableList(coffeeRepository); } public List<Coffee> findCoffeeByNamContain(String coffeeName) { return coffeeRepository.stream() .filter(coffee -> Optional.ofNullable(coffee).map(Coffee::getName).orElse("").contains(coffeeName)) .collect(Collectors.toList()); } }
-
注意转换
Money
到Json需要添加对应的转换器@Configuration public class JacksonConfig { @Bean public ObjectMapper objectMapper() { return new ObjectMapper() .registerModule(new JodaMoneyModule()); } }
<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-joda-money</artifactId> <version>2.11.0</version> </dependency>
-
通过
RestController
和RequestMaping
标注请求控制器; 这里使用RequestBody
标注请求体参数@RestController @RequestMapping("/coffee") @RequiredArgsConstructor public class CoffeeController { private final CoffeeService coffeeService; @GetMapping("/") public List<Coffee> getAll() { return coffeeService.findCoffees(); } } @Log4j2 @RestController @RequestMapping("/order") @RequiredArgsConstructor public class CoffeeOrderController { private final CoffeeService coffeeService; private final CoffeeOrderService coffeeOrderService; @PostMapping("/") @ResponseStatus(HttpStatus.CREATED) public CoffeeOrder create(@RequestBody NewOrderRequest newOrder) { log.info("Receive new order {}", newOrder); List<Coffee> coffees = coffeeService.findCoffeeByNamContain(newOrder.getCoffee()); return coffeeOrderService.createOrder(newOrder.getCustomer(), coffees); } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class NewOrderRequest { String customer; String coffee; }
-
之后通过
MockMvc
进行请求测试
请求处理机制
视图解析
- SpringMVC中的视图解析主要是通过
ViewResolver
和View
接口实现的, 主要包括AbstractCachingViewResolver
: 基于缓存的View Resolver的基类UrlBasedViewResolver
FreeMarkerViewResolver
: 用于解析free marker框架的视图解析器ContentNegotiatingViewResolver
: 根据返回类型解析的视图解析器 如会转发接收xml和接收json的请求到不同的视图解析器InternalResourceViewResolver
: 默认最后的用于解析JSP/JSTL的解析器
ResponseBody
视图解析- 在
HandlerAdapter
中的handle()
中完成Reponse的输出 - 之后不走
ViewResolver
而是直接创建输出流并将内容写到流当中
- 在
- 重定向视图
redirect
和forward
类型转换
-
Spring的类型转换主要是通过
Converter
和Formatter
来实现的, 因此要实现自定义类型转换可以通过在SpringBoot 的WebMvcAutoConfiguration
中添加自定义的Converter
和自定义的Formatter
来实现 -
SpringBoot默认的配置如下
// WebMvcAutoConfiguration @Override public void addFormatters(FormatterRegistry registry) { ApplicationConversionService.addBeans(registry, this.beanFactory); } // ApplicationConversionService public static void addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory) { Set<Object> beans = new LinkedHashSet<>(); beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values()); beans.addAll(beanFactory.getBeansOfType(Converter.class).values()); beans.addAll(beanFactory.getBeansOfType(Printer.class).values()); beans.addAll(beanFactory.getBeansOfType(Parser.class).values()); for (Object bean : beans) { if (bean instanceof GenericConverter) { registry.addConverter((GenericConverter) bean); } else if (bean instanceof Converter) { registry.addConverter((Converter<?, ?>) bean); } else if (bean instanceof Formatter) { registry.addFormatter((Formatter<?>) bean); } else if (bean instanceof Printer) { registry.addPrinter((Printer<?>) bean); } else if (bean instanceof Parser) { registry.addParser((Parser<?>) bean); } } }
-
添加自定义类型转换首先要添加一个
Formatter
@Component public class MoneyFormatter implements Formatter<Money> { @Override @NonNull public Money parse(@NonNull String text, @NonNull Locale locale) throws ParseException { if (NumberUtil.isNumber(text)) { return Money.of(CurrencyUnit.of("CNY"), new BigDecimal(text)); } else if (StrUtil.isAllNotBlank(text)) { String[] split = text.split(" "); Assert.isTrue(split.length == 2 && NumberUtil.isNumber(split[1]), () -> new ParseException(text, 0)); return Money.of(CurrencyUnit.of(split[0]), new BigDecimal(split[1])); } throw new ParseException(text, 0); } @NonNull @Override public String print(@NonNull Money money, @NonNull Locale locale) { return String.format(Locale.ROOT, "%s %s", money.getCurrencyUnit().getCode(), money.getAmount()); } }
-
然后在在
WebMvcConfigurer
中添加该配置@Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final MoneyFormatter moneyFormatter; @Override public void addFormatters(FormatterRegistry registry) { registry.addFormatter(moneyFormatter); } }
-
之后
Money
类型的数据就可以正常转换了, 测试用例同上
校验
-
SpringBoot通过
Validator
对绑定的结果进行校验, 如Hibernate Validator, 然后添加@Valid
注解标注需要校验的类 -
首先在实体类上添加校验规则
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class NewOrderRequest { @NotEmpty String customer; @NotNull String coffee; }
-
然后在对应的接口上添加
@Valid
启动校验@PostMapping("/") @ResponseStatus(HttpStatus.CREATED) public CoffeeOrder create(@Valid @RequestBody NewOrderRequest newOrder) { log.info("Receive new order {}", newOrder); List<Coffee> coffees = coffeeService.findCoffeeByNamContain(newOrder.getCoffee()); return coffeeOrderService.createOrder(newOrder.getCustomer(), coffees); }
-
之后未通过校验的请求都返回400
@Test public void createInvalidOrderTest() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post("/order/") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder() // Null Coffee .customer("Customer1") .build()))) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andReturn() .getResponse() .getContentAsString(); mockMvc.perform(MockMvcRequestBuilders.post("/order/") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder() .coffee("Coffee1") .customer("") // Empty Customer .build()))) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andReturn() .getResponse() .getContentAsString(); }
文件上传
-
Multipart上传是通过
MultipartResolver
实现的在MultipartAutoConfiguration
中配置, 支持multipart/form-data
(MultipartFile
)类型 -
定义一个接口, 接收
MultipartFile
类型, 这里设置consumes = MediaType.MULTIPART_FORM_DATA_VALUE
只是为了区分其他格式的参数@PostMapping(value = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public CoffeeOrder importOrders(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { return null; } NewOrderRequest newOrder = objectMapper.readValue(file.getBytes(), NewOrderRequest.class); return coffeeOrderService.createOrder(newOrder.getCustomer(), coffeeService.findCoffeeByNamContain(newOrder.getCoffee())); }
-
然后就可以上传文件了, 注意请求的文件名要和
RequestParameter
的对应上@Test public void importTest() throws Exception { CoffeeOrder expected = CoffeeOrder.builder() .customer("Customer1") .coffees(CoffeeService.coffeeRepository.subList(0, 1)) .build(); String response = mockMvc.perform(MockMvcRequestBuilders.multipart("/order/") .file("file", objectMapper.writeValueAsBytes(NewOrderRequest.builder() .coffee("Coffee1") .customer("Customer1") .build()))) .andExpect(MockMvcResultMatchers.status().isCreated()) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) .andReturn() .getResponse() .getContentAsString(); CoffeeOrder actual = objectMapper.readValue(response, CoffeeOrder.class); Assertions.assertEquals(expected, actual); }
静态资源及缓存
-
静态资源的配置可以通过
WebMvcConfigurer.addResourcehandlers()
来实现 -
具体可以通过以下配置
spring.mvc.static-path-pattern=/**
添加静态资源路径模式 默认从根路径下开始匹配spring.resource.static-locations=classpath:/META-INF/resources/classpath:/resources/,classpath:/datic/,classpath:/public/
添加静态资源路径
-
缓存相关的配置是在
ResouceProperties.Cache
中配置的, 主要包含以下内容spring.resources.cache.cachecontrol.max-age
来配置最大缓存时间spirng.resource.cache.cachecontrol.no-cache=true/false
来开启/关闭缓存spring.resources.cache.cachecontrol.s-max-age=
来配置共享缓存的缓存时间 一个是cache, 一个是cached by shared caches
-
首先在
resources/static
下添加一个静态资源ls src/main/resources/static/ img1.png
-
然后再
application.properties
中配置静态资源路径及缓存时间spring.mvc.static-path-pattern=/static/** spring.resources.cache.cachecontrol.max-age=20s
-
然后可以在请求头中看到
max-age=20
的缓存字段, 并且请求结果是一张图片; 并且第二次请求返回了304
@Test public void staticImageTest() throws Exception { String lastModifyTime = mockMvc.perform(MockMvcRequestBuilders.get("/static/img1.png")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.CACHE_CONTROL, "max-age=20")) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.IMAGE_PNG)) .andReturn() .getResponse() .getHeader(HttpHeaders.LAST_MODIFIED); mockMvc.perform(MockMvcRequestBuilders.get("/static/img1.png") .header(HttpHeaders.IF_MODIFIED_SINCE, lastModifyTime)) .andExpect(MockMvcResultMatchers.status().isNotModified()); }
异常处理
- SpringMvc中主要是通过
HandlerExceptionResolver
处理的, 它有以下几个主要的实现类SimpleMappingExceptionResolver
:DefaultHandlerExceptionResolver
: 默认实现, 用于将SpringMVC的异常转化为http状态码ResponseStatusExceptionResolver
: 处理带有ResponseStatus
注解的异常, 可以在异常类上添加该注解指定http状态码ExceptionHandlerExceptionResolver
: 若异常被标注了@ExceptionHandler
的方法处理, 会走该解析器
- 自定义异常处理方法主要通过
@ExceptionHandler
注解标注, 可以添加在- Controller下:
@Controller
/@RestController
- 或ControllerAdvice下:
@ControllerAdvice
/@RestControllerAdvice
注意在Advice下的处理器优先级低于在Controller下的处理器
- Controller下:
- spring添加异常处理有两个方式: 一个是在异常上添加
@ResponseStatus
, 这样Spring就会自动将该异常映射到对应的http状态码; 另外一个是添加@ExceptionHandler
在方法上定义异常处理逻辑
使用状态码标注异常
-
定义异常, 映射到Http 400状态码
@Getter @AllArgsConstructor @ResponseStatus(HttpStatus.BAD_REQUEST) public class MyBadRequestException extends RuntimeException { String request; }
-
添加一个接口抛出该异常
@GetMapping("/bad-request") public String badRequest() { throw new MyBadRequestException("Bad Request in HelloController.badRequest"); }
-
请求该路径, 返回htt400
@Test public void customerExceptionWithHttpStatusTest() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/HelloController/bad-request")) .andExpect(MockMvcResultMatchers.status().isBadRequest()); }
使用Advice处理异常
-
定义一个异常类
public class MyInternalServerException extends RuntimeException { }
-
定义一个异常处理拦截器, 用
@ExceptionHandler
标注处理的异常,@ResponseStatus
标注对应的状态码, 并在方法体内添加处理逻辑@RestControllerAdvice public class GlobalControllerAdvice { @ExceptionHandler(MyInternalServerException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public String internalServerExceptionHandler(MyInternalServerException e) { return "MyInternalServerException Handler By GlobalControllerAdvice.internalServerExceptionHandler()"; } }
-
声明一个接口抛出该异常
@GetMapping("/internal-server-error-request") public String internalServerErrorRequest() { throw new MyInternalServerException(); }
-
返回体会包含
500
状态码及Advice中定义的内容@Test public void controllerAdviceExceptionHandlerTest() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/HelloController/internal-server-error-request")) .andExpect(MockMvcResultMatchers.status().isInternalServerError()) .andExpect(MockMvcResultMatchers.content().string("MyInternalServerException Handler By GlobalControllerAdvice.internalServerExceptionHandler()")); }
SpringMVC 拦截器
-
SpringMVC的拦截器主要是通过
HandlerInterceptor
实现的public interface HandlerInterceptor { // 进入执行器前做预处理, 返回值表明是否会进入下一步 // 比如说可以在这里做权限验证, 有权限返回true default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true; } // 在视图呈现前执行 default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { } // 在视图呈现后执行 default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { } }
-
针对返回
@ReponseBOdy
或ResponseEntity
的情况, Spring提供了ResponseBodyAdvice
拦截 -
针对异步请求的接口, Spring也提供了类似的
AsyncHandlerInterceptor
-
之后可以通过
WebMvcConfigurer.addInterceptors()
显示添加
基本使用
-
首先定义一个
Interceptor
; 用于统计MVC请求时间@Log4j2 @Component public class PerformanceInterceptor implements HandlerInterceptor { private ThreadLocal<StopWatch> stopWatch = new ThreadLocal<>(); @Override public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception { StopWatch watch = new StopWatch(); stopWatch.set(watch); watch.start(); return true; } @Override public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, ModelAndView modelAndView) throws Exception { stopWatch.get().stop(); stopWatch.get().start(); } @Override public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, Object handler, Exception ex) throws Exception { StopWatch watch = stopWatch.get(); watch.stop(); String method = handler.getClass().getSimpleName(); if (handler instanceof HandlerMethod) { String beanType = ((HandlerMethod) handler).getBeanType().getName(); String methodName = ((HandlerMethod) handler).getMethod().getName(); method = String.format(Locale.ROOT, "%s.%s", beanType, methodName); } log.info("{};{};{};{};{}ms;{}ms;{}ms", request.getRequestURI(), method, response.getStatus(), Objects.isNull(ex) ? "-" : ex.getClass().getSimpleName(), watch.getTotalTimeMillis(), watch.getTotalTimeMillis() - watch.getLastTaskTimeMillis(), watch.getLastTaskTimeMillis()); stopWatch.remove(); } }
-
在
WebMvcConfigurer
中配置该拦截器@Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final MoneyFormatter moneyFormatter; private final PerformanceInterceptor performanceInterceptor; @Override public void addFormatters(FormatterRegistry registry) { registry.addFormatter(moneyFormatter); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(performanceInterceptor); } }
-
然后执行任意一个端点, 就可以看到对应的日志了
资源访问
-
Spring主要通过
RestTemplate
和WebClient
实现对web资源的访问 -
Spring中没有为我们提供自动装配好的RestTemplate, 我们需要通过
RestTemplateBuilder
来创建, 它主要包含了以下几个类别的方法功能 方法 GET请求 getForObject()
,getForEntity()
POST请求 postForObject()
,postForEntity()
PUT请求 put()
DELETE请求 delete()
请求时带上http请求头 exchange()
/RequestEntity
/ReponseEntity
类型转换 JsonSerializer
/JsonDeserializer
/@JsonComponent
解析泛型对象 exchange()
+ParameterizedTypeReference<T>
-
Spring在
RestTemplateBuilder
中配置了开箱即用的Converter, Customizer等组件 -
RestTemplate中可能会遇到相对路径/URL参数等情况, 此时手写URL非常不方便, 因此Spring为我们提供了以下几个组件拼接URI
UriComponentsBuilder
: 构造URIServletUriComponentsBuilder
: 构造相对于当前请求的URIMvcUriComponentsBuilder
: 构造指向Controller的URI
-
尽管使用
UriBuilder
构建URL已经非常方便, 但有的时候我们还需要保留URL的相对位置, 这个时候我们就可以使用UriBuilderFactory
来构建UriBuilder
; 它的默认实现是DefaultUriBuilderFactory
基本使用
-
因为主要使用SpringMVC实现web请求, 因此不需要启动web服务器, 这里可以通过
WebApplicationType.NONE
来指定@SpringBootApplication public class WebClientApplication { public static void main(String[] args) { new SpringApplicationBuilder() .sources(WebClientApplication.class) .bannerMode(Banner.Mode.OFF) .web(WebApplicationType.NONE) // 不启动web容器 .run(args); } }
-
之后再配置项目的
RestTemplate
@Bean public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { return restTemplateBuilder.build(); }
-
然后就可以通过
RestTemplate
访问资源了, 这里以访问baidu.com
为例public String ping() { URI uri = UriComponentsBuilder.fromUriString("https://baidu.com").build(""); return restTemplate.getForEntity(uri, String.class).getBody(); }
-
测试是否能够正常访问
@Log4j2 @SpringBootTest public class BaiduClientTest { @Autowired private BaiduClient baiduClient; @Test public void pingTest() { ResponseEntity<String> response = baiduClient.ping(); Assertions.assertEquals(HttpStatus.FOUND, response.getStatusCode()); Assertions.assertNotNull(response.getBody()); log.debug(response.getBody()); } }
自定义序列化器
-
Coffee
中的Money
是复杂类型, 因此需要自定义序列化器才能序列化 -
这里使用的是
jackson-datatype-joda-money
实现的<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-joda-money</artifactId> <version>2.11.0</version> </dependency>
-
它主要是定义了
StdDeserializer<Money>
和JodaMoneySerializerBase<T> extends StdSerializer<T>
然后打包为模块public class JodaMoneyModule extends Module implements java.io.Serializable { private static final long serialVersionUID = 1L; public JodaMoneyModule() { } @Override public String getModuleName() { return getClass().getName(); } @Override public Version version() { return PackageVersion.VERSION; } @Override public void setupModule(SetupContext context) { final SimpleDeserializers desers = new SimpleDeserializers(); desers.addDeserializer(CurrencyUnit.class, new CurrencyUnitDeserializer()); desers.addDeserializer(Money.class, new MoneyDeserializer()); context.addDeserializers(desers); final SimpleSerializers sers = new SimpleSerializers(); sers.addSerializer(CurrencyUnit.class, new CurrencyUnitSerializer()); sers.addSerializer(Money.class, new MoneySerializer()); context.addSerializers(sers); } }
-
再注册到
jackson
中实现的@Bean public ObjectMapper objectMapper() { return new ObjectMapper() .registerModule(new JodaMoneyModule()); }
-
之后就可以照常创建Client类
public ResponseEntity<Coffee> getById() { URI uri = UriComponentsBuilder .fromUriString("http://localhost:8080/springboot/mvc/coffee/{id}") .build(1); return restTemplate.getForEntity(uri, Coffee.class); }
-
并请求
@Test public void getByIdTest() { ResponseEntity<Coffee> coffee = customerClient.getById(); Assertions.assertEquals(HttpStatus.OK, coffee.getStatusCode()); Assertions.assertEquals(MediaType.APPLICATION_JSON, coffee.getHeaders().getContentType()); log.info(coffee.getBody()); }
定制RestTemplate
- 定制底层的http库: RestTemplate通过
ClientHttpRequestFactory
创建请求, 主要有以下几种方式SimpleClientHttpRequestFactory
: 默认使用的, 底层基于jdk自带的网络库HttpComponentsClientHttpRequestFactory
: Apache HttpComponentsNetty4ClientHttpRequestFactory
: NettyOkHttp3ClientHttpRequestFactory
: okhttp
- 连接管理:
- 连接池配置;
PoolingHttpClientConnectionmanager
- KeepAlive策略
- 连接池配置;
- 超时设置
- connectTimeout/readTimeout
- SSL校验
- 证书检查策略
使用Apache连接库代替jdk自带的连接库
-
引入对应的依赖
<dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.3.1</version> </dependency>
-
配置
Keep-Alive
时间@Configuration public class ConnectionKeepAliveStrategy implements org.apache.http.conn.ConnectionKeepAliveStrategy { @Override public long getKeepAliveDuration(HttpResponse response, HttpContext context) { return 10_000; } }
-
配置
RequestFactory
@Configuration public class HttpRequestFactoryConfiguration { @Bean public HttpComponentsClientHttpRequestFactory httpComponentsClientHttpRequestFactory( ConnectionKeepAliveStrategy connectionKeepAliveStrategy) { PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); connectionManager.setDefaultMaxPerRoute(20); HttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .evictIdleConnections(30, TimeUnit.SECONDS) .disableAutomaticRetries() .setKeepAliveStrategy(connectionKeepAliveStrategy) .build(); return new HttpComponentsClientHttpRequestFactory(httpClient); } }
-
之后就可以使用Apache提供的连接发起http请求
Rest 规范
-
http方法分类
动作 安全 幂等 用途 GET ✅ ✅ 获取信息 POST ❌ ❌ 用途广泛, 可用于创建/更新/批量修改 DELETE ❌ ✅ 删除资源 PUT ❌ ✅ 更新或替换资源 HEAD ✅ ✅ 获取与GET一样的HTTP头信息, 但没有响应体 OPTIONS ✅ ✅ 获取资源支持的http方法列表 TRACE ✅ ✅ 让服务器返回其收到的http头 -
以咖啡为例
URI HTTP方法 含义 /coffee/ GET 获取全部咖啡信息 /coffee/ POST 添加新的咖啡信息 /coffee/{id} GET 获取特定咖啡信息 /coffee/{id} DELETE 删除特定咖啡信息 /coffee/{id} PUT 修改特定咖啡信息
HATEOAS
-
HATEOAS: Hybermedia As The Engine Of Application State; 是REST统一接口的必要组成部分
-
常用的超链接类型 IANA协议规范中常用的超链接类型
引用 描述 self 指向当前资源本身的链接 edit 指向一个可以编辑当前资源的链接 collection 如果当前资源包含在某个集合当中, 指向该集合的链接 search 只想一个可以搜索当前资源与其相关资源的链接 related 指向一个与当前资源相关的链接 first 集合遍历相关的类型, 指向第一个资源的链接 last 集合遍历相关的类型, 指向最后一个资源的链接 previous 集合遍历相关的类型, 指向上一个资源的链接 next 集合遍历相关的类型, 指向下一个资源的链接
HAL
- HAL(Hypertext Application Language): 是一种简单的格式, 为API中的资源提供简单一致的链接
- HAL主要包含以下几个部分:
- 链接
- 内嵌资源
- 状态
Spirng Data Rest
-
SpringBoot中有一个依赖
spring-boot-starter-data-rest
, 可以将常用的Repository转化为Rest接口 -
其中有以下常用的类和注解
类/注解 功能 @RepositoryRestResource
将Repository转化为Rest接口 Resource<T>
T类型的资源 PagedResource<T>
分页的T类型的资源
基本使用
-
类似于Spring data jpa的基本使用, 将注解从
@Repository
换成@RepositoryRestResource(path = "coffee")
并添加path
参数之后就可以自动生成对应的rest接口@RepositoryRestResource(path = "coffee") public interface CoffeeRepository extends JpaRepository<Coffee, Long> { List<Coffee> findByName(String name); }
-
生成的Rest接口可以通过访问根路径看到
@Log4j2 @SpringBootTest @AutoConfigureMockMvc public class SpringDataJpaRestTest { @Autowired private MockMvc mockMvc; @Test public void getLinksTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/")) .andReturn() .getResponse() .getContentAsString(); Assertions.assertTrue(response.contains("\"href\" : \"http://localhost/profile\"")); Assertions.assertTrue(response.contains("\"href\" : \"http://localhost/coffee{?page,size,sort}\"")); } }
-
其值包含了一个
profile
及相关资源的访问{ "_links" : { "coffees" : { "href" : "http://localhost/coffee{?page,size,sort}", "templated" : true }, "profile" : { "href" : "http://localhost/profile" } } }
-
类似的, 在访问资源的根路径也可以获得所有的资源的信息及相关的元数据
@Test public void findByNameTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee")) .andReturn() .getResponse() .getContentAsString(); log.info(response); }
-
返回包含所有的
咖啡
以及咖啡访问相关的配置{ "_embedded": { "coffees": [ { "name": "Coffee-Name", "price": { "zero": false, "negative": false, "positive": true, "amount": 1000.00, "amountMajor": 1000, "amountMajorLong": 1000, "amountMajorInt": 1000, "amountMinor": 100000, "amountMinorLong": 100000, "amountMinorInt": 100000, "minorPart": 0, "positiveOrZero": true, "negativeOrZero": false, "currencyUnit": { "code": "CNY", "numericCode": 156, "decimalPlaces": 2, "symbol": "CNÂ¥", "numeric3Code": "156", "countryCodes": [ "CN" ], "pseudoCurrency": false }, "scale": 2 }, "createTime": "2024-03-10T13:26:02.000+00:00", "updateTime": "2024-03-10T13:26:02.000+00:00", "_links": { "self": { "href": "http://localhost/coffee/1" }, "coffee": { "href": "http://localhost/coffee/1" } } }, { "name": "Coffee-Name", "price": { "zero": false, "negative": false, "positive": true, "amount": 1000.00, "amountMajor": 1000, "amountMajorLong": 1000, "amountMajorInt": 1000, "amountMinor": 100000, "amountMinorLong": 100000, "amountMinorInt": 100000, "minorPart": 0, "positiveOrZero": true, "negativeOrZero": false, "currencyUnit": { "code": "CNY", "numericCode": 156, "decimalPlaces": 2, "symbol": "CNÂ¥", "numeric3Code": "156", "countryCodes": [ "CN" ], "pseudoCurrency": false }, "scale": 2 }, "createTime": "2024-03-10T13:27:40.000+00:00", "updateTime": "2024-03-10T13:27:40.000+00:00", "_links": { "self": { "href": "http://localhost/coffee/2" }, "coffee": { "href": "http://localhost/coffee/2" } } } ] }, "_links": { "self": { "href": "http://localhost/coffee" }, "profile": { "href": "http://localhost/profile/coffee" }, "search": { "href": "http://localhost/coffee/search" } }, "page": { "size": 20, "totalElements": 9, "totalPages": 1, "number": 0 } }
-
也可以直接在query parameter上面添加参数, 作查询; 下面根据
id
降序排序, 并取第1
页的三个元素@Test public void findPageTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee") .queryParam("page", "1") .queryParam("size", "3") .queryParam("sort", "id,dec")) .andReturn() .getResponse() .getContentAsString(); log.info(response); }
-
将方法拼接到路径+
search
上之后就可以直接通过URL调用查询; 下面就调用了CoffeeRepository.findByName()
查询所有咖啡名为Coffee-Name
的咖啡@Test public void findByNameTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee/search/findByName") .queryParam("name", "Coffee-Name")) .andReturn() .getResponse() .getContentAsString(); log.info(response); }
会话管理
- 对于分布式环境中, 请求由不同的机器完成, 因此需要保持统一, 常见的解决方案有
- 粘性会话: Load Balancer将会话转发到同一台机器上, 但若服务器下线则原先的请求被分配到其他机器, 会话就会失效
- 会话复制: 将集群中的机器会话都复制一份, 这样不论请求那一台服务器, 都由一样的会话, 但复制存在延迟且有资源消耗
- 集中会话: 将会话集中存储在中间件当中, 通过session id获取会话信息
- Spring Session则是Spring为我们提供的管理会话的组件, 它主要有以下功能:
- 简化集群中的用户会话管理
- 无需绑定容器特定解决方案
- 支持多种存储, 如Redis, MongoDB, JDBC等
- Spring Session的实现原理: Spring是通过定制HttpServletRequest和HttpSession来实现的, 主要包含以下几个组件
SessionRepositoryRequestWrapper
: 代理后的RequestSessionRespositoryFilter
: 代理Request和Response以支持Spring SessionDelegatingFilterProxy
配置应用容器
- SpringBoot不仅仅支持Tomcat容器, 还支持其他容器, 可选的依赖有
spring-boot-starter-tomcat
spring-boot-starter-jetty
spring-boot-starter-undertow
spring-boot-starter-reactor-netty
- 容器的配置主要包含以下配置
- 最基本的配置则是端口和地址的配置, 他们可以通过以下配置项配置
server.port
: 配置端口server.address
: 配置地址
- 除了端口地址之外, 还由压缩相关的配置
server.compression.enable
: 开启压缩server.compression.min-response-size
=2k: 最小要压缩的大小server.compression.mime-types
: 要压缩默认的类型
- Tomcat专属配置
server.tomcat.max-connections=10000
: 最大连接数server.tomcat.max-http-post-size=2MB
: 最大http post请求大小server.tomcat.max-swallow-size=2MB
: Tomcat在分批请求时最大能缓存的文件大小3server.tomcat.max-threads=200
: 最大线程数server.tomcat.min-spare-threads=10
: 最小空闲线程数
- 错误处理相关配置
server.error.path=/error
: 异常路径server.error.include-exception=false
: 是否在错误页面显示异常信息server.error.include-stacktrace=never
: 是否在错误页面上打印调用栈server.error.whitelabel.enabled=true
: 是否使用SpringBoot默认的错误页面
- ssl相关配置
server.ssl.key-store
: 证书位置server.ssl.key-store-type
: 证书类型server.ssl.key-store-password
: 证书密码
- 其他配置
server.use-forward-headers
: 是否在转发之后将信息保存在头中 反向代理后可以获得真实源ipserver.servlet.session.timeout
: session超时时间
- 最基本的配置则是端口和地址的配置, 他们可以通过以下配置项配置
- 修改配置主要通过以下类实现
WebServerFactoryCustomizer
, 对Tomcat/Jetty/Undertow对应的配置类分别是TomcatServletWebServerFactory
/JettyServletWebServerFactory
/UndertowServletWebServerFactory
基本配置
-
SpringBoot可以通过实现
TomcatServletWebServerFactory
或在application.properties
中添加配置的方式来实现对Tomcat容器的配置 -
最简单地方式是直接修改配置文件
server.compression.min-response-size=512B server.compression.enabled=true
-
其次还可以通过实现
WebServerFactoryCustomizer
达到修改的目的@Configuration public class TomcatConfig implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> { @Override public void customize(TomcatServletWebServerFactory factory) { Compression compression = new Compression(); compression.setEnabled(true); compression.setMinResponseSize(DataSize.ofBytes(512)); factory.setCompression(compression); } }
Spring Boot
- SpringBoot的四大核心
- Auto Configuration
- Starter Dependency
- Spring Boot CLI
- Actuator
Auto Configuration
-
自动装配: 指的是Spring有基于添加JAR依赖自动对SpringBoot程序配置的功能, 其主要包含在
spring-boot-autoconfiguration
下 -
开启自动装配
@EnableautoConfiguration
/@SpringBootApplication
: 开启自动配置 后者包含了前者- 添加
exclude=Class<?>[]
等参数以排除/包括某些自动装配类
-
自动配置的实现原理
-
通过
@EnableAutoConfiguration
启动, 它会自动启动AutoConfigurationImportSelector
, 它会自动加载META-INFO/spring.factories
下的配置文件 -
常用的配置注解
类别 注解 功能 条件注解 @Conditional
根据条件后才自动装配 类条件注解 @ConditionalOnClass
,@ConditionOnMissionClass
当存在或不存在某个类才装配 web应用条件注解 @ConditionOnWebApplication
,@ConditionalOnNotWebApplication
在web环境下载状态 属性条件注解 @ConditionOnProperty
特定的属性值为目标值 Bean条件注解 @ConditionalOnBean
,@ConditionalOnMissingBean
,@ConditionalOnSigleCandidate
存在/不存在/只有一个候选Bean时装配 资源条件注解 ConditionalOnResource
资源条件 其他条件注解 ``ConditionalOnExpression ,
@ConditionalOnJava,
ConditionalOnJndi`其他注解条件 执行顺序 @AutoConfigureBefore
,@AutoConfigureAfter
,@AutoConfigureOrder
自动配置执行顺序
-
-
查看Spring自动装配结果: 在运行参数上加上
--debug
, 之后就可以看到所有装配的类
基本使用
-
创建一个Spring项目, 用于被装配, 实现
ApplicationRunner
, 使其启动之后会打印信息@Log4j2 public class GreetingApplicationRunner implements ApplicationRunner { private final String name; public GreetingApplicationRunner(String name) { this.name = name; log.info("Initializing GreetingApplicationRunner for {}", name); } public GreetingApplicationRunner() { this("dummy spring application"); } @Override public void run(ApplicationArguments args) throws Exception { log.info("Hello from dummy spring"); } }
-
之后创建一个自动配置类, 在符合条件的情况下自动创建Bean
@Configuration @ConditionalOnClass(GreetingApplicationRunner.class) public class DummyAutoConfiguration { @Bean @ConditionalOnMissingBean(GreetingApplicationRunner.class) @ConditionalOnProperty(name = "dummy.enable", havingValue = "true", matchIfMissing = true) public GreetingApplicationRunner greetingApplicationRunner() { return new GreetingApplicationRunner(); } }
-
并在
spring.factories
中添加该自动配置类org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.passnight.springboot.autoconfiguration.DummyAutoConfiguration
-
之后其他模块引入该自动配置模块之后就可以在控制台中看到打印的信息了
注意
- 若在自动配置模块中, 被自动配置的模块的
scope
被设置为scope
则需要在引用自动配置的模块中手动引入被自动配置的模块 - 若自己手动添加了Bean, 则不符合
@ConditionalOnMissingBean(GreetingApplicationRunner.class)
的条件, 则不会自动配置 - 若配置了
dummy.enable
, 且值不为true
, 则不符合havingValue = "true"
, 不会自动配置 - 若未配置
dummy.enable
, 则符合matchIfMissing = true
, 会自动配置
- 若在自动配置模块中, 被自动配置的模块的
-
自动装配失败后会通过
FailureAnalyzer
分析
配置加载机制
-
SpringBoot配置加载顺序
- 开启DevTools时,
~/.spring-boot-devtools.properteis
- 测试类上的
@TestPropertySource
注解 @SpringBootTest#properties
属性- 命令行参数 如
--server.port=9000
SPRING_APPLICATION_JSON
中的属性 环境变量中的一个参数ServletConfig
初始化参数ServletContext
初始化参数java:comp/env
中的JNDI属性System.getProperties()
- 操作系统的环境变量
random.*
涉及到RandomValuePropertySource
- jar包外部的
application-{profile}.properties[.yml]
jar包外部 - jar包内部的
application-{profile}.properties[.yml]
jar包内部 - jar包外部的
application.properties[.yml]
先加载带*profile
*的配置文件 - jar包内部的
application.properties[.yml]
注意: 这四个文件都可能会被加载, 只是优先级覆盖, 默认外置在./config
,./config
,classpath://
,classpath://config
,spring.config.name
,spring.config.localtion
,spirng.config.additional-localtion
下 后面几个是配置的
- 开启DevTools时,
-
配置文件加载: 通过配置
@PropertySource
和@PropertySources
,@ConfigurationProperties
等注解, 以下面类为例// 匹配前缀为`spring.jdbc`的注解 @ConfigurationProperties(prefix = "spring.jdbc") public class JdbcProperties { // 匹配`spring.jdbc.template` private final Template template = new Template(); public Template getTemplate() { return this.template; } public static class Template { // 匹配`spring.jdbc.template.fetch-size` private int fetchSize = -1; private int maxRows = -1; // spring会自动转换时间单位 @DurationUnit(ChronoUnit.SECONDS) private Duration queryTimeout; } }
-
在使用Spring支持的配置源之外, 还可以自定义配置源, 以
RandomValuePropertySource
为例, 它可以随机生成property值public class RandomValuePropertySource extends PropertySource<Random> { // private static final String PREFIX = "random."; @Override public Object getProperty(String name) { if (!name.startsWith(PREFIX)) { return null; } if (logger.isTraceEnabled()) { logger.trace("Generating random property for '" + name + "'"); } return getRandomValue(name.substring(PREFIX.length())); } }
- 在实现了自定义的
PropertySource<T>
之后, 还要将其添加到Environment
当中, 比较合适的切入位置有EnvironmentPostProcessor
和BeanFactoryPostProcessor
- 在实现了自定义的
自定义PropertySource
-
添加自定义的配置文件
passnight.greeting=hello from passnight
-
创建自定义的
EnvironmentPostProcessor
public class MyPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor { private final PropertiesPropertySourceLoader loader = new PropertiesPropertySourceLoader(); @SneakyThrows @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { MutablePropertySources propertySources = environment.getPropertySources(); Resource resource = new ClassPathResource("my.properties"); PropertySource<?> myPropertyFile = loader.load("MyPropertyFile", resource).get(0); propertySources.addFirst(myPropertyFile); } }
-
将其添加到
spring.factories
中org.springframework.boot.env.EnvironmentPostProcessor=com.passnight.springboot.autoconfiguration.MyPropertySourceEnvironmentPostProcessor
-
之后该property source就会生效, 读取自定义配置文件中的配置
@SpringBootTest class AutoConfigurationApplicationTest { @Value("${passnight.greeting}") private String greeting; @Test public void loadPropertyFromMyPropertySource() { Assertions.assertEquals("hello from passnight", greeting); } }
SpringBoot监控
Actuator
-
SpringBoot Actuator是一个用于监控/管理应用程序的包, 可以通过HTTP/JMX等访问方式访问, 通过
spring-boot-starter-actuator
引入 -
SpringBoot Actuator常用的Endpoint有:
ID 说明 默认启动 默认HTTP 默认JMX beans 显示容器中的bean列表 ✅ ❌ ✅ caches 显示应用中的缓存 ✅ ❌ ✅ conditions 显示配置条件的计算情况 ✅ ❌ ✅ configprops 显示 @ConfigurationProperties
的信息✅ ❌ ✅ env 显示 ConfigurableEnvironment
中的属性✅ ❌ ✅ health 显示健康检查信息 ✅ ✅ ✅ httptrace 显示HTTP trace信息 ✅ ❌ ✅ info 显示设置好的应用信息 ✅ ✅ ✅ loggers 显示并更新日志信息 ✅ ❌ ✅ metriecs 显示应用的度量信息 ✅ ❌ ✅ mappings 显示所有的 @RequestMapping
信息✅ ❌ ✅ scheduledtasks 显示应用的调度任务信息 ✅ ❌ ✅ shutdown 优雅的关闭程序 ❌ ❌ ✅ treaddump 执行Thread Dump ✅ ❌ ✅ heapdump 返回Heap Dump文件, 格式为HPROF ✅ ❌ 🕳️ prometheus 返回可供prometheus抓取的信息 ✅ ❌ 🕳️ -
在开启了actuator之后, 就可以通过
/actuator/<id>
访问对应的端点了, 也可以通过以下配置调整Actor的访问:management.server.address
: 访问地址managerment.server.port
: 访问端口management.endpoints.web.base-path=/actuator
: 访问相对路径management.endpoints.web.path-mapping.<id>=
对应端点的路径
基本使用
-
SpringBoot可以自定义端点以用于监控程序的运行状态, SpringBoot提供了Health Indicator机制用于收集和展示相关信息
-
SpringBoot通过
HealthIndicatorRegistry
收集信息, 通过HealthIndicator
实现具体检查逻辑; 具体可以通过以下配置配置:management.health.defaults.enable=true|false
: 开启或关闭Health Indicatormanagement.health.<id>.enabled=true
: 开启或关闭某一个health Indicatormanagement.endpoint.health.show-details=never|when-authorized|always
: 通过打开这个可以查看详细信息 而不是一个up/down的概要
-
SpringBoot内置的HealthIndicator用于监控开源的基础设施, 如
MongoHealthIndicator
可以用于监控MongoDB的运行状态; 而DiskSpaceHealthIndicator
可以用于监控磁盘的使用状态 -
以
DataSourceIndicator
为例; 他可以用于监控数据源连接情况; 它继承了AbstractHealthIndicator
默认可以通过java.sql.Connection#isValid
来监控数据源的状态public class DataSourceHealthIndicator extends AbstractHealthIndicator implements InitializingBean{ @Override protected void doHealthCheck(Health.Builder builder) throws Exception { if (this.dataSource == null) { builder.up().withDetail("database", "unknown"); } else { doDataSourceHealthCheck(builder); } } private void doDataSourceHealthCheck(Health.Builder builder) throws Exception { builder.up().withDetail("database", getProduct()); String validationQuery = this.query; if (StringUtils.hasText(validationQuery)) { builder.withDetail("validationQuery", validationQuery); // Avoid calling getObject as it breaks MySQL on Java 7 and later List<Object> results = this.jdbcTemplate.query(validationQuery, new SingleColumnRowMapper()); Object result = DataAccessUtils.requiredSingleResult(results); builder.withDetail("result", result); } else { builder.withDetail("validationQuery", "isValid()"); boolean valid = isConnectionValid(); builder.status((valid) ? Status.UP : Status.DOWN); } } private Boolean isConnectionValid() { return this.jdbcTemplate.execute((ConnectionCallback<Boolean>) this::isConnectionValid); } private Boolean isConnectionValid(Connection connection) throws SQLException { return connection.isValid(0); } } public interface Connection extends Wrapper, AutoCloseable { boolean isValid(int timeout) throws SQLException; }
-
自定义Indicator也很容易, 只需要继承
HealthIndicator
然后实现health()
就可以了@Component @RequiredArgsConstructor public class CoffeeIndicator implements HealthIndicator { private final CoffeeService coffeeService; @Override public Health health() { int count = coffeeService.findCoffees().size(); return count > 0 ? Health.up() .withDetail("count", count) .withDetail("message", "Enough Coffee") .build() : Health.down() .withDetail("count", count) .withDetail("message", "Not Enough Coffee") .build(); } }
-
在实现了该接口之后, 就可以在SpringBoot Actuator中通过http请求到对应的信息了 这里主要要打开
management.endpoint.health.show-details=always
, 否则没有详细信息@Log4j2 @SpringBootTest(properties = {"management.endpoint.health.show-details=always"}) @AutoConfigureMockMvc public class HealthIndicatorTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test public void getHealthIndicatorTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn() .getResponse() .getContentAsString(); Assertions.assertTrue(response.contains("{\"coffeeIndicator\":{\"status\":\"UP\",\"details\":{\"count\":2,\"message\":\"Enough Coffee\"}}")); } }
Micrometer
- 除了使用HealthIndicator之外, 还可以使用Micrometer收集度量指标, 如jvm的运行状态等
- Micrometer提供了许多特性, 如
- 多维度度量, 因为Micrometer支持Tag
- 多内置探针, 如缓存/类加载器/GC/CPU利用率/线程池等
- 与Spring深度融合, 如可以与MVC/WebFlux集成
- 核心概念:
- 基本接口:
Meter
Gauge
/TimeGauge
: 单个值的对量Timer
/LongTaskTimer
/FunctionTimer
: 计时器Counter
/FunctionCounter
: 计数器DistributionSummary
: 分布统计 如95线/99线
- 基本接口:
- Micrometer可以通过
Actuator
的端点访问: 如/actuator/metrics
和针对Prometheus的/actuator/prometheus
- Micrometer提供了一些配置项用于配置其基本使用
management.metrics.export.*
: 输出配置 如向datadog输出management.metrics.tags.*
: 标签配置 如添加区域标签management.metrics.enable.*
: 是否开启management.metrics.web.server.auto-time.requests
: 用于监控web服务器的请求时间
- Spring Micrometer提供了许多内置的度量项, 如
- 核心系统相关: JVM/CPU/文件句柄/日志/启动时间
- Web服务端相关: MVC/WebFlux/Tomcat/Jersey
- Web客户端相关:RestTemplate/WebClient
- 数据库相关: 缓存/数据源/Hibernate
- MQ相关: Kafka/RabbitMQ
基本使用
-
自定义度量指标有以下三种方式:
- 通过
MeterRegistry
注册Meter - 通过
MeterBinder
让SpringBoot自动绑定 - 通过
MeterFilter
进行定制
- 通过
-
如下面通过实现
MeterBinder
来支持CoffeeOrderService
的监控, ❗注意❗, 在使用MockMvc
测试前需要打开management.endpoints.web.exposure.include=metrics
-
第一步要修改
Service
的代码, 通过实现MeterBindr
将Counter
绑定到metrices中, 然后再在业务逻辑中加入Counter
的修改@Service @RequiredArgsConstructor public class CoffeeOrderService implements MeterBinder { public final static List<CoffeeOrder> coffeeOrderRepository = new ArrayList<>(); private Counter orderCounter; public CoffeeOrder createOrder(String customerName, List<Coffee> coffees) { CoffeeOrder coffeeOrder = CoffeeOrder.builder() .coffees(coffees) .customer(customerName) .build(); coffeeOrderRepository.add(coffeeOrder); orderCounter.increment(); return coffeeOrder; } @Override public void bindTo(@NonNull MeterRegistry registry) { orderCounter = registry.counter("order.count"); } }
-
在这之后就可以在
metrics
中看到order.count
; 并且初始值为0
, 调用了一次createOrder
之后会变为1
@Test public void getIndicatorTest() throws Exception { List<String> metrics = objectMapper.<Map<String, List<String>>>readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn() .getResponse() .getContentAsString(), TypeFactory.defaultInstance().constructMapType(Map.class, TypeFactory.defaultInstance().constructType(String.class), TypeFactory.defaultInstance().constructCollectionType(List.class, String.class))).get("names"); Assertions.assertTrue(metrics.contains("order.count")); Map<String, Object> orderCountMetric = objectMapper.readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics/order.count") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn() .getResponse() .getContentAsString(), TypeFactory.defaultInstance().constructMapType(Map.class, String.class, Object.class)); Assertions.assertEquals(orderCountMetric.get("measurements"), List.of(Map.of("statistic", "COUNT", "value", 0d))); mockMvc.perform(MockMvcRequestBuilders.post("/order/") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder() .coffee("Coffee1") .customer("Customer1") .build()))) .andExpect(MockMvcResultMatchers.status().isCreated()) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)); orderCountMetric = objectMapper.readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics/order.count") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn() .getResponse() .getContentAsString(), TypeFactory.defaultInstance().constructMapType(Map.class, String.class, Object.class)); Assertions.assertEquals(orderCountMetric.get("measurements"), List.of(Map.of("statistic", "COUNT", "value", 1d))); }
SpringBoot Admin
- 在可以通过Actuator监控SpringBoot程序之后, 还需要一个可视化的界面将这些监控展示出来, Spring Boot Admin就是一个第三方的可视化工具
- 其主要功能为: 集中地展示Actuator相关的内容; 变更通知
基本使用
- 使用Spring Boot Admin分为服务端和客户端
- 开启服务端有两步: 第一步是引入对应的依赖
spring-boot-admin-starter-server
, 第二步是添加@EnableAdminServer
注解开启服务端 - 开启客户端也是两步: 第一步是引入对应的依赖
spring-boot-admin-starter-client
, 第二步是添加对应的配置:spring.boot.admin.client.url=http://localhost:8080
management.endpoints.web.exposure.include=*
- 开启服务端有两步: 第一步是引入对应的依赖
命令行程序
- SpringBoot除了提供了Web应用的框架之外, 还提供了命令行程序的框架; 其主要的类有, 他们的功能都是在程序启动后执行一段代码:
ApplicationRunner
: 接收ApplicationArguments
参数CommandLineRunner
: 接收String[]
参数
- 除了入参的格式是不同的之外, 其他都是类似的; 此外, 他们可以通过
@Order
来指定执行顺序 - 返回码的类型为
ExitCodeGenerator
基本使用
-
SpringBoot命令行程序的编写非常简单, 只需要实现Runner即可成功, 首先是实现一个
CommandLineRunner
, 它会在启动的时候打印一句话, 同时通过@Order
指定其为最先打印的@Log4j2 @Order(1) @Component public class FooCommandLineRunner implements CommandLineRunner { @Override public void run(String... args) { log.info("FooCommandLineRunner.run @Order(1)"); } }
-
ApplicationRunner
的使用和CommandLineRunner
类似, 只是接收的参数不同@Log4j2 @Order(2) @Component public class BarCommandLineRunner implements ApplicationRunner { @Override public void run(ApplicationArguments args) { log.info("BarCommandLineRunner.run @Order(2)"); } }
-
在程序结束之后, 我们可以通过实现
ExitCodeGenerator
来指定程序对出时的返回码, 下面指定返回码值为1
@Component public class MyCodeGenerator implements ExitCodeGenerator { @Override public int getExitCode() { return 1; } }
-
之后通过调用
SpringApplication.exit()
就可以获得该返回码 注意, 这里得通过ApplicationContextAware
来获取上下文信息; 使用@Autowired
也能达到类似的效果@Log4j2 @Order(3) @Component public class ExitCodeApplicationRunner implements ApplicationRunner, ApplicationContextAware { private ApplicationContext context; @Override public void run(ApplicationArguments args) { int code = SpringApplication.exit(context); log.info("ExitCodeApplicationRunner.run @Order(3) And Exit with code: {}", code); System.exit(code); } @Override public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { context = applicationContext; } }
SpringCloud
-
SpringCloud为常见的分布式系统提供了一套简单/便捷的编程模型, 它由多个组件组成:
-
SpringCloud组要由以下几个组件组成:
- 服务发现: Eureka, Zookeeper, Nacos
- 服务熔断: Hystrix, Sentinel, Resilience4j
- 配置服务: Git, Zookeeper, Consul, Nacos
- 服务安全: SpringCloudSecurity
- 服务网关: SpringCloudGateway, Zuul
- 分布式消息: SpringCloudStream
- 分布式跟踪: zipkin
- 云服务支持: GoogleCloud, Azure
-
使用SpringCloud同SpringBoot一样简单, 只需要引入
spring-cloud-dependencies
就可以自动管理相应的依赖 -
SpringCloud除了
application.yml
之外还有bootstrap配置, 它是在SpringCloud应用程序启动的时候用于启动引导阶段加载的属性; 通常需要配置应用名/配置中心相关的基本配置项
服务注册与发现
- 为了管理复杂的分布式系统, 需要有一个中心用于管理这种复杂的关系网络, 这个过程被称为服务注册与发现4; 服务注册与发现分为了服务注册和服务发现两个部分
- 服务注册: 就是将提供某个服务的模块信息(通常是这个服务的ip和端口)注册到1个公共的组件上去(比如: zookeeper\consul)。
- 服务发现: 就是新注册的这个服务模块能够及时的被其他调用者发现。不管是服务新增和服务删减都能实现自动发现。
Spring对服务注册与发现的抽象
- Spring将服务的注册与发现分为了以下几个部分:
- 服务注册: 通过
ServiceRegistry
抽象 - 服务发现:
DiscoveryClient
和@EnableDiscoveryClient
抽象 - 负载均衡(包含于服务发现):
LoadBalancerClient
抽象
- 服务注册: 通过
Eureka
- Eureka 是一个Netflix开源的用于服务注册与发现的组件
- 对于SpringCloud对服务注册与发现的抽象, Eureka通过
EurekaServiceRegistry
,EurekaRegistration
来实现服务注册发现, 通过EurekaClientAutoConfiguration
,EurekaAutoServiceRegistration
来实现自动配置
基本使用
-
Eureka分为了Client和Server两个部分, Server是用于管理服务注册与发现的服务器, 而Client是需要注册到注册中心的工作节点
-
类似于Spring的其他组件的引入一样, 引入Eureka只需要引入
spring-cloud-starter-netflix-eureka-client
和spring-cloud-starter-netflix-eureka-server
; 他们分别对应了服务注册的服务器和服务发现的客户端 -
服务端的启动需要通过
@EnableEurekaServer
开启, 而客户端也主需要配置@EnableDiscoveryClient
或@EnableEurekaClient
-
Eureka常用的配置有:
eureka.client.server-url.default-zone
: 用于配置Eureka集群的地址, 用,
分隔eureka.client.instance.prefer-ip-address=false
: 优先使用hostname还是ip注册
-
在引入了对应的依赖, 第一步是配置一些必要的配置 这里使用了高可用配置, 因此
defaultZone
配置了三个节点;hostname是通过写在本地
hosts`中完成解析的server: port: 8000 eureka: instance: hostname: eureka.internal client: register-with-eureka: false fetch-registry: false service-url: defaultZone: http://eureka1.internal:8001/eureka/,http://eureka2.internal:8002/eureka/ spring: application: name: eureka
-
完成配置之后, 只需要添加
@EnableEurekaServer
就可以启动Eureka服务@SpringBootApplication @EnableEurekaServer public class EurekaApplication { public static void main(String[] args) { SpringApplication.run(EurekaApplication.class, args); } }
-
之后编写一个服务, 添加类似的配置
server: port: 8080 # servlet: # context-path: /cloud/service eureka: client: service-url: defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka instance: instance-id: service spring: application: name: service
-
及
@EnableEurekaClient
开启服务发现@SpringBootApplication @EnableEurekaClient public class ServiceApplication { public static void main(String[] args) { SpringApplication.run(ServiceApplication.class, args); } }
-
就可以在Eureka的页面看到对应的服务了:
Zookeeper
- 除了使用Eureka作为注册中心之外, 还可以使用Zookeeper作为注册中心
- Zookeeper是一个分布式协调系统; 具有强一致性和使用简单等特点
基本使用
-
使用Zookeeper同Eureka类似, 只需要引入
spring-cloud-starter-zookeeper-discovery
并配置spring.cloud.zookeeper.connect-string
就可以了 -
在引入对应的依赖, 并配置Zookeeper的连接路径之后就可以成功注册了
spring: cloud: zookeeper: connect-string: server.passnight.local:20012,follower.passnight.local:20012,replica.passnight.local:20012 application: name: zookeeper-server
-
我们可以在Zookeeper中看到对应的信息
[zk: localhost:2181(CONNECTED) 0] ls /services [zookeeper-server] [zk: localhost:2181(CONNECTED) 1] ls /services/zookeeper-server [f95b757a-b08b-4291-b4ca-15701131918d] # 注册的节点信息 [zk: localhost:2181(CONNECTED) 3] get /services/zookeeper-server/f95b757a-b08b-4291-b4ca-15701131918d {"name":"zookeeper-server","id":"f95b757a-b08b-4291-b4ca-15701131918d","address":"server.passnight.local.lan","port":8003,"sslPort":null,"payload":{"@class":"org.springframework.cloud.zookeeper.discovery.ZookeeperInstance","id":"application-1","name":"zookeeper-server","metadata":{"instance_status":"UP"}},"registrationTimeUTC":1712396286972,"serviceType":"DYNAMIC","uriSpec":{"parts":[{"value":"scheme","variable":true},{"value":"://","variable":false},{"value":"address","variable":true},{"value":":","variable":false},{"value":"port","variable":true}]}}
-
服务发现也是类似地修改配置文件和依赖就行了
Nacos
- Nacos是阿里巴巴开源的一款易于构建云原生应用的动态服务发现/配置管理和服务管理平台
- 它提供了以下功能: 动态访问配置, 服务发现和管理, 动态DNS服务
基本使用
- 使用nacos之前需要提那几SpringCloud Alibaba的依赖:
spring-cloud-alibaba-dependencies
; 之后就可以类似Eureka一样使用SpringCloud Alibaba的组件了 - 在添加了BOM依赖之后, 还需要添加
spring-cloud-starter-alibaba-nacos-discovery
然后配置spring.cluod.nacos.discovery.server-addr
的地址就可以 - 在配置了nacos服务注册与发现之后, 类似Eureka, 也可以使用Ribbon来做负载均衡
自定义服务注册与发现
-
第一步是需要实现
DiscoveryClient
, 它可以提供可用实例和服务; 这里直接从application.yml
中读取, 获取域名+端口格式的配置项并解析为实例@Setter @Component @ConfigurationProperties("fix-discovery-client") public class FixedDiscoveryClient implements DiscoveryClient { public static final String SERVICE_ID = "SERVICE"; private List<String> services; @Override public String description() { return "DiscoveryClient that uses service.list from application.yml.;"; } @Override public List<ServiceInstance> getInstances(String serviceId) { if (!SERVICE_ID.equalsIgnoreCase(serviceId)) { return Collections.emptyList(); } return services.stream() .filter(service -> service.matches("[\\w.]+:\\d+")) .map(service -> new DefaultServiceInstance(service, SERVICE_ID, service.split(":")[0], Integer.parseInt(service.split(":")[1]), false)) .collect(Collectors.toList()); } @Override public List<String> getServices() { return Collections.singletonList(SERVICE_ID); } }
-
第二步是实现
ServerList
; 这个是用于ribbon负载均衡使用的@Component @RequiredArgsConstructor public class FixedServerList implements ServerList<Server> { private final FixedDiscoveryClient fixedDiscoveryClient; @Override public List<Server> getInitialListOfServers() { return fixedDiscoveryClient.getInstances(FixedDiscoveryClient.SERVICE_ID) .stream() .map(service -> new Server(service.getHost(), service.getPort())) .collect(Collectors.toList()); } @Override public List<Server> getUpdatedListOfServers() { return fixedDiscoveryClient.getInstances(FixedDiscoveryClient.SERVICE_ID) .stream() .map(service -> new Server(service.getHost(), service.getPort())) .collect(Collectors.toList()); } }
-
因为服务发现是通过在
application.yaml
中配置, 因此需要在yaml中添加对应的配置fix-discovery-client: services: - localhost:8080
-
-
之后照常配置
RestTemplate
并加上@LoadBalanced
就可以正常实现负载均衡了@Log4j2 @SpringBootTest public class HelloServiceRestTemplateImplTest { @Autowired private HelloServiceRestTemplateImpl helloService; @Test public void helloTest() { String response = helloService.hello(); log.debug(response); Assertions.assertTrue(response.matches("hello from service [0-2]")); } }
服务调用
Spring Cloud LoadBalance
-
在使用Eureka注册完服务之后, 需要通过LoadBalance来根据注册的信息实现对服务的负载均衡地调用
-
LoadBalance可以通过在
RestTemplate
或WebClient
的Bean上添加@LoadBalance
来实现, 其原理是通过ClientHttpRequestInterceptor
实现的; Spring中对它的实现为org.springframework.cloud.client.loadbalancer.LoadBalancerInterceptor
; 它是通过LoadBalancer原有的请求实现的@Override public ClientHttpResponse intercept(final HttpRequest request, final byte[] body, final ClientHttpRequestExecution execution) throws IOException { final URI originalUri = request.getURI(); String serviceName = originalUri.getHost(); Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri); return this.loadBalancer.execute(serviceName, this.requestFactory.createRequest(request, body, execution)); }
-
常用的LoadBalance有Netflix开源的ribbon; 在ribbon中,
RibbonLoadBalancerClient
通过实现LoadBalancerClient
提供了负载均衡的访问机制
基本使用
-
环境准备: 测试客户端负载均衡之前, 先编写三个访问, 使他们相同的Controller返回不同的信息; 用以区分
@RestController @RequestMapping("/hello") public class HelloWorld { @GetMapping("world") public String hello() { return "hello from service 0"; } } @RestController @RequestMapping("/hello") public class HelloWorld { @GetMapping("world") public String hello() { return "hello from service 1"; } } @RestController @RequestMapping("/hello") public class HelloWorld { @GetMapping("world") public String hello() { return "hello from service 2"; } }
-
使用Ribbon的第一步是导入相应的依赖
spring-cloud-starter-netflix-ribbon
-
之后在配置
RestTemplate
的方法上添加@LoadBalanced
注解表明客户端需要负载均衡请求@Bean @LoadBalanced public RestTemplate restTemplate() { return new RestTemplate(); }
-
在配置了负载均衡请求之后, 还需要在
application.yml
中配置服务发现的相关信息, 供负载均衡器使用server: port: 10080 eureka: client: service-url: defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka spring: application: name: gateway
-
之后编写对应的请求类即可完成请求, 它使用添加了
@LoadBalanced
注解的RestTemplate
@Service @RequiredArgsConstructor public class HelloServiceRestTemplateImpl { private final static UriBuilderFactory uriFactory = new DefaultUriBuilderFactory("http://SERVICE/hello"); private final RestTemplate restTemplate; public String hello() { return restTemplate.getForObject(uriFactory.uriString("/world").build(), String.class); } }
-
可以看到每次打印的数字都不一样, 表明请求的不是同一个服务
@Log4j2
@SpringBootTest
@AutoConfigureMockMvc
public class ServiceControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void helloTest() throws Exception {
String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
log.debug(response);
Assertions.assertTrue(response.matches("hello from service [0-2]"));
}
}
Feign
- Feign是一个声明式的Rest Web服务客户端; 它的使用类似于RestTemplate; 只需要在编写的接口上添加
@FeignClient
Feign就会自动将其代理实例化为一个Feign接口 - Feign可以通过
FeignClientsConfiguration
/application/yml
配置, 常见的配置项包括Encoder
/Decoder
/Logger
/COntract
/Client
基本使用
-
Feign的使用也是通过
@EnableFeignClients
来开启的; 在配置了注册中心的信息之后就可以通过注册中心的信息访问到对应的服务, 同时配置接口超时为500ms
eureka: client: service-url: defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka spring: application: name: feign-gateway feign: client: config: default: connect-timeout: 500 read-timeout: 500
-
第一步是开启feign和Eureka Client的支持
@SpringBootApplication @EnableEurekaClient @EnableFeignClients public class FeignGatewayApplication { public static void main(String[] args) { SpringApplication.run(FeignGatewayApplication.class, args); } }
-
之后需要添加Eureka相关的配置, 用于服务发现
eureka: client: service-url: defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka spring: application: name: feign-gateway
-
然后编写一个FeignClient, 它会自动调用; 它使用的注解和SpringMVC相同
@FeignClient(value = "service") @RequestMapping("/hello") public interface HelloService { @GetMapping("world") String hello(); }
-
之后就可以通过Feign访问远程Http服务
@Log4j2 @SpringBootTest public class HelloServiceTest { @Autowired private HelloService helloService; @Test public void helloTest() { String response = helloService.hello(); log.debug(response); Assertions.assertTrue(response.matches("hello from service [0-2]")); } }
服务熔断
- 服务熔断的核心思想在于当服务发生问题时, 不再实际调用, 而直接返回错误
- 核心思想: 使用断路器保护调用服务
- 在断路器对象中封装的方法调用是受保护的
- 断路器监控服务的调用和断路情况
- 调用失败出发阈值之后, 由断路器返回错误, 而不再实际进行调用
- 最简单的使用方式就是添加一个切面, 这个切面维护方法调用的失败情况, 若失败超过阈值, 则在这个切面中拦截所有的请求
Hystrix
- Hystrix是Netflix提供的一个实现服务熔断的组件
- Hystrix和Feign都是Netflix开发的, 因此Hystrix在Feign中有一些相关的配置, Hystrix主要的配置有以下几个:
feign.hystrix.enabled=true
: 是否打开Hystrix@FeignClient(fallback=, fallbackFactory)
: 指定fallback的类或fallback工厂函数的类
基本使用
-
第一步引入
spring-cloud-starter-netflix-systrix
依赖, 之后需要通过@EnableCircuitBreaker
开启Hystrix配置@SpringBootApplication @EnableEurekaClient @EnableCircuitBreaker public class HystrixApplication { public static void main(String[] args) { SpringApplication.run(HystrixApplication.class, args); } }
-
Hystrix和Feign的组合使用非常简单, 直接将
FallbackFactory
配置在@FeignClient
里面, 然后在断路时就可以使用了@FeignClient(value = "service", fallbackFactory = HelloFallbackFactory.class) @RequestMapping("/hello") public interface HelloService { @GetMapping("/world") String hello(); }
-
第一种方法是使用
@HystrixCommand
指定fallback方法; 在调用失败后, 它会直接执行fallbackMethod
中配置的方法@RestController @RequestMapping("/hello") public class Hello { @GetMapping("world") public String hello() { return "hello world"; } @GetMapping("error") @HystrixCommand(fallbackMethod = "hystrixError") public String error() { throw new RuntimeException("an error occurred"); } public String hystrixError() { return "hystrix intercept the error"; } }
-
在上面的
error()
抛出异常之后, 会调用hystrixError
, 并返回其对应的结果:@SpringBootTest @AutoConfigureMockMvc public class HelloControllerTest { @Autowired private MockMvc mockMvc; @Test public void errorTest() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/error")) .andReturn() .getResponse() .getContentAsString(); Assertions.assertEquals("hystrix intercept the error", response); } }
-
使用
@FeignClient
指定fallback
或fallbackFatory
也类似, 只需要在Service上添加对应的配置@FeignClient(value = "service", fallbackFactory = HelloFallbackFactory.class) @RequestMapping("/hello") public interface HelloService { @GetMapping("/world") String hello(); }
-
之后实现对应的类
@Component public class HelloFallbackFactory implements FallbackFactory<HelloService> { @Override public HelloService create(Throwable throwable) { return new HelloService() { @Override public String hello() { return "intercept by hystrix"; } }; } }
-
然后就可以在请求失败的时候走Hystrix提供的断路器了
@Test public void helloBreakByHystrixTest() { String response = helloService.hello(); log.debug(response); Assertions.assertEquals("intercept by hystrix", response); }
Resilience4J
- Resilience4j是一款类似于Hystrix的轻量级的容错库 轻量级在于它的依赖少
- Resilience4j主要包含以下几个组件
resilience4j-circulitbreaker
: 熔断保护resilience4j-ratelimiter
: 频率控制resilience4j-bulkhead
: 依赖隔离&负载保护resilience4j-retry
: 自动重试resilience4j-cache
: 应答缓存resilience4j-timelimiter
: 超时控制
- 基于
ConcurrentHashMap
的内存断路器:CurcuitBreakerRegistry
和CircuitBreakerConfig
基本使用
-
使用resilience4j只需要在引入依赖之后, 然后在需要做断路保护的方法上加上
CircuitBreaker
即可 -
主要的配置可以通过
CircuitBreakerProperties
或application.properties
来配置, 常用的配置有resilience4j.circuitbreaker.backends.failure-rate-threshold
: 断路阈值resilience4j.circuitbreaker.backends.wait-duration-in-open-state
: 断路器打开需要等待的时间
-
第一步是引入依赖:
resilience4j-spring-boot2
; ❗注意❗若要使用注解模式, 还要引入aop的包:spring-boot-starter-aop
-
之后可以使用注解或函数式两种方式来声明熔断,
@Log4j2 @RestController @RequestMapping("/hello") public class HelloController { private final CircuitBreaker circuitBreaker; public HelloController(CircuitBreakerRegistry registry) { this.circuitBreaker = registry.circuitBreaker("hello"); } @GetMapping("/functional-circuit-breaking") public String functionalCircuitBreaking(@RequestParam boolean errorFlag) { return Try.ofSupplier( CircuitBreaker.decorateSupplier(circuitBreaker, () -> { if (errorFlag) { throw new RuntimeException("Some thing wrong in functionalCircuitBreaking"); } return "functional-circuit-breaking normal"; })) .recover(RuntimeException.class, "functional-circuit-breaking broken by resilience4j") .get(); } @GetMapping("/annotation-circuit-breaking") @io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker(name = "annotation-circuit-breaking", fallbackMethod = "fallbackMethod") public String annotationCircuitBreaking(@RequestParam boolean errorFlag) { if (errorFlag) { throw new RuntimeException("Some thing wrong"); } return "annotation-circuit-breaking normal"; } public String fallbackMethod(RuntimeException e) { log.warn(e); return "annotation-circuit-breaking broken by resilience4j"; } }
-
请求的阈值可以在
application.yml
中配置resilience4j: circuitbreaker: backends: annotation-circuit-breaking: failure-rate-threshold: 50 wait-duration-in-open-state: 5000 event-consumer-buffer-size: 10 minimum-number-of-calls: 5 functional-circuit-breaking: failure-rate-threshold: 50 wait-duration-in-open-state: 5000 event-consumer-buffer-size: 10 minimum-number-of-calls: 5
-
之后请求, 若抛出异常则会走熔断器, 返回的是
brokern by resilience4j
的结果@Log4j2 @SpringBootTest @AutoConfigureMockMvc public class HelloControllerTest { @Autowired private MockMvc mockMvc; @Test public void functionalCircuitBreakTest() throws Exception { for (int i = 0; i < 10; i++) { String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/functional-circuit-breaking") .queryParam("errorFlag", "true")) .andReturn() .getResponse() .getContentAsString(); log.debug(response); Assertions.assertEquals("functional-circuit-breaking broken by resilience4j", response); } } @Test public void annotationCircuitBreakTest() throws Exception { for (int i = 0; i < 10; i++) { String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/annotation-circuit-breaking") .queryParam("errorFlag", "true")) .andReturn() .getResponse() .getContentAsString(); log.debug(response); Assertions.assertEquals("annotation-circuit-breaking broken by resilience4j", response); } } }
BulkHead
-
在现网环境中, 流量并不是均匀的, 为了解决突发流量的问题, 甚至导致雪崩 resilience4j提供了BulkHead模式, 可以将请求储存在队列当中; 然后排队处理请求 超过某些阈值的请求也会被直接丢弃掉
-
BulkHead模式和CircularBreak模式类似, 也有声明式和编程式两种实现方式, 对应的注解和类分别为:
BulkheadRegistry
和@Bulkhead
-
在SpringBoot中, resilience通过
BulkheadProperties
为我们提供了接口级的配置, 常用的有:resilience4j.bulkhead.backends.<名称>.max-concurrent-call
: 最大并发请求数resilience4j.bulkhead.backends.<名称>.max-wait-time
: 最大等待时间
-
bulkhead也有两种使用方式, 分别是声明式和编程式
@GetMapping("/annotation-bulkhead") @io.github.resilience4j.bulkhead.annotation.Bulkhead(name = "annotation-bulkhead", fallbackMethod = "bulkheadFallbackMethod") public String annotationBulkhead(@RequestParam boolean errorFlag) { if (errorFlag) { throw new RuntimeException("Some thing wrong"); } return "annotation-bulkhead normal"; } public String bulkheadFallbackMethod(RuntimeException e) { log.warn(e); return "annotation-bulkhead broken by resilience4j"; } @GetMapping("/function-bulkhead") public String functionBulkhead(@RequestParam boolean errorFlag) { return Try.ofSupplier( Bulkhead.decorateSupplier(bulkhead, CircuitBreaker.decorateSupplier(circuitBreaker, () -> { if (errorFlag) { throw new RuntimeException("Some thing wrong in functionalCircuitBreaking"); } return "functional-circuit-breaking normal"; }))).recover(RuntimeException.class, "functional-bulkhead broken by resilience4j") .get(); }
-
之后大流量请求, 过多的请求就会被拦截了
@Test public void functionalBulkhead() throws Exception { final AtomicInteger blockedCount = new AtomicInteger(); final CountDownLatch latch = new CountDownLatch(200); IntStream.range(0, 200) .boxed() .map(String::valueOf) .map(name -> new Thread() { @SneakyThrows @Override public void run() { for (int i = 0; i < 10000; i++) { String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/function-bulkhead") .queryParam("errorFlag", "false")) .andReturn() .getResponse() .getContentAsString(); log.debug(response); if (StrUtil.equals("functional-bulkhead broken by resilience4j", response)) { blockedCount.incrementAndGet(); } latch.countDown(); } } }) .forEach(Thread::start); latch.await(); Assertions.assertTrue(blockedCount.get() > 0); } @Test public void annotationBulkhead() throws Exception { final AtomicInteger blockedCount = new AtomicInteger(); final CountDownLatch latch = new CountDownLatch(200); IntStream.range(0, 200) .boxed() .map(String::valueOf) .map(name -> new Thread() { @SneakyThrows @Override public void run() { for (int i = 0; i < 10000; i++) { String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/annotation-bulkhead") .queryParam("errorFlag", "false")) .andReturn() .getResponse() .getContentAsString(); log.debug(response); if (StrUtil.equals("annotation-bulkhead broken by resilience4j", response)) { blockedCount.incrementAndGet(); } latch.countDown(); } } }) .forEach(Thread::start); latch.await(); Assertions.assertTrue(blockedCount.get() > 0); }
RateLimite
-
除了隔仓模式的保护之外, resilience4还提供了限制特定时间段内执行次数的机制
-
类似其他模式, 也提供了声明式和编程式两种方式, 分别对应了
@RateLimiter
和RateLimiterRegistry
-
对于SpringBoot, resilience4j在
RateLimiterProperties
中也提供了许多常用的配置, 如:resilience4j.ratelimiter.limiters.<名称>.limit-for-period
: 能接受的次数 (和下面一个配置连起来)resilience4j.ratelimiter.limiters.<名称>.limit-refresh-period
: 时间范围 (和上面一个配置连起来)resilience4j.ratelimiter.limiters.<名称>.timeout-duration
: 超时时间
-
RateLimiter使用非常简单, 只需要将执行的函数包裹在
rateLimiter.executeSupplier()
里面就行@GetMapping("/function-ratelimiter") public String functionRateLimiter() { return rateLimiter.executeSupplier(() -> "function-ratelimiter normal"); }
-
在
application.yml
中配置限流resilience4j: ratelimiter: limiters: hello: limit-for-period: 5 limit-refresh-period: 3s timeout-duration: 5s
-
之后再连续调用会发现每3s只能调用5次
@Test public void exceedRateLimit() throws Exception { for (int i = 0; i < 10; i++) { mockMvc.perform(MockMvcRequestBuilders.get("/hello/function-ratelimiter") .queryParam("errorFlag", "false")) .andReturn() .getResponse() .getContentAsString(); } }
服务配置
-
SpringCloud提供了配置中心的功能, 可以通过请求HTTP API获取配置, 为分布式系统提供外置的配置支持; 他可以基于Git/SVN/JDBC等第三方平台提供配置服务
-
SpringCloudConfig对配置的实现类似于SpringBoot对配置的支持, 也是实现类似
PropertySource
的类来实现的, 例如, 对于不同的平台:- SpringCloudConfigClient:
ConpositePropertySource
- Zookeeper:
ZookeeperPropertySource
- Consul:
ConsulPropertySource
/ConsulFilesPropertySource
- SpringCloudConfigClient:
-
SpringCloud通过
PropertySourceLoacator
实现了对PropertySource的定位功能; 它只有一个方法, 就是从Environment
中获取PropertySource 或获取集合PropertySource<?> locate(Environment environment);
SpringCloudConfig
-
SpringCloudConfig类似于注册中心, 只需要引入
spirng-cloud-config-server
的依赖, 之后添加@EnableConfigServer
就可以开启配置服务 -
使用Git作为配置的话, SpringCloudConfig是基于
MultipleJGitEnvironmentProperties
实现的, 主要的配置是spring.cloud.config.server.git.uri
@SpringBootApplication @EnableConfigServer @EnableDiscoveryClient public class ConfigurationApplication { public static void main(String[] args) { SpringApplication.run(ConfigurationApplication.class, args); } }
-
之后就可以通过http请求获得配置了
@Test public void configLoad() throws Exception { String response = mockMvc.perform(MockMvcRequestBuilders.get("/")) .andReturn() .getResponse() .getContentAsString(); log.info(response); }
客户端
- 在有了服务端之后, 还要有一个客户端用于连接spring cloud config server获取配置
- 使用第一步是引入依赖
spring-cloud-starter-config
; 之后添加配置spring.cloud.config.uri
配置配置中心的位置就可以了 - 也可以通过服务发现来配置, 具体需要启动
spring.cloud.config.discovery.enabled
然后配置对应的service idspring.cloud.config.discovery.service-id
- spring cloud 除了基本的提供配置的功能以外, 还可以支持配置的刷新, 只需要在properties添加
@RefreshScope
之后调用/actuator/refresh
就可以实现配置的刷新
使用zookeeper作为配置中心
- SpringCloud除了使用git作为配置中心之外, 还可以使用zookeeper作为配置中心
- 第一步是引入zookeeper的依赖
spring-cloud-starter-zookeeper-config
; 之后通过spring.cloud.zookeeper.config.enabled
开启即可 - 除此之外, 还可以通过以下配置修改配置的结构
spring.cloud.zookeeper.config.root
: config节点名spring.cloud.zookeeper.config.default-context
: 默认的上下文spring.cloud.zookeeper.config.profile.separator
: 应用名和profile的分隔符
Nacos
-
SpringCloudAlibaba也提供了配置中心Nacos; 只需要引入
spring-cloud-starter-alibaba-nacos-config
并配置spring.cloud.nacos.config.server-addr
和spring.cloud.nacos.config.enabled
即可spring.cloud.nacos.server-addr=localhost:8848 spring.cloud.nacos.discovery.username=****** spring.cloud.nacos.discovery.password=******* spring.cloud.nacos.discovery.namespace=public
消息队列
-
SpringCloud中提供了SpringCloudStream用于构建消息驱动的微服务应用程序的轻量级框架; 它具有以下特性:
- 声明式编程模型
- 对消息队列的抽象: 发布订阅/消费组/分区
- 支持多种消息中间件: RabbitMQ, Kafka等
-
它主要通过
Binder
提供了消息队列的抽象, 为我们提供了中间件和应用程序的连接: -
对于生产者/消费者和消息系统之间的通信, SpringCloud提供了Binding的机制; 为我们提供了这三者之间的通信桥梁
-
核心概念:
-
消费组: 对于同一消息, 每个组中都会有消费者收到消息
-
分区: 同一分区的数据只会被一个消费者消费
-
-
SpringCloudStream将消息通信分为了输入和输出两个部分, 因此提供了以下几个注解/接口供我们操作消息队列
-
@EnableBinding
: -
@Input
/SubscribableChannel
: -
@Output
/MessageChannel
:
-
-
消息队列的使用最重要的就是生产和消费, 在SpringCloud中, 消息的生产和消费分别使用以下方式实现
- 生产消息:
@SendTo
注解/MessageChannel#send()
接口 - 消费消息:
@StreamListener
, 其中可以配置@Payload
/@Headers
/@Header
- 生产消息:
-
使用spring cloud stream kafka的第一步是引入依赖
spring-cloud-starter-stream-kafka
; 然后配置一些常用项spring.cloud.stream.kafka.binder.*
: 与binder相关的配置spring.cloud.stream.kafka.bindings.<channelName>.consumer.*
: 与binding相关的配置spring.kafka.*
: kafka本身的配置
-
第一步配置kafka连接配置
spring: main: web-application-type: none cloud: stream: kafka: binder: brokers: - server.passnight.local - replica.passnight.local - follower.passnight.local default-broker-port: 20015 bindings: message: group: default-group
-
之后分别创建生产者和消费者; 消费者接收到消息后会打印一条日志
public interface KafkaProducer { String INPUT = "kafka-in"; String OUTPUT = "kafka-out"; @Input(INPUT) SubscribableChannel subscribableChannel(); @Output(OUTPUT) MessageChannel messageChannel(); }
@Component @Slf4j public class KafkaConsumer { @StreamListener(KafkaProducer.OUTPUT) public void handleGreetings(@Payload String message) { log.info("Received message: {}", message); } }
-
编写一个服务类, 用于触发生产者生产消息
@Service
@RequiredArgsConstructor
public class KafkaProducerService {
private final KafkaProducer kafkaProducer;
public void sendMessage(String message) {
kafkaProducer.messageChannel()
.send((MessageBuilder
.withPayload(message)
.setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON)
.build()));
}
}
-
之后再测试类中调用服务, 就可以看到对应的日志了
@SpringBootTest public class KafkaProducerServiceTest { @Autowired private KafkaProducerService kafkaProducerService; @Test public void messageTest() { kafkaProducerService.sendMessage("Test Message"); } } // 输出为: INFO [main] c.p.cloud.streamproducer.consumer.KafkaConsumer#[handleGreetings:14] - Received message: Test Message
服务治理
- 在微服务架构中, 服务之间的调用关系复杂度要远高于单体应用, 因此需要服务治理, 服务治理主要监控以下内容:
- 服务中的服务信息
- 服务之间的依赖关系
- 请求的执行路径; 及每个环节的耗时/状态
SpringCloud Sleuth
- SpringCloudSleuth是SpringCloud实现的一个分布式链路跟踪解决方案, 可以用于记录请求的开始/结束/耗时等信息
- 使用SpringCloudSleuth可以引入
spring-cloud-starter-sleuth
或引入和zipkin一同打包的spring-cloud-starter-zipkin
zipkin和sleuth可以结合使用; 一起监控 - zipkin和sleuth常用的配置有:
spring.zipkin.base-url
: 配置zipkin路径spring.zipkin.discovery-clietn-enabled
: 使用服务发现spring.zipkin.sender.type=web|rabbbit|kafka
: 通过web请求/mq的方式埋点spring.zipkin.compression.enabled
: 是否做压缩spring.sleuth.sampler.probability=0.1
: 采样比例
基本使用
-
使用SpringCloudSleuth首先可以在客户端和服务端同时引入
spring-cloud-starter-zipkin
, 这里面包含了sleuth的依赖 -
之后在两个服务中同时配置项目, 这里将采样率设置为
1
以保证每次请求都被采样, 并配置sender.type
为webspring: sleuth: sampler: probability: 1 zipkin: base-url: http://server.passnight.local:20025 sender: type: web
-
尝试发起请求; 这个请求最终会通过feign远程调用其他的服务
curl localhost:9080/hello
-
然后就可以在zipkin的UI里面看到链路追踪信息了
消息链路追踪
-
Zipkin不仅可以追踪web形式的远程调用, 还能够追踪消息形式的远程调用
-
此时需要添加以下配置
引用
DAO Support :: Spring Framework ↩︎
Context Hierarchy :: Spring Framework ↩︎
java - What does maxSwallowSize really do? - Stack Overflow ↩︎
深入了解服务注册与发现 - 知乎 (zhihu.com) ↩︎