SpringCloud-组件介绍

基本概念

Spring Cloud 是一系列框架的有序集合。

Spring Cloud 利用 Spring Boot 的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、断路器、数据监控等,都可以用 Spring Boot 的开发风格做到一键启动和部署。

它将目前各家公司开发的比较成熟、经得起实际考验的服务框架组合起来,通过 Spring Boot 风格进行再封装屏蔽掉了复杂的配置和实现原理,最终给开发者留出了一套简单易懂、易部署和易维护的分布式系统开发工具包。

  • Spring Cloud Netflix:重要组件之一,与各种Netflix OSS组件集成,组成微服务的核心。
  • Netflix Eureka:服务注册中心,云端服务发现,一个基于 REST 的服务,用于定位服务,以实现云端中间层服务发现和故障转移。
  • Netflix Hystrix:熔断器,容错管理工具,旨在通过熔断机制控制服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。
  • Spring Cloud Config:配置中心,配置管理工具包,可以把配置放到远程服务器,集中化管理集群配置,目前支持本地存储、Git 以及 Svn。
  • Spring Cloud Bus:事件、消息总线,用于在集群(例如,配置变化事件)中传播状态变化,可与Spring Cloud Config联合实现热部署。
  • Spring Cloud for Cloud Foundry:Cloud Foundry是VMware推出的业界第一个开源PaaS云平台,它支持多种框架、语言、运行时环境、云平台及应用服务,使开发人员能够在几秒钟内进行应用程序的部署和扩展,无需担心任何基础架构的问题。
  • Spring Cloud Cluster:Spring Cloud Cluster将取代Spring Integration。提供在分布式系统中的集群所需要的基础功能支持,如:选举、集群的状态一致性、全局锁、tokens等常见状态模式的抽象和实现。
  • Spring Cloud Zookeeper:操作Zookeeper的工具包,用于使用zookeeper方式进行服务发现和配置管理。
  • Spring Cloud Starters:为Spring Cloud提供开箱即用的依赖管理。
  • Dubbo:基于RPC调⽤,对于⽬前使⽤率较⾼的Spring Cloud Netflix来说,它是基于HTTP的,所以效率上没有Dubbo⾼,但问题在于Dubbo体系的组件不全,不能够提供⼀站式解决⽅案。
  • Nocas:注册中⼼ + 配置中⼼的组合,帮助我们解决微服务开发必会涉及到的服务注册与发现,服务配置,服务管理等问题。Nacos 是Spring Cloud Alibaba 核⼼组件之⼀,负责服务注册与发现,还有配置。
  • Zookeeper:Zookeeper ⽤来做服务注册中⼼,主要是因为它具有节点变更通知功能,只要客户端监听相关服务节点,服务节点的所有变更,都能及时的通知到监听客户端,这样作为调⽤⽅只要使⽤ Zookeeper 的客户端就能实现服务节点的订阅和 变更通知功能了,zookeeper遵循半数集群可用原则。

  • Ribbon负载均衡

开发组件选择

Eureka注册服务中心

在微服务项目中,我们一般会对一个项目,以业务的维度拆分至多个服务,比如用户服务、账务服务、订单服务、仓储服务等,这些服务在生产环境部署,
至少是2个服务实例,如果业务量大几十个都是有可能的。

订单服务实例部署了4个,仓库服务部署了5个,仓库服务要调用订单服务,如果没有注册中心,他会怎么做,那只有把对应的ip和端口写死在代码中,如果新增了一个订单服务怎么办?或者下线了订单服务怎么办?

另外,在云环境中,服务实例随时都有可能启动和关闭,随之IP也会发生变化,没法把IP写死在代码中。

基于以上问题就有了服务注册中心Eureka

Eureka能实现服务自动的注册和发现,在每次服务调用的时候根据服务名称会获取到目标服务的IP和端口,在进行调用。

如果服务下线或者上线,对应的服务的地址信息也会进行更新,这样就保证了,随时可以调用到有效的服务。

同时为了提高性能,这个服务地址信息会在每个服务本地缓存一份地址信息表,定时更新,这样每次请求服务时,不用每次去Eureka查询来降低服务调用耗时。

我们部署一个Eureka Server,并将我们的微服务(部门服务和用户服务)作为 Eureka 客户端,注册到Eureka Server,同时使用用户服务调用根据部门服务的Service ID 来调用部门服务相关接口。

在项目中添加组件Eureka Server,pom文件中进行导入

主方法上我们需要添加@EnableEurekaServer注解,使我们应用程序成为服务注册中心。

默认情况下,每个Eureka Server 也是一个Eureka客户端。由于我们只想让他做好服务注册中心,不想让他做客户端,因此我们将通过在application.properties文件中配置以下属性来禁用此客户端行为。

1
2
3
4
spring.application.name=Eureka Server
server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

访问http://localhost:8761,会显示以下界面

然后将一个服务注册到Eureka Server

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
1
2
3
4
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>2021.0.4</spring-cloud.version>
</properties>

在application.properties中配置eureka.client.service-url.defaultZone 属性 即可自动注册到 Eureka Server。

1
2
spring.application.name=DEPARTMENT-SERVICE
eureka.instance.client.serviceUrl.defaultZone=http://localhost:8761/eureka/

当服务注册到 Eureka Server 时,它会在一定的时间间隔内不断发送心跳。如果 Eureka 服务器没有收到来自任何服务实例的心跳,它将假定该服务实例已关闭并将其从池中取出

然后目标服务添加注释:

1
@FeignClient(value = "DEPARTMENT-SERVICE")

Open Feign服务调用

声明式 HTTP RPC 调用

1
@FeignClient(name = "forum-auth", path = "/api/auth")

表示这是一个 Feign 客户端接口,要去调用 名为 forum-auth 的微服务。

  • name:对应 Spring Cloud 服务注册中心(如 Nacos、Eureka)里的服务名
  • path:接口调用时统一加上的路径前缀

你在别的模块里注入这个接口(@Autowired AuthFeignClient client;),就能直接像调用本地方法一样发起远程 HTTP 请求

那么什么是RPC呢?

你写一个 Java 接口并打上 Feign 注解(不需要写实现类)。

Spring Cloud OpenFeign 会在运行时为这个接口创建 动态代理对象

当你调用方法时,代理会根据注解信息拼接成 HTTP 请求(GET /api/auth/...),
并通过负载均衡(Ribbon/Spring Cloud LoadBalancer)调用到 forum-auth 服务的对应接口。

返回 JSON 会被自动反序列化成 Result<T> 类型。

Nacos服务注册

config配置类:

@postconstruct的init方法,设置本地地址和元数据,重写run方法

然后写NacosShutdownHook类,关闭nacos,防止解决DefaultHttpClientFactory无法加载的问题。

然后再application文件中加入配置

1
2
3
4
5
6
7
8
9
10
11
12
13
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:}
group: ${NACOS_GROUP:DEFAULT_GROUP}
enabled: true
config:
server-addr: ${NACOS_SERVER_ADDR:localhost:8848}
file-extension: yaml
namespace: ${NACOS_NAMESPACE:}
group: ${NACOS_GROUP:DEFAULT_GROUP}
enabled: true

填写组别,发现端口,name名字,然后记录服务端的端口,组别等

Seata 分布式事务

​ Config配置文件:

根据 环境(dev/test/prod)和 服务名 动态生成事务组名,避免不同环境污染。

使用配置中心(Nacos / Apollo)动态管理事务组。

1
2
3
4
5
6
7
8
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
String txServiceGroup = String.format("%s-%s-tx-group",
System.getenv("SPRING_APPLICATION_NAME"),
System.getenv("ENV"));
return new GlobalTransactionScanner("forum-seata-group", txServiceGroup);
}

​ 不同的模式:

当前是 AT 模式(自动 SQL 拦截 + 全局锁),性能不错但会加大数据库锁粒度。

可以升级为:

长事务/跨多服务耗时操作改用 Saga 模式(状态机驱动 + 补偿动作)。

高并发热点写改用 TCC 模式(Try-Confirm-Cancel)避免全局锁。

​ 回滚策略:

然后对不同类型的异常,设定不同的回滚的策略

区分 业务异常(不回滚,如参数错误)和 系统异常(回滚,如数据库/网络错误)。

可自定义 BusinessException 并在 @GlobalTransactional 里排除。

​ 告警:

接入 Seata 控制台,实时查看全局事务状态。

通过 Prometheus + Grafana 做事务失败率/超时监控。

超时自动告警(钉钉/企业微信)。

集成 SkyWalking/Zipkin,将 XID 作为 TraceId 的一部分,方便跨服务链路分析。

在全局事务开始、提交、回滚时打业务日志。

Gateway网关认证

安全性升级

  • 现在 JWT 一旦签发,在过期时间内无法撤销(除非改密钥)。升级建议:引入 Redis 存储黑名单 Token,用户登出或被封禁时将 Token 加入黑名单,网关在过滤器里校验。

  • 目前 JwtUtil 应该是固定签名密钥,建议使用 定期轮换(Key Rotation),减少泄漏风险。可以用 kid(key id)标记密钥版本,JWT 验证时先取 kid,再用对应密钥解密。

  • 给 Token 增加 jti(唯一 ID),在 Redis 里做一次性校验,防止别人抓包重放。

扩展性升级

  • 白名单配置化,现在白名单是写死在代码里的,可以改成 Nacos / Apollo 配置动态加载:
  • 现在你用 X-User-IdX-Username,但可以考虑:全量透传 claims(JSON 压缩后放 header 或 Gateway Request Attribute)。或者只传一个 User-Context Base64,后端统一解码。

性能优化

  • Gateway 是 Reactor 模型,要确保 JwtUtil 验证不会有阻塞 IO(如 Redis、文件操作)。如果 JWT 公钥存 Redis,可提前加载到内存,用 Cache 缓存,减少每次请求访问 Redis。
  • 白名单频繁变动时,用单个 volatile 变量指向 Set<String>,避免并发锁开销。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package com.forum.gateway.filter;

import com.forum.common.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.SignatureException;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.Config> {

private final JwtUtil jwtUtil;
private final AntPathMatcher pathMatcher = new AntPathMatcher();

// 解析缓存(避免每次都重复解析 JWT)
private final Map<String, Claims> tokenCache = new ConcurrentHashMap<>(256);

@Value("${gateway.auth.tokenHeader:Authorization}")
private String tokenHeader;

public AuthGatewayFilterFactory(JwtUtil jwtUtil) {
super(Config.class);
this.jwtUtil = jwtUtil;
}

@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();

// 白名单放行
if (isWhiteListed(path, config.getWhiteList())) {
return chain.filter(exchange);
}

// 获取 Token
String token = getToken(request);
if (StringUtils.isBlank(token)) {
return unauthorizedResponse(exchange, "缺少认证信息");
}

try {
Claims claims = tokenCache.computeIfAbsent(token, t -> jwtUtil.getClaimsFromToken(t));

// 校验 Token 是否过期
if (jwtUtil.isTokenExpired(token)) {
tokenCache.remove(token);
return unauthorizedResponse(exchange, "Token 已过期");
}

// 提取用户信息并添加到请求头
Long userId = claims.get("userId", Long.class);
String username = StringUtils.defaultString(claims.getSubject(), "unknown");

ServerHttpRequest mutatedRequest = request.mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-Username", username)
.header("X-Trace-Id", UUID.randomUUID().toString())
.build();

return chain.filter(exchange.mutate().request(mutatedRequest).build());

} catch (ExpiredJwtException e) {
log.warn("Token 已过期: {}", token);
return unauthorizedResponse(exchange, "Token 已过期");
} catch (SignatureException e) {
log.warn("Token 签名无效: {}", token);
return unauthorizedResponse(exchange, "无效的认证信息");
} catch (Exception e) {
log.error("Token 验证失败", e);
return unauthorizedResponse(exchange, "认证失败");
}
};
}

private boolean isWhiteListed(String path, List<String> whiteList) {
return whiteList.stream().anyMatch(pattern ->
path.equals(pattern) || path.startsWith(pattern) || pathMatcher.match(pattern, path)
);
}

private String getToken(ServerHttpRequest request) {
String authorization = request.getHeaders().getFirst(tokenHeader);
if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7).trim();
}
return null;
}

private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().set("Content-Type", "application/json;charset=UTF-8");

String body = String.format("{\"code\":401,\"message\":\"%s\",\"timestamp\":%d}",
message, System.currentTimeMillis());

DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}

@Data
public static class Config {
private List<String> whiteList = Arrays.asList(
"/auth/login",
"/auth/register",
"/auth/captcha",
"/doc.html",
"/swagger-ui/**",
"/v3/api-docs/**"
);
}
}