Spring Cloud 微服务开发指南
如同 Spring Boot 在 Java Web 开发领域中的统治地位,Spring Cloud 在 Java 微服务应用开发领域中同样处于垄断地位。软件系统从单体升级到微服务架构,随之会出现各种分布式系统所特有的问题,包括服务注册发现、认证授权、限流熔断、调用追踪等。Spring Cloud 提供了各种组件来解决这些问题,本文将通过升级改造一个单体 API 服务为微服务架构来讲解部分核心组件的用法。

本文隶属于 Spring API 服务开发系列:
- Spring Boot API 服务开发指南
- Spring Boot API 服务测试指南
- Spring Cloud 微服务开发指南
- Spring Cloud OAuth2 微服务认证授权
本文将把前面开发的 Spring Boot API 服务 Spring Boot in Practice 改造成为一个 Spring Cloud 微服务应用,完整代码可从 GitHub 获取 Spring Cloud in Practice。
Spring Cloud 简介
Spring Cloud 是一系列框架的集合,它利用 Spring Boot 来简化分布式系统中各种基础组件的开发,包括服务注册发现、配置管理、消息总线、负载均衡、断路器、数据监控等。Spring Cloud 并没有重复制造轮子,它只是将各家公司开发的比较成熟且经过实践考验的各种框架组合起来,按照 Spring Boot 风格进行封装以屏蔽掉复杂的配置和实现,最终呈现给开发者一套简单易用的分布式系统开发工具包。微服务是可以独立开发和部署的服务单元,采用微服务架构的应用本质上是一个分布式系统。单体应用只有一个服务,系统中的各个功能模块通过进程内的函数或方法调用进行通信,高效可靠。升级到微服务架构之后,各个功能模块独立成为了运行在不同进程甚至不同机器上的服务,只能通过调用网络服务来进行通信。随之就会出现服务地址管理、配置管理、服务稳定性等问题,Spring Cloud 提供了各种组件来分别解决不同的问题。
Spring Cloud 包含非常多的组件,其中有些由第三方公司开发和维护,下面是一些常用的。
- 服务注册与发现 Spring Cloud Consul、Spring Cloud Zookeeper、Spring Cloud Alibaba Nacos、Spring Cloud Netflix Eureka
- 服务通信协议 REST、Spring Cloud Alibaba Dubbo
- 服务调用客户端 Spring Cloud OpenFeign、Spring Cloud Netflix Feign
- 服务调用负载均衡 Spring Cloud LoadBalancer、Spring Cloud Netflix Ribbon
- 服务降级 Spring Cloud Circuit Breaker、Spring Cloud Alibaba Sentinel、Spring Cloud Netflix Hystrix
- 服务追踪 Spring Cloud Sleuth
- 服务网关 Spring Cloud Gateway、Spring Cloud Netflix Zuul
- 安全保护 Spring Cloud Security
- 配置管理 Spring Cloud Config、Spring Cloud Alibaba Nacos、Spring Cloud Netflix Archaius
- 消息总线 Spring Cloud Bus
- 数据流 Spring Cloud Data Flow
- 事件驱动服务 Spring Cloud Stream
- 任务执行 Spring Cloud Task
- 分布式事务 Spring Cloud Alibaba Seata
- 云存储 Spring Cloud Alibaba OSS
其中 Spring Cloud Netflix 由 Netflix 开发,使用比较广泛,不过目前已进入维护状态,不再更新,不建议新项目使用。Spring Cloud Alibaba 由阿里巴巴开发,2019.7 月从 Spring 孵化器毕业,2019.10 月正式挂牌于 Spring Cloud 官方平台,对 Dubbo、Nacos、RocketMQ 等阿里巴巴开源组件比较熟悉的可以使用。除了这两家大公司提供的组件,Spring Cloud 官方也不断在开发和集成各种组件,目前已经可以满足大多数场景的需求。如果对其它公司提供的组件的稳定性和长久性存疑,完全可以全部使用由官方开发和维护的组件。本文开发项目所用组件均为官方提供,包括 Spring Cloud Consul、Spring Cloud Gateway、Spring Cloud Security、Spring Cloud OpenFeign 等。
整体架构
采用微服务架构的应用有三个问题需要优先解决,一是各个微服务如何安全可控地对外暴露,二是各个微服务如何认证授权,三是如何保障各个微服务之间的调用稳定可靠。
服务暴露
对于服务暴露,微服务系统里的各个微服务一般不直接暴露给外面,而是通过一个网关来集中对外提供服务。这样可以在一个地方统一实施安全、限流、监控等方案,而不是将它们分散在系统各处。这里我们选择 Spring Cloud Gateway 来作为我们的网关,它基于 Spring、Project Reactor 和 Spring Boot 来构建,跟 Spring 生态能够很好地融合。除了其强大的路由功能,由于底层采用了事件驱动模型(使用 Netty 作为应用容器),因此性能上也非常的高效。
认证授权
微服务应用中由于服务众多,如果每个服务都要去处理认证授权,将会出现很多重复性工作。因此建议将认证授权从各个微服务中剥离出来放到网关里集中处理,虽然这会造成网关跟后端微服务轻微耦合,不过换个角度来看,认证和授权属于安全范畴而不属于业务,而网关的职责之一就是保障应用安全,所以放在网关里也是合理的。许多时候后端微服务都需要获取当前登录用户身份,比如用户名或用户 ID,这种情况需要网关在转发请求时以某种方式将登录用户身份一并传递给后端微服务。为了避免跟正常请求体耦合,可放在请求头里,比如使用 HTTP 头 X-User-Id
来传递登录用户 ID,这可以通过定义一个 GlobalFilter
类型的 Bean 来实现。
服务调用
微服务架构里除了网关会调用各个后端微服务,各微服务之间也会相互调用。服务调用的前提是必须先知道被调用的服务有哪些可用的节点,这就需要服务注册与发现,我们将使用 Spring Cloud Consul 来实现此功能。Spring Cloud Consul 会自动将当前服务注册到 Consul 服务里,以及自动从 Consul 服务获取每个服务的可用节点信息,并且在服务节点发生变化时自动更新这些信息。得益于 Spring Cloud Consul 的良好封装,只需使用少量配置和简单注解即可实现服务注册与发现。微服务之间相互调用时,还需要 Relay X-User-Id
头,对于 Spring Cloud OpenFeign,可以定义一个 RequestInterceptor
来实现。
逻辑架构
拆分成微服务后应用的逻辑架构如下:
其中需要关注的点有(OAuth2 认证部分在后续文章会讲到):
- 每个微服务的后端数据库互相隔离,不能直接访问其它微服务的数据库,以避免微服务之间紧耦合。
- 微服务之间可以相互调用,但不应过多,耦合较紧的功能模块应划分到一个微服务中。不要为了拆分而拆分,避免出现分布式单体。
- 网关将外部请求路由到某个后端微服务,同时还承担安全、限流、监控等各微服务的横切需求。网关实现了登录、退出 API,通过请求用户微服务来获取认证用户信息,认证结果保存在 Session 里。为了防止重启后丢失,Session 存放在外部缓存里。此外网关同时还支持第三方 OAuth2 认证服务,每个外部用户将绑定到一个内部用户,因此对于各个微服务来说,可以统一当作内部用户来处理。
- 网关在转发请求给后端微服务时,通过 HTTP 头
X-User-Id
将登录用户 ID 传递过去。同样,微服务之间相互调用时,也要按此方式传递登录用户 ID。 - Consul 服务作为分布式系统的协调中心,负责在各节点之间同步服务和配置信息。每个微服务的节点在启动时自动注册到 Consul 服务里,网关和其它微服务通过 Consul 服务自动发现每个微服务的可用节点地址。
项目结构
为了方便编写代码,本项目采用了 Maven 多模块目录结构,这样可以在一个工程里编写所有微服务的代码。实际项目开发中,为了更好地隔离各个微服务的代码,可将每个微服务独立成为一个项目。各模块共用的配置,比如基础依赖、依赖包版本号等,统一放在了根模块的 POM 文件中,以避免重复。此外还将各微服务中公用的代码,比如异常处理、微服务接口封装等,抽取到了 common
模块中。
.
├── common # 公共库
│ ├── pom.xml
│ └── src
├── docker-compose.yml # Docker Compose 配置文件
├── file # 文件服务
│ ├── Dockerfile
│ ├── pom.xml
│ └── src
├── gateway # 网关
│ ├── Dockerfile
│ ├── pom.xml
│ └── src
├── pom.xml # 根模块 Maven 配置文件
├── post # 动态服务
│ ├── Dockerfile
│ ├── pom.xml
│ └── src
├── stat # 统计服务
│ ├── Dockerfile
│ ├── pom.xml
│ └── src
└── user # 用户服务
├── Dockerfile
├── pom.xml
└── src
开发微服务
每个微服务的长相都比较类似,下面以用户服务为例。由于是升级改造,这里我们只关注跟微服务架构有关的部分。
Maven 配置
<?xml version="1.0" ?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.jaggerwang</groupId>
<artifactId>spring-cloud-in-practice</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>spring-cloud-in-practice-user</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>spring-cloud-in-practice-user</name>
<description>Spring cloud in practice user</description>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>${querydsl.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.jaggerwang</groupId>
<artifactId>spring-cloud-in-practice-common</artifactId>
<version>${scip-common.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>${apt-plugin.version}</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>process</goal>
</goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>${querydsl.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
上面的配置里,首先设置父模块为 spring-cloud-in-practice
,里面包含了 Spring Boot 和 Spring Cloud 的 BOM(物料清单),以及其它本项目中要用到的依赖包的版本号,其目的是为了统一维护各子模块里的依赖包版本。接下来依次引入了本模块的依赖包,包括:
- Spring Boot Web 应用
spring-boot-starter-web
等 - Spring Cloud Consul 服务注册与发现
spring-cloud-starter-consul-discovery
- Spring Cloud Consul 配置管理
spring-cloud-starter-consul-config
- Spring Cloud OpenFeign 服务调用
spring-cloud-starter-openfeign
- 公共库
spring-cloud-in-practice-common
服务治理
注册与发现
在应用主类上使用注解 @EnableDiscoveryClient
来一键开启服务注册与发现功能。
package net.jaggerwang.scip.user.adapter.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* @author Jagger Wang
*/
@SpringBootApplication(scanBasePackages = "net.jaggerwang.scip.user")
@EntityScan("net.jaggerwang.scip.user.adapter.dao.jpa.entity")
@EnableJpaRepositories("net.jaggerwang.scip.user.adapter.dao.jpa")
@EnableDiscoveryClient
@EnableFeignClients
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
可以通过应用配置来单独决定是否开启注册和发现功能。
spring:
cloud:
consul:
discovery:
enabled: true
register: true
调用
Spring Cloud OpenFeign 允许通过定义接口来实现调用第三方 HTTP 服务的客户端封装,非常简单方便,并且还自动集成了 Spring Cloud 的服务发现与负载均衡。下面是用户微服务的接口封装:
package net.jaggerwang.scip.common.usecase.port.service;
import net.jaggerwang.scip.common.adapter.service.feign.ApiConfiguration;
import net.jaggerwang.scip.common.usecase.port.service.dto.RoleDTO;
import net.jaggerwang.scip.common.usecase.port.service.dto.UserDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* @author Jagger Wang
*/
@Component
@FeignClient(value = "spring-cloud-in-practice-user", configuration = ApiConfiguration.class)
public interface UserService {
@RequestMapping(method = RequestMethod.GET, value = "/user/info")
ApiResult<UserDTO> userInfo(@RequestParam Long id);
@RequestMapping(method = RequestMethod.GET, value = "/user/infoByUsername")
ApiResult<UserDTO> userInfoByUsername(@RequestParam String username);
@RequestMapping(method = RequestMethod.GET, value = "/user/infoByMobile")
ApiResult<UserDTO> userInfoByMobile(@RequestParam String mobile);
@RequestMapping(method = RequestMethod.GET, value = "/user/infoByEmail")
ApiResult<UserDTO> userInfoByEmail(@RequestParam String email);
@RequestMapping(method = RequestMethod.GET, value = "/user/rolesOfUser")
ApiResult<List<RoleDTO>> rolesOfUser(@RequestParam Long userId);
@RequestMapping(method = RequestMethod.GET, value = "/follow/following")
ApiResult<List<Long>> following(@RequestParam Long userId, @RequestParam Long limit,
@RequestParam Long offset);
@RequestMapping(method = RequestMethod.GET, value = "/follow/followingCount")
ApiResult<Long> followingCount(@RequestParam Long userId);
}
上面通过 @FeignClient
注解声明这是一个 Feign Client,里面包含了需要被其它微服务调用的方法,应用启动时将自动创建一个该接口的实现类。@FeignClient
注解的 value
属性表示服务地址,这里使用了用户服务在 Consul 里注册的服务名,默认为 Spring 应用名。
通过 @FeignClient
注解的 configuration
属性我们指定了一些个性化的配置。
package net.jaggerwang.scip.common.adapter.service.feign;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.codec.Decoder;
import org.springframework.context.annotation.Bean;
/**
* @author Jagger Wang
*/
public class ApiConfiguration {
@Bean
public Decoder apiResultDecoder(ObjectMapper objectMapper) {
return new ApiResultDecoder(objectMapper);
}
@Bean
public RequestInterceptor requestInterceptor() {
return new UserIdRequestInterceptor();
}
}
上面给我们的 Feign Client 配置了自己的 Decoder ApiResultDecoder
和 RequestInterceptor UserIdRequestInterceptor
,其中 ApiResultDecoder
用来解析调用结果,做一些公共处理,UserIdRequestInterceptor
用来 Relay X-User-Id
请求头。
开发网关
路由配置
package net.jaggerwang.scip.gateway.adapter.api.config;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author Jagger Wang
*/
@Configuration(proxyBeanMethods = false)
public class RouteConfig {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route(p -> p.path("/auth/**")
.filters(f -> f.rewritePath("^/auth", ""))
.uri("lb://spring-cloud-in-practice-auth"))
.route(p -> p.path("/user/**")
.filters(f -> f.rewritePath("^/user", ""))
.uri("lb://spring-cloud-in-practice-user"))
.route(p -> p.path("/post/**")
.filters(f -> f.rewritePath("^/post", ""))
.uri("lb://spring-cloud-in-practice-post"))
.route(p -> p.path("/file/**", "/files/**")
.filters(f -> f.rewritePath("^/file", ""))
.uri("lb://spring-cloud-in-practice-file"))
.route(p -> p.path("/stat/**")
.filters(f -> f.rewritePath("^/stat", ""))
.uri("lb://spring-cloud-in-practice-stat"))
.build();
}
}
上面的配置将不同路径前缀的请求路由到对应的后端微服务,注意协议用的是 lb
,服务名字为各个微服务应用配置里指定的名称 spring.application.name
。网关转发请求给上游微服务时会抹去路径最前面的服务名,这样各个微服务不用关心对外暴露的完整路径,方便网关后续调整路径格式。
为了将当前登录用户 ID 传递给上游微服务,使用了一个全局的网关过滤器来完成此功能。
package net.jaggerwang.scip.gateway.adapter.api.filter;
import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author Jagger Wang
*/
@Component
public class UserIdGatewayGlobalFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return ReactiveSecurityContextHolder.getContext()
.defaultIfEmpty(new SecurityContextImpl())
.flatMap(securityContext -> {
var auth = securityContext.getAuthentication();
if (auth == null || auth instanceof AnonymousAuthenticationToken ||
!auth.isAuthenticated()) {
return chain.filter(exchange);
}
LoggedUser loggedUser;
var principal = auth.getPrincipal();
if (principal instanceof BindedOidcUser) {
var bindedOidcUser = (BindedOidcUser) principal;
loggedUser = bindedOidcUser.getLoggedUser();
} else {
loggedUser = (LoggedUser) principal;
}
return chain.filter(exchange.mutate()
.request(exchange.getRequest()
.mutate()
.headers(headers -> headers
.set("X-User-Id", loggedUser.getId().toString()))
.build())
.build());
});
}
}
注意这里同时支持应用内置登录和第三方 OAuth2 认证服务登录,关于第三方 OAuth2 认证服务登录后续文章会讲到。
安全配置
package net.jaggerwang.scip.gateway.adapter.api.config;
import net.jaggerwang.scip.common.usecase.port.service.dto.UserDTO;
import net.jaggerwang.scip.common.usecase.port.service.dto.user.UserBindRequestDTO;
import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import net.jaggerwang.scip.gateway.usecase.port.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author Jagger Wang
*/
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Autowired
private UserService userService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager(
ReactiveUserDetailsService userDetailsService) {
var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
userDetailsService);
authenticationManager.setPasswordEncoder(passwordEncoder());
return authenticationManager;
}
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf().disable()
.authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
.pathMatchers("/", "/actuator/**", "/login", "/logout", "/auth/**",
"/user/user/register", "/file/files/**").permitAll()
.pathMatchers("/user/**").hasRole("user")
.pathMatchers("/post/**").hasRole("post")
.pathMatchers("/file/**").hasRole("file")
.pathMatchers("/stat/**").hasRole("stat")
.anyExchange().authenticated())
.oauth2Login(oAuth2LoginSpec -> {})
.build();
}
}
由于将认证鉴权放到了网关中处理,因此网关里要实现登录、退出 API,并验证权限。对于 WebFlux 环境,需要使用响应式的认证管理器 ReactiveAuthenticationManager
来手动设置登录状态,这里会借助于自定义的 ReactiveUserDetailsService
来获取用户信息(帐号密码)。
package net.jaggerwang.scip.gateway.adapter.api.security;
import net.jaggerwang.scip.common.usecase.exception.ApiException;
import net.jaggerwang.scip.common.usecase.port.service.ApiResult;
import net.jaggerwang.scip.common.usecase.port.service.dto.UserDTO;
import net.jaggerwang.scip.gateway.usecase.port.service.UserService;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author Jagger Wang
*/
@Service
public class ReactiveUserDetailsServiceImpl implements ReactiveUserDetailsService {
private UserService userService;
public ReactiveUserDetailsServiceImpl(@Lazy UserService userService) {
this.userService = userService;
}
@Override
public Mono<UserDetails> findByUsername(String username) {
Mono<ApiResult<UserDTO>> infoRequest;
try {
if (username.matches("[0-9]+")) {
infoRequest = userService.userInfoByMobile(username);
} else if (username.matches("[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+")) {
infoRequest = userService.userInfoByEmail(username);
} else {
infoRequest = userService.userInfoByUsername(username);
}
} catch (ApiException e) {
if (e.getCode() == ApiResult.Code.NOT_FOUND) {
throw new UsernameNotFoundException("用户未找到");
} else {
throw e;
}
}
return infoRequest
.flatMap(infoResult -> userService
.rolesOfUser(infoResult.getData().getId())
.map(rolesResult -> {
var userDTO = infoResult.getData();
var roleDTOs = rolesResult.getData();
List<GrantedAuthority> authorities = roleDTOs.stream()
.map(v -> new SimpleGrantedAuthority("ROLE_" + v.getName()))
.collect(Collectors.toList());
return new LoggedUser(userDTO.getId(), userDTO.getUsername(),
userDTO.getPassword(), authorities);
}));
}
}
登录、退出 API 在控制器 AuthController
里提供了实现:
package net.jaggerwang.scip.gateway.adapter.api.controller;
import net.jaggerwang.scip.common.usecase.exception.UsecaseException;
import net.jaggerwang.scip.common.usecase.port.service.ApiResult;
import net.jaggerwang.scip.common.usecase.port.service.dto.UserDTO;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import net.jaggerwang.scip.gateway.usecase.port.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import static org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository.DEFAULT_SPRING_SECURITY_CONTEXT_ATTR_NAME;
/**
* @author Jagger Wang
*/
@RestController
@RequestMapping("/auth")
public class AuthController extends BaseController {
@Autowired
private ReactiveAuthenticationManager authenticationManager;
@Autowired
private UserService userService;
@PostMapping("/login")
public Mono<ApiResult<UserDTO>> login(ServerWebExchange exchange,
@RequestBody UserDTO userDTO) {
String username = null;
if (userDTO.getUsername() != null) {
username = userDTO.getUsername();
} else if (userDTO.getMobile() != null) {
username = userDTO.getMobile();
} else if (userDTO.getEmail() != null) {
username = userDTO.getEmail();
}
if (!StringUtils.hasText(username)) {
throw new UsecaseException("用户名、手机或邮箱不能都为空");
}
var password = userDTO.getPassword();
if (!StringUtils.hasText(password)) {
throw new UsecaseException("密码不能为空");
}
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password))
.flatMap(auth -> ReactiveSecurityContextHolder.getContext()
.defaultIfEmpty(new SecurityContextImpl())
.flatMap(securityContext -> exchange.getSession()
.map(session -> {
securityContext.setAuthentication(auth);
session.getAttributes().put(
DEFAULT_SPRING_SECURITY_CONTEXT_ATTR_NAME,
securityContext);
return (LoggedUser) auth.getPrincipal();
})))
.flatMap(loggedUser -> userService.userInfo(loggedUser.getId()));
}
@GetMapping("/logout")
public Mono<ApiResult<UserDTO>> logout(ServerWebExchange exchange) {
return ReactiveSecurityContextHolder.getContext()
.defaultIfEmpty(new SecurityContextImpl())
.flatMap(securityContext -> exchange.getSession()
.flatMap(session -> {
var auth = securityContext.getAuthentication();
securityContext.setAuthentication(null);
session.getAttributes().remove(
DEFAULT_SPRING_SECURITY_CONTEXT_ATTR_NAME);
if (auth == null || auth instanceof AnonymousAuthenticationToken ||
!auth.isAuthenticated()) {
return Mono.empty();
}
return Mono.just((LoggedUser) auth.getPrincipal());
}))
.flatMap(loggedUser -> userService.userInfo(loggedUser.getId()))
.defaultIfEmpty(new ApiResult<>());
}
@GetMapping("/logged")
public Mono<ApiResult<UserDTO>> logged() {
return loggedUser()
.flatMap(loggedUser -> userService.userInfo(loggedUser.getId()))
.defaultIfEmpty(new ApiResult<>());
}
}
部署服务
本应用共包含 8 个服务,分别是网关、四个微服务(用户、动态、文件、统计)、Redis 服务、Consul 服务和 MySQL 服务。部署起来比较麻烦,这也是微服务架构的弊端之一,不过可以借助 Docker Compose 来简化部署工作。关于详细部署步骤,可以查看本项目的 README 文档,其中包含了本地手动部署和 Docker Compose 部署两种方式。
参考资料
- Spring Cloud in Practice
- Spring Boot
- Spring Data JPA
- Querydsl JPA
- Spring Security
- Spring Cloud Gateway
- Spring Cloud Consul
- Spring Cloud OpenFeign
public RequestInterceptor requestInterceptor() {
return new UserIdRequestInterceptor();
}