3.4 数据访问
尽管近年来NoSQL日渐成熟,并在众多互联网公司大放异彩,但是传统的关系型数据库仍然是很多核心系统不可动摇的基石,特别是在交易型系统中。笔者相信,关系型数据库仍然会长期存在并有着广泛的应用场景。本章将探讨Spring生态中如何访问关系型数据库。
3.4.1 访问关系型数据库
3.4.1.1 MySQL简介
我们通过图3-6来了解一下截至2021年7月各大数据库的市场份额(数据来源:db-engines网站的统计报表)。
图3-6 2021年各大数据库的市场份额
由图 3-6 可见,在目前的开源数据库中,市场份额最大的是MySQL(排名第一的Oracle是商用数据库)。
MySQL最被外界看重和津津乐道的是其功能强大的存储引擎,MySQL官方支持以下存储引擎:
• InnoDB
• MyISAM
• Memory
• CSV
• Merge
• Archive
• Federated
• Blackhole
• Example
市面上应用最多的引擎是InnoDB,因为它是MySQL体系中唯一支持ACID事务的存储引擎。从5.5.8版本开始,InnoDB成为MySQL默认的存储引擎,它具有如下特性:
• InnoDB存储引擎支持事务,其特点是行锁设计、支持事务、支持非锁定读。
• InnoDB使用多版本并发控制(MVCC)来获得并发性。
• InnoDB实现了SQL的四种隔离级别,默认是REPEATABLE。
• InnoDB使用next-key-locking策略来避免幻读现象的产生。
• InnoDB还提供了插入缓存、二次写、自适应哈希索引、预读等高性能和高可用功能。
虽然InnoDB不支持全文索引,但另外一项特性让它获得了更多的DBA团队的认可,那就是回滚及系统崩溃修复。InnoDB支持自增长的列,这对有主键约束的系统设计来说是不可或缺的功能。
MySQL体系里另外一个相对主流的存储引擎是MyISAM,它也是MySQL早期版本中默认的存储引擎,它在数据查询场景下的性能十分优秀,但是不支持事务,也不支持行锁定,只支持整表锁定。MyISAM在同一个数据库表上的读锁和写锁是互斥的,在并发读写时,如果等待队列中既有读请求又有写请求,那么默认情况下写请求的优先级更高。MyISAM不适合于有大量查询和修改并存的情况,在此种情形下,查询进程会长时间阻塞。
3.4.1.2 安装MySQL
以macOS为例,笔者将展示如何安装MySQL。
第一步是从MySQL官网下载所需安装包,网站会要求用户提供Oracle官网的账号和密码,请提前准备。MySQL的下载界面如图3-7所示(不建议读者从非MySQL官网以外的渠道下载安装包)。
图3-7 MySQL下载界面
(1)请读者根据当前使用的操作系统下载相应版本的安装包,笔者此处选择macOS 10.15(x86,64-bit)、DMG Archive。下载成功之后,下载目录下会有一个类似mysql-8.0.21-macos10.15-x86_64.dmg(根据下载的版本和操作系统,此文件名有所不同)的文件名。
(2)双击下载的文件,解压出原文件mysql-8.0.21-macos10.15-x86_64.pkg,此为最终安装文件。
(3)双击上述pkg文件,出现如图3-8所示的界面,单击Continue按钮,进入License界面。
(4)单击Continue按钮,会弹出安装许可页面,如图3-9所示,此时单击Agree按钮。
(5)下面选择安装路径,如无特殊原因直接使用默认路径,如图3-10所示,单击Install按钮。
图3-8 安装界面起始页
图3-9 安装许可页面
图3-10 安装路径选择
(6)单击Install按钮,会弹出安装的权限对话框,如图3-11所示,按照要求输入admin账号和密码。
图3-11 MySQL密码设定
(7)单击Install Software按钮,将出现选择安装组件界面,如图3-12所示(不同版本可能有差异),MySQL Server是必选项。
图3-12 选择安装组件界面
(8)随后的配置界面如图3-13所示,在此选择加密方式。
图3-13 加密方式选择
MySQL 8默认的加密方式是SHA256,但如果用户因为某些原因需要使用早前的加密方式mysql_native_password,则只能在此界面修改。然后单击Next按钮,设置管理员账户(root)的密码,不管是测试用途还是生产用途都建议使用强密码(包含数字、大小写字母和特殊字符,且至少8位以上),输入密码后单击Finish按钮,如图3-14所示。如果勾选了Start MySQL Server once the installation is complete,则在安装完毕之后,MySQL会自动运行,否则需要人工启动MySQL。
图3-14 输入MySQL密码
安装完毕之后,如果一切顺利将会呈现安装成功界面,如图3-15所示,单击Close按钮,结束整个安装流程。
图3-15 安装成功界面
在默认安装方式下,MySQL会被安装到/usr/local目录下(如macOS)。查看/usr/local目录,会有两个与MySQL相关的目录:mysql目录和mysql-8.0.21-macos10.15-x86_64目录。mysql-8.0.21-macos10.15-x86_64目录之下存在众多子目录。表3-1描述了MySQL子目录的用途。
表3-1 MySQL子目录的用途
安装完成之后,我们接下来简单体验一下MySQL的功能。从命令行界面进入bin目录,运行以下命令:
在上面这条命令里,mysql是MySQL的客户端命令工具,-h是用于指定要连接的MySQL服务器地址,在本例中就是localhost,-P(大写)用于指定服务端口号,默认的MySQL端口号为3306,-u就是用户名,此处我们使用root作为用户名,-p(小写)用于输入密码(可以选择直接输入密码,也可以只写-p,然后按回车键,等待密码提示,再输入密码)。
由于localhost和3306都是默认参数,所以前例也可以简化为如下命令:
MySQL安装完成,应用就可以将其作为关系型数据使用。更多的MySQL功能请读者自行探索。
3.4.1.3 ORM
众所周知,Java是一门面向对象的语言(OOP),传统的关系型数据库都以SQL为主要操作语言(由于本书的关注点是Spring生态,所以SQL的相关基础知识请读者自行学习)。SQL是一种声明式编程语言,这二者似乎不存在直接使用的可能性,那么Java应用将如何访问数据库并操作数据呢?
下面,我们从JDBC开始了解Java访问数据库的知识结构体系,JDBC是数据库框架(如Hibernate和MyBatis)与底层物理数据库之前的通信桥梁。
1. JDBC
JDBC即Java Database Connective,它是Java的一组语言规范,利用API连接数据库并执行SQL。JDBC使用JDBC Driver进行数据库通信,各大数据库厂商都提供了可以适配自家数据库产品的JDBC Driver(驱动)程序,这种驱动程序必须符合Java语言的相关规范。开发者只需通过标准JDBC接口就可以操作不同的数据库,JDBC的作用如图3-16所示。
图3-16 JDBC的作用
在创作此书时,最新的JDBC版本是4.3,其主要功能是使用X/Open SQL的接口来进行API设计,核心类位于java.sql和javax.sql之下,主要包含以下接口:
• Driver interface
• Connection interface
• Statement interface
• PreparedStatement interface
• CallableStatement interface
• ResultSet interface
• ResultSetMetaData interface
• DatabaseMetaData interface
• RowSet interface
JDBC Driver是JDBC接口的实现,常用的JDBC Driver主要有以下几类。
(1)JDBC-ODBC Bridge Driver
在JDBC-ODBC Bridge Driver(JDBC-ODBC桥接驱动)这种驱动模式下,其底层使用ODBC实现,工作原理如图3-17所示,虽然在Java的早期版本中,这种Driver应用较为广泛,但是在Java 8之后,这种Bridge Driver已经被删除,如果没有特殊原因,尽量不要使用该Driver。
图3-17 JDBC-ODBC Bridge Driver工作原理
(2)Native Driver
Native Driver使用数据库开发商自身提供的类库(即Vendor Database Library),它的主要作用是将JDBC方法调用转化为数据库的本地API调用,Native Driver并非纯Java程序,而是混合了Java和操作系统相关的Native Driver,其工作原理如图3-18所示。
图3-18 Native Driver工作原理
(3)Network Protocol Driver
Network Protocol Driver(网络协议驱动)利用中间件(一般是应用服务器,比如Tomcat、JBoss)将JDBC的方法调用转化为数据库协议,其工作原理如图3-19所示。这种驱动由于引入了更多的依赖,而且是和中间件强绑定的,所以不建议读者选择这种驱动。
图3-19 Network Protocol Driver工作原理
(4)Thin Driver
Thin Driver(瘦客户驱动)直接将JDBC的方法调用转化为对应的数据库专有协议,如图3-20所示,它可以保持自己的代码相对精简,在性能方面比较突出,而且是使用纯Java代码编写的,也是目前最主流的一类JDBC驱动。
图3-20 Thin Driver工作原理
在了解了JDBC相关的基础知识之后,那么在项目中应该如何使用JDBC访问数据库呢?整个过程可以分为以下五个步骤。
(1)注册驱动程序
驱动程序的注册过程,代码如下:
(2)建立连接
要建立数据库连接,首先需要配置JDBC connection URL,它的标准格式如下:
protocol:使用何种协议,此处选择标准的MySQL协议 jdbc:mysql。
hosts:数据库的地址和端口,测试服务器就是localhost:3306。
database:指数据库的实例名,此处为testdb。
综上,建立连接的代码如下:
root和password分别代表数据库的用户名和密码。
(3)创建声明
声明(Statement)的创建代码如下:
Statement是接下来执行SQL的对象。
(4)执行数据库查询
数据库查询代码示例如下:
(5)关闭连接
不管是否使用连接池,都要关闭connection,代码如下:
以上五个步骤虽然简单,却是一切高级ORM框架的基础,无论ORM框架中的功能如何复杂,最终在与数据库通信时都会被转化成以上(或近似)的五个步骤。ORM框架的作用就是让开发者无需直接使用JDBC,而是以更加面向对象的方式来操作数据库。通过这种方式,开发者可以更专注于业务逻辑。
2. Hibernate
对于JDBC开发方式,其步骤虽然简单,但开发人员必须在程序中编写大量的SQL语句。从Java开发者的角度审阅JDBC代码,这些代码完全不符合面向对象的思想。理论上,所有的业务逻辑都应该通过操作Java对象来实现,而非通过操作SQL语句来实现。而且,如果使用JDBC解决特别复杂的业务需求,将会产生以下后果:
(1)开发者的精力都将用于编写各种SQL脚本,而SQL语法不符合面向对象的设计原则。
(2)Java程序员的SQL水平各不相同,要维护复杂的SQL程序,将会引入额外的复杂度,如果能以操作对象的方式来操作数据库,则整个系统将更加符合面向对象的标准。
为了解决上述问题,ORM(Object Relationship Mapping,对象关系映射)的概念应运而生。ORM的主要功能是管理映射(此处的映射是指对象和数据库表之间的映射),即将Java对象上的操作反映到数据库表中。
ORM框架包括但不限于以下功能:
• 一组API支持持久化对象的CRUD(增删改查)。
• 一种语法或API可以操作对象及其属性来执行Query。
• 一种能定义对象和表之间的映射的机制。
• 事务控制。
在Java生态里ORM框架的先驱者是Gavin King,他在2001年创建了Hibernate ORM,Hibernate ORM主要由以下组件组成:
(1)实体(Entities):Java类(普通Java对象)多为POJO类,是Hibernate中映射到关系型数据库系统的表。
(2)对象-关系元数据(Object-Relational metadata):实体和关系型数据库的映射信息,可以通过注解(Java 1.5开始支持)来实现,或者使用传统的基于XML的配置文件。这些配置信息用于将Java对象的方法调用转化为HQL或SQL。
(3)Hibernate查询语言(HQL):使用Hibernate时,发送到数据库的查询,不必是原开发人员可以使用Hibernate专有的查询语言进行CRUD操作。HQL语句会被翻译成数据库方言(此处方言指某数据库专用的语法)。因此,HQL的语法是独立于特定数据库开发商的。Hibernate ORM在系统架构中的主要作用如图3-21所示。
图3-21 Hibernate ORM在系统架构中的主要作用
Hibernate ORM主要通过两种文件定义对象和数据的关系,mapping file和config file。
Hibernate mapping file用于定义Java类和数据库表的映射关系,现在基本被注释替代,在本书中(配合lombok的注释使用)的相关代码如下:
Hibernate config file用于描述应用如何连接数据库。在非Spring Boot应用中,config file是通过XML文件定义的,但是在Spring Boot项目中,相关配置会简化很多,详情请参见3.4.1.5节。
熟悉了基础概念和配置,再探究Hibernate的架构,有利于更深入地了解整个框架的运行原理,Hibernate架构如图3-22所示,其中四个主要组件说明如下。
(1)Session
Session为应用程序提供了访问数据库的接口,本质上它是JDBC connection的封装,并提供了对应的CRUD接口。同时,它还提供Transaction、Query和Criteria的创建方法,它也是一级缓存的存放地。
图3-22 Hibernate架构图
(2)Session Factory
顾名思义,就是创建Session的工厂类(factory模式的实现),同时它也是二级缓存的存放地。
(3)Transaction
主要用于提供事务相关的操作接口。
(4)Transaction Factory
Transaction的工厂类。
数据库表之间的关系是ORM框架中最重要的元素之一,在ORM框架中它被表达为对象与对象的关系,在Hibernate中支持以下关系类型:
(1)一对一
在面向对象的世界里,如果关联的双方有且只有一个实例参与到关联中,那么这种关系就是一对一的关系,如图3-23所示。
图3-23 一对一关系
在现实世界中有很多一对一的关系,比如一所公寓只能有一个地址、一张SIM卡只有一个电话号码,等等。在Hibernate中,可以用@OneToOne的注解来定义这种关系,具体实现代码如下:
(2)一对多
在面向对象的世界里,如果参与管理关系的一方只有一个实例,但是另外一方却有很多个实例,那么这就是一对多关系,如图3-24所示。
图3-24 一对多关系
在现实世界中一个公司可以有很多员工,一个人可以拥有很多手机,都可以用@OneToMany的注解来表达,具体实现代码如下:
(3)多对多
在面向对象的世界里如果关联关系的双方都可以有很多个实例,那么这种情况就是多对多,如图3-25所示。
图3-25 多对多关系
在现实世界中,一个人可以加入很多组,一个组也可以有很多人,这就是典型的多对多关系。在数据库设计层面通常使用一张中间表(关系表)来表达多对多的关系,具体实现代码如下:
使用Hibernate访问数据库和操作数据对象的方式与使用JDBC大同小异,具体代码如下:
其基本步骤与直接使用JDBC访问数据代码非常相似,因为二者的目的及秉承的思想都是一致的,即“为开发者提供更方便的数据库访问层”。
在实际项目中,很少直接使用Hibernate操作数据库,大都采用与Spring集成的方式,以使代码整洁的同时降低开发复杂度。笔者以Spring 5和Hibernate 5集成为例,演示二者是如何进行集成的。
首先引入相关依赖,具体代码如下:
然后,将Hibernate与Spring进行集成,具体代码如下:
最后,在需要使用Hibernate的场景下,采用下列方式操作数据库:
3.4.1.4 事务管理
现代的ORM框架对事务的支持是标准功能,在各种业务系统中,事务的控制更是必不可少的,因为通过事务开发者可以较为容易地保证数据的一致性,这一节主要介绍事务相关知识。
1. 什么是事务
Java应用程序的事务是指一种保证单一数据库操作数据一致性的机制,它可以抽象地定义为一组连续的数据库读写操作,要么都成功要么都失败,不能部分成功或部分失败,如图3-26所示。
图3-26 事务的含义
在关系型数据库中,每个SQL语句都必须在一个事务的范围内执行。如果未对事务边界进行定义,那么数据库就会为每次数据库操作自行加上一个事务,事务开始于SQL执行之前,结束于SQL执行完毕之后,数据库事务可以被提交或者回滚。
要完整地了解事务的相关知识,首先要了解事务最重要的特征ACID。
• Atomicity,原子性
原子性是指事务将多个操作当作一个完整的工作单元,只有在该单元内的所有操作全部成功的时候,事务才会成功,单无内的任何一个操作失败都将导致整体事务失败,该单元的所有操作将全部回滚。
• Consistency,一致性
一致性是指在每一次的事务提交中,数据库的所有约束都要被强制遵循且不可违背,这些约束包括各种唯一主键约束和数据类型等。
• Isolation,隔离性
隔离性用于保证并发进行的多个事务,其最后执行完毕的结果与顺序执行相同。常见的一种做法就是控制并行事务之间的数据互相可见性。
• Durability,持久性
持久性要求一个成功的事务必须永久记录数据的改变,并且这些变化要被记录于一个持久化的事务日志中,如果系统发生异常崩溃了,可以根据持久化日志中记录的操作日志进行恢复。
2. 事务的隔离级别
事务的一大特征是具有隔离性,以此来保证事务在并发时的数据一致性。根据标准SQL规范,将并发事务的并发控制划分为不同级别,并称之为事务隔离级别。标准的隔离级别有如下四级:
• READ_UNCOMMITTED。
• READ_COMMITTED。
• REPEATABLE_READ。
• SERIALIZABLE。
事务的隔离级别是为了控制事务与事务之间数据的可见性和一致性,对并行事务的一种严格定义。在讲解事务隔离级别之前,我们需要先了解数据一致性相关的三个定义。
(1)脏读
如图3-27所示,脏读(Dirty Read)就是当前事务可以读取其他正在进行的事务中未提交(Uncommitted)的数据变化,当多个事务并行执行时,需要通过数据库读写锁来控制多个事务对同一个数据库资源的并发访问。
图3-27 脏读
(2)不可重复读
如图3-28所示,不可重复读(Non-Repeatable Read)就是对同一数据进行连续读取,但每次读取的结果却有所差异。这是因为并发执行的事务修改了另一个事务读取的数据。这种现象并不是系统预期的行为,因为不同时刻读取的数据不一致,总会有一次读取的数据是不正确的。我们可以通过添加“读锁”的方式防止这种情况的出现。
图3-28 不可重复读
(3)幻读
当一个事务从数据库中读取了一批数据,在没有完成操作前,有一个新的事务插入了新的数据,而且新数据是满足第一个事务的读取条件的,但第一次读取的数据却不会包含第二个事务插入的新数据,我们将这种情况称为幻读(Phantom Read),如图3-29所示。
图3-29 幻读
不同隔离级别对并发控制的规范如表3-2所示。
表3-2 不同隔离级别对并发控制的规范
不同数据库的默认隔离等级如表3-3所示。
表3-3 不同数据库的默认隔离等级
在开发系统时,为了保证数据的一致性,是将隔离等级设置得越高越好吗?答案当然是否定的。事实上,在大多数系统中,数据库的处理能力和系统本身的并发情况都会影响隔离等级的设定。
在设计系统时,如果业务场景具备高流量和高并发的特征,那么就需要牺牲一定的一致性,隔离等级相对就会低一些;如果系统对数据一致性要求特别严格,那么将数据库的事务隔离等级设置到最高,则整个系统的处理速度肯定会下降,因为在高隔离等级下的数据库操作都是串行执行的。
JDBC默认的隔离等级是READ_COMMITTED,请勿与数据库的默认等级混淆。
3. Transactional
从3.1.4.3节的Hibernate实战可以看到,在配置TransactionManager和EnableTransactionManagement之后,开发者可以在Java方法或者类前使用@Transactional注解来进行事务控制,不再需要编写复杂的申明式事务代码。本章将探索Transactional的工作原理,并演示在项目中如何使用@Transactional注解。
在进一步探索Transactional之前,需要先理解两个相关的概念,即持久化上下文(Persistence Context)和数据库事务(Database Transaction)。
Transactional只是用于定义单一数据库的事务范围。而每个数据库事务都真实发生在一个持久化上下文的作用域内。在Spring的体系里,持久化上下文都是被EntityManager所管理的。它们的关系如图3-30所示。
图3-30 Transaction和EntityManager的关系
由图3-20可知,数据库的每张表(Table)都被映射成为一个Entity,Entity被称为持久化对象(Persistent Object),它们都存活于一个持久化上下文之中,所有的持久化对象都从持久化上下文中被创建、修改和读取。持久化上下文还要跟踪每个持久化对象的状态,并且保证这些对象的变化最终会被写入对应的数据库表中。
在Hibernate的实现中,持久化上下文通常是指Session,Session的生命周期是与一个事务(Transaction)绑定的,即Session被创建就表示绑定的Transaction的开启,Session被销毁(或关闭)就表示绑定的Transaction的结束,其关系如图3-31所示。
图3-31 Session和Transaction的关系
基于以上分析可知,一个EntityManager可以管理多个持久化上下文,即EntityManager可以同时管理多个数据库事务。但推荐的做法是,一个EntityManager只管理一个数据库的事务。EntityManager也分为被容器管理的(Container Managed)EntityManager和被应用管理的(Application Managed)EntityManager。以应用管理为例,事务都是与线程绑定的,它们的关系如图3-32所示。
图3-32 线程与事务的关系
下面,给出Spring容器中的一个普通的调用链示例,如图3-33所示。
图3-33 调用链示例
图3-33清晰地指明了EntityManager和TransactionManager各自的职责,EntityManager用于执行entity的相关操作。在entity操作成功后,再由一种事务管理机制(如interceptor)来控制事务的提交或回滚。因此,Transactional要在应用中生效,以下三者缺一不可:
(1)Transactional切面(aspect)
Transactional切面是一个around切面(关于around切面的相关知识请自行学习),它在被调用方法的前后都会执行。在Hibernate实现中,该切面就是TransactionInterceptor类。Transactional切面主要有两大作用。第一,在执行方法调用之前,它需要判断是否可以加入一个已经存在的事务,还是要创建一个新事务,但是这个判断并非由Transactional切面自身来决定的,它将这个决策权委派给了Transaction Manager,最终决策也与事务的传播性(本节后续将详细介绍)相关;第二,在调用之后,切面需要判断当前事务是要提交还是回滚。
(2)Transaction Manager
Transaction Manager的职责是做两个判断:
• 是否需要创建一个新的EntityManager。
• 是否创建一个新的数据库事务。
这些判断都是在Transactional 切面调用之前做出的,主要受以下两个因素影响:
• 是否已经存在一个进行中的事务。
• Transactional方法的事务传播等级。
如果需要创建一个新的事务,将会顺序发生以下步骤:
(a)创建一个新的EntityManager。
(b)绑定EntityManager到当前线程。
(c)从数据库连接池中获得一个连接。
(d)绑定该连接到当前线程。
此外EntityManager和数据库连接都存放于当前线程的ThreadLocal中,也被称为session per thread。二者在事务持续运行期间都存活于绑定线程中,由Transaction Manager决定何时清除它们。
(3)EntityManager
当业务代码操作EntityManager时,并不是直接操作EntityManager,而是调用了它的代理(proxy),代理再从当前线程中获取EntityManager(由TransactionManager放入的),完成相应操作。
4. 事务的传播等级
TransactionManager需要根据方法的事务传播等级来决定是否需要创建新的事务,那么什么是事务的传播等级,它的作用是什么呢?
在Spring中,开发者可以通过事务的传播等级来控制事务,其所定义的传播等级都在org.springframework.transaction.annotation.Propagation内。简而言之,传播等级就是帮助TransactionManager决定是否需要创建一个新事务的手段。传播等级总共有以下七种:
(1)Propagation.REQUIRED
Propagation.REQUIRED是@Transactional注解的默认传播等级,它的含义如下:
• 如果当前线程中没有正在运行的物理事务,则Spring容器将会创建一个新的事务。
• 如果当前线程中已经存在运行的物理事务,则当前方法参与到该事务中,不会再创建新的事务。
• 所有被REQUIRED修饰的方法,都会界定一个逻辑事务,这些逻辑事务都会参与到同一个物理事务中。
• 每一个逻辑事务都有其自身的作用范围,但是在REQUIRED的传播等级下,所有的作用范围都会映射到一个相同的物理事务上。
所有的逻辑事务都映射到一个相同的物理事务上,当其中某一个逻辑事务发生回滚时,所有参与到该物理事务的逻辑事务都会被回滚,具体过程如图3-34所示。
图3-34 Propagation.REQUIRED工作过程
(2)Propagation.REQUIRES_NEW
Propagation.REQUIRES_NEW告知Spring容器总是创建一个新的物理事务。通过这种方式创建出来的事务可以包含独立于外部事物的属性,Propagation.REQUIRES_NEW的工作流程如图3-35所示。
图3-35 Propagation.REQUIRES_NEW的工作流程
在处理此种传播等级时,需要特别注意数据一致性。由于每个物理事务都需要一个单独数据库连接,当我们执行新创建出的内部物理事务(Inner Transaction)时,外层的物理事务(Outer Transaction)将会被挂起,但是其所关联的数据库连接仍然保持开放。在内部物理事务提交之后,外层的物理事务才会恢复执行进而提交或回滚。而如果内部物理事务回滚之后,外层的物理事务并不一定会受其影响。也就是说在这种隔离等级下,它们各自管理自己的事务,并不能保证它们的状态最终是一致的。
(3)Propagation.NESTED
Propagation.NESTED的行为和Propagation.REQUIRED很相似,唯一不同的是inner逻辑事务可以单独地回滚,而不受outer逻辑事务的控制,其工作流程如图3-36所示。
(4)Propagation.MANDATORY
Propagation.MANDATORY必须存在于一个正在运行的物理事务中,否则就会抛出异常,除此之外,它的功能与Propagation.REQUIRED一致,图3-37描述了它的工作流程。
(5)Propagation.NEVER
Propagation.NEVER与Propagation.MANDATORY恰好相反,它需要当前线程不存在任何物理事务,否则就会抛出异常。尽管如此,在被NERVER修饰的方法体中却可以单独运行物理事务,Propagation.NEVER的工作流程如图3-38所示。
图3-36 Propagation.NESTED的工作流程
图3-37 Propagation.MANDATORY的工作流程
图3-38 Propagation.NEVER的工作流程
(6)Propagation.NOT_SUPPORTED
Propagation.NOT_SUPPORTED表明当前方法不需要任何事务。因此如果执行到被NOT_SUPPORTED修饰的方法时,如果当前线程有物理事务,则该事务将被挂起,直到方法执行完毕,事务恢复执行,Propagation.NOT_SUPPORTED的工作流程如图3-39所示。
图3-39 Propagation.NOT_SUPPORTED的工作流程
在此种传播等级下,尽管事务被挂起,但是其相关的数据库连接却是一直活跃的。用户访问量的增加会导致数据库连接被全部占用。因此,不建议读者在高并发场景中使用Propagation.NOT_SUPPORTED。
(7)Propagation.SUPPORTS
Propagation.SUPPORTS检查当前是否有活跃的物理事务,如果有,那么它的逻辑事务将会在物理事务中执行,否则该方法就以无事务的方式运行,Propagation.SUPPORTS的工作流程如图3-40所示。
图3-40 Propagation.SUPPORTS的工作流程
3.4.1.5 Spring Boot访问数据库
通过前面内容的铺垫,数据库相关基础知识已经准备完毕。本节将演示在Spring Boot中如何进行数据库操作。
1. 创建测试数据库及表
先按照前述数据库登录方式(参见3.4.1.2节)以root账号登录本地MySQL,输入MySQL的数据库创建命令如下:
如果创建成功,将会出现如图3-41所示的建表成功界面。
图3-41 建表成功界面
如果不确定结果,那么输入数据库查看命令:
如果broadview_coupon_db数据库实例创建成功,则会出现如图3-42所示的数据库展示界面。
图3-42 数据库展示界面
数据库实例创建成功之后,再创建所需要的表(table)。为了更好地控制用户权限,需要事先为应用创建单独的用户,而不再用root权限访问应用数据库。
创建数据库用户命令如下:
• username:创建的用户名。
• host:用于限制用户登录当前数据库的机器,如果只允许本地登录(用户只能从数据库所在机器登录)则可以选用localhost;如果指定IP地址(例如192.168.0.1),则该用户只能通过拥有此IP地址的机器才可以登录;如果允许该用户从任意机器(即远程或者本地皆可)登录,则使用通配符%。如果不加host参数,则默认值是%。
• password:用户的登录密码,密码可以为空,如果为空,则该用户可以不需要输入密码即可登录服务器。
为broadview数据库创建用户,命令如下:
请使用如下命令确认账号是否创建成功:
若创建成功,则显示内容如图3-43所示。
图3-43 MySQL系统用户创建成功
上述演示操作都是以root身份完成的,但是实际应用中我们需要为当前应用创建独立的数据库用户名。我们对broadviewapp用户进行授权(此时是使用root用户来完成授权操作),命令如下:
利用下面的命令查看用户的权限:
运行结果如图3-44所示。
图3-44 用户的权限
由图3-44可知,除显式授权外,还有一个USAGE授权。此处的USAGE授权并不是额外的权限,它的含义是空授权(No Privileges)。因此,第一行命令输出内容的真正含义是在*.*层面(即全局层面),该用户没有任何权限。第二行命令输出的内容与我们运行的授权命令一致,表明该用户已经成功获取broadview_coupon_db的管理员权限。
现在输入exit命令退出root账号,再以broadviewapp用户名登录MySQL,并输入以下命令:
此时命令行上显示的数据库比root用户要少,这是因为当前用户不再是root,所以只拥有了被赋予的有限权限。
要访问broadview_coupon_db,先使用以下命令切换database:
开始创建服务所需要的表,其详细命令如下(请读者从GitHub获取完整的建表命令):
再使用show tables和desc coupon_template命令查看创建是否成功。如果一切正常,则命令行会出现如图3-45所示的表结构。
图3-45 表结构
再以同样的方式创建另外一张表,代码如下:
使用show tables命令查看建表的情况,如图3-46所示。
图3-46 建表的情况
至此,所有的数据库操作就已完成。下面将演示在Spring Boot项目中如何访问数据库。
2. JDBCTemplate访问数据库
首先,引入相应依赖,代码如下:
在引入JDBC Starter之后,Spring Boot项目就具备了以下功能:
(1)自动引入tomcat-jdbc-{version}.jar,用于配置DataSource Bean。
(2)如果没有以任何形式显式地定义DataSource Bean,但是却有以下内存数据库的驱动H2、HSQL或Derby,则Spring Boot会自动注册对应的数据库DataSource Bean到Spring容器中。
(3)如果未显式定义以下Bean,Spring Boot也会自动将它们注册到容器中:
• PlatformTransactionManager(DataSourceTransactionManager)。
• JdbcTemplate。
• NamedParameterJdbcTemplate。
(4)如果在classpath根路径下有schema.sql和data.sql文件,Spring Boot会自动将对应的数据库初始化。
其次,配置DataSource,以coupon-template-service为例,在application.xml文件中定义代码如下:
而对于POJO类,无需映射定义,可以直接修改源代码如下:
因为没有定义映射,所以需要定义RowMapper,定义代码如下:
最后,通过注入Template来访问数据库,具体实现代码如下:
虽然JdbcTemplate可以操作数据库,但是在工业级编程中,业界已经很少直接使用它,除非是不得已而为之,新项目都建议使用下文介绍的JPA来访问数据库。
3. JPA访问数据库
JPA是Java Persistence API的简称,是针对POJO类的ORM Java规范,其工作原理如图3-47所示。
图3-47 JPA工作原理
JPA规范的主要职责如表3-4所示。
表3-4 JPA规范的主要职责
JPA架构如图3-48所示。
图3-48 JPA架构
通过JPA规范定义的entity生命周期如图3-49所示。
图3-49 JPA Entity生命周期
Spring Data JPA和JPA及Hibernate之间是紧密关联的。JPA是Java ORM的规范API;Hibernate则实现了这种规范;而Spring Data JPA是通用JPA规范的一种补充实现,它不仅提供了JPA的实现,同时还基于Spring的特性提供了额外的辅助功能,其架构如图3-50所示。
图3-50 Spring Data JPA架构
Spring Data JPA在原始的JPA规范之外提供了以下功能:
• Spring Data JPA并没有完整地实现JPA,它的底层实现可以使用不同的服务提供者,包括但不限于Hibernate、Eclipse Link和Open JPA等。这些服务提供者才是真正的JPA规范实现者,Spring Data JPA只是提供了一种上层封装,为开发者提供了顶层接口规范。
• 支持repositories模式(具体定义请自行参考DDD相关书籍)。
• 提供了audit功能。
• 支持Quesydsl的预言(Predicates)。
• 分页、排序、动态查询语句执行。
• 支持@Query注解。
• 支持XML映射定义。
Spring Data JPA提供了一种约定大于配置的repositories实现,开发者可以减少大部分简单而又重复的CRUD操作。在传统的开发方式中,开发者需要针对每个entity撰写不同的CRUD操作;而Spring Data JPA提供的repositories机制为所有的entity提供了通用解决方案,开发者只需要继承Repository<T,ID>、CrudRepository<T,ID>或者JpaRepository <T,ID>接口即可。此处,T是entity的泛型类,ID是该entity的主键类型。
Repository的工作原理如图3-51所示(以Hibernate实现为例)。
图3-51 Repository的工作原理
下面以CrudRepository为例,检验它所提供的所有功能,示例代码如下:
Spring Data JPA的API提供了Repository<T,ID>、CrudRepository<T,ID>和JpaRepository<T,ID>供开发者使用,但它们有何异同,各自适用的场景又是什么?下面我们分别介绍这三类接口提供的功能。
(1)CrudRepository
它继承了Repository接口,并实现了一组CRUD相关的方法如下
• save(…):提供了简单的entity保存功能,此外也有接收一组entity进行批量操作的接口。
• findOne(…):根据输入的主键查找对应的数据。
• findAll():查找出对应表的所有数据。
• count():统计数据库中数据的数量。
• delete(…):删除传入的entity(根据参数生成SQL条件)。
• exists(…):验证对应的entity在数据库中是否存在。
从以上API可以看出,通过CrudRepository提供的CRUD方法,可以满足大部分简单的增删改查功能。
(2)PagingAndSortingRepository
其接口定义如下
从方法签名可以推知,它在CRUD之外提供了分页和排序功能,此处的Pageable接口是所有分页相关信息的一个抽象,通过该接口可以得到与分页相关的所有信息,例如:
• page size每页的数量。
• page number当前的页码。
• sorting排序方式。
sort对象需要提供排序方式(升序或降序)和要排序的字段。PagingAndSortingRepository通过以上方式实现了分页和排序。
(3)JpaRepository
JpaRepository的实现代码如下:
JpaRepository在CRUD之外提供了一些额外的功能:
• flush()将所有没有与数据库同步的entity刷新到数据库中。
• saveAndFlush()保存并立即flush。
• deleteInBatch()批量删除。
Repository类图如图3-52所示。
图3-52 Repository类图
虽然引入Repository可以解决大部分应用的基础需求,但是在实际开发中,业务需求是远远多于通用操作的,Spring Data JPA是如何解决这些问题的?
约定俗成的派生查询方法是满足这类需求的杀手锏,即开发者可以在继承某一种Repository之后,再自定义一些方法,这些方法名都是以find…By、read…By、query…By、count…By、get…By开头的,前提是这些方法签名要符合特定的命名规则。当其他类调用这些方法时,Spring Data会根据方法名自动生成相应的JPQL查询语句。这种方式可以解决大部分简单查询需求。但是如果查询参数过多,就会导致方法签名过长,这种方式就不再合适,此时应当使用Query功能。
下面以一个代码片段来演示这种功能。例如CouponTemplateService要实现一个按照模板名查找优惠券模板的功能,最终这个功能会由CouponTemplateRepository来实现,那么应该如何通过派生查询方法来实现呢?具体实现代码如下:
通过CouponTemplateRepository的新方法签名可以推测该方法的用途,方法名以findBy开头,再加上一个需要查找的entity属性(此处为name),并以输入的参数作为WHERE条件(方法中的参数name),此方法的参数名要与entity定义的属性一致。
上面的例子演示了使用一个参数来定义派生查询的方法,如果超过一个参数又该如何定义呢?假如需要按照模板是否过期、是否有效这两个条件来筛选模板,可以参考以下代码:
此方法以findAll开头,后面有两个属性Available和Expired。这两个属性以And连接,表示SQL中的and条件。如果需要实现“或”查询,只需将方法名中的And替换为Or即可。
在默认情况下,派生查询方法中定义的属性,在生成SQL的时候需要与输入参数完美匹配,但Spring Data JPA也提供了下列关键字来进行辅助定义。
• Like 检查该属性是否符合SQL的like输入参数。
• Containing 检查该属性是否包含了参数值。
• IgnoreCase 在做值比较的时候忽略大小写,可以和其他关键字联合使用。比如findByNameContainingIgnoreCase。
• Between 检查属性值是否在一个区间范围内。
• LessThan/GreaterThan 比较属性值和参数的大小。
派生查询方法也支持排序、分页和返回固定数量的查询结果,此处不再一一详述,请读者自行参照相关文档研究。
Spring Data JPA的Repository类并没有实现本节中列举的数据库查询语句,它是将方法调用委派给了真正的实现者,Repository的工作原理如图3-53所示。
图3-53 Repository的工作原理
对于较复杂的查询场景,笔者不建议使用派生查询方法,因为过长的方法名会降低代码的可读性和可维护性,Spring Data JPA提供了更轻量级的@Query解决方案,Query可以支持JPQL和原生SQL。使用Query注解,开发者可以随意定义方法名,无需遵循派生方法的命名规范,只需要在Repository类中定义方法,并在方法上添加Query注解,再提供相应的JPQL或者原生SQL即可。
笔者建议使用JPQL定义Query查询,这种方式让代码可以更贴近系统的领域模型,此外,因为没有使用原生SQL,也可以规避一些数据库的方言问题,为系统提供更多的可移植性。但是JPQL只实现了标准SQL的子集,所以当编写某些复杂查询时,还是需要使用原生SQL的。
使用Query改写CouponTemplateRepository的示例代码如下:
在上面的代码中,对Query注解修饰的方法来说,尽管我们并没有添加SELECT语句,但是Spring Data JPA会自动解析CouponTemplate类中定义的数据库字段,并将这些字段添加到自动生成的可执行SQL语句中。
Query注解也支持排序功能,我们可以通过两种方式实现排序。一种是利用JPQL的语法,在定义注解时添加Sort关键字,另一种是在方法签名中添加Sort对象。
如果需要为查询语句添加分页功能,我们可以通过在方法中添加Pageable对象来实现。
Query也支持通过原生SQL执行查询语句,只需要将JPQL语句替换为原生SQL语句即可,具体代码如下:
通过上述代码不难看出,我们使用JPQL和原生SQL可以达到相同的效果。读者们一定在想,方法参数与最终产生的SQL是如何关联起来的呢?Spring Data JPA运用了一种参数绑定的技术来实现这种关联性。无论是JPQL还是SQL,先在代码中使用占位符,在运行期将占位符与查询参数绑定,然后把实际参数注入要执行的SQL中生成执行语句,最终这条执行语句被数据库执行。需要注意的是,参数绑定都是在WHERE子句中实现的。在项目中使用参数绑定有以下几个好处:
• 防止SQL注入攻击。
• 避免人工拼接参数导致SQL语法错误。
• JPA的底层实现(如Hibernate)和数据库引擎可以对语句进行查询优化。
Spring Data JPA对JPQL和原生SQL提供了两种不同的参数绑定方式,分别是“位置参数”或“参数命名”。
位置参数绑定,指的是JPQL或原生SQL中的参数引用所使用的对应参数在该方法签名上的位置,以问号“?”加位置的形式来绑定参数。如“?1”表示该参数应用的是方法签名中的第一个参数,“?2”表示该参数应用的是方法签名中的第二个参数,以此类推。
参数命名绑定,指的是JPQL或者原生SQL中的参数引用需要参考方法参数的名字。在具体的查询语句中,所有的命名绑定都是以冒号“:”加上参数名的方式来绑定的,比如“:available”表示方法签名中参数名为available的参数。在这种方式下需要在方法参数前加上@Param注解,该注解中定义的值要与SQL中的绑定名一致。
我们已经列举了若干SELECT语句的例子,那么对UPDATE语句来说,应当如何实现数据修改呢?答案仍然是使用@Query注解,但是需要添加一个额外的@Modifying注解来修改数据。例如,在CouponRepository类中我们定义了makeCouponUnavailable()方法,该方法实现了CouponTemplate类的修改,具体代码如下:
Repository还支持“命名查询”的功能,命名查询为一段查询语句指定一个“名称”。当我们执行这段语句的时候,只需通过这个“名称”就可以间接引用它对应的查询语句。命名查询又叫namedQuery,namedQuery可以引用JPQL或原生SQL,它有两种定义方式,一种方式是在定义映射的xml文件中使用<named-query/>,另外一种方式是直接添加@NamedQuery注解在数据库实体类上。
@NamedQuery有两个主要参数,一是查询语句对应的名称,二是要执行的JPQL。对名称来说,如果后续只想通过代码直接执行这段查询语句,那么我们无需遵循任何命名规范。但如果我们需要在Repository类中直接引用它,那么就需要遵循如下命名规则:Entity类的名字加上“.”再加上Repository的方法名。如果我们想用原生SQL来替换JPQL,那么只要将注解替换为@NamedNativeQuery即可。
下面,我们以CouponTemplateEntity为例,演示命名查询功能,具体代码如下:
基于上面的代码,我们可以使用两种方式执行命名查询,一种方式是直接以Java源代码的方式执行查询语句,示例代码如下:
另一种方式是直接在Repository类中增加新的方法,使用方法名来引用namedQuery,示例代码如下:
Spring data JPA提供了多种数据库操作方案,Spring Boot与JPA之间的工作流程如图3-54所示。
图3-54 Spring Boot与JPA 之间的工作流程
在Spring Boot项目中使用JPA,我们首先需要引入spring-boot-starter-data-jpa依赖项,其对应的pom.xml文件代码如下:
我们需要在application.xml文件中定义数据库的相关配置(比如数据库连接字符串、用户名密码、数据库连接池和JPA参数),代码如下:
在依赖项和配置项添加完成后,业务逻辑层就可以直接使用Repository中定义的方法操作数据库,从3.4.2节开始我们将向读者演示JPA的实战案例。
3.4.2 实现优惠券模板模块DAO层
这一节我们来定义优惠券模板的DAO层代码,我们先定义一个名为CouponTemplate的数据库实体类,用来保存优惠券模板,具体代码如下:
接下来我们定义优惠券模板的Repository类,具体代码如下:
3.4.3 实现用户领券模块DAO层
这一节中我们定义一个名为Coupon的数据库实体类,用来保存用户领取的优惠券,具体代码如下:
我们添加CouponDao类来实现数据库操作,它继承自JpaRepository类,具体代码如下。
3.4.4 使用key-value store实现缓存
在追求更高、更快、更强的现代社会中,用户对IT系统的性能要求也越来越高,他们总是希望在自己操作之后马上得到系统的反馈。据统计,一旦网页加载超过3s,大部分用户就会离开当前网站。因此,如何提高系统的响应速度是一名合格工程师需要考虑的问题。提高系统响应速度的方式有很多种,数据缓存在大多数场景下都是其中一种行之有效的方案。计算机系统的基础知识告诉我们,离CPU越近计算速度就越快,那么如果将数据库数据缓存于内存之中,从缓存读取数据一定比从数据库直接读取更快。此外,利用缓存技术还可以减少数据库的访问次数,进而降低数据库的访问压力。
在使用缓存技术,我们需要解决的一大问题就是缓存数据和数据库源数据之间的同步。如果我们将缓存中的数据看作数据库中数据的一份拷贝,那么当数据库中的源数据发生改变时,缓存中的副本数据如何与源数据保持一致呢?接下来,我们就来了解一下缓存的同步策略。
缓存的同步策略有四种:
(1)Cache-aside
Cache-aside是最简单的一种同步模式,应用程序同时管理缓存数据状态和数据库数据状态。它的工作原理很简单,应用层代码在查询数据库前先查询缓存,如果缓存数据不存在则查询数据库,而每次修改数据库后都会同步修改缓存数据。这种模式相对简单有效,如果结合Spring AOP技术使用起来也非常方便。只是所有访问数据库的代码都需要维护一段“先查缓存再查数据库”的代码逻辑,较为烦琐,也降低了系统的可维护性。Cache-aside模式的工作原理如图3-55所示。
(2)Read-through
这种方式与Cache-aside截然不同,应用程序只和缓存的抽象层进行通信,所有的数据同步工作都由缓存的实现层完成。当应用需要读取数据时,只需直接从缓存读取。如果缓存没有命中,那么缓存的实现层会自动从源数据加载数据并更新到缓存。在这种模式下,应用程序只需要访问缓存,不需要与底层数据库进行通信,也不需要处理缓存与数据库之间的数据同步,Read-through模式的工作原理如图3-56所示。
图3-55 Cache-aside模式的工作原理
图3-56 Read-through模式的工作原理
(3)Write-through
Write-through与read-through读取数据的方式相似,当缓存数据发生变更时,数据变更也会同时写入后台数据库。Write-through的工作原理如图3-57所示。
图3-57 Write-through的工作原理
在Write-through模式下还需注意缓存的事务管理:
• 如果对缓存和源数据要求强一致性,则需要采用分布式事务(例如XAResource)来控制缓存和数据库的数据写入操作。
• 在非强一致性的场景下,可以采取最终一致性方案,例如,先修改缓存,再修改源数据。如果修改源数据失败,则系统采取补偿机制来撤销缓存修改,保证一致性。
(4)Write-behind
由于分散写入效率不高,所以我们希望尽可能将源数据库的写入操作集中起来执行。Write-behind可以很好地解决这个问题,它将所有的缓存数据的变更先放入一个队列,再通过定时任务定期将队列中的数据写入源数据库,Write-behind的工作原理如图3-58所示。
图3-58 Write-behind的工作原理
了解以上四种缓存数据同步策略,有助于我们设计一个更合理的缓存系统。
3.4.4.1 Redis简介
基于内存的key-value数据库是首选的缓存方案,而Redis则是key-value数据库中的佼佼者。
Redis是Remote Dictionary Server的缩写,Salvatore Sanfilippo于2006年用C语言编写了Redis,如今Redis已经是一款非常先进的NoSQL key-value开源数据库,与同时期的Memcached相比,Redis提供了更丰富的数据类型,例如Strings、Hashes、Lists、Sets、Sorted Sets、Bitmaps等。此外,Redis还提供了key的时效功能、事务性及pub/sub功能,并且允许开发者使用Lua脚本操作数据。
3.4.4.2 安装Redis
以macOS为例,安装Redis最简单的方法是使用brew工具,命令如下:
如果本机并未安装brew,则可以从Redis官网下载Redis源码编译,我们可以在命令行执行以下命令来完成Redis的安装,具体步骤如下:
(1)wget http://download.redis.io/redis-stable.tar.gz。
(2)0tar xvzf redis-stable.tar.gz。
(3)cd redis-stable。
(4)make。
如果以上步骤一切都正常,那么在Redis的src目录下会有以下文件:
(1)redis-server:Redis服务器本身。
(2)redis-sentinel:Redis sentinel。
(3)redis-cli:Redis客户端命令行。
(4)redis-benchmark:检查Redis性能。
(5)redis-check-aof和redis-check-rdb:主要用于检查Redis数据是否损坏。
进入src目录,运行redis-server命令在本地启动Redis。如果一切运行正常,那么会看到Redis启动画面,如图3-59所示(不同版本可能略有差异)。
图3-59 Redis启动画面
注意:在启动过程中,我们没有修改任何参数,均采用默认值启动。因此,Redis的默认端口是6379。
进入src目录,通过./redis-cli命令进入Redis控制台,我们运行一个简单测试来验证Redis是否正常工作。通过Set语句指定一个Key和对应的Value,再通过Get语句根据Key获取Value,Redis Set Get测试如图3-60所示。
图3-60 Redis Set Get测试
3.4.4.3 Redis实现缓存
Spring 提供了Spring Data Redis组件来帮助开发者对接Redis。
要使用Spring Data Redis,首先需要引入以下依赖,具体代码如下:
对Java应用来说,我们可以选择很多开源库来实现Java程序与Redis的通信,如果我们使用Jedis组件,相关配置的具体代码如下:
由于篇幅原因,这里不对Jedis组件的用法做详细介绍,感兴趣的读者可以通过Jedis的官方文档了解Jedis的功能。
在Spring应用中要启用缓存功能,还需要使用Spring Cache组件,Spring Cache的工作原理是通过AOP的方式为Spring应用提供缓存服务。从Spring Framework 3.1版本起,Spring就内置了org.springframework.cache.Cache和org.springframework.cache.CacheManager接口,这两个接口对缓存操作进行了顶层抽象,我们可以通过一套统一的接口对不同缓存组件进行操作,Spring Cache还支持使用JCache(JSR-107)注解简化开发。Cache接口和CacheManager接口的功能介绍如下。
• Cache接口定义了缓存的各种操作规范,Spring针对不同缓存中间件提供了对应的实现,例如RedisCache、EhCacheCache、ConcurrentMapCache等。
• CacheManager缓存管理器,用于各种缓存软件的操作,例如ConcurrentMapCacheManager。
Spring Cache的工作原理如图3-61所示。
图3-61 Spring Cache的工作原理
在程序中使用Spring Cache我们需要向代码中添加以下两种信息:
• Cache的注解,将Spring Cache的注解及配套的策略添加到需要被缓存的方法上。
• Cache的配置,定义Cache的底层实现。
Cache的重要注解如表3-5所示。
表3-5 Spring Cache注解
在Spring Boot项目中(此处以coupon-template-service为例)使用Spring Cache有以下几步:
第一步,添加Maven依赖,具体代码如下:
第二步,添加Redis和Cache的相关配置到application.yml文件,具体代码如下:
第三步,在启动类上通过@EnableCaching注解开启缓存功能,具体代码如下:
最后一步,修改CouponTemplateServiceImpl类,通过@Cacheable注解添加缓存逻辑,具体代码如下: