赞
踩
之前我们学习的项目都是单体项目,可以满足小型项目或传统项目的开发。而在互联网时代,越来越多的一线互联网公司都在使用微服务技术。
从谷歌搜索指数来看,国内从自2016年底开始,微服务热度突然暴涨:
那么:
本文主要基于这个黑马商城这个单体项目来演示从单体架构到微服务架构的演变过程、分析其中存在的问题,以及微服务技术是如何解决这些问题的。你会发现每一个微服务技术都是在解决服务化过程中产生的问题,对于每一个微服务技术具体的应用场景和使用方式都会有更深层次的理解。
接下来我们就一起来揭开它的神秘面纱。
我们要完成的内容如下:
在课前资料中给大家提供了黑马商城项目的资料,我们需要先导入这个单体项目。不过需要注意的是,本篇及后续的微服务学习都是基于Centos7系统下的Docker部署,因此你必须做好一些准备:
如果你没有这样的Linux环境,或者不是Centos7的话,并且没有安装docker,也不会使用docker,那么这里有一篇参考文档:docker教程
建议按照上面的文档来搭建虚拟机环境,使用其它版本会出现一些环境问题,比较痛苦。
如果是学习过上面Docker内容的,虚拟机中已经有了商城项目及MySQL数据库了,可以先将整个项目移除。使用下面的命令:
cd /root
docker compose down
在课前资料提供好了MySQL的一个目录:
其中有MySQL的配置文件和初始化脚本:
我们将其复制到虚拟机的/root目录。如果/root下已经存在mysql目录则删除旧的,如果不存在则直接复制本地的
然后创建一个通用网络:
docker network create hm-net
使用下面的命令来安装MySQL:
docker run -d \
--name mysql \
-p 3306:3306 \
-e TZ=Asia/Shanghai \
-e MYSQL_ROOT_PASSWORD=123 \
-v /root/mysql/data:/var/lib/mysql \
-v /root/mysql/conf:/etc/mysql/conf.d \
-v /root/mysql/init:/docker-entrypoint-initdb.d \
--network hm-net\
mysql
此时,通过命令查看mysql容器:
docker ps
如图:
发现mysql容器正常运行。
注:图片中的dps命令是我设置的别名,等同于docker ps --format,可以简化命令格式。你可以参考Docker
的5.1.3小节来配置。
此时,如果我们使用MySQL的客户端工具连接MySQL,应该能发现已经创建了黑马商城所需要的表:
然后是Java代码,在资料提供了一个hmall目录:
将其复制到你的工作空间,然后利用Idea打开。
项目结构如下:
按下ALT + 8
键打开services窗口,新增一个启动项:
在弹出窗口中鼠标向下滚动,找到Spring Boot:
点击后应该会在services中出现hmall的启动项:
点击对应按钮,即可实现运行或DEBUG运行。
不过别着急!!
我们还需要对这个启动项做简单配置,在HMallApplication
上点击鼠标右键,会弹出窗口,然后选择Edit Configuration:
在弹出窗口中配置SpringBoot的启动环境为local:
点击OK配置完成。接下来就可以运行了!
启动完成后,试试看访问下 http://localhost:8080/hi 吧!
在资料中还提供了一个hmall-nginx
的目录:
其中就是一个nginx程序以及我们的前端代码,直接在windows下将其复制到一个非中文、不包含特殊字符
的目录下。然后进入hmall-nginx后,利用cmd启动即可:
# 启动nginx
start nginx.exe
# 停止
nginx.exe -s stop
# 重新加载配置
nginx.exe -s reload
# 重启
nginx.exe -s restart
特别注意:
nginx.exe 不要双击启动,而是打开cmd窗口,通过命令行启动。停止的时候也一样要是用命令停止。如果启动失败不要重复启动,而是查看logs目录中的error.log日志,查看是否是端口冲突。如果是端口冲突则自行修改端口解决。
启动成功后,访问http://localhost:18080,应该能看到我们的门户页面:
这一章我们从单体架构的优缺点来分析,看看开发大型项目采用单体架构存在哪些问题,而微服务架构又是如何解决这些问题的。
单体架构(monolithic structure):顾名思义,整个项目中所有功能模块都在一个工程中开发;项目部署时需要对所有模块一起编译、打包;项目的架构设计、开发模式都非常简单。
当项目规模较小时,这种模式上手快,部署、运维也都很方便,因此早期很多小型项目都采用这种模式。
但随着项目的业务规模越来越大,团队开发人员也不断增加,单体架构就呈现出越来越多的问题:
在上述问题中,前两点相信大家在实战过程中应该深有体会。对于第三点系统可用性问题,很多同学可能感触不深。接下来我们就通过黑马商城这个项目,给大家做一个简单演示。
首先,我们修改hm-service模块下的com.hmall.controller.HelloController
中的hello方法,模拟方法执行时的耗时:
接下来,启动项目,目前有两个接口是无需登录即可访问的:
http://localhost:8080/hi
http://localhost:8080/search/list
/search/list
是比较正常的,访问耗时在30毫秒左右。接下来,我们假设/h
i这个接口是一个并发较高的热点接口,我们通过Jemeter来模拟500个用户不停访问。在资料中已经提供了Jemeter的测试脚本:
导入Jemeter并测试:
这个脚本会开启500个线程并发请求http://localhost/hi
这个接口。由于该接口存在执行耗时(500毫秒),这就服务端导致每秒能处理的请求数量有限,最终会有越来越多请求积压,直至Tomcat资源耗尽。这样,其它本来正常的接口(例如/search/list
)也都会被拖慢,甚至因超时而无法访问了。
我们测试一下,启动测试脚本,然后在浏览器访问http://localhost:8080/search/list
这个接口,会发现响应速度非常慢:
如果进一步提高/hi
这个接口的并发,最终会发现/search/list
接口的请求响应速度会越来越慢。
可见,单体架构的可用性是比较差的,功能之间相互影响比较大。
当然,有同学会说我们可以做水平扩展。
此时如果我们对系统做水平扩展,增加更多机器,资源还是会被这样的热点接口占用,从而影响到其它接口,并不能从根本上解决问题。这也就是单体架构的扩展性差的一个原因。
而要想解决这些问题,就需要使用微服务架构了。
微服务架构,首先是服务化,就是将单体架构中的功能模块从单体应用中拆分出来,独立部署为多个服务。同时要满足下面的一些特点:
例如,黑马商城项目,我们就可以把商品、用户、购物车、交易等模块拆分,交给不同的团队去开发,并独立部署:
那么,单体架构存在的问题有没有解决呢?
综上所述,微服务架构解决了单体架构存在的问题,特别适合大型互联网项目的开发,因此被各大互联网公司普遍采用。大家以前可能听说过分布式架构,分布式就是服务拆分的过程,其实微服务架构正式分布式架构的一种最佳实践的方案。
当然,微服务架构虽然能解决单体架构的各种问题,但在拆分的过程中,还会面临很多其它问题。比如:
这些问题,我们在后续的学习中会给大家逐一解答。
微服务拆分以后碰到的各种问题都有对应的解决方案和微服务组件,而SpringCloud框架可以说是目前Java领域最全面的微服务组件的集合了。
而且SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。对于没有自研微服务组件能力的中小型企业,使用SpringCloud全家桶来实现微服务开发可以说是最合适的选择了!
https://spring.io/projects/spring-cloud/#overview
目前SpringCloud最新版本为2022.0.x版本,对应的SpringBoot版本为3.x版本,但它们全部依赖于JDK17,目前在企业中使用相对较少。
因此,我们推荐使用次新版本:Spring Cloud 2021.0.x
以及Spring Boot 2.7.x
版本。
另外,Alibaba的微服务产品SpringCloudAlibaba
目前也成为了SpringCloud组件中的一员,我们也会使用其中的部分组件。
在我们的父工程hmall中已经配置了SpringCloud以及SpringCloudAlibaba的依赖:
对应的版本:
这样,我们在后续需要使用SpringCloud或者SpringCloudAlibaba组件时,就无需单独指定版本了。
接下来,我们就一起将黑马商城这个单体项目拆分为微服务项目,并解决其中出现的各种问题。
首先,我们需要熟悉黑马商城项目的基本结构:
大家可以直接启动该项目,测试效果。不过,需要修改数据库连接参数,在application-local.yaml中:
hm:
db:
host: 192.168.150.101 # 修改为你自己的虚拟机IP地址
pw: 123 # 修改为docker中的MySQL密码
同时配置启动项激活的是local环境:
首先来看一下登录业务流程:
登录入口在com.hmall.controller.UserController
中的login
方法:
在首页搜索框输入关键字,点击搜索即可进入搜索列表页面:
该页面会调用接口:/search/list
,对应的服务端入口在com.hmall.controller.SearchController
中的search
方法:
这里目前是利用数据库实现了简单的分页查询。
在搜索到的商品列表中,点击按钮加入购物车,即可将商品加入购物车:
加入成功后即可进入购物车列表页,查看自己购物车商品列表:
同时这里还可以对购物车实现修改、删除等操作。
相关功能全部在com.hmall.controller.CartControlle
r中:
其中,查询购物车列表时,由于要判断商品最新的价格和状态,所以还需要查询商品信息,业务流程如下:
在购物车页面点击结算按钮,会进入订单结算页面:
点击提交订单,会提交请求到服务端,服务端做3件事情:
业务入口在com.hmall.controller.OrderController
中的createOrder
方法:
下单完成后会跳转到支付页面,目前只支持余额支付:
在选择余额支付这种方式后,会发起请求到服务端,服务端会立刻创建一个支付流水单,并返回支付流水单号到前端。
当用户输入用户密码,然后点击确认支付时,页面会发送请求到服务端,而服务端会做几件事情:
com.hmall.controller.PayController
中:服务拆分一定要考虑几个问题:
一般情况下,对于一个初创的项目,首先要做的是验证项目的可行性。因此这一阶段的首要任务是敏捷开发,快速产出生产可用的产品,投入市场做验证。为了达成这一目的,该阶段项目架构往往会比较简单,很多情况下会直接采用单体架构,这样开发成本比较低,可以快速产出结果,一旦发现项目不符合市场,损失较小。
如果这一阶段采用复杂的微服务架构,投入大量的人力和时间成本用于架构设计,最终发现产品不符合市场需求,等于全部做了无用功。
所以,对于大多数小型项目来说,一般是先采用单体架构,随着用户规模扩大、业务复杂后再逐渐拆分为微服务架构。这样初期成本会比较低,可以快速试错。但是,这么做的问题就在于后期做服务拆分时,可能会遇到很多代码耦合带来的问题,拆分比较困难 (前易后难)。
而对于一些大型项目,在立项之初目的就很明确,为了长远考虑,在架构设计时就直接选择微服务架构。虽然前期投入较多,但后期就少了拆分服务的烦恼(前难后易)。
之前我们说过,微服务拆分时粒度要小,这其实是拆分的目标。具体可以从两个角度来分析:
高内聚首先是单一职责,但不能说一个微服务就一个接口,而是要保证微服务内部业务的完整性为前提。目标是当我们要修改某个业务时,最好就只修改当前微服务,这样变更的成本更低。
一旦微服务做到了高内聚,那么服务之间的耦合度自然就降低了。
当然,微服务之间不可避免的会有或多或少的业务交互,比如下单时需要查询商品数据。这个时候我们不能在订单服务直接查询商品数据库,否则就导致了数据耦合。而应该由商品服务对应暴露接口,并且一定要保证微服务对外接口的稳定性(即:尽量保证接口外观不变)。虽然出现了服务间调用,但此时无论你如何在商品服务做内部修改,都不会影响到订单微服务,服务间的耦合度就降低了。
明确了拆分目标,接下来就是拆分方式了。我们在做服务拆分时一般有两种方式:
所谓纵向拆分,就是按照项目的功能模块来拆分。例如黑马商城中,就有用户管理功能、订单管理功能、购物车功能、商品管理功能、支付功能等。那么按照功能模块将他们拆分为一个个服务,就属于纵向拆分。这种拆分模式可以尽可能提高服务的内聚性。
而横向拆分,是看各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务。例如用户登录是需要发送消息通知,记录风控数据,下单时也要发送短信,记录风控数据。因此消息发送、风控数据记录就是通用的业务功能,因此可以将他们分别抽取为公共服务:消息中心服务、风控管理服务。这样可以提高业务的复用性,避免重复开发。同时通用业务一般接口稳定性较强,也不会使服务之间过分耦合。
当然,由于黑马商城并不是一个完整的项目,其中的短信发送、风控管理并没有实现,这里就不再考虑了。而其它的业务按照纵向拆分,可以分为以下几个微服务:
接下来,我们先把商品管理功能、购物车功能抽取为两个独立服务。
一般微服务项目有两种不同的工程结构:
注意: 为了学习方便,我们会采用Maven聚合工程,大家以后到了企业,可以根据需求自由选择工程结构。
在hmall父工程之中,我已经提前定义了SpringBoot、SpringCloud的依赖版本,所以为了方便期间,我们直接在这个项目中创建微服务module.
在hmall中创建module:
选择maven模块,并设定JDK版本为11:
商品模块,我们起名为item-service
:
引入依赖:
<?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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>item-service</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--common--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!--单元测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
编写启动类:
代码如下:
package com.hmall.item;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.hmall.item.mapper")
@SpringBootApplication
public class ItemApplication {
public static void main(String[] args) {
SpringApplication.run(ItemApplication.class, args);
}
}
接下来是配置文件,可以从hm-service
中拷贝:
其中,application.yaml
内容如下:
server: port: 8081 spring: application: name: item-service profiles: active: dev datasource: url: jdbc:mysql://${hm.db.host}:3306/hm-item?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: ${hm.db.pw} mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: update-strategy: not_null id-type: auto logging: level: com.hmall: debug pattern: dateformat: HH:mm:ss:SSS file: path: "logs/${spring.application.name}" knife4j: enable: true openapi: title: 商品服务接口文档 description: "信息" email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.hmall.item.controller
剩下的application-dev.yaml
和application-local.yaml
直接从hm-service
拷贝即可。
然后拷贝hm-service
中与商品管理有关的代码到item-service
,如图:
这里有一个地方的代码需要改动,就是ItemServiceImpl
中的deductStock
方法:
改动前:
改动后:
这也是因为ItemMapper的所在包发生了变化,因此这里代码必须修改包路径。
最后,还要导入数据库表。默认的数据库连接的是虚拟机,在你docker数据库执行提供的SQL文件:
最终,会在数据库创建一个名为hm-item的database,将来的每一个微服务都会有自己的一个database:
注意:在企业开发的生产环境中,每一个微服务都应该有自己的独立数据库服务,而不仅仅是database,学习中我们用database来代替。
接下来,就可以启动测试了,在启动前我们要配置一下启动项,让默认激活的配置为local
而不是dev
:
在打开的编辑框填写active profiles
:
接着,启动item-service
,访问商品微服务的swagger接口文档:http://localhost:8081/doc.html
然后测试其中的根据id批量查询商品这个接口:
测试参数:100002672302,100002624500,100002533430,结果如下:
说明商品微服务抽取成功了。
与商品服务类似,在hmall下创建一个新的module,起名为cart-service
:
然后是依赖:
<?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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>cart-service</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--common--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!--单元测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
然后是启动类:
package com.hmall.cart;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}
然后是配置文件,同样可以拷贝自item-service,不过其中的application.yaml需要修改:
server: port: 8082 spring: application: name: cart-service profiles: active: dev datasource: url: jdbc:mysql://${db.host}:3306/hm-cart?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: ${db.pw} mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: update-strategy: not_null id-type: auto logging: level: com.hmall: debug pattern: dateformat: HH:mm:ss:SSS file: path: "logs/${spring.application.name}" knife4j: enable: true openapi: title: 商品服务接口文档 description: "信息" email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.hmall.cart.controller
最后,把hm-service中的与购物车有关功能拷贝过来,最终的项目结构如下:
特别注意的是com.hmall.cart.service.impl.CartServiceImpl,其中有两个地方需要处理:
我们对这部分代码做如下修改:
package com.hmall.cart.service.impl; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.hmall.cart.domain.dto.CartFormDTO; import com.hmall.cart.domain.po.Cart; import com.hmall.cart.domain.vo.CartVO; import com.hmall.cart.mapper.CartMapper; import com.hmall.cart.service.ICartService; import com.hmall.common.exception.BizIllegalException; import com.hmall.common.utils.BeanUtils; import com.hmall.common.utils.CollUtils; import com.hmall.common.utils.UserContext; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.Collection; import java.util.List; /** * <p> * 订单详情表 服务实现类 * </p> * * @author 虎哥 * @since 2023-05-05 */ @Service @RequiredArgsConstructor public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService { // private final IItemService itemService; @Override public void addItem2Cart(CartFormDTO cartFormDTO) { // 1.获取登录用户 Long userId = UserContext.getUser(); // 2.判断是否已经存在 if (checkItemExists(cartFormDTO.getItemId(), userId)) { // 2.1.存在,则更新数量 baseMapper.updateNum(cartFormDTO.getItemId(), userId); return; } // 2.2.不存在,判断是否超过购物车数量 checkCartsFull(userId); // 3.新增购物车条目 // 3.1.转换PO Cart cart = BeanUtils.copyBean(cartFormDTO, Cart.class); // 3.2.保存当前用户 cart.setUserId(userId); // 3.3.保存到数据库 save(cart); } @Override public List<CartVO> queryMyCarts() { // 1.查询我的购物车列表 List<Cart> carts = lambdaQuery().eq(Cart::getUserId, 1L /*TODO UserContext.getUser()*/).list(); if (CollUtils.isEmpty(carts)) { return CollUtils.emptyList(); } // 2.转换VO List<CartVO> vos = BeanUtils.copyList(carts, CartVO.class); // 3.处理VO中的商品信息 handleCartItems(vos); // 4.返回 return vos; } private void handleCartItems(List<CartVO> vos) { // 1.获取商品id TODO 处理商品信息 /*Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2.查询商品 List<ItemDTO> items = itemService.queryItemByIds(itemIds); if (CollUtils.isEmpty(items)) { throw new BadRequestException("购物车中商品不存在!"); } // 3.转为 id 到 item的map Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity())); // 4.写入vo for (CartVO v : vos) { ItemDTO item = itemMap.get(v.getItemId()); if (item == null) { continue; } v.setNewPrice(item.getPrice()); v.setStatus(item.getStatus()); v.setStock(item.getStock()); }*/ } @Override public void removeByItemIds(Collection<Long> itemIds) { // 1.构建删除条件,userId和itemId QueryWrapper<Cart> queryWrapper = new QueryWrapper<Cart>(); queryWrapper.lambda() .eq(Cart::getUserId, UserContext.getUser()) .in(Cart::getItemId, itemIds); // 2.删除 remove(queryWrapper); } private void checkCartsFull(Long userId) { int count = lambdaQuery().eq(Cart::getUserId, userId).count(); if (count >= 10) { throw new BizIllegalException(StrUtil.format("用户购物车课程不能超过{}", 10)); } } private boolean checkItemExists(Long itemId, Long userId) { int count = lambdaQuery() .eq(Cart::getUserId, userId) .eq(Cart::getItemId, itemId) .count(); return count > 0; } }
最后,还是要导入数据库表,在本地数据库直接执行资料对应的SQL文件:
在数据库中会出现名为hm-cart的database,以及其中的cart表,代表购物车:
接下来,就可以测试了。不过在启动前,同样要配置启动项的active profile为local:
然后启动CartApplication,访问swagger文档页面:http://localhost:8082/doc.html
我们测试其中的查询我的购物车列表接口:
无需填写参数,直接访问:
我们注意到,其中与商品有关的几个字段值都为空!这就是因为刚才我们注释掉了查询购物车时,查询商品信息的相关代码。
那么,我们该如何在cart-service服务中实现对item-service服务的查询呢?
在拆分的时候,我们发现一个问题:就是购物车业务中需要查询商品信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致我们无法查询。
最终结果就是查询到的购物车数据不完整,因此要想解决这个问题,我们就必须改造其中的代码,把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。
因此,现在查询购物车列表的流程变成了这样:
代码中需要变化的就是这一步:
那么问题来了:我们该如何跨服务调用,准确的说,如何在cart-service中获取item-service服务中的提供的商品数据呢?
大家思考一下,我们以前有没有实现过类似的远程查询的功能呢?
答案是肯定的,我们前端向服务端查询数据,其实就是从浏览器远程查询服务端数据。比如我们刚才通过Swagger测试商品查询接口,就是向http://localhost:8081/items这个接口发起的请求:
而这种查询就是通过http请求的方式来完成的,不仅仅可以实现远程查询,还可以实现新增、删除等各种远程请求。
假如我们在cart-service中能模拟浏览器,发送http请求到item-service,是不是就实现了跨微服务的远程调用了呢?
那么:我们该如何用Java代码发送Http的请求呢?
Spring给我们提供了一个RestTemplate
的API,可以方便的实现Http请求的发送。
org.springframework.web.client public class RestTemplate
extends InterceptingHttpAccessor
implements RestOperations
同步客户端执行HTTP请求,在底层HTTP客户端库(如JDK HttpURLConnection、Apache HttpComponents等)上公开一个简单的模板方法API。RestTemplate通过HTTP方法为常见场景提供了模板,此外还提供了支持不太常见情况的通用交换和执行方法。 RestTemplate通常用作共享组件。然而,它的配置不支持并发修改,因此它的配置通常是在启动时准备的。如果需要,您可以在启动时创建多个不同配置的RestTemplate实例。如果这些实例需要共享HTTP客户端资源,它们可以使用相同的底层ClientHttpRequestFactory。 注意:从5.0开始,这个类处于维护模式,只有对更改和错误的小请求才会被接受。请考虑使用org.springframework.web.react .client. webclient,它有更现代的API,支持同步、异步和流场景。
自: 3.0 参见: HttpMessageConverter, RequestCallback, ResponseExtractor, ResponseErrorHandler
其中提供了大量的方法,方便我们发送Http请求,例如:
可以看到常见的Get、Post、Put、Delete请求都支持,如果请求参数比较复杂,还可以使用exchange方法来构造请求。
我们在cart-service服务中定义一个配置类:
先将RestTemplate注册为一个Bean:
package com.hmall.cart.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RemoteCallConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
接下来,我们修改cart-service中的com.hmall.cart.service.impl.CartServiceImpl的handleCartItems方法,发送http请求到item-service:
可以看到,利用RestTemplate发送http请求与前端ajax发送请求非常相似,都包含四部分信息:
handleCartItems方法的完整代码如下:
private void handleCartItems(List<CartVO> vos) { // TODO 1.获取商品id Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); // 2.查询商品 // List<ItemDTO> items = itemService.queryItemByIds(itemIds); // 2.1.利用RestTemplate发起http请求,得到http的响应 ResponseEntity<List<ItemDTO>> response = restTemplate.exchange( "http://localhost:8081/items?ids={ids}", HttpMethod.GET, null, new ParameterizedTypeReference<List<ItemDTO>>() { }, Map.of("ids", CollUtil.join(itemIds, ",")) ); // 2.2.解析响应 if(!response.getStatusCode().is2xxSuccessful()){ // 查询失败,直接结束 return; } List<ItemDTO> items = response.getBody(); if (CollUtils.isEmpty(items)) { return; } // 3.转为 id 到 item的map Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity())); // 4.写入vo for (CartVO v : vos) { ItemDTO item = itemMap.get(v.getItemId()); if (item == null) { continue; } v.setNewPrice(item.getPrice()); v.setStatus(item.getStatus()); v.setStock(item.getStock()); } }
好了,现在重启cart-service,再次测试查询我的购物车列表接口:
可以发现,所有商品相关数据都已经查询到了。
在这个过程中,item-service提供了查询接口,cart-service利用Http请求调用该接口。因此item-service可以称为服务的提供者,而cart-service则称为服务的消费者或服务调用者。
什么时候需要拆分微服务?
如何拆分?
服务拆分之后,不可避免的会出现跨微服务的业务,此时微服务之间就需要进行远程调用。微服务之间的远程调用被称为RPC,即远程过程调用。RPC的实现方式有很多,比如:
Java发送http请求可以使用Spring提供的RestTemplate,使用的基本步骤如下:
在上一章我们实现了微服务拆分,并且通过Http请求实现了跨微服务的远程调用。不过这种手动发送Http请求的方式存在一些问题。
试想一下,假如商品微服务被调用较多,为了应对更高的并发,我们进行了多实例部署,如图:
此时,每个item-service的实例其IP或端口不同,问题来了:
为了解决上述问题,就必须引入注册中心的概念了,接下来我们就一起来分析下注册中心的原理。
在微服务远程调用的过程中,包括两个角色:
在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:
流程如下:
当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?
目前开源的注册中心框架有很多,国内比较常见的有:
以上几种注册中心都遵循SpringCloud中的API规范,因此在业务开发使用上没有太大差异。由于Nacos是国内产品,中文文档比较丰富,而且同时具备配置管理功能(后面会学习),因此在国内使用较多
官方网站如下:
我们基于Docker来部署Nacos的注册中心,首先我们要准备MySQL数据库表,用来存储Nacos的数据。由于是Docker部署,所以大家需要将资料中的SQL文件导入到你Docker中的MySQL容器中:
最终表结构如下:
然后,找到资料下的nacos文件夹:
其中的nacos/custom.env文件中,有一个MYSQL_SERVICE_HOST也就是mysql地址,需要修改为你自己的虚拟机IP地址,以及用户名和密码都改成自己的:
然后,将资料中的nacos目录上传至虚拟机的/root目录。
进入root目录,然后执行下面的docker命令:
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim
启动完成后,访问下面地址:http://192.168.150.101:8848/nacos/,注意将192.168.150.101替换为你自己的虚拟机IP地址。
首次访问会跳转到登录页,账号密码都是nacos
接下来,我们把item-service注册到Nacos,步骤如下:
在item-service的pom.xml中添加依赖:
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
在item-service的application.yml中添加nacos地址配置:
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
为了测试一个服务多个实例的情况,我们再配置一个item-service的部署实例:
然后配置启动项,注意重命名并且配置新的端口,避免冲突:
重启item-service
的两个实例:
访问nacos控制台,可以发现服务注册成功:
点击详情,可以查看到item-service服务的两个实例信息:
服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:
服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。
我们在cart-service中的pom.xml中添加下面的依赖:
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
可以发现,这里Nacos的依赖与服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者。
因此,等一会儿cart-service启动,同样会注册到Nacos
在cart-service的application.yml中添加nacos地址配置:
spring:
cloud:
nacos:
server-addr: 192.168.150.101:8848
接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。
因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:
另外,服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用:
接下来,我们就可以对原来的远程调用做修改了,之前调用时我们需要写死服务提供者的IP和端口:
但现在不需要了,我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用:
经过swagger测试,发现没有任何问题。
在上一章,我们利用Nacos实现了服务的治理,利用RestTemplate实现了服务的远程调用。但是远程调用的代码太复杂了:
而且这种调用方式,与原本的本地方法调用差异太大,编程时的体验也不统一,一会儿远程调用,一会儿本地调用。
因此,我们必须想办法改变远程调用的开发模式,让远程调用像本地方法调用一样简单。而这就要用到OpenFeign组件了。
其实远程调用的关键点就在于四个:
我们还是以cart-service中的查询我的购物车为例。因此下面的操作都是在cart-service中进行。
在cart-service服务的pom.xml中引入OpenFeign的依赖和loadBalancer依赖:
<!--openFeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--负载均衡器-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
接下来,我们在cart-service的CartApplication启动类上添加注解,启动OpenFeign功能:
在cart-service中,定义一个新的接口,编写Feign客户端:
其中代码如下:
package com.hmall.cart.client;
import com.hmall.cart.domain.dto.ItemDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}
这里只需要声明接口,无需实现方法。接口中的几个关键信息:
有了上述信息,OpenFeign就可以利用动态代理帮我们实现这个方法,并且向http://item-service/items发送一个GET请求,携带ids为请求参数,并自动将返回值处理为List。
我们只需要直接调用这个方法,即可实现远程调用了。
最后,我们在cart-service的com.hmall.cart.service.impl.CartServiceImpl中改造代码,直接调用ItemClient的方法:
feign替我们完成了服务拉取、负载均衡、发送http请求的所有工作,是不是看起来优雅多了。
而且,这里我们不再需要RestTemplate了,还省去了RestTemplate的注册。
Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:
因此我们通常会使用带有连接池的客户端来代替默认的HttpURLConnection。比如,我们使用OK Http.
在cart-service的pom.xml中引入依赖:
<!--OK http 的依赖 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
在cart-service的application.yml配置文件中开启Feign的连接池功能:
feign:
okhttp:
enabled: true # 开启OKHttp功能
重启服务,连接池就生效了。
我们可以打断点验证连接池是否生效,在org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient中的execute方法中打断点:
Debug方式启动cart-service,请求一次查询我的购物车方法,进入断点:
可以发现这里底层的实现已经改为OkHttpClient
将来我们要把与下单有关的业务抽取为一个独立微服务:trade-service,不过我们先来看一下hm-service中原本与下单有关的业务逻辑。
入口在com.hmall.controller.OrderController的createOrder方法,然后调用了IOrderService中的createOrder方法。
由于下单时前端提交了商品id,为了计算订单总价,需要查询商品信息:
也就是说,如果拆分了交易微服务(trade-service),它也需要远程调用item-service中的根据id批量查询商品功能。这个需求与cart-service中是一样的。
因此,我们就需要在trade-service中再次定义ItemClient接口,这不是重复编码吗? 有什么办法能加避免重复编码呢?
相信大家都能想到,避免重复编码的办法就是抽取。不过这里有两种抽取思路:
方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。
方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。
由于item-service已经创建好,无法继续拆分,因此这里我们采用方案1.
在hmall下定义一个新的module,命名为hm-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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>hm-api</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--open feign--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!-- load balancer--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency> <!-- swagger 注解依赖 --> <dependency> <groupId>io.swagger</groupId> <artifactId>swagger-annotations</artifactId> <version>1.6.6</version> <scope>compile</scope> </dependency> </dependencies> </project>
然后把ItemDTO和ItemClient都拷贝过来,最终结构如下:
现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。
接下来,我们在cart-service的pom.xml中引入hm-api模块:
<!--feign模块-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-api</artifactId>
<version>1.0.0</version>
</dependency>
删除cart-service中原来的ItemDTO和ItemClient,重启项目,发现报错了:
这里因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了。
解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:
OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:
在hm-api模块下新建一个配置类,定义Feign的日志级别:
package com.hmall.api.config;
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.FULL;
}
}
接下来,要让日志级别生效,还需要配置这个类。有两种方式:
@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
日志格式:
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] ---> GET http://item-service/items?ids=100000006163 HTTP/1.1
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] ---> END HTTP (0-byte body)
17:35:32:278 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] <--- HTTP/1.1 200 (127ms)
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] connection: keep-alive
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] content-type: application/json
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] date: Fri, 26 May 2023 09:35:32 GMT
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] keep-alive: timeout=60
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] transfer-encoding: chunked
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds]
17:35:32:280 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] [{"id":100000006163,"name":"巴布豆(BOBDOG)柔薄悦动婴儿拉拉裤XXL码80片(15kg以上)","price":67100,"stock":10000,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t23998/350/2363990466/222391/a6e9581d/5b7cba5bN0c18fb4f.jpg!q70.jpg.webp","category":"拉拉裤","brand":"巴布豆","spec":"{}","sold":11,"commentCount":33343434,"isAD":false,"status":2}]
17:35:32:281 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] <--- END HTTP (369-byte body)
将hm-service中的其它业务也都拆分为微服务,包括:
其中交易服务、支付服务、用户服务中的业务都需要知道当前登录用户是谁,目前暂未实现,先将用户id写死。
思考:如何才能在每个微服务中都拿到用户信息?如何在微服务之间传递用户信息?
在上述业务中,包含大量的微服务调用,将被调用的接口全部定义为FeignClient,将其与对应的DTO放在hm-api模块
资料提供了一个hmall-nginx目录,其中包含了Nginx以及我们的前端代码:
将其拷贝到一个不包含中文、空格、特殊字符的目录,启动后即可访问到页面:
之前nginx内部会将发向服务端请求全部代理到8080端口,但是现在拆分了N个微服务,8080不可用了。请通过Nginx配置,完成对不同微服务的反向代理。
认真思考这种方式存在哪些问题,有什么好的解决方案?
在hmall下新建一个module,命名为user-service:
user-service的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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>user-service</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--common--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <!--api--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-api</artifactId> <version>1.0.0</version> </dependency> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!--nacos 服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
在user-service中的com.hmall.user包下创建启动类:
package com.hmall.user;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.hmall.user.mapper")
@SpringBootApplication
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}
从hm-service项目中复制3个yaml配置文件到user-service的resource目录。
其中application-dev.yaml和application-local.yaml保持不变。application.yaml如下:
server: port: 8084 spring: application: name: user-service # 服务名称 profiles: active: dev datasource: url: jdbc:mysql://${hm.db.host}:3306/hm-user?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: ${hm.db.pw} cloud: nacos: server-addr: 192.168.150.101 # nacos地址 mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: update-strategy: not_null id-type: auto logging: level: com.hmall: debug pattern: dateformat: HH:mm:ss:SSS file: path: "logs/${spring.application.name}" knife4j: enable: true openapi: title: 用户服务接口文档 description: "信息" email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.hmall.user.controller hm: jwt: location: classpath:hmall.jks alias: hmall password: hmall123 tokenTTL: 30m
将hm-service下的hmall.jks文件拷贝到user-service下的resources目录,这是JWT加密的秘钥文件:
复制hm-service中所有与user、address、jwt有关的代码,最终项目结构如下:
user-service也需要自己的独立的database,向MySQL中导入资料提供的SQL:
导入结果如下:
给user-service配置启动项,设置profile为local:
启动UserApplication,访问http://localhost:8084/doc.html#/default/用户相关接口/loginUsingPOST,测试登录接口:
用户服务测试通过。
在hmall下新建一个module,命名为trade-service:
trade-service的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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>trade-service</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--common--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <!--api--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-api</artifactId> <version>1.0.0</version> </dependency> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!--nacos 服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
在trade-service中的com.hmall.trade包下创建启动类:
package com.hmall.trade;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = DefaultFeignConfig.class)
@MapperScan("com.hmall.trade.mapper")
@SpringBootApplication
public class TradeApplication {
public static void main(String[] args) {
SpringApplication.run(TradeApplication.class, args);
}
}
从hm-service项目中复制3个yaml配置文件到trade-service的resource目录。
其中application-dev.yaml和application-local.yaml保持不变。application.yaml如下:
server: port: 8085 spring: application: name: trade-service # 服务名称 profiles: active: dev datasource: url: jdbc:mysql://${hm.db.host}:3306/hm-trade?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: ${hm.db.pw} cloud: nacos: server-addr: 192.168.150.101 # nacos地址 mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: update-strategy: not_null id-type: auto logging: level: com.hmall: debug pattern: dateformat: HH:mm:ss:SSS file: path: "logs/${spring.application.name}" knife4j: enable: true openapi: title: 交易服务接口文档 description: "信息" email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.hmall.trade.controller
复制hm-service中所有与trade有关的代码,最终项目结构如下:
在交易服务中,用户下单时需要做下列事情:
其中,查询商品、扣减库存都是与商品有关的业务,在item-service中有相关功能;清理购物车商品是购物车业务,在cart-service中有相关功能。
因此交易服务要调用他们,必须通过OpenFeign远程调用。我们需要将上述功能抽取为FeignClient.
首先是扣减库存,在item-service中的对应业务接口如下:
我们将这个接口抽取到hm-api模块的com.hmall.api.client.ItemClient中:
将接口参数的OrderDetailDTO抽取到hm-api模块的com.hmall.api.dto包下:
接下来是清理购物车商品,在cart-service中的对应业务接口如下:
我们在hm-api模块的com.hmall.api.client包下定义一个CartClient接口:
代码如下:
package com.hmall.api.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Collection;
@FeignClient("cart-service")
public interface CartClient {
@DeleteMapping("/carts")
void deleteCartItemByIds(@RequestParam("ids") Collection<Long> ids);
}
接下来,就可以改造OrderServiceImpl中的逻辑,将本地方法调用改造为基于FeignClient的调用,完整代码如下
package com.hmall.trade.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.hmall.api.client.CartClient; import com.hmall.api.client.ItemClient; import com.hmall.api.dto.ItemDTO; import com.hmall.api.dto.OrderDetailDTO; import com.hmall.common.exception.BadRequestException; import com.hmall.common.utils.UserContext; import com.hmall.trade.domain.dto.OrderFormDTO; import com.hmall.trade.domain.po.Order; import com.hmall.trade.domain.po.OrderDetail; import com.hmall.trade.mapper.OrderMapper; import com.hmall.trade.service.IOrderDetailService; import com.hmall.trade.service.IOrderService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * <p> * 服务实现类 * </p> */ @Service @RequiredArgsConstructor public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService { private final ItemClient itemClient; private final IOrderDetailService detailService; private final CartClient cartClient; @Override @Transactional public Long createOrder(OrderFormDTO orderFormDTO) { // 1.订单数据 Order order = new Order(); // 1.1.查询商品 List<OrderDetailDTO> detailDTOS = orderFormDTO.getDetails(); // 1.2.获取商品id和数量的Map Map<Long, Integer> itemNumMap = detailDTOS.stream() .collect(Collectors.toMap(OrderDetailDTO::getItemId, OrderDetailDTO::getNum)); Set<Long> itemIds = itemNumMap.keySet(); // 1.3.查询商品 List<ItemDTO> items = itemClient.queryItemByIds(itemIds); if (items == null || items.size() < itemIds.size()) { throw new BadRequestException("商品不存在"); } // 1.4.基于商品价格、购买数量计算商品总价:totalFee int total = 0; for (ItemDTO item : items) { total += item.getPrice() itemNumMap.get(item.getId()); } order.setTotalFee(total); // 1.5.其它属性 order.setPaymentType(orderFormDTO.getPaymentType()); order.setUserId(UserContext.getUser()); order.setStatus(1); // 1.6.将Order写入数据库order表中 save(order); // 2.保存订单详情 List<OrderDetail> details = buildDetails(order.getId(), items, itemNumMap); detailService.saveBatch(details); // 3.扣减库存 try { itemClient.deductStock(detailDTOS); } catch (Exception e) { throw new RuntimeException("库存不足!"); } // 4.清理购物车商品 cartClient.deleteCartItemByIds(itemIds); return order.getId(); } private List<OrderDetail> buildDetails(Long orderId, List<ItemDTO> items, Map<Long, Integer> numMap) { List<OrderDetail> details = new ArrayList<>(items.size()); for (ItemDTO item : items) { OrderDetail detail = new OrderDetail(); detail.setName(item.getName()); detail.setSpec(item.getSpec()); detail.setPrice(item.getPrice()); detail.setNum(numMap.get(item.getId())); detail.setItemId(item.getId()); detail.setImage(item.getImage()); detail.setOrderId(orderId); details.add(detail); } return details; } }
trade-service也需要自己的独立的database,向MySQL中导入资料提供的SQL:
导入结果如下:
给trade-service配置启动项,设置profile为local:
启动TradeApplication,访问http://localhost:8085/doc.html,测试查询订单接口:
请求参数:1654779387523936258,交易服务测试通过。
注意,创建订单接口无法测试,因为无法获取登录用户信息。
在hmall下新建一个module,命名为pay-service:
pay-service的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>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>pay-service</artifactId> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <!--common--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <!--api--> <dependency> <groupId>com.heima</groupId> <artifactId>hm-api</artifactId> <version>1.0.0</version> </dependency> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!--nacos 服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
在pay-service中的com.hmall.pay包下创建启动类:
package com.hmall.pay;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = DefaultFeignConfig.class)
@MapperScan("com.hmall.pay.mapper")
@SpringBootApplication
public class PayApplication {
public static void main(String[] args) {
SpringApplication.run(PayApplication.class, args);
}
}
从hm-service项目中复制3个yaml配置文件到trade-service的resource目录。
其中application-dev.yaml和application-local.yaml保持不变。application.yaml如下:
server: port: 8086 spring: application: name: pay-service profiles: active: dev datasource: url: jdbc:mysql://${hm.db.host}:3306/hm-pay?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: ${hm.db.pw} cloud: nacos: server-addr: 192.168.150.101 mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler global-config: db-config: update-strategy: not_null id-type: auto logging: level: com.hmall: debug pattern: dateformat: HH:mm:ss:SSS file: path: "logs/${spring.application.name}" knife4j: enable: true openapi: title: 支付服务接口文档 description: "支付服务接口文档" email: zhanghuyi@itcast.cn concat: 虎哥 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.hmall.pay.controller
复制hm-service中所有与支付有关的代码,最终项目结构如下:
在支付服务中,基于用户余额支付时需要做下列事情:
其中,扣减用户余额是在user-service中有相关功能;标记订单状态则是在trade-service中有相关功能。因此交易服务要调用他们,必须通过OpenFeign远程调用。我们需要将上述功能抽取为FeignClient.
首先是扣减用户余额,在user-service中的对应业务接口如下:
我们将这个接口抽取到hm-api模块的com.hmall.api.client.UserClient中:
package com.hmall.api.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient("user-service")
public interface UserClient {
@PutMapping("/users/money/deduct")
void deductMoney(@RequestParam("pw") String pw,@RequestParam("amount") Integer amount);
}
接下来是标记订单状态,在trade-service中的对应业务接口如下:
我们将这个接口抽取到hm-api模块的com.hmall.api.client.TradeClient中:
package com.hmall.api.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
@FeignClient("trade-service")
public interface TradeClient {
@PutMapping("/orders/{orderId}")
void markOrderPaySuccess(@PathVariable("orderId") Long orderId);
}
接下来,就可以改造PayOrderServiceImpl中的逻辑,将本地方法调用改造为基于FeignClient的调用,完整代码如下
package com.hmall.pay.service.impl; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.hmall.api.client.TradeClient; import com.hmall.api.client.UserClient; import com.hmall.common.exception.BizIllegalException; import com.hmall.common.utils.BeanUtils; import com.hmall.common.utils.UserContext; import com.hmall.pay.domain.dto.PayApplyDTO; import com.hmall.pay.domain.dto.PayOrderFormDTO; import com.hmall.pay.domain.po.PayOrder; import com.hmall.pay.enums.PayStatus; import com.hmall.pay.mapper.PayOrderMapper; import com.hmall.pay.service.IPayOrderService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; /** * <p> * 支付订单 服务实现类 * </p> * */ @Service @RequiredArgsConstructor public class PayOrderServiceImpl extends ServiceImpl<PayOrderMapper, PayOrder> implements IPayOrderService { private final UserClient userClient; private final TradeClient tradeClient; @Override public String applyPayOrder(PayApplyDTO applyDTO) { // 1.幂等性校验 PayOrder payOrder = checkIdempotent(applyDTO); // 2.返回结果 return payOrder.getId().toString(); } @Override @Transactional public void tryPayOrderByBalance(PayOrderFormDTO payOrderDTO) { // 1.查询支付单 PayOrder po = getById(payOrderDTO.getId()); // 2.判断状态 if(!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())){ // 订单不是未支付,状态异常 throw new BizIllegalException("交易已支付或关闭!"); } // 3.尝试扣减余额 userClient.deductMoney(payOrderDTO.getPw(), po.getAmount()); // 4.修改支付单状态 boolean success = markPayOrderSuccess(payOrderDTO.getId(), LocalDateTime.now()); if (!success) { throw new BizIllegalException("交易已支付或关闭!"); } // 5.修改订单状态 tradeClient.markOrderPaySuccess(po.getBizOrderNo()); } public boolean markPayOrderSuccess(Long id, LocalDateTime successTime) { return lambdaUpdate() .set(PayOrder::getStatus, PayStatus.TRADE_SUCCESS.getValue()) .set(PayOrder::getPaySuccessTime, successTime) .eq(PayOrder::getId, id) // 支付状态的乐观锁判断 .in(PayOrder::getStatus, PayStatus.NOT_COMMIT.getValue(), PayStatus.WAIT_BUYER_PAY.getValue()) .update(); } private PayOrder checkIdempotent(PayApplyDTO applyDTO) { // 1.首先查询支付单 PayOrder oldOrder = queryByBizOrderNo(applyDTO.getBizOrderNo()); // 2.判断是否存在 if (oldOrder == null) { // 不存在支付单,说明是第一次,写入新的支付单并返回 PayOrder payOrder = buildPayOrder(applyDTO); payOrder.setPayOrderNo(IdWorker.getId()); save(payOrder); return payOrder; } // 3.旧单已经存在,判断是否支付成功 if (PayStatus.TRADE_SUCCESS.equalsValue(oldOrder.getStatus())) { // 已经支付成功,抛出异常 throw new BizIllegalException("订单已经支付!"); } // 4.旧单已经存在,判断是否已经关闭 if (PayStatus.TRADE_CLOSED.equalsValue(oldOrder.getStatus())) { // 已经关闭,抛出异常 throw new BizIllegalException("订单已关闭"); } // 5.旧单已经存在,判断支付渠道是否一致 if (!StringUtils.equals(oldOrder.getPayChannelCode(), applyDTO.getPayChannelCode())) { // 支付渠道不一致,需要重置数据,然后重新申请支付单 PayOrder payOrder = buildPayOrder(applyDTO); payOrder.setId(oldOrder.getId()); payOrder.setQrCodeUrl(""); updateById(payOrder); payOrder.setPayOrderNo(oldOrder.getPayOrderNo()); return payOrder; } // 6.旧单已经存在,且可能是未支付或未提交,且支付渠道一致,直接返回旧数据 return oldOrder; } private PayOrder buildPayOrder(PayApplyDTO payApplyDTO) { // 1.数据转换 PayOrder payOrder = BeanUtils.toBean(payApplyDTO, PayOrder.class); // 2.初始化数据 payOrder.setPayOverTime(LocalDateTime.now().plusMinutes(120L)); payOrder.setStatus(PayStatus.WAIT_BUYER_PAY.getValue()); payOrder.setBizUserId(UserContext.getUser()); return payOrder; } public PayOrder queryByBizOrderNo(Long bizOrderNo) { return lambdaQuery() .eq(PayOrder::getBizOrderNo, bizOrderNo) .one(); } }
pay-service也需要自己的独立的database,向MySQL中导入资料提供的SQL:
导入结果如下:
给pay-service配置启动项,设置profile为local:
在支付服务的PayController中添加一个接口方便测试:
@ApiOperation("查询支付单")
@GetMapping
public List<PayOrderVO> queryPayOrders(){
return BeanUtils.copyList(payOrderService.list(), PayOrderVO.class);
}
启动PayApplication,访问http://localhost:8086/doc.html,测试查询订单接口:
支付服务测试通过。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。