【云岚到家】-day05-2-预约下单-系统开发
- 1 预约下单系统开发
- 1.1 订单号生成规则
- 1.1.1 常见的订单号生成规则
- 1.1.2 本项目订单号生成规则
- 1.1.3 代码实现
- 1.2 下单接口实现
- 1.2.1 Mapper实现
- 1.2.2 Service实现
- 1.2.3 Controller
- 1.2.4 测试
- 1.3 下单代码优化
- 1.3.1 事务方法存在远程调用问题
- 1.3.2 事务失效问题
- 1.3.2.1 测试事务失效问题
- 1.3.2.2 解决事务失效问题
- 1.3.3 循环依赖问题
- 1.3.3.1 什么是循环依赖
- 1.3.3.2 Spring 如何解决循环依赖
- 1.3.3.3 构造参数注入解决循环依赖问题
- 1.3.3.4 分析本项目自已注入自己没有循环依赖的原因
- 1.3.4 Service方法事务失效的原因是什么?
- 1.3.5 Spring 如何解决循环依赖?
1 预约下单系统开发
我们还是接着day05-1的步骤继续开发
1.1 订单号生成规则
1.1.1 常见的订单号生成规则
- 自增数字序列
使用数据库的自增主键或者其他递增的数字序列(比如redis的INCR命令)作为订单号的一部分。例如,订单号可以是"202310280001",其中"20231028"表示日期,"0001"是自增的订单序号。
- 时间戳+随机数
将年月日时分秒和一定范围内的随机数组合起来。例如,订单号可以是"20181028124523" + “1234”,其中"20181028124523"表示日期和时间,"1234"是随机生成的数字。
使用时间戳+随机数作为主键有重复的风险。
- 订单类型+日期+序号
将订单类型(例如"01"表示普通订单,“02"表示VIP订单等)、日期和序号组合起来。例如,订单号可以是"0101028100001”,其中"01"表示订单类型,"20181028"表示日期,"00001"是序号。
加上订单类型的好处是方便客户服务,根据订单号就可以知道订单的类型。
- 分布式唯一ID生成器
使用分布式唯一ID生成器(例如Snowflake雪花算法)生成全局唯一的ID作为订单号。这种方法保证了在分布式系统中生成的订单号的唯一性和有序性。
Snowflake 算法根据机器ID、时间戳、序号等因素生成,保证全局唯一性,它的优势在于生成的 ID 具有趋势递增、唯一性、高效性等特点.
Snowflake 算法对系统时钟的依赖性较强,如果系统时钟发生回拨,可能会导致 ID 生成出现问题。因此,在使用 Snowflake 算法时,需要定时进行时钟同步,确保系统时钟的稳定性。
1.1.2 本项目订单号生成规则
19位:2位年+2位月+2位日+13位序号
例如:2311011000000000001
实现方案:
1、前6位通过当前时间获取,即 DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd")
。
2、后13位通过Redis的INCR命令实现,即 redisTemplate.opsForValue().increment
。
1.1.3 代码实现
定义订单管理的接口,在com.jzo2o.orders.manager.service.impl.OrdersCreateServiceImpl中
@Slf4j
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
@Resource
private RedisTemplate<String, Long> redisTemplate;
/**
* 生成订单id 格式:{yyMMdd}{13位id}
*
* @return
*/
private Long generateOrderId() {
//通过Redis自增序列得到序号
Long id = redisTemplate.opsForValue().increment(ORDERS_SHARD_KEY_ID_GENERATOR, 1);
//生成订单号 2位年+2位月+2位日+13位序号
long orderId = DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd") * 10000000000000L + id;
return orderId;
}
...
1.2 下单接口实现
1.2.1 Mapper实现
下单接口向orders表插入一条记录,使用Mybatis-Plus生成的Service类或Mapper接口即可实现,不用单独定义Mapper接口。
1.2.2 Service实现
@Slf4j
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
@Resource
private CustomerClient customerClient;
@Resource
private FoundationsClient foundationsClient;
@Resource
private RedisTemplate<String, Long> redisTemplate;
/**
* 生成订单id 格式:{yyMMdd}{13位id}
*
* @return
*/
private Long generateOrderId() {
//通过Redis自增序列得到序号
Long id = redisTemplate.opsForValue().increment(RedisConstants.Lock.ORDERS_SHARD_KEY_ID_GENERATOR, 1);
//生成订单号 2位年+2位月+2位日+13位序号
long orderId = DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd") * 10000000000000L + id;
return orderId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//1.生成订单
//1.1 订单基本信息
//1.2 下单人信息-远程调用jzo2o-customer服务
AddressBookResDTO customerDetail = customerClient.getDetail(placeOrderReqDTO.getAddressBookId());
//1.3 订单服务信息-远程调用jzo2o-foundations服务
ServeAggregationResDTO foundationsDetail = foundationsClient.getDetail(placeOrderReqDTO.getServeId());
//1.4 生成订单id
Long orderId = generateOrderId();
//1.5 订单基础信息
Orders orders = new Orders();
//1.5.0 下单人id
orders.setUserId(UserContext.currentUserId());
//1.5.1 设置订单id
orders.setId(orderId);
//1.5.2 设置服务类型id
orders.setServeTypeId(foundationsDetail.getServeTypeId());
//1.5.3 设置服务类型名称
orders.setServeTypeName(foundationsDetail.getServeTypeName());
//1.5.4 设置服务项id
orders.setServeItemId(foundationsDetail.getServeItemId());
//1.5.5 设置服务项名称
orders.setServeItemName(foundationsDetail.getServeItemName());
//1.5.6 设置服务项图片
orders.setServeItemImg(foundationsDetail.getServeItemImg());
//1.5.7 设置服务单位
orders.setUnit(foundationsDetail.getUnit());
//1.5.8 设置服务id
orders.setServeId(placeOrderReqDTO.getServeId());
//1.5.9 设置订单状态
orders.setOrdersStatus(OrderStatusEnum.NO_PAY.getStatus());
//1.5.10 设置支付状态
orders.setPayStatus(OrderPayStatusEnum.NO_PAY.getStatus());
//1.6 订单价格信息
//1.6.1 单价
orders.setPrice(foundationsDetail.getPrice());
//1.6.1 购买数量
orders.setPurNum(placeOrderReqDTO.getPurNum());
//1.6.2 总价
BigDecimal totalAmount = foundationsDetail.getPrice().multiply(new BigDecimal(placeOrderReqDTO.getPurNum()));
orders.setTotalAmount(totalAmount);
//1.6.3 优惠金额
orders.setDiscountAmount(BigDecimal.ZERO);
//1.6.4 实付金额
orders.setRealPayAmount(NumberUtils.sub(orders.getTotalAmount(), orders.getDiscountAmount()));
//1.7 服务地址信息
//1.7.1 设置服务cityCode
orders.setCityCode(foundationsDetail.getCityCode());
//1.7.2 设置服务地址
String serveAddr = new StringBuffer()
.append(customerDetail.getProvince())
.append(customerDetail.getCity())
.append(customerDetail.getCounty())
.append(customerDetail.getAddress())
.toString();
orders.setServeAddress(serveAddr);
//1.7.3 设置服务人电话
orders.setContactsPhone(customerDetail.getPhone());
//1.7.4 设置服务人姓名
orders.setContactsName(customerDetail.getName());
//1.8 服务其他信息
//1.8.1 设置服务开始时间
orders.setServeStartTime(placeOrderReqDTO.getServeStartTime());
//1.8.2 经纬度
orders.setLon(customerDetail.getLon());
orders.setLat(customerDetail.getLat());
//1.8.3 排序字段
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
//2 插入数据库
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
return new PlaceOrderResDTO(orders.getId());
}
}
如果插入失败要记得回滚 @Transactional(rollbackFor = Exception.class)
1.2.3 Controller
@RestController("consumerOrdersController")
@Api(tags = "用户端-订单相关接口")
@RequestMapping("/consumer/orders")
public class ConsumerOrdersController {
@Resource
private IOrdersManagerService ordersManagerService;
@Resource
private IOrdersCreateService ordersCreateService;
@ApiOperation("下单接口")
@PostMapping("/place")
public PlaceOrderResDTO place(@RequestBody PlaceOrderReqDTO placeOrderReqDTO) {
PlaceOrderResDTO orderResDTO = ordersCreateService.placeOrder(placeOrderReqDTO);
return orderResDTO;
}
1.2.4 测试
测试流程:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
打开小程序进行下单。
打开小程序,进入首页,点击一个服务进行预约下单。
预期结果:下单成功向orders表写入一条记录,注意观察数据的正确性。
打开小程序,切换到上海
点击我们的码农洗车,进入详情页
点击立即预约后填写上门时间
点击立即预约,查看控制台和数据库
控制台已经显示插入
查看数据库
已经插入,测试成功
1.3 下单代码优化
1.3.1 事务方法存在远程调用问题
在事务方法中存在远程调用是否有问题?
下单方法中远程调用查询地址簿信息和服务信息,远程调用涉及网络传输,如果网络传输时间过长会增加数据库事务的时长,如果并发高会把数据库的链接消耗殆尽,导致系统不能正常工作。
将保存订单的代码移动add方法中,add方法只保存订单,去掉placeOrder方法上的@Transactional
注解,在add方法上添加@Transactional
注解,优化如下:
在com.jzo2o.orders.manager.service.IOrdersCreateService中添加add方法用于保存订单:
public interface IOrdersCreateService extends IService<Orders> {
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO);
/**
* 生成订单
*
* @param orders
*/
void add(Orders orders);
}
实现:
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
}
修改service的代码,即com.jzo2o.orders.manager.service.impl.OrdersCreateServiceImpl#placeOrder,修改事务的控制粒度
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//1.远程调用获取订单相关信息
//1.1 下单人信息-远程调用jzo2o-customer服务
AddressBookResDTO customerDetail = customerClient.getDetail(placeOrderReqDTO.getAddressBookId());
if (customerDetail == null) {
throw new BadRequestException("预约地址异常,无法下单");
}
//1.2 订单服务信息-远程调用jzo2o-foundations服务
ServeAggregationResDTO foundationsDetail = foundationsClient.getDetail(placeOrderReqDTO.getServeId());
if (foundationsDetail == null || foundationsDetail.getSaleStatus() != 2) {
throw new BadRequestException("服务不可用");
}
//2 下单前数据准备
Orders orders = new Orders();
//2.1 生成订单id
Long orderId = generateOrderId();
//2.2 下单人id
orders.setUserId(UserContext.currentUserId());
//2.3 设置订单id
orders.setId(orderId);
//2.4 设置服务类型id
orders.setServeTypeId(foundationsDetail.getServeTypeId());
//2.5 设置服务类型名称
orders.setServeTypeName(foundationsDetail.getServeTypeName());
//2.6 设置服务项id
orders.setServeItemId(foundationsDetail.getServeItemId());
//2.7 设置服务项名称
orders.setServeItemName(foundationsDetail.getServeItemName());
//2.8 设置服务项图片
orders.setServeItemImg(foundationsDetail.getServeItemImg());
//2.9 设置服务单位
orders.setUnit(foundationsDetail.getUnit());
//2.10 设置服务id
orders.setServeId(placeOrderReqDTO.getServeId());
//2.11 设置订单状态
orders.setOrdersStatus(OrderStatusEnum.NO_PAY.getStatus());
//2.12 设置支付状态
orders.setPayStatus(OrderPayStatusEnum.NO_PAY.getStatus());
//3 订单价格信息
//3.1 单价
orders.setPrice(foundationsDetail.getPrice());
//3.2 购买数量
orders.setPurNum(placeOrderReqDTO.getPurNum());
//3.3 总价
BigDecimal totalAmount = foundationsDetail.getPrice().multiply(new BigDecimal(placeOrderReqDTO.getPurNum()));
orders.setTotalAmount(totalAmount);
//3.4 优惠金额
orders.setDiscountAmount(BigDecimal.ZERO);
//3.5 实付金额
orders.setRealPayAmount(NumberUtils.sub(orders.getTotalAmount(), orders.getDiscountAmount()));
//4 服务地址信息
//4.1 设置服务cityCode
orders.setCityCode(foundationsDetail.getCityCode());
//4.2 设置服务地址
String serveAddr = new StringBuffer()
.append(customerDetail.getProvince())
.append(customerDetail.getCity())
.append(customerDetail.getCounty())
.append(customerDetail.getAddress())
.toString();
orders.setServeAddress(serveAddr);
//4.3 设置服务人电话
orders.setContactsPhone(customerDetail.getPhone());
//4.4 设置服务人姓名
orders.setContactsName(customerDetail.getName());
//5 服务其他信息
//5.1 设置服务开始时间
orders.setServeStartTime(placeOrderReqDTO.getServeStartTime());
//5.2 经纬度
orders.setLon(customerDetail.getLon());
orders.setLat(customerDetail.getLat());
//5.3 排序字段
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
//6 插入数据库
this.add(orders);
//7 返回结果
return new PlaceOrderResDTO(orders.getId());
}
1.3.2 事务失效问题
1.3.2.1 测试事务失效问题
现在对优化后的代码进行测试,测试add方法是否可以进行事务控制,在add方法中添加异常代码:
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
//测试事务失效
int i=1/0;
}
如果事务可以被控制,当抛出异常数据库事务进行回滚,最终保存订单失败。
经过测试发现事务控制失败,当add方法抛出异常数据库事务并没有回滚,订单信息保存成功。
1.3.2.2 解决事务失效问题
这里为什么会事务失效呢?
首先要明白Spring进行事务控制是通过代理对象进行的,在调用add方法之前开启事务,方法执行结束提交事务。
如下图所示:
跟踪add方法调用代码,如下图:
并不是通过代理对象执行的add方法。
如果是在placeOrder方法上加@Transactional
就可以进行事务控制,暂时先在placeOrder方法上添加@Transactional
注解,我们跟踪placeOrder方法调用处的代码,在controller方法中调用的placeOrder方法,打断点,如下图:
的确是通过CGLIB代理对象调用的placeOrder方法。
执行完成后控制台抛出了异常,事务被控制,订单数据没有保存成功。
为什么调用add方法处不是通过代理对象调用呢?
下图展示了代理对象与原始对象之间的关系图:
代理对象调用原始对象的placeOrder方法,在placeOrder方法中通过this.add()调用add方法,this就是原始对象本身并不是代理对象。
如何解决呢?
在OrdersCreateServiceImpl注入OrdersCreateServiceImpl的代理对象,通过代理对象去调用add方法.
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
//将自己的代理对象注入
@Resource
private IOrdersCreateService owner;
...
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
...
//保存订单
owner.add(orders);
return new PlaceOrderResDTO(orders.getId());
}
重启订单管理服务继续进行测试,在owner.add(orders);打断点:
从上图可以看出这次是通过代理对象调用的add方法。
方法执行完成事务控制成功,add方法抛出异常,事务回滚,订单保存失败,符合预期结果。
可以参考我的手写Java系列中事务传播原理的7.6中如何正确的调用代理对象的test2(),【吃透Java手写】2-Spring(下)-AOP-事务及传播原理
1.3.3 循环依赖问题
通过将自己注入自己,使用代理对象调用add方法解决了事务失效问题,这样不会产生循环依赖吗?
1.3.3.1 什么是循环依赖
在 Spring 中,如果一个 bean 尝试将自身引用注入到自身中,通常会引发循环依赖。
首先搞清楚什么是循环依赖:
两个Bean,A依赖B,B依赖A就构成了循环依赖,如下图:
同样的道理,如果在A中注入A表示A依赖A,也就构成了循环依赖。
1.3.3.2 Spring 如何解决循环依赖
以上图为例说明Spring是如何处理循环依赖问题的?
首先按照常规的流程是:
创建A实例–》初始化A–》注入B–》创建B实例–》初始化B–》注入A
在初始化A时需要注入B,要注入B就需要创建B实例再初始化B,而在初始B时需要注入A,此时A还没有创建完成就陷入死循环。
针对循环依赖的问题Spring会上边的过程调整为下边的流程:
创建A实例–》创建B实例–》在B中注入A–》B初始化—》在A中注入B–》A初始化。
Spring是如何做到呢?
Spring会延迟初始化,B需要注入A,此时Spring会先实例化A,把一个半成品A注入给B,延迟A的初始化。
具体的底层原理是Spring通过三级缓存实现:
1)singletonObjects缓存:这是 Spring 容器用来缓存完全初始化好的单例bean 实例的缓存。当一个 bean 初始化完成后,它会被放入singletonObjects缓存中。这个缓存是单例 bean 的最终缓存,也是 BeanFactory 中保存 bean 的主要缓存。
2)earlySingletonObjects缓存:这个缓存是用来保存被实例化但还未完全初始化的 bean 的引用。当一个 bean 已经被实例化(但还未初始化)时,它会被放入earlySingletonObjects缓存中。
3)singletonFactories缓存:这个缓存保存的是用于创建 bean 实例的 ObjectFactory,用于支持循环依赖的延迟初始化。当一个 bean 被实例化,但尚未完全初始化时,Spring 会在singletonFactories缓存中查找该 bean 的ObjectFactory。这个ObjectFactory会在需要时被调用来完成 bean 的初始化。
Spring 通过这三级缓存的组合,来确保在循环依赖情况下,能够正常初始化 bean。当两个或多个 bean 之间存在循环依赖时,Spring 使用 singletonFactories 缓存来存储 bean 的提供者(ObjectFactory)。当一个 bean 在初始化过程中需要依赖另一个还未初始化的 bean 时,Spring 会调用相应的 ObjectFactory 来获取对应的 bean 实例,这样就实现了循环依赖的延迟初始化。一旦 bean 初始化完成,它就会被移动到singletonObjects缓存中。
举例:
创建A实例–》创建B实例–》在B中注入A–》B初始化—》在A中注入B–》A初始化。
创建A实例(半成品),在earlySingletonObjects放入A半成品。
创建B实例(半成品),在earlySingletonObjects放入B半成品。
在B中注入A,通过singletonFactories拿到A的对象工厂,通过对象工厂拿到A的半成品注入到B中。
B初始化完成,将B从earlySingletonObjects移动到singletonObjects。
A初始化完成,将A从earlySingletonObjects移动到singletonObjects。
1.3.3.3 构造参数注入解决循环依赖问题
虽然Spring可以解决上边通过成员变量注入引发的循环依赖问题,但是通过构造参数注入引发的循环依赖问题是会报错。
为什么上图中的循环依赖会报错呢?
因为创建C需要调用构造方法,而构造方法需要依赖D,此时C是无法实例化的,上边分析Spring解决循环依赖是通过延迟初始化,当出现循环依赖问题可以注入一个半成品,而这里连半成品都无法创建成功。
如何解决这种通过构造参数注入导致的循环依赖问题呢?
可以在C或D的任意一方注入另一方的代理对象而不是注入原始对象,如下:
假设在C的构造方法中注入D的代理对象可以写为:
在构造参数前加@Lazy注解,表示注入D的代理对象。
public C(@Lazy D d){
...
}
1.3.3.4 分析本项目自已注入自己没有循环依赖的原因
我们在OrdersCreateServiceImpl 中注入的是OrdersCreateServiceImpl 的代理对象,并不是OrdersCreateServiceImpl 本身实例,构不成循环依赖。
即使向OrdersCreateServiceImpl 注入的是本身实例也不会报错,Spring通过三级缓存解决循环依赖,会先向成员变量注入一个半成品实例,而后再完成初始化,过程如下:
创建A实例–>向A注入自己–>完成A初始化
1.3.4 Service方法事务失效的原因是什么?
- 在方法中捕获了异常没有抛出去,没有把异常抛给代理对象,代理对象捕捉不到异常没有进行事务回滚
- 非事务方法内部调用事务方法,不是通过代理对象去调用
@Transactional
标记的方法不是public\- 抛出的异常与rollbackFor指定的异常不匹配,默认rollbackFor指定的异常为RuntimeException
- 数据库表不支持事务,比如MySQL的MyISAM
- Spring的传播行为导致事务失效,比如:PROPAGATION_NEVER、PROPAGATION_NOT_SUPPORTED
PROPAGATION_NOT_SUPPORTED
—— 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。PROPAGATION_NEVER
—— 以非事务方式执行,如果当前存在事务,则抛出异常。
1.3.5 Spring 如何解决循环依赖?
Spring通过三级缓存对Bean延迟初始化解决循环依赖。
具体如下:
-
singletonObjects缓存:这是 Spring 容器用来缓存完全初始化好的单例 bean 实例的缓存。
-
earlySingletonObjects缓存:这个缓存是用来保存被实例化但还未完全初始化的 bean (半成品)的引用。
-
singletonFactories缓存:这个缓存保存的是用于创建 bean 实例的 ObjectFactory,用于支持循环依赖的延迟初始化。
Spring 通过这三级缓存的组合,来确保在循环依赖情况下,能够正常初始化 bean。当一个 bean 在初始化过程中需要依赖另一个还未初始化的 bean 时,Spring 会调用相应的 对象工厂来获取对应的 bean 半成品实例,这样就实现了循环依赖的延迟初始化。一旦 bean 初始化完成,它就会被移动到正式的单例缓存中。
对于通过构造方法注入导致循环依赖的在其中一个类的构造方法中使用@Lazy注解注入一个代理对象即可解决。