项目结构
-
按照业务拆分为:用户模块(8080端口)、博客模块(9090端口)
-
将多个模块中的公共 SDK 等统一放到
blog-common模块中 -
网关服务
gateway-service(10020端口)单独作为一个模块 -
blog-info-api和user-info-api模块各自存放对应服务的公共 SDK 比如 feign远程调用接口 、pojo 等数据。

Controller 层返回 Result 是该用手动写还是使用 ResultAdvice 统一处理❓❓❓
先说结论:当 Controller 已经显式返回 Result<T> 时,再使用 ResultAdvice,会有以下问题:
-
冗余
-
增加隐式行为(双层
Result嵌套) -
在微服务场景下容易出事故
所以大多数微服务项目刻意不用 ResultAdvice。
| 层级 | 建议 |
|---|---|
| Gateway层 | 统一异常与拦截结果(如 401 / 403 / 429 / 503) |
| Controller服务层 | 显式(手动)返回 Result<T> |
| Feign远程调用层 | 统一显式(手动)使用 Result<T> |
| Exception服务层 | @ControllerAdvice 只处理异常 |
因为此时是微服务架构,不再是之前的单体架构,所以有一个关键前提:Controller = Feign 契约实现,即两者的接口要一致 。
并且 Feign 需要 "确定性返回值",比如下面的接口:
Result<Product> getById(Long id);那 Feign 必须 拿到的就是:
{
"code": 0,
"data": {...}
}此时如果使用 Controller 层又用 ResultAdvice 统一处理就会导致双层嵌套问题。
举个例子:Controller 已经返回 Result
@GetMapping
public Result<Product> get() {
return Result.ok(product);
}此时由于存在 ResultAdvice 统一处理,所以再包一层,变成:
{
"code": 0,
"data": {
"code": 0,
"data": {...}
}
}👉 双层 Result,直接炸。
行业里的主流做法
-
Controller 显式返回
Result<T> -
Feign 接口返回
Result<T> -
不使用
ResultAdvice -
统一异常 →
@RestControllerAdvice
网关认证过滤
结合上面的问题,可以得出结论:
Gateway 只 "统一异常与拦截结果",不 "统一业务成功结果" 。(所以不能在网关中编写 ResultAdvice 这类工具来统一返回业务成功结果)
| 场景 | 是否在 Gateway 统一 | 原因 |
|---|---|---|
| 登录未授权 | ✅ | 业务未到达服务 |
| Token 过期 | ✅ | 网关拦截 |
| 限流 | ✅ | 网关能力 |
| 熔断 | ✅ | 网关兜底 |
| 服务下线 | ✅ | 网关可感知 |
| 参数校验失败(Controller) | ❌ | 业务语义 |
| 业务异常 | ❌ | 业务语义 |
| 成功返回 | ❌ | 数据结构未知 |
通常用户鉴权放到网关来处理,下面是样例:
@Slf4j
@Component
public class AuthFilter **implements GlobalFilter, Ordered** {
@Autowired
private ObjectMapper objectMapper; // jackson序列化对象
private List<String> whiteList = List.*of*("/user/login", "/user/register"); // 白名单
@Override
public Mono<Void> **filter** (ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求和响应(通过Spring WebFlux中的ServerWebExchange获取)
ServerHttpRequest request = exchange.getRequest();
// 如果请求路径在白名单中的,直接放行
if(whiteList.contains(request.getURI().getPath())) {
return chain.filter(exchange);
}
// 如果非白名单,则进行身份验证
String userToken = request.getHeaders().getFirst("user_token");
*log*.info("user_token: {}", userToken);
if(!StringUtils.*hasLength*(userToken)) {
return invalidResponse(exchange, "token不存在!");
}
Integer userId = JWTUtils.*getUserIdFromToken*(userToken);
if(userId == null) {
return invalidResponse(exchange, "token无效!");
}
// 到这说明可以验证通过,放行
return chain.filter(exchange);
}
@Override
public int **getOrder** () {
return -200; // 值越小,优先级越高
}
// 处理非法响应情况,这种写法适合绝大多数统一异常处理或响应封装场景,直接用就行了,不需要深挖细节
@SneakyThrows
private Mono<Void> invalidResponse(ServerWebExchange exchange, String msg) {
// 设置响应状态,设置响应类型为json
*log*.error("[用户身份认证异常] 请求路径为:{}", exchange.getRequest().getURI().getPath());
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.*UNAUTHORIZED*);
response.getHeaders().add(HttpHeaders.*CONTENT_TYPE*, MediaType.*APPLICATION_JSON_VALUE*);
Result result = Result.*fail*(msg);
DataBuffer dataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(result));
return response.writeWith(Mono.*just*(dataBuffer));
}
}这里对 invalidResponse() 中最后三行代码进行解释:
-
构建统一响应对象
Result -
序列化成 JSON 字节数组 → 包装成
DataBuffer -
异步写入响应流 → 返回
Mono<Void>表示完成
-
DataBuffer dataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(result));-
objectMapper.writeValueAsBytes(result)-
用 Jackson 将
result转成 JSON 字节数组 。 -
返回类型:
byte[] -
注意:
-
可能抛
JsonProcessingException,所以你可能要用@SneakyThrows或 try/catch。 -
JSON 格式会根据
Result的字段和 Jackson 注解决定,比如@JsonIgnore会被忽略。
-
-
-
response.bufferFactory().wrap(...)-
response是ServerHttpResponse。 -
bufferFactory()返回DataBufferFactory,用来生成DataBuffer。 -
wrap(byte[])把字节数组包装成 不可变的DataBuffer。 -
DataBuffer是 WebFlux 的 非阻塞响应数据容器 ,类似 Servlet 的输出流。
-
- 结果:得到一个 准备写给客户端的 JSON 数据缓冲区 。
-
return response.writeWith(Mono.just(dataBuffer));-
writeWith是 WebFlux 的 响应写入方法 :-
参数类型:
Publisher<DataBuffer>(这里用Mono.just(dataBuffer)) -
将
DataBuffer写到 响应体 。
-
-
注意:
-
返回值类型:
Mono<Void>,表示异步完成。 -
WebFlux 完全异步非阻塞 ,所以你不能直接
return dataBuffer,必须封装成Publisher。
-
-
Mono是响应式编程库 Reactor 中的一个类,代表一个异步的,可能只发出一个数据的响应式类型,Mono.just(dataBuffer)创建了一个包含dataBuffer的Mono实例,这意味着它将异步地发出一个数据,即前面创建的DataBuffer对象,response.writeWith(...)方法接受一个Publisher(Mono是Publisher的一个实现),并将其作为 HTTP 响应的主体。writeWith方法将Mono.just(dataBuffer)作为响应体,发送给客户端。
Spring WebFlux是Spring5中引入的一个响应式编程模块,旨在为构建非阻塞,事件驱动的服务提供支持。Spring Cloud Gateway是基于Spring WebFlux构建的,这意味着Spring Cloud Gateway继承了Spring WebFlux的非阻塞 I/O 和反应式编程特性,使其能够以异步的方式处理大量的并发请求,同时保持较低的资源消耗。这种结合使得Spring Cloud Gateway能够⾼效地处理⾼并发请求,同时保持系统的可伸缩性和弹性。
nacos配置中心的作用
上述代码中有个问题,就是白名单 whiteList 是写死的,每次修改后都需要对代码进行编译、测试、部署等操作,相当麻烦,所以就需要用到配置中心来解决这个繁琐问题了。
首先到 nacos 配置中心里面添加配置,这里属性写为 auth.white.url,然后把白名单列上去即可:

注意 DataID 的格式分为三部分:
${prefix}-${spring.profiles.active}.${file-extension}-
prefix:默认为spring.application.name的值,当然也可以通过配置项spring.cloud.nacos.config.prefix来配置。 -
spring.profiles.active:当前环境对应的 profile。当spring.profiles.active为空时,对应的连接符-也将不存在,dataId 的拼接格式变成${prefix}.${file-extension} -
file-exetension:配置内容的数据格式,可以通过配置项spring.cloud.nacos.config.file-extension来配置。目前只支持properties和yaml类型,默认为properties。
这里采用的是 yaml 格式,所以需要显式写上 .yaml,然后在网关服务中创建一个白名单类专门接收这些配置属性:
package com.liren.gateway.properties;
@Data
@Configuration
**@RefreshScope // 更新配置后自动刷新**
**@ConfigurationProperties** ("auth.white") // 指定属性
public class AuthWhiteList {
private List<String> url; // 白名单配置列表
}然后就能在过滤器那里直接注入使用了:
@Slf4j
@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 白名单
// private List<String> whiteList = List.of("/user/login", "/user/register");
@Autowired
private AuthWhiteList whiteList; // 不再写死,自动更新配置
...
}记得别忘了 bootstrap.yml 配置文件:
spring:
application:
name: gateway-service
cloud:
nacos:
config:
server-addr: lirendada.art:8848
file-extension: yaml注意: Nacos 配置中心地址、命名空间、组、DataId 必须在 bootstrap.yml中 ,这些信息必须比 application.yml 先加载,否则 Spring Cloud 无法连接 Nacos 获取配置。
公共 SDK 是否引入依赖❓❓❓
比如 JWT
-
✅ 可以引入依赖
-
原因:
-
纯计算(算法 / 编解码)
-
不依赖外部服务
-
不绑定 Spring / 环境
-
-
做法:
-
SDK 直接提供
JwtUtil等工具类 -
不读配置、不当 Bean
-
比如 Redis
-
❌ 不建议引入依赖
-
原因:
-
依赖外部基础设施
-
强依赖 Spring、连接池、配置
-
并非所有模块都需要
-
-
做法:
-
SDK 只定义接口(如
RedisOperator),具体实现放在业务模块,通过 Bean 注入 -
如果真的需要直接在 SDK 中实现操作供业务模块直接使用,那不得不引入依赖 ,不过遵守一个原则:宁可构造器注入,也别直接
@Autowired。因为直接注入的话 SDK 就强依赖 Spring 容器,业务模块就失去了管理主动权。
-
上述第二种做法如下所示:
public class RedisUtil {
private final StringRedisTemplate redisTemplate;
public RedisUtil(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void set(String key, String value, long seconds) {
redisTemplate.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
}
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
}关键点:
-
✅ 只依赖构造器参数。
-
❌ 不加
@Component -
❌ 不用
@Autowired -
❌ 不读配置
然后在业务模块中把它注册成 Bean:
@Configuration
public class RedisSdkConfig {
@Bean
public RedisUtil redisUtil(StringRedisTemplate redisTemplate) {
return new RedisUtil(redisTemplate);
}
}注意: 直接在业务模块中使用的话会出现找不到 RedisUtil 这个 Bean 的情况,这是因为在业务模块中扫描不到公共 SDK 这个模块中的 Bean,自然就没办法交给 Spring 管理,所以就得在启动类中使用 @ComponentScan 添加 RedisUtil.class 才行。(这里了解为什么即可,下面会解决这个问题)
自动装配 && @Conditional条件注册
首先上面这种当每个业务模块需要用到 RedisUtil 的时候,都需要去写一个 Config,这是非常麻烦的,所以干脆直接将 Config 也一起放在公共 SDK 模块中,如下所示:

然后业务模块在使用的时候需要在启动类进行路径扫描:
**@ComponentScan(basePackages = "com.liren.common")**
@EnableFeignClients(clients = {BlogInfoApi.class})
@SpringBootApplication
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.*run*(UserServiceApplication.class, args);
}
}但是这又引入了一个问题:如何不进行路径扫描就能使用 RedisUtil 呢❓❓❓
这就需要用到自动装配了,具体细节参考:[BarLwtjq3iG940k50wjcJXkknXf],这里主要讲解如何操作!
首先来解决自动装配的问题,在公共 SDK 模块的 resources 目录下创建文件 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,如下所示:

然后往该文件中填入要自动装配的类的路径:
com.liren.common.config.RedisConfig之后就不用在业务模块的启动类中使用 @``ComponentScan 手动扫描路径了,可以直接使用!
以上确实解决了自动装配的问题,但又引入了一个问题:
不管业务模块是否使用到了 RedisUtil,只要添加了这个公共 SDK模块的依赖之后,都会自动装配 RedisUtil 到容器中 ,这是不合理的。
为了解决这个问题,需要使用 @Conditional 注解来进行条件过滤。
直接在公共 SDK 模块中的 RedisConfig 配置类中添加以下代码:
@Configuration
public class RedisConfig {
@Bean
**@ConditionalOnProperty** (
prefix = "spring.data.redis",
name = {"host", "port"}, *// 两个关键配置都必须存在*
* *matchIfMissing = false *// 配置缺失时不加载*
* *)
public RedisUtil redisUtil(StringRedisTemplate stringRedisTemplate) {
return new RedisUtil(stringRedisTemplate);
}
}此后只有当业务模块中 application.yml 等配置文件配置了 redis 的信息,才会自动装配 RedisUtil 到模块中!
@Conditional详解
什么是@Conditional
@Conditional 是 Spring4.0 版本开始引入的一个注解,它允许开发者根据特定的条件来控制 Bean 的注册。一般与 @Configuration 和 @Bean 配合使用。
简单说,Spring 在解析 @Configuration配置类的时候,如果该配置类增加了 @Conditional注解,那么会根据该注解配置的条件来决定是否要实现 Bean 的装配 。
@Conditonal 注解类声明代码如下,该注解可以接收一个 Condition 的数组,位于 org.springframework.context.annotation 包下。
package org.springframework.context.annotation;
@Target({ElementType.*TYPE*, ElementType.*METHOD*})
@Retention(RetentionPolicy.*RUNTIME*)
@Documented
public @interface Conditional {
*/***
* * All {@link Condition} classes that must {@linkplain Condition#matches match}*
* * in order for the component to be registered.*
* */*
* *Class<? extends Condition>[] value();
}其中 Condition 是一个函数式接口,提供了 matches 方法,它主要是提供一个条件匹配规则,返回 true 表示可以注入 Bean,反之不注入。
@FunctionalInterface
public interface Condition {
* *boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}条件装配案例
下面自定义一个 Condition,如果当前 JDK 版本为 21,则注册 JDK21;如果当前 JDK 版本是 17,则注册 JDK17:
package com.liren.user;
import org.junit.jupiter.api.Test;
import org.springframework.boot.system.JavaVersion;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.*;
import org.springframework.core.type.AnnotatedTypeMetadata;
@SpringBootTest
public class conditionalTest {
@Test
public void testConditional() {
System.*out*.println("条件装配测试");
}
}
@Configuration
class AppConfig {
@Bean
**@Conditional(Jdk17Condition.class)**
public JDK17 jdk17() {
System.*out*.println("初始化jdk17");
return new JDK17();
}
@Bean
**@Conditional(Jdk21Condition.class)**
public JDK21 jdk21() {
System.*out*.println("初始化jdk21");
return new JDK21();
}
}
class JDK17 {}
class JDK21 {}
// 自定义Condition
class Jdk17Condition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return JavaVersion.*getJavaVersion*().equals(JavaVersion.*SEVENTEEN*);
}
}
// 自定义Condition
class Jdk21Condition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return JavaVersion.*getJavaVersion*().equals(JavaVersion.*TWENTY_ONE*);
}
}
因为当前机器是 JDK17 版本,所以就只注册了对应的版本!
拓展注解
Spring Boot 的自动装配体系,本质就是 @Conditional+ 一堆语义化扩展注解 ,目的是:在 "依赖、配置、环境、Bean 状态" 满足时,才把 Bean 放进容器。
1️⃣ @ConditionalOnProperty(出现频率 No.1)
用途 :配置开关、是否启用某功能 / 模块
@ConditionalOnProperty(
prefix = "redis", // 配置前缀,对应 redis.enabled
name = "enabled", // 配置项名称
havingValue = "true", // 当值 == "true" 时条件成立
matchIfMissing = false // 配置不存在时是否也算成立
)
@Bean
public RedisClient redisClient() {
return new RedisClient();
}📌 对应配置文件:
redis:
enabled: true⚠️ 经验总结:
-
如果不写
havingValue→ 表示只要存在就生效 -
matchIfMissing = true→ 默认开启(Boot 很常用)
2️⃣ @ConditionalOnMissingBean
用途 :容器中不存在该 Bean 时,配置才生效
@ConditionalOnMissingBean(RedisTemplate.class) **// 容器中不存在该 Bean 时才生效**
@Bean
public RedisTemplate<String, Object> redisTemplate() {
return new RedisTemplate<>();
}📌 含义一句话:"你不配,我来配;你自己配了,我就闭嘴。"
⚠️ 常见误区:
-
判断的是 Spring 容器里有没有
-
和类路径无关
3️⃣ @ConditionalOnBean
用途 :某 Bean 必须存在,当前配置才加载
@ConditionalOnBean(DataSource.class) **// 容器中必须已经有 DataSource 才生效**
@Bean
public TransactionManager transactionManager() {
return new DataSourceTransactionManager();
}📌 常见组合:
-
OnBean + OnMissingBean -
做 "增强型配置"
4️⃣ @ConditionalOnClass(判断依赖是否引入)
用途 :是否引入了某个 starter / 三方库
@ConditionalOnClass(
name = "org.springframework.data.redis.core.StringRedisTemplate"
// 使用 name 是为了避免 ClassNotFoundException
)
@Bean
public RedisHelper redisHelper() {
return new RedisHelper();
}📌 使用原则:
-
写自动装配一定要用它
-
不要直接写
RedisTemplate.class,不然 starter 未引入的话会直接炸
@ConditionalOnClass 和 @ConditionalOnBean 的区别
| 维度 | @ConditionalOnClass | @ConditionalOnBean |
|---|---|---|
| 判断对象 | 类(class)是否存在 | Bean 是否存在于容器 |
| 判断时机 | 编译期 / 启动时 | 运行时 / 容器刷新时 |
| 是否依赖容器 | ❌ 不依赖 | ✅ 依赖 |
| 典型用途 | 判断 jar/starter 是否引入 | 判断其它 Bean 是否已被装配 |
| 例子 | RedisTemplate 是否在 classpath | DataSource 是否在容器 |
两者经常是搭配使用的,如下所示:
@ConditionalOnClass(RedisTemplate.class) // 先保证 Redis 库存在
@ConditionalOnBean(DataSource.class) // 再保证已经有 DataSource Bean
@ConditionalOnMissingBean(RedisClient.class) // 用户没自己定义 RedisClient
@Bean
public RedisClient redisClient() {
return new RedisClient();
}5️⃣ @ConditionalOnMissingClass(兜底/冲突规避)
用途 :当某类不存在时才生效
@ConditionalOnMissingClass(
"com.xxx.custom.CustomRedisClient"
)
@Bean
public DefaultRedisClient defaultRedisClient() {
return new DefaultRedisClient();
}📌 常见于:
- 公共 SDK
防止和业务自定义实现冲突
6️⃣ @ConditionalOnWebApplication
用途 :区分 Web / 非 Web 项目
@ConditionalOnWebApplication(
type = ConditionalOnWebApplication.Type.SERVLET
// SERVLET:Spring MVC
// REACTIVE:WebFlux
)
@Bean
public WebInterceptor webInterceptor() {
return new WebInterceptor();
}7️⃣ @Profile(本质也是 Conditional)
用途 :环境隔离
@Profile("dev") // 仅在 spring.profiles.active=dev 时生效
@Bean
public DataSource devDataSource() {
return new HikariDataSource();
}⭐ 企业级黄金组合(直接记)
@ConditionalOnClass(SomeClient.class)
@ConditionalOnProperty(prefix = "feature.x", name = "enabled", havingValue = "true")
@ConditionalOnMissingBean(SomeClient.class)📌 含义:"依赖在 + 配置开 + 用户没自己实现" → 我才自动装配
前端跨域问题
在后端网关模块中添加以下配置:
spring:
cloud:
gateway:
*# 全局跨域配置*
* *globalcors:
add-to-simple-url-handler-mapping: true
cors-configurations:
'[/**]':
allowedOrigins:
- "http://localhost"
- "http://127.0.0.1"
- "http://49.235.136.223"
- "http://lirendada.art"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders: "*"
allowCredentials: true