为了体验从 0 到 1 的微服务改造过程,我们先使用 Spring Boot 搭建一个基础版的优惠券平台项目,等学习到 Spring Cloud 的时候,我们就在这个项目之上做微服务化改造,将 Spring Cloud 的各个组件像添砖加瓦一样集成到项目里。上一章节我们介绍了优惠券平台的功能模块,得知在用户领取优惠券的过程当中,优惠券是通过券模板来生成的,因此,优惠券模板服务是整个项目的底层基础服务。
目录
1 项目总体结构
1.1 搭建项目结构
1.2 添加 Maven 主依赖项
2 搭建 coupon-template-serv 模块
2.1 编写 coupon-template-serv 依赖项
2.2 搭建 coupon-template-api 子模块
① 通过 IDEA 搭建子项目
② 补充 template-api 依赖项
③ 编写优惠券相关实体类
2.3 搭建 coupon-template-dao 子模块
① 补充 template-dao 依赖项
② 创建优惠券模板数据库对象
③ 创建 CouponTemplateDao CRUD 接口
2.4 搭建 coupon-template-impl 子模块
① 补充 template-impl 依赖项
② 编写 template-impl 接口层
③ 编写 template-impl 启动类
④ 创建 template-impl 配置文件
3 搭建 coupon-calculation-serv 模块
3.1 编写 coupon-calculation-serv 依赖项
3.2 搭建 coupon-calculation-api 子模块
① 补充 calculation-api 依赖项
② 编写订单信息封装实体类
3.3 搭建 coupon-calculation-impl 子模块
① 补充 calculation-impl 依赖项
② 通过模板设计模式编写 template
③ 编写 calculation-impl 的 service 层
4 搭建 coupon-customer-serv 模块
4.1 编写 coupon-customer-serv 依赖项
4.2 搭建 coupon-customer-api 子模块
① 补充 customer-api 依赖项
② 编写 customer-api 请求参数封装类
4.3 搭建 coupon-customer-dao 子模块
① 补充 customer-dao 依赖项
② 创建优惠券数据库对象
③ 编写 customer-dao 接口类
4.4 搭建 coupon-customer-impl 子模块
① 补充 customer-impl 依赖项
③ 编写 customer-impl service 层
④ 编写 customer-impl Controller 层
⑤ 编写 customer-impl 启动类
5 总结
1 项目总体结构
首先我们来看看整体的项目结构是怎样搭建的
1.1 搭建项目结构
整个优惠券平台项目从 Maven 模块管理的角度划分为了多个模块:
在顶层项目 yinyu-coupon 之下有四个子模块:
- coupon-template-serv: 创建、查找、克隆、删除优惠券模板
- coupon-calculation-serv:计算优惠后的订单价格、试算每个优惠券的优惠幅度
- coupon-customer-serv:通过调用 template 和 calculation 服务,实现用户领取优惠券、模拟计算最优惠的券、删除优惠券、下订单等操作
- middleware:存放一些与业务无关的平台类组件
在大型的微服务项目里,每一个子模块通常都存放在独立的 Git 仓库中,为了方便下载代码,所有模块的代码都打包在一个代码仓库【代码仓库-原版】,这里可以找到课程各阶段对应的源代码,本人也会在项目结束时讲代码上传。
在每一个以“-serv”结尾的业务子模块中,以内部分层的角度对其做了进一步拆分,以 coupon-template-serv 为例,它内部包含了三个子模块:
- coupon-template-api:存放公共 POJO 类或者对外接口的子模块
- coupon-template-dao:存放数据库实体类和 Dao 层的子模块
- coupon-template-impl:核心业务逻辑的实现层,对外提供 REST API
你会发现,我们把 coupon-template-api 作为一个单独的模块,这样做的好处是:当某个上游服务需要获取 coupon-template-serv 的接口参数时,只要导入轻量级的 coupon-template-api 模块,就能够获取接口中定义的 Request 和 Response 的类模板,不需要引入多余的依赖项(比如 Dao 层或者 Service 层)。
这就是开闭原则的应用,它使各个模块间的职责和边界划分更加清晰,降低耦合的同时也更加利于依赖管理。
搭建好项目的结构之后(建议第一步搭建项目结构!),接下来我们借助 Maven 工具将需要的依赖包导入到项目中。
1.2 添加 Maven 主依赖项
需要注意的是,添加 Maven 依赖项需要遵循“从上到下”的原则,也就是从顶层项目 yinyu-coupon 开始,顺藤摸瓜直到 coupon-template-serv 下的子模块。首先,我们来看看顶层 geekbang-coupon 依赖项的编写。
编写 yinyu-coupon 依赖项 👇
yinyu-coupon 是整个实战项目的顶层项目,只用完成一个任务:管理子模块和定义 Maven 依赖项的版本。这就像一个公司的大 boss 一样,只用制定方向战略,琐碎的业务就交给下面人(子模块)来办就好了。
路径:pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>yinyu-coupon</artifactId>
<groupId>com.yinyu</groupId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>coupon-template-serv</module>
<module>coupon-calculation-serv</module>
<module>coupon-customer-serv</module>
<module>middleware</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencyManagement>
<!-- 子项目会继承父项目中的依赖。子项目在需要依赖时,先从自身pom中查找,如果没有找到,就从父项目的pom中查找,与Java中的继承机制类似。 -->
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2020.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.9</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.31</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
在 pom 文件里有以下三个重点标签:
Ⅰ < parent > 标签
在 parent 标签中指定了 yinyu-coupon 项目的“父级依赖”为 spring-boot-starter-parent,这样一来,spring-boot-starter-parent 里定义的 Spring Boot 组件版本信息就会被自动带到子模块中。这种做法也是大多数 Spring Boot 项目的通用做法,不仅降低了依赖项管理的成本,也不需要担心各个组件间的兼容性问题。
Ⅱ < packaging > 标签
maven 的打包类型有三种:jar、war 和 pom。当我们指定 packaging 类型为 pom 时,意味着当前模块是一个“boss”,它只用关注顶层战略,即定义依赖项版本和整合子模块,不包含具体的业务实现。
Ⅲ < dependencymanagement > 标签
这个标签的作用和 < parent > 标签类似,两者都是将版本信息向下传递。dependencymanagement 是 boss 们定义顶层战略的地方,我们可以在这里定义各个依赖项的版本,当子项目需要引入这些依赖项的时候,只用指定 groupId 和 artifactId 即可,不用管 version 里该写哪个版本。
2 搭建 coupon-template-serv 模块
2.1 编写 coupon-template-serv 依赖项
coupon-template-serv 是大 boss 下面的一个小头目,和 yinyu-coupon 一样,它的 packaging 类型也是 pom。我们说过 boss 只用管顶层战略,因此 coupon-temolate-serv 的 pom 文件内容很简单,只是定义了父级项目和子模块。
路径:coupon-template-serv\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>yinyu-coupon</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-template-serv</artifactId>
<packaging>pom</packaging>
<modules>
<module>coupon-template-api</module>
<module>coupon-template-dao</module>
<module>coupon-template-impl</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
</project>
2.2 搭建 coupon-template-api 子模块
coupon-template-api 模块存放了接口 Request 和 Response 的类模板,是另两个子模块需要依赖的公共类库,所以我就先从 coupon-template-api 开始项目构建。
coupon-template-api 模块是专门用来存放公共类的仓库,我把 REST API 接口的服务请求和服务返回对象的 POJO 类放到了里面。在微服务领域,将外部依赖的 POJO 类或者 API 接口层单独打包是一种通用做法,这样就可以给外部依赖方提供一个“干净”(不包含非必要依赖)的接口包,为远程服务调用(RPC)提供支持。
① 通过 IDEA 搭建子项目
Ⅰ右键选择新建项目
Ⅱ 选择通过 Maven 创建
Ⅲ 填写相关信息
Ⅳ 新建软件包
② 补充 template-api 依赖项
通过 IDEA 搭建完 coupon-template-api 子项目后,只需要在 coupon-template-api 项目的 pom 文件中,添加了少量的“工具类”依赖,比如 lombok、guava 和 validation-api 包等通用组件。
路径:coupon-template-serv\coupon-template-api\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-template-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-template-api</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
</dependencies>
</project>
③ 编写优惠券相关实体类
首先,我们需要定义一个用来表示优惠券类型的 enum 对象,创建一个名为 CouponType 的枚举类。
路径:coupon-template-serv\coupon-template-api\src\main\java\com\yinyu\coupon\template\api\enums\CounponType.java
@Getter
@AllArgsConstructor
public enum CounponType {
UNKNOWN("unknown", "0"),
MONEY_OFF("满减券", "1"),
DISCOUNT("打折", "2"),
RANDOM_DISCOUNT("随机减", "3"),
LONELY_NIGHT_MONEY_OFF("寂寞午夜double券", "4"),
ANTI_PUA("PUA加倍奉还券", "5");
private String description;
// 存在数据库里的最终code
private String code;
public static CounponType convert(String code) {
return Stream.of(values())
.filter(couponType -> couponType.code.equalsIgnoreCase(code))
.findFirst()
.orElse(UNKNOWN);
}
}
CouponType 类定义了多个不同类型的优惠券,convert 方法可以根据优惠券的编码返回对应的枚举对象。这里还有一个“Unknown”类型的券,它专门用来对付故意输错 code 的恶意请求。
接下来,我们创建两个用来定义优惠券模板规则的类,分别是 TemplateRule 和 Discount。
路径:coupon-template-api\src\main\java\com\yinyu\coupon\template\api\beans\rules
TemplateRule 包含了两个规则,一是领券规则,包括每个用户可领取的数量和券模板的过期时间;二是券模板的计算规则。
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateRule {
/** 可以享受的折扣 */
private Discount discount;
// 每个人最多可以领券数量
private Integer limitation;
// 过期时间
private Long deadline;
}
这里推荐使用一键三连的 lombok 注解自动生成基础代码,它们分别是 Data、NoArgsConstructor 、AllArgsConstructor 和 Builder 。其中,@Data 注解自动生成 getter、setter、toString 等方法,后两个注解分别生成无参构造器和全参构造器,省时省力省地盘,@Builder 则是用于简化实体的构建~
TemplateRule 中的 Discount 成员变量定义了使用优惠券的规则,代码如下:
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Discount {
// 对于满减券 - quota是减掉的钱数,单位是分
// 对于打折券 - quota是折扣(以100表示原价),90就是打9折, 95就是95折
// 对于随机立减券 - quota是最高的随机立减额
// 对于晚间特别优惠券 - quota是日间优惠额,晚间优惠翻倍
private Long quota;
// 订单最低要达到多少钱才能用优惠券,单位为分
private Long threshold;
}
从上面代码中可以看出,我使用 Long 来表示“金额”。对于境内电商行业来说,金额往往是以分为单位的,这样我们可以直接使用 Long 类型参与金额的计算,比如 100 就代表 100 分,也就是一块钱。这比使用 Double 到处转换 BigDecimal 省了很多事儿。
路径:coupon-template-api\src\main\java\com\yinyu\coupon\template\api\beans
创建一个名为 CouponTemplateInfo 的类,用来创建优惠券模板,代码如下:
/**
* 创建优惠券模板
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CouponTemplateInfo {
private Long id;
@NotNull
private String name;
// 优惠券描述
@NotNull
private String desc;
// 优惠券类型
@NotNull
private String type;
// 适用门店 - 若无则为全店通用券
private Long shopId;
/** 优惠券规则 */
@NotNull
private TemplateRule rule;
private Boolean available;
}
在上面的代码中,我们应用了 jakarta.validate-api 组件的注解 @NotNull,对参数是否为 Null 进行了校验。如果请求参数为空,那么接口会自动返回 Bad Request 异常。当然,jakarta 组件还有很多可以用来做判定验证的注解,合理使用可以节省大量编码工作,提高代码可读性。
此外,你还会发现,CouponTemplateInfo 内封装了优惠券模板的基本信息,我们可以把优惠券模板当做一个“模具”,每一张优惠券都经由模具来制造,被制造出来的优惠券则使用 CouponInfo 对象来封装。CouponInfo 对象包含了优惠券的模板信息、领券用户 ID、适用门店 ID 等属性。
/**
* 封装优惠券信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CouponInfo {
private Long id;
private Long templateId;
private Long userId;
private Long shopId;
private Integer status;
private CouponTemplateInfo template;
}
到这里我们就完成了 coupon-template-api 项目的搭建,下面我们开始搭建 Dao 层模块:coupon-template-dao。它主要负责和数据库的对接、读取。
2.3 搭建 coupon-template-dao 子模块
① 补充 template-dao 依赖项
通过 IDEA 搭建子项目后,我们把必要的依赖项添加到 coupon-template-dao 项目中,比较关键的 maven 依赖项有以下几个。
- coupon-template-api: 引入 api 包下的公共类
- spring-boot-starter-data-jpa: 添加 spring-data-jpa 的功能进行 CRUD 操作
- mysql-connector-java: 引入 mysql 驱动包,驱动版本尽量与 mysql 版本保持一致
路径:coupon-template-serv\coupon-template-dao\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-template-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-template-dao</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- DAO层理论上不应该依赖API层,这里懒省事儿用了一套POJO从前到后传递 -->
<!-- 正确的做法是dao层定义自己的DTO,然后上层用一个converter再转化成API里的类 -->
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.21</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
</project>
② 创建优惠券模板数据库对象
路径:
coupon-template-serv\coupon-template-dao\src\main\java\com\yinyu\coupon\template\dao\entity
/**
* 优惠券模板
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
@EntityListeners(AuditingEntityListener.class)
@Table(name = "coupon_template")
public class CouponTemplate implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
// 状态是否可用
@Column(name = "available", nullable = false)
private Boolean available;
@Column(name = "name", nullable = false)
private String name;
// 适用门店-如果为空,则为全店满减券
@Column(name = "shop_id")
private Long shopId;
@Column(name = "description", nullable = false)
private String description;
// 优惠券类型
@Column(name = "type", nullable = false)
@Convert(converter = CouponTypeConverter.class)
private CounponType category;
// 创建时间,通过@CreateDate注解自动填值(需要配合@JpaAuditing注解在启动类上生效)
@CreatedDate
@Column(name = "created_time", nullable = false)
private Date createdTime;
// 优惠券核算规则,平铺成JSON字段
@Column(name = "rule", nullable = false)
@Convert(converter = RuleConverter.class)
private TemplateRule rule;
}
在 CouponTemplate 上,我们运用了 javax.persistence 包和 Spring JPA 包的标准注解,对数据库字段进行了映射,我挑几个关键注解说道一下。
- Entity:声明了“数据库实体”对象,它是数据库 Table 在程序中的映射对象;
- Table:指定了 CouponTemplate 对应的数据库表的名称;
- ID/GeneratedValue:ID 注解将某个字段定义为唯一主键,GeneratedValue 注解指定了主键生成策略;
- Column:指定了每个类属性和数据库字段的对应关系,该注解还支持非空检测、对 update 和 create 语句进行限制等功能;
- CreatedDate:自动填充当前数据的创建时间;
- Convert:如果数据库中存放的是 code、string、数字等等标记化对象,可以使用 Convert 注解指定一个继承自 AttributeConverter 的类,将 DB 里存的内容转化成一个 Java 对象。
③ 创建 CouponTemplateDao CRUD 接口
最后,我们来到定义 DAO 的地方,借助 Spring Data 的强大功能,我们只通过接口名称就可以声明一系列的 DB 层操作。
路径:
coupon-template-serv\coupon-template-dao\src\main\java\com\yinyu\coupon\template\dao\CouponTemplateDao.java
public interface CouponTemplateDao
extends JpaRepository<CouponTemplate, Long> {
// 根据Shop ID查询出所有券模板
List<CouponTemplate> findAllByShopId(Long shopId);
// IN查询 + 分页支持的语法
Page<CouponTemplate> findAllByIdIn(List<Long> Id, Pageable page);
// 根据shop ID + 可用状态查询店铺有多少券模板
Integer countByShopIdAndAvailable(Long shopId, Boolean available);
// 将优惠券设置为不可用
@Modifying
@Query("update CouponTemplate c set c.available = 0 where c.id = :id")
int makeCouponUnavailable(@Param("id") Long id);
}
其实,“增删改” 这些方法都在 CouponTemplateDao 所继承的 JpaRepository 类中。这个父类就像一个百宝箱,内置了各种各样的数据操作方法。我们可以通过内置的 save 方法完成对象的创建和更新,也可以使用内置的 delete 方法删除数据。
此外,它还提供了对“查询场景”的丰富支持,除了通过 ID 查询以外,我们还可以使用三种不同的方式查询数据。
- 通过接口名查询:将查询语句写到接口的名称中
- 通过 Example 对象查询:构造一个模板对象,使用 findAll 方法来查询
- 自定义查询:通过 Query 注解自定义复杂查询语句
在 CouponTemplateDao 中,第一个方法 findAllByShopId 就是通过接口名查询的例子,jpa 使用了一种约定大于配置的思想,你只需要把要查询的字段定义在接口的方法名中,在你发起调用时后台就会自动转化成可执行的 SQL 语句。构造方法名的过程需要遵循 < 起手式 >By< 查询字段 >< 连接词 > 的结构。
- 起手式:以 find 开头表示查询,以 count 开头表示计数
- 查询字段:字段名要保持和 Entity 类中定义的字段名称一致
- 连接词:每个字段之间可以用 And、Or、Before、After 等一些列丰富的连词串成一个查询语句
以接口名查询的方式虽然很省事儿,但它面对复杂查询却力不从心,一来容易导致接口名称过长,二来维护起来也挺吃力的。所以,对于复杂查询,我们可以使用自定义 SQL、或者 Example 对象查找的方式。
关于自定义 SQL,你可以参考 CouponTemplateDao 中的 makeCouponUnavailable 方法,我将 SQL 语句定义在了 Query 注解中,通过参数绑定的方式从接口入参处获取查询参数,这种方式是最接近 SQL 编码的 CRUD 方式。
Example 查询的方式也很简单,构造一个 CouponTemplate 的对象,将你想查询的字段值填入其中,做成一个查询模板,调用 Dao 层的 findAll 方法即可,这里留给你自己动手验证。
couponTemplate.setName("查询名称");
templateDao.findAll(Example.of(couponTemplate));
现在,API 和 Dao 层都已经准备就绪,万事俱备只差最后的业务逻辑层了,接下来我们去搭建 coupon-template-impl 模块。
2.4 搭建 coupon-template-impl 子模块
① 补充 template-impl 依赖项
coupon-template-impl 是 coupon-template-serv 下的一个子模块,也是实现业务逻辑的地方。从依赖管理的角度,它引入了 coupon-template-api 和 coupon-template-dao 两个内部依赖项到 pom.xml。
路径:coupon-template-serv\coupon-template-impl\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-template-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-template-impl</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-dao</artifactId>
<version>${project.version}</version>
</dependency>
<!--阿里巴巴开源的Java对象和JSON格式字符串的快速转换的工具库-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 用来处理常用的编码方法的工具类包,例如SHA1、MD5、Base64,URL,Soundx等等 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<!-- Actuator 对微服务端点进行管理和配置监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<!-- 如果要以java -jar命令执行这个应用,记得把这个注解打开
如果这个项目是作为其它项目的依赖,那么就不要添加下面这个注解。
在Spring Boot阶段,由于我们是通过customer服务来跑单体应用,所以不用加
在Spring Cloud阶段,以java -jar 运行项目必须在各个impl项目的pom中加上这个注解
-->
<!-- <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>-->
</project>
② 编写 template-impl 接口层
首先我们来定义 Service 层的接口类:CouponTemplateService。在这个接口中,我们定义了优惠券创建、查找优惠券和修改优惠券可用状态的方法。
public interface CouponTemplateService {
// 创建优惠券模板
CouponTemplateInfo createTemplate(CouponTemplateInfo request);
CouponTemplateInfo cloneTemplate(Long templateId);
// 模板查询(分页)
PagedCouponTemplateInfo search(TemplateSearchParams request);
// 通过模板ID查询优惠券模板
CouponTemplateInfo loadTemplateInfo(Long id);
// 让优惠券模板无效
void deleteTemplate(Long id);
// 批量查询
// Map是模板ID,key是模板详情
Map<Long, CouponTemplateInfo> getTemplateInfoMap(Collection<Long> ids);
}
由于这部分比较简单,就是通过 CouponTemplateDao 层来实现优惠券模板的增删改查,这里就不展开介绍实现层代码了,你可以参考源码中的 CouponTemplateServiceImpl 类。
接下来,我们创建 CouponTemplateController 类对外暴露 REST API,可以借助 spring-web 注解来完成,具体代码如下。
@Slf4j
@RestController
@RequestMapping("/template")
public class CouponTemplateController {
@Autowired
private CouponTemplateService couponTemplateService;
// 创建优惠券
@PostMapping("/addTemplate")
public CouponTemplateInfo addTemplate(@Valid @RequestBody CouponTemplateInfo request) {
log.info("Create coupon template: data={}", request);
return couponTemplateService.createTemplate(request);
}
@PostMapping("/cloneTemplate")
public CouponTemplateInfo cloneTemplate(@RequestParam("id") Long templateId) {
log.info("Clone coupon template: data={}", templateId);
return couponTemplateService.cloneTemplate(templateId);
}
// 读取优惠券
@GetMapping("/getTemplate")
public CouponTemplateInfo getTemplate(@RequestParam("id") Long id){
log.info("Load template, id={}", id);
return couponTemplateService.loadTemplateInfo(id);
}
// 批量获取
@GetMapping("/getBatch")
public Map<Long, CouponTemplateInfo> getTemplateInBatch(@RequestParam("ids") Collection<Long> ids) {
log.info("getTemplateInBatch: {}", JSON.toJSONString(ids));
return couponTemplateService.getTemplateInfoMap(ids);
}
// 搜索模板
@PostMapping("/search")
public PagedCouponTemplateInfo search(@Valid @RequestBody TemplateSearchParams request) {
log.info("search templates, payload={}", request);
return couponTemplateService.search(request);
}
// 优惠券无效化
@DeleteMapping("/deleteTemplate")
public void deleteTemplate(@RequestParam("id") Long id){
log.info("Load template, id={}", id);
couponTemplateService.deleteTemplate(id);
}
}
在这里,Controller 类中的注解来自 spring-boot-starter-web 依赖项,通过这些注解将服务以 RESTful 接口的方式对外暴露。现在,我们来了解下上述代码里,服务寻址过程中的三个重要注解:
- RestController:用来声明一个 Controller 类,加载到 Spring Boot 上下文
- RequestMapping:指定当前类中所有方法在 URL 中的访问路径的前缀
- Post/Get/PutMapping:定义当前方法的 HTTP Method 和访问路径
③ 编写 template-impl 启动类
项目启动类是最后的代码部分,我们在 com.yinyu.coupon.template 下创建一个 Application 类作为启动程序的入口,并在这个类的头上安上 SpringBoot 的启动注解。
@SpringBootApplication
@EnableJpaAuditing
@ComponentScan(basePackages = {"com.yinyu"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
SpringBootApplication 注解会自动开启包路径扫描,并启动一系列的自动装配流程(AutoConfig)。在默认情况下,Spring Boot 框架会扫描启动类所在 package 下的所有类,并在上下文中创建受托管的 Bean 对象,如果我们想加载额外的扫包路径,只用添加 ComponentScan 注解并指定 path 即可。
④ 创建 template-impl 配置文件
所有代码环节全部完工后,我们还剩最后的画龙点睛之笔:创建配置文件 application.yml,它位于 src/main/resources 文件夹下。Spring Boot 支持多种格式的配置文件,这里我们顺应主流,使用 yml 格式。
# 项目的启动端口
server:
port: 20000
spring:
application:
# 定义项目名称
name: coupon-template-serv
datasource:
# mysql数据源
username: root
# password: 这里写上你自己的密码
url: jdbc:mysql://127.0.0.1:3306/geekbang_coupon_db?autoReconnect=true&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&zeroDateTimeBehavior=convertToNull&serverTimezone=UTC
# 指定数据源DataSource类型
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
# 数据库连接池参数配置,比如池子大小、超时时间、是否自动提交等等
hikari:
pool-name: GeekbangCouponHikari
connection-timeout: 5000
idle-timeout: 30000
maximum-pool-size: 10
minimum-idle: 5
max-lifetime: 60000
auto-commit: true
jpa:
show-sql: true
hibernate:
# 在生产环境全部为none,防止ddl结构被自动执行,破坏生产数据
ddl-auto: none
# 在日志中打印经过格式化的SQL语句
properties:
hibernate.format_sql: true
hibernate.show_sql: true
open-in-view: false
在配置文件中,有一个地方需要你多加注意,那就是 jdbc 连接串(spring.datasource.url)。不同版本的 MySQL 对连接串中的参数有不同的要求。
好,到这里,我们优惠券平台项目的第一个模块 coupon-template-serv 就搭建完成了,你可以在本地启动项目并通过 Postman 发起调用。
3 搭建 coupon-calculation-serv 模块
之前我们搭建了 coupon-template-serv 模块,实现了优惠券模板的创建和批量查询等功能,相信你已经对如何使用 Spring Boot 搭建应用驾轻就熟了。今天我们就来搭建优惠券平台项目的另外两个模块,coupon-calculation-serv(优惠计算服务)和 coupon-customer-serv(用户服务),组建一个完整的实战项目应用(middleware 模块将在 Spring Cloud 环节进行搭建)。
3.1 编写 coupon-calculation-serv 依赖项
coupon-calculation-serv 提供了用于计算订单的优惠信息的接口,它是一个典型的“计算密集型”服务。所谓计算密集型服务一般具备下面的两个特征:
- 不吃网络 IO 和磁盘空间
- 运行期主要占用 CPU、内存等计算资源
在做大型应用架构的时候,我们通常会把计算密集型服务与 IO/ 存储密集型服务分割开来,这样做的一个主要原因是提高资源利用率。
比如说,我们有一个计算密集型的微服务 A 和一个 IO 密集型微服务 B,大促峰值流量到来的时候,如果微服务 A 面临的压力比较大,我可以专门调配高性能 CPU 和内存等“计算类”的资源去定向扩容 A 集群;如果微服务 B 压力吃紧了,我可以定向调拨云上的存储资源分配给 B 集群,这样就实现了一种“按需分配”。
假如微服务 A 和微服务 B 合二为一变成了一个服务,那么在分配资源的时候就无法做到定向调拨,全链路压测环节也难以精准定位各项性能指标,这难免出现资源浪费的情况。这也是为什么,我要把优惠计算这个服务单独拿出来的原因。
现在,我们开始着手搭建 coupon-calculation-serv 下的子模块。和 coupon-template-serv 结构类似,coupon-calculation-serv 下面也分了若干个子模块,包括 API 层和业务逻辑层。API 层定义了公共的 POJO 类,业务逻辑层主要实现优惠价格计算业务。因为 calculation 服务并不需要访问数据库,所以没有 DAO 模块。
路径:coupon-calculation-serv\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>geekbang-coupon</artifactId>
<groupId>com.geekbang</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-calculation-serv</artifactId>
<packaging>pom</packaging>
<modules>
<module>coupon-calculation-impl</module>
<module>coupon-calculation-api</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
</project>
3.2 搭建 coupon-calculation-api 子模块
① 补充 calculation-api 依赖项
通过 IDEA 搭建子项目后,如果 coupon-calculation-serv 需要计算订单的优惠价格,那就得知道当前订单用了什么优惠券。封装了优惠券信息的 Java 类 CouponInfo 位于 coupon-template-api 包下,因此我们需要把 coupon-template-api 的依赖项加入到 coupon-calculation-api 中。
路径:coupon-calculation-serv\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-calculation-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-calculation-api</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
② 编写订单信息封装实体类
路径:coupon-calculation-api\src\main\java\com\yinyu\coupon\calculation\api\beans\Product.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
// 你可以试着搭建一个商品中心,用来存储商品信息,逐步完善这个应用
private Long productId;
// 商品的价格
private long price;
// 商品在购物车里的数量
private Integer count;
// 商品销售的门店
private Long shopId;
}
在上面的源码中,我们看到 ShoppingCart 订单类中使用了 Product 对象,来封装当前订单的商品列表。在 Product 类中包含了商品的单价、商品数量,以及当前商品的门店 ID。
路径:
coupon-calculation-api\src\main\java\com\yinyu\coupon\calculation\api\beans\ShoppingCart.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
// 你可以试着搭建一个商品中心,用来存储商品信息,逐步完善这个应用
private Long productId;
// 商品的价格
private long price;
// 商品在购物车里的数量
private Integer count;
// 商品销售的门店
private Long shopId;
}
在电商领域中,商品的数量通常不能以 Integer 整数来表示,这是因为只有标品才能以整数计件。对于像蔬菜、肉类等非标品来说,它们的计件单位并不是“个”。所以在实际项目中,尤其是零售行业的业务系统里,计件单位要允许小数位的存在。而我们的实战项目为了简化业务,就假定所有商品都是“标品”了。
在下单的时候,你可能有多张优惠券可供选择,你需要通过“价格试算”来模拟计算每张优惠券可以扣减的金额,进而选择最优惠的券来使用。SimulationOrder 和 SimulationResponse 分别代表了“价格试算”的订单类,以及返回的计算结果 Response。我们来看一下这两个类的源码。
路径:
coupon-calculation-api\src\main\java\com\yinyu\coupon\calculation\api\beans\SimulationOrder.java
// 试算最优的优惠券
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SimulationOrder {
@NotEmpty
private List<Product> products;
@NotEmpty
private List<Long> couponIDs;
private List<CouponInfo> couponInfos;
@NotNull
private Long userId;
}
路径:
coupon-calculation-api\src\main\java\com\yinyu\coupon\calculation\api\beans\SimulationResponse.java
@Data
@NoArgsConstructor
public class SimulationResponse {
// 最省钱的coupon
private Long bestCouponId;
// 每一个coupon对应的order价格
private Map<Long, Long> couponToOrderPrice = Maps.newHashMap();
}
到这里,coupon-calculation-api 模块就搭建好了。因为 calculation 服务不需要访问数据库,所以我们就不用搭建 dao 模块了,直接来实现 coupon-calculation-impl 业务层的代码逻辑。
3.3 搭建 coupon-calculation-impl 子模块
① 补充 calculation-impl 依赖项
路径:coupon-calculation-serv\coupon-calculation-impl\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-calculation-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-calculation-impl</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-calculation-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<!-- 如果要以java -jar命令执行这个应用,记得把这个Plugins注解打开
如果这个项目是作为其它项目的依赖,那么就不要添加下面这个注解。
在Spring Boot阶段,由于我们是通过customer服务来跑单体应用,所以不用加
在Spring Cloud阶段,以java -jar 运行项目必须在各个impl项目的pom中加上这个注解
-->
<!-- <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>-->
</project>
从 coupon-template-api 和 coupon-calculation-api 两个依赖项中,你可以拿到订单优惠计算过程用到的 POJO 对象。接下来,我们可以动手实现优惠计算逻辑了。
② 通过模板设计模式编写 template
在搭建优惠计算业务逻辑的过程中,我运用了模板设计模式来封装计算逻辑。模板模式是一种基于抽象类的设计模式,它的思想很简单,就是将共性的算法骨架部分上升到抽象层,将个性部分延迟到子类中去实现。
在搭建优惠计算业务逻辑的过程中,我运用了模板设计模式来封装计算逻辑。模板模式是一种基于抽象类的设计模式,它的思想很简单,就是将共性的算法骨架部分上升到抽象层,将个性部分延迟到子类中去实现。
优惠券类型有很多种,比如满减券、打折券、随机立减等等,这些券的计算流程(共性部分)是相同的,但具体的计算规则(个性部分)是不同的。我将共性的部分抽象成了 AbstractRuleTemplate 抽象类,将各个券的差异性计算方式做成了抽象类的子类。
让我们看一下计算逻辑的类结构图:
在这张图里,顶层接口 RuleTemplate 定义了 calculate 方法,抽象模板类 AbstractRuleTemplate 将通用的模板计算逻辑在 calculate 方法中实现,同时它还定义了一个抽象方法 calculateNewPrice 作为子类的扩展点。各个具体的优惠计算类通过继承 AbstractRuleTemplate,并实现 calculateNewPrice 来编写自己的优惠计算方式。
我们先来看一下 AbstractRuleTemplate 抽象类的代码,走读 calculate 模板方法中的计算逻辑实现。
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\template\AbstractRuleTemplate.java
/**
* 定义通用的计算逻辑
*/
@Slf4j
public abstract class AbstractRuleTemplate implements RuleTemplate {
@Override
public ShoppingCart calculate(ShoppingCart order) {
// 获取订单总价
Long orderTotalAmount = getTotalPrice(order.getProducts());
// 获取以shopId为维度的价格统计
Map<Long, Long> sumAmount = this.getTotalPriceGroupByShop(order.getProducts());
// 以下规则只做单个优惠券的计算
CouponTemplateInfo template = order.getCouponInfos().get(0).getTemplate();
// 最低消费限制
Long threshold = template.getRule().getDiscount().getThreshold();
// 优惠金额或者打折比例
Long quota = template.getRule().getDiscount().getQuota();
// 当前优惠券适用的门店ID,如果为空则作用于全店券
Long shopId = template.getShopId();
// 如果优惠券未指定shopId,shopTotalAmount=orderTotalAmount
// 如果指定了shopId,则shopTotalAmount=对应门店下商品总价
Long shopTotalAmount = (shopId == null) ? orderTotalAmount : sumAmount.get(shopId);
// 如果不符合优惠券使用标准, 则直接按原价走,不使用优惠券
if (shopTotalAmount == null || shopTotalAmount < threshold) {
log.warn("Totals of amount not meet, ur coupons are not applicable to this order");
order.setCost(orderTotalAmount);
order.setCouponInfos(Collections.emptyList());
return order;
}
// 子类中计算新的价格
Long newCost = calculateNewPrice(orderTotalAmount, shopTotalAmount, quota);
// 订单价格不能小于最低价格
if (newCost < minCost()) {
newCost = minCost();
}
order.setCost(newCost);
log.debug("original price={}, new price={}", orderTotalAmount, newCost);
return order;
}
// 金额计算具体逻辑,延迟到子类实现
abstract protected Long calculateNewPrice(Long orderTotalAmount, Long shopTotalAmount, Long quota);
// 计算订单总价
protected long getTotalPrice(List<Product> products) {
return products.stream()
.mapToLong(product -> product.getPrice() * product.getCount())
.sum();
}
// 根据门店维度计算每个门店下商品价格
// key = shopId
// value = 门店商品总价
protected Map<Long, Long> getTotalPriceGroupByShop(List<Product> products) {
Map<Long, Long> groups = products.stream()
.collect(Collectors.groupingBy(m -> m.getShopId(),
Collectors.summingLong(p -> p.getPrice() * p.getCount()))
);
return groups;
}
// 每个订单最少必须支付1分钱
protected long minCost() {
return 1L;
}
protected long convertToDecimal(Double value) {
return new BigDecimal(value).setScale(0, RoundingMode.HALF_UP).longValue();
}
}
在上面的源码中,我们看到大部分计算逻辑都在抽象类中做了实现,子类只要实现 calculateNewPrice 方法完成属于自己的订单价格计算就好。我们以满减规则类为例来看一下它的实现,其他的规则类见源码。
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\template\impl\MoneyOffTemplate.java
/**
* 满减优惠券计算规则
*/
@Slf4j
@Component
public class MoneyOffTemplate extends AbstractRuleTemplate implements RuleTemplate {
@Override
protected Long calculateNewPrice(Long totalAmount, Long shopAmount, Long quota) {
// benefitAmount是扣减的价格
// 如果当前门店的商品总价<quota,那么最多只能扣减shopAmount的钱数
Long benefitAmount = shopAmount < quota ? shopAmount : quota;
return totalAmount - benefitAmount;
}
}
在上面的源码中,我们看到子类业务的逻辑非常简单清爽。通过模板设计模式,我在抽象类中封装了共性逻辑,在子类中扩展了可变逻辑,每个子类只用关注自己的特定实现即可,使得代码逻辑变得更加清晰,大大降低了代码冗余。
随着业务发展,你的优惠券模板类型可能会进一步增加,比如赠品券、随机立减券等等,如果当前的抽象类无法满足新的需求,本项目通过建立多级抽象类的方式进一步增加抽象层次,不断将共性不变的部分抽取为抽象层。
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\template\RuleTemplate.java
public interface RuleTemplate {
// 计算优惠券
ShoppingCart calculate(ShoppingCart settlement);
}
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\template\CouponTemplateFactory.java
// 工厂方法根据订单中的优惠券信息,返回对应的Template用于计算优惠价
@Component
@Slf4j
public class CouponTemplateFactory {
@Autowired
private MoneyOffTemplate moneyOffTemplate;
@Autowired
private DiscountTemplate discountTemplate;
@Autowired
private RandomReductionTemplate randomReductionTemplate;
@Autowired
private LonelyNightTemplate lonelyNightTemplate;
@Autowired
private DummyTemplate dummyTemplate;
@Autowired
private AntiPauTemplate antiPauTemplate;
public RuleTemplate getTemplate(ShoppingCart order) {
// 不使用优惠券
if (CollectionUtils.isEmpty(order.getCouponInfos())) {
// dummy模板直接返回原价,不进行优惠计算
return dummyTemplate;
}
// 获取优惠券的类别
// 目前每个订单只支持单张优惠券
CouponTemplateInfo template = order.getCouponInfos().get(0).getTemplate();
CounponType category = CounponType.convert(template.getType());
switch (category) {
// 订单满减券
case MONEY_OFF:
return moneyOffTemplate;
// 随机立减券
case RANDOM_DISCOUNT:
return randomReductionTemplate;
// 午夜下单优惠翻倍
case LONELY_NIGHT_MONEY_OFF:
return lonelyNightTemplate;
// 打折券
case DISCOUNT:
return discountTemplate;
case ANTI_PUA:
return antiPauTemplate;
// 未知类型的券模板
default:
return dummyTemplate;
}
}
}
③ 编写 calculation-impl 的 service 层
创建完优惠计算逻辑,我们接下来看一下 Service 层的代码实现逻辑。Service 层的 calculateOrderPrice 代码非常简单,通过 CouponTemplateFactory 工厂类获取到具体的计算规则,然后调用 calculate 计算订单价格就好了。simulate 方法实现了订单价格试算,帮助用户在下单之前了解每个优惠券可以扣减的金额,从而选出最省钱的那个券。
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\controller\service\CouponCalculationServiceImpl.java
@Slf4j
@Service
public class CouponCalculationServiceImpl implements CouponCalculationService {
@Autowired
private CouponTemplateFactory couponProcessorFactory;
// 优惠券结算
// 这里通过Factory类决定使用哪个底层Rule,底层规则对上层透明
@Override
public ShoppingCart calculateOrderPrice(@RequestBody ShoppingCart cart) {
log.info("calculate order price: {}", JSON.toJSONString(cart));
RuleTemplate ruleTemplate = couponProcessorFactory.getTemplate(cart);
return ruleTemplate.calculate(cart);
}
// 对所有优惠券进行试算,看哪个最赚钱
@Override
public SimulationResponse simulateOrder(@RequestBody SimulationOrder order) {
SimulationResponse response = new SimulationResponse();
Long minOrderPrice = Long.MAX_VALUE;
// 计算每一个优惠券的订单价格
for (CouponInfo coupon : order.getCouponInfos()) {
ShoppingCart cart = new ShoppingCart();
cart.setProducts(order.getProducts());
cart.setCouponInfos(Lists.newArrayList(coupon));
cart = couponProcessorFactory.getTemplate(cart).calculate(cart);
Long couponId = coupon.getId();
Long orderPrice = cart.getCost();
// 设置当前优惠券对应的订单价格
response.getCouponToOrderPrice().put(couponId, orderPrice);
// 比较订单价格,设置当前最优优惠券的ID
if (minOrderPrice > orderPrice) {
response.setBestCouponId(coupon.getId());
minOrderPrice = orderPrice;
}
}
return response;
}
}
在上面的源码中,我们看到,优惠券结算方法不用关心订单上使用的优惠券是满减券还是打折券,因为工厂方法会将子类转为顶层接口 RuleTemplate 返回。在写代码的过程中,我们也要有这样一种意识,就是尽可能对上层业务屏蔽其底层业务复杂度,底层具体业务逻辑的修改对上层是无感知的,这其实也是开闭原则的思想。
完成 Service 层后,我们接下来新建一个 CouponCalculationController 类,对外暴露 2 个 POST 接口,第一个接口完成订单优惠价格计算,第二个接口完成优惠券价格试算。
路径:
coupon-calculation-serv\coupon-calculation-impl\src\main\java\com\yinyu\coupon\calculation\controller\CouponCalculationController.java
@Slf4j
@RestController
@RequestMapping("calculator")
public class CouponCalculationController {
@Autowired
private CouponCalculationService calculationService;
// 优惠券结算
@PostMapping("/checkout")
@ResponseBody
public ShoppingCart calculateOrderPrice(@RequestBody ShoppingCart settlement) {
log.info("do calculation: {}", JSON.toJSONString(settlement));
return calculationService.calculateOrderPrice(settlement);
}
// 优惠券列表挨个试算
// 给客户提示每个可用券的优惠额度,帮助挑选
@PostMapping("/simulate")
@ResponseBody
public SimulationResponse simulate(@RequestBody SimulationOrder simulator) {
log.info("do simulation: {}", JSON.toJSONString(simulator));
return calculationService.simulateOrder(simulator);
}
}
好了,现在你已经完成了所有业务逻辑的源码。最后一步画龙点睛,你还需要为 coupon-calculation-impl 应用创建一个 Application 启动类并添加 application.yml 配置项。因为它并不需要访问数据库,所以你不需要在配置文件或者启动类注解上添加 spring-data 的相关内容。
到这里,我们就完成了优惠计算服务的搭建工作,你可以到代码仓库中查看完整的 coupon-calculation-serv 源码实现。
4 搭建 coupon-customer-serv 模块
coupon-customer-serv 是一个服务于用户的子模块,它的结构和 coupon-template-serv 一样,包含了 API 层、DAO 层和业务逻辑层。它实现了用户领券、用户优惠券查找和订单结算功能。为了简化业务逻辑,我在源码里省略了“用户注册”等业务功能,使用 userId 来表示一个已注册的用户。
4.1 编写 coupon-customer-serv 依赖项
路径:coupon-customer-serv\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>yinyu-coupon</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-customer-serv</artifactId>
<packaging>pom</packaging>
<modules>
<module>coupon-customer-api</module>
<module>coupon-customer-dao</module>
<module>coupon-customer-impl</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
</project>
4.2 搭建 coupon-customer-api 子模块
按照惯例,我们先从 API 层开始搭建,搭建 coupon-customer-api 的过程非常简单。
① 补充 customer-api 依赖项
首先,我们需要把 coupon-template-api 和 coupon-calculation-api 这两个服务的依赖项添加到 coupon-customer-api 的 pom 依赖中,这样一来 customer 服务就可以引用到这两个服务的 Request 和 Response 对象了。
路径:coupon-customer-serv\coupon-customer-api\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-customer-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-customer-api</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-calculation-api</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
② 编写 customer-api 请求参数封装类
接下来,我们在 API 子模块中创建一个 RequestCoupon 类,作为用户领取优惠券的请求参数,通过传入用户 ID 和优惠券模板 ID,用户可以领取一张由指定模板打造的优惠券。另一个类是 SearchCoupon,用来封装优惠券查询的请求参数。
package com.yinyu.coupon.customer.api.beans;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RequestCoupon {
// 用户领券
@NotNull
private Long userId;
// 券模板ID
@NotNull
private Long couponTemplateId;
}
package com.yinyu.coupon.customer.api.beans;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SearchCoupon {
@NotNull
private Long userId;
private Long shopId;
private Integer couponStatus;
}
4.3 搭建 coupon-customer-dao 子模块
我在 DAO 子模块中创建了一个 Coupon 数据库实体对象用于保存用户领到的优惠券,并按照 spring-data-jpa 规范创建了一个 CouponDAO 接口用来提供 CRUD 操作。
① 补充 customer-dao 依赖项
路径:coupon-customer-serv\coupon-customer-dao\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-customer-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-customer-dao</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- DAO层理论上不应该依赖API层,我这里懒省事儿用了一套POJO从前到后传递 -->
<!-- 正确的做法是dao层定义自己的DTO,然后上层用一个converter再转化成API里的类 -->
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-customer-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.21</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
② 创建优惠券数据库对象
路径:coupon-customer-dao\src\main\java\com\yinyu\coupon\customer\dao\entity\Coupon.java
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "coupon")
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
// 对应的模板ID - 不使用one to one映射
// 不推荐使用级联查询的原因是为了防止滥用而导致的DB性能问题
@Column(name = "template_id", nullable = false)
private Long templateId;
// 所有者的用户ID
@Column(name = "user_id", nullable = false)
private Long userId;
// 冗余一个shop id方便查找
@Column(name = "shop_id")
private Long shopId;
// 优惠券的使用/未使用状态
@Column(name = "status", nullable = false)
@Convert(converter = CouponStatusConverter.class)
private CouponStatus status;
// 被Transient标记的属性是不会被持久化的
@Transient
private CouponTemplateInfo templateInfo;
// 获取时间自动生成
@CreatedDate
@Column(name = "created_time", nullable = false)
private Date createdTime;
}
③ 编写 customer-dao 接口类
public interface CouponDao extends JpaRepository<Coupon, Long> {
long countByUserIdAndTemplateId(Long userId, Long templateId);
}
在上面的源码中,我们只创建了一个接口用于 count 计算,至于其他增删改查功能则统一由父类 JpaRepository 一手包办了。spring-data-jpa 沿袭了 spring 框架的简约风,大道至简解放双手,整个 Spring 框架从诞生至今,也一直都在朝着不断简化的方向发展。到这里,coupon-customer-dao 层的代码就写完了,接下来我们去搞定最后一个子模块 coupon-customer-impl 业务逻辑层。
4.4 搭建 coupon-customer-impl 子模块
既然 coupon-customer-impl 需要调用 template 和 calculation 两个服务,在没有进入微服务化改造之前,我们只能先暂时委屈一下 template 和 calculation,将它俩作为 customer 服务的一部分,做成一个三合一的单体应用,之后单体应用会被拆分成独立的微服务模块。
① 补充 customer-impl 依赖项
将 template、calculation 的依赖项添加到 coupon-customer-impl 的配置文件中,注意这里我们添加的可不是 API 接口层的依赖,而是 Impl 接口实现层的依赖。
路径:coupon-customer-serv\coupon-customer-impl\pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>coupon-customer-serv</artifactId>
<groupId>com.yinyu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-customer-impl</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-customer-dao</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-calculation-impl</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>coupon-template-impl</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
③ 编写 customer-impl service 层
CouponCustomerService 是业务逻辑层的接口抽象,添加了几个方法,用来实现用户领券、查询优惠券、下单核销优惠券、优惠券试算等功能。
package com.yinyu.coupon.customer.service.intf;
// 用户对接服务
public interface CouponCustomerService {
// 领券接口
Coupon requestCoupon(RequestCoupon request);
// 核销优惠券
ShoppingCart placeOrder(ShoppingCart info);
// 优惠券金额试算
SimulationResponse simulateOrderPrice(SimulationOrder order);
void deleteCoupon(Long userId, Long couponId);
// 查询用户优惠券
List<CouponInfo> findCoupon(SearchCoupon request);
}
这里,我以 placeOrder 方法为例,带你走读一下它的源码。如果你对其它方法的源码感兴趣,可以到代码仓库找到 Spring Boot 急速落地篇的 CouponCustomerServiceImpl 类,查看源代码。
placeOrder 方法实现了用户下单 + 优惠券核销的功能,我们来看一下它的实现逻辑。
@Override
@Transactional
public ShppingCart placeOrder(ShppingCart order) {
// 购物车为空,丢出异常
if (CollectionUtils.isEmpty(order.getProducts())) {
log.error("invalid check out request, order={}", order);
throw new IllegalArgumentException("cart is empty");
}
Coupon coupon = null;
if (order.getCouponId() != null) {
// 如果有优惠券就把它查出来,看是不是属于当前用户并且可用
Coupon example = Coupon.builder().userId(order.getUserId())
.id(order.getCouponId())
.status(CouponStatus.AVAILABLE)
.build();
coupon = couponDao.findAll(Example.of(example)).stream()
.findFirst()
// 如果当前用户查不到可用优惠券,就抛出错误
.orElseThrow(() -> new RuntimeException("Coupon not found"));
// 优惠券有了,再把它的券模板信息查出
// 券模板里的Discount规则会在稍后用于订单价格计算
CouponInfo couponInfo = CouponConverter.convertToCoupon(coupon);
couponInfo.setTemplate(templateService.loadTemplateInfo(coupon.getTemplateId()));
order.setCouponInfos(Lists.newArrayList(couponInfo));
}
// 调用calculation服务使用优惠后的订单价格
ShppingCart checkoutInfo = calculationService.calculateOrderPrice(order);
if (coupon != null) {
// 如果优惠券没有被结算掉,而用户传递了优惠券,报错提示该订单满足不了优惠条件
if (CollectionUtils.isEmpty(checkoutInfo.getCouponInfos())) {
log.error("cannot apply coupon to order, couponId={}", coupon.getId());
throw new IllegalArgumentException("coupon is not applicable to this order");
}
log.info("update coupon status to used, couponId={}", coupon.getId());
coupon.setStatus(CouponStatus.USED);
couponDao.save(coupon);
}
return checkoutInfo;
}
在上面的源码中,我们看到 Coupon 对象的构造使用了 Builder 链式编程的风格,这是得益于在 Coupon 类上面声明的 Lombok 的 Builder 注解,只用一个 Builder 注解就能享受链式构造的体验。
④ 编写 customer-impl Controller 层
搞定了业务逻辑层后,接下来轮到 Controller 部分了,我在 CouponCustomerController 中对外暴露了几个服务,这些服务调用 CouponCustomerServiceImpl 中的方法实现各自的业务逻辑。
package com.yinyu.coupon.customer.controller;
@Slf4j
@RestController
@RequestMapping("coupon-customer")
public class CouponCustomerController {
@Autowired
private CouponCustomerService customerService;
@PostMapping("requestCoupon")
public Coupon requestCoupon(@Valid @RequestBody RequestCoupon request) {
return customerService.requestCoupon(request);
}
// 用户删除优惠券
@DeleteMapping("deleteCoupon")
public void deleteCoupon(@RequestParam("userId") Long userId,
@RequestParam("couponId") Long couponId) {
customerService.deleteCoupon(userId, couponId);
}
// 用户模拟计算每个优惠券的优惠价格
@PostMapping("simulateOrder")
public SimulationResponse simulate(@Valid @RequestBody SimulationOrder order) {
return customerService.simulateOrderPrice(order);
}
// ResponseEntity - 指定返回状态码 - 可以作为一个课后思考题
@PostMapping("placeOrder")
public ShoppingCart checkout(@Valid @RequestBody ShoppingCart info) {
return customerService.placeOrder(info);
}
// 实现的时候最好封装一个search object类
@PostMapping("findCoupon")
public List<CouponInfo> findCoupon(@Valid @RequestBody SearchCoupon request) {
return customerService.findCoupon(request);
}
}
⑤ 编写 customer-impl 启动类
以上,就是所有的业务逻辑代码部分了。接下来你只需要完成启动类和配置文件,就可以启动项目做测试了。我先来带你看一下启动类的部分:
package com.yinyu.coupon.customer;
@SpringBootApplication
@EnableJpaAuditing
@ComponentScan(basePackages = {"com.yinyu"})
@EnableTransactionManagement
//用于扫描Dao @Repository
@EnableJpaRepositories(basePackages = {"com.yinyu"})
//用于扫描JPA实体类 @Entity,默认扫本包当下路径
@EntityScan(basePackages = {"com.geekbang"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
在上面的源码中,我们看到很多注解上都注明了 com.yinyu作为包路径。之所以这么做,是因为 Spring Boot 的潜规则是将当前启动类类所在 package 作为扫包路径。
如果你的 Application 在 com.yinyu.customer 下,而你在项目中又需要加载来自 com.yinyu.template 下的类资源,就必须额外声明扫包路径,否则只有在 com.yinyu.customer 和其子路径之下的资源才会被加载。
关于配置项的部分,你可以直接把 coupon-template-impl 的配置文件 application.yml 照搬过来,不过,要记得把里面配置的 spring.application.name 改成 coupon-customer-serv。
好,到这里,我们优惠券平台项目的 Spring Boot 版本就搭建完成了。现在,coupon-customer-serv 已经成了一个三合一的单体应用,你只要在本地启动这一个应用,就可以调用 customer、template 和 calculation 三个服务的功能。
5 总结
我们来回顾一下这两节 Spring Boot 实战课的重点内容。通过这两节课,我带你搭建了完整的 Spring Boot 版优惠券平台的三个子模块。为了让项目结构更加清晰,我用分层设计的思想将每个模块拆分成 API 层、DAO 层和业务层。在搭建过程中,我们使用 spring-data-jpa 搞定了数据层,短短几行代码就能实现复杂的 CRUD 操作;使用 spring-web 搭建了 Controller 层,对外暴露了 RESTFul 风格的接口。
接下来我们将进入 Spring Cloud 基础的学习,将学习使用 Nacos、Loadbalancer 和 OpenFeign 组件来搭建基于微服务架构的跨服务调用。