# SpringCloud

# 从单体到集群再到分布式

早期阶段,单体架构是主流选择,所有功能模块打包在一个应用中,开发简单直接,但是随着业务增长,代码变得臃肿,难以扩展特定功能模块,技术栈单一,难以采用新技术。

为了应对单体架构的性能瓶颈和高可用需求,集群架构应运而生。

实现方式:

  1. 水平扩展:部署多个相同的单体应用实例
  2. 通过负载均衡器 (Nginx、F5 等) 分配请求
  3. 共享数据库或数据库主从复制

但是仍然有缺陷,比如应用本身仍然是单体,业务复杂时扩展不灵活。

此时分布式架构与微服务应运而生,分布式架构通过将系统拆分为多个服务来解决上述问题。

本次学习使用尚硅谷 b 站开放课堂:https://www.bilibili.com/video/BV1UJc2ezEFU

框架(组件)学习与本套课程高度重合,但并不是课程资料的再复写。

相关技术:

  1. Nacos(注册中心、配置中心)来自 Spring Cloud Alibaba
  2. OpenFegin(远程调用)来自 Spring Cloud 官方
  3. Sentinel(异常处理、流控规则、熔断规则)来自 Spring Cloud Alibaba
  4. Gateway(路由、断言、过滤)来自 Spring Cloud 官方
  5. 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 的形式,替换的结果符合负载均衡。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

KarryLiu 微信支付

微信支付

KarryLiu 支付宝

支付宝