本文棣属于 Spring API 服务开发系列。

  1. Spring Boot API 服务开发指南
  2. Spring Boot API 服务测试指南
  3. Spring Cloud 微服务开发指南

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 LoadBalancer、Spring Cloud Circuit Breaker、Spring Cloud Gateway、Spring Cloud Security、Spring Cloud Config 等。

系统架构

接下来我们将把之前采用 Spring Boot 框架开发的一个 API 服务 Spring Boot in Practice 改造成为一个 Spring Cloud 微服务应用,最终代码可从 GitHub 获取 Spring Cloud in Practice。采用微服务架构的应用有三个问题需要优先解决,一是众多的微服务如何安全可控地对外暴露,二是认证状态如何在各个微服务之间共享,三是如何保障各个微服务之间的调用可靠而稳定。下面依次来看一下我们的选择方案。

服务暴露

对于服务暴露,微服务系统里的各个微服务一般不直接暴露给外面,而是通过一个网关来统一对外提供服务。这样可以在一个地方来统一实施安全、限流、监控等运维操作,而不是把它们分散在系统各处。这里我们选择了 Spring Cloud Gateway 来作为我们的网关,它基于 Spring、Project Reactor 和 Spring Boot 来构建,跟 Spring 生态能够很好地融合。除了其强大的路由功能,由于底层采用了事件驱动模型(使用 Netty 作为应用容器),因此性能上也非常地高效。

统一认证

对于认证,我们将使用 ORY/Hydra 这个开源的 OAuth2 认证服务器,网关可作为一个 OAuth2 Client 应用来获取 Token 并 Relay 给后端的各个微服务。具体的认证流程如下(图片来源于网络)。

OAuth2 微服务认证流程

首先需要在网关统一配置安全策略。当网关发现某个客户端的请求不满足认证和权限要求时,将重定向客户端浏览器到认证服务器(图中的单点登录服务器),然后认证服务器依次让用户完成登录和授权操作。用户登录和授权完成后,认证服务器将重定向客户端浏览器到网关的授权回调页。此时网关会先从回调地址的 Query 参数里获取到认证服务器传回的授权码(Authorization Code),然后使用该授权码到认证服务器换取访问 Token(Access Token),并保存到某个地方(内存、Session、数据库等)。后续请求网关发现已经获取过 Access Token,并且权限验证通过,则会转发请求给资源服务器(Resource Server,也即是微服务)。需要的话,也可以配置网关把 Access Token 转发(Relay)给资源服务器,以便资源服务器可以获取到登录用户的身份和权限信息。

服务调用

微服务架构里除了网关会调用各个下游微服务,各微服务之间也会相互调用。服务调用的前提是必须先知道被调用的服务有哪些可用的节点,这里就需要服务注册与发现,我们使用 Spring Cloud Consul 来实现这个功能。Spring Cloud Consul 会自动将当前服务注册到 Consul 服务里,以及自动从 Consul 服务获取每个服务的可用节点信息,并且在服务节点状态发生变化时自动更新这些信息。得益于 Spring Cloud Consul 的良好封装,我们只需做极少配置和简单注解即可达成此目的。

部署架构

下面是系统的整体部署架构:

spring-cloud-micro-service-architecture

其中需要关注的点如下:

  1. 每个微服务的后端数据库应互相隔离,每个微服务应尽量避免直接访问其它微服务的数据库,而是通过 REST API 来访问,这样可以降低微服务之间的耦合。如果两个微服务之间调用 API 过多,那么请确认微服务划分是否合理,必要的话可以合并成一个。
  2. 各微服务可相互调用(图中省略了),但不应过多,耦合较紧的功能模块应划分到一个微服务中。不要为了拆分而拆分,应避免出现分布式单体。
  3. 网关使用 Spring Cloud Gateway,其主要作用是将外部请求路由给后端微服务,同时还承担安全、限流、监控等各微服务的横切性需求。本项目中网关不负责权限检查,而是交由各个微服务自己去判断,这样可以降低网关跟微服务之间的耦合性。此外网关还提供 GraphQL 服务,将内部 REST API 转化成 GraphQL API,以便客户端可以自行决定返回数据的结构。
  4. 网关在转发客户端请求给后端微服务时,会一并转发请求头里的 Access Token,以便后端微服务可以获取到认证和授权信息。同样地如果某个微服务需要访问其它微服务,那么也需要转发 Access Token。
  5. Hydra 服务提供 OAuth2 认证服务,Access Token 由客户端应用直接向认证服务获取,并在请求时通过 Authorization 头带上。为了验证 Access Token 的合法性,各个微服务需要向认证服务器请求验证密钥。
  6. Consul 服务作为分布式系统的协调中心,负责在各节点之间同步服务和配置信息。每个微服务的节点在启动时自动注册到 Consul 服务,网关和各个微服务通过 Consul 服务自动发现每个服务可用的节点地址。

由于我们的微服务使用了 REST 协议,因此适合把认证和授权交给微服务自己去处理。这样很容易地把某个现有的 REST 服务转化为微服务,同时还能降低网关的复杂性。但如果微服务使用的是 gRPC 这样的 RPC 协议,那么最好把认证和授权交给网关来处理。因为 RPC 可以很方便地通过参数传递用户身份信息,就像是调用服务层的函数一样,只不过是跨了进程而已,此外 RPC 服务内也不方便做权限检查。

项目结构

为了方便代码编写,本项目采用了 Maven 多模块目录结构,这样可以在一个工程里编写所有微服务的代码。实际项目开发中,为了更好地隔离各个微服务的代码,可以将每个微服务独立成为一个项目。各模块共用的配置,比如基础依赖、依赖包版本号等,统一放在了根模块的 POM 文件中,以避免重复。此外还将各微服务中公用的代码,比如异常处理、各微服务接口封装等,抽取到了 common 模块中。具体的目录结构如下。

.
├── common # 公共库
│   ├── pom.xml
│   └── src
├── docker-compose.yml # Docker Compose 配置文件
├── file # 文件服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── gateway # 网关
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── pom.xml
├── post # 动态服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── stat # 统计服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
└── user # 用户服务
    ├── Dockerfile
    ├── pom.xml
    └── src

开发微服务

每个微服务的长相都比较类似,下面以用户服务为例。由于是升级改造,这里我们只关注跟微服务架构相关的部分,有关 Spring Boot API 服务开发,可以参考 Spring Boot API 服务开发指南

Maven POM 配置

<?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>

    <groupId>net.jaggerwang</groupId>
    <artifactId>spring-cloud-in-practice-user</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>spring-cloud-in-practice-user</name>
    <description>Spring cloud in practice user</description>

    <dependencies>
        <dependency>
            <groupId>net.jaggerwang</groupId>
            <artifactId>spring-cloud-in-practice-common</artifactId>
            <version>${scip-common.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</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.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </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-circuitbreaker-reactor-resilience4j</artifactId>
        </dependency>

        <!--...-->
    </dependencies>

    <!--...-->
</project>

上面的配置里,首先设置父模块为父目录模块 spring-cloud-in-practice,里面包含了 Spring Boot 和 Spring Cloud 的 BOM(物料清单),以及其它本项目中要用到的依赖包的版本号,其目的是为了统一维护各个子模块里的依赖包版本。接下来依次引入了本模块的依赖包,具体如下。

  1. 公共库 spring-cloud-in-practice-common
  2. Spring Boot 应用相关 spring-boot-starter-web
  3. Spring Security OAuth2 资源服务器相关 spring-security-oauth2-resource-serverspring-security-oauth2-jose
  4. Spring Cloud Consul 服务注册与发现相关 spring-cloud-starter-consul-discovery
  5. Spring Cloud Circuitbreaker 服务调用断路器相关 spring-cloud-starter-circuitbreaker-reactor-resilience4j

服务配置

package net.jaggerwang.scip.user.api.config;

...

@Configuration(proxyBeanMethods = false)
public class ServiceConfig {
    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> cbFactoryCustomizer() {
        return factory -> factory.configureDefault(id -> {
            var timeout = Duration.ofSeconds(2);
            if (id.equals("fast")) {
                timeout = Duration.ofSeconds(1);
            } else if (id.equals("slow")) {
                timeout = Duration.ofSeconds(5);
            }

            return new Resilience4JConfigBuilder(id)
                    .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
                    .timeLimiterConfig(TimeLimiterConfig.custom()
                            .timeoutDuration(timeout)
                            .build())
                    .build();
        });
    }

    @LoadBalanced
    @Bean
    public RestTemplate fileServiceRestTemplate(RestTemplateBuilder builder) {
        return builder.rootUri("http://spring-cloud-in-practice-file").build();
    }

    @Bean
    @RequestScope
    public FileSyncService fileSyncService(@Qualifier("fileServiceRestTemplate") RestTemplate restTemplate,
                                           CircuitBreakerFactory cbFactory,
                                           ObjectMapper objectMapper,
                                           HttpServletRequest request) {
        return new FileSyncServiceImpl(restTemplate, cbFactory, objectMapper, request);
    }

    @LoadBalanced
    @Bean
    public RestTemplate statServiceRestTemplate(RestTemplateBuilder builder) {
        return builder.rootUri("http://spring-cloud-in-practice-stat").build();
    }

    @Bean
    @RequestScope
    public StatSyncService statSyncService(@Qualifier("statServiceRestTemplate") RestTemplate restTemplate,
                                           CircuitBreakerFactory cbFactory,
                                           ObjectMapper objectMapper,
                                           HttpServletRequest request) {
        return new StatSyncServiceImpl(restTemplate, cbFactory, objectMapper, request);
    }
}

断路器跟服务调用相关,因此也放在了服务配置里。上面的断路器配置了三种超时级别,分别是默认的 5s,fast 的 2s,以及 slow 的 10s。在调用服务时可以依据当前 API 来决定使用哪种级别的断路器。

为了返回完整的用户信息,用户服务会调用文件服务来查询头像信息,并调用统计服务来查询统计信息,因此这里定义了 FileSyncServiceStatSyncService Bean。以 FileSyncService 为例,它只是一个接口,其实现为 FileSyncServiceImpl。从名字可以看出其为同步调用,与之对应的还有异步接口 FileAsyncService 和实现 FileAsyncServiceImpl。它们均放在 common 模块里,以便在各个微服务之间共享代码。传递给 FileSyncServicerestTemplate 对象来自于自定义的 RestTemplate Bean,我们为其指定了 rootUri 并通过 @LoadBalanced 注解为其添加了负载均衡能力。

安全配置

package net.jaggerwang.scip.user.api.config;

...

@Configuration(proxyBeanMethods = false)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private ObjectMapper objectMapper;

    public SecurityConfig(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    private void responseJson(HttpServletResponse response, HttpStatus status, RootDto data) throws IOException {
        response.setStatus(status.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(data));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt())
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint((request, response, authException) ->
                                responseJson(response, HttpStatus.UNAUTHORIZED,
                                        new RootDto("unauthenticated", "未认证")))
                        .accessDeniedHandler((request, response, accessDeniedException) ->
                                responseJson(response, HttpStatus.FORBIDDEN,
                                        new RootDto("unauthorized", "未授权")))
                )
                .authorizeRequests(authorizeRequests -> authorizeRequests
                        .antMatchers("/favicon.ico", "/actuator/**", "/user/register",
                                "/user/verifyPassword", "/user/logged")
                        .permitAll()
                        .anyRequest()
                        .hasAuthority("SCOPE_user"));
    }
}

权限检查没有统一放在网关,而是放在各个微服务里,这样更利于后期维护。网关会把客户端传递过来的 Access Token 传递给后端微服务,各个微服务相互调用时也需要传递原始请求里的 Access Token。通过配置 oauth2ResourceServer.jwt(),告知 Spring Security 本微服务是一个 OAuth2 资源服务器,并且使用 JWT(Json Web Token)作为 Access Token 格式。

应用配置

应用配置文件里除了通常的 Spring Boot 配置,增加了一些跟服务注册与发现、OAuth2 相关的。

...

spring:
    security:
        oauth2:
            resourceserver:
                jwt:
                    jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}
    cloud:
        loadbalancer:
            ribbon:
                enabled: false
        consul:
            host: ${SCIP_SPRING_CLOUD_CONSUL_HOST:localhost}
            port: ${SCIP_SPRING_CLOUD_CONSUL_PORT:8500}
            discovery:
                enabled: true
                register: true
                healthCheckPath: /actuator/health

上面配置了获取 JWT Token 验证密钥的获取地址 jwk-set-uri,认证服务后面会讲到。禁用了不再更新的 Ribbon 负载均衡器,以启用 Spring Cloud 官方的 LoadBalancer。此外同时开启了 Spring Cloud Consul 的服务注册与发现功能,因为用户服务既要被其它服务调用,又需要调用其它服务。

配置网关

路由配置

package net.jaggerwang.scip.gateway.api.config;

...

@Configuration(proxyBeanMethods = false)
public class RouteConfig {
    private TokenRelayGatewayFilterFactory tokenRelayFilterFactory;

    public RouteConfig(TokenRelayGatewayFilterFactory tokenRelayFilterFactory) {
        this.tokenRelayFilterFactory = tokenRelayFilterFactory;
    }

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(p -> p.path("/user/**")
                        .filters(f -> f
                                .filters(tokenRelayFilterFactory.apply()))
                        .uri("lb://spring-cloud-in-practice-user"))
                .route(p -> p.path("/post/**")
                        .filters(f -> f
                                .filters(tokenRelayFilterFactory.apply()))
                        .uri("lb://spring-cloud-in-practice-post"))
                .route(p -> p.path("/file/**").or().path("/files/**")
                        .filters(f -> f
                                .filters(tokenRelayFilterFactory.apply()))
                        .uri("lb://spring-cloud-in-practice-file"))
                .route(p -> p.path("/stat/**")
                        .filters(f -> f
                                .filters(tokenRelayFilterFactory.apply()))
                        .uri("lb://spring-cloud-in-practice-stat"))
                .build();
    }
}

上面的配置里分别将不同路径前缀的请求路由到对应的后端微服务,注意协议用的是 lb,服务名字为各个微服务应用的名称 spring.application.name。此外还通过 TokenRelayGatewayFilterFactory 来把客户端请求里的 Token Relay 到后端微服务。

安全配置

package net.jaggerwang.scip.gateway.api.config;

...

@Configuration(proxyBeanMethods = false)
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http
                .csrf(csrf -> csrf.disable())
                .oauth2Client(oauth2Client -> {})
                .oauth2Login(oauth2Login -> {})
                .authorizeExchange(exchanges -> exchanges
                        .anyExchange()
                        .permitAll()
                )
                .build();
    }

    @Bean
    ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }
}

由于我们把权限检查放在了各个微服务里,因此这里通过 permitAll() 来允许所有请求。同时通过 oauth2Client()oauth2Login() 来为网关开启 OAuth2 登录,适合网关本身就是一个 OAuth2 客户端应用的情况。但如果是由移动应用或单页网页应用(SPA)来向 OAuth2 服务请求 Access Token,那么则不用开启网关的 OAuth2 登录。此外通过创建一个 WebSessionServerOAuth2AuthorizedClientRepository Bean,使得网关将获取到的 Access Token 保存在 Session 里,而不是默认的内存,这样 Access Token 可以跟随 Session 一起持久化到其它地方,比如 Redis。

服务配置

package net.jaggerwang.scip.gateway.api.config;

...

@Configuration(proxyBeanMethods = false)
public class ServiceConfig {
    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> cbFactoryCustomizer() {
        return factory -> factory.configureDefault(id -> {
            var timeout = Duration.ofSeconds(5);
            if (id.equals("fast")) {
                timeout = Duration.ofSeconds(2);
            } else if (id.equals("slow")) {
                timeout = Duration.ofSeconds(10);
            }

            return new Resilience4JConfigBuilder(id)
                    .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
                    .timeLimiterConfig(TimeLimiterConfig.custom()
                            .timeoutDuration(timeout)
                            .build())
                    .build();
        });
    }

    @LoadBalanced
    @Bean
    public WebClient.Builder webClientBuilder() {
        var headersRelayFilter = new HeadersRelayFilter(Set.of(HttpHeaders.AUTHORIZATION,
                HttpHeaders.COOKIE));
        return WebClient.builder().filter(headersRelayFilter);
    }

    @Bean
    public UserAsyncService userAsyncService(WebClient.Builder builder,
                                             ReactiveCircuitBreakerFactory cbFactory,
                                             ObjectMapper objectMapper) {
        var webClient = builder.baseUrl("http://spring-cloud-in-practice-user").build();
        return new UserAsyncServiceImpl(webClient, cbFactory, objectMapper);
    }

    ...
}

相比于之前的用户服务,这里的使用了 Reactive 版本的断路器工厂 ReactiveResilience4JCircuitBreakerFactory 来创建断路器,微服务调用也是采用异步方式。这里创建各个微服务的 Bean 是因为后面在网关里实现 GraphQL API 时需要用到,网关路由转发并不会用到。

开发 GraphQL API

在网关里实现 GraphQL API 跟在普通的 Spring Boot 应用里类似,只不过是把 DataFetcher 里调用的用例层方法换成调用 REST API。唯一的麻烦点在于如何把原始请求里的 Authorization 头转发给后端微服务。

不像 Spring MVC 可以通过 RequestContextHolder 获取到原始的 HttpServletRequest,Spring WebFlux 需要使用 Mono.subscriberContext() 来获取上下文里的 ServerWebExchange。但由于 GraphQL 的控制器是异步执行 DataFetcher,因此又增加了一些难度。

首先,需要需要在 DataFetcher 里通过 subscriberContext() 把 GraphQL 执行环境的上下文传递给调用 REST API 的 Mono。

package net.jaggerwang.scip.gateway.adapter.graphql;

import graphql.schema.DataFetcher;
import org.springframework.stereotype.Component;

@Component
public class QueryDataFetcher extends AbstractDataFetcher {
    public DataFetcher userLogged() {
        return env -> userAsyncService.logged()
                .subscriberContext(ctx -> env.getContext())
                .toFuture();
    }

    public DataFetcher userInfo() {
        return env -> {
            var id = Long.valueOf((Integer) env.getArgument("id"));
            return userAsyncService.info(id)
                    .subscriberContext(ctx -> env.getContext())
                    .toFuture();
        };
    }
    
    ...
}

注意 GraphQL 不支持 Reactor Mono,需要通过 toFuture() 来将其转化为 Java 语言内置的 CompletableFuture

然后,使用下面的 WebClient Filter 来自动把原始 ServerWebExchange 的 Authorization 头转发给被调用的微服务。

package net.jaggerwang.scip.common.api.filter;

...

public class HeadersRelayFilter implements ExchangeFilterFunction {
    private Set<String> headers;

    public HeadersRelayFilter(Set<String> headers) {
        this.headers = headers;
    }

    @Override
    public Mono<ClientResponse> filter(ClientRequest clientRequest,
                                       ExchangeFunction exchangeFunction) {
        return Mono.subscriberContext()
                .flatMap(ctx -> {
                    var request = clientRequest;
                    var upstreamExchange = ctx.getOrEmpty(ServerWebExchange.class);
                    if (upstreamExchange.isPresent()) {
                        var builder = ClientRequest.from(request);
                        var upstreamHeaders = ((ServerWebExchange) upstreamExchange.get())
                                .getRequest().getHeaders();
                        for (var header: headers) {
                            if (upstreamHeaders.get(header) != null) {
                                builder.header(header,
                                        upstreamHeaders.get(header).toArray(new String[0]));
                            }
                        }
                        request = builder.build();
                    }
                    return exchangeFunction.exchange(request);
                });
    }
}

对于微服务,虽然使用的是 Spring MVC,但由于使用 RestTemplate 调用其它微服务是在一个新的线程里进行,因此还是无法通过 RequestContextHolder 获取到原始的 HttpServletRequest。这里我们通过 @RequestScope Bean 来解决了此问题,在请求到来时才去创建服务对象,以便可以注入当前请求对象。

package net.jaggerwang.scip.user.api.config;

...

@Configuration(proxyBeanMethods = false)
public class ServiceConfig {
    ...

    @LoadBalanced
    @Bean
    public RestTemplate statServiceRestTemplate(RestTemplateBuilder builder) {
        return builder.rootUri("http://spring-cloud-in-practice-stat").build();
    }

    @Bean
    @RequestScope
    public StatSyncService statSyncService(@Qualifier("statServiceRestTemplate") RestTemplate restTemplate,
                                           CircuitBreakerFactory cbFactory,
                                           ObjectMapper objectMapper,
                                           HttpServletRequest request) {
        return new StatSyncServiceImpl(restTemplate, cbFactory, objectMapper, request);
    }
}

认证和授权

在微服务架构里由于应用被拆分成为了多个服务,因此需要实现单点登录。简单场景可以使用分布式会话(Session)方案,实现方式有粘滞(Sticky)会话、会话复制(Replication)和集中式(Centralized)会话存储,这几种方式都各有缺点。粘滞会话要求负载均衡器能够将同一用户的所有请求发送到固定的处理节点,当因某种原因需要切换处理节点时,之前的会话数据将丢失。会话复制要求每个处理节点保存所有会话数据,当某个节点新建会话时,需要同步给所有其它节点,这会造成网络带宽和存储空间的浪费。集中式会话将会话数据存储到 Redis 这样的外部存储中,所有微服务共享一份数据,避免了前面两种方式的缺点,但存储服务容易成为性能瓶颈。这几种方式都不支持第三方应用认证和授权。

我们最终选择了 OAuth2 + JWT 的方案,除了可以满足内部应用的认证和授权,还可用于开放服务给第三方应用访问。由于认证和授权信息直接保存在了 JWT Token 里,因此各个微服务不需要每次请求都去认证服务获取信息,从而避免了认证服务成为性能瓶颈。

Hydra 简介

自己开发一个安全的 OAuth2 认证服务需要比较专业的知识,包括 Spring Security 官方也宣布不再继续提供认证服务器支持 Spring Security OAuth2 Roadmap Update,建议大家使用第三方服务。不过国内提供 OAuth2 服务的很少,即便有有些时候也不放心使用第三方服务,这时可以使用开源的 OAuth2 Server 来自行搭建。这里我们选择了 ORY/Hydra,它使用 Go 语言开发,性能高效,功能完善,使用者较多。

Hydra 支持 OAuth2 的各种 授权类型,除了 Password Grant,具体原因可参考官方文档 Why is the Resource Owner Password Credentials grant not supported?。简单来说,Password Grant 需要用户直接在客户端应用里直接输入帐号密码来从认证服务换取 Access Token,因此只适合完全受信任的客户端应用(比如自家应用)。不过即便是这样,也无法防止某个冒牌应用(外观上无法跟原应用区分,不像浏览器里可以通过地址栏)诱骗用户输入并窃取其帐号密码。

除了 Password Grant 类型,Implicit Flow 也不再推荐使用,虽然 Hydra 支持。Implicit Flow 跳过了使用 Authorization Code 换取 Access Token 的步骤,在用户登录授权完成后直接颁发 Access Token。Implicit Flow 原本是设计给移动应用和单页网页应用这些不方便通过回调地址接受授权码的场景。不过现代浏览器均已支持本地路由,移动应用也可以启动一个本地 Web 服务来提供回调地址,因此此种方式不再需要。这种方式最大的弊端就是无法使用 Refresh Token,每次 Access Token 过期都需要重新发起认证流程去获取(Access Token 生命周期一般较短)。对于这些场景,最新标准推荐的方式是使用标准的 Authorization Code 流程结合 PKCE 扩展来实现。更多内容可参考此篇文档 Securely set up OAuth2 for Mobile Apps, Browser Apps, and Single Page Apps

我们的应用将使用最常用也是最完善的 Authorization Code + Refresh Token 模式。客户端应用如果检测到服务端响应 401 状态码,则打开认证入口页(如果是移动应用则需要在外部浏览器里打开)。接下来用户完成登录和授权操作,认证服务器将重定向浏览器到预设的授权回调页。回调页使用 Query 参数里传回的授权码到认证服务换取 Access Token 和 Refresh Token,并保存在客户端应用里。Access Token 生命周期较短,如果过期客户端可以使用 Refresh Token 去换取新的 Access Token,不用发起完整的授权流程。

Hydra OAuth2 服务只保存跟认证相关的信息,不保存用户信息,需要使用者实现一个 Login & Consent Provider,其中 Login 页让用户完成登录,Consent(同意)页让用户完成授权。虽然看起来有点麻烦,但是这种方式更方便集成认证服务到现有用户系统里。Hydra OAuth2 服务跟 Login & Consent Provider 的具体交互流程如下(图片来源于官方文档)。

login-consent-flow

实现 Login & Consent Provider

Login & Consent Provider 比较简单,只有几个页面,因此将它直接放在了网关里。

package net.jaggerwang.scip.gateway.adapter.controller;

...

@Controller
@RequestMapping("/hydra")
public class HydraController {
    protected ObjectMapper objectMapper;
    protected UserAsyncService userAsyncService;
    protected HydraAsyncService hydraAsyncService;

    public HydraController(ObjectMapper objectMapper, UserAsyncService userAsyncService,
                           HydraAsyncService hydraAsyncService) {
        this.objectMapper = objectMapper;
        this.userAsyncService = userAsyncService;
        this.hydraAsyncService = hydraAsyncService;
    }

    @GetMapping("/login")
    public Mono<String> login(@RequestParam(name = "login_challenge") String challenge,
                              Model model) {
        return hydraAsyncService
                .getLoginRequest(challenge)
                .flatMap(loginRequest -> {
                    if (loginRequest.getSkip()) {
                        var loginAccept = LoginAcceptDto.builder()
                                .subject(loginRequest.getSubject())
                                .build();
                        return hydraAsyncService
                                .directlyAcceptLoginRequest(challenge, loginAccept)
                                .map(redirectTo -> "redirect:"+redirectTo);
                    }

                    model.addAttribute("challenge", challenge);
                    return Mono.just("hydra/login");
        });
    }

    @Data
    static class LoginForm {
        private String challenge;
        private String username;
        private String mobile;
        private String email;
        private String password;
        private Boolean remember = false;
        private String submit;
    }

    @PostMapping("/login")
    public Mono<String> login(@ModelAttribute LoginForm form, Model model) {
        if (form.submit.equals("No")) {
            var loginReject = LoginRejectDto.builder()
                    .error("login_rejected")
                    .errorDescription("The resource owner rejected to log in")
                    .build();
            return hydraAsyncService
                    .rejectLoginRequest(form.challenge, loginReject)
                    .map(redirectTo -> "redirect:" + redirectTo);
        }

        Mono<UserDto> mono;
        if (form.username != null) {
            mono = userAsyncService.verifyPasswordByUsername(form.username, form.password);
        } else if (form.mobile != null) {
            mono = userAsyncService.verifyPasswordByMobile(form.mobile, form.password);
        } else if (form.email != null) {
            mono = userAsyncService.verifyPasswordByEmail(form.email, form.password);
        } else {
            model.addAttribute("error", "用户名、手机或邮箱不能都为空");
            return Mono.just("hydra/login");
        }

        return mono
                .flatMap(userDto -> {
                    var loginAccept = LoginAcceptDto.builder()
                            .subject(userDto.getId().toString())
                            .remember(form.remember)
                            .rememberFor(86400)
                            .build();
                    return hydraAsyncService
                            .acceptLoginRequest(form.challenge, loginAccept)
                            .map(redirectTo -> "redirect:"+redirectTo);
                });
    }

    ...
}

由于 Spring Cloud Gateway 基于 Spring WebFlux 实现,因此这里控制器的实现也采用了响应式(Reactive)编程方式,所有 IO 操作均为异步。对于登录页,首先使用跳转链接里的 login_challenge 去认证服务获取登录请求信息,如果请求信息里指示跳过本次登录(已处于登录状态),则直接调用认证服务接口接受本次登录请求,否则展示登录页。用户在登录页输入帐号密码后提交表单,服务端验证帐号密码是否匹配,如果匹配则调用认证服务接口接受本次登录请求。同意页的流程与登录页类似,只不过是让用户确认授权而已。更多说明可参考官方文档 Implementing a Login & Consent Provider

应用配置

...

security:
        oauth2:
            client:
                registration:
                    hydra:
                        client-id: scip
                        client-secret: ilxzM0AdA7BVaL7c
                        authorization-grant-type: authorization_code
                        redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
                        scope: offline,user,post,file,stat
                provider:
                    hydra:
                        authorization-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_AUTHORIZATION_URI:http://localhost:4444/oauth2/auth}
                        token-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_TOKEN_URI:http://localhost:4444/oauth2/token}
                        user-info-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_USER_INFO_URI:http://localhost:4444/userinfo}
                        user-name-attribute: sub
                        jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}
    cloud:
        loadbalancer:
            ribbon:
                enabled: false
        consul:
            host: ${SCIP_SPRING_CLOUD_CONSUL_HOST:localhost}
            port: ${SCIP_SPRING_CLOUD_CONSUL_PORT:8500}
            discovery:
                enabled: true
                register: false

service:
    file:
        base-url: ${SCIP_SERVICE_FILE_BASE_URL:http://localhost:8080/files}
    hydra:
        url: ${SCIP_SERVICE_HYDRA_URL:http://localhost:4444/}
        admin-url: ${SCIP_SERVICE_HYDRA_ADMIN_URL:http://localhost:4445/}

...

网关的配置文件里配置了 Spring Security OAuth2 Client,包括当前应用在认证服务注册的客户端信息 registration,一个应用可以在多个 OAuth2 Provider 里注册。由于我们使用了自己的 Provider,需要提供 Provider 的信息,包括授权地址、Token 地址等。如果使用的是 Google、Facebook 等官方支持的 Provider,则无需提供这些信息。如果网关本身不是一个 OAuth2 Client,不需要去获取 Access Token,那么则无需配置这些信息。此外由于在 Login & Consent Provider 里需要调用认证服务接口,因此还配置了认证服务的地址 service.hydra.urlservice.hydra.admin-url

部署服务

本应用共包含 8 个服务,分别是网关、四个微服务(用户、动态、文件、统计)、认证服务、Consul 服务和 MySQL 服务。部署起来比较麻烦,这也是微服务架构的弊端之一,不过可以借助 Docker Compose 来简化部署工作。关于详细部署步骤,可以查看本项目的 README 文档,其中包含了本地手动部署和 Docker Compose 部署两种方式。

参考资料

  1. Spring Cloud in Practice
  2. Spring Boot
  3. Spring Data JPA
  4. Querydsl JPA
  5. Spring Security
  6. GraphQL Java
  7. Flyway
  8. Spring Cloud Gateway
  9. Spring Cloud Consul
  10. Spring Cloud Circuit Breaker
  11. ORY/Hydra