上一节课我们搭建了coupon-template-serv模块,实现了优惠券模板的创建和批量查询等功能,相信你已经对如何使用Spring Boot搭建应用驾轻就熟了。今天我们就来搭建优惠券平台项目的另外两个模块,coupon-calculation-serv(优惠计算服务)和coupon-customer-serv(用户服务),组建一个完整的实战项目应用(middleware模块将在Spring Cloud环节进行搭建)。

通过今天的课程,你可以巩固并加深Spring Boot的实操能力,为接下来Spring Cloud微服务化改造打好前置知识的基础,在这节课里我也会分享一些关于设计模式和数据冗余的经验之谈。

另外,这节课的源码都可以在Gitee代码库中找到。你可不要只读爽文不动手敲代码,我建议你把代码下载到本地,对照着源码动手练习一遍,才能学为己用。

闲话少叙,我们根据优惠券项目的依赖关系,先从上游服务coupon-calculation-serv开始动手搭建吧。

1.搭建coupon-calculation-serv

coupon-calculation-serv提供了用于计算订单的优惠信息的接口,它是一个典型的“计算密集型”服务。所谓计算密集型服务一般具备下面的两个特征:

  • 1.不吃网络IO和磁盘空间;
  • 2.运行期主要占用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-api这个接口层子模块开始搭建吧。

1.1.搭建coupon-calculation-api

如果coupon-calculation-serv需要计算订单的优惠价格,那就得知道当前订单用了什么优惠券。封装了优惠券信息的Java类CouponInfo位于coupon-template-api包下,因此我们需要把coupon-template-api的依赖项加入到coupon-calculation-api中。

<?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.geekbang</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>

添加好了依赖项之后,接下来我们定义用于封装订单信息的ShoppingCart类。

package com.geekbang.coupon.calculation.api.beans;

import com.geekbang.coupon.template.api.beans.CouponInfo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;

// 封装了订单信息
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ShoppingCart {

    @NotEmpty
    private List<Product> products;

    private Long couponId;

    private long cost;

    // 目前只支持单张优惠券
    // 但是为了以后的扩展考虑,你可以添加多个优惠券的计算逻辑
    private List<CouponInfo> couponInfos;

    @NotNull
    private Long userId;

}

在上面的源码中,我们看到ShoppingCart订单类中使用了Product对象,来封装当前订单的商品列表。在Product类中包含了商品的单价、商品数量,以及当前商品的门店ID。

package com.geekbang.coupon.calculation.api.beans;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {

    // 你可以试着搭建一个商品中心,用来存储商品信息,逐步完善这个应用
    private Long productId;

    // 商品的价格
    private long price;

    // 商品在购物车里的数量
    private Integer count;

    // 商品销售的门店
    private Long shopId;

}

在电商领域中,商品的数量通常不能以Integer整数来表示,这是因为只有标品才能以整数计件。对于像蔬菜、肉类等非标品来说,它们的计件单位并不是“个”。所以在实际项目中,尤其是零售行业的业务系统里,计件单位要允许小数位的存在。而我们的实战项目为了简化业务,就假定所有商品都是“标品”了。

在下单的时候,你可能有多张优惠券可供选择,你需要通过“价格试算”来模拟计算每张优惠券可以扣减的金额,进而选择最优惠的券来使用。SimulationOrder和SimulationResponse分别代表了“价格试算”的订单类,以及返回的计算结果Response。我们来看一下这两个类的源码。

// 优惠券价格试算
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SimulationOrder {

    @NotEmpty
    private List products;

    @NotEmpty
    private List couponIDs;

    private List couponInfos;

    @NotNull
    private Long userId;
}

// 订单试算结果,可以看出哪个优惠券的优惠力度最大
@Data
@NoArgsConstructor
public class SimulationResponse {
    // 最省钱的coupon
    private Long bestCouponId;
    // 每一个coupon对应的order价格
    private Map couponToOrderPrice = Maps.newHashMap();
}

到这里,coupon-calculation-api模块就搭建好了。因为calculation服务不需要访问数据库,所以我们就不用搭建dao模块了,直接来实现coupon-calculation-impl业务层的代码逻辑。

1.2.搭建coupon-calculation-impl

首先,我们在coupon-calculation-impl的pom.xml文件中添加下面的三个依赖项。

<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>

从coupon-template-api和coupon-calculation-api两个依赖项中,你可以拿到订单优惠计算过程用到的POJO对象。接下来,我们可以动手实现优惠计算逻辑了。

在搭建优惠计算业务逻辑的过程中,我运用了模板设计模式来封装计算逻辑。模板模式是一种基于抽象类的设计模式,它的思想很简单,就是将共性的算法骨架部分上升到抽象层,将个性部分延迟到子类中去实现。

优惠券类型有很多种,比如满减券、打折券、随机立减等等,这些券的计算流程(共性部分)是相同的,但具体的计算规则(个性部分)是不同的。我将共性的部分抽象成了AbstractRuleTemplate抽象类,将各个券的差异性计算方式做成了抽象类的子类。

让我们看一下计算逻辑的类结构图。

商城系统 优惠券设计 场景 spring java java实现优惠券功能_微服务


在这张图里,顶层接口RuleTemplate定义了calculate方法,抽象模板类AbstractRuleTemplate将通用的模板计算逻辑在calculate方法中实现,同时它还定义了一个抽象方法calculateNewPrice作为子类的扩展点。各个具体的优惠计算类通过继承AbstractRuleTemplate,并实现calculateNewPrice来编写自己的优惠计算方式。

我们先来看一下AbstractRuleTemplate抽象类的代码,走读calculate模板方法中的计算逻辑实现。

public ShoppingCart calculate(ShoppingCart order) {
    // 获取订单总价
    Long orderTotalAmount = getTotalPrice(order.getProducts());
    // 获取以shopId为维度的总价统计
    Map sumAmount = getTotalPriceGroupByShop(order.getProducts());
    CouponTemplateInfo template = order.getCouponInfos().get(0).getTemplate();
    // 最低消费限制
    Long threshold = template.getRule().getDiscount().getThreshold();
    // 优惠金额或者打折比例
    Long quota = template.getRule().getDiscount().getQuota();
    // 如果优惠券未指定shopId,则shopTotalAmount=orderTotalAmount
    // 如果指定了shopId,则shopTotalAmount=对应门店下商品总价
    Long shopId = template.getShopId();
    Long shopTotalAmount = (shopId == null) ? orderTotalAmount : sumAmount.get(shopId);
    
    // 如果不符合优惠券使用标准, 则直接按原价走,不使用优惠券
    if (shopTotalAmount == null || shopTotalAmount < threshold) {
        log.debug("Totals of amount not meet");
        order.setCost(orderTotalAmount);
        order.setCouponInfos(Collections.emptyList());
        return order;
    }
    // 子类中实现calculateNewPrice计算新的价格
    Long newCost = calculateNewPrice(orderTotalAmount, shopTotalAmount, quota);
    if (newCost < minCost()) {
        newCost = minCost();
    }
    order.setCost(newCost);
    log.debug("original price={}, new price={}", orderTotalAmount, newCost);
    return order;
}

在上面的源码中,我们看到大部分计算逻辑都在抽象类中做了实现,子类只要实现calculateNewPrice方法完成属于自己的订单价格计算就好。我们以满减规则类为例来看一下它的实现。

@Slf4j
@Component
public class MoneyOffTemplate extends AbstractRuleTemplate implements RuleTemplate {
    @Override
    protected Long calculateNewPrice(Long totalAmount, Long shopAmount, Long quota) {
        // benefitAmount是扣减的价格
        // 如果当前门店的商品总价

在上面的源码中,我们看到子类业务的逻辑非常简单清爽。通过模板设计模式,我在抽象类中封装了共性逻辑,在子类中扩展了可变逻辑,每个子类只用关注自己的特定实现即可,使得代码逻辑变得更加清晰,大大降低了代码冗余。

随着业务发展,你的优惠券模板类型可能会进一步增加,比如赠品券、随机立减券等等,如果当前的抽象类无法满足新的需求,你可以通过建立多级抽象类的方式进一步增加抽象层次,不断将共性不变的部分抽取为抽象层。

创建完优惠计算逻辑,我们接下来看一下Service层的代码实现逻辑。Service层的calculateOrderPrice代码非常简单,通过CouponTemplateFactory工厂类获取到具体的计算规则,然后调用calculate计算订单价格就好了。simulate方法实现了订单价格试算,帮助用户在下单之前了解每个优惠券可以扣减的金额,从而选出最省钱的那个券。