你好,我是姚秋辰。

今天我们来动手搭建优惠券平台的实战项目。为了让你体验从0到1的微服务改造过程,我们先使用Spring Boot搭建一个基础版的优惠券平台项目,等你学习到Spring Cloud的时候,我们就在这个项目之上做微服务化改造,将Spring Cloud的各个组件像添砖加瓦一样集成到项目里。

如果你没有太多Spring Boot的相关开发经验,通过今天的学习,你可以掌握如何通过Spring Boot组件快速落地一个项目。如果你之前了解过Spring Boot,那么今天的学习不仅可以起到温故知新的作用,你还可以从我分享的开发经验里得到一些启发。

03讲中,我们介绍了优惠券平台的功能模块。我们说过,在用户领取优惠券的过程当中,优惠券是通过券模板来生成的,因此,优惠券模板服务是整个项目的底层基础服务。今天咱就直接上手搭建这个服务模块:coupon-template-serv。不过在此之前,我们先来看看整体的项目结构是怎样搭建的。

搭建项目结构

我把整个优惠券平台项目从Maven模块管理的角度划分为了多个模块。

在顶层项目geekbang-coupon之下有四个子模块,我先来分别解释下它们的功能:

在大型的微服务项目里,每一个子模块通常都存放在独立的Git仓库中,为了方便你下载代码,我把所有模块的代码都打包放到了这个代码仓库里,你可以在这里找到课程各阶段对应的源代码。

在每一个以“-serv”结尾的业务子模块中,我从内部分层的角度对其做了进一步拆分,以我们今天要搭建的coupon-template-serv为例,它内部包含了三个子模块:

你会发现,我把coupon-template-api作为一个单独的模块,这样做的好处是:当某个上游服务需要获取coupon-template-serv的接口参数时,只要导入轻量级的coupon-template-api模块,就能够获取接口中定义的Request和Response的类模板,不需要引入多余的依赖项(比如Dao层或者Service层)

这就是开闭原则的应用,它使各个模块间的职责和边界划分更加清晰,降低耦合的同时也更加利于依赖管理。

搭建好项目的结构之后,接下来我们借助Maven工具将需要的依赖包导入到项目中。

添加Maven依赖项

这里你要注意一下,添加Maven依赖项需要遵循“从上到下”的原则,也就是从顶层项目geekbang-coupon开始,顺藤摸瓜直到coupon-template-serv下的子模块。首先,我们来看看顶层geekbang-coupon依赖项的编写。

编写geekbang-coupon依赖项

geekbang-coupon是整个实战项目的顶层项目,它不用操心具体的业务逻辑,只用完成一个任务:管理子模块和定义Maven依赖项的版本。这就像一个公司的大boss一样,只用制定方向战略,琐碎的业务就交给下面人(子模块)来办就好了。

那么顶层战略在哪里制定?其实就在pom.xml文件里,我们看一下geekbang-coupon的pom文件中都定义了哪些内容。

<!-- 已省略部分标签,完整内容请参考项目源代码 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.4.2</version>
</parent>
<groupId>com.geekbang</groupId>
<artifactId>geekbang-coupon</artifactId>
<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>
<dependencyManagement>
    <dependencies>
       <dependency>
          <groupId>org.apache.commons</groupId>
          <artifactId>commons-lang3</artifactId>
          <version>3.0</version>
       </dependency>
    <!-- 省略部分依赖项 -->
    </dependencies>
</dependencyManagement>

在pom文件里有以下三个重点标签。

  1. < parent >标签

在parent标签中我们指定了geekbang-coupon项目的“父级依赖”为spring-boot-starter-parent,这样一来,spring-boot-starter-parent里定义的Spring Boot组件版本信息就会被自动带到子模块中。这种做法也是大多数Spring Boot项目的通用做法,不仅降低了依赖项管理的成本,也不需要担心各个组件间的兼容性问题。

  1. < packaging >标签

maven的打包类型有三种:jar、war和pom。当我们指定packaging类型为pom时,意味着当前模块是一个“boss”,它只用关注顶层战略,即定义依赖项版本和整合子模块,不包含具体的业务实现。

  1. < dependencymanagement >标签

这个标签的作用和< parent >标签类似,两者都是将版本信息向下传递。dependencymanagement是boss们定义顶层战略的地方,我们可以在这里定义各个依赖项的版本,当子项目需要引入这些依赖项的时候,只用指定groupId和artifactId即可,不用管version里该写哪个版本。

完成了geekbang-coupon依赖项的编写,接下来我们看看coupon-template-serv依赖项的编写。

编写coupon-template-serv依赖项

coupon-template-serv是大boss下面的一个小头目,和geekbang-coupon一样,它的packaging类型也是pom。我们说过boss只用管顶层战略,因此coupon-temolate-serv的pom文件内容很简单,只是定义了父级项目和子模块。

<!-- 已省略部分标签,完整内容请参考项目源代码 -->
<parent>
    <artifactId>geekbang-coupon</artifactId>
    <groupId>com.geekbang</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>

我们已经把geekbang-coupon和coupon-template-serv两个父级项目的依赖项添加完毕,接下来就去搭建coupon-template-serv下面的三个子模块。

coupon-template-api模块存放了接口Request和Response的类模板,是另两个子模块需要依赖的公共类库,所以我就先从coupon-template-api开始项目构建。

搭建coupon-template-api模块

coupon-template-api模块是专门用来存放公共类的仓库,我把REST API接口的服务请求和服务返回对象的POJO类放到了里面。在微服务领域,将外部依赖的POJO类或者API接口层单独打包是一种通用做法,这样就可以给外部依赖方提供一个“干净”(不包含非必要依赖)的接口包,为远程服务调用(RPC)提供支持。

在coupon-template-api项目的pom文件中,我只添加了少量的“工具类”依赖,比如lombok、guava和validation-api包等通用组件,这些工具类用来帮助我们自动生成代码并提供一些便捷的功能特性,具体的依赖项你可以参考项目源码。

首先,我们需要定义一个用来表示优惠券类型的enum对象,在com.geekbang.coupon.template.api.enum包下创建一个名为CouponType的枚举类。

@Getter
@AllArgsConstructor
public enum CouponType {
    UNKNOWN("unknown", "0"),
    MONEY_OFF("满减券", "1"),
    DISCOUNT("打折", "2"),
    RANDOM_DISCOUNT("随机减", "3")
    LONELY_NIGHT_MONEY_OFF("晚间双倍优惠券", "4");
    
    private String description;
    // 存在数据库里的最终code
    private String code;
    
    public static CouponType convert(String code) {
        return Stream.of(values())
                .filter(bean -> bean.code.equalsIgnoreCase(code))
                .findFirst()
                .orElse(UNKNOWN);
    }
}

CouponType类定义了多个不同类型的优惠券,convert方法可以根据优惠券的编码返回对应的枚举对象。这里还有一个“Unknown”类型的券,它专门用来对付故意输错code的恶意请求。

作为一个骨灰级程序员,我会认为所有需要用户输入的信息都是不可靠的,并且需要对各种意外输入做拦截、防范,这就是“防御性编程”的思维。工作的时间越久,人往往会变得越怂(都是被各种故障吓大的)。

接下来,我们创建两个用来定义优惠券模板规则的类,分别是TemplateRule和Discount。我把它们放在com.geekbang.coupon.template.api.beans.rules包路径下。

TemplateRule包含了两个规则,一是领券规则,包括每个用户可领取的数量和券模板的过期时间;二是券模板的计算规则。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateRule {


    // 可以享受的折扣
    private Discount discount;
    
    // 每个人最多可以领券数量
    private Integer limitation;
    
    // 过期时间
    private Long deadline;
}

这里我强烈推荐你使用一键三连的lombok注解自动生成基础代码,它们分别是Data、NoArgsConstructor和AllArgsConstructor。其中,Data注解自动生成getter、setter、toString等方法,后两个注解分别生成无参构造器和全参构造器,省时省力省地盘。

TemplateRule中的Discount成员变量定义了使用优惠券的规则,代码如下。

public class Discount {


    // 对于满减券 - quota是减掉的钱数,单位是分
    // 对于打折券 - quota是折扣(以100表示原价),90就是打9折,  95就是95折
    // 对于随机立减券 - quota是最高的随机立减额
    // 对于晚间特别优惠券 - quota是日间优惠额,晚间优惠翻倍
    private Long quota;


    // 订单最低要达到多少钱才能用优惠券,单位为分
    private Long threshold;
}

从上面代码中可以看出,我使用Long来表示“金额”。对于境内电商行业来说,金额往往是以分为单位的,这样我们可以直接使用Long类型参与金额的计算,比如100就代表100分,也就是一块钱。这比使用Double到处转换BigDecimal省了很多事儿。

最后,我们在com.geekbang.coupon.template.api.beans包下创建一个名为CouponTemplateInfo的类,用来创建优惠券模板,代码如下:

// 已省略部分内容,完整内容请参考项目源代码
public class CouponTemplateInfo {
    private Long id;
    @NotNull
    private String name; // 优惠券名称
    @NotNull
    private String desc; // 优惠券描述
    
    @NotNull
    private String type;  // 优惠券类型(引用CouponType里的code)
    
    private Long shopId; // 优惠券适用门店 - 若无则为全店通用券
   
    @NotNull
    private TemplateRule rule; // 优惠券使用规则
    
    private Boolean available; // 当前模板是否为可用状态
}

在上面的代码中,我们应用了jakarta.validate-api组件的注解@NotNull,对参数是否为Null进行了校验。如果请求参数为空,那么接口会自动返回Bad Request异常。当然,jakarta组件还有很多可以用来做判定验证的注解,合理使用可以节省大量编码工作,提高代码可读性。

此外,你还会发现,CouponTemplateInfo内封装了优惠券模板的基本信息,我们可以把优惠券模板当做一个“模具”,每一张优惠券都经由模具来制造,被制造出来的优惠券则使用CouponInfo对象来封装。

CouponInfo对象包含了优惠券的模板信息、领券用户ID、适用门店ID等属性。除此之外,我还在源码中定义了用来实现分页查找的对象,如果你特别感兴趣,可以到项目源码中查看完整的类定义。

到这里我们就完成了coupon-template-api项目的搭建,下面我们开始搭建Dao层模块:coupon-template-dao。它主要负责和数据库的对接、读取。

搭建coupon-template-dao模块

首先,我们把必要的依赖项添加到coupon-template-dao项目中,比较关键的maven依赖项有以下几个。

接下来,我们在com.geekbang.coupon.template.dao.entity目录下创建了一个数据库实体对象的Java类:CouponTemplate。

// 完整内容请参考源代码
@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 = "description", nullable = false)
    private String description;


    // 适用门店-如果为空,则为全店满减券
    @Column(name = "shop_id")
    private Long shopId;
    
    // 优惠券类型
    @Column(name = "type", nullable = false)
    @Convert(converter = CouponTypeConverter.class)
    private CouponType 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包的标准注解,对数据库字段进行了映射,我挑几个关键注解说道一下。

这里我要补充一点,其实JPA也支持一对多、多对多的级联关系(ManyToOne、OneToOne等注解),但是你发现我并没有在项目中使用,原因是这些注解背后有很多隐患。过深的级联层级所带来的DB层压力可能会在洪峰流量下被急剧放大,而DB恰恰是最不抗压的一环。所以,我们很少在一些一二线大厂的超高并发项目中看到级联配置的身影。

我的经验是尽可能减少级联配置,用单表查询取而代之,如果一个查询需要join好几张表,最好的做法就通过重构业务逻辑来简化DB查询的复杂度。

最后,我们来到定义DAO的地方,借助Spring Data的强大功能,我们只通过接口名称就可以声明一系列的DB层操作。我们先来看一下CouponTemplateDao这个类的代码。

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查询以外,我们还可以使用三种不同的方式查询数据。

在CouponTemplateDao中,第一个方法findAllByShopId就是通过接口名查询的例子,jpa使用了一种约定大于配置的思想,你只需要把要查询的字段定义在接口的方法名中,在你发起调用时后台就会自动转化成可执行的SQL语句。构造方法名的过程需要遵循<起手式>By<查询字段><连接词>的结构。

以接口名查询的方式虽然很省事儿,但它面对复杂查询却力不从心,一来容易导致接口名称过长,二来维护起来也挺吃力的。所以,对于复杂查询,我们可以使用自定义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模块。

搭建coupon-template-impl模块

coupon-template-impl是coupon-template-serv下的一个子模块,也是实现业务逻辑的地方。从依赖管理的角度,它引入了coupon-template-api和coupon-template-dao两个内部依赖项到pom.xml。

当然,我们也需要加入几个外部依赖项,你可以参考项目的pom.xml源代码获取完整的依赖项列表。

首先,我们先来定义Service层的接口类:CouponTemplateService。在这个接口中,我们定义了优惠券创建、查找优惠券和修改优惠券可用状态的方法。

public interface CouponTemplateService {
    // 创建优惠券模板
    CouponTemplateInfo createTemplate(CouponTemplateInfo request);
    
    // 通过模板ID查询优惠券模板
    CouponTemplateInfo loadTemplateInfo(Long id);
    
    // 克隆券模板
    CouponTemplateInfo cloneTemplate(Long templateId);


    // 模板查询(分页)
    PagedCouponTemplateInfo search(TemplateSearchParams request);
    
    // 删除券模板
    void deleteTemplate(Long id);
    
    //批量读取模板
    Map<Long, CouponTemplateInfo> getTemplateInfoMap(Collection<Long> ids);
     
    // 完整方法列表请至源码查看    
}

由于这部分比较简单,就是通过CouponTemplateDao层来实现优惠券模板的增删改查,这里我就不展开介绍实现层代码了,你可以参考源码中的CouponTemplateServiceImpl类。

不过,我建议你不要直接copy源码,先尝试自己实现这几个Service方法,写完之后再和我的源码做比较,看一看有哪些可以改进的地方。

接下来,我们创建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);
    }
    
    // 搜索模板(支持分页查询)
    @PostMapping("/search")
    public PagedCouponTemplateInfo search(@Valid @RequestBody TemplateSearchParams request) {
        log.info("search templates, payload={}", request);
        return couponTemplateService.search(request);
    }
    
    // ... 完整代码请至源码查看
}

在这里,Controller类中的注解来自spring-boot-starter-web依赖项,通过这些注解将服务以RESTful接口的方式对外暴露。现在,我们来了解下上述代码里,服务寻址过程中的三个重要注解:

项目启动类是最后的代码部分,我们在com.geekbang.coupon.template下创建一个Application类作为启动程序的入口,并在这个类的头上安上SpringBoot的启动注解。

@SpringBootApplication
@EnableJpaAuditing
@ComponentScan(basePackages = {"com.geekbang"})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

SpringBootApplication注解会自动开启包路径扫描,并启动一系列的自动装配流程(AutoConfig)。在默认情况下,Spring Boot框架会扫描启动类所在package下的所有类,并在上下文中创建受托管的Bean对象,如果我们想加载额外的扫包路径,只用添加ComponentScan注解并指定path即可。

所有代码环节全部完工后,我们还剩最后的画龙点睛之笔:创建配置文件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对连接串中的参数有不同的要求。

如果你发现项目启动过程中抛出了MySQL连接报错,一定记得检查自己的MySQL版本,检查是否缺失了某些参数(比如MySQL 8.x版本下要求传入serverTimezone参数)。如果你本地安装的MySQL版本早于8.x系列,我推荐你重新安装和我一样的MySQL 8.0.27版本,这样就不会碰到兼容性问题了。

好,到这里,我们优惠券平台项目的第一个模块coupon-template-serv就搭建完成了,你可以在本地启动项目并通过Postman发起调用。我已经将Postman API集合上传到了这个Gitee源码库中的“资源文件”目录下,文件名为“Spring Boot阶段.postman_collection.json”,你可以导入到自己本地的Postman中使用。

现在,我们来回顾一下这节课的重点内容。

总结

今天我带你搭建了整个优惠券服务的整体项目结构,并且用Spring Boot快速落地了优惠券模板服务。如果你在自己的项目中还在使用繁琐的sql资源文件来操作数据库,不妨升级成coupon-template-dao中使用的spring-data-jpa来简化DB操作。spring-data-jpa的功能特性也折射出Spring框架的发展趋势:约定大于配置,且越来越轻量级。

在学习这节课的时候,我希望你不要只满足于把项目跑起来就万事大吉了,你还要做一些思考和总结沉淀,想一想如何能把课程中的一些技术点应用在自己的项目中。我在这节课分享了很多开发小技巧,比如防御性编程、代码自动生成、金额计算、如何简化数据校验、级联关系的误区等,这些都可以作为你的开发素材。

希望你能够动起手来,顺着这节课程的内容动手搭建整个服务,不要直接照搬源码本地执行一下就完事儿了,只有上手实际搭建项目我们才能了解技术细节、积累排查问题的经验。要知道,纸上得来终觉浅,绝知此事要躬行。

在下一节课中,我会带你搭建coupon-calculation-ser和coupon-customer-serv,构建一个完整的优惠券平台Spring Boot项目。

思考题

最后,请你思考一个问题:

级联查询很容易引发性能问题,你在自己的项目中遇到最复杂的SQL是什么?然后,请你进一步做个思考:如果这条SQL的调用量激增,你该如何进行优化?欢迎你“显摆”出来,我在留言区等你。

好啦,这节课就结束啦。也欢迎你把这节课分享给更多对Spring Cloud感兴趣的朋友。我是姚秋辰,我们下节课再见!

评论