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

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


  • 首页

  • 归档

  • 搜索

Spring Boot 2x基础教程:事务管理入门

发表于 2020-07-11

什么是事务?

我们在开发企业应用时,通常业务人员的一个操作实际上是对数据库读写的多步操作的结合。由于数据操作在顺序执行的过程中,任何一步操作都有可能发生异常,异常会导致后续操作无法完成,此时由于业务逻辑并未正确的完成,之前成功操作的数据并不可靠,如果要让这个业务正确的执行下去,通常有实现方式:

  1. 记录失败的位置,问题修复之后,从上一次执行失败的位置开始继续执行后面要做的业务逻辑
  2. 在执行失败的时候,回退本次执行的所有过程,让操作恢复到原始状态,带问题修复之后,重新执行原来的业务逻辑

事务就是针对上述方式2的实现。事务,一般是指要做的或所做的事情,就是上面所说的业务人员的一个操作(比如电商系统中,一个创建订单的操作包含了创建订单、商品库存的扣减两个基本操作。如果创建订单成功,库存扣减失败,那么就会出现商品超卖的问题,所以最基本的最发就是需要为这两个操作用事务包括起来,保证这两个操作要么都成功,要么都失败)。

这样的场景在实际开发过程中非常多,所以今天就来一起学习一下Spring Boot中的事务管理如何使用!

快速入门

在Spring Boot中,当我们使用了spring-boot-starter-jdbc或spring-boot-starter-data-jpa依赖的时候,框架会自动默认分别注入DataSourceTransactionManager或JpaTransactionManager。所以我们不需要任何额外配置就可以用@Transactional注解进行事务的使用。

我们以之前实现的《使用Spring Data JPA访问MySQL》的示例作为基础工程进行事务的使用学习。在该样例工程中(若对该数据访问方式不了解,可先阅读该前文),我们引入了spring-data-jpa,并创建了User实体以及对User的数据访问对象UserRepository,在单元测试类中实现了使用UserRepository进行数据读写的单元测试用例,如下:

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
java复制代码@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {

@Autowired
private UserRepository userRepository;

@Test
public void test() throws Exception {

// 创建10条记录
userRepository.save(new User("AAA", 10));
userRepository.save(new User("BBB", 20));
userRepository.save(new User("CCC", 30));
userRepository.save(new User("DDD", 40));
userRepository.save(new User("EEE", 50));
userRepository.save(new User("FFF", 60));
userRepository.save(new User("GGG", 70));
userRepository.save(new User("HHH", 80));
userRepository.save(new User("III", 90));
userRepository.save(new User("JJJ", 100));

// 省略后续的一些验证操作
}

}

可以看到,在这个单元测试用例中,使用UserRepository对象连续创建了10个User实体到数据库中,下面我们人为的来制造一些异常,看看会发生什么情况。

通过@Max(50)来为User的age设置最大值为50,这样通过创建时User实体的age属性超过50的时候就可以触发异常产生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@Entity
@Data
@NoArgsConstructor
public class User {

@Id
@GeneratedValue
private Long id;
private String name;
@Max(50)
private Integer age;

public User(String name, Integer age) {
this.name = name;
this.age = age;
}

}

执行测试用例,可以看到控制台中抛出了如下异常,关于age字段的错误:

1
2
3
4
bash复制代码2020-07-09 11:55:29.581 ERROR 24424 --- [           main] o.h.i.ExceptionMapperStandardImpl        : HHH000346: Error during managed flush [Validation failed for classes [com.didispace.chapter310.User] during persist time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
ConstraintViolationImpl{interpolatedMessage='最大不能超过50', propertyPath=age, rootBeanClass=class com.didispace.chapter310.User, messageTemplate='{javax.validation.constraints.Max.message}'}
]]

此时查数据库中的User表:

可以看到,测试用例执行到一半之后因为异常中断了,前5条数据正确插入而后5条数据没有成功插入,如果这10条数据需要全部成功或者全部失败,那么这时候就可以使用事务来实现,做法非常简单,我们只需要在test函数上添加@Transactional注解即可。

1
2
3
4
5
6
7
java复制代码@Test
@Transactional
public void test() throws Exception {

// 省略测试内容

}

再来执行该测试用例,可以看到控制台中输出了回滚日志(Rolled back transaction for test context),

1
2
3
4
5
bash复制代码2020-07-09 12:48:23.831  INFO 24889 --- [           main] o.s.t.c.transaction.TransactionContext   : Began transaction (1) for test context [DefaultTestContext@f6efaab testClass = Chapter310ApplicationTests, testInstance = com.didispace.chapter310.Chapter310ApplicationTests@60816371, testMethod = test@Chapter310ApplicationTests, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@3c19aaa5 testClass = Chapter310ApplicationTests, locations = '{}', classes = '{class com.didispace.chapter310.Chapter310Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@34cd072c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@528931cf, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2353b3e6, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@7ce6a65d], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true]]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@4b85edeb]; rollback [true]
2020-07-09 12:48:24.011 INFO 24889 --- [ main] o.s.t.c.transaction.TransactionContext : Rolled back transaction for test: [DefaultTestContext@f6efaab testClass = Chapter310ApplicationTests, testInstance = com.didispace.chapter310.Chapter310ApplicationTests@60816371, testMethod = test@Chapter310ApplicationTests, testException = javax.validation.ConstraintViolationException: Validation failed for classes [com.didispace.chapter310.User] during persist time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
ConstraintViolationImpl{interpolatedMessage='最大不能超过50', propertyPath=age, rootBeanClass=class com.didispace.chapter310.User, messageTemplate='{javax.validation.constraints.Max.message}'}
], mergedContextConfiguration = [WebMergedContextConfiguration@3c19aaa5 testClass = Chapter310ApplicationTests, locations = '{}', classes = '{class com.didispace.chapter310.Chapter310Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@34cd072c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@528931cf, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2353b3e6, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@7ce6a65d], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true]]

再看数据库中,User表就没有AAA到EEE的用户数据了,成功实现了自动回滚。

这里主要通过单元测试演示了如何使用@Transactional注解来声明一个函数需要被事务管理,通常我们单元测试为了保证每个测试之间的数据独立,会使用@Rollback注解让每个单元测试都能在结束时回滚。而真正在开发业务逻辑时,我们通常在service层接口中使用@Transactional来对各个业务逻辑进行事务管理的配置,例如:

1
2
3
4
5
6
java复制代码public interface UserService {

@Transactional
User update(String name, String password);

}

事务详解

上面的例子中我们使用了默认的事务配置,可以满足一些基本的事务需求,但是当我们项目较大较复杂时(比如,有多个数据源等),这时候需要在声明事务时,指定不同的事务管理器。对于不同数据源的事务管理配置可以见《Spring Data JPA的多数据源配置》中的设置。在声明事务时,只需要通过value属性指定配置的事务管理器名即可,例如:@Transactional(value="transactionManagerPrimary")。

除了指定不同的事务管理器之后,还能对事务进行隔离级别和传播行为的控制,下面分别详细解释:

隔离级别

隔离级别是指若干个并发的事务之间的隔离程度,与我们开发时候主要相关的场景包括:脏读取、重复读、幻读。

我们可以看org.springframework.transaction.annotation.Isolation枚举类中定义了五个表示隔离级别的值:

1
2
3
4
5
6
7
java复制代码public enum Isolation {
DEFAULT(-1),
READ_UNCOMMITTED(1),
READ_COMMITTED(2),
REPEATABLE_READ(4),
SERIALIZABLE(8);
}
  • DEFAULT:这是默认值,表示使用底层数据库的默认隔离级别。对大部分数据库而言,通常这值就是:READ_COMMITTED。
  • READ_UNCOMMITTED:该隔离级别表示一个事务可以读取另一个事务修改但还没有提交的数据。该级别不能防止脏读和不可重复读,因此很少使用该隔离级别。
  • READ_COMMITTED:该隔离级别表示一个事务只能读取另一个事务已经提交的数据。该级别可以防止脏读,这也是大多数情况下的推荐值。
  • REPEATABLE_READ:该隔离级别表示一个事务在整个过程中可以多次重复执行某个查询,并且每次返回的记录都相同。即使在多次查询之间有新增的数据满足该查询,这些新增的记录也会被忽略。该级别可以防止脏读和不可重复读。
  • SERIALIZABLE:所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

指定方法:通过使用isolation属性设置,例如:

1
java复制代码@Transactional(isolation = Isolation.DEFAULT)

传播行为

所谓事务的传播行为是指,如果在开始当前事务之前,一个事务上下文已经存在,此时有若干选项可以指定一个事务性方法的执行行为。

我们可以看org.springframework.transaction.annotation.Propagation枚举类中定义了6个表示传播行为的枚举值:

1
2
3
4
5
6
7
8
9
java复制代码public enum Propagation {
REQUIRED(0),
SUPPORTS(1),
MANDATORY(2),
REQUIRES_NEW(3),
NOT_SUPPORTED(4),
NEVER(5),
NESTED(6);
}
  • REQUIRED:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
  • SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
  • REQUIRES_NEW:创建一个新的事务,如果当前存在事务,则把当前事务挂起。
  • NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。
  • NESTED:如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于REQUIRED。

指定方法:通过使用propagation属性设置,例如:

1
java复制代码@Transactional(propagation = Propagation.REQUIRED)

代码示例

本文的相关例子可以查看下面仓库中的chapter3-10目录:

  • Github:github.com/dyc87112/Sp…
  • Gitee:gitee.com/didispace/S…

如果您觉得本文不错,欢迎Star支持,您的关注是我坚持的动力!

本文首发:Spring Boot 2.x基础教程:事务管理入门,转载请注明出处。
欢迎关注我的公众号:程序猿DD,获得独家整理的学习资源和日常干货推送。
如果您对我的其他专题内容感兴趣,直达我的个人博客:didispace.com。

本文转载自: 掘金

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

5分钟白嫖我常用的免费效率软件/工具!效率300% up!

发表于 2020-07-10

Mac 免费效率软件/工具推荐

本文来自李文文投稿,原文地址:mp.weixin.qq.com/s/8c-94YhOd… 。

  1. uTools(Windows/Mac)

还在为了翻译 English 而专门下载一个翻译软件吗?

还在为了格式某个 json 文本、时间戳转换而打开网址百度地址吗?

还在为了查找 linux/redis/vue 文档而打开你的浏览器搜索吗?

还在为了改动 hosts 文件专门下载软件或改动 hosts 文件吗

还在为了从时间戳到秒而网上百度吗?

还在为了斗图赢过好友而网上百度吗?

这就是 utools 诞生的意义!

最重要的是它提供 API 以供用户自定义插件来解决重复和低效的操作,而上面所说的功能,都是通过不同的插件实现的,还有很多插件,像剪切板历史记录,密码管理,内网穿透,todo 列表等。

  1. 文档地址:u.tools/docs/guide/…
  2. 交流论坛:yuanliao.info/t/utools
  3. 视频演示地址:5分钟白嫖我常用的免费效率软件/工具!效率300% up!
  1. brew(Mac)

为什么有 brew,因为 mac 平台的 appstore 非常的不好用,审核也很严,因此有很多一些大家公认的“正版”好用的软件,都会在 homebrew 发布,例如 openjdk、qq、maven、go 等,它是 Mac OSX 上的软件包管理工具,能够使用命令行实现安装、卸载、升级的功能。类似 ubuntu 系统下的 apt-get 的功能。
而且很多软件都推荐使用 brew 安装,因为它可以帮助你解决安装依赖问题,例如你想下载 go、maven 这种命令行工具,你还需要配置其它的一些环境,而 brew 在安装的时候都帮你配置好了。

其中有个趣闻,就是 homebrew 作者因为不会白板翻转二叉树被 Google 拒了。笔者搜到 15 年的推特图。。。

  1. 主页地址:brew.sh/index_zh-cn
  2. 支持安装的命令行软件列表:formulae.brew.sh/formula/
  3. 支持安装的桌面端软件列表:formulae.brew.sh/cask/
  1. 命令行软件,例如 go、openjdk、maven、python 等。使用的基本命令为:brew install openjdk
  2. 桌面端软件:例如 qq、微信、网易云音乐等桌面的软件。使用的基本命令为:brew cask install qq,只是多了一个 cask 参数。

常见命令整理如下,以下命令都可带上 cask 参数:

  1. brew search name:联网搜索软件是否存在 brew 中
  2. brew install name:安装软件
  3. brew upgrade name:更新软件
  4. brew uninstall name:卸载软件
  5. brew reinstall name:重新安装软件
  6. brew info name:查看软件安装地址
  7. brew cleanup:清理缓存等
  8. brew doctor:查看建议,例如升级等
  1. iterm2+zsh+json_pp(Mac)

这是我的一个套装组合,各个都可以拆分来使用,但是它们组合使用效率极高。

iTerm2 是 macOS 的终端仿真器,支持一个界面有多个 session 等,你可以把它当做 SecureCRT 命令行版,但是支持各种自定义配置。
zsh 是 oh-my-zsh 的简称,我们默认都是用的 bash 终端,是不支持命令的自动填充高亮等。

json_pp 我主要是用来格式化 curl 命令行的结果,例如测试某个 restful 接口,返回的 json,在命令行就会自动给你格式化好输出,

  1. 这三个软件都是搭配 brew 安装
  2. 搭配安装教程地址:gist.github.com/kevin-smets…
  3. iterm2 安装:brew cask install iterm2
  4. zsh 安装:ohmyz.sh/#install
  5. json_pp 安装:brew install jsonpp

当安装了 zsh 后,你可以在 vscode/idea 软件中切换默认的 shell

效果图如下,命令提示高亮,显示当前 git 分支

  1. Itsycal(Mac)

Mac 左上角的时间栏只能看当前时间不能看日历非常的不方便,因此有很多软件都支持左上角时间点击显示日历,但是 Itsycal 是我用过最方便轻量的免费软件, 不仅可以同步日历的事件,而且各种小功能也非常的实用:

  1. 支持各种快捷键,包括创建事件、快速显示小日历等。
  2. 支持高亮显示某星期、设置每周的第一天为周一或周日。
  3. 自定义 dateformat 显示日期,我设置的为:YYYY-MM-dd EE HH:mm:ss
  4. 支持暗黑模式,不过我都是设置为跟随系统主题,白天白色,晚上黑色自动切换。

  1. 文档地址:www.mowglii.com/itsycal/
  2. 下载: brew cask install itsycal
  1. xnip pro + QQ(Mac)

没错,这个 QQ 就是我们平常用的 QQ 聊天软件。

在电脑截图上,我经常用的功能包括:滚动截长图,快速截图、识别图片中的文字、快速录个电脑操作视频。这些操作中,xnip 支持滚动截长图,我个人用了几款,虽然有好用的,但是都是收费的,而 xnip 免费版滚动截图只会有个水印。

QQ 截图是结合截图、OCR 识别以及录视频为一体的超级功能!有时候发的截图里面的 token,或者手机号,又例如银行卡,就非常的方便,而且识别的非常准确!另外 qq 截图有个小技巧,截图双击时会截当前软件的边缘,不用自己手动拖拽。

  1. snip 地址:zh.xnipapp.com/
  2. QQ 地址:im.qq.com/macqq/

QQ 截图 OCR 识别:

QQ 首选项配置截图、录制快捷键设置:

  1. 语雀(Windows/Mac)

我的笔记之路,从白嫖有道云笔记,再到买了一年的印象笔记的高级会员,最后是买了阿里云服务器自制了蚂蚁笔记服务端,自己的笔记之路一路折腾,最后我发现还是语雀符合我对笔记知识整合的理解。本篇文章也同步发布在了语雀。

  1. 界面清新,客户端启动快。不像印象笔记,启动慢不说,运行久了还会卡。而且有很多我不必要的功能在那里。
  2. 支持现在流行的卡片式笔记,例如流程图、视频链接、思维导图等。
  3. 快速发布,生成博文,可访问的链接,用于快速分享好基友,还支持多个文件形成目录后文件夹层级。
  4. 免费使用 oss 存储,包括图片视频思维导图都可以免费存储并有单独的链接访问(个人版存储空间 90G 够用了)

个人感觉语雀还是很香的!自己用来记笔记还是当做博客都是非常不错的选择。

  1. 主页地址:www.yuque.com/
  2. 使用手册:www.yuque.com/yuque/help
  1. tencent-lemon(Mac)

腾讯柠檬清理,基本对标的就是 CleanMyMac,我个人没用过 CleanMyMac,但是感觉腾讯家的这个产品挺好用,在内测的时候就在一直用,产品在社区里面也一直听取用户的意见改进,持状态栏显示当前内存占用、网速等,对我来说是够用了。

  1. 主页地址:lemon.qq.com/
  2. 社区地址:support.qq.com/products/36…
  3. 下载: brew cask install tencent-lemon

8.oss-brower(Windows/Mac)

这个仅适合使用了阿里云 oss 的用户,它是快速的方便用户对自己 oss 进行操作管理,而且还有权限功能,我平常都是电脑截图,然后拖拽图片到 oss-brower 里面,接着获取地址。另外该产品是开源的,我开始很难相信这是阿里出的产品。

  1. 主页地址:github.com/aliyun/oss-…
  2. 中文文档:github.com/aliyun/oss-…
  1. ScreenTime(Mac)

即 apple 自带的屏幕时间,大家有时候可能想知道,我在 Mac 和 Iphone 上使用各个软件的时候大概是多久,可能 Iphone 大家都知道,但是 Mac 大家可能不怎么关注,但是其实 Mac 的屏幕时间更加方便和强大,因为它根据 icloud 可以获取所有设备的总时间,或其它设备(例如 watch???)的使用时间。

  1. BackgroudMusic(Mac)

这个软件用于单独为每个软件设置独立的音量大小。属于你不装的时候挺好,装了之后就严重依赖它的软件。属于开源软件,社区活跃,更新也频繁,我使用过程中已知的 bug 主要是在开启和关闭过程中会没声音,不过我设置开机启动就好了。

  1. 上班时,我想设置音乐软件声音小,办公软件提示音大,它可以做到。
  2. 在家时,我想设置小网页声音大,办公软件提示音小,它可以做到。
  3. 我想设置声音左耳大,右耳小(这个功能我暂时没用上)。

  1. 主页地址:github.com/kyleneideck…
  2. 下载地址:brew cask install background-music
  1. TeamViewer(Windows/Mac)

主要是方便自己的 Mac 远程连接好基友的 Win 电脑,来做一下操作。不过由于之前 TeamVierwer 曝出重大安全漏洞,现在国内都需要手机号验证了,但是不影响我们使用免费版。

  1. 主页地址:www.teamviewer.cn/cn/
  2. 下载: brew cask install teamviewer
  1. another redis desktop manager(Windows/Mac)

可视化的 Redis 管理软件,开源软件。颜值高、功能多、作者更新快的可视化 Redis 管理软件。

  1. 主页地址:github.com/qishibo/Ano…
  2. 下载: brew cask install another-redis-desktop-manager
  1. IIna(Mac)

现代化的视频播放器,我用的功能只是本地播放,有次 Mac 自带的播放器无法播放某个特殊的小视频,因此在 Github 找到了它,目前视频类的格式它都能播放,功能齐全,设置速率、翻转等基本功能都有,我把它当做万能播放器使用。

  1. 主页地址:github.com/iina/iina
  2. 下载: brew cask install iina
  1. ffmpeg(Windows/Mac)

视频格式转换、视频转 gif、视频压缩等视频操作的命令行工具。基本现在播放器的软件都使用了该仓库的代码。我平常主要用它压缩视频,例如介绍 utools 章节的视频,原视频 23M,然后通过它压缩 8M,方便快捷。缺点是命令行复杂,需要好好调教,我本人用的较少。

  1. 主页地址:github.com/FFmpeg/FFmp…
  2. 下载: brew install ffmpeg

本文转载自: 掘金

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

为数不多的人知道的 Kotlin 技巧以及 原理解析(二)

发表于 2020-07-10

文章中没有奇淫技巧,都是一些在实际开发中常用,但很容易被我们忽略的一些常见问题,源于平时的总结,这篇文章主要对这些常见问题进行分析。

之前分享过一篇文章 为数不多的人知道的 Kotlin 技巧以及 原理解析 主要分析了一些让人傻傻分不清楚的操作符的原理。

这篇文章主要分析一些常见问题的解决方案,如果使用不当会对 性能 和 内存 造成的那些影响以及如何规避这些问题,文章中涉及的案例来自 Kotlin 官方、Stackoverflow、Medium 等等网站,都是平时看到,然后进行汇总和分析。

通过这篇文章你将学习到以下内容:

  • 使用 toLowerCase 和 toUpperCase 等等方法会造成那些影响?
  • 如何优雅的处理空字符串?
  • 为什么解构声明和数据类不能在一起使用?
  • Kotlin 提供的高效的文件处理方法,以及原理解析?
  • Sequence 和 Iterator 有那些不同之处?
  • 便捷的 joinToString 方法的使用?
  • 如何用一行代码实现移除字符串的前缀和后缀?

尽量少使用 toLowerCase 和 toUpperCase 方法

当我们比较两个字符串,需要忽略大小写的时候,通常的写法是调用 toLowerCase() 方法或者 toUpperCase() 方法转换成大写或者小写,然后在进行比较,但是这样的话有一个不好的地方,每次调用 toLowerCase() 方法或者 toUpperCase() 方法会创建一个新的字符串,然后在进行比较。

调用 toLowerCase() 方法

1
2
3
4
5
6
7
8
9
kotlin复制代码fun main(args: Array<String>) {
// use toLowerCase()
val oldName = "Hi dHL"
val newName = "hi Dhl"
val result = oldName.toLowerCase() == newName.toLowerCase()

// or use toUpperCase()
// val result = oldName.toUpperCase() == newName.toUpperCase()
}

toLowerCase() 编译之后的 Java 代码

如上图所示首先会生成一个新的字符串,然后在进行字符串比较,那么 toUpperCase() 方法也是一样的如下图所示。

toUpperCase() 编译之后的 Java 代码

这里有一个更好的解决方案,使用 equals 方法来比较两个字符串,添加可选参数 ignoreCase 来忽略大小写,这样就不需要分配任何新的字符串来进行比较了。

1
2
3
4
5
kotlin复制代码fun main(args: Array<String>) {
val oldName = "hi DHL"
val newName = "hi dhl"
val result = oldName.equals(newName, ignoreCase = true)
}

equals 编译之后的 Java 代码

使用 equals 方法并没有创建额外的对象,如果遇到需要比较字符串的时候,可以使用这种方法,减少额外的对象创建。

如何优雅的处理空字符串

当字符串为空字符串的时候,返回一个默认值,常见的写法如下所示:

1
2
ini复制代码val target = ""
val name = if (target.isEmpty()) "dhl" else target

其实有一个更简洁的方法,可读性更强,使用 ifEmpty 方法,当字符串为空字符串时,返回一个默认值,如下所示。

1
ini复制代码val name = target.ifEmpty { "dhl" }

其原理跟我们使用 if 表达式是一样的,来分析一下源码。

1
2
kotlin复制代码public inline fun <C, R> C.ifEmpty(defaultValue: () -> R): R where C : CharSequence, C : R =
if (isEmpty()) defaultValue() else this

ifEmpty 方法是一个扩展方法,接受一个 lambda 表达式 defaultValue ,如果是空字符串,返回 defaultValue,否则不为空,返回调用者本身。

除了 ifEmpty 方法,Kotlin 库中还封装很多其他非常有用的字符串,例如:将字符串转为数字。常见的写法如下所示:

1
2
ini复制代码val input = "123"
val number = input.toInt()

其实这种写法存在一定问题,假设输入字符串并不是纯数字,例如 123ddd 等等,调用 input.toInt() 就会报错,那么有没有更好的写法呢?如下所示。

1
2
3
4
ini复制代码val input = "123"
// val input = "123ddd"
// val input = ""
val number = input.toIntOrNull() ?: 0

避免将解构声明和数据类一起使用

这是 Kotlin 团队一个建议:避免将解构声明和数据类一起使用,如果以后往数据类添加新的属性,很容易破坏代码的结构。我们一起来思考一下,为什么 Kotlin 官方会这么说,我先来看一个例子:数据类和解构声明的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码// 数据类
data class People(
val name: String,
val city: String
)

fun main(args: Array<String>) {
// 编译测试
printlnPeople(People("dhl", "beijing"))
}

fun printlnPeople(people: People) {
// 解构声明,获取 name 和 city 并将其输出
val (name, city) = people
println("name: ${name}")
println("city: ${city}")
}

输出结果如下所示:

1
2
makefile复制代码name: dhl
city: beijing

随着需求的变更,需要给数据类 People 添加一个新的属性 age。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码// 数据类,增加了 age
data class People(
val name: String,
val age: Int,
val city: String
)

fun main(args: Array<String>) {
// 编译测试
printlnPeople(People("dhl", 80, "beijing"))
}

此时没有更改解构声明,也不会有任何错误,编译输出结果如下所示:

1
2
makefile复制代码name: dhl
city: 80

得到的结果并不是我们期望的,此时我们不得不更改解构声明的地方,如果代码中有多处用到了解构声明,因为增加了新的属性,就要去更改所有使用解构声明的地方,这明显是不合理的,很容易破坏代码的结构,所以一定要避免将解构声明和数据类一起使用。当我们使用不规范的时候,并且编译器也会给出警告,如下图所示。

文件的扩展方法

Kotlin 提供了很多文件扩展方法 Extensions for java.io.Reade :forEachLine 、 readLines 、 readText 、 useLines 等等方法,帮助我们简化文件的操作,而且使用完成之后,它们会自动关闭,例如 useLines 方法:

1
2
3
scss复制代码File("dhl.txt").useLines { line ->
println(line)
}

useLines 是 File 的扩展方法,调用 useLines 会返回一个文件中所有行的 Sequence,当文件内容读取完毕之后,它会自动关闭,其源码如下。

1
2
kotlin复制代码public inline fun <T> File.useLines(charset: Charset = Charsets.UTF_8, block: (Sequence<String>) -> T): T =
bufferedReader(charset).use { block(it.lineSequence()) }
  • useLines 是 File 的一个扩展方法
  • useLines 接受一个 lambda 表达式 block
  • 调用了 BufferedReader 读取文件内容,之后调用 block 返回文件中所有行的 Sequence 给调用者

那它是如何在读取完毕自动关闭的呢,核心在 use 方法里面,在 useLines 方法内部调用了 use 方法,use 方法也是一个扩展方法,源码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
var exception: Throwable? = null
try {
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
when {
apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
this == null -> {}
exception == null -> close()
else ->
try {
close()
} catch (closeException: Throwable) {
// cause.addSuppressed(closeException) // ignored here
}
}
}
}

其实很简单,调用 try...catch...finally 最后在 finally 内部进行 close。其实我们也可以根据源码实现一个通用的异常捕获方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码inline fun <T, R> T.dowithTry(block: (T) -> R) {
try {
block(this)
} catch (e: Throwable) {
e.printStackTrace()
}
}

// 使用方式
dowithTry {
// 添加会出现异常的代码, 例如
val result = 1 / 0
}

当然这只是一个非常简单的异常捕获方法,在实际项目中还有很多需要去处理的,比如说异常信息需不需要返回给调用者等等。

在上文中提到了调用 useLines 方法返回一个文件中所有行的 Sequence,为什么 Kolin 会返回 Sequence,而不返回 Iterator?

Sequence 和 Iterator 不同之处

为什么 Kolin 会返回 Sequence,而不返回 Iterator?其实这个核心原因由于 Sequence 和 Iterator 实现不同导致 内存 和 性能 有很大的差异。

接下来我们围绕这两个方面来分析它们的性能,Sequences(序列) 和 Iterator(迭代器) 都是一个比较大的概念,本文的目的不是去分析它们,所以在这里不会去详细分析 Sequence 和 Iterator,只会围绕着 内存 和 性能 两个方面去分析它们的区别,让我们有一个直观的印象。更多信息可以查看国外一位大神写的文章 Prefer Sequence for big collections with more than one processing step。

Sequence 和 Iterator 从代码结构上来看,它们非常的相似如下所示:

1
2
3
4
5
6
7
kotlin复制代码interface Iterable<out T> {
operator fun iterator(): Iterator<T>
}

interface Sequence<out T> {
operator fun iterator(): Iterator<T>
}

除了代码结构之外,Sequences(序列) 和 Iterator(迭代器) 它们的实现完全不一样。

Sequences(序列)

Sequences 是属于懒加载操作类型,在 Sequences 处理过程中,每一个中间操作不会进行任何计算,它们只会返回一个新的 Sequence,经过一系列中间操作之后,会在末端操作 toList 或 count 等等方法中进行最终的求职运算,如下图所示。

在 Sequences 处理过程中,会对单个元素进行一系列操作,然后在对下一个元素进行一系列操作,直到所有元素处理完毕。

1
2
3
4
5
6
7
scss复制代码val data = (1..3).asSequence()
.filter { print("F$it, "); it % 2 == 1 }
.map { print("M$it, "); it * 2 }
.forEach { print("E$it, ") }
println(data)

// 输出 F1, M1, E2, F2, F3, M3, E6

Sequences

如上所示:在 Sequences 处理过程中,对 1 进行一系列操作输出 F1, M1, E2, 然后对 2 进行一系列操作,依次类推,直到所有元素处理完毕,输出结果为 F1, M1, E2, F2, F3, M3, E6。

在 Sequences 处理过程中,每一个中间操作( map、filter 等等 )不进行任何计算,只有在末端操作( toList、count、forEach 等等方法 ) 进行求值运算,如何区分是中间操作还是末端操作,看方法的返回类型,中间操作返回的是 Sequence,末端操作返回的是一个具体的类型( List、int、Unit 等等 )源码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码// 中间操作 map ,返回的是  Sequence
public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}

// 末端操作 toList 返回的是一个具体的类型(List)
public fun <T> Sequence<T>.toList(): List<T> {
return this.toMutableList().optimizeReadOnlyList()
}

// 末端操作 forEachIndexed 返回的是一个具体的类型(Unit)
public inline fun <T> Sequence<T>.forEachIndexed(action: (index: Int, T) -> Unit): Unit {
var index = 0
for (item in this) action(checkIndexOverflow(index++), item)
}
  • 如果是中间操作 map、filter 等等,它们返回的是一个 Sequence,不会进行任何计算
  • 如果是末端操作 toList、count、forEachIndexed 等等,返回的是一个具体的类型( List、int、Unit 等等 ),会做求值运算

Iterator(迭代器)

在 Iterator 处理过程中,每一次的操作都是对整个数据进行操作,需要开辟新的内存来存储中间结果,将结果传递给下一个操作,代码如下所示:

1
2
3
4
5
6
7
scss复制代码val data = (1..3).asIterable()
.filter { print("F$it, "); it % 2 == 1 }
.map { print("M$it, "); it * 2 }
.forEach { print("E$it, ") }
println(data)

// 输出 F1, F2, F3, M1, M3, E2, E6

Iterator

如上所示:在 Iterator 处理过程中,调用 filter 方法对整个数据进行操作输出 F1, F2, F3,将结果存储到 List 中, 然后将结果传递给下一个操作 ( map ) 输出 M1, M3 将新的结果在存储的 List 中, 直到所有操作处理完毕。

1
2
3
4
5
6
7
8
9
kotlin复制代码// 每次操作都会开辟一块新的空间,存储计算的结果
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}

// 每次操作都会开辟一块新的空间,存储计算的结果
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

对于每次操作都会开辟一块新的空间,存储计算的结果,这是对内存极大的浪费,我们往往只关心最后的结果,而不是中间的过程。

了解完 Sequences 和 Iterator 不同之处,接下里我们从 性能 和 内存 两个方面来分析 Sequences 和 Iterator。

Sequences 和 Iterator 性能对比

分别使用 Sequences 和 Iterator 调用它们各自的 filter、map 方法,处理相同的数据的情况下,比较它们的执行时间。

使用 Sequences :

1
2
3
4
5
6
7
8
scss复制代码val time = measureTimeMillis {
(1..10000000 * 10).asSequence()
.filter { it % 2 == 1 }
.map { it * 2 }
.count()
}

println(time) // 1197

使用 Iterator :

1
2
3
4
5
6
7
8
scss复制代码val time2 = measureTimeMillis {
(1..10000000 * 10).asIterable()
.filter { it % 2 == 1 }
.map { it * 2 }
.count()
}

println(time2) // 23641

Sequences 和 Iterator 处理时间如下所示:

Sequences Iterator
1197 23641

这个结果是很让人吃惊的,Sequences 比 Iterator 快 19 倍,如果数据量越大,它们的时间差距会越来越大,当我们在读取文件的时候,可能会进行一系列的数据操作 drop、filter 等等,所以 Kotlin 库函数 useLines 等等方法会返回 Sequences,因为它们更加的高效。

Sequences 和 Iterator 内存对比

这里使用了 Prefer Sequence for big collections with more than one processing step 文章的一个例子。

有 1.53 GB 犯罪分子的数据存储在文件中,从文件中找出有多少犯罪分子携带大麻,分别使用 Sequences 和 Iterator,我们先来看一下如果使用 Iterator 处理会怎么样(这里调用 readLines 函返回 List<String>)

1
2
3
4
5
6
7
scss复制代码File("ChicagoCrimes.csv").readLines()
.drop(1) // Drop descriptions of the columns
.mapNotNull { it.split(",").getOrNull(6) }
// Find description
.filter { "CANNABIS" in it }
.count()
.let(::println)

运行完之后,你将会得到一个意想不到的结果 OutOfMemoryError

1
arduino复制代码Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

调用 readLines 函返回一个集合,有 3 个中间操作,每一个中间操作都需要一块空间存储 1.53 GB 的数据,它们需要占用超过 4.59 GB 的空间,每次操作都开辟了一块新的空间,这是对内存巨大浪费。如果我们使用序列 Sequences 会怎么样呢?(调用 useLines 方法返回的是一个 Sequences)。

1
2
3
4
5
6
7
8
9
scss复制代码File("ChicagoCrimes.csv").useLines { lines ->
// The type of `lines` is Sequence<String>
lines
.drop(1) // Drop descriptions of the columns
.mapNotNull { it.split(",").getOrNull(6) }
// Find description
.filter { "CANNABIS" in it }
.count()
.let { println(it) } // 318185

没有出现 OutOfMemoryError 异常,共耗时 8.3 s,由此可见对于文件操作使用序列不仅能提高性能,还能减少内存的使用,从性能和内存这两面也解释了为什么 Kotlin 库的扩展方法 useLines 等等,读取文件的时候使用 Sequences 而不使用 Iterator。

便捷的 joinToString 方法的使用

joinToString 方法提供了一组丰富的可选择项( 分隔符,前缀,后缀,数量限制等等 )可用于将可迭代对象转换为字符串。

1
2
3
4
5
6
7
8
9
10
ini复制代码val data = listOf("Java", "Kotlin", "C++", "Python")
.joinToString(
separator = " | ",
prefix = "{",
postfix = "}"
) {
it.toUpperCase()
}

println(data) // {JAVA | KOTLIN | C++ | PYTHON}

这是很常见的用法,将集合转换成字符串,高效利用便捷的joinToString 方法,开发的时候事半功倍,既然可以添加前缀,后缀,那么可以移除它们吗? 可以的,Kotlin 库函数提供了一些方法,帮助我们实现,如下代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go复制代码var data = "**hi dhl**"

// 移除前缀
println(data.removePrefix("**")) // hi dhl**
// 移除后缀
println(data.removeSuffix("**")) // **hi dhl
// 移除前缀和后缀
println(data.removeSurrounding("**")) // hi dhl

// 返回第一次出现分隔符后的字符串
println(data.substringAfter("**")) // hi dhl**
// 如果没有找到,返回原始字符串
println(data.substringAfter("--")) // **hi dhl**
// 如果没有找到,返回默认字符串 "no match"
println(data.substringAfter("--","no match")) // no match

data = "{JAVA | KOTLIN | C++ | PYTHON}"

// 移除前缀和后缀
println(data.removeSurrounding("{", "}")) // JAVA | KOTLIN | C++ | PYTHON

有了这些 Kotlin 库函数,我们就不需要在做 startsWith() 和 endsWith() 的检查了,如果让我们自己来实现上面的功能,我们需要花多少行代码去实现呢,一起来看一下 Kotlin 源码是如何实现的,上面的操作符最终都会调用以下代码,进行字符串的检查和截取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
arduino复制代码public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}

参考源码的实现,如果以后遇到类似的需求,但是 Kotlin 库函数有无法满足我们,我们可以以源码为基础进行扩展。

全文到这里就结束了,Kotlin 的强大不止于此,后面还会分享更多的技巧,在 Kotlin 的道路上还有很多实用的技巧等着我们一起来探索。

结语

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


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

  • 计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看:AndroidX-Jetpack-Practice
  • LeetCode / 剑指 offer / 国内外大厂面试题 / 多线程 题解,语言 Java 和 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矢量图动画:每人送一辆掘金牌小黄车

发表于 2020-07-10

看完本文,每人送一台小黄车,掘金牌的~

不得不说,矢量图在项目中用得少之又少,却很香!可缩放矢量图形(SVG)是一套语法规范,常在前端中使用,而VectorDrawable(Android中的矢量图)只实现了SVG的部分语法。使用VectorDrawable代替位图可以减小 APK 的大小,因为可以针对不同的屏幕密度调整同一文件的大小,而不会降低图片质量,同时可以实现一些复制的效果图。

可以从下面两个地方获得常用矢量图:

  • IconFont
  • Android Stuido 自带的Vector Asset Studio工具

「Android版本兼容问题」

由于兼容低版本问题,导致矢量图得不到推广吧?但是现在大多数手机系统都Android 5.0起步了吧。

矢量图VectorDrawable仅支持到Android 4.4,通过支持库可最低支持到Android 2.1。

矢量图动画AnimatedVectorDrawable仅支持到Android 5.0,通过支持库最低支持到Android 3.0。
「Gralde配置」

1
2
3
4
5
6
7
8
9
10
复制代码    android {
defaultConfig {
vectorDrawables.useSupportLibrary = true
}
}

dependencies {
//需要 Android 支持库 23.2 或更高版本以及 Android Plugin for Gradle 2.0 或更高版本
implementation 'com.android.support:appcompat-v7:23.2.0'
}

「美图减压鉴赏:」

矢量图

通过Android Studio的Vector Asset Studio工具来获取一张矢量图。


根据个人喜好配置Vector Assert。

会在drawable文件夹生成资源文件,例如这里生成ic_menu.xml文件:

1
2
3
4
5
6
7
8
9
10
复制代码<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
</vector>

没了解过矢量图,相信是看不懂path标签pathData属性的内容。

在vector标签设置矢量图大小,width和height属性分别为24dp。viewportWidth和viewportHeight属性表示画布的大小,表示将矢量图等分的份数,这里划分为24*24。可以理解在一张24dp*24dp的图片上有24*24个小方格,通过这些小方格,可以绘制不同图案。

path可以理解为路径,图片绘制的内容。fillColor属性表示填充颜色,pathData属性表示在图片上作画内容。


pathData属性的值是SVG的语法内容,通过下面的内容就可以了解pathData属性值的含义了。

常用命令:

  • 「M x,y」 移动到坐标(x,y)。M3,18表示将画笔移动到坐标(3,18)
  • 「L x,y」从当前的位置画一条直线到指定的位置(x,y)。
  • 「H x」 画水平线到x位置。
  • 「V y」 画垂直线到y位置。
  • 「Z」 闭合,连接终点和起点
  • 「A rx,ry,xRotationAnagle,radianFlag,sweepFlag,x,y」 画弧线,理解为椭圆的一部分,rx和ry表示 x轴和y轴半径,即椭圆的长短轴问题;xRotationAnagle表示x轴旋转角度(搞不明白用法);radianFlag 0表示取小弧度,1表示取大弧度;sweepFlag 0表示逆时针,表示1顺时针画弧线;x和y弧线的终点位置,起点位置为画笔所在的地方。
  • 「C x1,y1,x2,y2,x3,y3」 三次贝赛曲线
  • 「S x2,y2,x,y」 光滑三次贝塞尔曲线
  • 「Q x1,y1,x2,y2」 二次贝赛曲线
  • 「T x,y」 映射

ps:大写表示绝对坐标,小写表示相对坐标。

对pathData标签内容进行解读:

1
2
3
4
5
6
7
8
9
10
复制代码<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18 h18 v-2 L3,16 v2 z M3,13 h18 v-2 L3,11 v2 z M3,6 v2 h18 L21,6 L3,6 z"/>
</vector>

M3,18 将画笔移动到坐标(3,18);

h18从坐标(3,18)到坐标(21,18)画一条水平直线;

v-2从坐标(21,18)到坐标(21,16)画一条垂直直线;

L3,16从坐标(21,18)到坐标(3,16)画一条直线;

v2从坐标(3,16)到坐标(3,18)画一条垂直直线;

z 闭合起点和终点。

到这里,最底部的直线就会画出来了,其他两条线是相同原理。


「注意事项」:不要将pathData的值抽离出来放到string.xml文件,不然在兼容5.0以下机型生成png图片,会报pathData错误。

那path标签除了pathData属性,还有哪些可用的属性呢?

path标签

path标签可用属性:

  • name:路径名称,可在其他地方引用,例如矢量图动画引用;
  • strokeWidth:线条宽度,单位为viewportHeight或viewportWidth中的1等分;。
  • strokeColor:线条颜色;
  • strokeAlpha:线条透明度。0f->1f;
  • strokeLineJoin:线条拐角的形状。圆角round、斜切尖角miter、斜角bevel,例如正方形的四个角;
  • strokeLineCap:线条线帽形状。圆角round、正方形square、臂butt;
  • strokeMiterLimit:斜线miter与strokeWidth的比例上限。如果比例值超过这个值,不再显示尖角而是bevel斜角。当strokeLineJoin属性设置为miter才生效。

  • fillColor:填充颜色;
  • fillType:填充类型,取值nonZero、evenOdd;
  • fillAlpha:填充透明度;
  • trimPathStart:从路径开始位置剪掉比率的内容,留下剩下的,0f->1f;
  • trimPathEnd:从路径开始位置截取比率的内容,留下截取的内容,0f->1f;
  • trimPathOffset:trimPathStart或trimPathEnd的偏移量0f->1f;

例如:

XML布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:name="triangle"
android:pathData="M3,18 h18 v-5 L3,13 v5 z "
android:strokeWidth="1"
android:strokeLineJoin="round"
android:strokeAlpha="0.9"
android:strokeColor="@color/white"
android:trimPathStart="0.1"
android:strokeLineCap="round"
android:trimPathOffset="0.15"
/>
</vector>

PreView效果如下:

group标签

group标签主要是将多个path标签组合起来,子标签也可以是group标签,其属性作用于所有子标签,有以下属性:

  • name: 定义group标签名称;
  • translateX: x轴位移;
  • translateY: y轴位移;
  • rotation: 旋转;
  • scaleX: x轴缩放;
  • scaleY: y轴缩放;
  • pivotX: 缩放与旋转参考点X;
  • pivotY: 缩放与旋转参考点y;
    栗子:

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
复制代码<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">

<group
android:name="triangleGroup"
android:rotation="10"
android:translateX="1"
android:translateY="1"
android:scaleX="0.5f"
android:scaleY="0.5f"
android:pivotX="0.5"
android:pivotY="0.5">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M6,6 L9,12 H3 z"
android:strokeWidth="0.5"
android:strokeColor="@color/white"
android:strokeLineJoin="round" />

<path
android:fillColor="@color/chart_color_1"
android:pathData="M18,6 L21,12 H15 z"
android:strokeWidth="0.5"
android:strokeColor="@color/white"
android:strokeLineJoin="bevel" />

</group>
</vector>

效果图:

矢量图动画

只要胆子大,没有实现不了的矢量图,加上点动画效果那就更炫酷吊了。属性动画了解多少呢?好文链接==>:Android属性动画,看完这篇够用了吧

「矢量图动画步骤」

  1. 实现矢量图
  2. 实现属性动画
  3. 实现矢量属性动画粘合剂
  4. 布局引用,代码开始动画

轨迹动画

轨迹动画主要利用属性动画设置矢量图的trimPathStart或trimPahtEnd属性。要正确实现轨迹动画的前提条件:矢量图是一笔画出的,不能存在多次挪画笔的操作。

示例:

  1. 在drawable文件夹下创建vector_text.xml文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="24"
android:viewportHeight="24">

<path
android:name="pathText"
android:pathData="M9,6 L12,12 L15,6.5 18,12 21,6"
android:strokeWidth="0.5"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

画了一个白色的W:


2. 在animator文件夹下创建animator_text.xml文件。定义一个属性动画,操作矢量图的trimPathEnd属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码<?xml version="1.0" encoding="utf-8"?>
<set
xmlns:android="http://schemas.android.com/apk/res/android">

<objectAnimator
android:duration="2000"
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1"
android:repeatMode="reverse"
android:repeatCount="infinite"
android:valueType="floatType" />
//这里顺便操作矢量图的画笔颜色
<objectAnimator
android:duration="2000"
android:propertyName="strokeColor"
android:valueFrom="@color/white"
android:repeatMode="reverse"
android:repeatCount="infinite"
android:valueTo="@android:color/holo_blue_dark"
android:valueType="colorType" />

</set>
  1. 在drawable文件夹下创建animator_vector_text.xml文件,组合矢量图和属性动画,成为它两的粘合剂。由于兼容问题,需要在drawable-v21文件夹下创建。
1
2
3
4
5
6
复制代码<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/vector_text">
<target
android:name="pathText"
android:animation="@animator/animator_text" />
</animated-vector>

animated-vector标签的drawable属性值是第一步定义的矢量图文件名。target标签的name属性是我们在矢量图中定义的;而animation属性则是第二部定义的属性动画文件名。

  1. 在布局在引用animator_vector_text文件
1
2
3
4
5
复制代码    <ImageView
android:id="@+id/iv"
app:srcCompat="@drawable/vector_animator_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
  1. 在代码中开始动画:
1
2
复制代码    val animatable = iv.drawable as Animatable
animatable.start()

「效果图:」

路径动画

路径动画是利用矢量图中相同的关键点进行变幻的过程。

Android 5.0前不支持路径动画。

示例:

  1. 在drawable文件夹下创建vector_line.xml文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="24"
android:viewportHeight="24">

<path
android:name="pathLine"
android:pathData="M9,6 L12,6 L15,6 18,6 21,6"
android:strokeWidth="0.5"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

我们定义了几个关键点,画了一条直线:


2. 在animator文件夹下创建animator_morphing.xml文件。定义一个属性动画,操作矢量图的pathData属性。valueFrom是第一步创建直线矢量图的属性pathData的值,valueTo是W矢量图的pathData的值。

1
2
3
4
5
6
7
8
9
复制代码<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="2000"
android:propertyName="pathData"
android:valueFrom="M9,6 L12,6 L15,6 18,6 21,6"
android:valueTo="M9,6 L12,12 L15,6.5 18,12 21,6"
android:valueType="pathType" />
</set>
  1. 在drawable文件夹下创建animator_vector_line.xml文件,组合矢量图和属性动画,成为它两的粘合剂。由于兼容问题,需要在drawable-v21文件夹下创建。
1
2
3
4
5
6
7
复制代码<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/vector_line">
<target
android:name="pathLine"
android:animation="@animator/animator_morphing" />
</animated-vector>
  1. 在布局在引用animator_vector_text文件
1
2
3
4
5
复制代码    <ImageView
android:id="@+id/iv"
app:srcCompat="@drawable/vector_animator_line"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
  1. 在代码中开始动画效果:
1
2
复制代码    val animatable = iv.drawable as Animatable
animatable.start()

「效果图」

到这里就结束了,下面是属于大家的小黄车~

大家的小黄车

实例demo演示,用了2小时给大家制作的小黄车,希望不要嫌弃:

  1. 在drawable文件夹下创建vertor_bicycle.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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
复制代码<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="300dp"
android:height="300dp"
android:viewportWidth="200"
android:viewportHeight="200">

<!--左车轮-->
<group
android:name="leftWheel"
android:pivotX="40"
android:pivotY="70">

<path
android:name="leftWheelAxle"
android:pathData="M 40,70 L23,80M 40,70 L40,50 M 40,70 L57,80"
android:strokeWidth="1"
android:strokeColor="@color/white" />


<path
android:pathData="M 60,70 A 20,20,0,1,1,60,69 z"
android:strokeWidth="1"
android:strokeColor="@color/white" />

</group>
<!--右车轮-->
<group
android:name="rightWheel"
android:pivotX="130"
android:pivotY="70">

<path
android:name="rightWheelAxle"
android:pathData="M 130,70 L113,80 M 130,70 L130,50 M 130,70 L147,80"
android:strokeWidth="1"
android:strokeColor="@color/white" />

<path
android:pathData="M 150,70 A 20,20,0,1,1,150,69 z"
android:strokeWidth="1"
android:strokeColor="@color/white" />
</group>
<!--车链盒子-->
<path
android:name="chainBox"
android:fillColor="@color/colorPrimary"
android:pathData="M 35,62 h54 v12 H35 z"
android:strokeWidth="1"
android:strokeColor="@color/white" />
<!--车架-->
<path
android:pathData="M 50,69 L 65,40 L 80,69 M 86,65 L110,31
v 20 L130,70 "
android:strokeWidth="2"
android:strokeColor="@color/colorPrimary"
android:strokeLineJoin="round" />
<!--前车轮挡板-->
<path
android:pathData="M105,73 A 20,20,0,1,1,125,85"
android:strokeWidth="2"
android:strokeColor="@color/colorPrimary"
android:strokeLineJoin="round"
android:trimPathEnd="0.4" />
<!--车把-->
<path
android:pathData="M 110,31 V20 l -10,-4 h -3 M110,21 l -4,-10 h-3"
android:strokeWidth="2"
android:strokeColor="@color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!--车篮-->
<path
android:fillColor="@color/white"
android:pathData="M 111,41 h 21 v -12 H 111 z"
android:strokeWidth="1"
android:strokeColor="@color/white"
android:strokeLineCap="square"
android:strokeLineJoin="round" />
<!--掘金LOGO-->
<path
android:fillColor="@color/colorPrimary"
android:pathData="M 121,30 L122,31 L121,32 L120,31 z"
android:strokeWidth="0.5"
android:strokeColor="@color/colorPrimary"
android:strokeLineCap="square"
android:strokeLineJoin="miter" />
<path
android:pathData=" M119,33 L121,35,L123,33
M118,34 L121,37,L124,34"
android:strokeWidth="0.5"
android:strokeColor="@color/colorPrimary"
android:strokeLineCap="square"
android:strokeLineJoin="miter" />
<!--皮座-->
<path
android:fillColor="@color/white"
android:pathData="M 55,40 h 20 v-4 H56 v-3h-2"
android:strokeWidth="1"
android:strokeColor="@color/white"
android:strokeLineCap="square"
android:strokeLineJoin="round" />
<!--脚踏板-->
<group
android:name="pedal"
android:pivotX="82"
android:pivotY="68">
<path
android:pathData="M 82,68 L 98,80"
android:strokeWidth="0.5"
android:strokeColor="@color/white"
android:strokeLineCap="round" />

<path
android:fillColor="@color/white"
android:pathData="M 96,80 A 2,2,0,1,1,96,81 z"
android:strokeWidth="1"
android:strokeColor="@color/white" />
</group>
</vector>

「预览图:」


2. 在animator文件夹下创建animator_wheel.xml文件,实现车轮和脚踏旋转属性动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:interpolator="@android:interpolator/linear"
android:propertyName="rotation"
android:repeatCount="infinite"
android:valueType="floatType"
android:valueFrom="0f"
android:valueTo="360f"
android:repeatMode="restart"
android:duration="2000"/>
//可以再增加其他动画效果,例如颜色变化
</set>
  1. 在animator文件夹下创建animator_bicycle_left_to_right.xml布局文件,实现单车从左到右移动属性动画:
1
2
3
4
5
6
7
8
9
10
复制代码<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
android:propertyName="translationX"
android:valueFrom="-600f"
android:valueTo="900f"
android:valueType="floatType"
android:repeatCount="infinite"
android:repeatMode="restart"
android:duration="9000"
android:interpolator="@android:interpolator/linear"
/>
  1. 在drawable文件夹下创建verctor_animator_bicycle.xml文件,实现单车矢量图和属性动画的粘合剂,也就是最终的矢量图动画。由于兼容问题,需要在drawable-v21文件夹下创建。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/vertor_bicycle">

<target
android:animation="@animator/animator_wheel"
android:name="leftWheel"/>

<target
android:animation="@animator/animator_wheel"
android:name="rightWheel"/>

<target
android:animation="@animator/animator_wheel"
android:name="pedal"/>
</animated-vector>
  1. 在布局中引用verctor_animator_bicycle.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
复制代码<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@color/black">

<ImageView
android:id="@+id/iv"
app:srcCompat="@drawable/verctor_animator_bicycle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<TextView
android:id="@+id/btnStart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="50dp"
android:background="@color/white"
android:padding="10dp"
android:text="开始"
android:textColor="@color/colorPrimary"
android:textSize="16sp" />
</RelativeLayout>
  1. 在AppCompatActivity中代码调用:
1
2
3
4
复制代码    btnStart.setOnClickListener {
val animatable = iv.drawable as Animatable
animatable.start()
}

此时效果图:

7. 加上从左到右的属性动画:

1
2
3
4
5
6
7
8
复制代码    btnStart.setOnClickListener {
val animatable = iv.drawable as Animatable
animatable.start()

val animator = AnimatorInflater.loadAnimator(this, R.animator.animator_bicycle_left_to_right)
animator.setTarget(iv)
animator.start()
}

效果图:


好了哇,大家的小黄车已经造好,请给文章点个赞领取,如果不满意,可以自行定制小黄车哦~

「要啥自行车,想要个赞而已」

参考文章:

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

Android过渡动画,让APP更富有生机

官网

Android 5.0+ 高级动画开发系列 矢量图动画

Android Vector曲折的兼容之路

我的GitHub

本文使用 mdnice 排版

本文转载自: 掘金

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

听说你的JWT库用起来特别扭,推荐一款贼好用的!

发表于 2020-07-10

SpringBoot实战电商项目mall(35k+star)地址:github.com/macrozheng/…

摘要

以前一直使用的是jjwt这个JWT库,虽然小巧够用,但对JWT的一些细节封装的不是很好。最近发现了一个更好用的JWT库nimbus-jose-jwt,简单易用,API非常易于理解,对称加密和非对称加密算法都支持,推荐给大家!

简介

nimbus-jose-jwt是最受欢迎的JWT开源库,基于Apache 2.0开源协议,支持所有标准的签名(JWS)和加密(JWE)算法。

JWT概念关系

这里我们需要了解下JWT、JWS、JWE三者之间的关系,其实JWT(JSON Web Token)指的是一种规范,这种规范允许我们使用JWT在两个组织之间传递安全可靠的信息。而JWS(JSON Web Signature)和JWE(JSON Web Encryption)是JWT规范的两种不同实现,我们平时最常使用的实现就是JWS。

使用

接下来我们将介绍下nimbus-jose-jwt库的使用,主要使用对称加密(HMAC)和非对称加密(RSA)两种算法来生成和解析JWT令牌。

对称加密(HMAC)

对称加密指的是使用相同的秘钥来进行加密和解密,如果你的秘钥不想暴露给解密方,考虑使用非对称加密。

  • 要使用nimbus-jose-jwt库,首先在pom.xml添加相关依赖;
1
2
3
4
5
6
复制代码<!--JWT解析库-->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>8.16</version>
</dependency>
  • 创建JwtTokenServiceImpl作为JWT处理的业务类,添加根据HMAC算法生成和解析JWT令牌的方法,可以发现nimbus-jose-jwt库操作JWT的API非常易于理解;
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
复制代码/**
* Created by macro on 2020/6/22.
*/
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
@Override
public String generateTokenByHMAC(String payloadStr, String secret) throws JOSEException {
//创建JWS头,设置签名算法和类型
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.HS256).
type(JOSEObjectType.JWT)
.build();
//将负载信息封装到Payload中
Payload payload = new Payload(payloadStr);
//创建JWS对象
JWSObject jwsObject = new JWSObject(jwsHeader, payload);
//创建HMAC签名器
JWSSigner jwsSigner = new MACSigner(secret);
//签名
jwsObject.sign(jwsSigner);
return jwsObject.serialize();
}

@Override
public PayloadDto verifyTokenByHMAC(String token, String secret) throws ParseException, JOSEException {
//从token中解析JWS对象
JWSObject jwsObject = JWSObject.parse(token);
//创建HMAC验证器
JWSVerifier jwsVerifier = new MACVerifier(secret);
if (!jwsObject.verify(jwsVerifier)) {
throw new JwtInvalidException("token签名不合法!");
}
String payload = jwsObject.getPayload().toString();
PayloadDto payloadDto = JSONUtil.toBean(payload, PayloadDto.class);
if (payloadDto.getExp() < new Date().getTime()) {
throw new JwtExpiredException("token已过期!");
}
return payloadDto;
}
}
  • 创建PayloadDto实体类,用于封装JWT中存储的信息;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码/**
* Created by macro on 2020/6/22.
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Builder
public class PayloadDto {
@ApiModelProperty("主题")
private String sub;
@ApiModelProperty("签发时间")
private Long iat;
@ApiModelProperty("过期时间")
private Long exp;
@ApiModelProperty("JWT的ID")
private String jti;
@ApiModelProperty("用户名称")
private String username;
@ApiModelProperty("用户拥有的权限")
private List<String> authorities;
}
  • 在JwtTokenServiceImpl类中添加获取默认的PayloadDto的方法,JWT过期时间设置为60s;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码/**
* Created by macro on 2020/6/22.
*/
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
@Override
public PayloadDto getDefaultPayloadDto() {
Date now = new Date();
Date exp = DateUtil.offsetSecond(now, 60*60);
return PayloadDto.builder()
.sub("macro")
.iat(now.getTime())
.exp(exp.getTime())
.jti(UUID.randomUUID().toString())
.username("macro")
.authorities(CollUtil.toList("ADMIN"))
.build();
}
}
  • 创建JwtTokenController类,添加根据HMAC算法生成和解析JWT令牌的接口,由于HMAC算法需要长度至少为32个字节的秘钥,所以我们使用MD5加密下;
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
复制代码/**
* JWT令牌管理Controller
* Created by macro on 2020/6/22.
*/
@Api(tags = "JwtTokenController", description = "JWT令牌管理")
@Controller
@RequestMapping("/token")
public class JwtTokenController {

@Autowired
private JwtTokenService jwtTokenService;

@ApiOperation("使用对称加密(HMAC)算法生成token")
@RequestMapping(value = "/hmac/generate", method = RequestMethod.GET)
@ResponseBody
public CommonResult generateTokenByHMAC() {
try {
PayloadDto payloadDto = jwtTokenService.getDefaultPayloadDto();
String token = jwtTokenService.generateTokenByHMAC(JSONUtil.toJsonStr(payloadDto), SecureUtil.md5("test"));
return CommonResult.success(token);
} catch (JOSEException e) {
e.printStackTrace();
}
return CommonResult.failed();
}

@ApiOperation("使用对称加密(HMAC)算法验证token")
@RequestMapping(value = "/hmac/verify", method = RequestMethod.GET)
@ResponseBody
public CommonResult verifyTokenByHMAC(String token) {
try {
PayloadDto payloadDto = jwtTokenService.verifyTokenByHMAC(token, SecureUtil.md5("test"));
return CommonResult.success(payloadDto);
} catch (ParseException | JOSEException e) {
e.printStackTrace();
}
return CommonResult.failed();

}
}
  • 调用使用HMAC算法生成JWT令牌的接口进行测试;

  • 调用使用HMAC算法解析JWT令牌的接口进行测试。

非对称加密(RSA)

非对称加密指的是使用公钥和私钥来进行加密解密操作。对于加密操作,公钥负责加密,私钥负责解密,对于签名操作,私钥负责签名,公钥负责验证。非对称加密在JWT中的使用显然属于签名操作。

  • 如果我们需要使用固定的公钥和私钥来进行签名和验证的话,我们需要生成一个证书文件,这里将使用Java自带的keytool工具来生成jks证书文件,该工具在JDK的bin目录下;

  • 打开CMD命令界面,使用如下命令生成证书文件,设置别名为jwt,文件名为jwt.jks;
1
复制代码keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
  • 输入密码为123456,然后输入各种信息之后就可以生成证书jwt.jks文件了;

  • 将证书文件jwt.jks复制到项目的resource目录下,然后需要从证书文件中读取RSAKey,这里我们需要在pom.xml中添加一个Spring Security的RSA依赖;
1
2
3
4
5
6
复制代码<!--Spring Security RSA工具类-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
<version>1.0.7.RELEASE</version>
</dependency>
  • 然后在JwtTokenServiceImpl类中添加方法,从类路径下读取证书文件并转换为RSAKey对象;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码/**
* Created by macro on 2020/6/22.
*/
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
@Override
public RSAKey getDefaultRSAKey() {
//从classpath下获取RSA秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
KeyPair keyPair = keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
//获取RSA公钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
//获取RSA私钥
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey).privateKey(privateKey).build();
}
}
  • 我们可以在JwtTokenController中添加一个接口,用于获取证书中的公钥;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码/**
* JWT令牌管理Controller
* Created by macro on 2020/6/22.
*/
@Api(tags = "JwtTokenController", description = "JWT令牌管理")
@Controller
@RequestMapping("/token")
public class JwtTokenController {

@Autowired
private JwtTokenService jwtTokenService;

@ApiOperation("获取非对称加密(RSA)算法公钥")
@RequestMapping(value = "/rsa/publicKey", method = RequestMethod.GET)
@ResponseBody
public Object getRSAPublicKey() {
RSAKey key = jwtTokenService.getDefaultRSAKey();
return new JWKSet(key).toJSONObject();
}
}
  • 调用该接口,查看公钥信息,公钥是可以公开访问的;

  • 在JwtTokenServiceImpl中添加根据RSA算法生成和解析JWT令牌的方法,可以发现和上面的HMAC算法操作基本一致;
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
复制代码/**
* Created by macro on 2020/6/22.
*/
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
@Override
public String generateTokenByRSA(String payloadStr, RSAKey rsaKey) throws JOSEException {
//创建JWS头,设置签名算法和类型
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
//将负载信息封装到Payload中
Payload payload = new Payload(payloadStr);
//创建JWS对象
JWSObject jwsObject = new JWSObject(jwsHeader, payload);
//创建RSA签名器
JWSSigner jwsSigner = new RSASSASigner(rsaKey, true);
//签名
jwsObject.sign(jwsSigner);
return jwsObject.serialize();
}

@Override
public PayloadDto verifyTokenByRSA(String token, RSAKey rsaKey) throws ParseException, JOSEException {
//从token中解析JWS对象
JWSObject jwsObject = JWSObject.parse(token);
RSAKey publicRsaKey = rsaKey.toPublicJWK();
//使用RSA公钥创建RSA验证器
JWSVerifier jwsVerifier = new RSASSAVerifier(publicRsaKey);
if (!jwsObject.verify(jwsVerifier)) {
throw new JwtInvalidException("token签名不合法!");
}
String payload = jwsObject.getPayload().toString();
PayloadDto payloadDto = JSONUtil.toBean(payload, PayloadDto.class);
if (payloadDto.getExp() < new Date().getTime()) {
throw new JwtExpiredException("token已过期!");
}
return payloadDto;
}
}
  • 在JwtTokenController类,添加根据RSA算法生成和解析JWT令牌的接口,使用默认的RSA钥匙对;
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
复制代码/**
* JWT令牌管理Controller
* Created by macro on 2020/6/22.
*/
@Api(tags = "JwtTokenController", description = "JWT令牌管理")
@Controller
@RequestMapping("/token")
public class JwtTokenController {

@Autowired
private JwtTokenService jwtTokenService;

@ApiOperation("使用非对称加密(RSA)算法生成token")
@RequestMapping(value = "/rsa/generate", method = RequestMethod.GET)
@ResponseBody
public CommonResult generateTokenByRSA() {
try {
PayloadDto payloadDto = jwtTokenService.getDefaultPayloadDto();
String token = jwtTokenService.generateTokenByRSA(JSONUtil.toJsonStr(payloadDto),jwtTokenService.getDefaultRSAKey());
return CommonResult.success(token);
} catch (JOSEException e) {
e.printStackTrace();
}
return CommonResult.failed();
}

@ApiOperation("使用非对称加密(RSA)算法验证token")
@RequestMapping(value = "/rsa/verify", method = RequestMethod.GET)
@ResponseBody
public CommonResult verifyTokenByRSA(String token) {
try {
PayloadDto payloadDto = jwtTokenService.verifyTokenByRSA(token, jwtTokenService.getDefaultRSAKey());
return CommonResult.success(payloadDto);
} catch (ParseException | JOSEException e) {
e.printStackTrace();
}
return CommonResult.failed();
}
}
  • 调用使用RSA算法生成JWT令牌的接口进行测试;

  • 调用使用RSA算法解析JWT令牌的接口进行测试。

参考资料

官方文档:connect2id.com/products/ni…

项目源码地址

github.com/macrozheng/…

公众号

mall项目全套学习教程连载中,关注公众号第一时间获取。

公众号图片

本文转载自: 掘金

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

从源码看 Jetpack(5)- Startup 源码详解

发表于 2020-07-09

公众号:字节数组

Google Jetpack 自从推出以后,极大地改变了 Android 开发者们的开发模式,并降低了开发难度。这也要求我们对当中一些子组件的实现原理具有一定的了解,所以我就打算来写一系列 Jetpack 源码解析的文章,希望对你有所帮助 🤣🤣🤣

最近,Google Jetpack 官网上新增了一个名为 Startup 的组件。根据官方文档的介绍,Startup 提供了一种直接高效的方式用来在应用程序启动时对多个组件进行初始化,开发者可以依靠它来显式地设置多个组件间的初始化顺序并优化应用的启动时间

本文内容基于以下版本来进行讲解

1
java复制代码implementation "androidx.startup:startup-runtime:1.0.0-alpha01"

一、Startup 的意义

Startup 允许 Library 开发者和 App 开发者共享同一个 ContentProvider 来完成各自的初始化逻辑,并支持设置组件之间的初始化先后顺序,避免为每个需要初始化的组件都单独定义一个 ContentProvider,从而大大缩短应用的启动时间

目前很多第三方依赖库为了简化使用者的使用成本,就选择通过声明一个 ContentProvider 来获取 Context 对象并自动完成初始化过程。例如 Lifecycle 组件就声明了一个 ProcessLifecycleOwnerInitializer 用于获取 context 对象并完成初始化。而在 AndroidManifest 文件中声明的每一个 ContentProvider,在 Application 的 onCreate() 方法被调用之前就会预先被执行并调用内部的 onCreate() 方法。应用每构建并执行一个 ContentProvider 都是有着内存和时间的消耗成本,如果应用的 ContentProvider 过多,无疑会大大增加应用的启动时间

因此,Startup 的存在无疑是可以为很多依赖项(应用自身的组件和第三方组件)提供一个统一的初始化入口,当然这也需要等到 Startup 发布 release 版本并被大多数三方依赖组件采用之后了

二、如何使用

假设我们的项目中一共有三个 Library 需要进行初始化。当中,Library A 依赖于 Library B,Library B 依赖于 Library C,Library C 不需要其它依赖项,则此时可以分别为三个 Library 建立三个 Initializer 实现类

Initializer 是 Startup 提供的用于声明初始化逻辑和初始化顺序的接口,在 create(context: Context)方法中完成初始化过程并返回结果值,在dependencies()中指定初始化此 Initializer 前需要先初始化的其它 Initializer

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
kotlin复制代码class InitializerA : Initializer<A> {

//在此处完成组件的初始化,并返回初始化结果值
override fun create(context: Context): A {
return A.init(context)
}

//获取在初始化自身之前需要先初始化的 Initializer 列表
//如果不需要依赖于其它组件,则可以返回一个空列表
override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(InitializerB::class.java)
}

}

class InitializerB : Initializer<B> {

override fun create(context: Context): B {
return B.init(context)
}

override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(InitializerC::class.java)
}

}

class InitializerC : Initializer<C> {

override fun create(context: Context): C {
return C.init(context)
}

override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf()
}

}

Startup 提供了两种初始化方法,分别是自动初始化和手动初始化(延迟初始化)

自动初始化

在 AndroidManifest 文件中对 Startup 提供的 InitializationProvider 进行声明,并且用 meta-data 标签声明 Initializer 实现类的包名路径,value 必须是 androidx.startup。在这里我们只需要声明 InitializerA 即可,因为 InitializerB 和 InitializerC 均可以通过 InitializerA 的 dependencies()方法的返回值链式定位到

1
2
3
4
5
6
7
8
9
xml复制代码<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="leavesc.lifecyclecore.core.InitializerA"
android:value="androidx.startup" />
</provider>

只要完成以上步骤,当应用启动时,Startup 就会自动按照我们规定的顺序依次进行初始化。需要注意的是,如果 Initializer 之间不存在依赖关系,且都希望由 InitializationProvider 为我们自动初始化的话,此时所有的 Initializer 就必须都进行显式声明,且 Initializer 的初始化顺序会和在 provider 中的声明顺序保持一致

手动初始化

大部分情况下自动初始化的方式都能满足我们的要求,但在某些情况下并不适用,例如:组件的初始化成本(性能消耗或者时间消耗)较高且该组件最终未必会使用到,此时就可以将之改为在使用到的时候再来对其进行初始化了,即懒加载组件

手动初始化的 Initializer 不需要在 AndroidManifest 中进行声明,只需要通过调用以下方法进行初始化即可

1
kotlin复制代码val result = AppInitializer.getInstance(this).initializeComponent(InitializerA::class.java)

由于 Startup 内部会缓存 Initializer 的初始化结果值,所以重复调用 initializeComponent方法不会导致多次初始化,该方法也可用于自动初始化时获取初始化结果值

如果应用内的所有 Initializer 都不需要进行自动初始化的话,也可以不在 AndroidManifest 中声明 InitializationProvider

三、注意事项

移除 Initializer

假设我们在项目中引入的某个第三方依赖库自身使用到了 Startup 进行自动初始化,我们希望将之改为懒加载的方式,但我们无法直接修改第三方依赖库的 AndroidManifest 文件,此时就可以通过 AndroidManifest 的合并规则来移除指定的 Initializer

假设第三方依赖库的 Initializer 的包名路径是 xxx.xxx.InitializerImpl,在主项目工程的 AndroidManifest 文件中主动对其进行声明,并添加 tools:node="remove" 语句要求在合并 AndroidManifest 文件时移除自身,这样 Startup 就不会自动初始化 InitializerImpl 了

1
2
3
4
5
6
7
8
9
10
xml复制代码<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="leavesc.lifecyclecore.mylibrary.TestIn"
android:value="androidx.startup"
tools:node="remove" />
</provider>

禁止自动初始化

如果希望禁止 Startup 的所有自动初始化逻辑,但又不希望通过直接删除 provider 声明来实现的话,那么可以通过如上所述的方法来实现此目的

1
2
3
4
xml复制代码<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />

Lint 检查

Startup 包含一组 Lint 规则,可用于检查是否已正确定义了组件的初始化程序,可以通过运行 ./gradlew :app:lintDebug 来执行检查规则

例如,如果项目中声明的 InitializerB 没有在 AndroidManifest 中进行声明,且也不包含在其它 Initializer 的依赖项列表里时,通过 Lint 检查就可以看到如下的警告语句:

1
2
3
4
5
6
xml复制代码Errors found:

xxxx\leavesc\lifecyclecore\core\InitializerHodler.kt:52: Error: Every Initializer needs to be accompanied by a corresponding <meta-data> entry in the AndroidManifest.xml file. [Ensur
eInitializerMetadata]
class InitializerB : Initializer<B> {
^

四、源码解析

Startup 整个依赖库仅包含五个 Java 文件,整体逻辑比较简单,这里依次介绍下每个文件的作用

StartupLogger

StartupLogger 是一个日志工具类,用于向控制台输出日志

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
java复制代码public final class StartupLogger {

private StartupLogger() {
// Does nothing.
}

/
* The log tag.
*/
private static final String TAG = "StartupLogger";

/
* To enable logging set this to true.
*/
static final boolean DEBUG = false;

/
* Info level logging.
*
* @param message The message being logged
*/
public static void i(@NonNull String message) {
Log.i(TAG, message);
}

/
* Error level logging
*
* @param message The message being logged
* @param throwable The optional {@link Throwable} exception
*/
public static void e(@NonNull String message, @Nullable Throwable throwable) {
Log.e(TAG, message, throwable);
}
}

StartupException

StartupException 是一个自定义的 RuntimeException 子类,当 Startup 在初始化过程中遇到意外之外的情况时(例如,Initializer 存在循环依赖、Initializer 反射失败等情况),就会抛出 StartupException

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public final class StartupException extends RuntimeException {
public StartupException(@NonNull String message) {
super(message);
}

public StartupException(@NonNull Throwable throwable) {
super(throwable);
}

public StartupException(@NonNull String message, @NonNull Throwable throwable) {
super(message, throwable);
}
}

Initializer

Initiaizer 是 Startup 提供的用于声明初始化逻辑和初始化顺序的接口,在 create(context: Context)方法中完成初始化过程并返回结果值,在dependencies()中指定初始化此 Initializer 前需要先初始化的其它 Initializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public interface Initializer<T> {

/
* Initializes and a component given the application {@link Context}
*
* @param context The application context.
*/
@NonNull
T create(@NonNull Context context);

/
* @return A list of dependencies that this {@link Initializer} depends on. This is
* used to determine initialization order of {@link Initializer}s.
* <br/>
* For e.g. if a {@link Initializer} `B` defines another
* {@link Initializer} `A` as its dependency, then `A` gets initialized before `B`.
*/
@NonNull
List<Class<? extends Initializer<?>>> dependencies();
}

InitializationProvider

InitializationProvider 就是需要我们主动声明在 AndroidManifest 文件中的 ContentProvider,Startup 的整个初始化逻辑都是在这里进行统一触发的

由于 InitializationProvider 的作用仅是用于统一多个依赖项的初始化入口并获得 Context 对象,所以除了 onCreate() 方法会由系统自动调用外,其它方法是没有意义的,如果开发者调用了这几个方法就会直接抛出异常

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
java复制代码public final class InitializationProvider extends ContentProvider {
@Override
public boolean onCreate() {
Context context = getContext();
if (context != null) {
AppInitializer.getInstance(context).discoverAndInitialize();
} else {
throw new StartupException("Context cannot be null");
}
return true;
}

@Nullable
@Override
public Cursor query(
@NonNull Uri uri,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder) {
throw new IllegalStateException("Not allowed.");
}

@Nullable
@Override
public String getType(@NonNull Uri uri) {
throw new IllegalStateException("Not allowed.");
}

@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
throw new IllegalStateException("Not allowed.");
}

@Override
public int delete(
@NonNull Uri uri,
@Nullable String selection,
@Nullable String[] selectionArgs) {
throw new IllegalStateException("Not allowed.");
}

@Override
public int update(
@NonNull Uri uri,
@Nullable ContentValues values,
@Nullable String selection,
@Nullable String[] selectionArgs) {
throw new IllegalStateException("Not allowed.");
}
}

AppInitializer

AppInitializer 是 Startup 整个库的核心重点,整体代码量不足两百行,AppInitializer 的整体流程是:

  • 由 InitializationProvider 传入 Context 对象以此来获得 AppInitializer 唯一实例,并调用 discoverAndInitialize() 方法完成所有的自动初始化逻辑
  • discoverAndInitialize() 方法会先对 InitializationProvider 进行解析,获取到包含的所有 metadata,然后按声明顺序依次反射构建每个 metadata 指向的 Initializer 对象
  • 当在初始化某个 Initializer 对象之前,会首先判断其关联的依赖项 dependencies 是否为空。如果为空的话则直接调用其 create(Context) 方法进行初始化。如果不为空的话则先对 dependencies 进行初始化,对每个 dependency 均重复此遍历操作,直到不包含 dependencies 的 Initializer 最先初始化完成后才原路返回依次进行初始化,从而保证了 Initializer 之间初始化顺序的有序性
  • 当存在这几种情况时,Startup 会抛出异常:Initializer 实现类不包含无参构造方法、Initializer 之间存在循环依赖关系、Initializer 的初始化过程(create(Context) 方法)抛出了异常

AppInitializer 对外开放了 getInstance(@NonNull Context context) 方法用于获取唯一的静态实例

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
java复制代码public final class AppInitializer {

/
* 唯一的静态实例
* The {@link AppInitializer} instance.
*/
private static AppInitializer sInstance;

/
* 同步锁
* Guards app initialization.
*/
private static final Object sLock = new Object();

//用于存储所有已进行初始化了的 Initializer 及对应的初始化结果
@NonNull
final Map<Class<?>, Object> mInitialized;

@NonNull
final Context mContext;

/
* Creates an instance of {@link AppInitializer}
*
* @param context The application context
*/
AppInitializer(@NonNull Context context) {
mContext = context.getApplicationContext();
mInitialized = new HashMap<>();
}

/
* @param context The Application {@link Context}
* @return The instance of {@link AppInitializer} after initialization.
*/
@NonNull
@SuppressWarnings("UnusedReturnValue")
public static AppInitializer getInstance(@NonNull Context context) {
synchronized (sLock) {
if (sInstance == null) {
sInstance = new AppInitializer(context);
}
return sInstance;
}
}

···

}

discoverAndInitialize() 方法由 InitializationProvider 进行调用,由其触发所有需要进行默认初始化的依赖项的初始化操作

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
java复制代码@SuppressWarnings("unchecked")
void discoverAndInitialize() {
try {
Trace.beginSection(SECTION_NAME);

//获取 InitializationProvider 包含的所有 metadata
ComponentName provider = new ComponentName(mContext.getPackageName(),
InitializationProvider.class.getName());
ProviderInfo providerInfo = mContext.getPackageManager()
.getProviderInfo(provider, GET_META_DATA);
Bundle metadata = providerInfo.metaData;

//获取到字符串 androidx.startup
//因为 Startup 是以该字符串作为 metaData 的固定 value 来进行遍历的
//所以如果在 AndroidManifest 文件中声明了不同 value 则不会被初始化
String startup = mContext.getString(R.string.androidx_startup);

if (metadata != null) {
//用于标记正在准备进行初始化的 Initializer
//用于判断是否存在循环依赖的情况
Set<Class<?>> initializing = new HashSet<>();
Set<String> keys = metadata.keySet();
for (String key : keys) {
String value = metadata.getString(key, null);
if (startup.equals(value)) {
Class<?> clazz = Class.forName(key);
//确保 metaData 声明的包名路径指向的是 Initializer 的实现类
if (Initializer.class.isAssignableFrom(clazz)) {
Class<? extends Initializer<?>> component =
(Class<? extends Initializer<?>>) clazz;
if (StartupLogger.DEBUG) {
StartupLogger.i(String.format("Discovered %s", key));
}
//进行实际的初始化过程
doInitialize(component, initializing);
}
}
}
}
} catch (PackageManager.NameNotFoundException | ClassNotFoundException exception) {
throw new StartupException(exception);
} finally {
Trace.endSection();
}
}

doInitialize() 方法是实际调用了 Initializer 的 create(context: Context)的地方,其主要逻辑就是通过嵌套调用的方式来完成所有依赖项的初始化,当判断出存在循环依赖的情况时将抛出异常

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
60
61
62
63
64
65
66
67
68
69
java复制代码@NonNull
@SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"})
<T> T doInitialize(
@NonNull Class<? extends Initializer<?>> component,
@NonNull Set<Class<?>> initializing) {
synchronized (sLock) {
boolean isTracingEnabled = Trace.isEnabled();
try {
if (isTracingEnabled) {
// Use the simpleName here because section names would get too big otherwise.
Trace.beginSection(component.getSimpleName());
}
if (initializing.contains(component)) {
//initializing 包含 component,说明 Initializer 之间存在循环依赖
//直接抛出异常
String message = String.format(
"Cannot initialize %s. Cycle detected.", component.getName()
);
throw new IllegalStateException(message);
}
Object result;
if (!mInitialized.containsKey(component)) {
//如果 mInitialized 不包含 component
//说明 component 指向的 Initializer 还未进行初始化
initializing.add(component);
try {
//通过反射调用 component 的无参构造方法并初始化
Object instance = component.getDeclaredConstructor().newInstance();
Initializer<?> initializer = (Initializer<?>) instance;
//获取 initializer 的依赖项
List<Class<? extends Initializer<?>>> dependencies =
initializer.dependencies();

//如果 initializer 的依赖项 dependencies 不为空
//则遍历 dependencies 每个 item 进行初始化
if (!dependencies.isEmpty()) {
for (Class<? extends Initializer<?>> clazz : dependencies) {
if (!mInitialized.containsKey(clazz)) {
doInitialize(clazz, initializing);
}
}
}
if (StartupLogger.DEBUG) {
StartupLogger.i(String.format("Initializing %s", component.getName()));
}
//进行初始化
result = initializer.create(mContext);
if (StartupLogger.DEBUG) {
StartupLogger.i(String.format("Initialized %s", component.getName()));
}
//将已经进行初始化的 component 从 initializing 中移除掉
//避免误判循环依赖
initializing.remove(component);
//将初始化结果保存起来
mInitialized.put(component, result);
} catch (Throwable throwable) {
throw new StartupException(throwable);
}
} else {
//component 指向的 Initializer 已经进行初始化
//此处直接获取缓存值直接返回即可
result = mInitialized.get(component);
}
return (T) result;
} finally {
Trace.endSection();
}
}
}

五、不足点

Startup 的优点我在上边已经列举了,最后再来列举下它的几个不足点

  1. InitializationProvider 的 onCreate() 方法是在主线程被调用的,导致我们的每个 Initializer 默认就都是运行在主线程,这对于某些初始化时间过长,需要运行在子线程的组件来说就不太适用了。且 Initializer 的 create(context: Context) 方法的本意是完成组件的初始化并返回初始化的结果值,如果在此处通过主动 new Thread 来运行耗时组件的初始化,那么我们就无法返回有意义的结果值,间接导致后续也无法通过 AppInitializer 获取到缓存的初始化结果值
  2. 如果某组件的初始化需要依赖于其它耗时组件(初始化时间过长,需要运行在子线程)的结果值,此时 Startup 一样不适用
  3. 对于已经使用 ContentProvider 完成初始化逻辑的第三方依赖库,我们一般也无法直接修改其初始化逻辑(除非 clone 该项目导到本地直接修改源码),所以在初始阶段 Startup 的意义主要在于统一项目本地组件的初始化入口,需要等到 Startup 被大多数开发者接受并使用后,才更加具有性能优势

本文转载自: 掘金

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

初识ES(ES系列一)

发表于 2020-07-09

前言

“这个世界已然被数据淹没。多年来,我们系统间流转和产生的大量数据已让我们不知所措。 现有的技术都集中在如何解决数据仓库存储以及如何结构化这些数据。 这些看上去都挺美好,直到你实际需要基于这些数据实时做决策分析的时候才发现根本不是那么一回事。Elasticsearch是一个分布式、可扩展、实时的搜索与数据分析引擎。无论你是需要全文搜索,还是结构化数据的实时统计,或者两者结合,Elasticsearch不仅仅只是全文搜索,我们还将介绍结构化搜索、数据分析、复杂的人类语言处理、地理位置和对象间关联关系等”。
– Elasticsearch: 权威指南 » 前言

上边一段摘录自Elasticsearch: 权威指南,告诉了我们ES拥有的超强能力。这个系列文章主要是根据在工作中的实战总结,最终目的是探讨一下如何根据现有的业务需求平滑的迁移到ES(使用java技术栈),去弥补关系型数据库的局限性,提高我们的处理数据的能力。

本系列暂时规划为四个部分

  • 第一部分:谈谈为什么我们需要使用ES,以及相关基本概念
  • 第二部分:我们讨论Mysql -> ES数据的迁移策略、技术选型,以及在各种场景下选择的同步策略。
  • 第三部分: 我们将实现spring boot与ES的集成,以及使用ES提供的API去“翻译sql”
  • 第四部分:我们以前边的技术基础,去尝试解决一个现实中RDBMS单表数据量千万级,同时还有多表join的情况下如何使用ES去解决这个令人头痛的难题。

话不多说,我们先来看为什么需要使用ES。

一、为什么需要 Elasticsearch

1、快,就是快

我们使用ES最主要的原因就是因为ES速度快。特别是当数据量到达千万级以上的时候,关系型数据库单表无论是通过增加索引、分库分表来优化,最终能够优化的效果往往不如人意(且分库分表复杂度较高),而ES可以轻松hold住千万、亿级数据量。

为了达到这样的速度,ES使用了有限状态转换器实现了用于全文检索的倒排索引,实现了用于存储数值数据和地理位置数据的BKD树,以及用于分析的列存储。并且由于ES默认就是将所有的字段全部建立索引,所以我们在查询的时候可以实时地检索到数据。

2、不只是全文检索

这一点也是我们使用ES很重要的原因,在传统关系型数据库中,我们很多使用需要采用模糊查询的方式来获取想要的数据。

1
sql复制代码select * from author where name like '%鲁迅%'.

在数据量较大的时候,就算我们在name字段上建立索引,上边的SQL也是不会走索引的(不符合最左前缀原则),将执行全表扫描,性能可想而知。
但是若在ES中实现上述的查询则很简单,由于ES采用的存储、索引策略,可以实时的查询到想要的结果。

但是这里无论是精确查询还是模糊查询,本质上来时“传统关系型数据库”的思维,ES本质上是一个搜索、分析引擎,搜索引擎从一个抽象的角度来讲,它做了三件事:收集数据、建立数据索引、相关度排名。在ES中收集数据是有我们来完成的,1、比如将关系型数据库中的数据同步到ES中;2、然后ES将同步的数据建立索引,方便之后的查询;3、最后一步也是最重要的一步,也就是相关度排名,如此大量的数据,不会是所有都拥有相同的重要程度,所以排名的好坏对于搜索引擎来说很重要,它决定了搜索质量的高低。

在ES中也是这样,我们在使用查询的时候不仅仅是将数据查询出来,ES还能将关键词检索到的数据根据相关度排名。这也更符合我们人类的思维方式,假设我们使用关键词“鲁迅”不是为了查询所有包含鲁迅关键词的文章、报道。而是希望把大家都认为有关鲁迅这个人最重要的作品、个人背景、人生经历、历史评价等等信息根据相关度排序检索出来。

所以这也是传统关系型数据库不能提供的功能,它”似乎“更能懂得我们真正想要的结果。

3、完整的生态系统

ElasticSearch是Elastic公司的核心技术栈,他们还包括Logstash、Filebeat、Kibana等等。

我们可以使用ES提供的技术栈,实现各种目的,比如典型的采用ELK + Filebeat搭建一套分布式日志采集系统,将各个微服务的日志通过Filebeat收集推送到Logstash管道做处理,然后logstash推送给ES,最终Kibana展示,查询日志。

4、可扩展性

对于大多数的数据库而言,通常需要对应用程序进行非常大的改动,才能利用上横向扩容的新增资源。 与之相反的是,ElastiSearch天生就是分布式的 ,在ES集群中,我们可以随时增加、摘除节点,集群将会重新平均分布所有的数据。它知道如何通过管理多节点来提高扩容性和可用性。 这也意味着你的应用无需关注这个问题。


当然,任何技术都有其合适的应用场景,ES不支持事务、同时更适合查多改少的场景,所以我们在选择技术栈的时候需要注意这些限制。

二、核心概念

无论我们是在开发、维护ES集群的时候,弄清楚ES中的核心概念都是重要的,下面我们采用“从小到大”的方式来介绍ES的核心概念。

1、字段(Fields)

字段是ES中最小的独立单元数据,每一个字段有自己的数据类型(可以自己定义覆盖ES自动设置的数据类型),我们还可以对单个字段设置是否分析、分词器等等。

核心的数据类型有string、Numeric、DateDate、Boolean、Binary、Range等等,复杂类型有Object、Nested,详细的可以参考官方的介绍

2、文档(Documents)

在ES中文档的概念相当于RDBMS中的一行数据,不同的是在ES中文档的存储是直接使用json格式存储的(也就是可以嵌套),而不是像RDBMS中把数据”压平”了存储,这一点也是Nosql和关系型数据库比较大的区别。

下面是一个文档的例子

1
2
3
4
5
6
7
8
9
10
json复制代码{
"_id": 3,
“_type”: [“your index type”],
“_index”: [“your index name”],
"_source":{
"age": 28,
"name": ["daniel”],
"year":1989,
}
}

3、映射(Mapping)

”Mapping is the process of defining how a document, and the fields it contains, are stored and indexed.“

也就是Mapping是定义文档和字段如何存储和索引,使用Mapping可以定义下面这些信息

  • 哪些字段应该作为全文索引
  • 哪些字段包括numbers, dates, geolocations.
  • 时间类型的格式
  • 定义规则控制动态增加字段的mapping

4、索引(Index)

在ES中是最大的数据存储概念,它是由许多具有相同特征的文档组成的一个集合。 由于在ES7.0之后逐渐废除Type类型,所以Index从”数据库“的概念变成了实际上的”表“概念,我们可以把它近似地当成RDBMS中的表,但是要注意Index只是一个逻辑上的概念,真实的数据是分开存储在各个分片中的。

5、分片(Shards)

这里需要多说几句,搞清楚分片对于理解ES集群(扩容、容错、路由)原理是很重要的。

首先,每个分片都是一个Lucene索引实例,我们可以将其视作一个独立的搜索引擎,它能够对Elasticsearch集群中的数据子集进行索引并处理相关查询。

分片分为两种:主分片(Primary Shard)、副本分片(Replica Shard)

  • 主分片:由于所有的数据都会在主分片中存储,所以主分片决定了文档存储数量的上限,但是一个索引的主分片数在创建时一旦指定,那么主分片的数量就不能改变,这是因为当索引一个文档时,它是通过其主键(默认)Hash到对应的主分片上(类似于RDBMS分库分表路由策略),所以我们一旦修改主分片数量,那么将无法定位到具体的主分片上。在mapping时我们可以设置number_of_shards值,最大值默认为1024。
  • 副本分片:我们可以为一个主分片根据实际的硬件资源指定任意数量的副本分片,上边已经说过,每个分片都可以处理查询,所以我们可以增加副本分片的资源(相应硬件资源提升)来提升系统的处理能力。同样,在mapping时,可以通过number_of_replicas参数来设置每个主分片的副本分片数量

但是要注意,为了容错(节点主机宕机导致数据丢失),主分片和副本分片不能在同一个节点上,防止节点宕机导致部分数据丢失。

6、实例和节点(Instances and Nodes)

”A node is a running instance of Elasticsearch which belongs to a cluster”

节点也就是运行的ES实例,它隶属于某一个集群。通常来说,我们在一个服务器上边部署一个节点,但有时候为了测试集群,也可以在单台服务器上启动多个节点来测试。假设当我们启动一个节点想加入已经存在的一个集群中时,可以在配置文件中配置改集群的名称,以及通信的ip + port,ES会自动通过“单点传送”的方式来自动发现集群并尝试加入这个集群。

节点分为下面几种类型:

  • Master-eligible node:负责管理和配置集群,例如增加、删除节点相关动作。
  • Data node:文档实际上就存储在数据节点,负责执行相关的操作, 例如CRUD、搜索、聚合等等操作
  • Coordinating node:用于处理请求的路由、查询结果集的汇总、智能负载均衡..
  • Ingest node:用于文档在indexing之前进行预处理
  • Machine learning node:主要用于机器学习的任务,但是需要Basic License。

关于ES节点的详细介绍,可以参考官方文档

7、集群(Cluster)

  • 在ES中,集群是由一个或多个ES节点组成的,每个集群都有一个唯一的名称/标识符,用作节点加入集群的依据。
  • 每一个集群中有一个Master节点,若是这个Master节点挂了,集群可以用其他的节点代替。
    在ES中还支持跨集群复制、跨集群检索等等功能,详细的可以参考Cross-cluster replication,Search across clusters

在上边的两节,我们知道了ES拥有的超强数据处理能力,也了解了ES一些基础的概念。因为网上关于ES使用的倒序索引原理分析已经比较多了,我这里就不在重新描述其中的细节了,具体原理推荐大家一篇文章参考:时间序列数据库的秘密 (2)——索引

参考

  • Elasticsearch Reference [7.8] » Mapping
  • Elasticsearch Reference [7.8] » Index modules
  • Elasticsearch Reference [7.8] » Set up Elasticsearch » Configuring Elasticsearch » Node
  • Elasticsearch Reference [7.8] » Glossary of terms
  • Elasticsearch:是什么?你为什么需要他?

本文转载自: 掘金

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

SpringSecurity动态鉴权流程解析 掘金新人第

发表于 2020-07-09

如果不能谈情说爱,我们可以自怜自爱。

楔子

上一篇文我们讲过了SpringSecurity的认证流程,相信大家认真读过了之后一定会对SpringSecurity的认证流程已经明白个七八分了,本期是我们如约而至的动态鉴权篇,看这篇并不需要一定要弄懂上篇的知识,因为讲述的重点并不相同,你可以将这两篇看成两个独立的章节,从中撷取自己需要的部分。

祝有好收获。

本文代码: 码云地址 GitHub地址

  1. 📖SpringSecurity的鉴权原理

上一篇文我们讲认证的时候曾经放了一个图,就是下图:

image.png

整个认证的过程其实一直在围绕图中过滤链的绿色部分,而我们今天要说的动态鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor。

1. FilterSecurityInterceptor

想知道怎么动态鉴权首先我们要搞明白SpringSecurity的鉴权逻辑,从上图中我们也可以看出:FilterSecurityInterceptor是这个过滤链的最后一环,而认证之后就是鉴权,所以我们的FilterSecurityInterceptor主要是负责鉴权这部分。

一个请求完成了认证,且没有抛出异常之后就会到达FilterSecurityInterceptor所负责的鉴权部分,也就是说鉴权的入口就在FilterSecurityInterceptor。

我们先来看看FilterSecurityInterceptor的定义和主要方法:

1
2
3
4
5
6
7
8
9
复制代码public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter {

public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
}

上文代码可以看出FilterSecurityInterceptor是实现了抽象类AbstractSecurityInterceptor的一个实现类,这个AbstractSecurityInterceptor中预先写好了一段很重要的代码(后面会说到)。

FilterSecurityInterceptor的主要方法是doFilter方法,过滤器的特性大家应该都知道,请求过来之后会执行这个doFilter方法,FilterSecurityInterceptor的doFilter方法出奇的简单,总共只有两行:

第一行是创建了一个FilterInvocation对象,这个FilterInvocation对象你可以当作它封装了request,它的主要工作就是拿请求里面的信息,比如请求的URI。

第二行就调用了自身的invoke方法,并将FilterInvocation对象传入。

所以我们主要逻辑肯定是在这个invoke方法里面了,我们来打开看看:

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
复制代码public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}

// 进入鉴权
InterceptorStatusToken token = super.beforeInvocation(fi);

try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}

super.afterInvocation(token, null);
}
}

invoke方法中只有一个if-else,一般都是不满足if中的那三个条件的,然后执行逻辑会来到else。

else的代码也可以概括为两部分:

  1. 调用了super.beforeInvocation(fi)。
  2. 调用完之后过滤器继续往下走。

第二步可以不看,每个过滤器都有这么一步,所以我们主要看super.beforeInvocation(fi),前文我已经说过,
FilterSecurityInterceptor实现了抽象类AbstractSecurityInterceptor,
所以这个里super其实指的就是AbstractSecurityInterceptor,
那这段代码其实调用了AbstractSecurityInterceptor.beforeInvocation(fi),
前文我说过AbstractSecurityInterceptor中有一段很重要的代码就是这一段,
那我们继续来看这个beforeInvocation(fi)方法的源码:

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
复制代码protected InterceptorStatusToken beforeInvocation(Object object) {
Assert.notNull(object, "Object was null");
final boolean debug = logger.isDebugEnabled();

if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
throw new IllegalArgumentException(
"Security invocation attempted for object "
+ object.getClass().getName()
+ " but AbstractSecurityInterceptor only configured to support secure objects of type: "
+ getSecureObjectClass());
}

Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);

Authentication authenticated = authenticateIfRequired();

try {
// 鉴权需要调用的接口
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));

throw accessDeniedException;
}

}

源码较长,这里我精简了中间的一部分,这段代码大致可以分为三步:

  1. 拿到了一个Collection<ConfigAttribute>对象,这个对象是一个List,其实里面就是我们在配置文件中配置的过滤规则。
  2. 拿到了Authentication,这里是调用authenticateIfRequired方法拿到了,其实里面还是通过SecurityContextHolder拿到的,上一篇文章我讲过如何拿取。
  3. 调用了accessDecisionManager.decide(authenticated, object, attributes),前两步都是对decide方法做参数的准备,第三步才是正式去到鉴权的逻辑,既然这里面才是真正鉴权的逻辑,那也就是说鉴权其实是accessDecisionManager在做。

2. AccessDecisionManager

前面通过源码我们看到了鉴权的真正处理者:AccessDecisionManager,是不是觉得一层接着一层,就像套娃一样,别急,下面还有。先来看看源码接口定义:

1
2
3
4
5
6
7
8
9
10
11
复制代码public interface AccessDecisionManager {

// 主要鉴权方法
void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
InsufficientAuthenticationException;

boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);
}

AccessDecisionManager是一个接口,它声明了三个方法,除了第一个鉴权方法以外,还有两个是辅助性的方法,其作用都是甄别 decide方法中参数的有效性。

那既然是一个接口,上文中所调用的肯定是他的实现类了,我们来看看这个接口的结构树:

image.png

从图中我们可以看到它主要有三个实现类,分别代表了三种不同的鉴权逻辑:

  • AffirmativeBased:一票通过,只要有一票通过就算通过,默认是它。
  • UnanimousBased:一票反对,只要有一票反对就不能通过。
  • ConsensusBased:少数票服从多数票。

这里的表述为什么要用票呢?因为在实现类里面采用了委托的形式,将请求委托给投票器,每个投票器拿着这个请求根据自身的逻辑来计算出能不能通过然后进行投票,所以会有上面的表述。

也就是说这三个实现类,其实还不是真正判断请求能不能通过的类,真正判断请求是否通过的是投票器,然后实现类把投票器的结果综合起来来决定到底能不能通过。

刚刚已经说过,实现类把投票器的结果综合起来进行决定,也就是说投票器可以放入多个,每个实现类里的投票器数量取决于构造的时候放入了多少投票器,我们可以看看默认的AffirmativeBased的源码。

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
复制代码public class AffirmativeBased extends AbstractAccessDecisionManager {

public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
super(decisionVoters);
}

// 拿到所有的投票器,循环遍历进行投票
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;

for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);

if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}

switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;

case AccessDecisionVoter.ACCESS_DENIED:
deny++;

break;

default:
break;
}
}

if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}

// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}

AffirmativeBased的构造是传入投票器List,其主要鉴权逻辑交给投票器去判断,投票器返回不同的数字代表不同的结果,然后AffirmativeBased根据自身一票通过的策略决定放行还是抛出异常。

AffirmativeBased默认传入的构造器只有一个->WebExpressionVoter,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果。

所以SpringSecurity默认的鉴权逻辑就是根据配置文件中的配置进行鉴权,这是符合我们现有认知的。

  1. ✍动态鉴权实现

通过上面一步步的讲述,我想你也应该理解了SpringSecurity到底是什么实现鉴权的,那我们想要做到动态的给予某个角色不同的访问权限应该怎么做呢?

既然是动态鉴权了,那我们的权限URI肯定是放在数据库中了,我们要做的就是实时的在数据库中去读取不同角色对应的权限然后与当前登录的用户做个比较。

那我们要做到这一步可以想些方案,比如:

  • 直接重写一个AccessDecisionManager,将它用作默认的AccessDecisionManager,并在里面直接写好鉴权逻辑。
  • 再比如重写一个投票器,将它放到默认的AccessDecisionManager里面,和之前一样用投票器鉴权。
  • 我看网上还有些博客直接去做FilterSecurityInterceptor的改动。

我一向喜欢小而美的方式,少做改动,所以这里演示的代码将以第二种方案为基础,稍加改造。

那么我们需要写一个新的投票器,在这个投票器里面拿到当前用户的角色,使其和当前请求所需要的角色做个对比。

单单是这样还不够,因为我们可能在配置文件中也配置的有一些放行的权限,比如登录URI就是放行的,所以我们还需要继续使用我们上文所提到的WebExpressionVoter,也就是说我要自定义权限+配置文件双行的模式,所以我们的AccessDecisionManager里面就会有两个投票器:WebExpressionVoter和自定义的投票器。

紧接着我们还需要考虑去使用什么样的投票策略,这里我使用的是UnanimousBased一票反对策略,而没有使用默认的一票通过策略,因为在我们的配置中配置了除了登录请求以外的其他请求都是需要认证的,这个逻辑会被WebExpressionVoter处理,如果使用了一票通过策略,那我们去访问被保护的API的时候,WebExpressionVoter发现当前请求认证了,就直接投了赞成票,且因为是一票通过策略,这个请求就走不到我们自定义的投票器了。

注:你也可以不用配置文件中的配置,将你的自定义权限配置都放在数据库中,然后统一交给一个投票器来处理。

1. 重新构造AccessDecisionManager

那我们可以放手去做了,首先重新构造AccessDecisionManager,
因为投票器是系统启动的时候自动添加进去的,所以我们想多加入一个构造器必须自己重新构建AccessDecisionManager,然后将它放到配置中去。

而且我们的投票策略已经改变了,要由AffirmativeBased换成UnanimousBased,所以这一步是必不可少的。

并且我们还要自定义一个投票器起来,将它注册成Bean,AccessDecisionProcessor就是我们需要自定义的投票器。

1
2
3
4
5
6
7
8
9
10
11
复制代码@Bean
public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
return new AccessDecisionProcessor();
}

@Bean
public AccessDecisionManager accessDecisionManager() {
// 构造一个新的AccessDecisionManager 放入两个投票器
List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
return new UnanimousBased(decisionVoters);
}

定义完AccessDecisionManager之后,我们将它放入启动配置:

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
复制代码@Override
protected void configure(HttpSecurity http) throws Exception {

http.authorizeRequests()
// 放行所有OPTIONS请求
.antMatchers(HttpMethod.OPTIONS).permitAll()
// 放行登录方法
.antMatchers("/api/auth/login").permitAll()
// 其他请求都需要认证后才能访问
.anyRequest().authenticated()
// 使用自定义的 accessDecisionManager
.accessDecisionManager(accessDecisionManager())
.and()
// 添加未登录与权限不足异常处理器
.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler())
.authenticationEntryPoint(restAuthenticationEntryPoint())
.and()
// 将自定义的JWT过滤器放到过滤链中
.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class)
// 打开Spring Security的跨域
.cors()
.and()
// 关闭CSRF
.csrf().disable()
// 关闭Session机制
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

这样之后,SpringSecurity里面的AccessDecisionManager就会被替换成我们自定义的AccessDecisionManager了。

2. 自定义鉴权实现

上文配置中放入了两个投票器,其中第二个投票器就是我们需要创建的投票器,我起名为AccessDecisionProcessor。

投票其也是有一个接口规范的,我们只需要实现这个AccessDecisionVoter接口就行了,然后实现它的方法。

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
复制代码@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {
@Autowired
private Cache caffeineCache;

@Override
public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
assert authentication != null;
assert object != null;

// 拿到当前请求uri
String requestUrl = object.getRequestUrl();
String method = object.getRequest().getMethod();
log.debug("进入自定义鉴权投票器,URI : {} {}", method, requestUrl);

String key = requestUrl + ":" + method;
// 如果没有缓存中没有此权限也就是未保护此API,弃权
PermissionInfoBO permission = caffeineCache.get(CacheName.PERMISSION, key, PermissionInfoBO.class);
if (permission == null) {
return ACCESS_ABSTAIN;
}

// 拿到当前用户所具有的权限
List<String> roles = ((UserDetail) authentication.getPrincipal()).getRoles();
if (roles.contains(permission.getRoleCode())) {
return ACCESS_GRANTED;
}else{
return ACCESS_DENIED;
}
}

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}
}

大致逻辑是这样:我们以URI+METHOD为key去缓存中查找权限相关的信息,如果没有找到此URI,则证明这个URI没有被保护,投票器可以直接弃权。

如果找到了这个URI相关权限信息,则用其与用户自带的角色信息做一个对比,根据对比结果返回ACCESS_GRANTED或ACCESS_DENIED。

当然这样做有一个前提,那就是我在系统启动的时候就把URI权限数据都放到缓存中了,系统一般在启动的时候都会把热点数据放入缓存中,以提高系统的访问效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码@Component
public class InitProcessor {
@Autowired
private PermissionService permissionService;
@Autowired
private Cache caffeineCache;

@PostConstruct
public void init() {
List<PermissionInfoBO> permissionInfoList = permissionService.listPermissionInfoBO();
permissionInfoList.forEach(permissionInfo -> {
caffeineCache.put(CacheName.PERMISSION, permissionInfo.getPermissionUri() + ":" + permissionInfo.getPermissionMethod(), permissionInfo);
});
}
}

这里我考虑到权限URI可能非常多,所以将权限URI作为key放到缓存中,因为一般缓存中通过key读取数据的速度是O(1),所以这样会非常快。

鉴权的逻辑到底如何处理,其实是开发者自己来定义的,要根据系统需求和数据库表设计进行综合考量,这里只是给出一个思路。

如果你一时没有理解上面权限URI做key的思路的话,我可以再举一个简单的例子:

比如你也可以拿到当前用户的角色,查到这个角色下的所有能访问的URI,然后比较当前请求的URI,有一致的则证明当前用户的角色下包含了这个URI的权限所以可以放行,没有一致的则证明不够权限不能放行。

这种方式的话去比较URI的时候可能会遇到这样的问题:我当前角色权限是/api/user/**,而我请求的URI是/user/get/1,这种Ant风格的权限定义方式,可以用一个工具类来进行比较:

1
2
3
4
5
6
复制代码@Test
public void match() {
AntPathMatcher antPathMatcher = new AntPathMatcher();
// true
System.out.println(antPathMatcher.match("/user/**", "/user/get/1"));
}

这是我是为了测试直接new了一个AntPathMatcher,实际中你可以将它注册成Bean,注入到AccessDecisionProcessor中进行使用。

它也可以比较RESTFUL风格的URI,比如:

1
2
3
4
5
6
复制代码@Test
public void match() {
AntPathMatcher antPathMatcher = new AntPathMatcher();
// true
System.out.println(antPathMatcher.match("/user/{id}", "/user/1"));
}

在面对真正的系统的时候,往往是根据系统设计进行组合使用这些工具类和设计思想。

注:ACCESS_GRANTED,ACCESS_DENIED和ACCESS_ABSTAIN是AccessDecisionVoter接口中带有的常量。

后记

好了,上面就是这期的所有内容了,我从周日就开始肝了。

我写文章啊,一般要写三遍:

  • 第一遍是初稿,把思路里面已有的梳理之后转化成文字。
  • 第二遍是查漏补缺,看看有哪些原来的思路里面遗漏的地方可以补上。
  • 第三遍就是对语言结构的重新整理。

经此三遍之后,我才敢发,所以认证和授权分成两篇了,一是可以分开写,二是写到一块很费时间,我又是第一次写文,不敢设太大的目标。

这就好比你第一次背单词就告诉自己一天要背1000个,最后当然背不下来,然后就会自己责怪自己,最终陷入循环。

初期设立太大的目标往往会适得其反,前期一定要挑一些自己力所能及的,先尝到完成的喜悦,再慢慢加大难度,这个道理是很多做事的道理。

这篇结束后SpringSecurity的认证与授权就都完成了,希望大家有所收获。

上一篇SpringSecurity的认证流程,大家也可以再回顾一下。

下一篇的话还没想好,估计会写一点开发时候常遇到的通用工具或配置的问题,放松放松,oauth2的东西也有打算,不知道oauth2的东西有人看吗。

如果觉得写的还不错的话,可以抬一手帮我点个赞哈,毕竟我也需要升级啊🚀

你们的每个点赞收藏与评论都是对我知识输出的莫大肯定,如果有文中有什么错误或者疑点或者对我的指教都可以在评论区下方留言,一起讨论。

我是耳朵,一个一直想做知识输出的人,下期见。

本文代码:码云地址 GitHub地址

本文转载自: 掘金

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

MyBatis 框架基本使用及深入理解

发表于 2020-07-09

题记: 本文对 Mybatis 框架相关内容进行整理,从最开始使用JDBC 操作数据库,理解 DAO 层底层需要执行的步骤,到仿照 MyBatis 自定义框架,对 MyBatis 框架结构进行梳理。之后再介绍 MyBatis 框架的基本使用以及常用特性,了解 MyBatis 的日常应用,最后深入框架源码去感受 MyBatis 框架的精妙设计。

  • 注:文章内容输出来源:拉勾教育Java高薪训练营;

复习JDBC操作流程

  1. 加载数据库连接驱动
  2. 通过驱动管理类获取数据库连接
  3. 获取预编译语句
  4. 设置预编译语句参数
  5. 执行SQL, 处理结果集
  6. 释放资源
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
复制代码public static void main(String[] args) { 
Connection connection = null;
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;
try {
// 1. 加载数据库驱动
Class.forName("com.mysql.jdbc.Driver");
// 2. 通过驱动管理类获取数据库连接
connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8", "root", "root");

String sql = "select * from user where username = ?";
// 3. 获取预编译语句
preparedStatement = connection.prepareStatement(sql);
// 4. 设置预编译语句参数
preparedStatement.setString(1, "tom");
// 5. 执行SQL, 处理结果集
resultSet = preparedStatement.executeQuery();

while (resultSet.next()) {
int id = resultSet.getInt("id");
String username = resultSet.getString("username");

user.setId(id);
user.setUsername(username);
}
System.out.println(user);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 6. 释放资源
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (preparedStatement != null) {
try {
preparedStatement.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}

直接使用JDBC存在的问题:

  1. 数据库连接的创建、销毁频繁造成系统资源浪费。
  2. SQL 语句在代码中硬编码,SQL 语句的变化需要改变Java代码
  3. 在向 preparedStatement 占位符传参存在硬编码
  4. 对结果集解析存在硬编码(查询列名),系统不易维护

问题解决思路

  1. 数据库频繁创建连接、释放资源 ⇒ 数据库连接池
  2. SQL语句及参数硬编码 ⇒ 配置文件
  3. 手动解析封装返回结果集 ⇒ 反射、内省

自定义框架设计

使用端:

提供核心配置文件

1
2
3
4
复制代码/**
* sqlMapConfig.xml:存放数据源信息,引入mapper.xml
* Mapper.xml:SQL语句的配置文件信息
*/

框架端:

  1. 读取配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码/**
* 读取完以后以流的形式存在,可创建JavaBean来存储
*/
public class Configuration {
// 数据源
private DataSource dataSource;
// map集合: key:statementId value:MappedStatement
private Map<String,MappedStatement> mappedStatementMap =
new HashMap<String, MappedStatement>();
}

public class MappedStatement {
//id
private Integer id;
//sql语句
private String sql;
//输入参数
private Class<?> paramterType;
//输出参数
private Class<?> resultType;
}
  1. 解析配置文件

创建SqlSessionFactoryBuilder类

使用 dom4j 解析配置文件,将解析出来的内容封装到 Configuration 和 MappedStatement 中
3. 创建SqlSessionFactory

创建SqlSessionFactory的实现类DefaultSqlSession,并实现openSession() 方法,获取sqlSession接口的实现类实例对象 ( 传递Configuration 对象)
4. 创建SqlSession接口及实现类 主要封装CRUD方法

方法:selectList(String StatementId,Object param)查询所有

selectOne(String StatementId,Object param)查询单个

close() 释放资源

具体实现:封装JDBC完成对数据库表的查询操作

Executor 类,从Configuration类中获取,DataSource、SQL、paramterType、resultType,通过反射设置预编译语句占位符的值,执行SQL,通过内省,通过列名与对象属性的对应关系解析结果为对象

自定义框架优化

上述自定义框架存在的问题

  1. dao 的实现类中存在重复的代码,整个操作的过程重复(创建SqlSession, 调用SqlSession方法、关闭SqlSession)
  2. dao 的实现类中存在硬编码,调用SqlSession 的方法时,参数 Statement 的 Id硬编码

解决方法

使用代理模式来创建接口的代理对象

Mybatis快速使用

1. 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码<!--mybatis-->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.4.5</version>
</dependency>
<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.6</version>
<scope>runtime</scope>
</dependency>

2. 分别创建数据表及实体类

3. 编写 UserMapper 映射文件

1
2
3
4
5
6
7
复制代码<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="userMapper">
<select id="findAll" resultType="com.lagou.domain.User">
select * from User
</select>
</mapper>

4. 编写 MyBatis 核心文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///test"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="com/lagou/mapper/UserMapper.xml"/>
</mappers>
</configuration>

5. CRUD

注:增删改需要提交事务或设置自动提交

示例:

1
2
3
4
5
6
7
8
9
10
11
复制代码InputStream resourceAsStream = Resources.getResourceAsStream("SqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
// SqlSession sqlSession = sqlSessionFactory.openSession(true);
// 带参数true 为自动提交事务
SqlSession sqlSession = sqlSessionFactory.openSession();
// namespace.id
int insert = sqlSession.insert("userMapper.add", user);
System.out.println(insert);
//提交事务
sqlSession.commit();
sqlSession.close();

MyBatis 核心配置文件层级关系

  • configuration 配置
    • properties 属性
    • settings 设置
    • typeAliases 类型别名
    • typeHandlers 类型处理器
    • objectFactory 对象工厂
    • plugins 插件
    • environments 环境
      • environment 环境变量
        • transactionManager 事务管理器type: [JDBC, MANAGED]
        • dataSource 数据源type: [UNPOOLED, POOLED, JNDI]
    • databaseIdProvider 数据库厂商标识
    • mappers 映射器

mapper.xml

动态 SQL 语句

SQL语句的主体结构,在编译时尚无法确定,只有等到程序运行起来,在执行的过程中才能确定,这种SQL叫做动态SQL。

常用标签:

1
2
3
4
5
6
7
8
9
10
11
复制代码<select id="findByCondition" parameterType="user" resultType="user"> 
select * from User
<where>
<if test="id!=0">
and id=#{id}
</if>
<if test="username!=null">
and username=#{username}
</if>
</where>
</select>

属性:

+ collection: 代表要遍历的集合元素
    1. 集合 ⇒  list
    2. 数组 ⇒ array
    3. Map ⇒ 集合对应的key
+ open: 代表语句的开始部分
+ close: 语句结束部分
+ item: 代表遍历集合的每个元素,生成的变量名
+ separator: 分隔符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码<select id="findByIds" parameterType="list" resultType="user">
<include refid="selectUser"></include>
<where>
<foreach collection="array" open="id in(" close=")" item="id" separator=",">
#{id}
</foreach>
</where>
</select>
<!--
List ids = new ArrayList();
ids.add(1);
ids.add(2);
Map params = new HashMap();
params.put("ids", ids);
params.put("title", "中国");
-->
<select id="dynamicForeach3Test" resultType="Blog">
select * from t_blog where title like "%"#{title}"%" and id in
<foreach collection="ids" index="index" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</select>

Mybatis 注解开发

常用注解

  • @Insert:新增
  • @Update:更新
  • @Delete:删除
  • @Select:查询
  • @Result:实现结果集封装 [, , , ]
  • @Results:可以与@Result 一起使用,封装多个结果集
  • @One:实现一对一结果封装
  • @Many:实现一对多结果集封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public interface UserMapper { 
@Select("select * from user")
@Results({
@Result(id = true,property = "id",column = "id"),
@Result(property = "username",column = "username"),
@Result(property = "password",column = "password"),
@Result(property = "birthday",column = "birthday"),
@Result(property = "roleList",column = "id",javaType = List.class,
many = @Many(select ="com.lagou.mapper.RoleMapper.findByUid")) })
List<User> findAllUserAndRole();
}

public interface RoleMapper {
@Select("select * from role r,user_role ur where r.id=ur.role_id and ur.user_id=#{uid}")
List<Role> findByUid(int uid);
}

Mybatis 缓存

  • 一级缓存是 SqlSession 级别的缓存,由sqlSession 对象中的 HashMap 数据结构来储存, 不同sqlSession 之间的缓存互不影响,一级缓存默认开启
  • 二级缓存是 mapper 级别的缓存,二级缓存需手动开启

一级缓存

+ 执行commit() 操作, 会清空对应 sqlSession 下的一级缓存

Map 中 CacheKey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码CacheKey cacheKey = new CacheKey(); 
// MappedStatement的id
// id: namespace + SQLid
cacheKey.update(ms.getId());
// offset 0
cacheKey.update(rowBounds.getOffset());
// limit就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
// 具体的SQL语句
cacheKey.update(boundSql.getSql());
// SQL中带的参数
cacheKey.update(value); ...
if (configuration.getEnvironment() != null) {
// environmentId
cacheKey.update(configuration.getEnvironment().getId());
}

二级缓存

  • 二级缓存与一级缓存流程类似,但二级缓存基于 mapper 文件的 namespace
  • 二级缓存底层还是 HashMap 结构
  • 执行 commit() 操作会清空二级缓存数据

1. 开启二级缓存

  1. 在全局配置文件sqlMapConfig.xml 中加入
1
2
3
4
复制代码<!-- 开启二级缓存 --> 
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
  1. 在 Mapper.xml 中开启缓存
1
2
3
4
5
6
复制代码<!-- 开启二级缓存 -->
<!--
空标签,默认 type=PerpetualCache的相对路径
也可以通过实现 Cache 接口来自定义缓存
-->
<cache></cache>
  1. pojo类实现序列化接口

因为二级缓存存储介质并非内存一种,可能会序列化到硬盘中

userCache 与 flushCache 配置项

  • userCache: 设置是否禁用二级缓存,控制粒度为 SQL,在statement中默认为true
  • flushCache: 刷新缓存,防止出现脏读,默认为true
  • 使用缓存是如果手动修改数据库表中的查询数据会出现脏读

二级缓存整合Redis

  • 目的:实现分布式缓存

1. 依赖

1
2
3
4
5
复制代码<dependency>
<groupId>org.mybatis.caches</groupId>
<artifactId>mybatis-redis</artifactId>
<version>1.0.0-beta2</version>
</dependency>

2. 配置文件

Mapper.xml

1
复制代码<cache type="org.mybatis.caches.redis.RedisCache" />

3. Redis连接配置文件

1
2
3
4
5
复制代码redis.host=localhost
redis.port=6379
redis.connectionTimeout=5000
redis.password=
redis.database=0

注:

mybatis-redis 在存储数据的时候,使用的 hash 结构

key: namespace

field: Cachekey

value: result

因为需要序列化与反序列化,所以第二次从缓存中获取的对象和之前的对象并不是同一个

Mybatis 插件介绍

Mybatis作为一个应用广泛的优秀的ORM框架,这个框架具有强大的灵活性,在四大组件(Executor、StatementHandler、ParameterHandler、ResultSetHandler)处提供了简单易用的插件扩展机制。Mybatis对持久层的操作就是借助于四大核心对象。MyBatis支持用插件对四大核心对象进行拦截,对mybatis来说插件就是拦截器,用来增强核心对象的功能,增强功能本质上是借助于底层的动态代理实现的,换句话说,MyBatis中的四大对象都是代理对象。

MyBatis 所允许拦截的方法如下:

  • 执行器Executor [update、query、commit、rollback]
  • SQL语法构建器StatementHandler[prepare、parameterize、batch、update、query]
  • 参数处理器ParameterHandler[gerParameterObject、setParameters方法]
  • 结果集处理器ResultSetHandler[handleResultSets、handleOutputParameters等方法]

自定义插件

  1. 创建自定义插件类实现Interceptor接口
  2. 重写Intercept()方法
  3. 然后给插件编写注解,指定需要拦截 的接口和方法
  4. 在mybatis配置文件中配置所写的插件类

MyBatis 执行流程

设计模式

MyBatis 用到的设计模式

设计模式 MyBatis 体现
Builder模式 SqlSessionFactoryBuilder、Environment
工厂方法 SqlSessionFactory、TransactionFactory、LogFactory
单例模式 ErrorContext、LogFactory
代理模式 Mybatis实现的核心,比如MapperProxy、ConnectionLogger,用的jdk动态代理,还有executor.loader包使用了cglib或者javassist达到延迟加载的效果
组合模式 SqlNode 和各个子类ChooseSqlNode等
模板方法模式 BaseExecutor和SimpleExecutor,还有BaseTypeHandler和他的子类例如IntegerTypeHandler;
适配器模式 例如Log的Mybatis接口和它对jdbc、log4j等日志框架的适配实现
装饰者模式 例如Cache包中的cache.decorators子包中等各个装饰者的实现
迭代器模式 例如迭代器模式PropertyTokenizer

本文转载自: 掘金

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

Redis 60 多线程 IO 处理过程详解

发表于 2020-07-08

引

大半年前,看到 Redis 即将推出 “多线程 IO” 的特性,基于当时的各种资料,和 unstable 分支的代码,写了《多线程的 Redis》,浅尝辄止地介绍了下特性,不够华也不实。本文将深入到实处,内容包含:

  • 介绍 Redis 单线程 IO 处理过程
  • 单线程的问题
  • 解析 Redis 多线程 IO 如何工作

要分析多线程 IO,必须先搞清楚经典的单线程异步 IO。文章会先介绍单线程 IO 的知识,然后再引出多线程 IO,如果已经熟悉,可以直接跳到多线程 IO 部分。

接下来我们一起啃下这两块大骨头。代码基于: github.com/antirez/red…

异步 IO

Redis 核心的工作负荷是一个单线程在处理, 但为什么还那么快?

  • 其一是纯内存操作。
  • 其二就是异步 IO,每个命令从接收到处理,再到返回,会经历多个 “不连续” 的工序。

假设客户端发送了以下命令:

1
复制代码GET key-how-to-be-a-better-man?

redis 回复:

1
复制代码努力加把劲把文章写完

要处理命令,则 redis 必须完整地接收客户端的请求,并将命令解析出来,再将结果读出来,通过网络回写到客户端。整个工序分为以下几个部分:

  • 接收。通过 TCP 接收到命令,可能会历经多次 TCP 包、ack、IO 操作
  • 解析。将命令取出来
  • 执行。到对应的地方将 value 读出来
  • 返回。将 value 通过 TCP 返回给客户端,如果 value 较大,则 IO 负荷会更重

其中解析和执行是纯 cpu/内存操作,而接收和返回主要是 IO 操作,这是我们要关注的重点。以接收为例,redis 要完整接收客户端命令,有两种策略:

  • 接收客户端命令时一直等,直到接收到完整的命令,然后执行,再将结果返回,直到客户端收到完整结果, 然后才处理下一个命令。 这叫同步。 同步的过程中有很多等待的时间,例如有个客户端网络不好,那等它完整的命令就会更耗时。
  • 客户端的 TCP 包来一个才处理一个,将数据追加到缓冲区,处理完了就去立即找其他事做,不等待,下一个 TCP 包来了再继续处理。命令的接收过程是穿插的,不连续。一会儿接收这个命令,一会儿又在接收另一个。 这叫做异步,过程中没有额外的空闲等待时间。

用聊天的例子做对应,假设你在回答多个人的问题,也有同步和异步的策略:

  • 聊天框中显示 “正在输入” 时,你一直等 ta 输入完毕,然后回答 ta 的问题,再发送出去,发送时会有等待,常规表现就是有个圆圈在转。你等发送完毕后,才去回答另一个人的问题。 同步
  • 显示 “正在输入” 时,不等 ta,而是去回答其他输入完毕的问题,回答完后,不等发送完毕,又去回答其它问题。 异步

很显然异步的效率更高,要实现高并发必须要异步,因为同步有太多时间浪费在等待上了,遇到网络不好的客户端直接就被拖垮。异步的策略简单可总结如下:

  • 网络包有数据了,就去读一下放到缓冲区,读完立马切到其他事情上,不等下一个包
  • 解析下缓冲区数据是否完整。如完整则执行命令,不完整切到其他事情上
  • 数据完整了,立即执行命令,将执行结果放到缓冲区
  • 将数据给客户端,如果一次给不完,就等下次能给时再给,不等,直到全部给完

事件驱动

异步没有零散的等待,但有个问题是,如果 redis 不一直阻塞等命令来,咋个知道 “网络包有数据了”、“下次能给时” 这两个时机? 如果一直去轮训问肯定效率很低,要有个高效的机制,来通知 redis 这两个时刻,由这些时刻来触发动作。 这就是事件驱动。

一个新TCP包来了、可以再次发给客户端数据这两个时机都是事件。与之对应的就是 redis 和客户端之间 socket 的可读、可写事件 [1] ,就像微信聊天中新消息提醒一样。 linux 中的 epoll 就是干这个事的,redis 基于 epoll 等机制抽象出了一套事件驱动框架 [2],整个 server 完全由事件驱动,有事件发生就处理,没有就空闲等待。

单线程 IO 处理过程

redis 启动后会进入一个死循环 aeMain,在这个循环里一直等待事件发生,事件分为 IO 事件和 timer 事件,timer 事件是一些定时执行的任务,如 expire key 等,本文只聊 IO 事件。

epoll 处理的是 socket 的可读、可写事件,当事件发生后提供一种高效的通知方式, 当想要异步监听某个 socket 的读写事件时,需要去事件驱动框架中注册要监听事件的 socket,以及对应事件的回调 function。然后死循环中可以通过 epoll_wait 不断地去拿发生了可读写事件的 socket,依次处理即可。

可读可以简单理解为,对应的 socket 中有新的 tcp 数据包到来。 可写可以简单理解为,对应的 socket 写缓冲区已经空了 (数据通过网络已经发给了客户端)

一图胜前言,完整、详细流程图如下:

* aeMain() 内部是一个死循环,会在 epoll_wait 处短暂休眠

  • epoll_wait 返回的是当前可读、可写的 socket 列表
  • beforeSleep 是进入休眠前执行的逻辑,核心是回写数据到 socket
  • 核心逻辑都是由 IO 事件触发,要么可读,要么可写,否则执行 timer 定时任务
  • 第一次的 IO 可读事件,是监听 socket(如监听 6379 的 socket),当有握手请求时,会执行 accept 调用,得到一个连接 socket,注册可读回调 createClient,往后客户端和 redis 的数据都通过这个 socket 进行
  • 一个完整的命令,可能会通过多次 readQueryFromClient 才能从 socket 读完,这意味这多次可读 IO 事件
  • 命令执行的结果会写,也是这样,大概率会通过多次可写回调才能写完
  • 当命令被执行完后,对应的连接会被追加到 clients_pending_write,beforeSleep 会尝试回写到 socket,写不完会注册可写事件,下次继续写
  • 整个过程 IO 全部都是同步非阻塞,没有浪费等待时间
  • 注册事件的函数叫 aeCreateFileEvent

单线程 IO 的瓶颈

上面详细梳理了单线程 IO 的处理过程,IO 都是非阻塞,没有浪费一丁点时间,虽然是单线程,但动辄能上 10W QPS。不过也就这水平了,难以提供更多的自行车。

同时这个模型有几个缺陷:

  • 只能用一个 cpu 核 (忽略后台线程)
  • 如果 value 比较大,redis 的 QPS 会下降得很厉害,有时一个大 key 就可以拖垮
  • QPS 难以更上一层楼

redis 主线程的时间消耗主要在两个方面:

  • 逻辑计算的消耗
  • 同步 IO 读写,拷贝数据导致的消耗

当 value 比较大时,瓶颈会先出现在同步 IO 上 (假设带宽和内存足够),这部分消耗在于两部分:

  • 从 socket 中读取请求数据,会从内核态将数据拷贝到用户态(read 调用)
  • 将数据回写到 socket,会将数据从用户态拷贝到内核态 (write 调用)

这部分数据读写会占用大量的 cpu 时间,也直接导致了瓶颈。 如果能有多个线程来分担这部分消耗,那 redis 的吞吐量还能更上一层楼,这也是 redis 引入多线程 IO 的目的。[3]

多线程 IO

上面已经梳理了单线程 IO 的处理流程,以及多线程 IO 要解决的问题,接下来将目光放到: 如何用多线程分担 IO 的负荷。其做法用简单的话来说就是:

  • 用一组单独的线程专门进行 read/write socket 读写调用(同步 IO)
  • 读回调函数中不再读数据,而是将对应的连接追加到可读 clients_pending_read 的链表
  • 主线程在 beforeSleep 中将 IO 读任务分给 IO 线程组
  • 主线程自己也处理一个 IO 读任务,并自旋式等 IO 线程组处理完,再继续往下
  • 主线程在 beforeSleep 中将 IO 写任务分给 IO 线程组
  • 主线程自己也处理一个 IO 写任务,并自旋式等 IO 线程组处理完,再继续往下
  • IO 线程组要么同时在读,要么同时在写
  • 命令的执行由主线程串行执行 (保持单线程)
  • IO 线程数量可配置

完整流程图如下:

beforesleep 中,先让 IO 线程读数据,然后再让 IO 线程写数据。 读写时,多线程能并发执行,利用多核。

  1. 将读任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i],将 io_threads_pending[i] 设置为对应的任务数,此时 IO 线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i] 清零。 函数名为: handleClientsWithPendingReadsUsingThreads
  2. 将写任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i],将 io_threads_pending[i] 设置为对应的任务数,此时 IO 线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i] 清零。 函数名为: handleClientsWithPendingWritesUsingThreads
  3. beforeSleep 中主线程也会执行其中一个任务 (图中忽略了),执行完后自旋等待 IO 线程处理完。
  4. 读任务要么在 beforeSleep 中被执行,要么在 IO 线程被执行,不会再在读回调中执行
  5. 写任务会分散到 beforeSleep、IO 线程、写回调中执行
  6. 主线程和 IO 线程交互是无锁的,通过标志位设置进行,不会同时写任务链表

性能据测试提升了一倍以上 (4 个 IO 线程)。

本文转载自: 掘金

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

1…795796797…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%