乐优商城:笔记(十五):订单微服务:LyOrderApplication
文章目录
1 创建订单微服务
1.1 分析
当我们在购物车种选择好商品时,会选择右下角的结算按钮去下单:
会跳转到订单结算页,并不是直接去付款:
因此此处页面需要渲染的内容主要包含3部分:
- 收货人信息
- 支付方式
- 商品信息
这部分渲染已经在前端实现了
1.2 服务搭建
1.2.1 创建module
1.2.2 引入依赖
<?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>leyou</artifactId> <groupId>com.leyou.parent</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>com.leyou.service</groupId> <artifactId>ly-order</artifactId> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> < 4000 span class="token tag"><groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>com.leyou.service</groupId> <artifactId>ly-item-interface</artifactId> <version>${leyou.latest.version}</version> </dependency> <dependency> <groupId>com.leyou.common</groupId> <artifactId>ly-common</artifactId> <version>${leyou.latest.version}</version> </dependency> <dependency> <groupId>com.leyou.service</groupId> <artifactId>ly-auth-common</artifactId> <version>${leyou.latest.version}</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency><dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>com.github.wxpay</groupId> <artifactId>wxpay-sdk</artifactId> <version>0.0.3</version> </dependency> </dependencies> </project>
1.2.3 配置文件
server: port: 8089 spring: application: name: order-service datasource: url: jdbc:mysql://localhost:3306/yun6 username: root password: 123 driver-class-name: com.mysql.jdbc.Driver jackson: default-property-inclusion: non_null eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka registry-fetch-interval-seconds: 5 instance: prefer-ip-address: true ip-address: 127.0.0.1 mybatis: type-aliases-package: com.leyou.order.pojo ly: jwt: pubKeyPath: H:/javacode/idea/rsa/rsa.pub # 公钥地址 cookieName: LY_TOKEN
1.2.4 启动类
@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients @MapperScan("com.leyou.order.mapper") public class LyOrderApplication { public static void main(String[] args) { SpringApplication.run(LyOrderApplication.class); } }
1.2.5 配置路由
1.2.6 其他配置
订单微服务和购物车微服务有共同特征,都需要解析token,进行鉴权,知道当前登陆的用户是谁,所以应该把购物车微服务中的
JwtProperties,
UserInterceptor,
MvcConfig在订单微服务中准备一份,结构如下:
注:以上代码其实可以抽取到
ly-common中,减少代码重复,但是不用担心拦截器会无条件生效,只有配置了
MvcConfig拦截器才会生效
2 订单结算页
2.1 数据结构
注:
- 订单数据的量级是非常大的,所以需要数据库分表、分库,此时的分表指的是水平分表,就是每张表字段是一样的,所以传统的主键自增长就不适用了,需要采用一个全局唯一的ID redis的主键自增长策略,可以实现全局唯一ID,但是生成速度有限
- 雪花算法
2.2 实体类
@Data @Table(name = "tb_order") public class Order { @Id private Long orderId;// id private Long totalPay;// 总金额 private Long actualPay;// 实付金额 private Integer paymentType; // 支付类型,1、在线支付,2、货到付款 private String promotionIds; // 参与促销活动的id private Long postFee = 0L;// 邮费 private Date createTime;// 创建时间 private String shippingName;// 物流名称 private String shippingCode;// 物流单号 private Long userId;// 用户id private String buyerMessage;// 买家留言 private String buyerNick;// 买家昵称 private Boolean buyerRate;// 买家是否已经评价 private String receiver; // 收货人全名 private String receiverMobile; // 移动电话 private String receiverState; // 省份 private String receiverCity; // 城市 private String receiverDistrict; // 区/县 private String receiverAddress; // 收货地址,如:xx路xx号 private String receiverZip; // 邮政编码,如:310001 private Integer invoiceType = 0;// 发票类型,0无发票,1普通发票,2电子发票,3增值税发票 private Integer sourceType = 1;// 订单来源 1:app端,2:pc端,3:M端,4:微信端,5:手机qq端 @Transient private OrderStatus orderStatus; @Transient private List<OrderDetail> orderDetails; }
@Data @Table(name = "tb_order_detail") public class OrderDetail { @Id @KeySql(useGeneratedKeys = true) private Long id; private Long orderId;// 订单id private Long skuId;// 商品id private Integer num;// 商品购买数量 private String title;// 商品标题 private Long price;// 商品单价 private String ownSpec;// 商品规格数据 private String image;// 图片 }
@Data @Table(name = "tb_order_status") public class OrderStatus { @Id private Long orderId; private Integer status; private Date createTime;// 创建时间 private Date paymentTime;// 付款时间 private Date consignTime;// 发货时间 private Date endTime;// 交易结束时间 private Date closeTime;// 交易关闭时间 private Date commentTime;// 评价时间 }
2.2 提交订单
当我们在订单详情页确认好地址商品等信息无误后,点击右下角的提交订单:
发现报错了,不过没关系,因为这个接口我们没有实现,现在我们去实现
2.2.1 接口分析
- 请求方式:post
- 请求路径:order
- 返回结果: 订单ID,因为创建订单之后下一步就是要付款,付款需要知道为哪个订单进行付款,所以我们返回当前订单的ID
- 请求参数:
可以看到请求参数结构比较复杂,付款状态和地址我们传的都是Long类型的,但是购物车信息却是一个json,因此将这些信息封装成一个DTO(data transfer object)类:
@Data @AllArgsConstructor @NoArgsConstructor public class OrderDTO { @NotNull private Long addressId; // 收获人地址id private Integer paymentType;// 付款类型 private List<CartDTO> carts;// 订单详情,carts又是一个集合 包含了商品信息,所以又定义一个cartDTO }
@Data @NoArgsConstructor @AllArgsConstructor public class CartDTO { private Long skuId; // 商品skuId private Integer num; // 购买数量 }
之前订单详情页上我们发现商品的价格标题参数等信息都有但是为什么这里我们只传递ID这一个参数呢?这是因为安全问题,因为我们的URL是对外暴露的,防止有人利用insomnia等工具修改价格,之后再进行订单的提交,造成损失
2.2.2 mapper
OrderMapper:
public interface OrderMapper extends BaseMapper<Order> { }
OrderDetailMapper:
public interface OrderDetailMapper extends BaseMapper<OrderDetail> { }
OrderStatusMapper:
public interface OrderStatusMapper extends Mapper<OrderStatus>{ }
2.2.3 controller
@RestController @RequestMapping("order") public class OrderController { @Autowired private OrderService orderService; @PostMapping public ResponseEntity<Long> createOrder(@RequestBody OrderDTO orderDTO){ return ResponseEntity.ok(orderService.createOrder(orderDTO)); } }
@RequestBody:
- 将json字符串中的值赋予被注解的类中对应的属性上
- 将请求体中的json字符串绑定到相应的字符串上
参考资料:@requestBody注解的使用
2.2.4 service
生成ID的方式——雪花算法
订单id的特殊性
订单数据非常庞大,将来一定会做分库分表。那么这种情况下, 要保证id的唯一,就不能靠数据库自增,而是自己来实现算法,生成唯一id。
雪花算法
这里的订单id是通过一个工具类生成的:
而工具类所采用的生成id算法,是由Twitter公司开源的snowflake(雪花)算法。
简单原理
雪花算法会生成一个64位的二进制数据,为一个Long型。(转换成字符串后长度最多19) ,其基本结构:
第一位:为未使用
第二部分:41位为毫秒级时间(41位的长度可以使用69年)
第三部分:5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)
第四部分:最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
snowflake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和workerId作区分),并且效率较高。经测试snowflake每秒能够产生26万个ID。
配置
为了保证不重复,我们给每个部署的节点都配置机器id:
ly: worker: workerId: 1 datacenterId: 1
加载属性:
@Data @ConfigurationProperties(prefix = "ly.worker") public class IdWorkerProperties { private long workerId;// 当前机器id private long dataCenterId;// 序列号 }
编写配置类:
@Configuration @EnableConfigurationProperties(IdWorkerProperties.class) public class IdWorkerConfig { @Bean public IdWorker idWorker(IdWorkerProperties prop) { return new IdWorker(prop.getWorkerId(), prop.getDataCenterId()); } }
使用:
直接@Autowired注入,然后使用
准备物流、收货人信息:
前端页面传递过来的是addressId,我们需要根据这个id查询物流信息,但是因为没有做物流地址管理,所以我们准备一些假数据:
@Data public class AddressDTO { private Long id; private String name; private String phone; private String state; private String city; private String district; private String address; private String zipCode; private Boolean isDefault; }
然后准备一个常量类:
public abstract class AddressClient { public static final List<AddressDTO> addressList = new ArrayList<AddressDTO>(){ { AddressDTO address = new AddressDTO(); address.setId(1L); address.setAddress("太白南路"); address.setCity("西安"); address.setDistrict("雁塔区"); address.setName("max"); address.setPhone("15656789999"); address.setState("陕西"); address.setZipCode("7100710"); address.setIsDefault(true); add(address); AddressDTO address2 = new AddressDTO(); address2.setId(2L); address2.setAddress("学院路三号"); address2.setCity("太原"); address2.setDistrict("尖草坪区"); address2.setName("su"); address2.setPhone("15656781314"); address2.setState("山西"); address2.setZipCode("03500150"); address2.setIsDefault(false); add(address2); } }; public static AddressDTO findById(Long id){ for (AddressDTO addressDTO : addressList) { if(addressDTO.getId() == id){ return addressDTO; } } return null; } }
结构:
然后我们就可以在service中调用,根据id查到收货人信息
订单状态:
订单状态一般以数字表示状态,但是不容易理解,所以我们创建一个枚举:
public enum OrderStatusEnum { UN_PAY(1, "初始化,未付款"), PAYED(2, "已付款,未发货"), DELIVERED(3, "已发货,未确认"), SUCCESS(4, "已确认,未评价"), CLOSED(5, "已关闭,交易失败"), RATED(6, "已评价,交易结束") ; private int code; private String msg; OrderStatusEnum(int code, String msg) { this.code = code; this.msg = msg; } public int value(){ return this.code; } public String msg(){ return msg; } }
减库存:
减库存应该是在属于商品微服务的业务,因此我们写一个接口,在商品微服务那边实现:
GoodsClient:
@FeignClient("item-service") public interface GoodsClient extends GoodsApi{ }
GoodsApi:
@PostMapping("stock/decrease") void decreaseStock(@RequestBody List<CartDTO> cartDTOS);
GoodsController:
@PostMapping("stock/decrease") public ResponseEntity<Void> decreaseStock(@RequestBody List<CartDTO> cartDTOS){ goodsService.decreaseStock(cartDTOS); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); }
分析:减库存的业务实现
减库存可以采用同步调用(Feign的方式),也可以采用异步调用(RabbitMQ传递消息),我们这里采用同步调用,接下来我们分析为什么
如果我们采用异步调用的方式,减库存的这条消息发送到MQ就不管了,那么到底库存减成功了没有呢?这我们并不知道,如果库存不足,那么我们减库存失败,但是service的业务不会回滚,这个问题就是分布式事务问题,即跨服务的事务。减库存这个业务从订单微服务跨越到了商品微服务,而事务是由Spring来管理的,两套tomcat两套Spring,本身没有任何关联,但是却是一个事务,如果采用异步,这边的微服务执行失败另一边的微服务并不知道,破坏了事务的一致性,我们解决的方案是什么呢?
变异步调用为同步调用,如果一个微服务执行失败就会抛出异常,事务自然回滚(减库存的操作只能放在创建订单业务的最后,因为减库存执行失败事务自然回滚订单也不会创建成功,但是如果上来就先减库存,那玩意订单创建失败库存无法回滚),但是这种方案也不是最优的,因为我们没做优惠券功能,当我们做了优惠券功能,那计算优惠和减库存哪个放在最后呢?哪个放在最后都不可行,这时候就必须解决分布式事务问题了
解决分布式事务问题:
- 2PC(两阶段提交):第一阶段,事务开始执行发送一条消息给相关的微服务告诉它们这个业务要开始执行,执行完毕后返回一条消息,告诉这个微服务业务执行成功了没有;第二阶,如果上一阶段返回的消息是执行成功,那么再发送一条消息告诉所有微服务事务执行成功了,相关所有事务都可以提交了,如果第一阶段有一个微服务执行失败,则所有事务都回滚 缺点:实现复杂、事务执行过程数据锁定的范围太大了,在本业务未执行完毕之前,数据库相关的表都是锁定状态,因此这种处理方式性能较差,在高并发的业务中较少使用
-
缺点:解决了业务问题,但是使得业务变得复杂了,写一个业务必须写一个确定执行业务方法和一个补偿业务方法,除此之外还要考虑补偿方案的失败问题,当补偿方案也执行失败了呢,这时候就要考虑重试问题、人工介入问题
-
缺点:事务无法回滚,不合适减库存这个业务
综上,在电商行业中适用的还是TCC,虽然业务变得复杂了,但是行之有效;如果是转账业务,适合异步确保,转账业务只需要消息可靠就可以,执行时间晚一点也无妨,所以异步确保的关键点是消息的可靠
但是在我们这个小项目中,无需把业务变得这么复杂,接下来讨论我们采用的同步调用的解决方案。
同步调用中加锁实现方式:
- 先查询库存,然后if判断,库存足够就减库存 逻辑是对的,但是这么做有线程上的安全问题,当线程很多的时候,有可能引发超卖问题
-
性能太差了,只有一个线程可以执行,当搭了集群时synchronized只锁住了当前一个tomcat,看起来是可行的,但是在分布式系统下是不安全的
-
zookeeper是树结构,它利用节点的唯一性来实现,加了分布式锁以后,任何一个逻辑进入到减库存这个地方,都会创建一个节点,创建成功就认为得到了锁,继续执行代码;反之则失败,返回或者wait,因此只有一个人可以拿到这个锁,执行完毕后删除节点释放锁,其他人可以再次创建锁
-
原理类似于上述的 节点 ,只能set不存在的key,如果不存在则创建;如果存在它会set失败,并返回0,拿到锁以后可以使用del命令释放锁
但是这里不推荐加锁实现,因为用了锁,就变成单线程了,相当于一执行这段代码就把数据库锁死,同一时刻只能有一个人来操作,这样的实现类似于悲观锁,默认线程安全问题一定会发生,在面对高并发时,往往性能很差。
那既然不推荐悲观锁,是不是可以采用乐观锁呢?乐观锁是默认线程安全问题不会发生,不加锁,但是不加锁会有线程安全问题,那怎么处理这件事情呢?
——我们不做查询不做判断,业务执行到减库存代码这里之后直接开始减库存,唉?这不是会超卖吗?不要紧,我们的sql内部可以加条件来判断,失败则事务回滚,所有人不论怎么操作,最后都会来操作数据库,但是数据库写了判断语句来判断库存,每个人来执行都会被判断,本质上还是乐观锁。如果执行失败会反馈失败信息,而不像是悲观锁那样线程阻塞,导致一直等待,性能上来将,这种处理方式优于加锁,我们的sql语句如下:
"UPDATE tb_stock SET stock = stock - #{num} WHERE sku_id = #{id} AND stock >= #{num}"
StockMapper:
public interface StockMapper extends BaseMapper<Stock> { @Update("UPDATE tb_stock SET stock = stock - #{num} WHERE sku_id = #{id} AND stock >= #{num}") int decreaseStock(@Param("id") Long id, @Param("num") Integer num); }
GoodsService实现减库存:
@Transactional public void decreaseStock(List<CartDTO> cartDTOS) { for (CartDTO cartDTO : cartDTOS) { int count = stockMapper.decreaseStock(cartDTO.getSkuId(), cartDTO.getNum()); if(count != 1){ throw new LyException(ExceptionEnum.STOCK_NOT_ENOUGH); } } }
我们可以写一个测试类来测试:
可以看到库存只有1的时候执行失败,抛出异常,事务回滚,查询数据库,商品的库存还是1,不会发生超卖现象
最终的service:
@Transactional public Long createOrder(OrderDTO orderDTO) { // 1 新增订单 Order order = new Order(); // 1.1 订单编号,基本信息 -- 订单ID,雪花算法(snowflake)生成全局唯一的ID long orderId = idWorker.nextId(); order.setOrderId(orderId); order.setCreateTime(new Date()); order.setPaymentType(orderDTO.getPaymentType()); // 1.2 用户信息 UserInfo user = UserInterceptor.getUser(); order.setUserId(user.getId()); order.setBuyerNick(user.getUsername()); order.setBuyerRate(false); // 1.3 收货人地址信息 -- orderDTO中只有地址ID(addressID),要根据地址ID去数据库中查询(假数据) AddressDTO addr = AddressClient.findById(orderDTO.getAddressId()); order.setReceiver(addr.getName());//收货人 order.setReceiverMobile(addr.getPhone());//收货人手机号码 order.setReceiverAddress(addr.getAddress());//收货所在街道 order.setReceiverState(addr.getState());//收货人所在省 order.setReceiverCity(addr.getCity());//收货人所在城市 order.setReceiverDistrict(addr.getDistrict());//收货人所在区 order.setReceiverZip(addr.getZipCode());//收货人邮编 // 1.4 金额 Map<Long, Integer> numMap = orderDTO.getCarts() .stream().collect(Collectors.toMap(CartDTO::getSkuId,CartDTO::getNum)); Set<Long> ids = numMap.keySet(); List<Sku> skus = goodsClient.querySkuByIds(new ArrayList<>(ids)); // 准备orderDetail集合 List<OrderDetail> details = new ArrayList<>(); Long totalPrice = 0L; for (Sku sku : skus) { totalPrice += sku.getPrice() * numMap.get(sku.getId()); //封装orderDetail OrderDetail detail = new OrderDetail(); detail.setImage(StringUtils.substringBefore(sku.getImages(),",")); detail.setNum(numMap.get(sku.getId())); detail.setOrderId(orderId); detail.setOwnSpec(sku.getOwnSpec()); detail.setPrice(sku.getPrice()); detail.setSkuId(sku.getId()); detail.setTitle(sku.getTitle()); details.add(detail); } order.setTotalPay(totalPrice); order.setActualPay(totalPrice + order.getPostFee() - 0 );// 实付金额= 总金额 + 邮费 - 优惠金额 // 1.5 写入数据库 int count = orderMapper.insertSelective(order); if(count != 1){ log.error("[创建订单] 创建订单失败,orderID:{}", orderId); throw new LyException(ExceptionEnum.CREATE_ORDER_ERROR); } // 2 新增订单详情 count = orderDetailMapper.insertList(details); if(count != details.size()){ log.error("[创建订单] 创建订单失败,orderID:{}", orderId); throw new LyException(ExceptionEnum.CREATE_ORDER_ERROR); } // 3 新增订单状态 OrderStatus orderStatus = new OrderStatus(); orderStatus.setOrderId(orderId); orderStatus.setCreateTime(order.getCreateTime()); orderStatus.setStatus(OrderStatusEnum.UN_PAY.value()); count = orderStatusMapper.insertSelective(orderStatus); if(count != 1){ log.error("[创建订单] 创建订单失败,orderID:{}", orderId); throw new LyException(ExceptionEnum.CREATE_ORDER_ERROR); } // 4 减库存 -- 需要调用商品微服务,传递商品id和数量两个参数 List<CartDTO> cartDTOS = orderDTO.getCarts(); goodsClient.decreaseStock(cartDTOS); return orderId; }
点击提交订单跳转到订单支付页面
2.3 实现查询订单功能
当写完订单业务时,跳转到支付页面,可以看到没有订单编号、二维码、支付金额这三项,同时前台页面发起了一个请求:
2.3.1 controller
- 请求方式:get
- 请求路径:/order/订单编号
- 请求参数:订单编号
- 返回结果:order对象
@GetMapping("{id}") public ResponseEntity<Order> queryOrderById(@PathVariable("id") Long id){ return ResponseEntity.ok(orderService.queryOrderById(id)); }
2.3.2 service
我们实际需求中会有 订单状态 这一选项供用户查看,因此查询的时候不单单要实现订单的查询,还要查询订单的金额和订单的状态:
public Order queryOrderById(Long id) { Order order = orderMapper.selectByPrimaryKey(id); if (order == null) { throw new LyException(ExceptionEnum.ORDER_NOT_FOUND); } // 查询订单详情 OrderDetail detail = new OrderDetail(); detail.setOrderId(id); List<OrderDetail> orderDetails = orderDetailMapper.select(detail); if(CollectionUtils.isEmpty(orderDetails)){ throw new LyException(ExceptionEnum.ORDER_DETAIL_NOT_FOUNT); } order.setOrderDetails(orderDetails); // 查询订单状态 OrderStatus orderStatus = orderStatusMapper.selectByPrimaryKey(id); if(orderStatus == null){ throw new LyException(ExceptionEnum.ORDER_STATUS_NOT_FOUND); } order.setOrderStatus(orderStatus); return order; }
现在刷新页面,发现又发起了两个请求,同时二维码还没有生成:
可以看到二维码是根据URL生成的,接下来,我们去实现微信支付,见下一篇博客:笔记十六
- 乐优商城--服务(四) : 上传微服务(LyUploadApplication)
- 乐优商城--服务(三) : 商品微服务(LyItemApplication)--前半部分
- 乐优商城--服务(七) : 用户中心微服务(LyUserApplication)
- 乐优商城--服务(三) : 商品微服务(LyItemApplication)--后半部分
- 乐优商城--服务(一): 注册微服务(LyRegister)
- 网上图书商城项目学习笔记-020订单详情
- Sharepoint学习笔记—ECM系列--管理元数据服务应用Metadata Service Application
- ###Jsp+Servlet购物商城day04.3:订单模块Order实体设计失误。【Bean属性设计两大原则】-MyBeanUtils使用注意事项。
- 【JAVAWEB学习笔记】网上商城实战3:购物模块和订单模块
- **从零开始完成微服务项目乐优商城**
- 乐优商城--关于微服务的安全问题
- 【乐优商城】springcloud微服务-项目搭建
- 购物网第四阶段总结笔记2:订单页面 order_modify.aspx, order_confirm.aspx,order_ok.aspx页面
- angular学习笔记(十五)-module里的'服务'
- ###Jsp+Servlet购物商城day04.2:订单模块难点总结。【购物商城最大的难点:【封装一个Order对象】查询封装三张表】
- 乐优商城学习笔记八-商品管理(修改商品)
- web商城项目笔记-6.订单详情展示功能
- ###Jsp+Servlet购物商城day03.2:生成订单。重点笔记
- (十五)Java springcloud B2B2C o2o多用户商城 springcloud架构-commonservice-sso服务搭建(一)
- ###Jsp+Servlet购物商城day04.1:显示订单。【重中之重:查询3张表信息,封装到Order】