书名《学透 Spring 》原书 读书详细笔记(第8章)

八、数据访问进阶

本章内容

(1)适用于生产环境的连接池配置技巧

(2)在 Spring 工程中使用 Redis 的方法

(3)通过 Spring 的缓存抽象简化缓存的使用

前两章我们都在讨论如何实现基本的数据库操作:

(1)直接使用 JDBC,或者通过 ORM 框架。

(2)但在实际的生产环境中,仅仅实现基本的操作是不够的,甚至只用关系型数据库也是不够的,我们还需要 NoSQL 的帮助,遇到热点数据,还要增加缓存为数据库减负。

(3)所以,在这一章里,我们就要来聊聊这些进阶的内容。

8.1、连接池的实用配置

在之前的章节里,我们基本都是在使用 Spring Boot 提供的默认数据库连接池配置,它能满足基本的需求。但在生产环境中会遇到很多实际的问题,光靠基本配置就有点捉襟见肘了,例如:

(1)连接数据库用的密码属于需要保护的敏感信息,不能直接放在配置文件里该怎么办?

(2)为了方便排查问题,希望能记录执行的所有 SQL 该怎么办?

8.1.1、保护敏感的连接配置

连接数据库所需的信息包括三个要素——JDBC URL、用户名和密码。数据库密码是需要重点保护的信息,所以像第 6 章的代码示例那样以明文方式将密码写在 application.properties 里显然是不合适的。也许你会说:“为配置文件设置一个普通用户不可读的权限,只有运维人员能查看其中的内容行不行?”负责安全的工作人员会告诉你:“不行!”

在本节中,我们先来了解一下如何为 HikariCP 和 Druid 实现密码加密功能,而在后续的第 14 章,我们还会聊到 Spring Cloud Config 的配置项加密功能。如果你正在使用 Spring Cloud Config,集中式地管理加密密码会是一个相对更好的选择。

1、结合 HikariCP 与 Jasypt 实现密码加密

HikariCP 的作者一心想做好高性能连接池,把所有其他工作都“外包”了出去,所以配置项加密这个差事显然就需要其他工具来帮忙了。

Jasypt 的全称是 Java Simplified Encryption,一看这个名字就知道它是在 Java 环境里处理加解密的,Jasypt 可以很方便地与 Spring 项目集成到一起,究竟有多方便呢?

它直接提供了一个 EncryptablePropertiesPropertySource,可以直接解密属性值中用 ENC() 括起来的密文。而且它还有一个 Spring Boot Starter,几乎就是“开箱即用”。

第一步,在 pom.xml 中添加 jasypt-spring-boot-starter 依赖:

      <dependency>
        <groupId>com.github.ulisesbocchio</groupId>
        <artifactId>jasypt-spring-boot-starter</artifactId>
        <version>3.0.3</version>
      </dependency>

因为我们都会开启自动配置,所以这个起步依赖会自己完成剩下的配置。如果没有开启自动配置,则需要在配置类上增加 @EnableEncryptableProperties 注解。

第二步,修改配置文件,增加 Jasypt 的配置,并将明文密码改为密文。主要是配置加解密使用的算法和密钥,两者分别是

jasypt.encryptor.algorithm

jasypt.encryptor.password

默认的算法是 PBEWITHHMACSHA512ANDAES_256。其主要的配置如表 8-1 所示。

表 8-1 jasypt-spring-boot-starter 的一些默认配置

配置项

默认值

说明

jasypt.encryptor.algorithm

PBEWITHHMACSHA512ANDAES_256

加解密算法

jasypt.encryptor.provider-name

SunJCE

加密提供者

jasypt.encryptor.salt-generator-classname

org.jasypt.salt.RandomSaltGenerator

盐生成器

jasypt.encryptor.iv-generator-classname

org.jasypt.iv.RandomIvGenerator

初始化向量生成器

要进行加密,可以直接用 Jasypt 的 Jar 包,调用 CLI 的类。在 macOS 中,可以在 ~/.m2/repository 的 Maven 本地仓库里找到 jasypt-1.9.3.jar,执行如下命令:

java -cp ./jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI input=明文 password=密钥
algorithm=PBEWITHHMACSHA512ANDAES_256 ivGeneratorClassName=org.jasypt.iv.RandomIvGenerator
saltGeneratorClassName=org.jasypt.salt.RandomSaltGenerator

假设给的明文和密钥都是 binary-tea,那执行的输出应该会是下面这样的(OUTPUT 部分就是加密后的密文):

----ARGUMENTS-------------------

input: binary-tea
password: binary-tea
saltGeneratorClassName: org.jasypt.salt.RandomSaltGenerator
ivGeneratorClassName: org.jasypt.iv.RandomIvGenerator
algorithm: PBEWITHHMACSHA512ANDAES_256
----OUTPUT----------------------

X401LMpOiBz7+4gOXybK9cQdDOYlqX7mWXmmj6aGZPGWwjqcbf/80hj0vQWqhaqa

application.properties 中,将 spring.datasource.password 修改为 ENC(X401LMpOiBz7+4gOXybK9cQdDOYlqX7mWXmmj6aGZPGWwjqcbf/80hj0vQWqhaqa) 就完成了配置的修改。

第三步,在运行时提供解密的密钥。如果把密钥也写在 application.properties 里,那等于把保险箱钥匙和保险箱放在了一起,所以,至少密钥应该放在另一个单独的文件里。借助 Spring Boot 的能力,可以将 jasypt.encryptor.password 放在命令行参数或者环境变量里。由于命令行参数可以通过命令行直接观察到,所以环境变量 JASYPT_ENCRYPTOR_PASSWORD 会是个更好的选择。

2、 使用 Druid 内置功能实现密码加密

Druid 的思路与 HikariCP 截然相反,连接池可能会用到的各种相关功能,它都自己实现了,可谓“Druid 在手,连接无忧”。Druid 内置了数据库密码的加密功能,使用 RSA 非对称算法来进行加解密,我们无须操心各种加解密的细节,它能够自己全部封装好,例如,具体操作时内部使用 RSA/ECB/PKCS1Padding。只要用它的工具生成公私钥对,并加密好明文就可以了。

使用 Druid 提供的命令行工具来生成密钥和密文,和 Jasypt 一样,在本机的 Maven 仓库里找到 Druid 的 Jar 包。例如,在我的 Mac 上,1.2.6 版本 Jar 包的位置是 D:\WareHouse\Maven\com\alibaba\druid\1.2.6,在这个目录里执行下面的命令:

java -cp druid-1.2.6.jar com.alibaba.druid.filter.config.ConfigTools 密码明文

假设密码明文是 binary-tea,则输出会类似下面这样,公私钥和密文会有所不同:

Microsoft Windows [版本 10.0.19045.5487]
(c) Microsoft Corporation。保留所有权利。

D:\WareHouse\Maven\com\alibaba\druid\1.2.6>java -cp druid-1.2.6.jar com.alibaba.druid.filter.config.ConfigTools binary-tea
privateKey:MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAiV0Lu5Wn7NtDYgxLUYd6isDxQfE8dG+Q4RadoOKCLE+V0TKmHVr5V/nB3OYF9oq1OWh7MM+pjL7iBAsObTserwIDAQABAkAt6yDE8F5WW2XHHP0AoB1izOTZel8PPUxdMkY3RgDl9qXCni8wpiQov0FqpBCtHgxUHYDjiQ9TeJs8cskDJGlhAiEA3JUEETXswvZkZq3L0SjyetYOC9pGq4x+ftkp5VSse+sCIQCfa1gc+v/ZAX2uFIlFSfC9DOhsDdSJ7+Sacx4MACRLTQIgAfcx+hVI7tPTQTb7Qfnjb0TJC0H+rzipR+gXf3uprdECIQCRIhjFyXzDAyh4IxoVioswkV/Hf4/PRCbKtaLVKgvgwQIgZgbKWsgpUzRnAd8odQKCdkAKXEgV9ukDXXquMyWg+Zk=
publicKey:MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIldC7uVp+zbQ2IMS1GHeorA8UHxPHRvkOEWnaDigixPldEyph1a+Vf5wdzmBfaKtTloezDPqYy+4gQLDm07Hq8CAwEAAQ==
password:eKGfKQsvu8MTWDx0my6EL/NphRclyhCEmAAh7bPoYXP9gMTwlJo3WLFwAAzNFgPy6+EsPLYg7QLy8gxLu9imCA==

D:\WareHouse\Maven\com\alibaba\druid\1.2.6>
privateKey:MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAiV0Lu5Wn7NtDYgxLUYd6isDxQfE8dG+Q4RadoOKCLE+V0TKmHVr5V/nB3OYF9oq1OWh7MM+pjL7iBAsObTserwIDAQABAkAt6yDE8F5WW2XHHP0AoB1izOTZel8PPUxdMkY3RgDl9qXCni8wpiQov0FqpBCtHgxUHYDjiQ9TeJs8cskDJGlhAiEA3JUEETXswvZkZq3L0SjyetYOC9pGq4x+ftkp5VSse+sCIQCfa1gc+v/ZAX2uFIlFSfC9DOhsDdSJ7+Sacx4MACRLTQIgAfcx+hVI7tPTQTb7Qfnjb0TJC0H+rzipR+gXf3uprdECIQCRIhjFyXzDAyh4IxoVioswkV/Hf4/PRCbKtaLVKgvgwQIgZgbKWsgpUzRnAd8odQKCdkAKXEgV9ukDXXquMyWg+Zk=
publicKey:MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIldC7uVp+zbQ2IMS1GHeorA8UHxPHRvkOEWnaDigixPldEyph1a+Vf5wdzmBfaKtTloezDPqYy+4gQLDm07Hq8CAwEAAQ==
password:eKGfKQsvu8MTWDx0my6EL/NphRclyhCEmAAh7bPoYXP9gMTwlJo3WLFwAAzNFgPy6+EsPLYg7QLy8gxLu9imCA==

接下来,要在 application.properties 中开启密码加密功能,需要让 Druid 加载 ConfigFilter 这个过滤器,并配置解密用的密钥,就像下面这样 1

spring.datasource.password=eKGfKQsvu8MTWDx0my6EL/NphRclyhCEmAAh7bPoYXP9gMTwlJo3WLFwAAzNFgPy6+EsPLYg7QLy8gxLu9imCA==
spring.datasource.druid.filters=config
spring.datasource.druid.connection-properties=config.decrypt=true;config.decrypt.key=${publicKey}
publicKey=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIldC7uVp+zbQ2IMS1GHeorA8UHxPHRvkOEWnaDigixPldEyph1a+Vf5wdzmBfaKtTloezDPqYy+4gQLDm07Hq8CAwEAAQ==

1通常 RSA 加解密,都用公钥加密,私钥解密。此处考虑到是用来加密密码,随后将密文和解密用的密钥分发到各服务器上,所以反转了一下,用私钥加密,公钥解密。在 ConfigTools 中,有这么一段注释——“因为 IBM JDK 不支持私钥加密公钥解密,所以要反转公私钥。也就是说对于解密,可以通过公钥的参数伪造一个私钥对象欺骗 IBM JDK”。可见这个机制在实践中还是踩过坑的。

同样的,把解密的密钥和密文放在一起也不太安全,有两种方式可供选择。

(1)在命令行上设置系统属性 -Ddruid.config.decrypt.key= 密钥。

(2)在 Druid 专属的配置文件里设置解密密钥 config.decrypt.key= 密钥和 password= 密码密文,同时要修改 application.properties 中的连接属性,就像下面这样:

spring.datasource.druid.connection-properties=config.decrypt=true;config.file=外部 Druid 配置文件路径

Druid 配置加密的逻辑基本都在 ConfigFilter 里,它的大致逻辑是这样的:

(1) 在 Druid 加载 Filter 时,会调用其中的 init() 初始化方法;

(2) init() 会从 DruidDataSourceconnectProperties 属性,以及指定的配置文件中获取配置;

(3) 判断是否需要解密密码;

(4) 如果需要解密,再从第 (2) 步的两个位置获取解密的密钥;

(5) 解密获得密码明文并进行设置。

8.1.2、记录 SQL 语句执行情况

通常在遇到请求处理缓慢的情况时,我们会对执行的每一步进行分析,看看究竟慢在哪里。如果是执行 SQL 语句,那就要找到较慢的 SQL 进行优化,这时需要记录慢 SQL 日志,DBA 一般也会监控数据库端的慢 SQL。还有另一种场景,数据库里的记录内容与预期的不符,这种时候,如果能记录下每条执行的 SQL 语句,再回过头来分析问题就能方便很多。因此,不管什么情况,如果能够详细地记录程序执行的 SQL 语句,在后续各种性能优化和问题分析时都会非常有用。2

2对性能有追求的各位一定很想知道,每条 SQL 都用日志打印出来会不会很慢。答案是一定会比不打印日志慢,但也不至于太夸张。毕竟天下没有免费的午餐,与它带来的好处相比,付出这点慢的代价还是值得的。为了确保日志对性能的影响不会太大,建议一定对 SQL 日志开启异步日志支持。

1、结合 HikariCP 与 P6SPY 实现 SQL 记录

HikariCP 本身并没有提供 SQL 日志的功能,因此需要借助 P6SPY3 来记录执行的 SQL。P6SPY 是一套可以无缝拦截并记录 SQL 执行情况的框架,它工作在 JDBC 层面,所以无论我们使用什么连接池,是否使用 ORM 框架,都能通过 P6SPY 来进行拦截。

3官方主页见 GitHub(p6spy/p6spy)。

首先,在 pom.xml 中引入 P6SPY 的依赖:

        <dependency>
            <groupId>p6spy</groupId>
            <artifactId>p6spy</artifactId>
            <version>3.9.1</version>
        </dependency>

接下来,调整连接池的配置,将 JDBC 驱动类名指定为 com.p6spy.engine.spy.P6SpyDriver,并修改 URL:

spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver
spring.datasource.url=jdbc:p6spy:h2:mem:testdb

P6SPY 的 URL 形式基本可以归纳为在原先的 JDBC URL 的基础上,在 jdbc: 后插入一段 p6spy:,其他与使用数据库原生 JDBC 驱动一致。如果是 MySQL,则 URL 类似 jdbc:p6spy:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8

最后,我们还需要一个 P6SPY 的配置文件。在 CLASSPATH 里放一个 spy.properties,其中是 P6SPY 的相关配置 a。表 8-2 列举了一些基本的配置。

表 8-2 P6SPY 配置文件中的基本配置项4

配置项

默认值

说明 5

dateformat

默认使用时间戳的形式

日期格式,使用 SimpleDateFormat

的格式进行配置

logMessageFormat

com.p6spy.engine.spy.appender.SingleLineFormat

日志格式化类,可以在 SingleLineFormat

CustomLineFormat

之间选择

customLogMessageFormat

%(currentTime)|%(executionTime)|%(category)|connection%(connectionId)|%(sqlSingleLine)

CustomLineFormat

使用的输出格式

appender

com.p6spy.engine.spy.appender.FileLogger

打印日志使用的 Appender

,可以在 FileLogger

SthoutLogger

Slf4JLogger

之间选择

logfile

spy.log

FileLogger

输出的日志文件

outagedetection

false

是否开启慢 SQL 检测,当这个开关开启时,除了慢的 SQL 语句其他语句都不会再输出了

outagedetectioninterval

60

慢 SQL 执行检测的间隔时间,单位是秒

realdatasourceclass

真实的数据源类名,一般都能自动检测出实际需要的驱动类名

realdatasourceproperties

真实的数据源配置属性,配置项用键值对形式表示,键与值用分号分隔,不同的键值对之间用逗号分隔

4详细的配置信息可以访问官方说明(p6spy.readthedocs.io/en/latest/configandusage.html)。

5为了方便排版,这一列的类只写了类名,而在实际配置时需要使用全限定类名。

假设我们的 spy.properties 是下面这样的:

appender=com.p6spy.engine.spy.appender.Slf4JLogger
dateformat=yyyyMMdd'T'HH:mm:ss

在之前的 binarytea-jpa 中完成上述所有的修改,关闭 Hibernate 的 SQL 输出,运行程序,就能在日志中看到类似下面的输出,其中包含了 SQL 执行时间、耗时和 SQL 等内容,一般建议把 P6SPY 的日志单独配置到一个日志里去,方便查看:6

2022-02-26 14:27:29.922 INFO 67257 --- [main] p6spy : 20220226T14:27:29|0|statement|connection 0|
url jdbc:p6spy:h2:mem:testdb|select count(*) as col_0_0_ from t_menu menuitem0_|select count(*) as
col_0_0_ from t_menu menuitem0_

6这个例子在 ch8/binarytea-jpa-p6spy 项目中。

2、 使用 Druid 内置功能实现 SQL 记录

Druid 就不需要什么额外的库支持了,它自己就内置了详尽的日志与统计功能,与密码加密功能一样,这些功能也是通过 Filter 来实现的。

先是日志过滤器 LogFilter,Druid 一共内置了四个针对不同日志框架的 LogFilter 子类,在配置时可以使用它们的别名。

(1)对应 Log4j 1.x 的 Log4jFilter,别名 log4j

(2)对应 Log4j 2.x 的 Log4j2Filter,别名 log4j2

(3)对应 Commongs Logging 的 CommonsLogFilter,别名 commonlogging

(4)对应 SLF4J 的 Slf4jLogFilter,别名 slf4j

Druid 的日志过滤器打印的信息很多,它们分别使用了不同的 Logger。我们可以针对不同的 Logger 做不同的日志配置,在实际使用时建议挑选其中的一些打印就可以了。例如,根据不同的日志级别,将日志输出到不同的文件,具体的日志框架配置可以参考它们的文档。表 8-3 罗列了一些与 LogFilter 相关的配置。

表 8-3 LogFilter 中用到的 Logger 名称和配置

Logger

名称

配置项

说明

druid.sql.DataSource

打印关于 DataSource

的日志

druid.sql.Connection

druid.log.conn=true

打印关于 Connection

的日志

druid.sql.Statement

druid.log.stmt=true

打印关于 Statement

的日志

druid.sql.Statement

druid.log.stmt.executableSql=false

在开启了 Statement

的日志时,是否打印执行的 SQL

druid.sql.ResultSet

druid.log.rs=true

打印关于 ResultSet

的日志

子类,在配置时可以使用它们的别名:查看。要在打印的一大堆 SQL 里找到慢 SQL,还是需要一点时间的。为此,Druid 还贴心地提供了一个慢 SQL 统计的过滤器 StatFilter,别名是 stat。它有三个参数:

(1)druid.stat.logSlowSql,是否打印慢 SQL,默认值为 false

(2)druid.stat.slowSqlMillis,用来定义多慢的 SQL 属于慢 SQL,默认值为 3000,单位毫秒;

(3)druid.stat.mergeSql,在统计时是否合并 SQL,默认值为 false

application.properties 中,可以配置多个过滤器,就像下面示例的第二行代码这样,用逗号分隔,随后再配置一些属性。由于 Druid 中正常的 SQL 输出使用的是 DEBUG 级别,所以我们还要调整一下相关 Logger 的日志级别才能输出日志。下面是直接在 application.properties 里修改日志级别的代码,但实践中更建议在日志框架的配置文件里修改,和其他日志配置放一起:

logging.level.druid.sql.*=debug

spring.datasource.druid.filters=config,slf4j,stat
spring.datasource.druid.connection-properties=druid.log.stmt.executableSql=true;druid.stat.
logSlowSql=true;druid.stat.mergeSql=true

执行程序时,我们可以在日志里找到大量与数据库操作相关的日志,其中会有类似下面这样的日志,打印出了 PreparedStatement 的 SQL、参数值的类型,以及执行耗时:

2020-10-07 23:16:43.174 DEBUG 68289 --- [main] druid.sql.Statement : {conn-10001, pstmt-20014}
created. select * from t_menu where id = ?
2020-10-07 23:16:43.174 DEBUG 68289 --- [main] druid.sql.Statement : {conn-10001, pstmt-20014}
Parameters : [1]
2020-10-07 23:16:43.174 DEBUG 68289 --- [main] druid.sql.Statement : {conn-10001, pstmt-20014}
Types : [BIGINT]
2020-10-07 23:16:43.175 DEBUG 68289 --- [main] druid.sql.Statement : {conn-10001, pstmt-20014, rs-50009}
query executed. 0.293573 millis. select * from t_menu where id = ?

8.1.3、Druid 的 Filter 扩展

Druid 的 Filter 是个非常有用的机制,可以拦截 DruidDataSourceConnectionStatementPreparedStatementCallableStatementResultSetResultSetMetaDataWrapperClob 上方法的执行。这其中使用了责任链模式,也就是将不同的过滤器串联在一起,以实现不同的功能。

前文提到的数据库密码加密、数据库执行日志都是 Filter 的例子,在 Druid 里还有一个非常有用的 Filter,那就是 SQL 注入防火墙,即 WallFilter,别名是 wall。它能够有效地控制通过 Druid 执行的 SQL,避免恶意行为。通常情况下,自动识别的配置就已经够用了。在 Spring Boot 中,Filter 除了像 8.1.1 节和 8.1.2 节中那样配置之外,还可以借助 Druid Spring Boot Starter 的帮助,直接在 application.properties 里像下面这样来配置,具体的配置实现可以参考 DruidFilterConfiguration 类:

spring.datasource.druid.filter.wall.enabled=true
spring.datasource.druid.filter.wall.db-type=h2
spring.datasource.druid.filter.wall.config.delete-allow=false
spring.datasource.druid.filter.wall.config.drop-table-allow=false
spring.datasource.druid.filter.wall.config.create-table-allow=false
spring.datasource.druid.filter.wall.config.alter-table-allow=false

不光很多内置功能是通过 Filter 实现的,我们自己也可以通过它做出很多扩展。要开发自己的 Filter,可以直接实现 Filter 接口。但这么做太麻烦,有太多的方法需要我们提供空实现,而我们往往只关心其中的几个,所以继承 FilterAdapter 或者 FilterEventAdapter 会是更好的选择。

FilterAdapter 为每个方法都提供了默认实现,可以直接调用方法参数中传入的 FilterChain 的对应方法,继续执行责任链中的其他过滤器方法。例如,preparedStatement_executeUpdate() 方法的实现是下面这样的:

public abstract class FilterAdapter 
    extends NotificationBroadcasterSupport 
    implements Filter {

    public int preparedStatement_executeUpdate(
        FilterChain chain, 
        PreparedStatementProxy statement) throws SQLException {
        return chain.preparedStatement_executeUpdate(statement);
    }
}

FilterEventAdapterFilterAdapter 的子类,它在执行责任链的基础之上,又增加了执行前后的动作,以 statement_execute() 为例,它的实现是下面这样的:

public boolean statement_execute(FilterChain chain, StatementProxy statement, String sql,
                                 String columnNames[]) throws SQLException {
    statementExecuteBefore(statement, sql);
    try {
        boolean firstResult = super.statement_execute(chain, statement, sql, columnNames);
        this.statementExecuteAfter(statement, sql, firstResult);
        return firstResult;
    } catch (SQLException error) {
        statement_executeErrorAfter(statement, sql, error);
        throw error;
    } catch (RuntimeException error) {
        statement_executeErrorAfter(statement, sql, error);
        throw error;
    } catch (Error error) {
        statement_executeErrorAfter(statement, sql, error);
        throw error;
    }
}

我们可以根据自己的需要,选择性覆盖 statementExecuteBefore()statementExecuteAfter()statement_executeErrorAfter() 方法,达到在 SQL 语句执行前、执行后、抛异常时运行自定义逻辑的目的。

现在,假设我们希望在执行 Connection 的连接动作前后打印一些日志,可以像代码示例 8-17 那样,继承 FilterEventAdapter,覆盖 connection_connectBefore()connection_connectAfter,并在里面添加自己的逻辑就可以了。

7这个例子在 ch8/binary-jpa-druid 项目里。

代码示例 8-1ConnectionConnectFilter 类代码片段

@Slf4j
@AutoLoad // 这个注解稍后解释
public class ConnectionConnectFilter extends FilterEventAdapter {
    @Override
    public void connection_connectBefore(FilterChain chain, Properties info) {
        log.info("Trying to create a new Connection.");
        super.connection_connectBefore(chain, info);
    }

    @Override
    public void connection_connectAfter(ConnectionProxy connection) {
        super.connection_connectAfter(connection);
        log.info("We have a new connected Connection.");
    }
}

在加载 Filter 时有三种方式,第一种是在配置文件中通过别名来选择要加载的 Filter。别名与具体类的对应关系配置在 META-INF/druid-filter.properties 里,内置的文件内容如下所示:

druid.filters.default=com.alibaba.druid.filter.stat.StatFilter
druid.filters.stat=com.alibaba.druid.filter.stat.StatFilter
druid.filters.mergeStat=com.alibaba.druid.filter.stat.MergeStatFilter
druid.filters.counter=com.alibaba.druid.filter.stat.StatFilter
druid.filters.encoding=com.alibaba.druid.filter.encoding.EncodingConvertFilter
druid.filters.log4j=com.alibaba.druid.filter.logging.Log4jFilter
druid.filters.log4j2=com.alibaba.druid.filter.logging.Log4j2Filter
druid.filters.slf4j=com.alibaba.druid.filter.logging.Slf4jLogFilter
druid.filters.commonlogging=com.alibaba.druid.filter.logging.CommonsLogFilter
druid.filters.commonLogging=com.alibaba.druid.filter.logging.CommonsLogFilter
druid.filters.wall=com.alibaba.druid.wall.WallFilter
druid.filters.config=com.alibaba.druid.filter.config.ConfigFilter

可以看到,键是 druid.filters. 别名,值是具体的全限定类名,所以前面可以用 configstatslf4j 这样的别名来加载 Filter

我们可以在自己的工程里也创建一个 META-INF/druid-filter.properties 文件,内容是之前 ConnectionConnectFilter 的映射:

druid.filters.connectLog=learning.spring.binarytea.support.ConnectionConnectFilter

第二种方式是让 Druid 自动加载 FilterDruidDataSource 在通过 init() 初始化时,会调用 initFromSPIServiceLoader() 方法,使用 Java 的 ServiceLoader 来加载 Filter 的实现类。如果类上加了 @AutoLoad 注解,则自动加载该 Filter

ServiceLoader 会查找 META-INF/services/com.alibaba.druid.filter.Filter 文件,并从文件中获取具体的全限定类名,因此我们需要把扩展的类写在这个文件里。在工程中创建这个文件,内容如下:

learning.spring.binarytea.support.ConnectionConnectFilter

第三种方式,就是直接在 Spring 上下文中配置 Filter 对应的 Bean,随后将它赋值给 DruidDataSourceproxyFilters 属性。这种方式最为灵活,可以根据情况对 Bean 做各种调整,但配置时相对麻烦一些。

8.2、在 Spring 工程中访问 Redis

如果对系统的性能有所要求,通常都会在系统中引入分布式缓存,在一些极端的情况下甚至会抛弃传统的关系型数据库,将大量数据直接持久化在类似 Redis 这样的 NoSQL8 中。Redis9 是一款优秀的开源 KV 存储方案,与 Memcached 仅支持简单的 KV 类型和操作不同,Redis 支持很多不同的数据结构,例如列表、集合、散列等,还支持不少复杂的操作,因此 Redis 在实践中得到了广泛的应用。本节我们就来了解一下如何在 Spring 工程中方便地使用 Redis。

8NoSQL 这个名字是为了与传统的关系型数据库 SQL 有所区分,一般解释为非关系型数据库。它分为几大类型,分别是键值型(Key Value,简称 KV)数据库,例如 Redis 和 Memcached;文档型数据库,例如 MongoDB 和 CouchDB;列存储数据库,例如 HBase 和 Cassandra;图数据库,例如 Neo4j。

9具体见 Redis 的官网。

8.2.1、配置 Redis 连接

要使用 Redis,自然少不了 Java 的 Redis 客户端。表 8-4 中展示了目前比较主流的三个 Redis 客户端,这三个也是 Redis 官方推荐的。

表 8-4 主流的 Redis 客户端

IO 方式

线程安全

API

Jedis

阻塞

较底层,与 Redis 命令对应

Lettuce

非阻塞

有较高抽象

Redission

非阻塞

有较高抽象

在项目中,我们可以直接使用 Redis 客户端来进行操作,只需将其配置为容器中的 Bean,然后注入需要使用它的对象中即可。

以 Jedis 为例,在 Spring 容器中配置好 JedisPool Bean,将它注入需要的 Bean 中,操作时从 JedisPool 里取出一个 Jedis 实例就可以了。

如果只有这种方式,那我们也不用在这里讨论了。和之前的 ORM 框架一样,Spring 为我们提供了一套对应的抽象——Spring Data Redis,它屏蔽了不同客户端之间的差异,让我们能用相似的方式来配置并操作 Redis。

Spring Data Redis 支持 Redis 2.6 及以上版本,在客户端方面,支持 Jedis 和 Lettuce,后者是默认客户端。工程的 pom.xml 会通过如下方式引入相关依赖,具体的版本由 Spring Boot 来控制,它会传递引入 spring-data-redislettuce-core 这两个依赖:

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
1、Spring Data Redis 的模型抽象

Spring Data Redis 通过几层抽象来为开发者提供统一的使用体验,屏蔽底层差异,下面列出的这些接口还有一些扩展,就不在此一一列举了。

(1)RedisCommands,针对命令的抽象。

(2)RedisConnection,针对连接的抽象。

(3)RedisConnectionFactory,针对连接创建工厂的抽象。

RedisConnectionFactory 这个名字就能看出,此处使用了工厂模式来构造 Redis 连接,该接口有两个实现类——LettuceConnectionFactoryJedisConnectionFactory,分别对应了 Lettuce 和 Jedis 两个不同的客户端。RedisCommandsRedisConnection 的情况也是类似的,最终都会提供针对这两种客户端的实现。

既然 Lettuce 是默认的客户端,那就让我们先来看看它的配置。

Spring Boot 在 spring-boot-autoconfigure 中提供了 Redis 相关的自动配置,Lettuce 的配置类是 LettuceConnectionConfiguration,如果 CLASSPATH 中存在 Lettuce 的 RedisClient,则说明用的是 Lettuce 客户端,否则该配置不生效。

这个配置类最终会创建两个 Bean:

(1)一个是提供构建客户端所需配置及资源的 lettuceClientResources

(2)另一个就是对应 Lettuce 的 redisConnectionFactory

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisClient.class)
class LettuceConnectionConfiguration extends RedisConnectionConfiguration {
    @Bean(destroyMethod = "shutdown")
    @ConditionalOnMissingBean(ClientResources.class)
    DefaultClientResources lettuceClientResources() {...}

    @Bean
    @ConditionalOnMissingBean(RedisConnectionFactory.class)
    LettuceConnectionFactory redisConnectionFactory(
        ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers,
        ClientResources clientResources)
    throws UnknownHostException {...}

    // 省略其他方法
}

LettuceClientConfigurationBuilderCustomizer 是用来定制 LettuceClientConfigurationBuilder 的,我们可以调整其中的一些属性,例如,让 Lettuce 优先读取从节点的数据。

根据配置的不同,自动配置可以为单机模式、哨兵模式和集群模式的 Redis 创建合适的 RedisConnectionFactory,具体的配置由 RedisProperties 类实现,配置的前缀为 spring.redis。主要的配置项如表 8-5 所示。

表 8-5 Spring Data Redis 的主要配置项

配置项

默认值

说明

spring.redis.host

localhost

Redis 服务器主机名

spring.redis.port

6379

Redis 服务器端口

spring.redis.password

Redis 服务器密码

spring.redis.timeout

60s

连接超时时间

spring.redis.sentinel.master

Redis 服务器名称

spring.redis.sentinel.nodes

哨兵节点列表,节点用“主机名 : 端口

”表示,主机之间用逗号分割

spring.redis.sentinel.password

哨兵节点密码

spring.redis.cluster.nodes

集群节点列表,节点可以自发现,但至少要配置一个节点

spring.redis.cluster.maxRedirects

5

在集群中执行命令时的最大重定向次数

spring.redis.jedis.pool.\*

Jedis 连接池配置

spring.redis.lettuce.\*

Lettuce 特定的配置

2、Redis 的几种部署模式

单机版本的 Redis 仅能用于开发和测试,在生产环境中还是需要做很多高可用的保障的。Redis 官方为我们提供了两种高可用方案:

(1)哨兵模式(redis sentinel)

(2)和集群模式(redis cluster)。

(1)哨兵模式

哨兵模式,即在原有的 Redis 主从节点之外,再搭建一组哨兵节点,通过哨兵来实现对 Redis 节点的监控,在发生问题时进行通知并自动执行故障迁移。新版本的哨兵模式中客户端也可以通过哨兵来获取当前的主节点。出于可用性方面的考虑,搭建高可用的哨兵模式至少需要三个节点,具体如图 8-1 所示。

图 8-1 Redis 哨兵模式

(2)集群模式

集群模式,比哨兵模式更为强大。在哨兵模式中,Redis 数据过大后需要由开发者来负责数据分片,而集群模式则会自动进行分片。通过建立 16 384 个虚拟槽,每个槽映射一部分分片范围,再将这些槽分布到节点上就实现了数据分片。集群模式下,所有节点之间都会相互通信,连上一个节点就能找到整个集群。为了保证高可用性,其中也加入了主从模式,某个主节点出问题后,集群会把对应的从节点提升为主节点。一种可能的 Redis 集群模式如图 8-2 所示。

图 8-2 Redis 集群模式

如果集群节点的数量发生变化,那么槽也会进行迁移,这时原先缓存在客户端的槽分布信息就有可能不准确,收到命令的节点会让客户端重定向到正确的节点,这就是不建议把最大重定向次数设置为 0 的原因。

为了最大化地利用集群资源,我们可以将部分读请求发送给从节点。Jedis 对 Redis 集群的读写分离支持得很不好,建议有这方面需求的开发者可以使用 Lettuce。在 Spring Data Redis 里可以配置一个 LettuceClientConfigurationBuilderCustomizer,设置优先通过从节点读取数据:

@Bean
public LettuceClientConfigurationBuilderCustomizer customizer() {
    return builder -> builder.readFrom(ReadFrom.SLAVE_PREFERRED);
}

另外,因为 Redis 用的是异步复制,所以如果有数据写到主节点,但还来不及同步到从节点上,这时主节点的故障就会导致部分数据丢失。如果数据非常重要,不能丢失,那建议还是不要仅存放在 Redis 里,至少应该再备一份到其他存储上。

3、 将 Lettuce 替换为 Jedis

如果希望使用 Jedis 而非 Lettuce,只需简单调整 pom.xml 文件中的依赖,就能完成替换。比如像下面这样,先排除 spring-boot-starter-data-redis 里的 Lettuce 依赖,随后添加 Jedis 的依赖,所有的版本都交由 Spring Boot 的依赖负责管理:

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
          <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
          </exclusion>
        </exclusions>
      </dependency>
      
      <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
      </dependency>

Jedis 的自动配置是由 JedisConnectionConfiguration 实现的,它的生效条件是 CLASSPATH 中同时存在 Apache 的 Commons Pool2、Spring Boot Data Redis 和 Jedis 相关类(Commons Pool2 是由 Jedis 传递依赖进来的)。这个自动配置类会根据情况注册一个 redisConnectionFactory Bean:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ GenericObjectPool.class, JedisConnection.class, Jedis.class })
class JedisConnectionConfiguration extends RedisConnectionConfiguration {
    @Bean
    @ConditionalOnMissingBean(RedisConnectionFactory.class)
    JedisConnectionFactory redisConnectionFactory(ObjectProvider<JedisClientConfigurationBuilderCustomizer>
                                                  builderCustomizers) throws UnknownHostException {...}
    // 省略其他方法
}

通过这个自动配置类,后续我们就能使用 Jedis 作为底层客户端来进行操作了。其中 JedisClientConfigurationBuilderCustomizer 的作用与之前提到的 LettuceClientConfigurationBuilderCustomizer 类似。

8.2.2、Redis 的基本操作

前面在介绍数据库操作时,我们接触到了 TransactionTemplateJdbcTemplate 等模板类,Spring 把各类可以固化的代码都封装成了模板。其实,Redis 的操作也很符合这个特征,并且 Redis 的操作“界面”也很符合模板模式,常用操作都被封装进了 RedisTemplate 类中,直接操作这个类就能完成 Redis 的操作了。

Spring Boot 的 RedisAutoConfiguration 为我们自动配置好了两个 RedisTemplate,其中有一个专门用于字符串类型的 Redis 操作。而创建这些 RedisTemplate 所需的 RedisConnectionFactory,就是由上文提到的部分所提供的:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
    throws UnknownHostException {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

    @Bean
    @ConditionalOnMissingBean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory)
    throws UnknownHostException {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

其中,RedisConnection 提供了与 Redis 交互的底层能力,RedisTemplate 则在前者的基础上提供了序列化与连接管理能力。根据数据结构的不同,具体的操作上也做了一定的抽象,详情如表 8-6 所示。

表 8-6 RedisTemplate 中封装的操作类型

操作

绑定键名操作

描述

ClusterOperations

Redis 集群的相关操作

GeoOperations

BoundGeoOperations

Redis 地理位置的相关操作

HashOperations

BoundHashOperations

Redis Hash 类型的相关操作

HyperLogLogOperations

Redis HyperLogLog 类型 10 的相关操作

ListOperations

BoundListOperations

Redis 列表类型的相关操作

SetOperations

BoundSetOperations

Redis 集合类型的相关操作

StreamOperations

BoundStreamOperations

Redis 流 11 的相关操作

ValueOperations

BoundValueOperations

Redis 值类型的相关操作

ZSetOperations

BoundZSetOperations

Redis 有序结合类型的相关操作

10HyperLogLog 是 Redis 2.8.9 版本发布时加入的数据结构,专门用来做基数统计,虽然不能 100% 准确地计算基数,但它的优点是只需要很少的空间就能完成对大量数据的统计。

11是 Redis 5.0 版本发布时加入的类型。这里所谓的流,其实更像是一个消息队列,就连 Redis 的作者本人也承认在设计这部分时很大程度上借鉴了 Kafka。

当我们要进行某种数据结构的操作时,调用 RedisTemplateopsForXxx() 方法获得对应的操作对象,然后就能进行操作了。例如,要对 foo 集合做操作,可以调用 opsForSet() 方法,随后就能使用其中的 add()remove()pop() 等方法了。如果要对同一个键名的数据做多次操作,则可以使用 boundXxxOps() 来获取 BoundKeyOperations 对象,再执行后续操作。

此外,RedisTemplate 中还直接提供了一些操作,大多是用于那些和数据结构无关的情况,例如删除、设置过期时间和判断数据是否存在等,这些操作会直接调用 delete()expire()hasKey() 方法,无须再获取操作对象了。

回到二进制奶茶店的例子,我们来看看 Redis 作为一种缓存是如何在工程中发挥作用的。

需求描述 奶茶店里的菜单虽然会有更新,但频率不高,通常一个月甚至一个季度才会根据情况对品类和价格做些调整。如果进店的顾客比较多,大家一起查看菜单,对菜单的请求量就会直线上升,类似情况下数据库迟早会成为瓶颈,这时,我们就需要引入新的解决方案了。

通常对于那些不太会变的东西,我们不会每次访问都去查询数据库,而是将它们缓存起来,从而实现在提升性能的同时降低数据库的压力。在这个例子中,我们完全可以将整个菜单缓存到 Redis 里。

drop table if exists t_menu;
drop table if exists t_order;
drop table if exists t_order_item;
drop table if exists t_tea_maker;

create table t_menu (
    id bigint not null auto_increment,
    create_time timestamp,
    name varchar(255),
    price bigint,
    size varchar(255),
    update_time timestamp,
    primary key (id)
);

create table t_order (
    id bigint not null auto_increment,
    amount_discount integer,
    amount_pay bigint,
    amount_total bigint,
    create_time timestamp,
    status integer,
    update_time timestamp,
    maker_id bigint,
    primary key (id)
);

create table t_order_item (
   item_id bigint not null,
   order_id bigint not null
);

create table t_tea_maker (
    id bigint not null auto_increment,
    create_time timestamp,
    name varchar(255),
    update_time timestamp,
    primary key (id)
);
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'MEDIUM', 1200, now(), now());
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'LARGE', 1500, now(), now());

insert into t_tea_maker (name, create_time, update_time) values ('LiLei', now(), now());
insert into t_tea_maker (name, create_time, update_time) values ('HanMeimei', now(), now());

insert into t_order (maker_id, status, amount_discount, amount_pay, amount_total, create_time, update_time) values (1, 0, 100, 1200, 1200, now(), now());

insert into t_order_item (order_id, item_id) values (1, 1);

第一步,让我们使用 Docker 在本地启动一个 Redis13,监听 6379 端口,后续就会将该 Redis 作为缓存:

13关于 Redis 的镜像,可以查看 Docker Hub 的对应页面。

docker pull redis
docker run --name redis -d -p 6379:6379 redis

第二步,修改 Menu 的代码,因为 RedisTemplate 会将该对象序列化后存储到 Redis 里,所以它必须实现 Serializable 接口:

public class Menu implements Serializable {

    // 其他代码省略
}

第三步,在启动时增加一个“加载菜单并存储到 Redis”的动作,这里我们同样使用 ApplicationRunner,具体如代码示例 8-2 所示。它从数据库中获得所有的菜单项,再将其序列化存入 Redis 的集合中,并将过期时间设置为 300 秒。这里演示了 opsForList()expire() 的用法。

代码示例 8-2MenuCacheRunner 代码片段

@Component
@Slf4j
@Order(1)
public class MenuCacheRunner implements ApplicationRunner {
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private MenuRepository menuRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<MenuItem> itemList = menuRepository.findAll();
        log.info("Load {} MenuItems from DB, ready to cache.", itemList.size());
        redisTemplate.opsForList().leftPushAll("binarytea-menu", itemList);
        redisTemplate.expire("binarytea-menu", 300, TimeUnit.SECONDS);
    }
}

第四步,修改之前的 MenuPrinterRunner。原本它只能从数据库中取得信息并输出,而新的版本会优先从 Redis 中获取数据,如果没有的话再从数据库加载。具体如代码示例 8-3 所示。为了保证 MenuCacheRunnerMenuPrinterRunner 之前运行,两个类上都增加了 @Order 注解,并配置了执行顺序。

代码示例 8-3 修改后的 MenuPrinterRunner 代码片段

@Component
@Slf4j
@Order(2)
public class MenuPrinterRunner implements ApplicationRunner {
    @Autowired
    private MenuRepository menuRepository;
    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        long size = 0;
        List<MenuItem> menuItemList = null;
        if (redisTemplate.hasKey("binarytea-menu")) {
            BoundListOperations<String, MenuItem> operations = redisTemplate.boundListOps("binarytea-menu");
            size = operations.size();
            menuItemList = operations.range(0, -1);
            log.info("Loading menu from Redis.");
        } else {
            size = menuRepository.count();
            menuItemList = menuRepository.findAll();
            log.info("Loading menu from DB.");
        }
        log.info("共有{}个饮品可选。", size);
        menuItemList.forEach(i -> log.info("饮品:{}", i));
    }
}

Spring Boot 的自动配置默认就会连接 localhost:6379 的 Redis,因此我们无须在 application.properties 中做额外配置。如果不是用这个地址,也可以自己设置,例如:

spring.redis.host=127.0.0.1
spring.redis.port=6379

程序执行的输出大致会是这样的:

[           main] c.shw.binarytea.runner.MenuCacheRunner   : 当前菜单列表的大小是:2
[           main] c.s.binarytea.runner.MenuPrinterRunner   : 从 Redis 数据库中加载菜单列表
[           main] c.s.binarytea.runner.MenuPrinterRunner   : 共有2个饮品可选。
[           main] c.s.binarytea.runner.MenuPrinterRunner   : 饮品:Menu(id=2, name=Java咖啡, size=LARGE, price=CNY 15.00, createTime=2025-02-15 17:47:13.314336, updateTime=2025-02-15 17:47:13.314336)
[           main] c.s.binarytea.runner.MenuPrinterRunner   : 饮品:Menu(id=1, name=Java咖啡, size=MEDIUM, price=CNY 12.00, createTime=2025-02-15 17:47:13.313336, updateTime=2025-02-15 17:47:13.313336)

如果这时用客户端连上 Redis,查看我们保存进去的数据,会看到下面这样的一大串内容。如果不做特殊配置,Spring Data Redis 默认会使用 JDK 自带的序列化机制进行序列化和反序列化。如果有不同语言的系统共用这些缓存数据,那会在很大程度上影响缓存的使用,所以可以考虑改用 JSON 来进行序列化:

\xAC\xED\x00\x05sr\x00\x1Dcom.shw.binarytea.module.Menu\xFFj\xD2!\x1B\xD1Y\xE1\x02\x00\x06L\x00\x0AcreateTimet\x00\x10Ljava/util/Date;L\x00\x02idt\x00\x10Ljava/lang/Long;L\x00\x04namet\x00\x12Ljava/lang/String;L\x00\x05pricet\x00\x16Lorg/joda/money/Money;L\x00\x04sizet\x00\x1ELcom/shw/binarytea/enums/Size;L\x00\x0AupdateTimeq\x00~\x00\x01xpsr\x00\x12java.sql.Timestamp&\x18\xD5\xC8\x01S\xBFe\x02\x00\x01I\x00\x05nanosxr\x00\x0Ejava.util.Datehj\x81\x01KYt\x19\x03\x00\x00xpw\x08\x00\x00\x01\x95	\x01D\xE8x\x12\xBCc\x00sr\x00\x0Ejava.lang.Long;\x8B\xE4\x90\xCC\x8F#\xDF\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xAC\x95\x1D\x0B\x94\xE0\x8B\x02\x00\x00xp\x00\x00\x00\x00\x00\x00\x00\x02t\x00\x0AJava\xE5\x92\x96\xE5\x95\xA1sr\x00\x12org.joda.money.Serq\xD7\xFE\x1B\x88\xED\x97\x9C\x0C\x00\x00xpw\x14M\x00\x03CNY\x00\x9C\x00\x02\x00\x00\x00\x02\x05\xDC\x00\x00\x00\x02x~r\x00\x1Ccom.shw.binarytea.enums.Size\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00\x00xr\x00\x0Ejava.lang.Enum\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00\x00xpt\x00\x05LARGEsq\x00~\x00\x07w\x08\x00\x00\x01\x95	\x01D\xE8x\x12\xBCc\x00

RedisTemplate 默认使用 JdkSerializationRedisSerializer,如果要改变这个方式,就要自己来创建 RedisTemplate,调整序列化方式。Spring Data Redis 内置了几种实现了 RedisSerializer 接口的序列化器,具体如表 8-7 所示,其中两个 JSON 的序列化器都是基于 Jackson2 来实现的。

表 8-7 Spring Data Redis 内置的序列化器

序列化器

快捷方式

说明

JdkSerializationRedisSerializer

RedisSerializer.java()

使用 JDK 的序列化方式

ByteArrayRedisSerializer

RedisSerializer.byteArray()

直接透传 byte[],不做任何处理

StringRedisSerializer

RedisSerializer.string()

根据字符集将字符串序列化为字节

GenericToStringSerializer<T>

依赖 Spring 的 ConversionService 来序列化字符串

GenericJackson2JsonRedisSerializer

RedisSerializer.json()

按照 Object 来序列化对象

Jackson2JsonRedisSerializer<T>

根据给定的泛型类型序列化对象

OxmSerializer

依赖 Spring 的 OXM(Object/XML Mapper,O/M 映射器)来序列化对象

假设我们针对键和值使用不同的序列化方式,可以像下面这段代码一样来配置自己的 RedisTemplate

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisConfig {


    /**
     * 指定 Redis 键值对的序列化方式
     * @param connectionFactory 连接工厂
     * @return Redis 模板对象
     */
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
        
        // 1. 创建 Redis 模板对象
        RedisTemplate redisTemplate = new RedisTemplate();
        // 2. 设置连接工厂
        redisTemplate.setConnectionFactory(connectionFactory);
        // 3. 指定 key 的序列化方式
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // 4. 指定 value 的序列化方式
        redisTemplate.setValueSerializer(RedisSerializer.json());
        return redisTemplate;
    }
}

但往往只这么做是不够的,因为总有些 Jackson2 的 ObjectMapper 无法直接序列化的类型,比如 Money 类型就需要做些特别的处理。

java.lang.IllegalStateException: Failed to execute ApplicationRunner
	at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:762) ~[spring-boot-2.7.6.jar:2.7.6]
	at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:749) ~[spring-boot-2.7.6.jar:2.7.6]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:314) ~[spring-boot-2.7.6.jar:2.7.6]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1303) ~[spring-boot-2.7.6.jar:2.7.6]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1292) ~[spring-boot-2.7.6.jar:2.7.6]
	at com.shw.binarytea.BinaryTeaApplication.main(BinaryTeaApplication.java:15) ~[classes/:na]
Caused by: org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Unrecognized field "negative" (class org.joda.money.Money), not marked as ignorable (0 known properties: ])

Jackson2 提供了标准的序列化和反序列化接口,我们只需实现这些接口就能实现特定类型的转换,而在 Spring Boot 提供的 @JsonComponent 注解的支持下,带了这个注解的类会直接被注册为 Bean,并注入 Spring Boot 维护的 ObjectMapper 中,省去了我们自己配置的麻烦。

其实,在整个序列化和反序列化的过程中,最重要的就是有一个合适的 ObjectMapper,如果我们希望把控其中的细节,还可以注册自己的 Jackson2ObjectMapperBuilderCustomizer,通过它来进行个性化配置。代码示例 8-4 提供了一套简单的处理 Money 类型的代码。

代码示例 8-4 简单的 Money 类型处理代码

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import org.joda.money.Money;
import org.springframework.boot.jackson.JsonComponent;

import java.io.IOException;

/**
 * MoneySerializer:金额序列化器
 */
@JsonComponent
public class MoneySerializer extends StdSerializer<Money> {

    protected MoneySerializer() {
        super(Money.class);
    }

    @Override
    public void serialize(Money money, 
                          JsonGenerator jsonGenerator,
                          SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeNumber(money.getAmount());
    }
}
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.springframework.boot.jackson.JsonComponent;

import java.io.IOException;

/**
 * MoneyDeserializer:金额反序列化器
 */
@JsonComponent
public class MoneyDeserializer extends StdDeserializer<Money> {


    protected MoneyDeserializer() {
        super(Money.class);
    }

    @Override
    public Money deserialize(JsonParser jsonParser,
                             DeserializationContext deserializationContext)
            throws IOException, JsonProcessingException {
        return Money.of(CurrencyUnit.of("CNY"), jsonParser.getDecimalValue());
    }
}

实际上,考虑到 Joda Money 的使用很广泛,Jackson JSON 官方提供了一个针对 Money 类的序列化类型,无须我们自己来实现序列化与反序列化器,只需添加如下依赖就能引入 jackson-datatype-joda-money:15

15这里的 jackson-datatype-joda-money 的版本建议与使用的 Jackson2 版本保持一致。例如,Spring Boot 2.6.3 管理的 Jackson2 版本为 2.13.1,所以我们也要引入 2.13.1 的 jackson-datatype-joda-money。

      <dependency>
        <groupId>com.fasterxml.jackson.datatype</groupId>
        <artifactId>jackson-datatype-joda-money</artifactId>
        <version>2.13.1</version>
      </dependency>

随后在 Spring 配置类中注册这个 JSON 模块,让 Spring Boot 在自动配置 ObjectMapper 时自动注册它,也可以手动在自己的 ObjectMapper 中注册这个模块:

import com.fasterxml.jackson.datatype.jodamoney.JodaMoneyModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MoneyConfig {

    /**
     * 金额对象
     * @return 金额对象
     */
    @Bean
    public JodaMoneyModule jodaMoneyModule() {
        return new JodaMoneyModule();
    }
}

接下来,再调整一下 redisTemplate(),指定我们要处理的泛型类型,让它专门来处理键为 String 值为 Menu的类型,序列化与反序列化都是用 Spring Boot 自动配置的 ObjectMapper。具体如代码示例 8-5 所示。

代码示例 8-5 为 Menu 提供个性化的 RedisTempalte

    @Bean
    public RedisTemplate<String, Menu> redisTemplate(RedisConnectionFactory connectionFactory,
                                                     ObjectMapper objectMapper) {

        Jackson2JsonRedisSerializer<Menu> serializer =
                new Jackson2JsonRedisSerializer<>(Menu.class);
        serializer.setObjectMapper(objectMapper);

        RedisTemplate<String, Menu> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setValueSerializer(serializer);

        return redisTemplate;
    }

程序执行后,再到 Redis 里用 LRANGE "binarytea-menu" 0 0 查看数据,看到的 JSON 输出大概是类似下面这样的:

{\"id\":2,\"name\":\"Java\xe5\x92\x96\xe5\x95\xa1\",\"size\":\"LARGE\",\"price\":15.00,\"createTime\":
 \"2020-10-15T16:59:26.037+00:00\",\"updateTime\":\"2020-10-15T16:59:26.037+00:00\"}
1、Pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.shw</groupId>
    <artifactId>binarytea</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>BinaryTea</name>
    <description>二进制奶茶店</description>

    <properties>
        <java.version>11</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.7.6</spring-boot.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.joda</groupId>
            <artifactId>joda-money</artifactId>
            <version>1.0.1</version>
        </dependency>

        <dependency>
            <groupId>org.jadira.usertype</groupId>
            <artifactId>usertype.core</artifactId>
            <version>6.0.1.GA</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-joda-money</artifactId>
            <version>2.13.1</version>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.shw.binarytea.BinaryTeaApplication</mainClass>
                    <skip>false</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>
2、BinaryTeaApplication
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BinaryTeaApplication {

    public static void main(String[] args) {

        SpringApplication.run(BinaryTeaApplication.class,args);
    }
}
3、application.properties
binarytea.ready=true
binarytea.open-hours=8:30-22:00
# 监控端点:健康检查展示详情 = 总是
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info,shop

spring.jpa.properties.hibernate.format_sql=true
#spring.jpa.properties.hibernate.show_sql=true
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

spring.redis.host=127.0.0.1
spring.redis.port=6379
4、BinaryTeaProperties
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("binarytea") // 配置属性类:将读取到的属性,映射到相应的字段上(此时,还不是容器中的 Bean)
public class BinaryTeaProperties {

    private boolean ready;

    private String openHours;

    public boolean isReady() {
        return ready;
    }

    public void setReady(boolean ready) {
        this.ready = ready;
    }

    public String getOpenHours() {
        return openHours;
    }

    public void setOpenHours(String openHours) {
        this.openHours = openHours;
    }
}
5、ShopConfiguration
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * ShopConfiguration:店铺配置类
 */
@Configuration // 配置类
@EnableConfigurationProperties(
        BinaryTeaProperties.class
) // 开启配置属性(将指定的属性类配置为容器中的 Bean)
 // @EnableConfigurationProperties 相当于:@ConfigurationProperties + @Component 组合
@ConditionalOnProperty(
        name = "binarytea.ready", // 属性名称
        havingValue = "true" // 值
) // 属性条件注解
public class ShopConfiguration {
}
6、spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.shw.config.ShopConfiguration
7、actuator
(1)ShopEndpoint
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

@Component
@Endpoint(id = "shop") // 访问健康监控端点的路径
public class ShopEndpoint {

    private final BinaryTeaProperties binaryTeaProperties;

    public ShopEndpoint(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     *
     * @return 字符串
     */
    @ReadOperation // 类似于 Get 方法
    public String shopState(){
        if (ObjectUtils.isEmpty(binaryTeaProperties) || !binaryTeaProperties.isReady()){
            return "奶茶店:尚未准备营业";
        }else {
            return "奶茶店:已经开始营业";
        }
    }
}
(2)ShopReadyHealthIndicator
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.health.AbstractHealthIndicator;
import org.springframework.boot.actuate.health.Health;
import org.springframework.stereotype.Component;

/**
 * ShopReadyHealthIndicator:店铺准备健康指示器
 *      (1)AbstractHealthIndicator:抽象健康指示器
 */
@Component
public class ShopReadyHealthIndicator extends AbstractHealthIndicator {

    private final BinaryTeaProperties binaryTeaProperties;

    /**
     * 构造器
     *      (1)ObjectProvider:对象提供者(可以提前检验容器中是否有指定的对象)
     * @param binaryTeaProperties 属性类
     */
    public ShopReadyHealthIndicator(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     * 设置健康检查
     * @param builder 构造者
     * @throws Exception 异常
     */
    @Override
    protected void doHealthCheck(Health.Builder builder) throws Exception {

        // 如果属性对象不存在,健康状态设置为 false
        if (binaryTeaProperties == null || !binaryTeaProperties.isReady()){
            builder.down();
        }else {
            builder.up(); // 状态正常
        }
    }
}
8、enums
(1)OrderStatus
/**
 * OrderStatus:订单状态
 */
public enum OrderStatus {
    ORDERED,
    PAID,
    MAKING,
    FINISHED,
    TAKEN;
}
(2)Size
/**
 * Size:杯型
 */
public enum Size {
    SMALL, MEDIUM, LARGE
}
9、module
(1)Amount
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;
import org.joda.money.Money;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.io.Serializable;

/**
 * Amount:金额
 */
@Data
@Builder
@Embeddable // 嵌套注解
@NoArgsConstructor
@AllArgsConstructor
public class Amount implements Serializable {

    @Column(name = "amount_discount")
    private int discount; // 折扣

    @Column(name = "amount_total")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money totalAmount;

    @Column(name = "amount_pay")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money payAmount;
}
(2)Menu
import com.shw.binarytea.enums.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.joda.money.Money;

import javax.persistence.*;
import java.util.Date;
import java.io.Serializable;

/**
 * Menu:菜单实体类
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_menu")
public class Menu implements Serializable {

    private static final long serialVersionUID = -41990206864139807L;

    @Id // 默认自动生成主键
    @GeneratedValue // 主键生成策略
    private Long id;

    private String name;

    @Enumerated(EnumType.STRING) // 枚举(枚举类型是枚举值)
    private Size size;

    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")
    })
    private Money price;

    @CreationTimestamp // 创建时传入当前时间
    @Column(updatable = false) // 字段是否传现在更新语句中
    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    private Date createTime;

    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    @UpdateTimestamp // 更新时传入当前时间
    private Date updateTime;

}
(3)Order
import com.shw.binarytea.enums.OrderStatus;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
import java.util.List;

/**
 * Order:订单
 */
@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_order")
public class Order implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    /**
     * 调茶师:多对一
     */
    @JoinColumn(name = "maker_id") // 映射字段
    @ManyToOne(fetch = FetchType.LAZY) // 多对一关系,懒加载
    private TeaMaker teaMaker;

    /**
     * 菜单列表:多对多
     */
    @OrderBy
    @ManyToMany
    @JoinTable(
            name = "t_order_item",
            joinColumns = @JoinColumn(name = "item_id"),
            inverseJoinColumns = @JoinColumn(name = "order_id")
    )
    private List<Menu> menuList;

    /**
     * 金额
     */
    @Embedded
    private Amount amount;

    /**
     * 订单状态
     */
    @Enumerated
    private OrderStatus status;

    @Column(updatable = false)
    @CreationTimestamp
    private Date createTime;

    @UpdateTimestamp
    private Date updateTime;
}
(4)TeaMaker
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * TeaMaker:调茶师
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_tea_maker")
public class TeaMaker implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    private String name; // 调茶师名字

    /**
     * 一对多:一个调茶师有多个订单
     */
    @OrderBy("id desc ") // 将返回的结果,按照主键排序
    @OneToMany(mappedBy = "teaMaker")
    private List<Order> orderList = new ArrayList<>();

    @CreationTimestamp
    @Column(updatable = false)
    private Date createTime; // 创建时间

    @UpdateTimestamp
    private Date updateTime; // 更新时间
}
10、repository
(1)MenuRepository
import com.shw.binarytea.module.Menu;
import org.springframework.data.jpa.repository.JpaRepository;

/**
 * MenuRepository:菜单数据仓库
 */
public interface MenuRepository extends JpaRepository<Menu,Long> {
    
}
(2)OrderRepository
import com.shw.binarytea.enums.OrderStatus;
import com.shw.binarytea.module.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

/**
 * OrderRepository:订单数据访问层
 */
public interface OrderRepository extends JpaRepository<Order,Long> {

    /**
     * 根据订单状态查询订单
     * @param orderStatus 订单状态
     * @return 订单列表
     */
    List<Order> findByStatusOrderById(OrderStatus orderStatus);

    /**
     * 查询订单列表
     * @param name 调茶师名字
     * @return 订单列表
     */
    List<Order> findByTeaMaker_NameLikeIgnoreCaseOrderByUpdateTimeDescId(String name);
}
(3)TeaMakerRepository
import com.shw.binarytea.module.TeaMaker;
import org.springframework.data.jpa.repository.JpaRepository;

/**
 * TeaMakerRepository:调茶师数据访问层
 */
public interface TeaMakerRepository extends JpaRepository<TeaMaker,Long> {

}
11、runner
(1)MenuCacheRunner
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * MenuCacheRunner:菜单缓存运行器
 *  (1)ApplicationRunner:应用程序运行器
 */
@Slf4j
@Order(1)
@Component
@RequiredArgsConstructor
public class MenuCacheRunner implements ApplicationRunner {

    @Resource
    private MenuRepository menuRepository;

    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 程序启动,运行此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<Menu> menuList = menuRepository.findAll();
        log.info("当前菜单列表的大小是:{}",menuList.size());
        redisTemplate.opsForList().leftPushAll("binary-menuList",menuList);
        redisTemplate.expire("binary-menuList",300, TimeUnit.SECONDS);
    }
}
(2)MenuPrinterRunner
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.repository.MenuRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.List;

/**
 * MenuPrinterRunner:菜单打印启动
 *  (1)ApplicationRunner:应用程序启动类
 */
@Slf4j
@Order(2)
@Component
public class MenuPrinterRunner implements ApplicationRunner {

    @Resource
    private MenuRepository menuRepository;

    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 应用程序启动,调用此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {

        long size = 0;
        List<Menu> menuList = null;

        if (redisTemplate.hasKey("binary-menuList")){
            BoundListOperations<String,Menu> boundListOperations =
                    redisTemplate.boundListOps("binary-menuList");
            size = boundListOperations.size();
            menuList = boundListOperations.range(0,-1);
            log.info("从 Redis 数据库中加载菜单列表");
        }else {
            size = menuRepository.count();
            menuList = menuRepository.findAll();
            log.info("从 数据库中 加载菜单列表");
        }
        log.info("共有{}个饮品可选。", size);
        menuList.forEach(i -> log.info("饮品:{}", i));
    }
}
12、support
(1)MoneySerializer
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import org.joda.money.Money;
import org.springframework.boot.jackson.JsonComponent;

import java.io.IOException;

/**
 * MoneySerializer:序列化器
 */
@JsonComponent
public class MoneySerializer extends StdSerializer<Money> {

    protected MoneySerializer() {
        super(Money.class);
    }

    @Override
    public void serialize(Money money,
                          JsonGenerator jsonGenerator,
                          SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeNumber(money.getAmount());
    }
}
(2)MoneyDeserializer
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.springframework.boot.jackson.JsonComponent;

import java.io.IOException;

/**
 * MoneyDeserializer:反序列化器
 */
@JsonComponent
public class MoneyDeserializer extends StdDeserializer<Money> {


    protected MoneyDeserializer() {
        super(Money.class);
    }

    @Override
    public Money deserialize(JsonParser jsonParser,
                             DeserializationContext deserializationContext)
            throws IOException, JsonProcessingException {
        return Money.of(CurrencyUnit.of("CNY"), jsonParser.getDecimalValue());
    }
}
13、cnfig
(1)MoneyConfig
import com.fasterxml.jackson.datatype.jodamoney.JodaMoneyModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MoneyConfig {

    /**
     * 金额对象
     * @return 金额对象
     */
    @Bean
    public JodaMoneyModule jodaMoneyModule() {
        return new JodaMoneyModule();
    }
}
(2)RedisConfig
import com.fasterxml.jackson.databind.ObjectMapper;
import com.shw.binarytea.module.Menu;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Menu> redisTemplate(RedisConnectionFactory connectionFactory,
                                                     ObjectMapper objectMapper) {

        Jackson2JsonRedisSerializer<Menu> serializer =
                new Jackson2JsonRedisSerializer<>(Menu.class);
        serializer.setObjectMapper(objectMapper);

        RedisTemplate<String, Menu> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setValueSerializer(serializer);

        return redisTemplate;
    }


    /**
     * 指定 Redis 键值对的序列化方式
     * @param connectionFactory 连接工厂
     * @return Redis 模板对象
     */
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {

        // 1. 创建 Redis 模板对象
        RedisTemplate redisTemplate = new RedisTemplate();
        // 2. 设置连接工厂
        redisTemplate.setConnectionFactory(connectionFactory);
        // 3. 指定 key 的序列化方式
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // 4. 指定 value 的序列化方式
        redisTemplate.setValueSerializer(RedisSerializer.json());
        return redisTemplate;
    }
}
14、SQL
(1)schema.sql
drop table if exists t_menu;
drop table if exists t_order;
drop table if exists t_order_item;
drop table if exists t_tea_maker;

create table t_menu (
    id bigint not null auto_increment,
    create_time timestamp,
    name varchar(255),
    price bigint,
    size varchar(255),
    update_time timestamp,
    primary key (id)
);

create table t_order (
     id bigint not null auto_increment,
     amount_discount integer,
     amount_pay bigint,
     amount_total bigint,
     create_time timestamp,
     status integer,
     update_time timestamp,
     maker_id bigint,
     primary key (id)
);

create table t_order_item (
      item_id bigint not null,
      order_id bigint not null
);

create table t_tea_maker (
         id bigint not null auto_increment,
         create_time timestamp,
         name varchar(255),
         update_time timestamp,
         primary key (id)
);
(2)data.sql
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'MEDIUM', 1200, now(), now());
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'LARGE', 1500, now(), now());

insert into t_tea_maker (name, create_time, update_time) values ('LiLei', now(), now());
insert into t_tea_maker (name, create_time, update_time) values ('HanMeimei', now(), now());

insert into t_order (maker_id, status, amount_discount, amount_pay, amount_total, create_time, update_time) values (1, 0, 100, 1200, 1200, now(), now());

insert into t_order_item (order_id, item_id) values (1, 1);

8.2.3、本地缓存 vs. 分布式缓存

读多写少的情况下就可以用缓存,比如读写比为 10:1 的情况就很合适。本节聊到的 Redis 很适合做缓存,这其实是把 Redis 集群当做分布式缓存集群在用。一个应用集群访问同一个缓存,一般不会出现缓存数据不一致的情况(如果要较真一些,还是有概率会出现数据不一致的情况,例如 Redis 主从同步有延时,从不同节点读取数据时就可能会有问题)。但分布式缓存也是有代价的,例如,网络交互的开销和序列化的开销,如果缓存的对象很大,或者访问量很高,也不排除会有打满带宽的情况。总之,没有哪种方案是包治百病还零成本的。

与其相对应的是本地缓存,即将数据缓存在应用本地。以 Java 应用为例,可以将数据缓存在 JVM 的堆内存里。这样做的好处是可以不用经过网络,无须序列化,直接就能获取需要的数据。但这样做的弊端也很明显,假设应用集群有 10 台服务器,每台服务器的缓存可能存在差异,何时更新缓存就是一门学问了。因此如果使用本地缓存,就必须考虑不同服务器缓存不一致的情况,要能够容忍这样的差异。

不过,这两种方式并非水火不容,不妨考虑适当结合两者。例如,我们可以接受缓存数据在更新后 15 秒内的不一致,假设应用集群有 100 台服务器,如果每台机器都每隔 10 秒查询一下数据库,那么这个压力也不小。怎么解决呢?可以在本地做 10 秒的缓存,然后每隔 10 秒查询分布式缓存,并在更新数据库时将分布式缓存的值直接写到缓存里。

8.2.4、 通过 Repository 操作 Redis

在介绍 JPA 时,Spring Data JPA 的 Repository 十分惊艳,让人印象深刻,只需定义接口和方法就能实现各种常用操作。其实,这并非 JPA 所独有的,Spring Data Redis 也有类似的机制,只要 Redis 服务器的版本在 2.8.0 以上,不用事务,就可以通过 Repository 实现各种常用操作了。

1、定义实体

既然是个仓库,就有对应要操作的领域对象,所以我们需要先定义这些对象。表 8-8 罗列了定义 Redis 领域对象时会用到的一些注解。

表 8-8 定义 Redis 领域对象常用的注解

注解

说明

@RedisHash

@Entity类似,用来定义 Redis 的 Repository操作的领域对象,其中的 value

定义了不同类型对象存储时使用的前缀,也叫做键空间(keyspace),默认是全限定类名,timeToLive用来定义缓存的秒数

@Id

定义对象的标识符

@Indexed

定义二级索引,加在属性上可以将该属性定义为查询用的索引

@Reference

缓存对象引用,一般引用的对象也会被展开存储在当前对象中,添加了该注解后会直接存储该对象在 Redis 中的引用

假设我们希望通过 Repository 来缓存菜单,可以像代码示例 8-6 那样定义一个用于 Redis 的菜单对象,其中我们指定了存储时的前缀是 menu,缓存 60 秒,id 为标识符,还有一个二级索引是 name16

代码示例 8-6 用于 Redis 的 RedisMenuItem 类代码片段

import com.shw.binarytea.enums.Size;
import lombok.Getter;
import lombok.Setter;
import org.joda.money.Money;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

import java.io.Serializable;

/**
 * MenuRedis:Redis 菜单数据仓库
 */
@Setter
@Getter
@RedisHash(value = "menu",timeToLive = 60) // 参数1:前缀 参数2:过期时间
public class MenuRedis implements Serializable {

    @Id // 对象标识
    private Long id;

    @Indexed // 对象二级搜索标识
    private String name;

    private Size size;

    private Money money;
}

这里有一个地方需要注意,如果不是用的 Java 序列化,而是 Jackson JSON,则无法自动处理 Money 类型,我们必须定义两个 Converter 处理 Moneybyte[] 的互相转换。

就像代码示例 8-7 那样,通过上下文里的 ObjectMapperJackson2JsonRedisSerializer 来进行序列化与反序列化,@ReadingConverter 标注的 BytesToMoneyConverter 负责在读取时将字节转换为 Money,写进 Redis 时则使用 @WritingConverter 标注的 MoneyToBytesConverter

代码示例 8-7 用于处理 Money 类型的 Converter 代码片段

import com.fasterxml.jackson.databind.ObjectMapper;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

/**
 * BytesToMoneyConverter:读取字节转换为金额对象(反序列化)
 *  (1)在读取时,将字节转换为金额
 */
@ReadingConverter // 读取转换器
public class BytesToMoneyConverter implements Converter<byte[], Money> {

    // Redis 序列化器
    private final Jackson2JsonRedisSerializer<Money> serializer;

    public BytesToMoneyConverter(ObjectMapper objectMapper) {
        serializer = new Jackson2JsonRedisSerializer<Money>(Money.class); // 序列化指定数据类型
        serializer.setObjectMapper(objectMapper);
    }

    @Override
    public Money convert(byte[] source) {
        return serializer.deserialize(source);
    }
}
import com.fasterxml.jackson.databind.ObjectMapper;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.WritingConverter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

/**
 * MoneyToBytesConverter:金额转换为字节转换器(序列化:写入)
 *  (1)金额转换为字节存入 Redis
 */
@WritingConverter // 写入转换器
public class MoneyToBytesConverter implements Converter<Money,byte[]> {

    private final Jackson2JsonRedisSerializer<Money> serializer;

    public MoneyToBytesConverter(ObjectMapper objectMapper){
        serializer = new Jackson2JsonRedisSerializer<Money>(Money.class);
        serializer.setObjectMapper(objectMapper);
    }


    @Override
    public byte[] convert(Money source) {
        return serializer.serialize(source);
    }
}

这两个类需要做个简单的注册,即需要在上下文中配置一个 RedisCustomConversions,将它们添加进去,如代码示例 8-8 所示。

代码示例 8-8 配置 RedisCustomConversions Bean

    /**
     * 注册金额转换对象
     * @param objectMapper 映射对象
     * @return Redis 自定义类型转换对象
     */
    @Bean
    public RedisCustomConversions redisCustomConversions(ObjectMapper objectMapper) {
        return new RedisCustomConversions(
                Arrays.asList(
                        new MoneyToBytesConverter(objectMapper),
                        new BytesToMoneyConverter(objectMapper)
                )
        );
    }

这时使用的 RedisTemplate 可以不用指定泛型类型,用 GenericJackson2JsonRedisSerializer 就够了。我们还是把键序列化成字符串,值序列化成 JSON,如代码示例 8-9 所示。

代码示例 8-9 定制 RedisTemplate Bean

    /**
     * 指定 Redis 键值对的序列化方式
     * @param connectionFactory 连接工厂
     * @return Redis 模板对象
     */
    @Bean
    public RedisTemplate redisTemplate(
            RedisConnectionFactory connectionFactory,
            ObjectMapper objectMapper) {

        // 1. 创建 Redis 类型信息不保存序列化对象
        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(objectMapper);

        // 2. 创建 Redis 模板对象
        RedisTemplate redisTemplate = new RedisTemplate();
        // 3. 设置连接工厂
        redisTemplate.setConnectionFactory(connectionFactory);
        // 4. 指定 key 的序列化方式
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // 5. 指定 value 的序列化方式
        redisTemplate.setValueSerializer(serializer);
        return redisTemplate;
    }
2、定义接口

用于 Redis 的 Repository 接口的定义与 JPA 的如出一辙,基本就是一个模子里刻出来的,继承一样的父接口,用一样的规则来定义接口,如果你不太记得的话,可以回顾一下 7.1.4 节。代码示例 8-10 定义了一个针对 RedisMenuItemRepository 接口。

代码示例 8-10 针对 Redis 修改过的 Repository 接口定义

import com.shw.binarytea.redis.entity.MenuRedis;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

/**
 * RedisMenuMapper:Redis 菜单数据仓库
 */
public interface RedisMenuRepository extends CrudRepository<MenuRedis,Long> {

    /**
     * 根据名称查询菜单列表
     * @param name 名称
     * @return 菜单列表
     */
    List<MenuRedis> findByName(String name);
}

要激活针对 Redis 的 Repository 接口支持,需要在配置类上添加 @EnableRedisRepositories 注解。与 JPA 一样,Spring Boot 的自动配置类 RedisRepositoriesAutoConfiguration(确切地说是它导入的 RedisRepositoriesRegistrar)已经自动添加了这个注解,只要满足条件,就不用我们自己动手了。

接下来,我们来改造一下之前的 MenuCacheRunnerMenuPrinterRunner,从直接使用 RedisTemplate 改为使用 RedisMenuRepository 来操作 Redis。代码示例 8-11 是 MenuCacheRunner 类,它从 MenuRepository 中获取全部的菜单项,转换为 RedisMenuItem 后保存进 Redis。

代码示例 8-11 改造后的 MenuCacheRunner

import com.shw.binarytea.module.Menu;
import com.shw.binarytea.redis.entity.MenuRedis;
import com.shw.binarytea.redis.mapper.RedisMenuRepository;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * MenuCacheRunner:菜单缓存运行器
 *  (1)ApplicationRunner:应用程序运行器
 */
@Slf4j
@Order(1)
@Component
@RequiredArgsConstructor
public class MenuCacheRunner implements ApplicationRunner {

    private final MenuRepository menuRepository;

    private final RedisMenuRepository redisMenuRepository;

    /**
     * 程序启动,运行此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {

        // 1. 数据库中查询所有菜单列表
        List<Menu> menuList = menuRepository.findAll();
        log.info("当前菜单列表的大小是:{}",menuList.size());

        menuList.forEach(menu -> {
            MenuRedis menuRedis = new MenuRedis();
            BeanUtils.copyProperties(menu,menuRedis);
            redisMenuRepository.save(menuRedis);
        });
    }
}

代码示例 8-12 是 MenuPrinterRunner 类:redisMenuRepository 中如果存储了内容,则 count() 会返回存储的对象数量,大于 0 就走缓存,否则就走数据库。

代码示例 8-12 改造后的 MenuPrinterRunner

import com.shw.binarytea.redis.mapper.RedisMenuRepository;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * MenuPrinterRunner:菜单打印启动
 *  (1)ApplicationRunner:应用程序启动类
 */
@Slf4j
@Order(2)
@Component
@RequiredArgsConstructor
public class MenuPrinterRunner implements ApplicationRunner {

    private final MenuRepository menuRepository;

    private final RedisMenuRepository redisMenuRepository;

    /**
     * 应用程序启动,调用此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {

        long size = 0;
        Iterable<?> menuList = null;

        if (redisMenuRepository.count() > 0){

            log.info("从 Redis 数据库中加载菜单列表");
            size = redisMenuRepository.count();
            menuList = redisMenuRepository.findAll();
            log.info("Java咖啡缓存了{}条", redisMenuRepository.findByName("Java咖啡").size());
        }else {
            size = menuRepository.count();
            menuList = menuRepository.findAll();
            log.info("从 数据库中 加载菜单列表");
        }
        log.info("共有{}个饮品可选。", size);
        menuList.forEach(i -> log.info("饮品:{}", i));
    }
}

程序运行后,在 Redis 里查询到的内容会是类似下面这样的:

▸ redis-cli
127.0.0.1:6379> keys *
1) "menu:1:phantom"
2) "menu:2"
3) "menu:1:idx"
4) "menu:2:idx"
5) "menu"
6) "menu:2:phantom"
7) "menu:1"
8) "menu:name:Java\xe5\x92\x96\xe5\x95\xa1"

127.0.0.1:6379> hgetall menu:1
1) "_class"
2) "learning.spring.binarytea.model.RedisMenuItem"
3) "id"
4) "1"
5) "name"
6) "Java\xe5\x92\x96\xe5\x95\xa1"
7) "size"
8) "MEDIUM"
9) "price"
10) "{\"amount\":12.00,\"currency\":\"CNY\"}"
127.0.0.1:6379>

3、 多种不同的 Repository 如何共存

不知道大家有没有这样的疑问:工程里同时存在 JPA 和 Redis 两种类型的 Repository 接口,Spring Data 怎么知道它们分别适用于什么类型的存储,又该如何实例化呢?

Spring Data 中定义了如下一些规则,来帮助我们区分。

(1) 领域对象上添加的注解。通过这条基本就已经可以充分区分了,JPA 的领域对象用 @Entity,Redis 的领域对象用 @RedisHash,还有 MongoDB 的领域对象用 @Document

(2) 接口继承的父接口。Spring Data 中有一些针对特定底层技术的接口,例如针对 JPA 的 JpaRepository 或者针对 MongoDB 的 MongoRepository。都用了这些接口了,那一定是适配这些技术的。

(3) 包路径。@EnableJpaRepositories@EnableRedisRepositories 注解里都有 basePackage 属性用于配置扫描的包路径,通过它可以明确地区分不同的接口。

如果可以的话,建议使用第(1)条规则,因为它最为清晰明了。

8.2.5、完整案例

1、Pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.shw</groupId>
    <artifactId>binarytea</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>BinaryTea</name>
    <description>二进制奶茶店</description>

    <properties>
        <java.version>11</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.7.6</spring-boot.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.joda</groupId>
            <artifactId>joda-money</artifactId>
            <version>1.0.1</version>
        </dependency>

        <dependency>
            <groupId>org.jadira.usertype</groupId>
            <artifactId>usertype.core</artifactId>
            <version>6.0.1.GA</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-joda-money</artifactId>
            <version>2.13.1</version>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.shw.binarytea.BinaryTeaApplication</mainClass>
                    <skip>false</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>
2、BinaryTeaApplication
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BinaryTeaApplication {

    public static void main(String[] args) {

        SpringApplication.run(BinaryTeaApplication.class,args);
    }
}
3、application.properties
binarytea.ready=true
binarytea.open-hours=8:30-22:00
# 监控端点:健康检查展示详情 = 总是
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info,shop

spring.jpa.properties.hibernate.format_sql=true
#spring.jpa.properties.hibernate.show_sql=true
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

spring.redis.host=127.0.0.1
spring.redis.port=6379
4、BinaryTeaProperties
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("binarytea") // 配置属性类:将读取到的属性,映射到相应的字段上(此时,还不是容器中的 Bean)
public class BinaryTeaProperties {

    private boolean ready;

    private String openHours;

    public boolean isReady() {
        return ready;
    }

    public void setReady(boolean ready) {
        this.ready = ready;
    }

    public String getOpenHours() {
        return openHours;
    }

    public void setOpenHours(String openHours) {
        this.openHours = openHours;
    }
}
5、ShopConfiguration
  • com.shw.config.ShopConfiguration
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * ShopConfiguration:店铺配置类
 */
@Configuration // 配置类
@EnableConfigurationProperties(
        BinaryTeaProperties.class
) // 开启配置属性(将指定的属性类配置为容器中的 Bean)
 // @EnableConfigurationProperties 相当于:@ConfigurationProperties + @Component 组合
@ConditionalOnProperty(
        name = "binarytea.ready", // 属性名称
        havingValue = "true" // 值
) // 属性条件注解
public class ShopConfiguration {
}
6、spring.factories
  • META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.shw.config.ShopConfiguration
7、actuator
(1)ShopEndpoint
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

@Component
@Endpoint(id = "shop") // 访问健康监控端点的路径
public class ShopEndpoint {

    private final BinaryTeaProperties binaryTeaProperties;

    public ShopEndpoint(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     *
     * @return 字符串
     */
    @ReadOperation // 类似于 Get 方法
    public String shopState(){
        if (ObjectUtils.isEmpty(binaryTeaProperties) || !binaryTeaProperties.isReady()){
            return "奶茶店:尚未准备营业";
        }else {
            return "奶茶店:已经开始营业";
        }
    }
}
(2)ShopReadyHealthIndicator
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.health.AbstractHealthIndicator;
import org.springframework.boot.actuate.health.Health;
import org.springframework.stereotype.Component;

/**
 * ShopReadyHealthIndicator:店铺准备健康指示器
 *      (1)AbstractHealthIndicator:抽象健康指示器
 */
@Component
public class ShopReadyHealthIndicator extends AbstractHealthIndicator {

    private final BinaryTeaProperties binaryTeaProperties;

    /**
     * 构造器
     *      (1)ObjectProvider:对象提供者(可以提前检验容器中是否有指定的对象)
     * @param binaryTeaProperties 属性类
     */
    public ShopReadyHealthIndicator(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     * 设置健康检查
     * @param builder 构造者
     * @throws Exception 异常
     */
    @Override
    protected void doHealthCheck(Health.Builder builder) throws Exception {

        // 如果属性对象不存在,健康状态设置为 false
        if (binaryTeaProperties == null || !binaryTeaProperties.isReady()){
            builder.down();
        }else {
            builder.up(); // 状态正常
        }
    }
}
8、enums
(1)OrderStatus
/**
 * OrderStatus:订单状态
 */
public enum OrderStatus {
    ORDERED,
    PAID,
    MAKING,
    FINISHED,
    TAKEN;
}
(2)Size
/**
 * Size:杯型
 */
public enum Size {
    SMALL, MEDIUM, LARGE
}
9、module
(1)Amount
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;
import org.joda.money.Money;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.io.Serializable;

/**
 * Amount:金额
 */
@Data
@Builder
@Embeddable // 嵌套注解
@NoArgsConstructor
@AllArgsConstructor
public class Amount implements Serializable {

    @Column(name = "amount_discount")
    private int discount; // 折扣

    @Column(name = "amount_total")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money totalAmount;

    @Column(name = "amount_pay")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money payAmount;
}
(2)Menu
import com.shw.binarytea.enums.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.joda.money.Money;

import javax.persistence.*;
import java.util.Date;
import java.io.Serializable;

/**
 * Menu:菜单实体类
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_menu")
public class Menu implements Serializable {

    private static final long serialVersionUID = -41990206864139807L;

    @Id // 默认自动生成主键
    @GeneratedValue // 主键生成策略
    private Long id;

    private String name;

    @Enumerated(EnumType.STRING) // 枚举(枚举类型是枚举值)
    private Size size;

    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")
    })
    private Money price;

    @CreationTimestamp // 创建时传入当前时间
    @Column(updatable = false) // 字段是否传现在更新语句中
    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    private Date createTime;

    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    @UpdateTimestamp // 更新时传入当前时间
    private Date updateTime;

}
(3)Order
import com.shw.binarytea.enums.OrderStatus;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
import java.util.List;

/**
 * Order:订单
 */
@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_order")
public class Order implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    /**
     * 调茶师:多对一
     */
    @JoinColumn(name = "maker_id") // 映射字段
    @ManyToOne(fetch = FetchType.LAZY) // 多对一关系,懒加载
    private TeaMaker teaMaker;

    /**
     * 菜单列表:多对多
     */
    @OrderBy
    @ManyToMany
    @JoinTable(
            name = "t_order_item",
            joinColumns = @JoinColumn(name = "item_id"),
            inverseJoinColumns = @JoinColumn(name = "order_id")
    )
    private List<Menu> menuList;

    /**
     * 金额
     */
    @Embedded
    private Amount amount;

    /**
     * 订单状态
     */
    @Enumerated
    private OrderStatus status;

    @Column(updatable = false)
    @CreationTimestamp
    private Date createTime;

    @UpdateTimestamp
    private Date updateTime;
}
(4)TeaMaker
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * TeaMaker:调茶师
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_tea_maker")
public class TeaMaker implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    private String name; // 调茶师名字

    /**
     * 一对多:一个调茶师有多个订单
     */
    @OrderBy("id desc ") // 将返回的结果,按照主键排序
    @OneToMany(mappedBy = "teaMaker")
    private List<Order> orderList = new ArrayList<>();

    @CreationTimestamp
    @Column(updatable = false)
    private Date createTime; // 创建时间

    @UpdateTimestamp
    private Date updateTime; // 更新时间
}
10、repository
(1)MenuRepository
import com.shw.binarytea.module.Menu;
import org.springframework.data.jpa.repository.JpaRepository;

/**
 * MenuRepository:菜单数据仓库
 */
public interface MenuRepository extends JpaRepository<Menu,Long> {
    
}
(2)OrderRepository
import com.shw.binarytea.enums.OrderStatus;
import com.shw.binarytea.module.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

/**
 * OrderRepository:订单数据访问层
 */
public interface OrderRepository extends JpaRepository<Order,Long> {

    /**
     * 根据订单状态查询订单
     * @param orderStatus 订单状态
     * @return 订单列表
     */
    List<Order> findByStatusOrderById(OrderStatus orderStatus);

    /**
     * 查询订单列表
     * @param name 调茶师名字
     * @return 订单列表
     */
    List<Order> findByTeaMaker_NameLikeIgnoreCaseOrderByUpdateTimeDescId(String name);
}
(3)TeaMakerRepository
import com.shw.binarytea.module.TeaMaker;
import org.springframework.data.jpa.repository.JpaRepository;

/**
 * TeaMakerRepository:调茶师数据访问层
 */
public interface TeaMakerRepository extends JpaRepository<TeaMaker,Long> {

}
11、money
(1)MoneyConfig
import com.fasterxml.jackson.datatype.jodamoney.JodaMoneyModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MoneyConfig {

    /**
     * 金额对象
     * @return 金额对象
     */
    @Bean
    public JodaMoneyModule jodaMoneyModule() {
        return new JodaMoneyModule();
    }
}
(2)BytesToMoneyConverter
import com.fasterxml.jackson.databind.ObjectMapper;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

/**
 * BytesToMoneyConverter:读取字节转换为金额对象(反序列化)
 *  (1)在读取时,将字节转换为金额
 */
@ReadingConverter // 读取转换器
public class BytesToMoneyConverter implements Converter<byte[], Money> {

    // Redis 序列化器
    private final Jackson2JsonRedisSerializer<Money> serializer;

    public BytesToMoneyConverter(ObjectMapper objectMapper) {
        serializer = new Jackson2JsonRedisSerializer<Money>(Money.class); // 序列化指定数据类型
        serializer.setObjectMapper(objectMapper);
    }

    @Override
    public Money convert(byte[] source) {
        return serializer.deserialize(source);
    }
}
(3)MoneyToBytesConverter
import com.fasterxml.jackson.databind.ObjectMapper;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.WritingConverter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

/**
 * MoneyToBytesConverter:金额转换为字节转换器(序列化:写入)
 *  (1)金额转换为字节存入 Redis
 */
@WritingConverter // 写入转换器
public class MoneyToBytesConverter implements Converter<Money,byte[]> {

    private final Jackson2JsonRedisSerializer<Money> serializer;

    public MoneyToBytesConverter(ObjectMapper objectMapper){
        serializer = new Jackson2JsonRedisSerializer<Money>(Money.class);
        serializer.setObjectMapper(objectMapper);
    }


    @Override
    public byte[] convert(Money source) {
        return serializer.serialize(source);
    }
}

12、redis

(1)RedisConfig
import com.fasterxml.jackson.databind.ObjectMapper;
import com.shw.binarytea.money.support.BytesToMoneyConverter;
import com.shw.binarytea.money.support.MoneyToBytesConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.convert.RedisCustomConversions;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.util.Arrays;

@Configuration
public class RedisConfig {

    /**
     * 指定 Redis 键值对的序列化方式
     * @param connectionFactory 连接工厂
     * @return Redis 模板对象
     */
    @Bean
    public RedisTemplate redisTemplate(
            RedisConnectionFactory connectionFactory,
            ObjectMapper objectMapper) {

        // 1. 创建 Redis 类型信息不保存序列化对象
        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(objectMapper);

        // 2. 创建 Redis 模板对象
        RedisTemplate redisTemplate = new RedisTemplate();
        // 3. 设置连接工厂
        redisTemplate.setConnectionFactory(connectionFactory);
        // 4. 指定 key 的序列化方式
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // 5. 指定 value 的序列化方式
        redisTemplate.setValueSerializer(serializer);
        return redisTemplate;
    }

    /**
     * 注册金额转换对象
     * @param objectMapper 映射对象
     * @return Redis 自定义类型转换对象
     */
    @Bean
    public RedisCustomConversions redisCustomConversions(ObjectMapper objectMapper) {
        return new RedisCustomConversions(
                Arrays.asList(
                        new MoneyToBytesConverter(objectMapper),
                        new BytesToMoneyConverter(objectMapper)
                )
        );
    }
}
(2)MenuRedis
import com.shw.binarytea.enums.Size;
import lombok.Getter;
import lombok.Setter;
import org.joda.money.Money;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

import java.io.Serializable;

/**
 * MenuRedis:Redis 菜单数据仓库
 */
@Setter
@Getter
@RedisHash(value = "menu",timeToLive = 60) // 参数1:前缀 参数2:过期时间
public class MenuRedis implements Serializable {

    @Id // 对象标识
    private Long id;

    @Indexed // 对象二级搜索标识
    private String name;

    private Size size;

    private Money money;
}
(3)RedisMenuRepository
import com.shw.binarytea.redis.entity.MenuRedis;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

/**
 * RedisMenuMapper:Redis 菜单数据仓库
 */
public interface RedisMenuRepository extends CrudRepository<MenuRedis,Long> {

    /**
     * 根据名称查询菜单列表
     * @param name 名称
     * @return 菜单列表
     */
    List<MenuRedis> findByName(String name);
}
13、runner
(1)MenuCacheRunner
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.redis.entity.MenuRedis;
import com.shw.binarytea.redis.mapper.RedisMenuRepository;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * MenuCacheRunner:菜单缓存运行器
 *  (1)ApplicationRunner:应用程序运行器
 */
@Slf4j
@Order(1)
@Component
@RequiredArgsConstructor
public class MenuCacheRunner implements ApplicationRunner {

    private final MenuRepository menuRepository;

    private final RedisMenuRepository redisMenuRepository;

    /**
     * 程序启动,运行此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {

        // 1. 数据库中查询所有菜单列表
        List<Menu> menuList = menuRepository.findAll();
        log.info("当前菜单列表的大小是:{}",menuList.size());

        menuList.forEach(menu -> {
            MenuRedis menuRedis = new MenuRedis();
            BeanUtils.copyProperties(menu,menuRedis);
            redisMenuRepository.save(menuRedis);
        });
    }
}
(2)MenuPrinterRunner
import com.shw.binarytea.redis.mapper.RedisMenuRepository;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * MenuPrinterRunner:菜单打印启动
 *  (1)ApplicationRunner:应用程序启动类
 */
@Slf4j
@Order(2)
@Component
@RequiredArgsConstructor
public class MenuPrinterRunner implements ApplicationRunner {

    private final MenuRepository menuRepository;

    private final RedisMenuRepository redisMenuRepository;

    /**
     * 应用程序启动,调用此方法
     * @param args 命令行参数
     * @throws Exception 异常
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {

        long size = 0;
        Iterable<?> menuList = null;

        if (redisMenuRepository.count() > 0){

            log.info("从 Redis 数据库中加载菜单列表");
            size = redisMenuRepository.count();
            menuList = redisMenuRepository.findAll();
            log.info("Java咖啡缓存了{}条", redisMenuRepository.findByName("Java咖啡").size());
        }else {
            size = menuRepository.count();
            menuList = menuRepository.findAll();
            log.info("从 数据库中 加载菜单列表");
        }
        log.info("共有{}个饮品可选。", size);
        menuList.forEach(i -> log.info("饮品:{}", i));
    }
}
14、SQL
(1)schema.sql
drop table if exists t_menu;
drop table if exists t_order;
drop table if exists t_order_item;
drop table if exists t_tea_maker;

create table t_menu (
    id bigint not null auto_increment,
    create_time timestamp,
    name varchar(255),
    price bigint,
    size varchar(255),
    update_time timestamp,
    primary key (id)
);

create table t_order (
     id bigint not null auto_increment,
     amount_discount integer,
     amount_pay bigint,
     amount_total bigint,
     create_time timestamp,
     status integer,
     update_time timestamp,
     maker_id bigint,
     primary key (id)
);

create table t_order_item (
      item_id bigint not null,
      order_id bigint not null
);

create table t_tea_maker (
         id bigint not null auto_increment,
         create_time timestamp,
         name varchar(255),
         update_time timestamp,
         primary key (id)
);
(2)data.sql
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'MEDIUM', 1200, now(), now());
insert into t_menu (name, size, price, create_time, update_time) values ('Java咖啡', 'LARGE', 1500, now(), now());

insert into t_tea_maker (name, create_time, update_time) values ('LiLei', now(), now());
insert into t_tea_maker (name, create_time, update_time) values ('HanMeimei', now(), now());

insert into t_order (maker_id, status, amount_discount, amount_pay, amount_total, create_time, update_time) values (1, 0, 100, 1200, 1200, now(), now());

insert into t_order_item (order_id, item_id) values (1, 1);

8.3、Spring 的缓存抽象

我们一般是按照图 8-3 的步骤来使用缓存数据的:

(1)第一次进入代码段,判断缓存中是否有需要的数据,如果存在就用缓存里的,如果不存在就从数据库或其他地方读取数据并放入缓存,这样下次就能从缓存获取数据了。

(2)当然,这里还要考虑缓存内容过期、超过缓存上限时内容淘汰、数据写入缓存时是否加锁等问题。

图 8-3 一般的缓存操作流程

我们将这个过程抽象一下,缓存的常见操作无非就是判断是否存在、读取、写入和淘汰,真希望有一套框架能自动缓存特定方法的返回值,这样就不用我们再自己写代码了。Spring Framework 恰好就提供了这么一层缓存抽象。

8.3.1、基于注解的方法缓存

Spring Framework 3.1 引入了一套缓存抽象,它通过注解或者 XML 的方式配置到方法上,每次执行方法就会在缓存里做一次检查,看看是否已经用当前参数调用过这个方法了,如果调用过并且有结果在缓存里了,就不再执行实际的方法调用,而是直接返回缓存值;如果没调用过,就进行调用,并缓存结果。这样一来,就可以自动避免反复执行一些开销很大的方法了。

请注意 这里有两点需要着重说明一下:

(1)这套缓存抽象背后是通过 AOP 来实现的,即在实际对象外面包了一层代理,由代理来完成缓存操作,所以必须访问代理后的对象;

(2)只有那些可幂等操作的方法才适用于这套抽象,因为必须要保证相同的参数拥有一样的返回值。

从 Spring Framework 4.1 版本开始,这套缓存抽象还提供了对 JSR-107 注解的支持。在本节中,我们就着重讨论基于注解的方法缓存。

1、常用注解介绍

表 8-9 中列举了在 Spring 的缓存抽象中提供的注解,以及它们与 JSR-107 注解的对应关系。

表 8-9 Spring 的缓存抽象中提供的注解

Spring 注解

JSR-107 对应注解

说明

@Cacheable

@CacheResult

从缓存中获取对应的缓存值,没有的话就执行方法并缓存,然后返回,其中 sync如果为 true,在调用方法时会锁住缓存,相同的参数只有一个线程会计算,其他线程等待结果

@CachePut

@CachePut

直接用方法返回更新缓存,不做判断

@CacheEvict

@CacheRemove

/ @CacheRemoveAll

清除缓存,其中的 allEntries 如果设置为 true,则清除指定缓存

@Caching

可以用来组合多个缓存抽象的注解,比如两个 @CacheEvict

@CacheConfig

@CacheDefaults

添加在类上,为这个类里的缓存抽象注解提供公共配置,例如统一的 cacheNamescacheManager

这些注解中有很多一样的属性(除了 @Caching),具体如下。

(1)cacheNames,用来设置操作的缓存列表,例如 cacheNames="menu"

(2)key,计算缓存键名的 SpEL 表达式,如果不设置,默认值是 "",即所有参数都参与计算,共同决定 键名。通过 {#root.args[0]} 来引用方法的第 1 个参数,此处也可以替换为参数名,例如 #foo#result 可以访问方法的返回对象,如果注解有 beforeInvocation 属性,配置为 true#result 不可用;#root.methodName#root.targetClass 可以访问方法名和目标类的类名,相应地,#root.method#root.target 则访问对应的对象;#root.caches 可以获得要操作的缓存列表。

(3)keyGenerator,自定义的 KeyGenerator Bean 名称,用来生成缓存键名,与 key 属性互斥。

(4)cacheManager,缓存管理器的 Bean 名称,负责管理实际的缓存。

(5)cacheResolver,缓存解析器的 Bean 名称,与 cacheManager 属性互斥。

(6)condition,操作缓存的条件,也是用 SpEL 表达式来计算的。

前面说过,keykeyGenerator 是用来计算缓存键名的,默认情况下,SimpleKeyGenerator 会根据参数来生成键名,策略如下。

(1)如果没有参数,直接返回 SimpleKey.EMPTY

(2)如果只有一个参数,且参数不为 null,也不是数组类型,直接返回这个参数。

(3)如果是其他情况,返回包含所有参数的 SimpleKey 实例。

condition 用来计算操作条件,它可以是这样的:

@Cacheable(cacheNames="menu", condition="#name.length() < 16")
public Menu findByName(String name) {...}

还有一个 unless 参数,并不是所有注解都有,它是用来投否决票的。unless 仅在方法执行后有效,可以拿到执行结果 #result。当 unless 的表达式计算为 true 时则不放入缓存。

2、 如何激活缓存抽象

了解了上面的这些注解后,又该怎么激活 Spring Framework 对它们的支持呢?激活的方法与事务类似,可以在配置类上增加 @EnableCaching 注解,例如下面这样:

@Configuration
@EnableCaching
public class Config {}

也可以在 XML 配置文件中使用 <cache:annotation-driven/> 标签,例如:

<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:cache="http://www.springframework.org/schema/cache"
  xsi:schemaLocation="
  http://www.springframework.org/schema/beans
  https://www.springframework.org/schema/beans/spring-beans.xsd
  http://www.springframework.org/schema/cache
  https://www.springframework.org/schema/cache/spring-cache.xsd">

  <cache:annotation-driven/>
</beans>

前文提到的各种注解都可以在 <cache/> 中找到对应的配置方法,此处就不展开了,我们还是以注解的用法为主。

现在,回到二进制奶茶店的例子中,8.2 节的需求描述中已经说明了菜单是长时间的,属于读多写少的内容,因此非常适合做缓存。我们可以将整个菜单缓存起来,减少对数据库的操作。先添加一个菜单服务类 MenuService,用它来封装 Repository 的操作,同时在其方法上添加相应的注解,如代码示例 8-13 所示。其实,一般都推荐将业务逻辑封装到 Service 中,一个服务在具体实现过程中可能会涉及多个不同表的操作,所以我们将事务也添加在 Service 上。

代码示例 8-13MenuServiceMenuRepository 的代码片段

import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@CacheConfig(cacheNames = "menu")
@RequiredArgsConstructor
public class MenuService {

    private final MenuRepository menuRepository;

    /**
     * 查询所有菜单
     * @return 菜单列表
     */
    @Cacheable
    public List<Menu> selectMenuList(){
        return menuRepository.findAll();
    }

    /**
     * 查询菜单
     * @param name 名称
     * @param size 大小
     * @return 菜单
     */
    @Cacheable(key = "#root.methodName + '-' + #name + '-' + #size")
    public Optional<Menu> selectMenuByNameAndSize(String name, Size size){
        return menuRepository.findByNameAndSize(name,size);
    }
}

上面这段代码中,类上添加的 @CacheConfig 注解配置了公共的 cacheNames,因此就不用再在两个方法上做配置了;selectMenuByNameAndSize() 上的 @Cacheable 注解中配置了 key 属性,此处将方法名、name 参数与 size 参数用“-”拼接在一起作为缓存的键名。

有了菜单服务,接下来需要初始化一下我们的缓存,做个“预热”,代码示例 8-14 就起到了这个作用。先调用 selectMenuList() 缓存完整的菜单列表,再调用 selectMenuByNameAndSize() 缓存单个菜单项。一般情况下在有了完整数据后,可以通过简单处理来获得里面的内容,这里出于演示的目的又加了些其他方法,在实践中可以酌情考虑合理使用缓存。

代码示例 8-14 用于“预热”缓存的 MenuCacheRunner 代码片段

import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.service.MenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.List;

@Slf4j
@Order(1)
@Component
@RequiredArgsConstructor
public class MenuCacheRunner implements ApplicationRunner {

    private final MenuService menuService;

    @Override
    public void run(ApplicationArguments args) throws Exception {

        log.info("从数据库加载菜单列表,后续应该就在缓存里了");
        List<Menu> menuList = menuService.selectMenuList();
        log.info("共取得{}个条目。", menuList.size());
        menuService.selectMenuByNameAndSize("Java咖啡", Size.MEDIUM)
                .ifPresent(m -> log.info("加载中杯Java咖啡,放入缓存,ID={}", m.getId()));
    }
}

缓存“预热”后,再遇到调用对应方法的情况,只要缓存未失效,就不会执行真正的调用,而是直接返回缓存的值。调整后的 MenuPrinterRunner 会使用 MenuService 来获取菜单信息,加载到 data.sql 中预先插入的两条数据后,再重新通过 selectMenuByNameAndSize() 来做遍历,这时因为在 MenuCacheRunner 中缓存过“中杯 Java 咖啡”,它会直接从缓存中获取,而大杯咖啡则还需要访问数据库,如代码示例 8-15 所示。

代码示例 8-15 经过调整的 MenuPrinterRunner 代码片段

import com.shw.binarytea.service.MenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Slf4j
@Order(2)
@Component
@RequiredArgsConstructor
public class MenuPrinterRunner implements ApplicationRunner {

    private final MenuService menuService;

    @Override
    public void run(ApplicationArguments args) throws Exception {

    }
}

由于开启了 Hibernate 的 SQL 打印功能,在运行程序时,通过观察日志中的 SQL 执行情况,就可以很方便地判断是否命中缓存。

3、默认缓存完整案例
1、Pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.shw</groupId>
    <artifactId>binarytea</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>BinaryTea</name>
    <description>二进制奶茶店</description>

    <properties>
        <java.version>11</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.7.6</spring-boot.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.joda</groupId>
            <artifactId>joda-money</artifactId>
            <version>1.0.1</version>
        </dependency>

        <dependency>
            <groupId>org.jadira.usertype</groupId>
            <artifactId>usertype.core</artifactId>
            <version>6.0.1.GA</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-joda-money</artifactId>
            <version>2.13.1</version>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.shw.binarytea.BinaryTeaApplication</mainClass>
                    <skip>false</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>
2、BinaryTeaApplication
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@EnableCaching // 开启缓存
@SpringBootApplication
public class BinaryTeaApplication {

    public static void main(String[] args) {

        SpringApplication.run(BinaryTeaApplication.class,args);
    }
}
3、application.properties
binarytea.ready=true
binarytea.open-hours=8:30-22:00
# 监控端点:健康检查展示详情 = 总是
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info,shop

spring.jpa.properties.hibernate.format_sql=true
#spring.jpa.properties.hibernate.show_sql=true
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
4、BinaryTeaProperties
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("binarytea") // 配置属性类:将读取到的属性,映射到相应的字段上(此时,还不是容器中的 Bean)
public class BinaryTeaProperties {

    private boolean ready;

    private String openHours;

    public boolean isReady() {
        return ready;
    }

    public void setReady(boolean ready) {
        this.ready = ready;
    }

    public String getOpenHours() {
        return openHours;
    }

    public void setOpenHours(String openHours) {
        this.openHours = openHours;
    }
}
5、ShopConfiguration
  • com.shw.config.ShopConfiguration
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * ShopConfiguration:店铺配置类
 */
@Configuration // 配置类
@EnableConfigurationProperties(
        BinaryTeaProperties.class
) // 开启配置属性(将指定的属性类配置为容器中的 Bean)
 // @EnableConfigurationProperties 相当于:@ConfigurationProperties + @Component 组合
@ConditionalOnProperty(
        name = "binarytea.ready", // 属性名称
        havingValue = "true" // 值
) // 属性条件注解
public class ShopConfiguration {
}
6、spring.factories
  • META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.shw.config.ShopConfiguration
7、actuator
(1)ShopEndpoint
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

@Component
@Endpoint(id = "shop") // 访问健康监控端点的路径
public class ShopEndpoint {

    private final BinaryTeaProperties binaryTeaProperties;

    public ShopEndpoint(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     *
     * @return 字符串
     */
    @ReadOperation // 类似于 Get 方法
    public String shopState(){
        if (ObjectUtils.isEmpty(binaryTeaProperties) || !binaryTeaProperties.isReady()){
            return "奶茶店:尚未准备营业";
        }else {
            return "奶茶店:已经开始营业";
        }
    }
}
(2)ShopReadyHealthIndicator
import com.shw.binarytea.properties.BinaryTeaProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.health.AbstractHealthIndicator;
import org.springframework.boot.actuate.health.Health;
import org.springframework.stereotype.Component;

/**
 * ShopReadyHealthIndicator:店铺准备健康指示器
 *      (1)AbstractHealthIndicator:抽象健康指示器
 */
@Component
public class ShopReadyHealthIndicator extends AbstractHealthIndicator {

    private final BinaryTeaProperties binaryTeaProperties;

    /**
     * 构造器
     *      (1)ObjectProvider:对象提供者(可以提前检验容器中是否有指定的对象)
     * @param binaryTeaProperties 属性类
     */
    public ShopReadyHealthIndicator(ObjectProvider<BinaryTeaProperties> binaryTeaProperties) {
        this.binaryTeaProperties = binaryTeaProperties.getIfAvailable();
    }

    /**
     * 设置健康检查
     * @param builder 构造者
     * @throws Exception 异常
     */
    @Override
    protected void doHealthCheck(Health.Builder builder) throws Exception {

        // 如果属性对象不存在,健康状态设置为 false
        if (binaryTeaProperties == null || !binaryTeaProperties.isReady()){
            builder.down();
        }else {
            builder.up(); // 状态正常
        }
    }
}
8、enums
(1)OrderStatus
/**
 * OrderStatus:订单状态
 */
public enum OrderStatus {
    ORDERED,
    PAID,
    MAKING,
    FINISHED,
    TAKEN;
}
(2)Size
/**
 * Size:杯型
 */
public enum Size {
    SMALL, MEDIUM, LARGE
}
9、module
(1)Amount
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;
import org.joda.money.Money;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.io.Serializable;

/**
 * Amount:金额
 */
@Data
@Builder
@Embeddable // 嵌套注解
@NoArgsConstructor
@AllArgsConstructor
public class Amount implements Serializable {

    @Column(name = "amount_discount")
    private int discount; // 折扣

    @Column(name = "amount_total")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money totalAmount;

    @Column(name = "amount_pay")
    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")})
    private Money payAmount;
}
(2)Menu
import com.shw.binarytea.enums.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.joda.money.Money;

import javax.persistence.*;
import java.util.Date;
import java.io.Serializable;

/**
 * Menu:菜单实体类
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_menu")
public class Menu implements Serializable {

    private static final long serialVersionUID = -41990206864139807L;

    @Id // 默认自动生成主键
    @GeneratedValue // 主键生成策略
    private Long id;

    private String name;

    @Enumerated(EnumType.STRING) // 枚举(枚举类型是枚举值)
    private Size size;

    @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyMinorAmount",
            parameters = {
                @org.hibernate.annotations.Parameter(
                        name = "currencyCode",
                        value = "CNY")
    })
    private Money price;

    @CreationTimestamp // 创建时传入当前时间
    @Column(updatable = false) // 字段是否传现在更新语句中
    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    private Date createTime;

    @Temporal(TemporalType.TIMESTAMP) // 映射日期类型
    @UpdateTimestamp // 更新时传入当前时间
    private Date updateTime;

}
(3)Order
import com.shw.binarytea.enums.OrderStatus;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
import java.util.List;

/**
 * Order:订单
 */
@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_order")
public class Order implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    /**
     * 调茶师:多对一
     */
    @JoinColumn(name = "maker_id") // 映射字段
    @ManyToOne(fetch = FetchType.LAZY) // 多对一关系,懒加载
    private TeaMaker teaMaker;

    /**
     * 菜单列表:多对多
     */
    @OrderBy
    @ManyToMany
    @JoinTable(
            name = "t_order_item",
            joinColumns = @JoinColumn(name = "item_id"),
            inverseJoinColumns = @JoinColumn(name = "order_id")
    )
    private List<Menu> menuList;

    /**
     * 金额
     */
    @Embedded
    private Amount amount;

    /**
     * 订单状态
     */
    @Enumerated
    private OrderStatus status;

    @Column(updatable = false)
    @CreationTimestamp
    private Date createTime;

    @UpdateTimestamp
    private Date updateTime;
}
(4)TeaMaker
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * TeaMaker:调茶师
 */
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_tea_maker")
public class TeaMaker implements Serializable {

    @Id
    @GeneratedValue
    private Long id; // 主键

    private String name; // 调茶师名字

    /**
     * 一对多:一个调茶师有多个订单
     */
    @OrderBy("id desc ") // 将返回的结果,按照主键排序
    @OneToMany(mappedBy = "teaMaker")
    private List<Order> orderList = new ArrayList<>();

    @CreationTimestamp
    @Column(updatable = false)
    private Date createTime; // 创建时间

    @UpdateTimestamp
    private Date updateTime; // 更新时间
}
10、repository
(1)MenuRepository
import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

/**
 * MenuRepository:菜单数据仓库
 */
public interface MenuRepository extends JpaRepository<Menu,Long> {

    Optional<Menu> findByNameAndSize(String name, Size size);
}
(2)OrderRepository
import com.shw.binarytea.enums.OrderStatus;
import com.shw.binarytea.module.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

/**
 * OrderRepository:订单数据访问层
 */
public interface OrderRepository extends JpaRepository<Order,Long> {

    /**
     * 根据订单状态查询订单
     * @param orderStatus 订单状态
     * @return 订单列表
     */
    List<Order> findByStatusOrderById(OrderStatus orderStatus);

    /**
     * 查询订单列表
     * @param name 调茶师名字
     * @return 订单列表
     */
    List<Order> findByTeaMaker_NameLikeIgnoreCaseOrderByUpdateTimeDescId(String name);
}
(3)MenuRepository
import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

/**
 * MenuRepository:菜单数据仓库
 */
public interface MenuRepository extends JpaRepository<Menu,Long> {

    Optional<Menu> findByNameAndSize(String name, Size size);
}
11、runner
(1)MenuCacheRunner
import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.service.MenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.List;

@Slf4j
@Order(1)
@Component
@RequiredArgsConstructor
public class MenuCacheRunner implements ApplicationRunner {

    private final MenuService menuService;

    @Override
    public void run(ApplicationArguments args) throws Exception {

        log.info("从数据库加载菜单列表,后续应该就在缓存里了");
        List<Menu> menuList = menuService.selectMenuList();
        log.info("共取得{}个条目。", menuList.size());
        menuService.selectMenuByNameAndSize("Java咖啡", Size.MEDIUM)
                .ifPresent(m -> log.info("加载中杯Java咖啡,放入缓存,ID={}", m.getId()));
    }
}
(2)MenuPrinterRunner
import com.shw.binarytea.service.MenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Slf4j
@Order(2)
@Component
@RequiredArgsConstructor
public class MenuPrinterRunner implements ApplicationRunner {

    private final MenuService menuService;

    @Override
    public void run(ApplicationArguments args) throws Exception {

    }
}
12、MenuService
import com.shw.binarytea.enums.Size;
import com.shw.binarytea.module.Menu;
import com.shw.binarytea.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@CacheConfig(cacheNames = "menu")
@RequiredArgsConstructor
public class MenuService {

    private final MenuRepository menuRepository;

    /**
     * 查询所有菜单
     * @return 菜单列表
     */
    @Cacheable
    public List<Menu> selectMenuList(){
        return menuRepository.findAll();
    }

    /**
     * 查询菜单
     * @param name 名称
     * @param size 大小
     * @return 菜单
     */
    @Cacheable(key = "#root.methodName + '-' + #name + '-' + #size")
    public Optional<Menu> selectMenuByNameAndSize(String name, Size size){
        return menuRepository.findByNameAndSize(name,size);
    }
}

8.3.2、替换不同的缓存实现

在上一节的例子中,我们使用了 Spring Framework 提供的默认缓存,它的背后是 Java 的 ConcurrentHashMap。其实 Spring 的缓存抽象能够支持多种不同的后端缓存实现,它有一层 CacheManager 抽象,在其中维护了多个 Cache,我们要缓存的内容就是保存在 Cache 里的,而之前注解的 cacheNames 中指定的就是这里的 Cache

只需选择不同的 CacheManager 实现类,就能替换具体使用的缓存。表 8-9 列出了 Spring Framework 中内置的几个 CacheManager 实现。

表 8-10 内置的 CacheManager 实现

实现类

底层实现

说明

ConcurrentMapCacheManager

ConcurrentHashMap

建议仅用于测试目的

NoOpCacheManager

不做任何缓存操作,可以视为关闭缓存

CompositeCacheManager

用于组合多个不同的 CacheManager,会在其中遍历要找的缓存

EhCacheCacheManager

EhCache

适用于 EhCache

CaffeineCacheManager

Caffeine

适用于 Caffeine

JCacheCacheManager

JCache

适用于遵循 JSR-107 规范的缓存

spring-context 这个 Jar 包中只有几个简单的实现,像 EhCache、Caffeine 和 JCache 这样的支持都在 spring-context-support 里。我们可以手动引入后者,但在 Spring Boot 的帮助下,缓存抽象的引入也变得和其他各种能力一样,有对应的起步依赖:

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
      </dependency>

而 Spring Boot 的自动配置支持的缓存类型就更多了,除了表 8-10 中提到的,还有下面的几种:

(1)Redis

(2)Couchbase

(3)Hazelcast

(4)Infinispan

CacheProperties 提供了一些基本的配置,在配置文件中使用的前缀是 spring.cache,例如:

# 可以指定缓存的类型
spring.cache.type=redis
# 可以限定只用 foo 和 bar 这两个缓存名称,不能动态创建其他的缓存。
spring.cache.cache-names=foo,bar
1、替换为 Caffeine

要将默认的 ConcurrentMapCacheManager 替换为 CaffeineCacheManager,首先需要在 pom.xml 中引入 Caffeine 的依赖,具体的版本交给 Spring Boot 来管理(当然,也可以由我们自己指定):

      <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
      </dependency>

Spring Boot 的自动配置 CaffeineCacheConfiguration 会在下面的条件下生效,创建一个 CaffeineCacheManager

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Caffeine.class, CaffeineCacheManager.class })
@ConditionalOnMissingBean(CacheManager.class)
@Conditional({ CacheCondition.class })
class CaffeineCacheConfiguration {
    
}

在配置文件中可以像这样来设置缓存,具体的 spec 内容可以参考 CaffeineSpec 类:

spring.cache.caffeine.spec=initialCapacity=10,maximumSize=50,expireAfterAccess=60s

8.3.1 节中的例子,只需做如上的调整,就能直接使用 Caffeine 运行起来,在代码层面实现零改动。我们也可以写一个代码示例 8-16 这样的测试用例,看看是否真的自动使用了 Caffeine 做了缓存。由于我们的测试在运行前会先运行 MenuCacheRunner,所以我们只需要在单元测试里做些简单的判断就行了,比如是不是用了 CaffeineCacheManager,缓存里有没有之前存进去的内容等。

代码示例 8-16 针对 Caffine 的 MenuCacheRunnerTest 测试类

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.caffeine.CaffeineCacheManager;

import javax.annotation.Resource;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class MenuCacheRunnerTest {

    @Resource
    private CacheManager cacheManager; // 缓存管理对象

    @Test
    void testCache() {
        Cache cache = cacheManager.getCache("menu");
        assertTrue(cacheManager instanceof CaffeineCacheManager);
        assertTrue(cache instanceof CaffeineCache);
        assertNotNull(cache.get("selectMenuByNameAndSize-Java咖啡-MEDIUM"));
    }
}
2、 替换为 Redis

如果要将缓存抽象的底层缓存实现更换为 Redis,方法和之前类似:

(1)引入 Spring Data Redis 的依赖,

(2)随后 Spring Boot 的自动配置类 RedisCacheConfiguration 会根据条件生效,在上下文中注册一个 RedisCacheManager Bean。

CacheProperties 中针对 Redis 有一些配置,可以参考 CacheProperties.Redis 这个内部类,具体如表 8-11 所示。

表 8-11 spring.cache.redis 配置

配置项

默认值

说明

spring.cache.redis.time-to-live

不过期

过期时间

spring.cache.redis.cache-null-values

true

是否可以缓存空值

spring.cache.redis.key-prefix

无前缀

缓存中键的前缀

spring.cache.redis.use-key-prefix

true

写入缓存时是否添加键的前缀

与前面使用 Caffeine 时一样,先修改 pom.xml,将 Caffeine 的依赖换掉:

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>

我们可以像 8.2 节那样对 Redis 的操作进行设置,调整序列化方式,这里的演示就不做过多的调整了。除了

spring.cache.redis.time-to-live=60s

以外,其他配置先全部使用默认值,连接监听在 localhost:6379 上的 Redis。在运行程序后,可以访问 Redis 缓存,查看数据,看到的结果会与下面的输出类似:

# redis-cli
127.0.0.1:6379> keys *
1) "menu:name:Java\xe5\x92\x96\xe5\x95\xa1"
2) "menu::getByNameAndSize-Java\xe5\x92\x96\xe5\x95\xa1-MEDIUM"
3) "menu::SimpleKey []"
4) "menu::getByNameAndSize-Java\xe5\x92\x96\xe5\x95\xa1-LARGE"
5) "menu:1:idx"
6) "menu:2:idx"
7) "menu"
127.0.0.1:6379> type "menu::getByNameAndSize-Java\xe5\x92\x96\xe5\x95\xa1-MEDIUM"
string
127.0.0.1:6379>

这里的序列化方式其实都是在 RedisCacheConfiguration 中配置的,其中的 keySerializationPairvalueSerializationPair 默认是用 RedisSerializer.string()RedisSerializer.java() 来构造的。要对它们进行调整有两种方式,具体如下所示(如果是要调整序列化方式,建议用第一种):

(1) 在上下文中配置一个我们自己的 RedisCacheConfiguration Bean,在里面完成自定义;

(2) 在上下文中配置一个我们自己的 RedisCacheManagerBuilderCustomizer 实现,对 RedisCacheManager 进行调整。

代码示例 8-16 的测试类也要稍做调整,Cache 应该判断是否为 RedisCache 的实例,具体如代码示例 8-17 所示。

代码示例 8-17 针对 Redis 改写的 MenuCacheRunnerTest 测试类

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.data.redis.cache.RedisCache;

import javax.annotation.Resource;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class MenuCacheRunnerTest {

    @Resource
    private CacheManager cacheManager; // 缓存管理对象

    @Test
    void testCache() {
        Cache cache = cacheManager.getCache("menu");
        assertTrue(cacheManager instanceof CaffeineCacheManager);
        assertTrue(cache instanceof RedisCache);
        assertNotNull(cache.get("selectMenuByNameAndSize-Java咖啡-MEDIUM"));
    }
}

8.4、小结

在这一章里,我们更多地还是在聊一些与数据操作实战相关的话题,例如,在实际生产环境中,数据库连接池除了管理连接,还应该搭配哪些功能——可能是其内置的功能,也可能是与其他组件结合,像密码加密就是个必备的能力。

除了关系型数据库,我们还会在工程中大量地使用非关系型数据库,Redis 就是其中比较常用的。你应该体会到了,在 Spring Boot 和 Spring Data Redis 的帮助下,Redis 的使用既轻松又惬意。最后我们还聊了聊 Spring Framework 提供的缓存抽象,它在一定程度上简化了方法返回值缓存的复杂性,在工作中十分好用。

本章我们聊完了数据库相关的话题,下一章将要开启一个新篇章 —— 怎么实现 Web 相关的需求。

二进制奶茶店项目开发小结

到这一章为止,Spring 中的数据操作部分就结束了,阶段性地小结一下二进制奶茶店目前的情况。在第 5 章小结的基础上,目前 BinaryTea 工程在技术层面又实现了如下功能:

(1)菜单、订单等对象建模,结合 JPA 注解与 Lombok 注解,Model 层的对象可以直接通过 ORM 框架与数据库中的表建立映射关系;

(2)常规关系型数据库操作,Spring Data 的 Repository 可以方便地实现最基本的增删改查操作,定义接口即能实现扩展;

(3)菜单数据缓存,通过 Spring 的缓存抽象,能够透明地更换底层的缓存实现。

在项目进展过程中,我们还交替演示了 JDBC、Hibernate 及 MyBatis 的基本使用方法;在演示 Spring Data Redis 时,也切换了 Lettuce 和 Jedis 客户端;介绍缓存抽象时,将基于 ConcurrentHashMap 的缓存替换为了 Caffeine 和 Redis。在 Spring 家族各种组件的帮助下,这种切换几乎是无缝的,对业务代码的改动微乎其微。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

shw2080

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值