开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

面试题:如何保证消息不丢失?处理重复消息?消息有序性?消息堆

发表于 2020-07-21

核心点有很多,为了更贴合实际场景,我从常见的面试问题入手:

  • 如何保证消息不丢失?
  • 如何处理重复消息?
  • 如何保证消息的有序性?
  • 如何处理消息堆积?

当然在剖析这几个问题之前需要简单的介绍下什么是消息队列,消息队列常见的一些基本术语和概念。

接下来进入正文。

什么是消息队列

来看看维基百科怎么说的,顺带学学英语这波不亏:

In computer science, message queues and mailboxes are software-engineering components typically used for inter-process communication (IPC), or for inter-thread communication within the same process. They use a queue for messaging – the passing of control or of content. Group communication systems provide similar kinds of functionality.

翻译一下:在计算机科学领域,消息队列和邮箱都是软件工程组件,通常用于进程间或同一进程内的线程通信。它们通过队列来传递消息-传递控制信息或内容,群组通信系统提供类似的功能。

简单的概括下上面的定义:消息队列就是一个使用队列来通信的组件。

上面的定义没有错,但就现在而言我们日常所说的消息队列常常指代的是消息中间件,它的存在不仅仅只是为了通信这个问题。

为什么需要消息队列

从本质上来说是因为互联网的快速发展,业务不断扩张,促使技术架构需要不断的演进。

从以前的单体架构到现在的微服务架构,成百上千的服务之间相互调用和依赖。从互联网初期一个服务器上有 100 个在线用户已经很了不得,到现在坐拥10亿日活的微信。我们需要有一个「东西」来解耦服务之间的关系、控制资源合理合时的使用以及缓冲流量洪峰等等。

消息队列就应运而生了。它常用来实现:异步处理、服务解耦、流量控制。

异步处理

随着公司的发展你可能会发现你项目的请求链路越来越长,例如刚开始的电商项目,可以就是粗暴的扣库存、下单。慢慢地又加上积分服务、短信服务等。这一路同步调用下来客户可能等急了,这时候就是消息队列登场的好时机。

调用链路长、响应就慢了,并且相对于扣库存和下单,积分和短信没必要这么的 “及时”。因此只需要在下单结束那个流程,扔个消息到消息队列中就可以直接返回响应了。而且积分服务和短信服务可以并行的消费这条消息。

可以看出消息队列可以减少请求的等待,还能让服务异步并发处理,提升系统总体性能。

### 服务解耦

上面我们说到加了积分服务和短信服务,这时候可能又要来个营销服务,之后领导又说想做个大数据,又来个数据分析服务等等。

可以发现订单的下游系统在不断的扩充,为了迎合这些下游系统订单服务需要经常地修改,任何一个下游系统接口的变更可能都会影响到订单服务,这订单服务组可疯了,真 ·「核心」项目组。

所以一般会选用消息队列来解决系统之间耦合的问题,订单服务把订单相关消息塞到消息队列中,下游系统谁要谁就订阅这个主题。这样订单服务就解放啦!

### 流量控制

想必大家都听过「削峰填谷」,后端服务相对而言都是比较「弱」的,因为业务较重,处理时间较长。像一些例如秒杀活动爆发式流量打过来可能就顶不住了。因此需要引入一个中间件来做缓冲,消息队列再适合不过了。

网关的请求先放入消息队列中,后端服务尽自己最大能力去消息队列中消费请求。超时的请求可以直接返回错误。

当然还有一些服务特别是某些后台任务,不需要及时地响应,并且业务处理复杂且流程长,那么过来的请求先放入消息队列中,后端服务按照自己的节奏处理。这也是很 nice 的。

上面两种情况分别对应着生产者生产过快和消费者消费过慢两种情况,消息队列都能在其中发挥很好的缓冲效果。

### 注意

引入消息队列固然有以上的好处,但是多引入一个中间件系统的稳定性就下降一层,运维的难度抬高一层。因此要权衡利弊,系统是演进的。

消息队列基本概念

消息队列有两种模型:队列模型和发布/订阅模型。

队列模型

生产者往某个队列里面发送消息,一个队列可以存储多个生产者的消息,一个队列也可以有多个消费者, 但是消费者之间是竞争关系,即每条消息只能被一个消费者消费。

发布/订阅模型

为了解决一条消息能被多个消费者消费的问题,发布/订阅模型就来了。该模型是将消息发往一个Topic即主题中,所有订阅了这个 Topic 的订阅者都能消费这条消息。

其实可以这么理解,发布/订阅模型等于我们都加入了一个群聊中,我发一条消息,加入了这个群聊的人都能收到这条消息。那么队列模型就是一对一聊天,我发给你的消息,只能在你的聊天窗口弹出,是不可能弹出到别人的聊天窗口中的。

讲到这有人说,那我一对一聊天对每个人都发同样的消息不就也实现了一条消息被多个人消费了嘛。

是的,通过多队列全量存储相同的消息,即数据的冗余可以实现一条消息被多个消费者消费。RabbitMQ 就是采用队列模型,通过 Exchange 模块来将消息发送至多个队列,解决一条消息需要被多个消费者消费问题。

这里还能看到假设群聊里除我之外只有一个人,那么此时的发布/订阅模型和队列模型其实就一样了。

小结一下

队列模型每条消息只能被一个消费者消费,而发布/订阅模型就是为让一条消息可以被多个消费者消费而生的,当然队列模型也可以通过消息全量存储至多个队列来解决一条消息被多个消费者消费问题,但是会有数据的冗余。

发布/订阅模型兼容队列模型,即只有一个消费者的情况下和队列模型基本一致。

RabbitMQ 采用队列模型,RocketMQ和Kafka 采用发布/订阅模型。

接下来的内容都基于发布/订阅模型。

常用术语

一般我们称发送消息方为生产者 Producer,接受消费消息方为消费者Consumer,消息队列服务端为Broker。

消息从Producer发往Broker,Broker将消息存储至本地,然后Consumer从Broker拉取消息,或者Broker推送消息至Consumer,最后消费。

为了提高并发度,往往发布/订阅模型还会引入队列或者分区的概念。即消息是发往一个主题下的某个队列或者某个分区中。RocketMQ中叫队列,Kafka叫分区,本质一样。

例如某个主题下有 5 个队列,那么这个主题的并发度就提高为 5 ,同时可以有 5 个消费者并行消费该主题的消息。一般可以采用轮询或者 key hash 取余等策略来将同一个主题的消息分配到不同的队列中。

与之对应的消费者一般都有组的概念 Consumer Group, 即消费者都是属于某个消费组的。一条消息会发往多个订阅了这个主题的消费组。

假设现在有两个消费组分别是Group 1 和 Group 2,它们都订阅了Topic-a。此时有一条消息发往Topic-a,那么这两个消费组都能接收到这条消息。

然后这条消息实际是写入Topic某个队列中,消费组中的某个消费者对应消费一个队列的消息。

在物理上除了副本拷贝之外,一条消息在Broker中只会有一份,每个消费组会有自己的offset即消费点位来标识消费到的位置。在消费点位之前的消息表明已经消费过了。当然这个offset是队列级别的。每个消费组都会维护订阅的Topic下的每个队列的offset。

来个图看看应该就很清晰了。

基本上熟悉了消息队列常见的术语和一些概念之后,咱们再来看看消息队列常见的核心面试点。

如何保证消息不丢失

就我们市面上常见的消息队列而言,只要配置得当,我们的消息就不会丢。

先来看看这个图,

可以看到一共有三个阶段,分别是生产消息、存储消息和消费消息。我们从这三个阶段分别入手来看看如何确保消息不会丢失。

生产消息

生产者发送消息至Broker,需要处理Broker的响应,不论是同步还是异步发送消息,同步和异步回调都需要做好try-catch,妥善的处理响应,如果Broker返回写入失败等错误消息,需要重试发送。当多次发送失败需要作报警,日志记录等。

这样就能保证在生产消息阶段消息不会丢失。

存储消息

存储消息阶段需要在消息刷盘之后再给生产者响应,假设消息写入缓存中就返回响应,那么机器突然断电这消息就没了,而生产者以为已经发送成功了。

如果Broker是集群部署,有多副本机制,即消息不仅仅要写入当前Broker,还需要写入副本机中。那配置成至少写入两台机子后再给生产者响应。这样基本上就能保证存储的可靠了。一台挂了还有一台还在呢(假如怕两台都挂了..那就再多些)。

那假如来个地震机房机子都挂了呢?emmmmmm…大公司基本上都有异地多活。

那要是这几个地都地震了呢?emmmmmm…这时候还是先关心关心人吧。

消费消息

这里经常会有同学犯错,有些同学当消费者拿到消息之后直接存入内存队列中就直接返回给Broker消费成功,这是不对的。

你需要考虑拿到消息放在内存之后消费者就宕机了怎么办。所以我们应该在消费者真正执行完业务逻辑之后,再发送给Broker消费成功,这才是真正的消费了。

所以只要我们在消息业务逻辑处理完成之后再给Broker响应,那么消费阶段消息就不会丢失。

小结一下

可以看出,保证消息的可靠性需要三方配合。

生产者需要处理好Broker的响应,出错情况下利用重试、报警等手段。

Broker需要控制响应的时机,单机情况下是消息刷盘后返回响应,集群多副本情况下,即发送至两个副本及以上的情况下再返回响应。

消费者需要在执行完真正的业务逻辑之后再返回响应给Broker。

但是要注意消息可靠性增强了,性能就下降了,等待消息刷盘、多副本同步后返回都会影响性能。因此还是看业务,例如日志的传输可能丢那么一两条关系不大,因此没必要等消息刷盘再响应。

如果处理重复消息

我们先来看看能不能避免消息的重复。

假设我们发送消息,就管发,不管Broker的响应,那么我们发往Broker是不会重复的。

但是一般情况我们是不允许这样的,这样消息就完全不可靠了,我们的基本需求是消息至少得发到Broker上,那就得等Broker的响应,那么就可能存在Broker已经写入了,当时响应由于网络原因生产者没有收到,然后生产者又重发了一次,此时消息就重复了。

再看消费者消费的时候,假设我们消费者拿到消息消费了,业务逻辑已经走完了,事务提交了,此时需要更新Consumer offset了,然后这个消费者挂了,另一个消费者顶上,此时Consumer offset还没更新,于是又拿到刚才那条消息,业务又被执行了一遍。于是消息又重复了。

可以看到正常业务而言消息重复是不可避免的,因此我们只能从另一个角度来解决重复消息的问题。

关键点就是幂等。既然我们不能防止重复消息的产生,那么我们只能在业务上处理重复消息所带来的影响。

幂等处理重复消息

幂等是数学上的概念,我们就理解为同样的参数多次调用同一个接口和调用一次产生的结果是一致的。

例如这条 SQLupdate t1 set money = 150 where id = 1 and money = 100; 执行多少遍money都是150,这就叫幂等。

因此需要改造业务处理逻辑,使得在重复消息的情况下也不会影响最终的结果。

可以通过上面我那条 SQL 一样,做了个前置条件判断,即money = 100情况,并且直接修改,更通用的是做个version即版本号控制,对比消息中的版本号和数据库中的版本号。

或者通过数据库的约束例如唯一键,例如insert into update on duplicate key...。

或者记录关键的key,比如处理订单这种,记录订单ID,假如有重复的消息过来,先判断下这个ID是否已经被处理过了,如果没处理再进行下一步。当然也可以用全局唯一ID等等。

基本上就这么几个套路,真正应用到实际中还是得看具体业务细节。

如何保证消息的有序性

有序性分:全局有序和部分有序。

全局有序

如果要保证消息的全局有序,首先只能由一个生产者往Topic发送消息,并且一个Topic内部只能有一个队列(分区)。消费者也必须是单线程消费这个队列。这样的消息就是全局有序的!

不过一般情况下我们都不需要全局有序,即使是同步MySQL Binlog也只需要保证单表消息有序即可。

部分有序

因此绝大部分的有序需求是部分有序,部分有序我们就可以将Topic内部划分成我们需要的队列数,把消息通过特定的策略发往固定的队列中,然后每个队列对应一个单线程处理的消费者。这样即完成了部分有序的需求,又可以通过队列数量的并发来提高消息处理效率。

图中我画了多个生产者,一个生产者也可以,只要同类消息发往指定的队列即可。

如果处理消息堆积

消息的堆积往往是因为生产者的生产速度与消费者的消费速度不匹配。有可能是因为消息消费失败反复重试造成的,也有可能就是消费者消费能力弱,渐渐地消息就积压了。

因此我们需要先定位消费慢的原因,如果是bug则处理 bug ,如果是因为本身消费能力较弱,我们可以优化下消费逻辑,比如之前是一条一条消息消费处理的,这次我们批量处理,比如数据库的插入,一条一条插和批量插效率是不一样的。

假如逻辑我们已经都优化了,但还是慢,那就得考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数一定要增加,不然新增加的消费者是没东西消费的。一个Topic中,一个队列只会分配给一个消费者。

当然你消费者内部是单线程还是多线程消费那看具体场景。不过要注意上面提高的消息丢失的问题,如果你是将接受到的消息写入内存队列之后,然后就返回响应给Broker,然后多线程向内存队列消费消息,假设此时消费者宕机了,内存队列里面还未消费的消息也就丢了。

最后

上面的几个问题都是我们在使用消息队列的时候经常能遇到的问题,并且也是面试关于消息队列方面的核心考点。今天没有深入具体消息队列的细节,但是套路就是这么个套路,大方向上搞明白很关键。之后再接着写有关Kafka的源码分析文章,有兴趣的小伙伴请耐心等待。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringBoot事务使用及注意事项 0前言 1简介

发表于 2020-07-21

0.前言

感谢你百忙之中抽出时间阅读我这篇笔记。如果有错误的地方,劳烦批评指正。如果有地方和我持不同意见,很高兴和你一起探讨。最后,如果觉得这篇笔记对你有帮助的话,麻烦点个赞,谢谢~

1.简介

数据库事务的存在是为了保证“多个数据库操作”的“原子性”。举个最简单的银行汇款业务的场景,A向B汇款1000元。这个汇款动作主要有两个,①是A的银行账户上扣去1000元,②是B的银行账户上增加两千元。假如操作①成功了,而操作②失败了,这样A的账户上就白白少了1000元,而B的账户上却没有增加1000。所以我们需要用技术来保证操作①和操作②整体的原子性(即让操作①和②要么同时成功,要么同时失败),数据库的事务就是为此而生的。

在我们使用Springboot框架来开发时,Springboot已经帮我们封装好对底层数据库事务的操作,降低了我们学习、操作使用数据库事务的成本。这篇笔记就简单的记录下,在Springboot框架中(Springboot版本2.3.1.RELEASE,整合了mybatis,数据库使用MySQL)如何配置使用事务,以及在使用Springboot事务时遇见的坑。

2.Springboot实现事务支持的3种技术

Springboot想让某个方法使用数据库事务,只需要在对应的方法上加上@Transactional就可以。(有关于@Transactional注解的各个参数的配置,可以去网上查下,或者看下@Transactional源代码上的注释。)

Springboot有3种技术方式来实现让加了@Transactional的方法能使用数据库事务,分别是”动态代理(运行时织入)”、“编译期织入”和“类加载期织入”。这3种技术都是基于AOP(Aspect Oriented Programming,面向切面编程)思想。(在网上看了很多文章,大家伙儿都把AOP称之为一种技术,其实不然,AOP并不特指一种技术,而是一种编程范式,基于AOP编程范式,不同的编程语言都有自己的实现。)

下面我们就来讲讲,如何配置Springboot,让它分别基于“动态代理”和“编译期织入(使用AspectJ)”来实现对@Transactional开启数据库事务的支持。(基于”动态代理”的方式(支持@Transactional)在使用上会有些坑需要注意,在后文中会指出。)

2.1.基于动态代理支持@Transactional

2.1.1.配置

  1. pom中添加spring-tx依赖
1
2
3
4
5
复制代码<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>5.2.7.RELEASE</version>
</dependency>
  1. 通过注解的方式(也可以通过xml或者Java配置类的方式,不过没有使用注解的方式快)开启你的SpringBoot应用对事务的支持。使用@EnableTransactionManagement注解(来自于上面引入的spring-tx包)
1
2
3
4
5
6
7
8
9
复制代码@SpringBootApplication
@EnableTransactionManagement
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

}

Spring推荐的方式,是将@EnableTransactionManagement加到被@Configuration注解的类上,而@SpringBootApplication被@SpringBootConfiguration注解,@SpringBootConfiguration又被@Configuration,所以这里我们可以将@EnableTransactionManagement注解加到被@SpringBootApplication注解的类上。

2.1.2.测试

  1. 创建测试用的TransactionController
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
复制代码package com.huang.spring.practice.transaction;

import com.huang.spring.practice.user.dao.UserMapper;
import com.huang.spring.practice.user.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
*
*/
@RestController
@RequestMapping("/api/transaction")
public class TransactionController {

@Autowired
private UserMapper userMapper;

/**
* 测试Spring事务
* 插入一个新user之后,故意抛出运行时异常,Spring事务会回滚,插入user失败
*/
@Transactional
@PostMapping("/testTransactionThrowExcep")
public User testTransactionThrowExcep() {
User user = new User();
user.setName("小李");
user.setAge((short) 13);
user.setCity("北京");
userMapper.insert(user);

throw new RuntimeException("故意抛出一个运行时异常");
}

/**
* 测试Spring事务
* 成功插入user
*/
@Transactional
@PostMapping("/testTransactionNoExcep")
public User testTransactionNoExcep() {
User user = new User();
user.setName("小李");
user.setAge((short) 13);
user.setCity("北京");
userMapper.insert(user);
return user;
}

}
2. 先调用/api/transaction/testTransactionThrowExcep接口


由于我们在testTransactionThrowExcep接口最后抛出了一个RuntimeException,所以接口返回500.![image-20200712163611066](https://gitee.com/songjianzaina/juejin_p15/raw/master/img/95c5284852520469666c0d70192fd2b129bda84f7ba6755d21ca4c2e501584db)

因为有异常抛出,所以testTransactionThrowExcep接口内的事务会回滚,我们插入“小李”的用户信息就不会落到数据库中,查看数据库user表中现在的数据,不存在“小李”的数据,说明Spring的事务生效了

1
2
3
4
5
6
7
复制代码mysql> select id, name, age,city from user order by id desc;
+----+--------+-----+--------+
| id | name | age | city |
+----+--------+-----+--------+
| 1 | 小明 | 18 | 深圳 |
+----+--------+-----+--------+
1 row in set (0.00 sec)
3. 再调用/api/transaction/testTransactionNoExcep接口,接口成功执行,返回200HTTP状态码以及往数据库中插入的新用户“小李”的信息:![image-20200712164059844](https://gitee.com/songjianzaina/juejin_p15/raw/master/img/5eef866daecb7df6d7501650821df6dfab2d6660a2af31654f989ba8cd772b1c)

查看数据库user表中现在的数据,用户“小李”的信息成功的插入到了user表中:

1
2
3
4
5
6
7
8
复制代码mysql> select id, name, age,city from user order by id desc;
+----+--------+-----+--------+
| id | name | age | city |
+----+--------+-----+--------+
| 4 | 小李 | 13 | 北京 |
| 1 | 小明 | 18 | 深圳 |
+----+--------+-----+--------+
2 rows in set (0.00 sec)

2.1.3.事务失效的坑

在使用基于动态代理支持的@Transactional的时候,遇见了一些@Transactional不生效的场景,大家在使用的时候要特别注意,最好是写个单元测试,测试下自己添加了@Transactional的方法,事务是否如我们预期的生效了。

具体的@Transactional事务失效的场景可以参考这篇文章Spring事务失效的 8 大原因!,写的还是挺详细的。我就这篇文章中提到的“被@Transactional注解的方法不是public”以及“被@Transactional注解的方法是通过同一个类中的其他方法的自调用”这两个场景,事务之所以失效,还是因为“动态代理”的原因。上文中我们已经提到@Transactional注解是Spring框架基于AOP的编程范式,通过动态代理技术来实现被@Transactional注解的方法能实现数据库事务。假设类A中有个方法a被@Transactional注解,但是方法a的访问权限是private的时候,Spring框架将类A的实例注入到Spring容器中成为bean的过程中,使用“动态代理”将bean A增加的时候,会忽略private方法,因为在实例外部,你是无法通过实例对象直接去调用它的private方法,比如下面这个例子,TransactionService的updateUserAgeByIdTransactional方法是private,在TransactionController中是无法被直接调用的:

image-20200719120624072

image-20200719120655581

所以动态代理也就没法代理private方法,自然加在private方法上面的@Transactional注解就会失效了。

而“被@Transactional注解的方法是通过同一个类中的其他方法的自调用”时事务没法生效的问题,其实也是“动态代理”的原因,看下下面的例子,①就是我们所说的类内部方法自调用,它等价于②。所以当我们通过类内部方法自调用的时候,是通过这个类的实例(这个类在Spring中的真正的原始的没有被动态代理过的bean)去调用被@Transactional注解的方法,而不是通过被Spring用动态代理增强过(解析支持了@Transactional注解)之后的实例对象去调用,所以自然@Transactional注解无法生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public void updateUserAgeById(long userId, short age) {
updateUserAgeByIdTransactional(userId, age); //①
this.updateUserAgeByIdTransactional(userId, age); //②
}

@Transactional
public void updateUserAgeByIdTransactional(long userId, short age) {
User user = new User();
user.setId(userId);
user.setAge(age);
userMapper.updateByPrimaryKeySelective(user);
throw new RuntimeException();
}

但是,如果我们非要让@Transactional注解能放到private方法上、让类内部方法自调用时@Transactional能生效的话,我们可以采用“编译期织入”或“类加载期织入”的方式,在运行代码前,将我们的目标类的方法增强,无需管用“动态代理”实现时的种种限制。本文就接下来就讲下,如何使用AspectJ来实现在编译期对@Transactional注解的方法进行织入。

2.2.基于AspectJ编译期织入来支持@Transactional

AspectJ的编译期织入的原理,其实就是动态生成class字节码的技术,修改我们原本要生成的class文件,在其上添加我们想要的功能代码。

2.2.1.配置

将@EnableTransactionManagement中的mode设置为AdviceMode.ASPECTJ(默认为AdviceMode.PROXY,也就是我们的动态代理~)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码package com.huang.spring.practice;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.AdviceMode;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;

@SpringBootApplication(scanBasePackages = {"com.huang.*"})
//@EnableTransactionManagement
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@MapperScan({"com.huang.spring.practice"})
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

}

pom文件中加入相关依赖,以及配置AspectJ的maven插件(我们的项目是通过maven管理的)让项目在编译期间能通过AspectJ来修改、创建需要被织入的class文件:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
复制代码		<!-- aspectj代码织入 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.5</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>5.2.7.RELEASE</version>
</dependency>

<!-- 添加AspectJ插件 -->
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.11</version>
<configuration>
<aspectLibraries>
<aspectLibrary>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</aspectLibrary>
</aspectLibraries>
<complianceLevel>1.8</complianceLevel>
<source>1.8</source>
<target>1.8</target>
<showWeaveInfo>true</showWeaveInfo>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

2.2.2.测试

下面我们测试,将@Transactional注解添加到private方法上,并通过类内部自调用,看看事务能否生效,代码如下:

在TransactionController中提供要测试的接口/api/transaction/updateUserAgeById

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
复制代码package com.huang.spring.practice.transaction.controller;

import com.huang.spring.practice.transaction.service.TransactionService;
import com.huang.spring.practice.user.dao.UserMapper;
import com.huang.spring.practice.user.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/transaction")
public class TransactionController {

@Autowired
private TransactionService transactionService;

@PostMapping("/updateUserAgeById/{userId}/{age}")
public void updateUserAgeById(@PathVariable("userId") long userId, @PathVariable("age") short age) {
transactionService.updateUserAgeById(userId, age);
}

}

TransactionService.updateUserAgeById方法如下,通过类内部自调用,调用添加了@Transactional注解的private方法updateUserAgeByIdTransactional,这个方法会更新指定id的用户的age,并且在方法最后抛出RuntimeException。假如我们调用/api/transaction/updateUserAgeById之后,用户的age有被更新掉,说民事务没有生效,反之事务生效了。

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
29
复制代码package com.huang.spring.practice.transaction.service;

import com.huang.spring.practice.user.dao.UserMapper;
import com.huang.spring.practice.user.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PathVariable;

@Service
public class TransactionService {

@Autowired
private UserMapper userMapper;

public void updateUserAgeById(long userId, short age) {
this.updateUserAgeByIdTransactional(userId, age);
}

@Transactional
private void updateUserAgeByIdTransactional(long userId, short age) {
User user = new User();
user.setId(userId);
user.setAge(age);
userMapper.updateByPrimaryKeySelective(user);
throw new RuntimeException();
}

}

查看数据库的user表,id=1的用户“小明”的age是11:

image-20200719194643239

启动应用:

在IDEA内启动应用去测试的话,@Transactional注解的方法事务还是没有生效,推测IDEA拿来启动应用的那份“代码”没有经过AspectJ的编译期织入,平时聪明智能的IDEA在这个时候犯了傻。

所以我们要自己使用maven命令构建项目打出jar包,在maven构建我们项目的compile阶段的时候,会根据我们我们在pom文件中的配置,调用aspectj-maven-plugin,进行编译期织入:

1
2
3
> 复制代码maven package
>
>

maven构建完成之后,在项目的target目录下生成我们springboot应用的jar包:

image-20200720091255795

为了验证AspectJ已经将事务织入到使用了@Transactional的方法上,我们可以用反编译工具来反编译我们刚刚打出来的jar包,看看TransactionService.java是否已经被织入了。反编译工具,我使用的是“java-decompiler”,还是挺好用的,大家有兴趣可以去他们的官网下载来玩玩看。

反编译应用包之后可以看到TransactionService.java经过AspectJ插件处理之后生成了三个class文件

image-20200720091635049

查看TransactionService.class的代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
> 复制代码package com.huang.spring.practice.transaction.service;
>
> import com.huang.spring.practice.user.dao.UserMapper;
> import com.huang.spring.practice.user.dto.User;
> import java.io.PrintStream;
> import org.aspectj.lang.JoinPoint.StaticPart;
> import org.aspectj.runtime.internal.Conversions;
> import org.aspectj.runtime.reflect.Factory;
> import org.springframework.beans.factory.annotation.Autowired;
> import org.springframework.stereotype.Service;
> import org.springframework.transaction.annotation.Transactional;
> import org.springframework.transaction.aspectj.AbstractTransactionAspect;
> import org.springframework.transaction.aspectj.AnnotationTransactionAspect;
>
> @Service
> public class TransactionService
> {
> @Autowired
> private UserMapper userMapper;
> private static final JoinPoint.StaticPart ajc$tjp_0;
> private static final JoinPoint.StaticPart ajc$tjp_1;
>
> private static void ajc$preClinit()
> {
> Factory localFactory = new Factory("TransactionService.java", TransactionService.class);ajc$tjp_0 = localFactory.makeSJP("method-execution", localFactory.makeMethodSig("1", "testTransactionModifyDto", "com.huang.spring.practice.transaction.service.TransactionService", "", "", "", "com.huang.spring.practice.user.dto.User"), 17);ajc$tjp_1 = localFactory.makeSJP("method-execution", localFactory.makeMethodSig("1", "updateUserAgeByIdTransactional", "com.huang.spring.practice.transaction.service.TransactionService", "long:short", "userId:age", "", "void"), 38);
> }
>
> public void updateUserAgeById(long userId, short age)
> {
> updateUserAgeByIdTransactional(userId, age);
> updateUserAgeByIdTransactional(userId, age);
> }
>
> @Transactional
> public void updateUserAgeByIdTransactional(long userId, short age)
> {
> long l = userId;
> short s = age;
> Object[] arrayOfObject = new Object[3];
> arrayOfObject[0] = this;
> arrayOfObject[1] = Conversions.longObject(l);
> arrayOfObject[2] = Conversions.shortObject(s);
> AnnotationTransactionAspect.aspectOf().ajc$around$org_springframework_transaction_aspectj_AbstractTransactionAspect$1$2a73e96c(this, new TransactionService.AjcClosure3(arrayOfObject), ajc$tjp_1);
> }
>
> static final void updateUserAgeByIdTransactional_aroundBody2(TransactionService ajc$this, long userId, short age)
> {
> User user = new User();
> user.setId(Long.valueOf(userId));
> user.setAge(Short.valueOf(age));
> ajc$this.userMapper.updateByPrimaryKeySelective(user);
> throw new RuntimeException();
> }
>
> static {}
> }
>
>
>

可以看到TransactionService.class中的代码已经被AspectJ织入了,正如上文所说的,AspectJ正是使用了“动态生成class字节码”的技术,来帮我们在代码中指定的位置上自动修改生成class字节码,按照我们的期望“增强”代码。这确实释放了我们不少人力和减弱了开发难度,如果上面AspectJ自动生成的代码要让我们自己来手动来写的话,那可要累死了。

好,现在让我们来用打出来的jar包启动我们的应用。我们不在IDEA中启动项目,直接在本地电脑上使用java命令启动我们刚刚用maven构建出来的应用包:

1
2
3
> 复制代码java -jar spring.practice-0.0.1-SNAPSHOT.jar
>
>

应用启动后,调用接口将“小明”的age更新成66,接口返回500,因为我们接口内是有抛出RuntimeException的,下面就再去查看下数据库的user表,看看“小明”的age是否有被更新吧,从而能知道我们的@Transactional注解是否有生效,

image-20200719194813263

bingo~,刷新了user表的数据,小明的age还是11,说明我们的@Transactional注解生效了,AspectJ编译期注入的方式来支持的@Transactional注解的路子走通了,我们以后给方法添加@Transactiona就不用考虑方法的访问权限(private)以及调用该方法时是否是类内部自调用了!

image-20200719195016170

2.3要注意的坑

在使用Springboot+MyBatis+事务(@Transactional)的过程中,发现有个小坑:

在Spring事务中,从数据库查询某条数据,返回这条数据的java对象,这个java对象的A成员变量的值为a,然后修改这个java对象的A成员变量的值为b,但是修改完之后,不将修改后的结果更新到数据库中。然后在同一个事务中,再次查询这条数据,返回的这条数据的java对象的A成员变量的值居然是之前修改后的值b。具体测试代码如下:

2.3.1.1场景再现

  1. 在上文中提到的TransactionController增加一个接口testTransactionModifyDto
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
29
30
复制代码package com.huang.spring.practice.transaction.controller;

import com.huang.spring.practice.transaction.service.TransactionService;
import com.huang.spring.practice.user.dao.UserMapper;
import com.huang.spring.practice.user.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/transaction")
public class TransactionController {

@Autowired
private TransactionService transactionService;

@PostMapping("/testTransactionModifyDto")
public User testTransactionModifyDto() {

transactionService.testTransactionModifyDto();

User user2 = userMapper.selectByPrimaryKey(4L);
System.out.println("在事务外,从DB查询id为4的用户,然后打印他的age : " + user2.getAge());

return user2;
}

}
2. 创建TransactionService,在其中添加被事务@Transactional注解的testTransactionModifyDto()方法



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
29
30
31
复制代码package com.huang.spring.practice.transaction.service;

import com.huang.spring.practice.user.dao.UserMapper;
import com.huang.spring.practice.user.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TransactionService {

@Autowired
private UserMapper userMapper;

@Transactional
public User testTransactionModifyDto() {
User user = userMapper.selectByPrimaryKey(4L);

System.out.println("在事务中,从DB查询id为4的用户,然后打印他的age : " + user.getAge());

user.setAge((short) 80);
System.out.println("在事务中,将id为4的用户的age设置为80, 不执行update命令将其更新到数据库中。");


user = userMapper.selectByPrimaryKey(4L);
System.out.println("在事务中,从DB查询id为4的用户,然后再打印他的age : " + user.getAge());

return user;
}

}
3. 调用/api/transaction/testTransactionModifyDto接口,接口调用过程中打印的日志如下:
1
2
3
4
复制代码在事务中,从DB查询id为4的用户,然后打印他的age : 13
在事务中,将id为4的用户的age设置为80, 不执行update命令将其更新到数据库中。
在事务中,从DB查询id为4的用户,然后再打印他的age : 80
在事务外,从DB查询id为4的用户,然后打印他的age : 13

2.3.2.2.问题定位

啥也不说了,debug就完事了。

先debug进去红框中的这一行代码:

image-20200721075950012

一行行debug进来,发现在MyBatis的BaseExecutor.java代码的query方法中,图中1处,会去localCache中来尝试获取当前sql的执行结果,如果有当前sql的执行结果,则返回当前的结果(list)键见图中2处,如果list为null,则执行图中3处的queryFromDatabase方法在数据库中执行sql查询结果。

image-20200721080230130

我们再来看下这个用来查询缓存的CacheKey中存放了什么东西

image-20200721080811097

image-20200721081059534

如上图所示,这个cachekey中存放了要执行的sql的Mapper信息、sql语句以及sql的入参。所以大胆猜测:

Mybatis会在一个sql查询过之后,就会使用这个sql的“Mapper信息、sql语句以及sql的入参”作为缓存的key,将这个sql的执行结果缓存起来,然后再下一次执行的时候,如果有这个sql的执行结果缓存就直接拿来使用。

不过localCache里面保存的sql执行结果缓存肯定只是在一个一定的作用域里面生校的,否则在应用运行的过程中,我们每次执行下面这个sql,查询id=4的用户的信息,返回的查询结果如果都从localCache中获取,那每次的查询结果都会一样,这就乱了套了,所以某个sql在localCache中保存的执行结果缓存一定是在一个有限的作用域中生效的。

1
复制代码  User user = userMapper.selectByPrimaryKey(4L);

接下来我们就要搞明白localCache里的缓存是什么时候添加,以及什么时候被删除的。

我们可以看到localCache是一个叫做PerpetualCache的一个实例

image-20200721082909200

进入到PerpatualCache类中,可以看到,缓存是保存在其中的名字叫做cache的HashMap中

image-20200721083016857

我们在PerpatualCache类中操作cache变量的三个方法putObject、removeObject、clear上添加断点,然后重新再来debug一下,还是先debug下图中的红框的这个sql查询的过程。

image-20200721075950012

Debug到PerpatualCache类的putObject方法时,我们查看到方法的调用栈,可以很清楚的看到,在执行完了BaseExecutor的queryFromDatabase方法之后,就会将从db查询到的结果保存到localCache(PrepetualCache)中

image-20200721083443896

接着继续debug到下图中的第二个红框(和第一个红框中的sql是一样的),在这期间并没有调用到PerpatualCache的clear方法,说明第一个红框中的查询结果的缓存还被保存在PerpatualCache中,我们继续debug进入到下图中的第二个红框中

image-20200721083848601

第二个红框中的sql查询,如我们上面预料的一样,直接从localCache中拿到了第一个红框中的查询结果,并返回,

image-20200721084139213

到这里就能解释上文中提到的这个现象了:

在Spring事务中,从数据库查询某条数据,返回这条数据的java对象,这个java对象的A成员变量的值为a,然后修改这个java对象的A成员变量的值为b,但是修改完之后,不将修改后的结果更新到数据库中。然后在同一个事务中,再次查询这条数据,返回的这条数据的java对象的A成员变量的值居然是之前修改后的值b。

简答的画个图来描述下:

①:从数据库中查询到id=4的user数据,在JVM的heap上开辟一块内存空间来存放这个user数据。

②③:从数据库中查出数据之后,mybatis的将结果缓存到PrepetualCache(localCache)中,PrepetualCache中有指向user数据所在的内存地址的指针。

④:testTransactionModifyDto方法中的user变量指向第①步中从数据库里查出来并存放在heap中的user数据的内存地址。

⑤:用user变量将heap中的user数据的age改成80

⑥⑦:还在同一个事务中,事务还未被提交,所以当前线程的PrepetualCache中的缓存还未被清空,执行同一个sql,从PrepetualCache中获取到上一次查询到的user数据在heap中的内存地址,testTransactionModifyDto方法中的user变量再次指向这个内存地址

image-20200721152459797

然后继续debug,从事务里面的这个方法出来

image-20200721084429177

会调用到PerpetualCache的clear方法(机智的我们提前就在这打好了断点),清空所有的缓存。查看方法的调用栈可以看到调用了很多类的commit方法,是因为事务方法执行结束了,spring要将事务期间的sql提交到数据库中,这样我们在事务期间内的数据操作才会最终落到DB上。

image-20200721084520127

其实这个debug过程中还有很多东西可以讲,比如PrepetualCache是属于Mybatis框架的东西,但是,当属于Spring框架的事务结束之后,却会去调用Mybatis框架的PrepetualCache的clear方法,这里让Spring框架的代码调用Mybatis框架的代码是如何实现的呢?(针对这个问题我特意debug了下,发现是通过Mybatis的SqlSessionUtils和Spring的TransactionSynchronizationManager、TransactionSynchronizationUtils实现的,具体的过程要写成文字表达出来有点繁琐吃力。大伙儿有兴趣可以debug下看看,可以发现Spring的事务为了能让其他持久层框架整合进来,是提供一个接口TransactionSynchronization,第三方的持久层框架实现这个接口,并将自己的实现注册到Spring的TransactionSynchronizationManager中的synchronizations里面,这样Spring就可以通过第三方的持久层框架来处理事务里。类似的做法,只要留心注意,就不难发现很多项目软件都会采用。通过提供接口的方式,来将各种情况下的实现和框架代码解耦,然后根据实际的需要,往框架中注册相应的实现,这个编码的技巧(思想)我们可以多多体会,对于帮忙我们构建健壮、高可维护的项目是很有帮助的。)

2.3.2.3.处理方式

如果想避免mybatis的localCache带来的影响,让同一个SqlSession中sql(statment)的执行结果不被localCache缓存,可以将mybatis的localCacheScope设置为STATEMENT,详见myatbis官方文档:

Setting Description Valid Values Default
localCacheScope MyBatis uses local cache to prevent circular references and speed up repeated nested queries. By default (SESSION) all queries executed during a session are cached. If localCacheScope=STATEMENT local session will be used just for statement execution, no data will be shared between two different calls to the same SqlSession. SESSION STATEMENT

具体配置如下(这里使用的是Springboot配置文件的方式配置mybatis的配置,大伙儿也可以用Java代码的方式来配置):

1
2
3
复制代码mybatis:
configuration:
local-cache-scope: statement

亲测这样子设置了之后,localCache就不生效了。

如果各位不想将mybatis的localCache的作用域设置成statement,又想避免本文中2.3.1.1章节所描述的场景,则可以使用对象深拷贝的方式,具体代码如下:

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
复制代码@Transactional
public User testTransactionModifyDto() {
User user = userMapper.selectByPrimaryKey(4L);

System.out.println("在事务中,从DB查询id为4的用户,然后打印他的age : " + user.getAge());
/**
* 使用对象字节流的方式进行对象的深拷贝,
* 具体实现的代码不用自己写,网上很多开源的工具包可以拿来直接用,
* 我这里用的ObjectUtil是来自是hutool这个工具包,官网地址:https://www.hutool.cn/
*
* ObjectUtil.cloneByStream具体的源码很简单,实际上就是对ObjectOutputStream和ObjectIputStream的使用
*
* 需要注意的一点是用字节流的方式进行深拷贝的话,被拷贝的对象必须实现了Serializable接口,
* 否则无法进行序列化、反序列化,拷贝会失败。
*/
user = ObjectUtil.cloneByStream(user);

user.setAge((short) 80);
System.out.println("在事务中,将id为4的用户的age设置为80, 不执行update命令将其更新到数据库中。");

user = userMapper.selectByPrimaryKey(4L);
System.out.println("在事务中,从DB查询id为4的用户,然后再打印他的age : " + user.getAge());

return user;
}

修改后打印的结果为:

1
2
3
4
复制代码在事务中,从DB查询id为4的用户,然后打印他的age : 13
在事务中,将id为4的用户的age设置为80, 不执行update命令将其更新到数据库中。
在事务中,从DB查询id为4的用户,然后再打印他的age : 13
在事务外,从DB查询id为4的用户,然后打印他的age : 13

3.项目代码地址

github.com/ambition080…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

畅购商城(一):环境搭建 畅购商城(一):环境搭建

发表于 2020-07-21

畅购商城(一):环境搭建

好好学习,天天向上

本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/Da…,欢迎Star

  • 畅购商城(一):环境搭建
  • 畅购商城(二):分布式文件系统FastDFS
  • 畅购商城(三):商品管理
  • 畅购商城(四):Lua、OpenResty、Canal实现广告缓存与同步
  • 畅购商城(五):Elasticsearch实现商品搜索
  • 畅购商城(六):商品搜索
  • 畅购商城(七):Thymeleaf实现静态页
  • 畅购商城(八):微服务网关和JWT令牌
  • 畅购商城(九):Spring Security Oauth2
  • 畅购商城(十):购物车
  • 畅购商城(十一):订单
  • 畅购商城(十二):接入微信支付
  • 畅购商城(十三):秒杀系统「上」
  • 畅购商城(十四):秒杀系统「下」

代码:github.com/RobodLee/ch…

啰嗦几句

畅购商城是黑马的一个项目。要说这个项目有多难,跟着视频做肯定是没什么大问题了,但是可以让我知道一个项目的具体开发流程以及提高自己对于一系列框架使用的熟练度,这也是我做这个项目的目的。关于这个项目的资料我就不提供了,视频b站上面有,配套的资料也在视频下面的评论中,有需要的朋友直接到b站上面找就可以了。接下来我会用十几篇文章来记录一下整个项目的开发经过以及遇到的问题。

畅购商城项目介绍

畅购商城项目是一个B2C的电商网站,采用了微服务架构,并且使用了前后端分离的方式进行开发。

技术栈

上面这张图就是畅购商城使用到的技术栈,从图中可以看出,整个微服务的开发是基于SpringBoot的,OAuth2.0是用来进行授权操作的,JWT用来封装用户的授权信息,Spring AMQP是消息队列协议。然后就是一套Spring Cloud的微服务框架。

持久化技术栈选用了MyBatis+通用Mapper,但我不准备用通用Mapper,因为我想锻炼一下写SQL语句,平时SQL也没怎么写,就借此机会练习练习。还用到了SpringDataEs用来操作ElasticSearch,SpringDataRedis用来操作Redis。

数据库采用了MySQL,消息队列选用了RabbitMQ,还实现了MySQL读写分离。

支付接口就选择了微信支付。

技术架构

这张图是畅购商城的技术架构图,可以看到,先是使用了Nginx做负载均衡以及限流;紧跟着的就是微服务网关,是用来将请求路由到不同的微服务,网关也集成了限流和权限校验的功能。后面就是具体的微服务了,业务方面一共分为了7个微服务,微服务之间也可能会相互调用,采用了Feign来进行不同微服务之间的调用,一些JavaBean及工具类也被单独抽取了出来。一些公共组件微服务也被单独抽取了出来,比如Oauth2.0微服务,RabbitMQ微服务等。

Hystrix Dashboard作为监控中心,Eureka是微服务的注册中心。

数据支撑方面,搜索功能用的是ElasticSearch,文件系统采用的是FastDFS,数据库选用的当然是MySQL了,缓存用的是Redis。

这里面有很多我也没用过,具体是干什么的我也不是很清楚,介绍的就简单了点。

环境搭建

项目介绍得差不多了就开始搭建项目吧。

安装虚拟机,准备数据库

我们用到的MySQL数据库是安装在docker中的,docker安装在了CentOS上,这些都已经安装好了,我们只需要把黑马提供的虚拟机安装一下就可以了。安装过程很简单,但是安装完成后一定要记得改IP,因为虚拟机的静态IP和我们自己的电脑可能不在一个网段,改到一个网段。还有一个问题就是用Navicat连不上数据库,可能的原因是数据库密码不对,视频上说的是123456,可我试了是root;要么就是没有允许远程访问,开启就好;最后一个原因就是防火墙不允许我们访问3306端口,把防火墙关闭就OK了。

项目框架搭建

1. 创建父工程

在changgou目录下创建一个新的Module名为changou-parent作为整个项目的父工程:

在父工程里面不需要写代码,所以把src目录删掉。因为每个微服务工程都是SpringBoot的,所以在changgou-parent的pom文件中添加SpringBoot的起步依赖。然后再添加一些需要用到的依赖包,视频里还添加了swagger的依赖,但是暂时不准备用,等用的时候再添加。整个changgou-parent的pom文件的内容我贴在了下面:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.robod</groupId>
<artifactId>changgou-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<description>
畅购商城项目的父工程
</description>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.4.RELEASE</version>
</parent>

<properties>
<!-- 跳过测试 -->
<skipTests>true</skipTests>
</properties>

<!--依赖包-->
<dependencies>
<!--测试包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

<!--fastjson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.51</version>
</dependency>

</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

</project>

2. 其它几个公共模块搭建

在changgou-parent下面创建changgou-gateway、changgou-service、changgou-service-api、changgou-web四个Module,因为这几个是各个模块的父工程,所以也不用写代码,删除src目录,并且打pom包。

1
xml复制代码<packaging>pom</packaging>

3. Eureka微服务搭建

微服务工程都搭建完毕了,现在就需要有个注册中心去启动微服务,所以接下来就在changgou-parent下创建一个名为changgou-eureka的Module,要想开启eureka的服务,就需要添加相应的依赖包。

1
2
3
4
5
6
xml复制代码<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>

接下来在resource目录下添加配置文件application.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
yml复制代码server:
port: 7001 #端口号
eureka:
instance:
hostname: 127.0.0.1 #ip
client:
register-with-eureka: false #是否将自己注册到eureka中
fetch-registry: false #是否从eureka中获取信息
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
spring:
application:
name: eureka

最后在java包下添加一个启动类:com.robod.EurekaApplication:

1
2
3
4
5
6
7
8
9
java复制代码@SpringBootApplication
@EnableEurekaServer //开启Eureka服务
public class EurekaApplication {

public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class,args);
}

}

现在就来测试一下Eureka能不能启动成功,运行上面的代码,等项目启动起来,访问http://127.0.0.1:7001

成功出现了上面的界面,说明我们的注册中心已经搭建成功了👍

4. 创建common工程

不同的微服务里面都会使用到一些相同的工具类,我们将这些工具类抽取到一个单独的子工程里面,在changgou-parent下面新建一个Module叫做changgou-common,在pom文件中添加所需的依赖。

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
29
30
31
32
33
xml复制代码<dependencies>
<!--web起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis 使用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--eureka-client-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--openfeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--微信支付-->
<dependency>
<groupId>com.github.wxpay</groupId>
<artifactId>wxpay-sdk</artifactId>
<version>0.0.3</version>
</dependency>
<!--httpclient支持-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>

最后在com.robod.entity包下添加几个工具类(这些类在配套资料里面都有):

5. 数据库工程搭建

这个工程是将需要访问数据库的一些依赖进行一个汇总,没有代码,在chnaggou-parent下新建一个Module叫changgou-common-db,然后删除src目录,最后添加所需的依赖:

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
xml复制代码    <!--依赖-->
<dependencies>
<!--对changgou-common的依赖-->
<dependency>
<groupId>com.robod</groupId>
<artifactId>changgou-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--通用mapper起步依赖,我不需要,所以没有添加这个-->
<!-- <dependency>-->
<!-- <groupId>tk.mybatis</groupId>-->
<!-- <artifactId>mapper-spring-boot-starter</artifactId>-->
<!-- <version>2.0.4</version>-->
<!-- </dependency>-->
<!--MySQL数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--mybatis分页插件-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>

商品微服务工程

1. 商品微服务的JavaBean工程

前面我们创建了一个changgou-service-api用来管理所有的微服务工程的API抽取,现在我们就在changgou-service-api下创建一个changogu-service-goods-api工程用来管理商品微服务的JavaBean,为了简化代码,我们需要用到Lombok,将Lombok的依赖添加到changgou-service-api的pom文件中:

1
2
3
4
5
6
7
8
xml复制代码<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
<scope>provided</scope>
</dependency>
</dependencies>

然后将我们需要的一些JavaBean放到com.robod.goods.pojo包下。

2. 创建商品微服务工程changgou-service-goods

在changgou-service下面创建一个changgou-service-goods作为商品微服务工程,因为要使用连接数据库的一些东西,所以我们将changgou-common-db引入到changgou-service中:

1
2
3
4
5
xml复制代码<dependency>
<groupId>com.robod</groupId>
<artifactId>changgou-common-db</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

微服务工程自然少不了配置文件,在resources目录下创建application.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
yml复制代码server:
port: 18081
spring:
application:
name: goods
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.31.200:3306/changgou_goods?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: root
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:7001/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true

最后为这个工程创建一个启动类com.robod.GoodsApplication:

1
2
3
4
5
6
7
8
9
java复制代码@SpringBootApplication
@EnableEurekaClient //开启Eureka客户端
@MapperScan("com.robod.mapper") //开启包扫描
@EnableTransactionManagement //事务管理
public class GoodsApplication {
public static void main(String[] args) {
SpringApplication.run(GoodsApplication.class, args);
}
}

将项目启动起来,访问http://127.0.0.1:7001。

可以看到,商品微服务工程已经成功启动并且注册到Eureka中了。

3. 查询所有品牌功能实现

分别创建出Controller层,Service层,Dao层对应的类:

写出对应的代码:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
java复制代码@Repository("brandMapper")
public interface BrandMapper {
/**
* 查询所有的品牌信息
* @return
*/
@Select("select * from tb_brand")
public List<Brand> findAll();
}
-------------------------------------------------------
@Service("brandService")
@Transactional(rollbackFor = Exception.class) //异常回滚
public class BrandServiceImpl implements BrandService {

private final BrandMapper brandMapper;

public BrandServiceImpl(BrandMapper brandMapper) {
this.brandMapper = brandMapper;
}

@Override
public List<Brand> findAll() {
return brandMapper.findAll();
}
}
---------------------------------------------------------
@RestController
@RequestMapping("/brand")
@CrossOrigin
public class BrandController {

private final BrandService brandService;

public BrandController(BrandService brandService) {
this.brandService = brandService;
}

@GetMapping
public Result<List<Brand>> findAll() {
List<Brand> brands = brandService.findAll();
return new Result<>(true, StatusCode.OK,"查询成功",brands);
}
}

然后将项目运行起来,访问http://localhost:18081/brand

成功查询出所有的品牌信息,说明我们的环境搭建成功了,剩下的修改品牌,删除品牌等功能直接写就可以了,如果不行很有可能是代码有问题,不是环境问题。

4. 分页 + 条件查询

分页查询我们需要用到PageHelper,这个依赖我们前面已经添加过了。直接上代码:

1
2
3
4
5
java复制代码    public PageInfo<Brand> findPage(Brand brand, int page, int size) {
PageHelper.startPage(page,size);
List<Brand> brands = brandMapper.findList(brand);
return new PageInfo<>(brands);
}

很简单,先是PageHelper.startPage(page,size),page是需要获取第几页的数据,size是每页的数据条数;再调用方法拿到所有数据;最后使用一个PageInfo将数据封装起来即可。

条件查询就需要根据条件动态构建SQL语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码@SelectProvider(type = BrandMapperProvider.class, method = "findList")
public List<Brand> findList(Brand brand);

class BrandMapperProvider {
public String findList(Brand brand) {
StringBuilder builder = new StringBuilder("select * from tb_brand where ");
if (!StringUtils.isEmpty(brand.getName())) {
builder.append(" name like ").append("\"%").append(brand.getName()).append("%\" ");
}
if (!StringUtils.isEmpty(brand.getImage())) {
builder.append(" and image like ").append("\"%").append(brand.getImage()).append("%\" ");
}
if (!StringUtils.isEmpty(brand.getLetter())) {
builder.append(" and letter = ").append(" \"").append(brand.getLetter()).append("\" ");
}
if (brand.getSeq() != null) {
builder.append(" and seq = ").append(brand.getSeq());
}
System.out.println(builder.toString());
return builder.toString();
}
}

在BrandMapper中添加一个内部类BrandMapperProvider,并提供一个构建SQL语句的方法,最后在findList方法上面添加注解@SelectProvider即可。

测试一下:

成功得到了我们想要的结果。

全局异常处理

为每个方法都写异常处理代码实在是太麻烦了,可以用一个全局异常处理类去处理所有的异常。因为所有的微服务工程都依赖了changgou-common,所以我们可以直接在changgou-common工程下的com.robod.exception包下创建一个类BaseExceptionHandler。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@ControllerAdvice
public class BaseExceptionHandler {

/***
* 异常处理
* @param e
* @return
*/
@ExceptionHandler(value = Exception.class)
@ResponseBody
public Result error(Exception e) {
e.printStackTrace();
return new Result(false, StatusCode.ERROR, e.getMessage());
}
}

在类的上面添加@ControllerAdvice注解,在这里的作用是应用到所有@RequestMapping中,实现全局异常处理,该注解还可以实现全局数据绑定以及全局数据预处理的功能。在error方法上面添加@ExceptionHandler(value=Exception.class)注解,用于声明所需要捕获的异常的类型。最后在error方法中编写异常处理的代码。

小结

这篇文章主要介绍了畅购商城的架构以及环境的搭建,然后写了个商品微服务工程,并实现了对品牌表的增删查改功能。最后我们还实现了全局异常处理功能,下一篇文章就写一写分布式文件系统fastDFS环境的搭建过程以及遇到的一些问题。

码字不易,可以的话,给我来个点赞,收藏,关注

如果你喜欢我的文章,欢迎关注微信公众号 『 R o b o d 』

代码:github.com/RobodLee/ch…

本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/Da…,欢迎Star

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

架构师写的BUG,非比寻常

发表于 2020-07-21

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。

部门新来了个架构师,BAT背景,住在三环,开宝马上班,有车位。

小伙话不多,但一旦说话斩钉截铁,带着无法撼动的自信。原因就是,有他着数亿高并发经验,每一秒钟的请求,都是其他企业运行一年也无法企及的。这就让人非常羡慕,毕竟他靠这个比我赚的钱要多。

俗话说,要想在公司不出事故,那就不要写代码。干活多了容易出事,一身轻松无人问津,这就是现实。

但有时候还是要看成果的。新来的研发领导不懂技术,但他懂技术指标,所以就统计大家提交git的数量,如果git活动是一片绿色如A股,那就算过关了。

架构师思来想去,决定领一个并发量最高的需求:统计接口的平均响应时间和启动以来的请求数。

为什么说它的并发量高呢?这是因为,它是统计所有接口的,自然比每一个接口的请求量都要大。AOP代码一包,每个接口都得从他这里走一圈。

该我们的架构师上场了。代码如图。

架构师说,我的代码不需要做注释。所谓的注释,都是给垃圾代码用的。我深以为是,他明显是受到了Netflix公司的影响。

程序考虑到了高并发场景,使用了线程安全的ConcurrentHashMap,然后每次通过监控key取出相应的数据,然后在value上递增。这么简单的代码,确实不需要增加什么注释。

作为项目里并发量最高的代码,出于对高级架构师的信任,我们并不需要做什么代码review,也不需要做什么测试。大家都很忙,代码您呐,到线上遛一遛吧。

我建议你先找一找代码的问题,如果你发现了问题,那就比架构师还厉害;如果你没发现,也不证明你比架构师弱,没有什么好伤心的。

下面插一副图,阻断一下思维。

装B遭雷劈,线上运行一段时间后,内存溢出了。

大家吵吵个没完,毕竟xjjdog说过,内存溢出问题的排查周期很长,大约平均需要40天左右才能解决问题。在大家开始论证的时候,架构师偷偷的启动了Eclipse MAT。MAT用来分析内存问题是非常合适的,但前提是你需要把堆栈给捣鼓下来。

架构师会用jmap,最主要的是权限大,于是自己搞了一份拷贝到线下分析。

我能理解到他的心情,毕竟问题定位到自己的代码不是一件什么值得高兴的事情。他发现内存的堆里面,满满的全是MonitorKey和MonitorValue。

1
复制代码Monitor$MonitorKey@15aeb7ab

我和架构师关系比较好,于是他问我:咱们的接口是不是特别的多?

我说:不是啊,你别看访问量大,就这么个狗屁业务能有多少接口?几百个撑了天了。

他说:我在堆里发现了几千万个…

说完他就不言语了,因为他发现里面有不少是一样的接口。一定是参数的原因,所以他在代码里加了这个,把?后面的给截断了。

1
复制代码key = key.split("\\?")[0];

结果发布到线上,过不了多久内存又溢出了。这次终于引起了大牛们的注意,经过大家的分析,发现代码是忘了给MonitorKey重写equals和hashCode方法了。

我不禁脸红起来。作为好朋友,我不应该让他出这个丑。但我又是隐隐快乐的,因为他工资比我高。

所以这就是一个很大的问题。很多同学对HashMap的知识点对答如流,甚至还专门记忆了红黑树。但换一个方式去问,却又一脸懵逼。

其中一种问法是这样的:一个普通的对象,能够作为HashMap的key么?

答案显然是可以的,但需要注意重写hashCode和equals方法。如果忘记重写的话,大概率会造成内存泄漏。

很不幸,现实中忘记的案例很多。大牛架构师也会中招。

代码重写hashCode和equals方法后,线上就再也没发生过内存溢出。


等等,还没完。毕竟是架构师,仅仅这样一个bug还是证明不了水平的。架构师写的bug,肯定非比寻常。

这种事出现的多了,研发领导对技术的权威性就不再是那么感冒。我们决定从并发量最高的代码开始,进行一下代码review。

很不幸,架构师的visit代码出现问题了。虽然问题不是很大,但它毕竟是个问题。

在统计数据的时候,代码使用了ConcurrentHashMap,但它并没有什么卵用。
visit方法,首先拿出了key,然后判空,再塞值。这明显不是一个原子操作。

1
2
3
4
5
6
复制代码线程1:获取key为a的值
线程2:获取key为a的值
线程1:a为null,生成一个b
线程2:a为null,生成一个c
线程1:保存a=b
线程2:保存a=c

此时,B丢了。

业务可以忍受,但严谨的技术大牛们忍受不了,提出了修改的意见。

架构师说,给visit方法加个synchronized不就成了。

1
复制代码public synchronized void visit(String url, String desc, long timeCost)

我说不行。有更优雅的写法,效率更高。那就是使用putIfAbsent方法,代码改动如下:

1
2
3
4
5
6
复制代码MonitorKey key = new MonitorKey(url, desc);
MonitorValue value = monitors.putIfAbsent(key, new MonitorValue());
value = monitors.get(key);
value.count.getAndIncrement();
value.totalTime.getAndAdd(timeCost);
value.avgTime = value.totalTime.get() / value.count.get();

大家就这两种方式争论了起来。

技术总监托着腮想了半天,看了看争的面红耳赤的同学们,说:这就是我不放心你们的缘故。线上环境要尽量保持稳定性,做最小的变更。既然加个synchronized就能够很容易简单解决的问题,为啥不直接用呢?下面这种代码改动太大,有风险。

总监接着把头转向我:这个BUG非比寻常,为了让大家引以为戒,你来做整个事故的复盘。把问题的排查和得到的教训分享给大家,让大家向这种至简的架构看齐。我们平常的工作中,也要尽量以结果导向为主,用什么手段无所谓,能漂亮把事情办好就行。

这就是此篇文章的由来,我虚心受教,同时也明白自己的工资是涨不上去了。

你要是点个赞或者友情三连,或许还能安慰我一下下。​

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,​进一步交流。​

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

47 张图带你 MySQL 进阶!!!

发表于 2020-07-21

我们在 MySQL 入门篇主要介绍了基本的 SQL 命令、数据类型和函数,在局部以上知识后,你就可以进行 MySQL 的开发工作了,但是如果要成为一个合格的开发人员,你还要具备一些更高级的技能,下面我们就来探讨一下 MySQL 都需要哪些高级的技能

MySQL 存储引擎

存储引擎概述

数据库最核心的一点就是用来存储数据,数据存储就避免不了和磁盘打交道。那么数据以哪种方式进行存储,如何存储是存储的关键所在。所以存储引擎就相当于是数据存储的发动机,来驱动数据在磁盘层面进行存储。

MySQL 的架构可以按照三层模式来理解

存储引擎也是 MySQL 的组建,它是一种软件,它所能做的和支持的功能主要有

  • 并发
  • 支持事务
  • 完整性约束
  • 物理存储
  • 支持索引
  • 性能帮助

MySQL 默认支持多种存储引擎,来适用不同数据库应用,用户可以根据需要选择合适的存储引擎,下面是 MySQL 支持的存储引擎

  • MyISAM
  • InnoDB
  • BDB
  • MEMORY
  • MERGE
  • EXAMPLE
  • NDB Cluster
  • ARCHIVE
  • CSV
  • BLACKHOLE
  • FEDERATED

默认情况下,如果创建表不指定存储引擎,会使用默认的存储引擎,如果要修改默认的存储引擎,那么就可以在参数文件中设置 default-table-type,能够查看当前的存储引擎

1
复制代码show variables like 'table_type';

奇怪,为什么没有了呢?网上求证一下,在 5.5.3 取消了这个参数

可以通过下面两种方法查询当前数据库支持的存储引擎

1
复制代码show engines \g

在创建新表的时候,可以通过增加 ENGINE 关键字设置新建表的存储引擎。

1
复制代码create table cxuan002(id int(10),name varchar(20)) engine = MyISAM;

上图我们指定了 MyISAM 的存储引擎。

如果你不知道表的存储引擎怎么办?你可以通过 show create table 来查看

如果不指定存储引擎的话,从MySQL 5.1 版本之后,MySQL 的默认内置存储引擎已经是 InnoDB了。建一张表看一下

如上图所示,我们没有指定默认的存储引擎,下面查看一下表

可以看到,默认的存储引擎是 InnoDB。

如果你的存储引擎想要更换,可以使用

1
复制代码alter table cxuan003 engine = myisam;

来更换,更换完成后回显示 0 rows affected ,但其实已经操作成功

我们使用 show create table 查看一下表的 sql 就知道

存储引擎特性

下面会介绍几个常用的存储引擎以及它的基本特性,这些存储引擎是 **MyISAM、InnoDB、MEMORY 和 MERGE **

MyISAM

在 5.1 版本之前,MyISAM 是 MySQL 的默认存储引擎,MyISAM 并发性比较差,使用的场景比较少,主要特点是

  • 不支持事务操作,ACID 的特性也就不存在了,这一设计是为了性能和效率考虑的。
  • 不支持外键操作,如果强行增加外键,MySQL 不会报错,只不过外键不起作用。
  • MyISAM 默认的锁粒度是表级锁,所以并发性能比较差,加锁比较快,锁冲突比较少,不太容易发生死锁的情况。
  • MyISAM 会在磁盘上存储三个文件,文件名和表名相同,扩展名分别是 .frm(存储表定义)、.MYD(MYData,存储数据)、MYI(MyIndex,存储索引)。这里需要特别注意的是 MyISAM 只缓存索引文件,并不缓存数据文件。
  • MyISAM 支持的索引类型有 全局索引(Full-Text)、B-Tree 索引、R-Tree 索引

Full-Text 索引:它的出现是为了解决针对文本的模糊查询效率较低的问题。

B-Tree 索引:所有的索引节点都按照平衡树的数据结构来存储,所有的索引数据节点都在叶节点

R-Tree索引:它的存储方式和 B-Tree 索引有一些区别,主要设计用于存储空间和多维数据的字段做索引,目前的 MySQL 版本仅支持 geometry 类型的字段作索引,相对于 BTREE,RTREE 的优势在于范围查找。

  • 数据库所在主机如果宕机,MyISAM 的数据文件容易损坏,而且难以恢复。
  • 增删改查性能方面:SELECT 性能较高,适用于查询较多的情况

InnoDB

自从 MySQL 5.1 之后,默认的存储引擎变成了 InnoDB 存储引擎,相对于 MyISAM,InnoDB 存储引擎有了较大的改变,它的主要特点是

  • 支持事务操作,具有事务 ACID 隔离特性,默认的隔离级别是可重复读(repetable-read)、通过MVCC(并发版本控制)来实现的。能够解决脏读和不可重复读的问题。
  • InnoDB 支持外键操作。
  • InnoDB 默认的锁粒度行级锁,并发性能比较好,会发生死锁的情况。
  • 和 MyISAM 一样的是,InnoDB 存储引擎也有 .frm文件存储表结构 定义,但是不同的是,InnoDB 的表数据与索引数据是存储在一起的,都位于 B+ 数的叶子节点上,而 MyISAM 的表数据和索引数据是分开的。
  • InnoDB 有安全的日志文件,这个日志文件用于恢复因数据库崩溃或其他情况导致的数据丢失问题,保证数据的一致性。
  • InnoDB 和 MyISAM 支持的索引类型相同,但具体实现因为文件结构的不同有很大差异。
  • 增删改查性能方面,果执行大量的增删改操作,推荐使用 InnoDB 存储引擎,它在删除操作时是对行删除,不会重建表。

MEMORY

MEMORY 存储引擎使用存在内存中的内容来创建表。每个 MEMORY 表实际只对应一个磁盘文件,格式是 .frm。 MEMORY 类型的表访问速度很快,因为其数据是存放在内存中。默认使用 HASH 索引。

MERGE

MERGE 存储引擎是一组 MyISAM 表的组合,MERGE 表本身没有数据,对 MERGE 类型的表进行查询、更新、删除的操作,实际上是对内部的 MyISAM 表进行的。MERGE 表在磁盘上保留两个文件,一个是 .frm 文件存储表定义、一个是 .MRG 文件存储 MERGE 表的组成等。

选择合适的存储引擎

在实际开发过程中,我们往往会根据应用特点选择合适的存储引擎。

  • MyISAM:如果应用程序通常以检索为主,只有少量的插入、更新和删除操作,并且对事物的完整性、并发程度不是很高的话,通常建议选择 MyISAM 存储引擎。
  • InnoDB:如果使用到外键、需要并发程度较高,数据一致性要求较高,那么通常选择 InnoDB 引擎,一般互联网大厂对并发和数据完整性要求较高,所以一般都使用 InnoDB 存储引擎。
  • MEMORY:MEMORY 存储引擎将所有数据保存在内存中,在需要快速定位下能够提供及其迅速的访问。MEMORY 通常用于更新不太频繁的小表,用于快速访问取得结果。
  • MERGE:MERGE 的内部是使用 MyISAM 表,MERGE 表的优点在于可以突破对单个 MyISAM 表大小的限制,并且通过将不同的表分布在多个磁盘上, 可以有效地改善 MERGE 表的访问效率。

选择合适的数据类型

我们会经常遇见的一个问题就是,在建表时如何选择合适的数据类型,通常选择合适的数据类型能够提高性能、减少不必要的麻烦,下面我们就来一起探讨一下,如何选择合适的数据类型。

CHAR 和 VARCHAR 的选择

char 和 varchar 是我们经常要用到的两个存储字符串的数据类型,char 一般存储定长的字符串,它属于固定长度的字符类型,比如下面

值 char(5) 存储字节
‘’ ‘ ‘ 5个字节
‘cx’ ‘cx ‘ 5个字节
‘cxuan’ ‘cxuan’ 5个字节
‘cxuan007’ ‘cxuan’ 5个字节

可以看到,不管你的值写的是什么,一旦指定了 char 字符的长度,如果你的字符串长度不够指定字符的长度的话,那么就用空格来填补,如果超过字符串长度的话,只存储指定字符长度的字符。

这里注意一点:如果 MySQL 使用了非 严格模式的话,上面表格最后一行是可以存储的。如果 MySQL 使用了 严格模式 的话,那么表格上面最后一行存储会报错。

如果使用了 varchar 字符类型,我们来看一下例子

值 varchar(5) 存储字节
‘’ ‘’ 1个字节
‘cx’ ‘cx ‘ 3个字节
‘cxuan’ ‘cxuan’ 6个字节
‘cxuan007’ ‘cxuan’ 6个字节

可以看到,如果使用 varchar 的话,那么存储的字节将根据实际的值进行存储。你可能会疑惑为什么 varchar 的长度是 5 ,但是却需要存储 3 个字节或者 6 个字节,这是因为使用 varchar 数据类型进行存储时,默认会在最后增加一个字符串长度,占用1个字节(如果列声明的长度超过255,则使用两个字节)。varchar 不会填充空余的字符串。

一般使用 char 来存储定长的字符串,比如身份证号、手机号、邮箱等;使用 varchar 来存储不定长的字符串。由于 char 长度是固定的,所以它的处理速度要比 VARCHAR 快很多,但是缺点是浪费存储空间,但是随着 MySQL 版本的不断演进,varchar 数据类型的性能也在不断改进和提高,所以在许多应用中,VARCHAR 类型更多的被使用。

在 MySQL 中,不同的存储引擎对 CHAR 和 VARCHAR 的使用原则也有不同

  • MyISAM:建议使用固定长度的数据列替代可变长度的数据列,也就是 CHAR
  • MEMORY:使用固定长度进行处理、CHAR 和 VARCHAR 都会被当作 CHAR 处理
  • InnoDB:建议使用 VARCHAR 类型

TEXT 与 BLOB

一般在保存较少的文本的时候,我们会选择 CHAR 和 VARCHAR,在保存大数据量的文本时,我们往往选择 TEXT 和 BLOB;TEXT 和 BLOB 的主要差别是 BLOB 能够保存二进制数据;而 TEXT 只能保存字符数据,TEXT 往下细分有

  • TEXT
  • MEDIUMTEXT
  • LONGTEXT

BLOB 往下细分有

  • BLOB
  • MEDIUMBLOB
  • LONGBLOB

三种,它们最主要的区别就是存储文本长度不同和存储字节不同,用户应该根据实际情况选择满足需求的最小存储类型,下面主要对 BLOB 和 TEXT 存在一些问题进行介绍

TEXT 和 BLOB 在删除数据后会存在一些性能上的问题,为了提高性能,建议使用 OPTIMIZE TABLE 功能对表进行碎片整理。

也可以使用合成索引来提高文本字段(BLOB 和 TEXT)的查询性能。合成索引就是根据大文本(BLOB 和 TEXT)字段的内容建立一个散列值,把这个值存在对应列中,这样就能够根据散列值查找到对应的数据行。一般使用散列算法比如 md5() 和 SHA1() ,如果散列算法生成的字符串带有尾部空格,就不要把它们存在 CHAR 和 VARCHAR 中,下面我们就来看一下这种使用方式

首先创建一张表,表中记录 blob 字段和 hash 值

向 cxuan005 中插入数据,其中 hash 值作为 info 的散列值。

然后再插入两条数据

插入一条 info 为 cxuan005 的数据

如果想要查询 info 为 cxuan005 的数据,可以通过查询 hash 列来进行查询

这是合成索引的例子,如果要对 BLOB 进行模糊查询的话,就要使用前缀索引。

其他优化 BLOB 和 TEXT 的方式:

  • 非必要的时候不要检索 BLOB 和 TEXT 索引
  • 把 BLOB 或 TEXT 列分离到单独的表中。

浮点数和定点数的选择

浮点数指的就是含有小数的值,浮点数插入到指定列中超过指定精度后,浮点数会四舍五入,MySQL 中的浮点数指的就是 float 和 double,定点数指的是 decimal,定点数能够更加精确的保存和显示数据。下面通过一个示例讲解一下浮点数精确性问题

首先创建一个表 cxuan006 ,只为了测试浮点数问题,所以这里我们选择的数据类型是 float

然后分别插入两条数据

然后执行查询,可以看到查询出来的两条数据执行的舍入不同

为了清晰的看清楚浮点数与定点数的精度问题,再来看一个例子

先修改 cxuan006 的两个字段为相同的长度和小数位数

然后插入两条数据

执行查询操作,可以发现,浮点数相较于定点数来说,会产生误差

日期类型选择

在 MySQL 中,用来表示日期类型的有 DATE、TIME、DATETIME、TIMESTAMP,在

138 张图带你 MySQL 入门

这篇文中介绍过了日期类型的区别,我们这里就不再阐述了。下面主要介绍一下选择

  • TIMESTAMP 和时区相关,更能反映当前时间,如果记录的日期需要让不同时区的人使用,最好使用 TIMESTAMP。
  • DATE 用于表示年月日,如果实际应用值需要保存年月日的话就可以使用 DATE。
  • TIME 用于表示时分秒,如果实际应用值需要保存时分秒的话就可以使用 TIME。
  • YEAR 用于表示年份,YEAR 有 2 位(最好使用4位)和 4 位格式的年。 默认是4位。如果实际应用只保存年份,那么用 1 bytes 保存 YEAR 类型完全可以。不但能够节约存储空间,还能提高表的操作效率。

MySQL 字符集

下面来认识一下 MySQL 字符集,简单来说字符集就是一套文字符号和编码、比较规则的集合。1960 年美国标准化组织 ANSI 发布了第一个计算机字符集,就是著名的 ASCII(American Standard Code for Information Interchange) 。自从 ASCII 编码后,每个国家、国际组织都研究了一套自己的字符集,比如 ISO-8859-1、GBK 等。

但是每个国家都使用自己的字符集为移植性带来了很大的困难。所以,为了统一字符编码,国际标准化组织(ISO) 指定了统一的字符标准 - Unicode 编码,它容纳了几乎所有的字符编码。下面是一些常见的字符编码

字符集 是否定长 编码方式
ASCII 是 单字节 7 位编码
ISO-8859-1 是 单字节 8 位编码
GBK 是 双字节编码
UTF-8 否 1 - 4 字节编码
UTF-16 否 2 字节或 4 字节编码
UTF-32 是 4 字节编码

对数据库来说,字符集是很重要的,因为数据库存储的数据大多数都是各种文字,字符集对数据库的存储、性能、系统的移植来说都非常重要。

MySQL 支持多种字符集,可以使用 show character set; 来查看所有可用的字符集

或者使用

1
复制代码select character_set_name, default_collate_name, description, maxlen from information_schema.character_sets;

来查看。

使用 information_schema.character_set 来查看字符集和校对规则。

索引的设计和使用

我们上面介绍到了索引的几种类型并对不同的索引类型做了阐述,阐明了优缺点等等,下面我们从设计角度来聊一下索引,关于索引,你必须要知道的一点就是:索引是数据库用来提高性能的最常用工具。

索引概述

所有的 MySQL 类型都可以进行索引,对相关列使用索引是提高 SELECT 查询性能的最佳途径。MyISAM 和 InnoDB 都是使用 BTREE 作为索引,MySQL 5 不支持函数索引,但是支持 前缀索引。

前缀索引顾名思义就是对列字段的前缀做索引,前缀索引的长度和存储引擎有关系。MyISAM 前缀索引的长度支持到 1000 字节,InnoDB 前缀索引的长度支持到 767 字节,索引值重复性越低,查询效率也就越高。

在 MySQL 中,主要有下面这几种索引

  • 全局索引(FULLTEXT):全局索引,目前只有 MyISAM 引擎支持全局索引,它的出现是为了解决针对文本的模糊查询效率较低的问题,并且只限于 CHAR、VARCHAR 和 TEXT 列。
  • 哈希索引(HASH):哈希索引是 MySQL 中用到的唯一 key-value 键值对的数据结构,很适合作为索引。HASH 索引具有一次定位的好处,不需要像树那样逐个节点查找,但是这种查找适合应用于查找单个键的情况,对于范围查找,HASH 索引的性能就会很低。默认情况下,MEMORY 存储引擎使用 HASH 索引,但也支持 BTREE 索引。
  • B-Tree 索引:B 就是 Balance 的意思,BTree 是一种平衡树,它有很多变种,最常见的就是 B+ Tree,它被 MySQL 广泛使用。
  • R-Tree 索引:R-Tree 在 MySQL 很少使用,仅支持 geometry 数据类型,支持该类型的存储引擎只有MyISAM、BDb、InnoDb、NDb、Archive几种,相对于 B-Tree 来说,R-Tree 的优势在于范围查找。

索引可以在创建表的时候进行创建,也可以单独创建,下面我们采用单独创建的方式,我们在 cxuan004 上创建前缀索引

我们使用 explain 进行分析,可以看到 cxuan004 使用索引的情况

如果不想使用索引,可以删除索引,索引的删除语法是

索引设计原则

创建索引的时候,要尽量考虑以下原则,便于提升索引的使用效率。

  • 选择索引位置,选择索引最合适的位置是出现在 where 语句中的列,而不是 select 关键字后的选择列表中的列。
  • 选择使用唯一索引,顾名思义,唯一索引的值是唯一的,可以更快速的确定某条记录,例如学生的学号就适合使用唯一性索引,而学生的性别则不适合使用,因为不管搜索哪个值,都差不多有一半的行。
  • 为经常使用的字段建立索引,如果某个字段经常用作查询条件,那么这个字段的查询速度在极大程度上影响整个表的查询速度,因此为这样的字段建立索引,可以提高整个表的查询速度。
  • 不要过度索引,限制索引数目,索引的数目不是越多越好,每个索引都会占据磁盘空间,索引越多,需要的磁盘空间就越大。
  • 尽量使用前缀索引,如果索引的值很长,那么查询速度会受到影响,这个时候应该使用前缀索引,对列的某几个字符进行索引,可以提高检索效率。
  • 利用最左前缀,在创建一个 n 列的索引时,实际上是创建了 MySQL 可利用的 n 个索引。多列索引可以起到几个索引的作用,利用索引最左边的列来匹配行,这样的列称为最左前缀。
  • 对于使用 InnoDB 存储引擎的表来说,记录会按照一定的顺序保存。如果有明确的主键定义,那么会按照主键的顺序进行保存;如果没有主键,但是有唯一索引,那么就按照唯一索引的顺序进行保存。如果既没有主键又没有唯一索引,那么表中会自动生成一个内部列,按照这个列的顺序进行保存。一般来说,使用主键的顺序是最快的
  • 删除不再使用或者很少使用的索引

视图

MySQL 从 5.0 开始就提供了视图功能,下面我们对视图功能进行介绍。

什么是视图

视图的英文名称是 view,它是一种虚拟存在的表。视图对于用户来说是透明的,它并不在数据库中实际存在,视图是使用数据库行和列动态组成的表,那么视图相对于数据库表来说,优势体现在哪里?

视图相对于普通的表来说,优势包含下面这几项

  • 使用视图可以简化操作:使用视图我们不用关注表结构的定义,我们可以把经常使用的数据集合定义成视图,这样能够简化操作。
  • 安全性:用户对视图不可以随意的更改和删除,可以保证数据的安全性。
  • 数据独立性:一旦视图的结构 确定了, 可以屏蔽表结构变化对用户的影响, 数据库表增加列对视图没有影响;具有一定的独立性

对视图的操作

视图的操作包括创建或者修改视图、删除视图以及查看视图定义。

创建或修改视图

使用 create view 来创建视图

为了演示功能,我们先创建一张表 product 表,有三个字段,id,name,price,下面是建表语句

1
复制代码create table product(id int(11),name varchar(20),price float(10,2));

然后我们向其中插入几条数据

1
复制代码insert into product values(1, "apple","3.5"),(2,"banana","4.2"),(3,"melon","1.2");

插入完成后的表结构如下

然后我们创建视图

1
复制代码create view v1 as select * from product;

然后我们查看一下 v1 视图的结构

可以看到我们把 product 中的数据放在了视图中,也相当于是创建了一个 product 的副本,只不过这个副本跟表无关。

视图使用

1
复制代码show tables;

也能看到所有的视图。

删除视图的语法是

1
复制代码drop view v1;

能够直接进行删除。

视图还有其他操作,比如查询操作

你还可以使用

1
复制代码describe v1;

查看表结构

更新视图

1
复制代码update v1 set name = "grape" where id = 1;

存储过程

MySQL 从 5.0 开始起就支持存储过程和函数了。

那么什么是存储过程呢?

存储过程是在数据库系统中完成一组特定功能的 SQL 语句集,它存储在数据库系统中,一次编译后永久有效。那么使用存储过程有什么优点呢?

  • 使用存储过程具有可封装性,能够隐藏复杂的 SQL 逻辑。
  • 存储过程可以接收参数,并返回结果
  • 存储过程性能非常高,一般用于批量执行语句

使用存储过程有什么缺点?

  • 存储过程编写复杂
  • 存储过程对数据库的依赖性比较强,可移植性比较差

存储过程使用

存储过程创建

在认识到存储过程是什么之后,我们就来使用一下存储过程,这里需要先了解一个小技巧,也就是 delimiter 的用法,delimiter 用于自定义结束符,什么意思呢,如果你使用

1
复制代码delimiter ?

的话,那么你在 sql 语句末使用 ; 是不能使 SQL 语句执行的,不信?我们可以看下

可以看到,我们在 SQL 语句的行末使用了 ; 但是我们却没有看到执行结果。下面我们使用

1
复制代码delimiter ;

恢复默认的执行条件再来看下

我们创建存储过程首先要把 ; 替换为 ?,下面是一个存储过程的创建语句

1
2
3
4
5
复制代码mysql> delimiter ?
mysql> create procedure sp_product()
-> begin
-> select * from product;
-> end ?

存储过程实际上是一种函数,所以创建完毕后,我们可以使用 call 方法来调用这个存储过程

因为我们上面定义了使用 delimiter ? 来结尾,所以这里也应该使用。

存储过程也可以接受参数,比如我们定义一种接收参数的情况

然后我们使用 call 调用这个存储过程

可以看到,当我们调用 id = 2 的时候,存储过程的 SQL 语句相当于是

1
复制代码select * from product where id = 2;

所以只查询出 id = 2 的结果。

存储过程删除

一次只能删除一个存储过程,删除存储过程的语法如下

1
复制代码drop procedure sp_product ;

直接使用 sp_product 就可以了,不用加 ()。

存储过程查看

存储过程创建后,用户可能需要需要查看存储过程的状态等信息,便于了解存储过程的基本情况

我们可以使用

1
复制代码show create procedure proc_name;

变量的使用

在 MySQL 中,变量可分为两大类,即系统变量和用户变量,这是一种粗略的分法。但是根据实际应用又被细化为四种类型,即局部变量、用户变量、会话变量和全局变量。

用户变量

用户变量是基于会话变量实现的,可以暂存,用户变量与连接有关,也就是说一个客户端定义的变量不能被其他客户端使用看到。当客户端退出时,链接会自动释放。我们可以使用 set 语句设置一个变量

1
复制代码set @myId = "cxuan";

然后使用 select 查询条件可以查询出我们刚刚设置的用户变量

用户变量是和客户端有关系,当我们退出后,这个变量会自动消失,现在我们退出客户端

1
复制代码exit

现在我们重新登陆客户端,再次使用 select 条件查询

发现已经没有这个 @myId 了。

局部变量

MySQL 中的局部变量与 Java 很类似 ,Java 中的局部变量是 Java 所在的方法或者代码块,而 MySQL 中的局部变量作用域是所在的存储过程。MySQL 局部变量使用 declare 来声明。

会话变量

服务器会为每个连接的客户端维护一个会话变量。可以使用

1
复制代码show session variables;

显示所有的会话变量。

我们可以手动设置会话变量

1
2
3
4
5
复制代码set session auto_increment_increment=1;

或者使用

set @@session.auto_increment_increment=2;

然后进行查询,查询会话变量使用

或者使用

全局变量

当服务启动时,它将所有全局变量初始化为默认值。其作用域为 server 的整个生命周期。

可以使用

1
复制代码show global variables;

查看全局变量

可以使用下面这两种方式设置全局变量

1
2
3
4
5
复制代码set global sql_warnings=ON;        -- global不能省略

/** 或者 **/

set @@global.sql_warnings=OFF;

查询全局变量时,可以使用

或者是

MySQL 流程语句介绍

MySQL 支持下面这些控制语句

  • IF

IF 用于实现逻辑判断,满足不同条件执行不同的 SQL 语句

1
复制代码IF ... THEN ...
  • CASE

CASE 实现比 IF 稍微复杂,语法如下

1
2
3
4
复制代码CASE ...
WHEN ... THEN...
...
END CASE

CASE 语句也可以使用 IF 来完成

  • LOOP

LOOP 用于实现简单的循环

1
2
3
复制代码label:LOOP
...
END LOOP label;

如果 ... 中不写 SQL 语句的话,那么就是一个简单的死循环语句

  • LEAVE

用来表示从标注的流程构造中退出,通常和 BEGIN…END 或者循环一起使用

  • ITERATE

ITERATE 语句必须用在循环中,作用是跳过当前循环的剩下的语句,直接进入下一轮循环。

  • REPEAT

带有条件的循环控制语句,当满足条件的时候退出循环。

1
2
3
4
复制代码REPEAT
...
UNTIL
END REPEAT;
  • WHILE

WHILE 语句表示的含义和 REPEAT 相差无几,WHILE 循环和 REPEAT 循环的区别在于:WHILE 是满足条件才执行循环,REPEAT 是满足条件退出循环;

触发器

MySQL 从 5.0 开始支持触发器,触发器一般作用在表上,在满足定义条件时触发,并执行触发器中定义的语句集合,下面我们就来一起认识一下触发器。

举个例子来认识一下触发器:比如你有一个日志表和金额表,你每录入一笔金额就要进行日志表的记录,你会怎么样?同时在金额表和日志表插入数据吗?如果有了触发器,你可以直接在金额表录入数据,日志表会自动插入一条日志记录,当然,触发器不仅只有新增操作,还有更新和删除操作。

创建触发器

我们可以用如下的方式创建触发器

1
复制代码create trigger triggername triggertime triggerevent on tbname for each row triggerstmt

上面涉及到几个参数,我知道你有点懵逼,解释一下。

  • triggername:这个指的就是触发器的名字
  • triggertime:这个指的就是触发器触发时机,是 BEFORE 还是 AFTER
  • triggerevent: 这个指的就是触发器触发事件,一共有三种事件:INSERT、UPDATE 或者 DELETE。
  • tbname:这个参数指的是触发器创建的表名,在哪个表上创建
  • triggerstmt: 触发器的程序体,也就是 SQL 语句

所以,可以创建六种触发器

BEFORE INSERT、AFTER INSERT、BEFORE UPDATE、AFTER UPDATE、BEFORE DELETE、AFTER DELETE

上面的 for each now 表示任何一条记录上的操作都会触发触发器。

下面我们通过一个例子来演示一下触发器的操作

我们还是用上面的 procuct 表做例子,我们创建一个 product_info 产品信息表。

1
复制代码create table product_info(p_info varchar(20));

然后我们创建一个 trigger

我们在 product 表中插入一条数据

1
复制代码insert into product values(4,"pineapple",15.3);

我们进行 select 查询,可以看到现在 product 表中有四条数据

我们没有向 product_info 表中插入数据,现在我们来看一下 product_info 表中,我们预想到是有数据的,具体来看下

这条数据是什么时候插入的呢?我们在创建触发器 tg_pinfo 的时候插入了的这条数据。

删除触发器

触发器可以使用 drop 进行删除,具体删除语法如下

1
复制代码drop trigger tg_pinfo;

和删除表的语法是一样的

查看触发器

我们经常会查看触发器,可以通过执行 show triggers 命令查看触发器的状态、语法等信息。

另一种查询方式是查询表中的 information_schema.triggers 表,这个可以查询指定触发器的指定信息,操作起来方便很多

触发器的作用

  • 在添加一条数据前,检查数据是否合理,例如检查邮件格式是否正确
  • 删除数据后,相当于数据备份的作用
  • 可以记录数据库的操作日志,也可以作为表的执行轨迹

注意:触发器的使用有两个限制

  1. 触发程序不能调用将数据返回客户端的存储程序。也不能使用 CALL 语句的动态 SQL 语句。
  2. 不能在触发器中开始和结束语句,例如 START TRANSACTION

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文图解弄懂八大常用算法思想!

发表于 2020-07-21

文章首发:微信搜索『业余码农』

算法和数据结构一直以来都是程序员的基本内功,可以说没有数据结构的基础建设和算法加持,也就没有这将近八十年的信息革命时代。数据结构可以看作是算法实现的容器,通过一系列特殊结构的数据集合,能够将算法更为高效而可靠的执行起来。

算法的应用不单只体现在编程中。狭义的来讲,算法可看作是数据传递和处理的顺序、方法和组成方式,就像是各种排序算法等。而广义的来讲,算法更像是一种事物运行的逻辑和规则。太阳东升西落,海水潮汐潮流,月儿阴晴圆缺,这些或许都可以看似一种算法,只不过执行者不是电子计算机,而是自然万物。

聊远了。所以对于算法的理解,重要的是领悟其思想,感受其内在。有同学或许就会说了,「算法不就是Leetcode,不就是刷题嘛 」。

片面了啊。题总是刷不完的,但是算法的思想就那么几个。所以呢,刷了那么多题的你,还不了解这几个常见的算法思想,想必是应该好好反省反省下了。

枚举

首先,最为简单的思想,枚举算法。枚举也叫穷举,顾名思义,就是穷尽列举。枚举思想的应用场景十分广泛,也非常容易理解。简单来说,枚举就是将问题的可能解依次列举出来,然后一一带入问题检验,从而从一系列可能解中获得能够解决问题的精确解。

枚举虽然看起来简单,但是其实还是有一些容易被人忽视的考虑点。比方说待解决问题的「可能解/候选解」的筛选条件,「可能解」之间相互的影响,穷举「可能解」的代价,「可能解」的穷举方式等等。

很多时候实际上不必去追求高大上的复杂算法结构,反而大道至简,采用枚举法就能够很好的规避系统复杂性带来的冗余,同时或许在一定程度上还能够对空间进行缩减。

枚举思想的流程可以用下图来表示。通过实现事先确定好「可能解」,然后逐一在系统中进行验证,根据验证结果来对「可能解」进行分析和论证。这是一种很明显的结果导向型的思想,简单粗暴地试图从最终结果反向分析「可能解」的可行性。

不过,枚举思想的劣势当然也很明显。在实际的运行程序中,能够直接通过枚举方法进行求解的问题少之又少。而当「可能解」的筛选条件不清晰,导致「可能解」的数量和范围无法准确判断时,枚举就失去了意义。

然而当「可能解」的规模比较小,同时依次验证的过程容易实施时,枚举思想不失为一种方便快捷的方式。只不过在具体使用时,还可以针对应用场景对「可能解」的验证进行优化。

这种优化可以从两个方向入手,一是问题的简化,尽可能对需要处理的问题进行模型结构上的精简。这种精简具体可体现在问题中的变量数目,减少变量的数据,从而能够从根本上降低「可能解」的组合。

二是对筛选「可能解」的范围和条件进行严格判断,尽可能的剔除大部分无效的「可能解」。

虽说如此,但是一般而言大部分枚举不可用的场景都是由于「可能解」的数量过多,无法在有限空间或有限时间内完成所有可能性的验证。不过实际上枚举思想是最接近人的思维方式,在更多的时候是用来帮助我们去「理解问题」,而不是「解决问题」。

案例

百钱买百鸡问题。 该问题叙述如下:鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一;百钱买百鸡,则翁、母、雏各几何?

翻译过来,意思是公鸡一个五块钱,母鸡一个三块钱,小鸡三个一块钱,现在要用一百块钱买一百只鸡,问公鸡、母鸡、小鸡各多少只?

递推

递推思想跟枚举思想一样,都是接近人类思维方式的思想,甚至在实际生活具有比枚举思想更多的应用场景。人脑在遇到未知的问题时,大多数人第一直觉都会从积累的「先验知识」出发,试图从「已知」推导「未知」,从而解决问题,说服自己。

事实上,这就是一种递推的算法思想。递推思想的核心就是从已知条件出发,逐步推算出问题的解。实现方式很像是初高中时我们的数学考卷上一连串的「因为」「所以」。那个时候还是用三个点来表示的。

而对于计算机而言,复杂的推导其实很难实现。计算机擅长的是执行高密度重复性高的工作,对于随机性高变化多端的问题反而不好计算。相比之下,人脑在对不同维度的问题进行推导时具有更高的自由度。

比方说,人脑可以很容易的从「太阳从东边升起」推出「太阳从西边落下」,然后大致推出「现在的时间」。但是对于计算机而言并没有那么容易,你可能需要设置一系列的限制条件,才能避免计算机推出「太阳/月亮/星星」从「南/北/东边」「落下/飞走/掉落」的可能性。

我说这个例子的用意是在说明,计算机在运用递推思想时,大多都是重复性的推理。比方说,从「今天是1号」推出「明天是2号」。这种推理的结构十分类似,往往可以通过继而往复的推理就可以得到最终的解。

递推思想用图解来表示可以参见下图。每一次推导的结果可以作为下一次推导的的开始,这似乎跟迭代、递归的思想有点类似,不过递推的范畴要更广一些。

案例

兔子问题。 定一对大兔子每月能生一对小兔子,且每对新生的小兔子经过一个月可以长成一对大兔子,具备繁殖能力,如果不发生死亡,且每次均生下一雌一雄,问一年后共有多少对兔子?

递归

说完递推,就不得不说说它的兄弟思想——递归算法。二者同样都带有一个「递」字,可以看出二者还是具有一定的相似性的。「递」的理解可以是逐次、逐步。在递推中,是逐次对问题进行推导直到获得最终解。而在递归中,则是逐次回归迭代,直到跳出回归。

递归算法实际上是把问题转化成规模更小的同类子问题,先解决子问题,再通过相同的求解过程逐步解决更高层次的问题,最终获得最终的解。所以相较于递推而言,递归算法的范畴更小,要求子问题跟父问题的结构相同。而递推思想从概念上并没有这样的约束。

用一句话来形容递归算法的实现,就是在函数或者子过程的内部,直接或间接的调用自己算法。所以在实现的过程中,最重要的是确定递归过程终止的条件,也就是迭代过程跳出的条件判断。否则,程序会在自我调用中无限循环,最终导致内存溢出而崩溃。

递归算法的图解可如下图。很明显,递归思想其实就是一个套娃过程。一般官方都是严禁套娃行为的。所以在使用时一定要明确「套娃」举动的停止条件,及时止损。

案例

汉诺塔问题。 源于印度传说中,大梵天创造世界时造了三根金钢石柱子,其中一根柱子自底向上叠着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

分治

分治,分而治之。

分治算法的核心步骤就是两步,一是分,二是治。但这还引申出了一系列的问题,为什么分,怎么分,怎么治,治后如何。

分治算法很像是一种向下管理的思想,从最高级层层划分,将子任务划分给不同的子模块,进而可以进行大问题的拆分,对系统问题的粒度进行细化,寻求最底层的最基本的解。这样的思路在很多领域都有运用,比如几何数学中的正交坐标、单位坐标、基的概念等,都是通过将复杂问题简化为基本的子问题,然后通过先解决子模块再逐步解决主模块。

在实际的运用中,分治算法主要包括两个维度的处理,一是自顶向下,将主要问题划分逐层级划分为子问题;二是自底向上,将子问题的解逐层递增融入主问题的求解中。

那为什么要分?这个很好解释,由于主要问题的规模过大,无法直接求解,所以需要对主要问题进行粒度划分。

那怎么分?遵循计算机的最擅长的重复运算,划分出来的子问题需要相互独立并且与原问题结构特征相同,这样能够保证解决子问题后,主问题也就能够顺势而解。

怎么治?这就涉及到最基本子问题的求解,我们约定最小的子问题是能够轻易得到解决的,这样的子问题划分才具有意义,所以在治的环节就是需要对最基本子问题的简易求解。

治后如何?子问题的求解是为了主问题而服务的。当最基本的子问题得到解后,需要层层向上递增,逐步获得更高层次问题的解,直到获得原问题的最终解。

分治思想的图解可见下图。通过层层粒度上的划分,将原问题划分为最小的子问题,然后再向上依次得到更高粒度的解。从上而下,再从下而上。先分解,再求解,再合并。

案例

归并排序。

动态规划

讲完分治,我们知道分治思想最重要的一点是分解出的子问题是相互独立且结构特征相同的。这一点并不是所有问题都能满足,许多问题的划分的子问题往往都是相互重叠且互相影响的,那么就很难使用分治算法进行有效而又干净的子问题划分。

于是乎,动态规划来了。动态规划同样需要将问题划分为多个子问题,但是子问题之间往往不是互相独立的。当前子问题的解可看作是前多个阶段问题的完整总结。因此这就需要在在子问题求解的过程中进行多阶段的决策,同时当前阶段之前的决策都能够构成一种最优的子结构。这就是所谓的最优化原理。

最优化原理,一个最优化策略具有这样的性质,不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略。同时,这样的最优策略是针对有已作出决策的总结,对后来的决策没有直接影响,只能借用目前最优策略的状态数据。这也被称之为无后效性。

动态规划是在目前看来非常不接近人类思维方式一种算法,主要原因是在于人脑在演算的过程中很难对每一次决策的结果进行记忆。动态规划在实际的操作中,往往需要额外的空间对每个阶段的状态数据进行保存,以便下次决策的使用。

动态规划的求解思路如下图解。动归的开始需要将问题按照一定顺序划分为各个阶段,然后确定每个阶段的状态,如图中节点的F0等。然后重点是根据决策的方法来确定状态转移方程。也就是需要根据当前阶段的状态确定下一阶段的状态。

在这个过程中,下一状态的确定往往需要参考之前的状态。因此需要在每一次状态转移的过程中将当前的状态变量进行记录,方便之后的查找。

动态规划主要就是用来解决多阶段决策的问题,但是实际问题中往往很难有统一的处理方法,必须结合问题的特点来进行算法的设计,这也是这种算法很难真正掌握的原因。

案例

背包问题。 有 n 件物品和容量为 m 的背包,给出物品的重量以及价值。求解让装入背包的物品重量不超过背包容量且价值最大 。

贪心

贪心算法,我愿称之为最现实的算法思想。

人活在世上,不可能每一个选择都那么恰到好处。那么多的问题,不可能所有问题都能找到最优解。很多问题根本没有准确解,甚至于无解。所以在某些场景下,傻傻的去追求问题的最精确解是没有意义的。

有人说,那还有最优解呢。难道最优解都不要了吗?

没错,许多问题虽然找不到最精确的解,但是的确会存在一个或者一些最优解。但是一定要去追求这些最优解吗?我看不一定。

算法的存在不是单纯的为了对问题求解,更多的是提供一种「策略」。何谓「策略」,解决问题的一种方式,一个角度,一条路。所以,贪心思想是有价值的。

说回贪心算法。从贪心二字就可得知,这个算法的目的就是为了「贪图更多」。但是这种贪心是「目光短浅」的,这就导致贪心算法无法从长远出发,只看重眼前的利益。

具体点说,贪心算法在执行的过程中,每一次都会选择最大的收益,但是总收益却不一定最大。所以这样傻白甜的思路带来的好处就是选择简单,不需要纠结,不需要考虑未来。

贪心算法的实现过程就是从问题的一个初始解出发,每一次都作出「当前最优」的选择,直至遇到局部极值点。贪心所带来的局限性很明显,就是无法保证最后的解是最优的,很容易陷入局部最优的情况。

但是它每一次做选择的速度很快,同时判断条件简单,能够比较快速的给出一种差不多的解决方案。这里的图解我用下图来表示。

这个图表示的是求解对多条直线的交点。很显然,下图中的直线是不存在统一交点的,但是可以通过算法求得统一交点的最优解。若是采用贪心算法,那么在进行迭代时,每一次都会选择离此时位置最近的直线进行更新。这样一来,在经过多次迭代治后,交点的位置就会在某一片区域无限轮回跳转。而这片区域也就是能求得出的大致的最优解区域。

案例

旅行推销员问题。 给定一系列城市和每对城市之间的距离,求解访问每一座城市一次并回到起始城市的最短回路。

回溯

蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。

每每提及回溯,都会忍不住想到「蒹葭」里的这句诗。看到心中所怀念的心上人啊,忍不住逆流而上去追寻她,尽管追随的道路险阻又漫长;又顺流而下继续寻觅,感觉她似乎就在河水中央。回溯算法的过程正如追逐爱情般的艰辛和反复,时而溯洄从之,时而溯游从之。

回溯算法也可称作试探算法,是不是让你回忆起了在女神面前的小心翼翼。简单来说,回溯的过程就是在做出下一步选择之前,先对每一种可能进行试探;只有当可能性存在时才会向前迈进,倘若所有选择都不可能,那么则向后退回原来的位置,重新选择。

这样看起来,回溯算法很像是一种进行中的枚举算法,在行进的过程中对所有可能性进行枚举并判断。常用的应用场景就在对树结构、图结构以及棋盘落子的遍历上。

举一个很简单的例子,看下面图解。假设目的是从最O0到达O4,需要对所有节点进行回溯遍历路径。那么回溯算法的过程则需要在前进的每一步对所有可能的路径进行试探。

比方说,O0节点前进的路径有三条,分别是上中下条的O1。回溯过程的开始,先走上面的O1,然后能够到达上面 O2,但是这时是一条死路。那么就需要从O2退回到O1,而此时O1的唯一选择也走不通,所以还需要从O1退回到O0。然后继续试探中间的O1。

回溯算法的过程就是不断进行这样的试探、判断、退回并重新试探,直至找到一条完整的前进路径。只不过在这个过程中,可以通过「剪枝」等限制条件降低试探搜索的空间,从而避免重复无效的试探。比方说上下的O2节点,在经过O0-O1-O2的试探之后,就已经验证了该节点不可行性,下次就无须从O1开始重复对O2的试探。

回溯思想在许多大规模的问题的求解上都能得到有效的运用。回溯能够将复杂问题进行分步调整,从而在中间的过程中可对所有可能运用枚举思想进行遍历。这样往往能够清晰的看到问题解决的层次,从而可以帮助更好的理解问题的最终解结构。

案例

八皇后问题。 在8×8格的国际象棋上摆放8个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。

模拟

模拟思想的理解相比上述思想应该不是什么难事。

许多真实场景下,由于问题规模过大,变量过多等因素,很难将具体的问题抽象出来,也就无法针对抽象问题的特征来进行算法的设计。这个时候,模拟思想或许是最佳的解题策略。

模拟的过程就是对真实场景尽可能的模拟,然后通过计算机强大的计算能力对结果进行预测。这相较于上述的算法是一种更为宏大的思想。在进行现实场景的模拟中,可能系统部件的实现都需要上述几个算法思想的参与。

模拟说起来是一种很玄幻的思想,没有具体的实现思路,也没有具体的优化策略。只能说,具体问题具体分析。

那应该怎么样来图解呢。我的理解是自定义的,任意的输入,不规则的系统响应,但是只为了获得一个可靠的理想的输出。

总结

算法思想这种东西,实际上是很玄幻的。同一种问题,或许在实现上可以采用不同的思想进行。这八种思想也不是想象中那么高的独立性,很多思想都是杂糅在一起的,只是角度和侧重点不同。上面这些案例也不代表单只能用一种思想来解答,只是用来体会一下对应的算法思想。

作为底层的程序员,虽说不需要每天刷题,但是基础的算法思想还是需要有的。这种东西不是具体于某个算法,而是在于更高层次的对于系统或者需求的理解。

如独立之精神,自由之思想般。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Google 推荐在 MVVM 架构中使用 Kotlin F

发表于 2020-07-20

前言

在之前分享过一篇 Jetpack 综合实战应用 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战 ,这个项目主要包了以下功能:

  1. 自定义 RemoteMediator 实现 network + db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )
  2. 使用 Data Mapper 分离数据源 和 UI
  3. Kotlin Flow 结合 Retrofit2 + Room 的混合使用
  4. Kotlin Flow 与 LiveData 的使用
  5. 使用 Coil 加载图片
  6. 使用 ViewModel、LiveData、DataBinding 协同工作
  7. 使用 Motionlayout 做动画
  8. App Startup 与 Hilt 的使用
  9. 增加 Fragment 1.2.0 上重要的更新: 通过 Fragment 的构造函数传递参数,以及 FragmentFactory 和 FragmentContainerView 的使用

我近期也在开发另外一个 Jetpack + MVVM 实战应用,和神奇宝贝(PokemonGo) 有很多不同之处,神奇宝贝(PokemonGo) 主要偏向于 Paging3 的分页处理,以及 Flow 在 MVVM 中的实战。

而今天这篇文章主要来分析一下 神奇宝贝(PokemonGo) 项目,主要包含以下几个方面的内容:

  • 在 Repositories 或者 DataSource 中直接使用 LiveData 这种做法对吗?
  • Kotlin Flow 是什么?
  • Kotlin Flow 解决了什么问题?
  • Kotlin Flow 如何在 MVVM 中使用?
  • Kotlin Flow 如何与 Retrofit2 + Room 混合使用?

Google 推荐在 MVVM 中使用 Kotlin Flow

我相信如今几乎所有的 Android 开发者至少都听过 MVVM 架构,在 Google Android 团队宣布了 Jetpack 的视图模型之后,它已经成为了现代 Android 开发模式最流行的架构之一,如下图所示:

在官宣 Jetpack 的视图模型之后,同时 Google 在 Jetpack Guide 文章中的示例,也在 Repositories 或者 DataSource 中使用 LiveData,以至于在很多开源的 MVVM 项目中也是直接使用 LiveData,但是在 Repositories 或者 DataSource 中直接使用 LiveData 这种做法对吗?这是我一直以来的一个疑问?

直到我打开 Android 架构组件 页面,看了在页面上增加了最新的文章,这几篇文章大概的内容是说如何在 MVVM 中使用 Flow 以及如何与 LiveData 一起使用,当我看完并通过实践之后大概明白了,LiveData 是一个生命周期感知组件,它并不属于 Repositories 或者 DataSource 层,下文会有详细的分析。

在 Google 发布的 Jetpack 的最新成员 Paging3,在其内部的源码实现也是使用的 Flow,关于 Paging3 的使用可以参考以下链接:

  • Jetpack 成员 Paging3 实践以及源码分析(一)
  • Jetpack 新成员 Paging3 网络实践及原理分析(二)
  • 自定义 RemoteMediator 实现 network + db 的混合使用

不仅仅是 Jetpack 成员支持 Flow,在 Google 提供的 Demo 里面也都在使用 Flow,也有很多开源的 MVVM 项目也在逐渐切换到 Flow,为什么 Google 会推荐使用它呢,使用 Flow 能带来那些好处呢,为我们解决了什么问题?

Kotlin Flow 是什么?Kotlin Flow 解决了什么问题?

Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable 、 Flowable 等等,所以很多人都用 Flow 与 RxJava 做对比。

Flow 相比于 RxJava 简单的太多了,你还记得那些 RxJava 傻傻分不清楚的操作符吗 Observable 、 Flowable 、 Single 、 Completable 、 Maybe 等等。

那么 Flow 为我们解决了什么问题,我主要从以下几个方面思考:

  • LiveData 是一个生命周期感知组件,最好在 View 和 ViewModel 层中使用它,如果在 Repositories 或者 DataSource 中使用会有几个问题
+ 它不支持线程切换,其次不支持背压,也就是在一段时间内**发送**数据的速度 > **接受**数据的速度,LiveData 无法正确的处理这些请求
+ 使用 LiveData 的最大问题是所有数据转换都将在主线程上完成
  • RxJava 虽然支持线程切换和背压,但是 RxJava 那么多傻傻分不清楚的操作符,实际上在项目中常用的可能只有几个例如 Observable 、 Flowable 、 Single 等等,如果我们不去了解背后的原理,造成内存泄露是很正常的事,大家可以从 StackOverflow 上查看一下,有很多因为 RxJava 造成内存泄露的例子
  • RxJava 入门的门槛很高,学习过的朋友们,我相信能够体会到从入门到放弃是什么感觉
  • 解决回调地狱的问题

而相对于以上的不足,Flow 有以下优点:

  • Flow 支持线程切换、背压
  • Flow 入门的门槛很低,没有那么多傻傻分不清楚的操作符
  • 简单的数据转换与操作符,如 map 等等
  • Flow 是对 Kotlin 协程的扩展,让我们可以像运行同步代码一样运行异步代码,使得代码更加简洁,提高了代码的可读性
  • 易于做单元测试

Kotlin Flow 如何在 MVVM 中使用

Jetpack 的视图模型 MVVM 架构由 View + DataBinding + ViewModel + Model 组成,如下所示,我相信下面这张图大家非常熟悉了,

接下来我们一起来探究一下 Kotlin Flow 在 MVVM 当中每层是如何实现的。

Kotlin Flow 在数据源中的使用

在 PokemonGo 项目中,进入详情页,会检查本地是否有数据,如果没有会去请求 pokeapi 详情页接口,获得最新的数据,然后存储在数据库中。

Flow 是协程的扩展,如果要在 Room 和 Retrofit 中使用,Room 和 Retrofit 需要支持协程才可以,在 Retrofit >= 2.6.0 和 Room >= 2.1 版本都支持协程,我们来看一下 Room 和 Retrofit 数据源的配置。

Room:

PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/local/PokemonInfoDao.kt

1
2
kotlin复制代码@Query("SELECT * FROM PokemonInfoEntity where name = :name")
suspend fun getPokemon(name: String): PokemonInfoEntity?

或者直接返回 Flow<PokemonInfoEntity>

1
2
kotlin复制代码@Query("SELECT * FROM PokemonInfoEntity where name = :name")
fun getPokemon(name: String): Flow<PokemonInfoEntity>

Retrofit:

PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/remote/PokemonService.kt

1
2
less复制代码@GET("pokemon/{name}")
suspend fun fetchPokemonInfo(@Path("name") name: String): NetWorkPokemonInfo

如上所见在方法前增加了用 suspend 进行了修饰,只有被 suspend 修饰的方法,才可以在协程中调用。

按照如上配置,在数据源的工作就完成了,相比于 RxJava 的 Observable 、 Flowable 、 Single 、 Completable 、 Maybe 使用场景要简单太多了,我们来看一下在 Repositories 中是如何使用的。

Kotlin Flow 在 Repositories 中的使用

如果我们想在 Flow 中使用 Retrofit 或者 Room 进行网络请求或者查询数据库的操作,我们需要将使用 suspend 修饰符的操作放到 flow { ... } 中执行,最后使用 emit() 方法更新数据,将数据发送给 ViewModel,代码如下所示:

PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt

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
ini复制代码flow {
val pokemonDao = db.pokemonInfoDao()
// 查询数据库是否存在,如果不存在请求网络
var infoModel = pokemonDao.getPokemon(name)
if (infoModel == null) {
// 网络请求
val netWorkPokemonInfo = api.fetchPokemonInfo(name)
// 将网路请求的数据,换转成的数据库的 model,之后插入数据库
infoModel = netWorkPokemonInfo.let {
PokemonInfoEntity(
name = it.name,
height = it.height,
weight = it.weight,
experience = it.experience
)
}
// 插入更新数据库
pokemonDao.insertPokemon(infoModel)
}
// 将数据源的 model 转换成上层用到的 model,
// ui 不能直接持有数据源,防止数据源的变化,影响上层的 ui
val model = mapper2InfoModel.map(infoModel)
// 更新数据,将数据发送给 ViewModel
emit(model)
}.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 IO 线程

将上面的代码简化如下所示:

1
2
3
4
scss复制代码flow {
// 进行网络或者数据库操作
emit(model)
}.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 IO 线程

正如你所见,将耗时操作放到 flow { ... } 里面,通过 flowOn(Dispatchers.IO) 切换到 IO 线程,最后通过 emit() 方法将数据发送给 ViewModel,接下来我们来看一下如何在 ViewModel 中接受 Flow 发送的数据。

Kotlin Flow 在 ViewModel 中的使用

在 ViewModel 中使用 Flow 之前在 Jetpack 成员 Paging3 实践以及源码分析(一) 文章也有提到, 这里我们在深入分析一下,在 ViewModel 中接受 Flow 发送的数据有三种方法,根据实际情况去调用。

PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt

方法一

在 LifeCycle 2.2.0 之前使用的方法,使用两个 LiveData,一个是可变的,一个是不可变的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码// 私有的 MutableLiveData 可变的,对内访问
private val _pokemon = MutableLiveData<PokemonInfoModel>()

// 对外暴露不可变的 LiveData,只能查询
val pokemon: LiveData<PokemonInfoModel> = _pokemon

viewModelScope.launch {
polemonRepository.featchPokemonInfo(name)
.onStart {
// 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的进度条
}
.catch {
// 捕获上游出现的异常
}
.onCompletion {
// 请求完成
}
.collectLatest {
// 将数据提供给 Activity 或者 Fragment
_pokemon.postValue(it)
}
}
  • 准备一私有的 MutableLiveData,只对内访问
  • 对外暴露不可变的 LiveData
  • 在 viewModelScope.launch 方法中执行协程代码块
  • collectLatest 是末端操作符,收集 Flow 在 Repositories 层发射出来的数据,在一段时间内发送多次数据,只会接受最新的一次发射过来的数据
  • 调用 _pokemon.postValue 方法将数据提供给 Activity 或者 Fragment

方法二

在 LifeCycle 2.2.0 之后,可以用更精简的方法来完成,使用 LiveData 协程构造方法 (coroutine builder),这个方法也是在 PokemonGo 项目中用到的方法。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@OptIn(ExperimentalCoroutinesApi::class)
fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
polemonRepository.featchPokemonInfo(name)
.onStart { // 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的进度条 }
.catch { // 捕获上游出现的异常 }
.onCompletion { // 请求完成 }
.collectLatest {
// 更新 LiveData 的数据
emit(it)
}
}
  • liveData{ ... } 协程构造方法提供了一个协程代码块,产生的是一个不可变的 LiveData,emit() 方法则用来更新 LiveData 的数据
  • collectLatest 是末端操作符,收集 Flow 在 Repositories 层发射出来的数据,在一段时间内发送多次数据,只会接受最新的一次发射过来的数据

PS:需要注意的是 flow { ... } 和 liveData{ ... } 内部都有一个 emit() 方法。

方法三:

调用 Flow 的扩展方法 asLiveData() 返回一个不可变的 LiveData,供 Activity 或者 Fragment 调用。

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码@OptIn(ExperimentalCoroutinesApi::class)
suspend fun fectchPokemonInfo3(name: String) =
polemonRepository.featchPokemonInfo(name)
.onStart {
// 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的按钮
}
.catch {
// 捕获上游出现的异常
}
.onCompletion {
// 请求完成
}.asLiveData()

因为 polemonRepository.featchPokemonInfo(name) 是一个用 suspend 修饰的方法,所以在 ViewModel 中调用也需要使用 suspend 来修饰。

为什么说调用 asLiveData() 方法会返回一个不可变的 LiveData,我们来看一下源码:

1
2
3
4
5
6
7
8
kotlin复制代码fun <T> Flow<T>.asLiveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {
collect {
emit(it)
}
}

asLiveData() 方法其实就是对 方法二 中的 liveData{ ... } 的封装

  • asLiveData 是 Flow 的扩展函数,返回值是一个 LiveData
  • liveData{ ... } 协程构造方法提供了一个协程代码块,在 liveData{ ... } 中执行协程代码
  • collect 是末端操作符,收集 Flow 在 Repositories 层发射出来的数据
  • 最后调用 LiveData 中的 emit() 方法更新 LiveData 的数据

DataBinding(数据绑定)

在 PokemonGo 项目中使用了 DataBinding 进行的数据绑定。

DataBinding(数据绑定)实际上是 XML 布局中的另一个视图结构层次,视图 (XML) 通过数据绑定层不断地与 ViewModel 交互,如下所示:

PokemonGo/app/src/main/res/layout/activity_details.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<data>
<variable
name="viewModel"
type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />

</data>

......
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/weight"
android:text="@{viewModel.pokemon.getWeightString}"/>
......

</layout>

这是获取神奇宝贝的详细信息,通过 DataBinding 以声明方式将数据(神奇宝贝的体重)绑定到界面上,更多使用参考项目中的代码。

如何处理 ViewModel 的三种方式

如果不使用数据绑定,在 Activity 或者 Fragment 中如何处理 ViewModel 的三种方式。

PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailsFragment.kt

方式一:

使用两个 LiveData,一个是可变的,一个是不可变的,在 Activity 或者 Fragment 中调用对外暴露不可变的 LiveData 即可,如下所示:

1
2
3
4
kotlin复制代码// 方法一
mViewModel.pokemon.observe(this, Observer {
// 将数据显示在页面上
})

方式二:

使用 LiveData 协程构造方法 (coroutine builder) 提供的协程代码块,产生的是一个不可变的 LiveData,处理方式 同方法一,在 Activity 或者 Fragment 中调用这个不可变的 LiveData 即可,如下所示:

1
2
3
4
kotlin复制代码// 方法二
mViewModel.fectchPokemonInfo2(mPokemonModel.name).observe(this, Observer {
// 将数据显示在页面上
})

方式三:

调用 Flow 的扩展方法 asLiveData() 返回一个不可变的 LiveData,在 Activity 或者 Fragment 调用这个不可变的 LiveData 即可,如下所示:

1
2
3
4
5
6
7
8
less复制代码// 方法三
lifecycleScope.launch {
mViewModel.apply {
fectchPokemonInfo3(mPokemonModel.name).observe(this@DetailsFragment, Observer {
// 将数据显示在页面上
})
}
}

到这里关于 Kotlin Flow 在 MVVM 当中每层的实践就分析完了,如果使用过 RxJava 的小伙伴们应该会非常熟悉,对于没有使用过 RxJava 的小伙伴们,入门的门槛也是非常低的,强烈建议至少体验一次,体验过之后,我认为你会跟我一样爱上它的。

神奇宝贝 (PokemonGo) 基于 Jetpack + MVVM + Repository + Data Mapper + Kotlin Flow 的实战项目,我也正在为 PokemonGo 项目设计更多的场景,也会加入更多的 Jetpack 成员,可以点击下方链接前往查看。

PokemonGo GitHub 地址:https://github.com/hi-dhl/PokemonGo

PokemonGo

结语

关注公众号:ByteCode,查看一系列 Android 系统源码、逆向分析、算法、译文、Kotlin、Jetpack 源码相关的文章,如果这篇文章对你有帮助,请帮我点个 star,感谢!!!,欢迎一起来学习,在技术的道路上一起前进。

梳理了 LeetCode / 剑指 offer 及国内外大厂面试题解,语言包含 Java 和 kotlin ,包含多种解法、解题思路、时间复杂度、空间复杂度分析,题库逐渐完善中,欢迎前去查看。

  • 剑指 offer 及国内外大厂面试题解:在线阅读
  • LeetCode 系列题解:在线阅读


最后推荐我一直在更新维护的项目和网站:

  • 计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看:AndroidX-Jetpack-Practice
  • LeetCode / 剑指 Offer / 国内外大厂面试题,涵盖: 多线程、数组、栈、队列、字符串、链表、树,查找算法、搜索算法、位运算、排序等等,每道题目都会用 Java 和 kotlin 去实现,仓库持续更新,欢迎前去查看 Leetcode-Solutions-with-Java-And-Kotlin,剑指 offer 及国内外大厂面试题解:在线阅读,LeetCode 系列题解:在线阅读
  • 最新 Android 10 源码分析系列文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,仓库持续更新,欢迎前去查看 Android10-Source-Analysis
  • 整理和翻译一系列精选国外的技术文章,每篇文章都会有译者思考部分,对原文的更加深入的解读,仓库持续更新,欢迎前去查看 Technical-Article-Translation
  • 「为互联网人而设计,国内国外名站导航」涵括新闻、体育、生活、娱乐、设计、产品、运营、前端开发、Android 开发等等网址,欢迎前去查看 为互联网人而设计导航网站

历史文章

  • 为数不多的人知道的 Kotlin 技巧以及 原理解析(一)
  • 为数不多的人知道的 Kotlin 技巧以及 原理解析(二)
  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度
  • Jetpack 最新成员 AndroidX App Startup 实践以及原理分析
  • Jetpack 成员 Paging3 实践以及源码分析(一)
  • Jetpack 新成员 Paging3 网络实践及原理分析(二)
  • Jetpack 新成员 Hilt 实践(一)启程过坑记
  • Jetpack 新成员 Hilt 实践之 App Startup(二)进阶篇
  • Jetpack 新成员 Hilt 与 Dagger 大不同(三)落地篇
  • 全方面分析 Hilt 和 Koin 性能
  • 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战
  • Google 推荐在项目中使用 sealed 和 RemoteMediator
  • Kotlin Sealed 是什么?为什么 Google 都用
  • Kotlin StateFlow 搜索功能的实践 DB + NetWork

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android MotionLayout动画:续写Const

发表于 2020-07-20

MotionLayout作为ConstraintLayout子类,在ConstraintLayout 2.0库被引入,主要用来管理运动和组件的动画。ConstrantLayout约束布局,用过的人都说好,反正我用着挺爽的。有部分同学说性能问题,其实对于初中级开发者来说,暂无需考虑这个,相比自己动手嵌套几层布局强吧,而且更重要的是业务UI的实现,尤其工作量大的时候。

本文属于入门级别,重点在于扫盲和入门。如果对你有用,欢迎点赞。个人能力有限,有些东西可能理解不透或不对,欢迎指正,非常感谢。

配置

需要将ConstraintLayout的版本升级到2.0+。

AndroidX:

1
复制代码  implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta8'

支持库:

1
复制代码 implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta8'

学习MotionLayout动画可能需要点Transition和ConstraintLayout知识点,不了解可以看看文末链接哦。MotionLayout运动动画定义了在两个状态集(StateSet)或者两个约束集(ConsraintSet)之间如何进行过渡。状态集与约束集只是过渡动画不同的组织方式。

如果快速入手

1、通过Android Studio创建名为activity_motion的MotionLayout布局文件。

2、 生成MotionLayout布局后会报红,提示创建MotionScene.xml文件。

3、选择创建后,会在res/xml文件夹下生成activity_motion_scene.xml文件。内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<ConstraintSet android:id="@+id/start">
<Constraint android:id="@+id/widget" />
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
<Constraint android:id="@id/widget" />
</ConstraintSet>

<Transition
app:constraintSetEnd="@id/end"
app:constraintSetStart="@+id/start" />
</MotionScene>

此时在activity_motion.xml布局文件中的MotionLayout标签会多一个layoutDescription="@xml/activity_motion_scene"属性,但Android Studio还是缺少layoutDescription属性的错误,需要手动添加上命名空间。

1
2
3
4
5
6
7
8
9
复制代码<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/activity_motion_scene">

</androidx.constraintlayout.motion.widget.MotionLayout>

欧力给,已经学会创建MotionLayout,但好像没什么卵用。我们来看看刚刚自动生成的activity_motion_scene.xml文件。

【划重点】根标签MotionScene有一个defaultDuration属性,表示所有未指定时间的动画的默认时间,默认为300毫秒。MotionScene根标签 必须包含Transition标签,可以有多个Transition标签。Transition标签是用来指定动画的开始和结束状态、任何中间状态以及触发动画的动作,可以理解为一个Transition标签对应一个动画。同时,MotionScene标签可以包含TransitionSet标签,这是可选的。TransitionSet标签主要为Transition标签提供起始和结束状态的位置和属性。而TransitionSet标签必须包含一个或多个Constraint子标签。Constraint标签用来定义布局中某个View在动画中某个状态下位置(通过ConstraintLayout的相关属性来约束)。

充分理解上段话的内容,下面通过实战加深理解:

1、在activity_motion.xml布局文件增加一个id 为vStartStatus的正方形View。并在根标签MotionLayout添加showPaths="true"属性,用来显示正方形运动的路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/activity_motion_scene"
app:showPaths="true">

<View
android:id="@+id/vStartStatus"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="@color/colorPrimary" />

</androidx.constraintlayout.motion.widget.MotionLayout>

2.将activity_motion_scene.xml文件中Constraint标签的id值修改成正方形的id,即vStartStatus。Constraint标签的id属性值需要与要起动画效果的View的id保持一致,这样Constraint标签的所有属性都会作用于该View。Constraint标签的属性与ConstraintLayout的属性是一致的,为此,给正方形开始状态增加一些属性,使其位置水平居中,距离顶部50dp。

1
2
3
4
5
6
7
8
9
10
11
复制代码<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@+id/vStartStatus"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="50dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
</Constraint>
</ConstraintSet>

因为id为start的ConstraintSet标签关联到Transition标签的constraintSetStart属性,所以它作为动画(目前只有一个动画)的起始状态。而id为end的ConstraintSet标签关联到Transition标签的constraintSetEnd属性,所以它将作为动画的结束状态。结束状态我们将正方形设置水平居中,距离底部50dp。

1
2
3
4
5
6
7
8
9
10
11
复制代码<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@id/vStartStatus"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginBottom="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
</Constraint>
</ConstraintSet>

设置Constraint标签时记得设置layout_width与layout_height,不然是看不到正方形的。

  1. 到这一步,Transition标签已经拥有开始和结束状态了,就差触发动画开始的操作了。给Transition标签增加onClick子标签,表示点击触发动画。onClick标签增加clickAction属性,值为toggle,表示重复点击时,动画循环效果;增加targetId属性,值为@id/vStartStatus,表示点击正方形视图触发过渡动画。
1
2
3
4
5
6
7
复制代码<Transition
app:constraintSetEnd="@id/end"
app:constraintSetStart="@+id/start">
<OnClick
app:clickAction="toggle"
app:targetId="@id/vStartStatus" />
</Transition>

此时activity_motion_scene.xml看起来是这样子的。

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
29
30
31
32
33
34
35
36
37
38
复制代码<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:defaultDuration="500">

<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@+id/vStartStatus"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="50dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">

</Constraint>
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@id/vStartStatus"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginBottom="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
</Constraint>
</ConstraintSet>

<Transition
app:constraintSetEnd="@id/end"
app:constraintSetStart="@+id/start">
<OnClick
app:clickAction="toggle"
app:targetId="@id/vStartStatus" />
</Transition>
</MotionScene>

效果图

OK,看到这里,你应该可以创建个类似的MotionLayout动画。还不行的话,需要回头再看看。
下面讲介绍一些标签的属性与效果。

标签与属性

Transition标签

Transition标签主要用来指定Motion场景中一个或多个动画。即关联到动画对应的各种状态和用户交互动作。和过渡动画是大同小异的。

常用属性:

constraintSetStart:指定动画初始状态;

constraintSetEnd:指定动画结束状态;

duration:指定动画时长;

autoTransiton:是否自动开启动画。取值有:animateToStart过渡到初始状态、animateToEnd过渡到结束状态、jumpToEnd跳到结束状态、jumpToStart跳到初始状态、none不开始状态。默认情况下是none,当设为其他值时,不用和用户交互即自动开启动画。

motionInterpolator:插值器。取值有:linear线性、bounce弹簧、easeIn淡入、easeOut淡出、easeInOut淡入淡出;

transitionDisable:允许动画功能。取值:false和true;

layoutDuringTransition:动画过程中,MotionLayout子View调用reqeustLayout,是否做出响应。取值honorRequest响应、ignoreRequest忽略;(beta 4)

1、用户交互的子标签

Transition标签通过一些子标签,实现与用户交互的行为。例如上文的OnClick子标签表示用户的点击行为。

  • OnClick标签:点击场景中某个视图,开始动画效果。

  • OnSwipe标签:表示在布局上滑动时要执行的操作。由于个人能力有限,一些属性不能准备表达。

2、关键帧子标签

在上文中,默认情况下过渡动画Transition标签会关联一个开始状态和一个结束状态的TransitionSet标签。但我们知道Transition标签不仅可以创建初始状态和结束状态,还可以创建中间状态。这些中间状态则由关键帧来构成,以实现更复杂的动画效果。

KeyFrameSet标签:用来指定某个中间状态的位置和属性。其实和过渡动画的关键帧是一样的概念。KeyFrameSet标签含有KeyPosition和KeyAttribute两个子标签,这些共同构成过渡动画过程中某特殊状态的位置和属性。

位置关键帧

KeyPosition标签:
用来定义整个运动动画中某个状态的位置,相比于静态的TransitionSet标签来说,更加灵活。

重点属性解释:
framePosition:当前关键帧的位置,把整个运动动画分成100个位置,取值0到99,那么初始状态的位置就是0,结束状态就是99。


keyPositionType:参考坐标系的选择,决定了percentX和percentY属性取值的结果。

取值:parentRelative表示坐标系基于父视图。例如在开头的demo,加上下面的关键帧:

1
2
3
4
5
6
7
8
复制代码<KeyFrameSet>
<KeyPosition
app:percentY="0.5"
app:framePosition="50"
app:motionTarget="@id/vStartStatus"
app:keyPositionType="parentRelative"
app:percentX="0.25" />
</KeyFrameSet>

代码定义了运动动画过程的中间位置framePosition="50",参考系选择了相对父视图坐标keyPositionType="parentRelative"。由于父视图是全屏,所以坐标系原点在屏幕的左上角,percentY="0.5"和percentX="0.25"则表示正方形在父视图高度的1/2,宽度1/4的位置。

效果图

将keyPositionType属性改为deltaRelative,即坐标系选择参照整个过渡动画的位置,那么起始状态的位置就是原点(0,0),结束状态的位置就是终点(1,1)。
这里由于原点和终点在x轴上的距离是0,所以percentX="0.25"是没有效果的。
效果图:

将keyPositionType属性改为pathRelative,即坐标系选择参照整个运动路径,即起始和终点的直线距离构成X轴,此时y轴就有正负之分,表示在X轴的左边还是右边。x轴和y轴的长度都是等于路径的长度。

例如代码如下:

1
2
3
4
5
6
7
8
复制代码<KeyFrameSet>
<KeyPosition
app:framePosition="50"
app:percentX="0.5"
app:percentY="0.1"
app:keyPositionType="pathRelative"
app:motionTarget="@id/vStartStatus" />
</KeyFrameSet>

效果图:

代码如下,percentY改为-1:

1
2
3
4
5
6
7
8
复制代码<KeyFrameSet>
<KeyPosition
app:framePosition="50"
app:percentX="0.5"
app:percentY="-0.1"
app:keyPositionType="pathRelative"
app:motionTarget="@id/vStartStatus" />
</KeyFrameSet>

效果图:


percentWidth和percentHeight属性表示视图自身大小,如果整个动画过程中,视图大小不存在变化,是没有效果的。例如文章开始的demo就是没有效果的,可以将正方形在起始状态和结束状态的大小改为不一致,就可以看到效果。percentWidth和percentHeight属性会导致sizePercent属性失效。

属性关键帧

KeyAttribute相对于位置关键帧,属性关键帧更注重的是属性,而不是某位置。例如常见的位移、旋转动画。
属性有:

  • framePosition 关键帧位置
  • motionTarget 关联视图Id
  • transitionEasing 动画速度
  • curveFit 选择基于直线的路径或基于单一速率的路径
  • motionProgress 设置动画进度
  • android:alpha 透明度
  • android:elevation 阴影,注意SDK版本
  • android:rotation 旋转
  • android:rotationX 绕X轴旋转
  • "android:rotationY" 绕Y轴旋转
  • android:transformPivotX 旋转或缩放的中心点X坐标
  • android:transformPivotY 旋转或缩放的中心点Y坐标
  • transitionPathRotate
  • android:scaleX" X轴缩放
  • android:scaleY" Y轴缩放
  • android:translationX X轴平移
  • android:translationY Y轴平移
  • android:translationZ X轴平移

如果以上属性不够,也可以通过添加CustomAttribute子标签实现自己属性,跟属性动画自定属性是同个概念。

自定义属性

CustomAttribute标签必须通过attributeName属性指定一个属性名。支持下类型的属性。

  • customColorValue 颜色值类型
  • customColorDrawableValue颜色值的Drawable类型
  • customIntegerValue int类型
  • customFloatValue float类型
  • customStringValue String类型
  • customDimension 尺寸类型
  • customPixelDimension Pixel尺寸类型
  • customBoolean Boolean 类型

到这里,Transition标签和其子标签、相关属性基本就介绍完了。

ConstraintSet标签

ConstraintSet约束集主要用来定义多个属性集合,并通过id被Transition标签引用,作为运动动画过程的起始或结束状态。

Constraint标签

子标签Constraint用来该状态某个View的相关约束属性,约束属性支持ConstraintLayout布局的所有属性+上文提到的自定义属性。或者通过组织Layout
、PropertySet、Transform、Motion、CustomAttribute等子标签,关于这些子标签,感兴趣可以参阅官方文档

【系列好文推荐】

Android属性动画,看完这篇够用了吧

Android矢量图动画:每人送一辆掘金牌小黄车

Android过渡动画,发现掘金小秘密

【参考文章】

官方文档

中文官方文档

【点赞,方便日后查阅;点赞,码字不易】

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

即学即用Kotlin - 协程

发表于 2020-07-20

前言

上周在内部分享会上大佬同事分享了关于 Kotlin 协程的知识,之前有看过 Kotlin 协程的一些知识,以为自己还挺了解协程的,结果…

打脸

打脸

在这一次分享中,发现 Flow 和 Channel 这一块儿知识是自己不怎么了解的,本文也将着重和大家聊一聊这一块儿的内容,协程部分将分为三篇,本文是第一篇:

“
《即学即用Kotlin - 协程》

《抽丝剥茧Kotlin - 协程基础篇》

《抽丝剥茧Kotlin - 协程Flow篇》

目录

目录

目录

一、基础

1. 概念

相信大家或多或少的都了解过,协程是什么,官网上这么说:

“
Essentially, coroutines are light-weight threads.

协程是轻量级的线程,为什么是轻量的?可以先告诉大家结论,因为它基于线程池API,所以在处理并发任务这件事上它真的游刃有余。

有可能有的同学问了,既然它基于线程池,那我直接使用线程池或者使用 Android 中其他的异步任务解决方式,比如 Handler、RxJava等,不更好吗?

协程可以使用阻塞的方式写出非阻塞式的代码,解决并发中常见的回调地狱,这是其最大的优点,后面介绍。

2. 使用

1
2
3
4
复制代码GlobalScope.launch(Dispatchers.Main) {
val res = getResult(2)
mNumTv.text = res.toString()
}

启动协程的代码就是如此的简单。上面的代码中可以分为三部分,分别是 GlobalScope、Dispatcher 和 launch,他们分别对应着协程的作用域、调度器和协程构建器,我们挨个儿介绍。

协程作用域

协程的作用域有三种,他们分别是:

  • runBlocking:顶层函数,它和 coroutineScope 不一样,它会阻塞当前线程来等待,所以这个方法在业务中并不适用 。
  • GlobalScope:全局协程作用域,可以在整个应用的声明周期中操作,且不能取消,所以仍不适用于业务开发。
  • 自定义作用域:自定义协程的作用域,不会造成内存泄漏。

显然,我们不能在 Activity 中调用 GlobalScope,这样可能会造成内存泄漏,看一下如何自定义作用域,具体的步骤我在注释中已给出:

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
复制代码class MainActivity : AppCompatActivity() {
// 1. 创建一个 MainScope
val scope = MainScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// 2. 启动协程
scope.launch(Dispatchers.Unconfined) {
val one = getResult(20)
val two = getResult(40)
mNumTv.text = (one + two).toString()
}
}

// 3. 销毁的时候释放
override fun onDestroy() {
super.onDestroy()

scope.cancel()
}

private suspend fun getResult(num: Int): Int {
delay(5000)
return num * num
}
}

调度器

调度器的作用是将协程限制在特定的线程执行。主要的调度器类型有:

  • Dispatchers.Main:指定执行的线程是主线程,如上面的代码。
  • Dispatchers.IO:指定执行的线程是 IO 线程。
  • Dispatchers.Default:默认的调度器,适合执行 CPU 密集性的任务。
  • Dispatchers.Unconfined:非限制的调度器,指定的线程可能会随着挂起的函数的发生变化。

什么是挂起?我们就以九心吃饭为例,如果到公司对面的广场吃饭,九心得经过:

  • 走到广场 10min > 点餐 5min > 等待上餐 10min > 就餐 30min > 回来 10 min

如果九心点广场的外卖呢?

  • 九心:下单 5min > 等待(等待的时候可以工作) 30min > 就餐 30min
  • 外卖骑手:到店 > 取餐 > 送外卖

从九心吃饭的例子可以看出,如果点了外卖,九心花费的时间较少了,可以空闲出更多的时间做自己的事。再仔细分析一下,其实从公司到广场和等待取餐这个过程并没有省去,只是九心把这个过程交给了外卖员。

协程的原理跟九心点外卖的原理是一致的,耗时阻塞的操作并没有减少,只是交给了其他线程:
协程请求数据过程

launch

launch 的作用从它的名称就可以看的出来,启动一个新的协程,它返回的是一个 Job对象,我们可以调用 Job#cancel() 取消这个协程。

除了 launch,还有一个方法跟它很像,就是 async,它的作用是创建一个协程,之后返回一个 Deferred<T>对象,我们可以调用 Deferred#await()去获取返回的值,有点类似于 Java 中的 Future,稍微改一下上面的代码:

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
复制代码class MainActivity : AppCompatActivity() {
// 1. 创建一个 MainScope
val scope = MainScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// 2. 启动协程
scope.launch(Dispatchers.Unconfined) {
val one = async { getResult(20) }
val two = async { getResult(40) }
mNumTv.text = (one.await() + two.await()).toString()
}
}

// 3. 销毁的时候释放
override fun onDestroy() {
super.onDestroy()

scope.cancel()
}

private suspend fun getResult(num: Int): Int {
delay(5000)
return num * num
}
}

与修改前的代码相比,async 能够并发执行任务,执行任务的时间也因此缩短了一半。

除了上述的并发执行任务,async 还可以对它的 start 入参设置成懒加载

1
复制代码val one = async(start = CoroutineStart.LAZY) { getResult(20) }

这样系统就可以在调用它的时候再为它分配资源了。

suspend

suspend 是修饰函数的关键字,意思是当前的函数是可以挂起的,但是它仅仅起着提醒的作用,比如,当我们的函数中没有需要挂起的操作的时候,编译器回给我们提醒 Redudant suspend modifier,意思是当前的 suspend 是没有必要的,可以把它删除。

那我们什么时候需要使用挂起函数呢?常见的场景有:

  • 耗时操作:使用 withContext 切换到指定的 IO 线程去进行网络或者数据库请求。
  • 等待操作:使用delay方法去等待某个事件。

withContext 的代码:

1
2
3
4
5
复制代码private suspend fun getResult(num: Int): Int {
return withContext(Dispatchers.IO) {
num * num
}
}

delay 的代码:

1
2
3
4
复制代码private suspend fun getResult(num: Int): Int {
delay(5000)
return num * num
}

结合 Android Jetpack

在介绍自定义协程作用域的时候,我们需要主动在 Activity 或者 Fragment 中的 onDestroy 方法中调用 job.cancel(),忘记处理可能是程序员经常会犯的错误,如何避免呢?

Google 总是能够解决程序员的痛点,在 Android Jetpack 中的 lifecycle、LiveData 和 ViewModel 已经集成了快速使用协程的方法,如果我们已经引入了 Android Jetpack,可以引入依赖:

1
2
3
4
5
6
7
8
9
10
复制代码    dependencies {
def lifecycle_version = "2.2.0"

// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Lifecycles only (without ViewModel or LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
}

使用可以结合具体的场景,比如结合 Lifecycle,需要使用 lifecycleScope 协程作用域:

1
2
3
4
5
6
复制代码lifecycleScope.launch {
// 代表当前生命周期处于 Resumed 的时候才会执行(选择性使用)
whenResumed {
// ... 具体的协程代码
}
}

即使你不使用 Android Jetpack 组件,由于 Lifecycles 在很早之前就内置在 Android 系统的代码中,所以你仍然可以仅仅引入 Lifecycle 的协程扩展库,因为它会帮助你很好的处理 Activity 或者 Fragment 的生命周期。

引入 Android Jetpack 协程扩展库官方文档:点我打开

二、流

长期以来,在 Android 中响应式编程的首选方案是 RxJava,我们今天就来了解一下 Kotlin中的响应式编程 Flow。如果你能熟练使用 RxJava,那你肯定能快速上手 Flow。

曾经我在《即学即用Android Jetpack - ViewModel & LiveData》一文中说过,LiveData 的使用类似于 RxJava,现在我收回这句话,事实上,LiveData 更加简单和纯粹,它建立单一的生产消费模型,Flow 才是类似于 RxJava 的存在。

1. 基础

先上一段代码:

1
2
3
4
5
6
7
8
9
复制代码lifecycleScope.launch {
// 创建一个协程 Flow<T>
createFlow()
.collect {num->
// 具体的消费处理
// ...
}
}
}

我在 createFlow 这个方法中,返回了 Flow<Int> 的对象,所以我们可以这样对比。

对比 Flow RxJava
数据源 Flow<T> Observable<T>
订阅 collect subscribe

创建 Flow 对象

我们暂不考虑 RxJava中的背压和非背压,直接先将 Flow 对标 RxJava 中的 Observable。

和 RxJava 一样,在创建 Flow 对象的时候我们也需要调用 emit 方法发射数据:

1
2
3
4
复制代码fun createFlow(): Flow<Int> = flow {
for (i in 1..10)
emit(i)
}

一直调用 emit 可能不便捷,因为 RxJava 提供了 Observable.just() 这类的操作符,显然,Flow 也为我们提供了快速创建操作:

  • flowof(vararg elements: T):帮助可变数组生成 Flow 实例
  • 扩展函数 .asFlow():面向数组、列表等集合

比如可以使用 (1..10).asFlow() 代替上述的 Flow 对象的创建。

消费数据

collect 方法和 RxJava 中的 subscribe 方法一样,都是用来消费数据的。

除了简单的用法外,这里有两个问题得注意一下:

  • collect 函数是一个 suspend 方法,所以它必须发生在协程或者带有 suspend 的方法里面,这也是我为什么在一开始的时候启动了 lifecycleScope.launch。
  • lifecycleScope 是我使用的 Lifecycle 的协程扩展库当中的,你可以替换成自定义的协程作用域。

2. 线程切换

我们学习 RxJava 的时候,大佬们都会说,RxJava 牛逼,牛逼在哪儿呢?

切换线程,同样的,Flow 的协程切换也很牛逼。Flow 是这么切换协程的:

1
2
3
4
5
6
7
8
9
10
11
复制代码lifecycleScope.launch {
// 创建一个协程 Flow<T>
createFlow()
// 将数据发射的操作放到 IO 线程中的协程
.flowOn(Dispatchers.IO)
.collect { num ->
// 具体的消费处理
// ...
}
}
}

和 RxJava 对比:

操作 Flow RxJava
改变数据发射的线程 flowOn subscribeOn
改变消费数据的线程 无 observeOn

改变数据发射的线程

flowOn 使用的参数是协程对应的调度器,它实质改变的是协程对应的线程。

改变消费数据的线程

我在上面的表格中并没有写到在 Flow 中如何改变消费线程,并不意味着 Flow 不可以指定消费线程?

Flow 的消费线程在我们启动协程指定调度器的时候就确认好了,对应着启动协程的调度器。比如在上面的代码中 lifecycleScope 启动的调度器是 Dispatchers.Main,那么 collect 方法就消费在主线程。

3. 异常和完成

异常捕获

对比 Flow RxJava
异常 catch onError

Flow 中的 catch 对应着 RxJava 中的 onError,catch 操作:

1
2
3
4
5
6
7
8
9
复制代码lifecycleScope.launch {
flow {
//...
}.catch {e->

}.collect(

)
}

除此以外,你可以使用声明式捕获 try { } catch (e: Throwable) { } 去捕获异常,不过 catch 本质上是一个扩展方法,它是对声明式捕获的封装。

完成

对比 Flow RxJava
完成 onCompletion onComplete

Flow 中的 onCompletion 对应这 RxJava 中的 onComplete 回调,onCompletion操作:

1
2
3
4
5
6
7
8
9
复制代码lifecycleScope.launch {
createFlow()
.onCompletion {
// 处理完成操作
}
.collect {

}
}

除此以外,我们还可以通过捕获式 try {} finally {} 去获取完成情况。

4. Flow的特点

我们在对 Flow 已经有了一些基础的认知了,再来聊一聊 Flow 的特点,Flow 具有以下特点:

  • 冷流
  • 有序
  • 协作取消

如果你对 Kotlin 中的 Sequence 有一些认识,那么你应该可以轻松的 Get 到前两个点。

冷流

有点类似于懒加载,当我们触发 collect 方法的时候,数据才开始发射。

1
2
3
4
5
6
7
8
9
复制代码lifecycleScope.launch {
val flow = (1..10).asFlow().flowOn(Dispatchers.Main)

flow.collect { num ->
// 具体的消费处理
// ...
}
}
}

也就是说,在第2行的时候,虽然流创建好了,但是数据一直到第四行发生 collect 才开始发射。

有序

看代码比较容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码lifecycleScope.launch {
flow {
for(i in 1..3) {
Log.e("Flow","$i emit")
emit(i)
}
}.filter {
Log.e("Flow","$it filter")
it % 2 != 0
}.map {
Log.e("Flow","$it map")
"${it * it} money"
}.collect {
Log.e("Flow","i get $it")
}
}

得到的日志:

1
2
3
4
5
6
7
8
9
10
复制代码E/Flow: 1 emit
E/Flow: 1 filter
E/Flow: 1 map
E/Flow: i get 1 money
E/Flow: 2 emit
E/Flow: 2 filter
E/Flow: 3 emit
E/Flow: 3 filter
E/Flow: 3 map
E/Flow: i get 9 money

从日志中,我们很容易得出这样的结论,每个数据都是经过 emit、filter 、map和 collect 这一套完整的处理流程后,下个数据才会开始处理,而不是所有的数据都先统一 emit,完了再统一 filter,接着 map,最后再 collect。

协作取消

Flow 采用和协程一样的协作取消,也就是说,Flow 的 collect 只能在可取消的挂起函数中挂起的时候取消,否则不能取消。

如果我们想取消 Flow 得借助 withTimeoutOrNull 之类的顶层函数,不妨猜一下,下面的代码最终会打印出什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码lifecycleScope.launch {
val f = flow {
for (i in 1..3) {
delay(500)
Log.e(TAG, "emit $i")
emit(i)
}
}
withTimeoutOrNull(1600) {
f.collect {
delay(500)
Log.e(TAG, "consume $it")
}
}
Log.e(TAG, "cancel")
}

5. 操作符对比

限于篇幅,我仅介绍一下 Flow 中操作符的作用,就不一一介绍每个操作符具体怎么使用了。

普通操作符:

Flow 操作符 作用
map 转换操作符,将 A 变成 B
take 后面跟 Int 类型的参数,表示接收多少个 emit 出的值
filter 过滤操作符

特殊的操作符

总会有一些特殊的情况,比如我只需要取前几个,我只要最新的数据等,不过在这些情况下,数据的发射就是并发执行的。

Flow 操作符 作用
buffer 数据发射并发,collect 不并发
conflate 发射数据太快,只处理最新发射的
collectLatest 接收处理太慢,只处理最新接收的

组合操作符

Flow 操作符 作用
zip 组合两个流,双方都有新数据才会发射处理
combine 组合两个流,在经过第一次发射以后,任意方有新数据来的时候就可以发射,另一方有可能是已经发射过的数据

展平流操作符

展平流有点类似于 RxJava 中的 flatmap,将你发射出去的数据源转变为另一种数据源。

Flow 操作符 作用
flatMapConcat 串行处理数据
flatMapMerge 并发 collect 数据
flatMapLatest 在每次 emit 新的数据以后,会取消先前的 collect

末端操作符

顾名思义,就是帮你做 collect 处理,collect 是最基础的末端操作符。

末端流操作符 作用
collect 最基础的消费数据
toList 转化为 List 集合
toSet 转化为 Set 集合
first 仅仅取第一个值
single 确保流发射单个值
reduce 规约,如果发射的是 Int,最终会得到一个 Int,可做累加操作
fold 规约,可以说是 reduce 的升级版,可以自定义返回类型

其他还有一些操作符,我这里就不一一介绍了,感兴趣可以查看 API。

三、通道

Channel是一个面向多协程之间数据传输的 BlockQueue。它的使用方式超级简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码lifecycleScope.launch {
// 1. 生成一个 Channel
val channel = Channel<Int>()

// 2. Channel 发送数据
launch {
for(i in 1..5){
delay(200)
channel.send(i * i)
}
channel.close()
}

// 3. Channel 接收数据
launch {
for( y in channel)
Log.e(TAG, "get $y")
}
}

实现协程之间的数据传输需要三步:

1.创建 Channel

创建的 Channel的方式可以分为两种:

  • 直接创建对象:方式跟上述代码一致。
  • 扩展函数 produce

如果使用了扩展函数,代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码lifecycleScope.launch {
// 1. 生成一个 Channel
val channel = produce<Int> {
for(i in 1..5){
delay(200)
send(i * i)
}
close()
}

// 2. 接收数据
// ... 省略 跟之前代码一致
}

直接将第一步和第二步合并了。

2. 发送数据

发送数据使用的 Channel#send() 方法,当我们数据发送完毕的时候,可以使用 Channel#close() 来表明通道已经结束数据的发送。

3. 接收数据

正常情况下,我们仅需要调用 Channel#receive() 获取数据,但是该方法只能获取一次传递的数据,如果我们仅需获取指定次数的数据,可以这么操作:

1
2
3
复制代码repeat(4){
Log.e(TAG, "get ${channel.receive()}")
}

但如果发送的数据不可以预估呢?这个时候我们就需要迭代 Channel 了

1
2
复制代码for( y in channel)
Log.e(TAG, "get $y")

四、多协程数据处理

多协程处理并发数据的时候,原子性同样也得不到保证,协程中出了一种叫 Mutex 的锁,区别是它的 lock 操作是挂起的,非阻塞的,感兴趣的同学可以自行查看。

总结

个人感觉协层的主要作用是简化代码的逻辑,减少了代码的回调地狱,结合 Kotlin,既可以写出优雅的代码,还能降低我们犯错的概率。至于提升多协程开发的性能?

不存在的

不存在的

如果觉得本文不错,「三连」是对我最大的鼓励。我将会在下一篇文章中和大家讨论协程的原理,欢迎大家关注。

学习协程和 kotlin 还是很有必要的,我们团队在开发新的功能的时候,也全部选择了 Kotlin。

关于我

我是九心,新晋互联网码农,如果想要进阶和了解更多的干货,欢迎关注我的公众号接收到的我的最新文章。

微信二维码

微信二维码

参考文章:

“
《最全面的Kotlin协程: Coroutine/Channel/Flow 以及实际应用》

《Kotlin中文站》

《Kotlin 的协程用力瞥一眼》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

我向面试官讲解了单例模式,他对我竖起了大拇指

发表于 2020-07-19

单例模式相信大家都有所听闻,甚至也写过不少了,在面试中也是考得最多的其中一个设计模式,面试官常常会要求写出两种类型的单例模式并且解释其原理,废话不多说,我们开始学习如何很好地回答这一道面试题吧。

什么是单例模式

面试官问什么是单例模式时,千万不要答非所问,给出单例模式有两种类型之类的回答,要围绕单例模式的定义去展开。

单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。

image.png

单例模式的类型

单例模式有两种类型:

  • 懒汉式:在真正需要使用对象时才去创建该单例类对象
  • 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用

懒汉式创建单例对象

懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。否则则先执行实例化操作。

image.png

根据上面的流程图,就可以写出下面的这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class Singleton {

private static Singleton singleton;

private Singleton(){}

public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}

}

没错,这里我们已经写出了一个很不错的单例模式,不过它不是完美的,但是这并不影响我们使用这个“单例对象”。

以上就是懒汉式创建单例对象的方法,我会在后面解释这段代码在哪里可以优化,存在什么问题。

饿汉式创建单例对象

饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。

关于类加载,涉及到JVM的内容,我们目前可以简单认为在程序启动时,这个单例对象就已经创建好了。

image.png

1
2
3
4
5
6
7
8
9
10
复制代码public class Singleton{

private static final Singleton singleton = new Singleton();

private Singleton(){}

public static Singleton getInstance() {
return singleton;
}
}

注意上面的代码在第3行已经实例化好了一个Singleton对象在内存中,不会有多个Singleton对象实例存在

类在加载时会在堆内存中创建一个Singleton对象,当类被卸载时,Singleton对象也随之消亡了。

懒汉式如何保证只创建一个对象

我们再来回顾懒汉式的核心方法

1
2
3
4
5
6
复制代码public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}

这个方法其实是存在问题的,试想一下,如果两个线程同时判断 singleton 为空,那么它们都会去实例化一个Singleton 对象,这就变成多例了。所以,我们要解决的是线程安全问题。

image.png

最容易想到的解决方法就是在方法上加锁,或者是对类对象加锁,程序就会变成下面这个样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
// 或者
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}

这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。

接下来要做的就是优化性能:目标是如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁

1
2
3
4
5
6
7
8
9
10
复制代码public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}

上面的代码已经完美地解决了并发安全 + 性能低效问题:

  • 第 2 行代码,如果 singleton 不为空,则直接返回对象,不需要获取锁;而如果多个线程发现 singleton 为空,则进入分支;
  • 第 3 行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断singleton 是否为空,因为 singleton 有可能已经被之前的线程实例化
  • 其它之后获取到锁的线程在执行到第 4 行校验代码,发现 singleton 已经不为空了,则不会再 new 一个对象,直接返回对象即可
  • 之后所有进入该方法的线程都不会去获取锁,在第一次判断 singleton 对象时已经不为空了

因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:Double Check(双重校验) + Lock(加锁)

完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class Singleton {

private static Singleton singleton;

private Singleton(){}

public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}

}

上面这段代码已经近似完美了,但是还存在最后一个问题:指令重排

使用 volatile 防止指令重排

创建一个对象,在 JVM 中会经过三步:

(1)为 singleton 分配内存空间

(2)初始化 singleton 对象

(3)将 singleton 指向分配好的内存空间

指令重排序是指:JVM 在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能

在这三步中,第 2、3 步有可能会发生指令重排现象,创建对象的顺序变为 1-3-2,会导致多个线程获取对象时,有可能线程 A 创建对象的过程中,执行了 1、3 步骤,线程 B 判断 singleton 已经不为空,获取到未初始化的singleton 对象,就会报 NPE 异常。文字较为晦涩,可以看流程图:

image.png

使用 volatile 关键字可以**防止指令重排序,**其原理较为复杂,这篇文章不打算展开,可以这样理解:使用 volatile 关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生 NPE 异常了。

volatile 还有第二个作用:使用 volatile 关键字修饰的变量,可以保证其内存可见性,即每一时刻线程读取到该变量的值都是内存中最新的那个值,线程每次操作该变量都需要先读取该变量。

最终的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class Singleton {

private static volatile Singleton singleton;

private Singleton(){}

public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}

}

破坏懒汉式单例与饿汉式单例

无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。

利用反射破坏单例模式

下面是一段使用反射破坏单例模式的例子

1
2
3
4
5
6
7
8
9
10
11
复制代码public static void main(String[] args) {
// 获取类的显式构造器
Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
// 可访问私有构造器
construct.setAccessible(true);
// 利用反射构造新对象
Singleton obj1 = construct.newInstance();
// 通过正常方式获取单例对象
Singleton obj2 = Singleton.getInstance();
System.out.println(obj1 == obj2); // false
}

上述的代码一针见血了:利用反射,强制访问类的私有构造器,去创建另一个对象

利用序列化与反序列化破坏单例模式

下面是一种使用序列化和反序列化破坏单例模式的例子

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public static void main(String[] args) {
// 创建输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
// 将单例对象写到文件中
oos.writeObject(Singleton.getInstance());
// 从文件中读取单例对象
File file = new File("Singleton.file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton) ois.readObject();
// 判断是否是同一个对象
System.out.println(newInstance == Singleton.getInstance()); // false
}

两个对象地址不相等的原因是:readObject() 方法读入对象时它必定会返回一个新的对象实例,必然指向新的内存地址。

让面试官鼓掌的枚举实现

我们已经掌握了懒汉式与饿汉式的常见写法了,通常情况下到这里已经足够了。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法,话不多说,学完下面,真的不虚面试官考你了。

在 JDK 1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举

枚举实现单例模式完整代码如下:

1
2
3
4
5
6
7
复制代码public enum Singleton {
INSTANCE;

public void doSomething() {
System.out.println("这是枚举类型的单例模式!");
}
}

使用枚举实现单例模式较其它两种实现方式的优势有 3 点,让我们来细品。

优势 1 :一目了然的代码

代码对比饿汉式与懒汉式来说,更加地简洁。最少只需要3行代码,就可以完成一个单例模式:

1
2
3
复制代码public enum Test {
INSTANCE;
}

我们从最直观的地方入手,第一眼看到这3行代码,就会感觉到少,没错,就是少,虽然这优势有些牵强,但写的代码越少,越不容易出错。

优势 2:天然的线程安全与单一实例

它不需要做任何额外的操作,就可以保证对象单一性与线程安全性。

我写了一段测试代码放在下面,这一段代码可以证明程序启动时仅会创建一个 Singleton 对象,且是线程安全的。

我们可以简单地理解枚举创建实例的过程:在程序启动时,会调用 Singleton 的空参构造器,实例化好一个Singleton 对象赋给 INSTANCE,之后再也不会实例化

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public enum Singleton {
INSTANCE;
Singleton() { System.out.println("枚举创建对象了"); }
public static void main(String[] args) { /* test(); */ }
public void test() {
Singleton t1 = Singleton.INSTANCE;
Singleton t2 = Singleton.INSTANCE;
System.out.print("t1和t2的地址是否相同:" + t1 == t2);
}
}
// 枚举创建对象了
// t1和t2的地址是否相同:true

除了优势1和优势2,还有最后一个优势是 保护单例模式,它使得枚举在当前的单例模式领域已经是 无懈可击 了

优势 3:枚举保护单例模式不被破坏

使用枚举可以防止调用者使用反射、序列化与反序列化机制强制生成多个单例对象,破坏单例模式。

防反射

image-20200718213354831.png
枚举类默认继承了 Enum 类,在利用反射调用 newInstance() 时,会判断该类是否是一个枚举类,如果是,则抛出异常。

防止反序列化创建多个枚举对象

在读入 Singleton 对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的类型和变量名输出到文件中,在读入文件反序列化成对象时,使用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。

所以,在序列化和反序列化的过程中,只是写出和读入了枚举类型和名字,没有任何关于对象的操作。

image-20200718224707754.png
小结:

(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象

(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象

(3)枚举类不需要关注线程安全、破坏单例和性能问题,因为其创建对象的时机与饿汉式单例有异曲同工之妙。

总结

(1)单例模式常见的写法有两种:懒汉式、饿汉式

(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题

(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。

(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;

(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题

(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加 volatile 关键字防止指令重排序

(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…792793794…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%