# SpringCloud
# 从单体到集群再到分布式
早期阶段,单体架构是主流选择,所有功能模块打包在一个应用中,开发简单直接,但是随着业务增长,代码变得臃肿,难以扩展特定功能模块,技术栈单一,难以采用新技术。
为了应对单体架构的性能瓶颈和高可用需求,集群架构应运而生。
实现方式:
- 水平扩展:部署多个相同的单体应用实例
- 通过负载均衡器 (Nginx、F5 等) 分配请求
- 共享数据库或数据库主从复制
但是仍然有缺陷,比如应用本身仍然是单体,业务复杂时扩展不灵活。
此时分布式架构与微服务应运而生,分布式架构通过将系统拆分为多个服务来解决上述问题。
本次学习使用尚硅谷 b 站开放课堂:https://www.bilibili.com/video/BV1UJc2ezEFU
框架(组件)学习与本套课程高度重合,但并不是课程资料的再复写。
相关技术:
- Nacos(注册中心、配置中心)来自 Spring Cloud Alibaba
- OpenFegin(远程调用)来自 Spring Cloud 官方
- Sentinel(异常处理、流控规则、熔断规则)来自 Spring Cloud Alibaba
- Gateway(路由、断言、过滤)来自 Spring Cloud 官方
- Seata(分布式事务)来自 Spring Cloud Alibaba
# Nacos
# 注册中心 - 服务注册
首先进行依赖导入
<!-- nacos 配置中心、注册中心 --> | |
<dependency> | |
<groupId>com.alibaba.cloud</groupId> | |
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> | |
</dependency> |
使用 docker 或者直接运行的方式启动 Nacos
在 Windows 平台直接运行下使用命令:startup.cmd -m standalone(standalone 为使用单机模式)
在不同的服务下编写配置,如订单服务和产品服务:
spring: | |
cloud: | |
nacos: | |
server-addr: 127.0.0.1:8848 | |
application: | |
name: service-order | |
server: | |
port: 8080 |
spring: | |
cloud: | |
nacos: | |
server-addr: 127.0.0.1:8848 | |
application: | |
name: service-product | |
server: | |
port: 9000 |
其中 nacos.server-addr 为 nacos 服务的地址为 127.0.0.1:8848(本地测试)
访问 http://localhost:8848/nacos,在服务管理 - 服务列表可以看到现在已经注册上的服务
服务名 | 分组名称 | 集群数目 | 实例数 | 健康实例数 | 触发保护阈值 | 操作 |
---|---|---|---|---|---|---|
service-order | DEFAULT_GROUP | 1 | 2 | 2 | false | 详情 | 示例代码 | 订阅者 | 删除 |
service-product | DEFAULT_GROUP | 1 | 3 | 3 | false | 详情 | 示例代码 | 订阅者 | 删除 |
# 注册中心 - 服务发现
由于使用了 Nacos,服务发现方法的调用存在两套标准,分别是 Spring Cloud 的 DiscoveryClient 和 Nacos 的 NacosServiceDiscovery
@Resource | |
DiscoveryClient discoveryClient; | |
@Resource | |
NacosServiceDiscovery nacosServiceDiscovery; |
下面为测试代码:
/** | |
* spring 标准 discovery 使用 DiscoveryClient | |
*/ | |
@Test | |
public void testDiscoveryClient() { | |
for (String service : discoveryClient.getServices()) { | |
/* | |
循环输出服务列表(服务名) | |
service-order | |
service-product | |
*/ | |
System.out.println(service); | |
// 获取所有实例、输出 IP 与端口号 | |
List<ServiceInstance> instances = discoveryClient.getInstances(service); | |
for (ServiceInstance instance : instances) { | |
System.out.println(instance.getHost() + ":" + instance.getPort()); | |
} | |
} | |
} | |
/** | |
* nacos 标准 discovery 使用 NacosServiceDiscovery | |
*/ | |
@Test | |
public void testNacosServiceDiscovery() throws NacosException { | |
for (String service : nacosServiceDiscovery.getServices()) { | |
// 输出服务列表 | |
System.out.println(service); | |
// 获取所有实例、输出 IP 与端口号 | |
List<ServiceInstance> instances = nacosServiceDiscovery.getInstances(service); | |
for (ServiceInstance instance : instances) { | |
System.out.println(instance.getHost() + ":" + instance.getPort()); | |
} | |
} | |
} |
输出:
service-order | |
192.168.25.1:8080 | |
192.168.25.1:8001 | |
service-product | |
192.168.25.1:9000 | |
192.168.25.1:9002 | |
192.168.25.1:9001 |
如果并没有如此多的输出可能是只启动了两个后端服务,还需要多启动几个来模拟分布式。
其次,在实际应用中,这个一般会被进一步封装,其发现的过程是自动进行的。
# 注册中心 - 初见,远程调用
现在有一个实例,我们需要一个下单功能,当用户下单后对其商品进行结算这里我们对一些数据做出模拟。
订单实体:
@Data | |
public class Order { | |
private Long id; | |
private BigDecimal totalAmount; | |
private Long userId; | |
private String nickName; | |
private String address; | |
private List<Object> product; | |
} |
商品实体:
@Data
public class Product {
private Long id;
private BigDecimal price;
private String productName;
private int num;
}
其次就是相应和 Controller 与 Service 代码,其较为简单不在此处详细展开,不过我想说一下订单部分的 Service:
@Service | |
public class OrderServiceImpl implements OrderService { | |
@Override | |
public Order createOrder(Long productId, Long userId) { | |
Order order = new Order(); | |
order.setId(1L); | |
// TODO 需要计算 | |
order.setTotalAmount(new BigDecimal("0")); | |
order.setUserId(userId); | |
order.setNickName("Karry.Liu"); | |
order.setAddress("北极"); | |
// TODO 需要远程查询 | |
order.setProduct(null); | |
return order; | |
} | |
} |
由于 Order 与 Product 分别位于两个服务之中,其详细的金额 Amount 与产品详情列表 Product List 我们目前似乎无法获取,那我们应该怎么办呢?这个我们暂时按下不表,我们现在需要解决一个更加棘手的问题。
现在我们有如下项目结构(简略版)
- cloud-demo(基座项目) | |
| | |
| - services(服务层) | |
| | | |
| | - service-order(订单服务)(包含订单实体bean、服务service和控制controller) | |
| | | |
| | - service-product(商品服务)(包含商品实体bean、服务service和控制controller) | |
订单服务无法使用商品bean,反之商品服务无法使用订单bean,因为其每个服务均为独立的项目。 |
当 Order 服务需要 Product 服务时,其在 Order 的代码内一定会存在与 Product 相关的关键字,特别地,由于两个服务之间项链紧密,在 Product 的代码内也许也会出现 Order 相关的关键字。可是两套服务分别维护着自己的 bean(实体对象 / 实体类),在不同的服务之间甚至没有办法使用对方的实体类。
解决方案也很简单,将商品与订单的 Bean 剥离出来,形成一个独立的项目,与 services 等价地位,并在 services 添加 model 依赖。
<!-- 模型依赖 --> | |
<dependency> | |
<groupId>com.KarryCode</groupId> | |
<artifactId>model</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
</dependency> |
现在的结构为:
- cloud-demo(基座项目) | |
| | |
| - model(模型层)(包含订单实体bean与商品实体bean) | |
| | |
| - services(服务层) | |
| | | |
| | - service-order(订单服务)(包含服务service和控制controller) | |
| | | |
| | - service-product(商品服务)(包含服务service和控制controller) |
至此,服务之间的实体使用已经被打通。
其次编写远程访问请求模板类 RestTemplate,由于 RestTemplate 是线程安全的,我们可以这样写:
@Configuration | |
public class ProductServiceConfig { | |
@Bean | |
public RestTemplate restTemplate() { | |
return new RestTemplate(); | |
} | |
} | |
// 上拉使他成为一个 Bean |
其次编写远程访问方法:
private Product getProductFromRemote(Long productId) { | |
// 获取商品服务所在的所有机器 IP + 端口 | |
List<ServiceInstance> instances = discoveryClient.getInstances("service-product"); | |
// 获取第一个机器(简单版) | |
ServiceInstance serviceInstance = instances.get(0); | |
// 拼接请求地址 http://192.168.25.1:9000/product/100 | |
String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/product/" + productId; | |
log.info("远程请求: {}", url); | |
// 发送请求(远程) | |
return restTemplate.getForObject(url, Product.class); | |
} |
补充 createOrder 方法
public Order createOrder(Long productId, Long userId) { | |
// 此处提前远程查询 | |
Product productFromRemote = getProductFromRemote(productId); | |
Order order = new Order(); | |
order.setId(1L); | |
// 计算 | |
order.setTotalAmount(productFromRemote.getPrice().multiply(new BigDecimal(productFromRemote.getNum()))); | |
order.setUserId(userId); | |
order.setNickName("Karry.Liu"); | |
order.setAddress("北极"); | |
// 远程查询的结果 | |
order.setProduct(List.of(productFromRemote)); | |
return order; | |
} |
此时双端服务已经打通了,注意到日志:2025-06-24T22:21:11.646+08:00 INFO 3496 — [service-order] [nio-8080-exec-1] c.K.service.impl.OrderServiceImpl : 远程请求: http://192.168.25.1:9000/product/100
{ | |
"id": 1, | |
"totalAmount": 198, | |
"userId": 2, | |
"nickName": "Karry.Liu", | |
"address": "北极", | |
"product": [ | |
{ | |
"id": 100, | |
"price": 99, | |
"productName": "IPhone-100", | |
"num": 2 | |
} | |
] | |
} |
此时我们还有两个问题,一是我们每次只取了第一个服务器,二是这样写太复杂,没有实现负载均衡。
针对此第一个问题将在下一小节中解决,第二个问题将在下一个组件中解决。
# 注册中心 - 实现负载均衡 APIs
首先是使用复杂一点的方式,后面将会介绍使用注解的形式。
先引入负载均衡环境依赖。
<!-- 负载均衡 --> | |
<dependency> | |
<groupId>org.springframework.cloud</groupId> | |
<artifactId>spring-cloud-starter-loadbalancer</artifactId> | |
</dependency> |
# 基于方法的负载均衡
将 getProductFromRemote 方法改造为 getProductFromRemoteLoadBalancing:
private Product getProductFromRemoteLoadBalancing(Long productId) { | |
// 获取商品服务所在机器 (负载均衡) | |
ServiceInstance chooseLoadBalancing = loadBalancerClient.choose("service-product"); | |
// 拼接请求地址 http://192.168.25.1:9000/product/100 | |
log.info("服务地址(uri): {}", chooseLoadBalancing.getUri());//http://192.168.25.1:9000 | |
String url = "http://" + chooseLoadBalancing.getHost() + ":" + chooseLoadBalancing.getPort() + "/product/" + productId; | |
log.info("远程请求: {}", url); | |
// 发送请求(远程) | |
return restTemplate.getForObject(url, Product.class); | |
} |
多次请求 http://localhost:8080/create 后观察日志:
服务地址(uri): http://192.168.25.1:9001 | |
远程请求: http://192.168.25.1:9001/product/100 | |
服务地址(uri): http://192.168.25.1:9002 | |
远程请求: http://192.168.25.1:9002/product/100 | |
服务地址(uri): http://192.168.25.1:9000 | |
远程请求: http://192.168.25.1:9000/product/100 |
可以观察到其负载均衡的使用了不同的端口,下面将介绍注解的形式。
# 基于注解的负载均衡
还记得我们之前使用的 RestTemplate 嘛?
@Resource | |
private RestTemplate restTemplate; |
我们可以观察到,无论哪种方法,最终都会是去使用 restTemplate.getForObject (…) 这个方法,如果这个方法自己就可以进行负载均衡呢?我们是不是可以少些一点代码?
改造 ProductServiceConfig 配置类:
@Configuration | |
public class ProductServiceConfig { | |
@LoadBalanced// 使用负载均衡 | |
@Bean | |
public RestTemplate restTemplate() { | |
return new RestTemplate(); | |
} | |
} |
将 getProductFromRemoteLoadBalancing 方法改造为 getProductFromRemoteLoadBalancingWithAnnotation:
private Product getProductFromRemoteLoadBalancingWithAnnotation(Long productId) { | |
String url = "http://service-product/product/" + productId; | |
// 发送请求(远程) | |
return restTemplate.getForObject(url, Product.class); | |
} |
注意到我们的 url 中出现了 service-product,在由于 restTemplate 被追加了 @LoadBalanced 注解,使得整个 restTemplate 自带有负载均衡的能力,url 传过去的时候 service-product 会被自动替换为 IP+HOST 的形式,替换的结果符合负载均衡。