0%

SpringBoot高级

本部分设计SpringBoot的高级内容,包括缓存,消息,任务,检索,分布式等内容.

SpringBoot缓存

image-20200727220900803

使用缓存时用到的部分注解:

image-20200727221025430

在SpringBoot中使用缓存,首先要添加相关的starter:spring-boot-starter-cache.接着在启动类上加上@EnableCaching注解.

然后在加上@Cacheable注解:

1
2
3
4
5
@Cacheable
public Employee getEmpById(Integer id) {
System.out.println("查询" + id + "员工");
return employeeMapper.getEmpById(id);
}

该注解有一些属性:

  • value/cacheNames:Cache的名字,实际是一组数据的名字,即下图里的Emp Cache.

    image-20200727223355222

    在一个CacheManager中有着多个Cache.而每个Cache又有着多个键值对.

  • key:缓存对象存储在Map集合中的key值,非必需.默认是方法参数值.以上面代码为例,参数传入1,则键值对为1-返回值.填写key时可以使用SpEL表达式:

    image-20200727224630411

    注意caches那一条,这代表一个键值对可以存在多个Cache中.

  • keyGenerator:key的生成器.可以自己指定,要实现KeyGenerator接口.改参数与key互斥.

  • cacheManager指定缓存管理器.与之类似的是cacheResolver,也是二选一.默认的是CurrentMapCacheManager.

  • condition:指定符合条件的情况下缓存,也支持SpEL表达式.在方法执行之前之后都可以执行.比如要求id大于1之类的条件.此外支持条件的复合.

  • unless:当unless指定的条件为true时不缓存.且该表达式只在方法执行后判断.

  • sync是否使用异步模式

使用缓存,引入缓存模块,这代表也有一个自动配置类CacheAutoConfiguration.同样是老套路,利用Bean注解注入了一些组件.另外还用@Import注解导入了CacheConfigurationImportSelector这个类.这个类实现了ImportSelector接口,具体作用就是一次性导入多个配置类:

image-20200728100508815

其中SimpleCacheConfiguration默认生效.这个配置类注册了一个ConcurrentMapCacheManager.

缓存流程,以emp请求为例:

  1. 首先根据请求上的注解@Cacheable(cacheNames = "emp", key = "#p0")找name为emp的Cache,具体方法是在ConcurrentMapCacheManager中:

    image-20200728114817078

  2. 现在是第一次访问,所以没有Cache.进入if,创建一个Cache.看看这个创建方法:

    image-20200728115520036

    清晰的显示了Cache的结构,CurrentMapCache中的store(存具体键值对的地方)使用ConcurrentHashMap实现.接着创建完成的Cache被放入ConcurrentMapCacheManager的cacheMap中(this.cacheMap.put(name,cache);),根据put这种方式也可以看出CacheManager管理Cache也是键值对:

    1
    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);

    所以CacheManager中:

    • CacheManager–>Cache(Cache默认是16个):用ConcurrentHashMap存储cacheNames和Cache,这个结构叫cachemap
    • Cache–>key&value:也是用ConcurrentHashMap存储key和value,这个结构叫store
  3. 现在名为”emp”的Cache有了,并且放入了cachemap.然后中间经过其它操作,最终来到CurrentMapCache中的查找value的方法:

image-20200728122450834

​ 这里的key默认是使用keyGenerator生成的,具体是其实现类SimpleKeyGenerator.

​ 现在第一次访问,查不到这个key.所以会直接调用目标方法获取查询结果.得到结果后存入store:

image-20200728135928839

key的生成也是可以自定义的.只需要自己实现keyGenerator,然后加入容器.

1
2
3
4
5
6
7
8
9
@Bean("myKeyGenerator")
public KeyGenerator keyGenerator() {
return new KeyGenerator() {
@Override
public Object generate(Object target, Method method, Object... params) {
return method.getName() + '[' + Arrays.asList(params) + ']';
}
};
}

然后在注解上指定自定义的keyGenerator:

1
2
3
4
5
@Cacheable(cacheNames = "emp", keyGenerator = "myKeyGenerator")
public Employee getEmpById(Integer id) {
System.out.println("查询" + id + "员工");
return employeeMapper.getEmpById(id);
}
  • 另一个注解:@CachePut,既调用目标方法,有更新缓存.主要是用来更新数据库的数据同时更新缓存.它的运行时机是在调用完目标方法之后执行,因为要用结果更新缓存.使用该注解更新缓存尤其要注意的是cacheNames和key必须相同.如果调用上面的getEmp方法使得缓存有1号员工数据,这时使用update方法更新数据库内1号员工数据(更新方法上只有@Cacheable注解并指定了cacheName/value,因为是必须的),接着在用getEmp方法查就会得到缓存中过时的数据.因为@Cacheable没有指定key,默认是参数,而更新方法的参数和get方法的key并不一样.这就回导致更新缓存其实没有更新对应的.

  • @CacheEvict:缓存清除.通过key指定要删除的具体值,也可以是添加allEntries属性表示清除emp中的所有缓存.默认是在目标方法之后执行,可以使用beforeInvocation=true表示在之前执行.这种设置是为了避免在目标方法(delete方法之类的)出现异常,没有清除缓存.

  • @Caching:这是一个组合注解:

    1
    2
    3
    4
    5
    Cacheable[] cacheable() default {};

    CachePut[] put() default {};

    CacheEvict[] evict() default {};

    用法如下:

    image-20200728152717498

  • @CacheConfig注解用来加在类上指定一些公共的属性.例如,CacheManager,cacheNames/value,keyGenerator

整合Redis

需要一个spring-boot-starter-data-redis,然后在配置文件中写好主机地址等配置.注意,新版本的驱动是lettuce的,改驱动基于netty.

下面是一些基本操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootTest
class SpringbootcacheApplicationTests {

@Autowired
StringRedisTemplate stringRedisTemplate;//k-v是字符串

@Autowired
RedisTemplate redisTemplate;//k-v是对象


@Test
public void test01() {
stringRedisTemplate.opsForValue().append("msg", "hello");
System.out.println(stringRedisTemplate.opsForValue().get("msg"));
stringRedisTemplate.opsForList().leftPush("mylist", "aa");
stringRedisTemplate.opsForList().leftPush("mylist", "bb");
stringRedisTemplate.opsForList().leftPush("mylist", "cc");
}
}

如果使用RedisTemplate存储对象,则该对象需要序列化.而默认的使用jdk的序列化来进行,但是在redis中可读性就非常差.

因此可以使用json来做序列化.这就需要自定义一个RedisTemplate:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Employee> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Employee> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Employee.class));
return template;
}
}

这个新的RedisTemplate不仅配置了DefaultSerializer还做了泛型.此时原本的RedisTemplate就被顶替了,不想顶替改个名字就可以.

之前使用CacheConfigurationImportSelector导入了多个配置类,其中SimpleCacheConfiguration默认生效.而现在生效的是RedisCacheConfiguration,这个配置类添加了一个RedisCacheManager.

做的切换的原因是每个导入的配置类上都有一个@ConditionalOnMissingBean(CacheManager.class)注解.而导入这些配置类是按照上面那个数组的图来的,谁先进入容器添加了CacheManager谁就生效.

而RedisCacheManager会创建RedisCache.这里和默认的CurrentMapManager的逻辑是差不多的,只是在底层存储值时使用的是redis,以存储数据为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {

Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
Assert.notNull(value, "Value must not be null!");

execute(name, connection -> {

if (shouldExpireWithin(ttl)) {
connection.set(key, value, Expiration.from(ttl.toMillis(), TimeUnit.MILLISECONDS), SetOption.upsert());
} else {
connection.set(key, value);
}

return "OK";
});
}

问题在于RedisCacheManager使用的RedisTemplate中序列化还是基于jdk的.因此需要自定义一个CacheManager:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public RedisCacheManager employeeRedisCacheManager(RedisConnectionFactory redisConnectionFactory) {
//初始化一个RedisCacheWriter
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
//设置CacheManager的值序列化方式为json序列化
RedisSerializer<Object> jsonSerializer = new GenericJackson2JsonRedisSerializer();
RedisSerializationContext.SerializationPair<Object> pair = RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer);
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(pair);
//设置默认超过期时间是30秒
defaultCacheConfig.entryTtl(Duration.ofSeconds(30));
//初始化RedisCacheManager
return new RedisCacheManager(redisCacheWriter, defaultCacheConfig);

}

SpringBoot与消息

点对点模式和发布订阅模式

首先两种模式都只有一个发送者和多个接受者.点对点模式是接受者从消息队列中获取一个消息后,这个消息就移出队列,其它接受者收不到这个消息.而发布订阅模式则是多个接受者都能收到这个消息.

JMS(Java Message Service)JAVA消息服务:基于JVM消息代理的规范。ActiveMQ、HornetMQ是JMS实现

AMQP(Advanced Message Queuing Protocol: – 高级消息队列协议,也是一个消息代理的规范,兼容JMS.RabbitMQ是AMQP的实现.

image-20200728220926793

image-20200728220959795

RabbitMQ概念

  • Message:消息,由消息头和消息体组成.消息体是不透明的.消息头有一系列可选属性组成.这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出 该消息可能需要持久性存储)等。

  • Publisher:消息的生产者,也是一个向交换器发布消息的客户端应用程序。

  • Exchange:交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列。 Exchange有4种类型:direct(默认),fanout, topic, 和headers,不同类型的Exchange转发消息的策略有所区别

  • Queue:消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。生产者产生一个消息,发给消息服务器,由路由根据路由键判断将消息放到哪个消息队列.

  • Binding:绑定,用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连 接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。 Exchange 和Queue的绑定可以是多对多的关系。

  • Connection:网络连接,比如一个TCP链接

  • Channel:信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内的虚 拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这 些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所 以引入了信道的概念,以复用一条 TCP 连接。

  • Consumer:消息的消费者,表示一个从消息队列中取得消息的客户端应用程序。

  • Virtual Host:虚拟主机,表示一批交换器、消息队列和相关对象。虚拟主机是共享相同的身份认证和加 密环境的独立服务器域。每个 vhost 本质上就是一个 mini 版的 RabbitMQ 服务器,拥有 自己的队列、交换器、绑定和权限机制。vhost 是 AMQP 概念的基础,必须在连接时指定, RabbitMQ 默认的 vhost 是 / 。

  • Broker:表示消息队列服务器实体.

image-20200728222420238

另一张图:

image-20200728223611953

RabbitMQ的Exchange分发消息有四种不同的分发策略.

  1. direct:消息中的路由键(routing key)如果和 Binding 中的 binding key 一致, 交换器就将消息发到对应的队列中。路由键与队 列名完全匹配,如果一个队列绑定到交换机要求路由键为 “dog”,则只转发 routing key 标记为“dog”的消息,不会转 发“dog.puppy”,也不会转发“dog.guard”等等。它是完全 匹配、单播的模式。

  2. fanout:每个发到 fanout 类型交换器的消息都会分到所 有绑定的队列上去。fanout 交换器不处理路由键, 只是简单的将队列绑定到交换器上,每个发送 到交换器的消息都会被转发到与该交换器绑定 的所有队列上。很像子网广播,每台子网内的 主机都获得了一份复制的消息。fanout 类型转发 消息是最快的。

  3. topic:交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列 需要绑定到一个模式上。它将路由键和绑定键 的字符串切分成单词,这些单词之间用点隔开。 它同样也会识别两个通配符:符号“#”和符号 “*”。#匹配0个或多个单词, *匹配一个单词。

    image-20200728224348271

此外,第四种headers功能上和direct一样,匹配的是消息的header而不是路由键,性能较差,几乎不用.

在SpringBoot中使用RabbitMQ需要导入spring-boot-starter-amqp.

老套路有一个RabbitMQAutoConfiguration作为配置类,该配置类导入了一些组件:

image-20200729133105399

其中rabbitTemplate是类似于RedisTemplate的组件,基本操作都是依靠它.而amqpAdmin则是管理交换器,进行绑定等对RabbitMQ本身的操作.

image-20200729133324463

用send方法发送消息需要自己构造一个消息Message:

image-20200729133511619

需要一个字节数组和消息头.这样比较麻烦,还有另外一个convertAndSend方法.同样要交换器和路由键,不同的是发送的是一个对象,该方法会自动序列化并发送对象作为消息体.

下面是单播的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void contextLoads() {
var map = new HashMap<String, Object>();
map.put("msg", "first message");
map.put("data", Arrays.asList(new Object[]{"hello", "world", 123}));
rabbitTemplate.convertAndSend("exchange.direct", "company.news", map));
}

@Test
void receive() {
var messgae = rabbitTemplate.receiveAndConvert("company.news");
System.out.println(messgae.getClass());
System.out.println(messgae);
}

默认的序列化方式是jdk的,所以要自己配置converter:

1
2
3
4
5
6
7
@Configuration
public class MyAMQPConfig {
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}

广播的代码与此类似,只需要修改exchange名字即可.

此外使用@EnableRabbit(加在启动类上)和@RabbitListener(queues = "company.news")可以实现监听消息队列的功能.一旦company.news收到消息,加了@RabbitListener的方法就会执行.

1
2
3
4
@RabbitListener(queues = "company.news")
public void receive(Book book) {
System.out.println("收到消息"+book);
}

如果发送的消息的消息体是一个Book(转为了json),这里就能成功转换并打印Book信息.如果要获得完整的消息内容,则将参数设为Message类型.

如果需要在程序里创建exchange等,则要用到上面说的AmqpAdmin.

image-20200729145631368

declarexxx就是创建的,delete是删除的.其中创建exchange要传入一个exchange.改类是一个接口,有五个实现类,包括三个常用的,一个不常用的和一个自定义的:

image-20200729145834472

构造器可以传入名字,

image-20200729150231013

其它的Queue,Binding也是与此类似:

image-20200729151455142

SpringBoot与检索

image-20200729170151231

索引相当于数据库,类型相当于表,文档相当于记录,属性相当于子段.

ES的管理是Restful风格的.添加数据用put请求发送json,在请求体里带上数据:

image-20200729174131919

类似的删除是delete请求,带上id即可.

用get请求就是获取数据,head请求是查询是否有数据.

当使用put重复向某个id提交数据,那么数据被覆盖,且版本号会增加.

如果要搜素megacrop索引下的employee类型数据,则使用Get请求,URL这样写:localhost:9200/megacorp/employee/_search.注意此时清空postman的请求体.

如果要指定条件来查询,则需要在后面加上参数q:

localhost:9200/megacorp/employee/_search?q=age:25

此外还可以采用post请求方式,在请求体中加入用json写的查询条件,实现更复杂的筛选:

1
2
3
4
5
6
7
{
"query": {
"match": {
"age": 26
}
}
}

根据搜索的条件,ES能进行全文检索,列出相关结果,并不是严格按照条件检索.例如条件:

1
2
3
4
5
6
7
{
"query": {
"match": {
"about": "1 test 34"
}
}
}

结果是下图.ES不仅给出了精确结果,还列出了相关的结果.并给每个结果打了一个相关性分数.

image-20200729201704626

这是因为在全文检索中,"1 test 34"当做三个词语.如果想要这三个作为一个关键词,只需要把match改为match_phrase.这是就是短语检索.

整合

使用SpringBoot的自动配置要注意版本问题.自动导入的版本往往不能与自己使用的版本对应,因此要在pom文件中设置一下版本

1
2
3
4
<properties>
<java.version>14</java.version>
<elasticsearch.version>7.8.1</elasticsearch.version>
</properties>

我使用的是SpringBoot2.3.2版本.这里面使用spring-boot-starter-data-elasticsearch来自动导包,导的是elasticsearch-rest-high-level-client这个.这里变动的地方很多,注意区别.实际使用的是org\springframework\boot\autoconfigure\elasticsearch中的类(见下面代码),而不是org\springframework\boot\autoconfigure\data\elasticsearch中的.原本springboot2.1.x的ES的配置类是在data下的,现在又变了.这里面挺烦人的,再加上ES自己也在平凡变动,真的是头疼.总之,直接使用的配置相关都在这:

image-20200729233111392

可以看下图,自动配置类导入了三个静态内部类.第二个就是要用的客户端.

image-20200729232419060

在一个配置类中,让一个客户端添加到容器中:

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class ElasticSearchConfig {
@Bean
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(
RestClient.builder(
new HttpHost("localhost", 9200, "http")
)
);
}
}

然后测试管理索引(相当于管理库):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@SpringBootTest
class EsApplicationTests {
@Autowired
RestHighLevelClient restHighLevelClient;

@Test
void testCreateIndex() throws IOException {
//创建索引请求
CreateIndexRequest my_index = new CreateIndexRequest("my_index");
//执行请求
CreateIndexResponse createIndexResponse = restHighLevelClient.indices().create(my_index, RequestOptions.DEFAULT);
System.out.println(createIndexResponse);
}

@Test
void testExistIndex() throws IOException {
GetIndexRequest my_index = new GetIndexRequest("my_index");
boolean exists = restHighLevelClient.indices().exists(my_index, RequestOptions.DEFAULT);
System.out.println(exists);
}

@Test
void testDeleteIndex() throws IOException {
DeleteIndexRequest deleteIndexRequest = new DeleteIndexRequest("my_index");
AcknowledgedResponse acknowledgedResponse = restHighLevelClient.indices().delete(deleteIndexRequest, RequestOptions.DEFAULT);
System.out.println(acknowledgedResponse);
}
}

接下来演示了添加文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void testAddDocument() throws IOException {
HashMap<String, Object> jsonMap = new HashMap<String, Object>();
jsonMap.put("name", "aa");
jsonMap.put("age", 25);
IndexRequest indexRequest = new IndexRequest("my_index");
indexRequest.id("1");
// indexRequest.source(new User("bb", 26));
indexRequest.source(jsonMap);
IndexResponse indexResponse = restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT);
System.out.println(indexResponse);
System.out.println(indexResponse.status());
}

其它不写了,SpringBoot在ES的整合上做的太差了,一堆变动,搞不清楚2.1.x和2.2.x不同,2.3.x又不同.再加上SpringBoot里面命名有问题,版本支持比较老.总之这里就是一团浆糊.

另外有一篇参考:https://blog.csdn.net/chengyuqiang/article/details/102938266

SpringBoot与任务

异步任务

假定现在有一个请求,这个请求内的方法执行必须要三秒.如果是普通的同步方式,即单线程的,那么就必须等待三秒才能给浏览器响应:

1
2
3
4
5
6
7
8
public void hello() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("处理数据...");
}
1
2
3
4
5
@GetMapping("/hello")
public String hello(){
asyncService.hello();
return "success";
}

上面的代码就是.必须要三秒钟后浏览器才能显示success结果.

通常的办法是再开一个线程来处理数据,原来的线程先给客户端返回结果.但是这样写起来比较麻烦.在SpringBoot中可以使用异步注解降低难度:

1
2
3
4
5
6
7
8
9
@Async
public void hello() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("处理数据...");
}

只需要加一个@async注解,并在启动类上加一个开启异步注解的注解@EnableAsync即可.

定时任务

可以在某个方法上加上@Scheduled注解表示这是一个定时任务.需要一个参数cron写明执行时间.写法是一个字符串,按照秒,分,时,日,月,周几排列.例如”0 * * * * MON-FRI”表示每月的周一到周五的每一天,每小时,每分钟的0秒执行.

此外还需要在启动类上加@EnableScheduling注解.

image-20200730144149194

"0,10,20,30 * * * * MON-FRI"表示0秒,10秒,20秒,30秒都会执行.

"0-10 * * * * MON-FRI"表示0-10秒内每秒都执行.

"0/5 * * * * MON-FRI"表示从0开始每过5秒执行.

邮件发送

引入mail的starter即可,写起来很简单.不展开了.

SpringBoot与安全

导入SpringSecurity的starter.然后写SpringSecurity的配置类.

1
2
3
4
5
6
7
8
9
10
11
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//定制请求的授权规则
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/level1/**").hasRole("VIP1")
.antMatchers("/level2/**").hasRole("VIP2")
.antMatchers("/level3/**").hasRole("VIP3");
}
}

重要的是@EnableWebSecurity注解和继承WebSecurityConfigurerAdapter类.然后重写configure方法.规则的第一行表示”/“请求所有人都可以进入,下面三个请求则要有对应的角色才能进去.

既然需要角色,那么就需要登录功能.在方法里加上http.formLogin();则开启Security自动配置的登录功能.使用”/login”请求可以来到登录页.

如果访问没有权限的页面如上面的”/level1/**”请求,会自动跳转到登录页面.

有了登录就需要用户名和密码.这里用存在内存中的用户名密码演示:

1
2
3
4
5
6
7
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("zs").roles("VIP1").password("1234")
.and()
.withUser("lisi").roles("VIP2").password("123");
}

添加了两个user,并赋予了角色和密码.

此外还需要设置加密:

1
2
3
4
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

这里设置对密码不加密.

在第一个configure方法中加上http.logout().logoutSuccessUrl("/");可以实现注销功能.logoutSuccessUrl表示注销后跳转到哪个页面.

http.rememberMe可以开启记住我功能.