目录
2.实现用户控制器UserController.class并定义接口
2.pom.xml引入Spring Security安全框架
3.实现redis序列化配置类 RedisConfig.class
4.实现spring security配置类SecurityConfig.class
2.redis序列化类型类RedisConfig.class
3.用户上下文工具类 UserContextHolder.class
4.统一请求头过滤器RequestHeaderFilter.class
🔆前言
本系列的 Spring Cloud Alibaba 微服务实践 已经更新到第七篇。最近入职的新项目同样采用了 Spring Cloud Alibaba 体系,但由于项目架构由其他同事预先搭建完成,其中的一些设计与我的理解和最佳实践有所不同。作为后来者,我无法对现有项目做大规模调整,因此选择在个人的开源项目和系列博客中进行补充与优化,以便更贴近标准化的微服务实践,并发挥其应有优势。
前七篇主要介绍了各组件的集成,但要打造一个真正 开箱即用的微服务脚手架,还需要补齐基础功能,让项目在拉取后即可快速进入业务开发。接下来的章节将逐步完善这些能力。
讨论
在动手实现代码之前,需要先明确两个关键问题:
统一 API 集成 vs 子服务自治
第一个需要讨论的问题是在微服务架构中,API 接口该如何管理与使用。这直接关系到后续统一网关鉴权的设计。目前常见的做法主要有两种:
统一 API 管理服务:所有接口统一在一个服务中定义与实现,具体业务数据再由该服务调用其他子服务完成。(这是当前公司项目采用的方案,由同事在前期搭建完成)
子服务高度自治:每个子服务独立对外提供接口,接口职责与业务逻辑严格划分,服务之间只通过调用或消息进行交互。(本篇将采用此方案)
对于为什么本篇采用第二种方案,下面总结了几点
1.单点故障风险
- 统一 API 服务承担所有接口聚合,一旦某台 API 服务故障,它上面承载的所有接口都会不可用,影响项目整体吞吐量。
- 分而治之,每个业务服务独立运行,单个服务故障只影响该业务相关接口,故障范围小,易排查。
2.运维和扩展困难
- API 服务水平扩展需要整体重新部署,接口服务都要重新构建,增加运维复杂度。
- 业务服务独立扩展,根据不同业务压力动态扩容。例如支付压力大,就只扩展支付服务,提高吞吐量更精准。
3.网络请求和延迟问题
- API 服务聚合逻辑增加了一层网络请求,多次跨服务调用导致响应时间不可预测,延迟不可控。
- 业务服务内部处理自己的数据,直达调用,延迟更低且可预期。
4.微服务理念和解耦
- 虽然每个服务都需要独立维护、扩展和配置,但这是微服务的本质——解耦、独立部署、按业务扩容。
- 运维成本增加是不可避免的,是微服务成熟和可扩展的必经过程。
5.聚合逻辑的权衡
- API 服务适合轻量级聚合或统一接口出口,但业务逻辑复杂的跨服务聚合,应尽量在业务服务内部完成,避免 API 服务成为性能瓶颈
在微服务项目中,推荐使用子服务自治方案,是为了降低服务耦合、减少单点故障风险、提高扩展性和性能,同时更符合微服务解耦与独立部署的理念。
API 模块依赖 vs 自定义 Feign
第二个需要讨论的问题是:在微服务项目中,如何使用 OpenFeign 调用其他子服务的接口。目前常见的做法主要有两种:
- 独立 API 子模块:每个子服务维护一个单独的 API 模块(通常只包含接口定义和对象),其他服务如需调用时,直接引入对应的 API 依赖。这种方式可以避免接口与 DTO 的重复定义,保证调用方与被调用方的一致性。(这是当前公司项目采用的方案,由同事在前期搭建完成)
调用方自定义 Feign 接口:被调用子服务只需对外暴露 REST 接口,调用方在自身服务中定义 FeignClient 来调用这些接口。与接口相关的 DTO/VO 则可放入公共模块,供双方共享。这种方式更加灵活,但需要调用方自己维护接口定义。(本篇将采用此方案)
对于为什么本篇采用第二种方案,下面总结了几点
服务耦合性
API 模块依赖增加服务间耦合,可能引发依赖冲突,维护和排查复杂;自定义 Feign 接口松耦合,只在需要的服务中定义接口,扩展性好。
版本管理与依赖更新
API 模块更新需要重新打包并在所有依赖服务中重新加载,否则可能出现版本不一致问题;自定义 Feign 接口避免这种版本冲突,公共模块只提供 DTO/VO 和工具类。
接口使用灵活性
API 模块依赖让所有服务都必须引入相同接口,使用不灵活;自定义 Feign 接口只在真正需要的服务中定义,按需使用,符合微服务设计理念。
在微服务架构中,推荐使用 自定义 Feign 接口 的方案。虽然在初期需要多写一些接口定义,但可以保证服务松耦合、扩展性好,并且避免版本依赖冲突,为后续统一网关鉴权和业务扩展提供了稳固基础。
上述问题在当前公司项目中几乎都出现过,确实带来诸多困扰。作为后来者,我只能适应现状并进行改进,希望看到这篇文章的小伙伴在项目初期能够谨慎选择架构方案,从而减少后期维护成本和复杂度。下面正式开始代码实现。
实践
代码实现我们分为三个部分:新增用户服务实现登录功能、网关服务实现统一鉴权、新增公共基础服务
源码获取:GitHub - RemainderTime/spring-cloud-alibaba-base-demo: 基于spring cloud alibaba生态快速构建微服务脚手架
实现用户服务
下面是用户服务的大致结构,本篇对主要逻辑进行代码展示,具体可访问GitHub源码进行获取
注:nacos远程的公共配置文件都放在了项目的doc文件中,可自行导入到自己的配置中心,每个子服务的配置文件都有对应的local版本,也可自行修改为dev后缀导入到配置中心
1.配置文件设置application.yml
spring:
application:
name: cloud-user
profiles:
active: dev
cloud:
nacos:
discovery: #注册中心
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PWD:nacos}
namespace: 74193cd9-fac4-4f2a-addc-47c60508b15c
cluster-name: DEFAULT # 集群名称,保持一致
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PWD:nacos}
file-extension: yml
namespace: 74193cd9-fac4-4f2a-addc-47c60508b15c
config:
import:
- nacos:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
- nacos:redis-common.yaml
应该发现了我们把nacos配置文件放在了application.yml中,而没有bootstrap.yml配置了,本项目其他子服务的配置文件都进行了优化,具体可以查看项目源码
因为我们项目使用的Spring boot版本为3.3.5,在Spring Boot 2.4+之后已经废弃bootstrap.yml。原因之一就是Spring Boot 本来有一套 application.yml + spring.profiles.active 的配置体系, bootstrap.yml 算是 Spring Cloud 强行加的,跟 Spring Boot 的设计理念不统一。因此Spring Boot 2.4+之后引入spring.config.import 统一声明外部配置源并且支持 optional:(可选加载,不存在也不会报错)
(如果你偏要使用原来的,则需要单独引入bootstrap的maven依赖也可以实现以前的方式)
下面是代码类基本结构组成
登录用户的token生成使用的是JWT框架,并存储在redis中,这也是比较常用的方案登录方案
2.pom.xml主要依赖引入
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--数据库 mysql-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- orm mybatis plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- 数据库数据源动态切换组件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
<!-- 公共模块 -->
<dependency>
<groupId>com.xf</groupId>
<artifactId>cloud-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
2.实现用户控制器UserController.class并定义接口
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
/**
* 登录
* @param req
* @return
*/
@PostMapping("/login")
public RetObj login(@RequestBody LoginInfoReq req){
return userService.login(req);
}
/**
* 获取登录用户名称
* @return
*/
@GetMapping("/getUserName")
public RetObj getUserName(){
return RetObj.success(UserContextHolder.getName());
}
}
3.登录逻辑实现
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private RedisTemplate redisTemplate;
@Override
public RetObj login(LoginInfoReq req) {
//校验登录账号密码
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper();
queryWrapper.eq(User::getAccount, req.getAccount());
queryWrapper.eq(User::getPassword, req.getPassword());
User user = this.baseMapper.selectOne(queryWrapper);
if (Objects.isNull(user)) {
return RetObj.error("账号或密码错误");
}
LoginUser loginUser = new LoginUser();
loginUser.setId(user.getId());
loginUser.setAccount(user.getAccount());
loginUser.setName(user.getName());
loginUser.setPhone(user.getPhone());
//生成token
String token = JwtTokenUtils.createToken(user.getId());
loginUser.setToken(token);
//缓存token
redisTemplate.opsForValue().set("alibaba-token:" + token, JSON.toJSONString(loginUser), 3600, TimeUnit.SECONDS);
redisTemplate.opsForValue().set("alibaba_user_login_token:" + user.getId(), token, 3600, TimeUnit.SECONDS);
return RetObj.success(loginUser);
}
}
注:这里可能以及出现获取用户名称的接口中对象UserContextHolder和redis找不到依赖了,不要着急,因为我们已经把这些提取到了公共服务cloud-common中,后面第三点中会讲到,因为这些功能其他子服务也会用到。
网关服务实现统一鉴权
本微服务项目的实现方案就是,所以接口都先请求到网关服务,然后由网关服务中配置的路由分发到对应的子服务,所以只需要在网关服务中实现统一鉴权,其他子服务不在重复鉴权。
1.远程的application-dev.yml路由配置
server:
port: 9090
spring:
cloud:
gateway:
discovery:
locator:
enabled: true # 开启自动服务发现
routes:
- id: cloud-producer
uri: lb://cloud-producer
predicates:
- Path=/cloud-producer/** # 匹配路径 /test/...
filters:
- StripPrefix=1
- id: cloud-consumer
uri: lb://cloud-consumer
predicates:
- Path=/cloud-consumer/**
filters:
- StripPrefix=1
- id: cloud-user
uri: lb://cloud-user
predicates:
- Path=/cloud-user/**
filters:
- StripPrefix=1
loadbalancer: #开启负载均衡
nacos:
enabled: true
retry:
enabled: true # 启用负载均衡重试机制 负载均衡策略默认为轮询,想要修改策略新版本中需要手动java代码配置,新版本中配置文件的方式不支持了,更灵活但略复杂
logging:
level:
org.springframework.cloud.gateway: DEBUG
org.springframework.cloud.nacos.discovery: DEBUG
可以看出每个服务都要单独的路由前缀,filters属性的值为- StripPrefix=1,表示路由到具体的服务后会把前缀自动去掉,如登录接口:http://localhost:9090/cloud-user/user/login
2.pom.xml引入Spring Security安全框架
在网关服务原来的基础上引入spring security安全框架,虽然现在只是用作了CSRF的禁用配置和接口放行功能,但是如果有小伙伴需要实现权限系统也能很好的实现,并且作为spring全家桶成员也更顺畅友好
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
网关服务单独引入了redis,因为作为网关服务不和其他业务服务公用cloud- common服务,需要单独处理,因为网关服务和其他业务服务公用的内容很少,并且getaway网关内部使用的是webflux依赖,而业务服务基本都是使用的web依赖,会存在依赖冲突等问题
3.实现redis序列化配置类 RedisConfig.class
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// value 使用 JSON 序列化(推荐 GenericJackson2JsonRedisSerializer)
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
4.实现spring security配置类SecurityConfig.class
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf(csrf -> csrf.disable()) // 禁用 CSRF
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/**").permitAll() //放行所有接口
.anyExchange().authenticated()
);
return http.build();
}
}
5.实现统一登录鉴权过滤器类
@Slf4j
@Component
public class AuthFilter implements GlobalFilter, Ordered {
private static final List<String> EXCLUDE_PATH_LIST = Arrays.asList("/cloud-user/user/login");
@Resource
private RedisTemplate redisTemplate;
private static final String SECRET_KEY = "expected-secret";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String requestURI = request.getURI().getPath();
// 白名单直接放行
if (EXCLUDE_PATH_LIST.stream().anyMatch(requestURI::startsWith)) {
//重新请求头方法,并设置自定义请求头数据
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders()); // 复制原始 headers
headers.set("X-Internal-Auth", SECRET_KEY); // 安全加 header
return headers;
}
};
return chain.filter(exchange.mutate()
.request(decorator)
.build());
}
// 获取 Token
String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !token.startsWith("Bearer")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 校验 Token
token = token.substring(7);
String key = "alibaba-token:" + token;
String userInfoJson = (String) redisTemplate.opsForValue().get(key);
if (Objects.isNull(userInfoJson)) {
// 登录校验失败,直接返回 JSON 响应
String body = "{\"code\":500,\"msg\":\"请先登录\"}";
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
// 修改请求头
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders()); // 复制原始 headers
headers.set("X-Internal-Auth", SECRET_KEY); // 安全加 header
//Header 默认只支持 ISO-8859-1,直接放中文 JSON 会被错误解码,所以传输时加密转码UTF-8
String base64 = Base64.getEncoder().encodeToString(userInfoJson.getBytes(StandardCharsets.UTF_8));
headers.set("X-UserInfo", base64); // 添加用户信息
return headers;
}
};
return chain.filter(exchange.mutate()
.request(decorator)
.build());
}
@Override
public int getOrder() {
return -100; // 保证在最前面执行
}
}
1.从代码可以看出,我们重写了请求头对象中的方法,在原本的请求头参数重追加了X-Internal-Auth属性,为了保证所有接口比心在通过网关转发,不能直接访问具体的子服务,在下级服务过滤器中获取校验(当然也可以在部署时只开放网关端口也可以避免绕过网关直接访问,加上X-Internal-Auth也起到双重防护)
2.在登录成功后也将用户信息加入到了请求头中,在下级服务过滤器中获取
实现公共服务cloud-common
对于公共服务使用的定义,一般只创建静态工具类、公共通用对象、公共统一配置类以及一些变动少,不涉及具体业务逻辑处理,不引入其他第三方依赖框架,只引入基础核心依赖,这样避免与其他服务过高的耦合性和不必要的版本冲突。
注:公共服务类没有配置文件
1.pom.xml引入依赖
<dependencies>
<!-- Web Starter 仅用于 Filter/Servlet API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope> <!-- 避免网关引入冲突 -->
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON 工具 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.49</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2.redis序列化类型类RedisConfig.class
(与上面的网关中配置的一样)
3.用户上下文工具类 UserContextHolder.class
存储登录用户信息
public class UserContextHolder {
private static final ThreadLocal<Map<String, String>> context = new ThreadLocal<>();
// 设置用户信息
public static void set(Map<String, String> userInfo) {
context.set(userInfo);
}
// 获取用户信息
public static Map<String, String> get() {
return context.get();
}
public static String getUserId() {
Map<String, String> userInfo = context.get();
return userInfo != null ? userInfo.get("id") : null;
}
public static String getName() {
Map<String, String> userInfo = context.get();
return userInfo != null ? userInfo.get("name") : null;
}
// 清理
public static void clear() {
context.remove();
}
}
4.统一请求头过滤器RequestHeaderFilter.class
验证接口网关防护以及用户信息获取
@Configuration
public class RequestHeaderFilter implements Filter {
private static final String INTERNAL_SECRET = "expected-secret";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String header = req.getHeader("X-Internal-Auth");
if (!INTERNAL_SECRET.equals(header)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "非法访问接口,禁止绕过网关访问");
}
String userBase64 = req.getHeader("X-UserInfo"); // 返回 String
if(!StringUtils.isEmpty(userBase64)){
//用户信息转码
String userJson = new String(Base64.getDecoder().decode(userBase64), StandardCharsets.UTF_8);
Map<String, String> map = JSON.parseObject(userJson, new TypeReference<>() {});
//将用户信息设置到自定义context中
UserContextHolder.set(map);
}
chain.doFilter(request, response);
}
}
5.cloud-common公共服务的依赖引入
只需要在每个业务子服务中引入依赖即可生效
<!-- 公共模块 -->
<dependency>
<groupId>com.xf</groupId>
<artifactId>cloud-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
注:因为本微服务项目是在同一个主体项目下创建并管理的子服务,所以可直接引入公共依赖,而如果每个子服务是单独的项目,不在一个版本管理体系中,那么公共服务模块需要先打包成jar包到本地或远程仓库,在通过maven方式引入依赖。
自此本篇的全部代码内容基本都完成了,让我们启动服务调用登录接口试试
调试
启动网关服务cloud- getaway以及用户服务cloud- common
1.调用登录接口返回
2.调用获取登录用户名称接口