SpringSecurity-OAuth2+JWT+SpringCloudGateway实现统一鉴权管理

SpringSecurityJava学习笔记

版权 本文为时间海绵原创文章,转载无需和我联系,但请注明来自博客 https://blog.hzchendou.com

一、SpringSecurity 入门

介绍

SpringSecurity 是Spring 全家桶中的安全框架,为了解决“用户身份认证”、“资源访问鉴权”这两个核心问题,SpringSecurity提供了一整套安全框架,基于安全框架,用户可以自定义身份认证、资源鉴权功能,例如:手机验证码登录、基于RDBC鉴权等,本文章主要介绍如何创建基于SpringSecurity项目。

项目创建

项目源码已上传到Gitee: 地址

项目依赖

基于 SpringBoot 创建SpringSecurity 可以实现开箱即用功能,引入依赖项:

- SpringBoot依赖

 <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.0</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

- Spring MVC 依赖(搭建基于 http 协议的web项目)

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>

- Spring Security 依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

详细 pom 文件可以参见源码:https://gitee.com/hzchendou/spring-security-demo/blob/lesson1/pom.xml

项目模块

创建简单mvc API,代码如下:

/**
 * hello 访问控制器
 * @Date: 2022-05-23 11:27
 * @since: 1.0
 */
@RequestMapping("/anonymity")
@RestController
public class AnonymityController {

    @RequestMapping("/hello")
    public ResultVO test() {
        return ResultVO.success("hello world");
    }
}

项目启动

自此完成项目配置,基于SpringBoot 自动装配功能可以帮助我们完成大部分配置,引入依赖后会帮助创建一个基础运行框架,配置了一些默认配置项,运行项目后看到如下日志:

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.0)

2022-05-23 12:23:13.584  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Starting Application using Java 1.8.0_211 on hzchendoudeMac-mini.local with PID 8538 (/Users/chendou/repo/hzchendou/learning/springsecurity/target/classes started by chendou in /Users/chendou/repo/hzchendou/learning/springsecurity)
2022-05-23 12:23:13.586  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : No active profile set, falling back to 1 default profile: "default"
2022-05-23 12:23:14.338  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-05-23 12:23:14.344  INFO 8538 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-05-23 12:23:14.344  INFO 8538 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.63]
2022-05-23 12:23:14.426  INFO 8538 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-05-23 12:23:14.426  INFO 8538 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 806 ms
2022-05-23 12:23:14.666  WARN 8538 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: ab60d0d9-a34b-4aee-ad31-e8881672c6a0

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2022-05-23 12:23:14.742  INFO 8538 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@20eaeaf8, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@748ac6f3, org.springframework.security.web.context.SecurityContextPersistenceFilter@7affc159, org.springframework.security.web.header.HeaderWriterFilter@72eb6200, org.springframework.security.web.csrf.CsrfFilter@52bf7bf6, org.springframework.security.web.authentication.logout.LogoutFilter@66de00f2, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@163042ea, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@479b5066, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@68f6e55d, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@1d8b0500, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1682c08c, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3fd05b3e, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@6fff46bf, org.springframework.security.web.session.SessionManagementFilter@76ececd, org.springframework.security.web.access.ExceptionTranslationFilter@67e25252, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@52b46d52]
2022-05-23 12:23:14.783  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-05-23 12:23:14.791  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Started Application in 1.471 seconds (JVM running for 1.869)

会生成一串用户密码,这是SpringSecurity 帮助学习的默认配置,后续将会讲解,

启动完成在浏览器输入访问地址:http://localhost:8080/anonymity/hello

网页会自动跳转到 http://localhost:8080/login

输入用户名:user

输入密码:在日志中的一串字符串, 这里是 *ab60d0d9-a34b-4aee-ad31-e8881672c6a0*(由程序自动生成,每次生成内容不一样)

登录成功后跳转到指定地址,得到内容如下:

{"code":200,"data":"hello world","message":null}

至此完成SpringSecurity项目搭建,SpringSecurity 提供了默认配置,默认组织匿名访问接口。

总结

  1. SpringSecurity 项目搭建很方便,结合 SpringBoot 进行使用可以快速完成基础框架搭建,同时提供默认配置,不需要任何配置即可完成项目资源保护
  2. SpringSecurity 提供了 用户身份鉴定(用户登录), 以及用户访问权限控制(判断是否拥有权限访问项目接口)

上述内容帮助完成搭建基础项目,当然这样的程序无法满足实际项目需求,我们需要自定义认证(登录方式)以及 鉴权(权限控制)流程,下一篇我们将在此基础上自定义登录方式

特别声明:项目采用最新SpringSecurity版本:5..7.1,版本升级带来了一点新变化,可能与老版本由一点不同,但是核心理念是一致的

二、SpringSecurity 自定义手机验证登录方式

简介

在上一篇文章中,我们介绍了如何搭建一套基于SpringSecuity的项目框架,并且进行了演示,本文将继续扩展项目功能,实现自定义用户登录功能。

项目源码仓库:Gitee

代码分支:lesson2

原理介绍

SpringSecurity 提供了web服务项目相关的安全配置,通常我们使用 Spring MVC进行开发(基于Servlet 容器技术实现,现在 Spring 提供了 WebFlux 技术可以提高系统吞吐量,两者都是基于 HTTP协议开发的web服务,MVC提供的是阻塞I/O,WebFlux 提供非阻塞 I/O),Servlet 容器中提供了两种核心组件:

  1. Filter
  2. Servlet

Filter 简介

Filter 组件可以实现过滤器功能,Http 请求达到时,Filter 优先接收到请求信息,并且可以依据业务逻辑对请求提前进行处理,例如,CORS(浏览器的同源请求策略, 详细信息参见:阮一峰网络日志), 对于非同源的请求,项目方可以按照要求选择拒绝或者接受请求,接口定义如下:

public interface Filter {
    /// 初始化
    public default void init(FilterConfig filterConfig) throws ServletException {}
    /// 过滤
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;
    /// 销毁
    public default void destroy() {}
}

在 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; 方法中可以获取到http 请求信息,并且可以阻断 Http 请求,防止调用实际的业务逻辑代码,例如 实现基于IP黑名单过滤器,发现请求IP 在系统的 IP黑名单中,可以直接返回错误信息阻止请求继续执行。

Servlet 简介

Servlet 组件用于接收Http请求信息,并依据请求信息进行处理,项目的业务逻辑在 Servlet 中进行处理,接口定义如下:

public interface Servlet {
    /// 初始化方法
    public void init(ServletConfig config) throws ServletException;
    /// 获取配置
    public ServletConfig getServletConfig();
    //// 业务处理
    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException;
    /// 获取基础信息
    public String getServletInfo();
    /// 销毁
    public void destroy();
}

其中最主要的是 public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException; 业务逻辑代码在此处进行调用处理(Spring MVC 中的重要组件 DispatcherServlet 是Servlet 子类,通过 service 方法接收并处理 Http 请求)

SpringSecurity原理

通过上述Servlet 技术简单讲解,我们知道Filter主要用于实现过滤功能,这些功能与业务逻辑关系不大,可以在请求进入业务逻辑之前进行拦截处理,保障系统稳定运行,SpringSecurity正是通过一系列“Filter组件”来实现安全过滤功能(在执行业务逻辑之前对Http请求进行身份校验和权限控制),SpringSecurity 中的两个主要功能分别是:

  • 身份校验:对当前发起请求的用户(可能是真实用户,也可能是网络爬虫或者是恶意攻击者)进行身份识别,主要解决你是谁的问题
  • 权限控制:对当前访问资源进行权限控制(管理后台功能只对管理员开发,普通用户无法访问),主要解决你是否有权限访问资源

通过上述两个功能点可以实现系统的访问控制安全,对于不符合要求的请求,直接返回错误信息,阻止不安全的资源访问。本文重点讲解身份校验,权限控制将在后续进行分析。

用户名密码登录分析

在之前文章中使用“user”用户进行了登录,这是SpringSecurity提供的默认用户密码登录实现:"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter",这是一个Filter 子类,可以实现Filter过滤功能,核心代码如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /// 默认匹配 POST /login 请求
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
            "POST");

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }
    //// 在 doFilter 方法中调用该方法实现过滤
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        /// 判断请求方法是否支持
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        /// 包装成 用户密码Token
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        /// 设置请求信息,这是一些额外的信息,例如用户IP地址等信息,与核心校验逻辑关系不大
        setDetails(request, authRequest);
        //// 调用 AuthenticationManager 进行身份验证,成功返回 Authentication 对象,失败抛出异常
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

我们来分析一下 attemptAuthentication 方法,执行的逻辑如下:

  1. 判断Http请求是否为用户密码登录请求(主要看请求路径是否为 /login 并且为 POST方法)
  2. 获取请求中的用户名密码信息(登录参数信息)
  3. 委托给AuthenticationManager组件进行身份验证
  4. 返回成功或者是错误信息

可以理解为Filter中并没有承担核心的身份信息校验责任,主要完成校验请求是否为用户名密码请求,如果是提取出相关参数,委托给AuthenticationManager组件校验身份,如果成功返回Authentication对象。这里有几个关键的类:

  • UsernamePasswordAuthenticationToken:保存用户名密码信息(是Authentication的子类)
  • Authentication:代表待验证信息或者是已验证完成后的身份信息(可以是未验证的信息也可以是已验证的身份信息,通过方法boolean isAuthenticated() 返回值判断是为已验证信息)
  • AuthenticationManager:验证管理器负责对待验证信息内容进行验证,验证成功返回身份信息,失败返回错误信息

UsernamePasswordAuthenticationToken和Authentication都是数据模型类,不存在处理逻辑,AuthenticationManager是主要的验证逻辑处理类,在SpringSecurity 中提供了ProviderManager实现类,核心代码如下:

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean{
   /// 身份校验处理器
   private List<AuthenticationProvider> providers = Collections.emptyList();

   @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        /// 循环使用Provider 来验证身份信息,只要有一个验证通过就算成功
        for (AuthenticationProvider provider : getProviders()) {
            /// 判断Provider是否支持验证Authentication子类类型,例如前面的UsernamepasswordAuthenticationToken
            if (!provider.supports(toTest)) {
                continue;
            }
            try {
                /// 使用具体的验证器进行验证,验证通过返回具体验证信息
                result = provider.authenticate(authentication);
                if (result != null) {
                    break;
                }
            }
            catch (AccountStatusException | InternalAuthenticationServiceException ex) {
                throw ex;
            }
            catch (AuthenticationException ex) {
                lastException = ex;
            }
        }
        /// 如果验证器无法验证,并且存在父级验证器那么使用父级验证器进行验证
        if (result == null && this.parent != null) {
            // Allow the parent to try.
            try {
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            }
            catch (ProviderNotFoundException ex) {
            }
            catch (AuthenticationException ex) {
                parentException = ex;
                lastException = ex;
            }
        }
        /// 判断是否存在已验证结果,存在返回验证信息,不存在抛出一样信息
        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {

                ((CredentialsContainer) result).eraseCredentials();
            }

            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        }
        throw lastException;
    }
}

在上述代码中最主要的是private List providers = Collections.emptyList();属性信息,ProviderManager委托该属性循环处理Authentication子类对象,直到验证通过或者是全部不通过, AuthenticationProvider接口定义如下:

public interface AuthenticationProvider {

    ///对待验证信息进行验证
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    //// 判断当前验证器是否支持对该类型验证信息进行校验处理
    boolean supports(Class<?> authentication);

}

SpringSecurity中提供了对UsernamepasswordAuthenticationToken参数验证的AuthenticationProvider子类DaoAuthenticationProvider,相关接口实现如下:

  • 判断是否支持方法
/// 判断待验证参数authentication是否为UsernamePasswordAuthenticationToken类型或者是其子类
public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
  • 身份验证逻辑
@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = determineUsername(authentication);
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;
            try {
                /// 依据用户名以及参数信息查找用户信息
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException ex) {
                /// 这里为了隐藏用户不存在错误,会对该错误进行包装,抛出新错误
                if (!this.hideUserNotFoundExceptions) {
                    throw ex;
                }
                throw new BadCredentialsException(this.messages
                        .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
        }
        try {
            //// 信息校验前执行
            this.preAuthenticationChecks.check(user);
            //// 校验用户密码是否正确
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException ex) {
            if (!cacheWasUsed) {
                throw ex;
            }
            //// 如果使用的是缓存,那么进行绕过缓存再次验证防止缓存信息过期
            cacheWasUsed = false;
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            this.preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        /// 校验结束处理
        this.postAuthenticationChecks.check(user);
        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }
        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
        /// 验证成功返回成功信息
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

整体流程图如下所示:

img

自定义验证流程

通过上述分析我们可以知道,自定义一个身份验证逻辑需要实现以下三个组件:

  1. 自定义验证参数类型:Authentication
  2. 自定义拦截过滤器:Filter
  3. 自定义特定验证参数类型验证器:AuthenticationProvider

下面我们将实现常用的手机验证码验证登录功能。

自定义验证参数

通过分析UsernamePasswordAuthenticationToken组件,我们知道该Token主要包装验证参数信息,方便后续使用,实现逻辑如下:

public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private Object credentials;
    ///未验证参数构造器
    private PhoneCodeAuthenticationToken(String phone, String code) {
        super(null);
        this.principal = phone;
        this.credentials = code;
        /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(false);
    }
    ///已验证参数构造器
    /// authorities代表取得打权限信息
    private PhoneCodeAuthenticationToken(Object principal,
            Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
         /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(true);
    }

    /// 未验证Token
    public PhoneCodeAuthenticationToken unAuthToken(String phone, String code) {
        return new PhoneCodeAuthenticationToken(phone, code);
    }

    ////已验证Token
    public PhoneCodeAuthenticationToken authToken(Object principal,
            Collection<? extends GrantedAuthority> authorities) {
        return new PhoneCodeAuthenticationToken(principal, authorities);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

自定义拦截器

通过分析UsernamePasswordAuthenticationFilter组件,我们知道拦截器主要完成三个功能:

  1. 拦截特定请求
  2. 解析参数
  3. 委托给验证器进行验证处理

手机验证码登录拦截POST/phone/login请求,解析参数,包装成PhoneCodeAuthenticationToken对象,最后委托给AuthenticationManager组件验证,具体代码参见Gitee仓库:地址

运行验证

程序启动后发起POST请求,参数信息:

  • phone:15000000000
  • code:888888

请求成功后返回用户信息(SpringSecurity默认配置会将登录成功请求跳转到 / 路径):

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

至此完成手机验证码登录功能

我们使用POST请求,SpringSecurity默认提供csrf保护,会拦截 POST请求,因此需要禁用

总结

  • SpringSecurity 使用Servlet容器组件Filter功能进行请求拦截,实现身份校验以及权限控制
  • SpringSecurity 使用AuthenticationManager来实现身份校验功能(实际上你可以在Filter中直接完成身份验证功能,但是这种硬编码方式会增加程序耦合性,后期维护/扩展不方便)
  • SpringSecurity 中的AuthenticationManager委托多个AuthenticationProvider对请求参数进行校验
  • 自定义手机验证码验证流程需要实现三个类:
    • 继承Filter的PhoneCodeAuthenticationFilter, 对手机验证码登录请求进行拦截,并解析处请求参数信息,最后委托给AuthenticationManager进行身份校验
    • 继承Authentication的PhoneCodeAuthenticationToken
    • 登录时存放请求参数信息:手机号和验证码
    • 登录成功后存放用户信息:用户名、手机号、权限等
    • 继承AuthenticationProvider的PhoneCodeAuthenticationProvider,对请求参数进行验证,验证通过返回用户信息

有过SpringSecurity开发经验的同学会发现仓库中的代码使用HttpSecurity进行配置的方式与之前的方式不同,这是SpringSecurity官方在新版中推荐使用的方式,老版本的配置方式将会被遗弃,目前两种方式都可以使用

参考文档

三、SpringSecurity 动态权限访问控制

简介

在先前文章中我们搭建了SpringSecurity项目,并且讲解了自定义登录方式需要做哪些工作,如果你感兴趣可以前往博客阅读文章以及代码,在本文将继续讲解如何实现动态权限控制。

代码仓库:Gitee

代码分支:lesson3

目标

Web项目通常都有前台和后台服务,前台服务面向目标客户,后台服务为项目方提供管理和数据分析服务,因此不同的用户需要赋予不同的角色,例如前台用户角色为USER,后台用户为ADMIN,USER允许访问"/user/hello"接口,ADMIN允许访问"/admin/hello"接口,但是USER不能访问。这是项目必须有的基本功能,同时访问规则也会不断变化,例如: 有一个用户昵称功能,初期只允许会员用户(可以理解为拥有角色VIP的用户)使用,后期产品决定全员都可以使用,这种需求也很常见,如果采用硬编码的方式那么会导致频繁修改代码,测试、发布,增加额外工作量,如果可以动态配置接口访问权限,那么就能减少很多工作量,SpringSecurity框架提供了扩展点,基于这些扩展点可以很方便的实现动态权限控制访问功能,我们再来回顾一下需求:

  • 基于角色进行接口权限控制
  • 访问接口需要的角色可以动态配置

原理分析

通过上一篇文章我们知道SpringSecurity基于Filter实现身份验证和权限控制功能,SpringSecurity提供了实现类FilterSecurityInterceptor对访问路径进行权限控制,核心代码逻辑如下:

public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
        ///此处省略无关逻辑
        /// 在这里执行权限控制逻辑
        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
}

在访问实际业务逻辑之前调用父级方法beforeInvocation进行权限判断,如果权限不符合要求,直接抛出异常阻止访问实际业务逻辑,核心代码如下:

protected InterceptorStatusToken beforeInvocation(Object object) {
        //此处省略无关代码
        //// 这里获取与访问路径相关的权限信息,例如:/user/hello 对应 ROLE_USER 角色,当然一个路径可能对应多个权限
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        if (CollectionUtils.isEmpty(attributes)) {
            /// 这里注意如果对应的路径在系统中没有配置权限或者是获取方法没有处理这种请求会导致放行,特别注意
            return null; // no further work post-invocation
        }
        /// 未登录用户直接返回未验证错误
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
                    "An Authentication object was not found in the SecurityContext"), object, attributes);
        }
        //// 获取验证信息
        Authentication authenticated = authenticateIfRequired();
        // Attempt authorization
        /// 判断用户是否拥有访问权限
        attemptAuthorization(object, attributes, authenticated);
        /// 这里实现了类似Linux su 命令,将当前用户暂时赋予另外一个用户运行权限,可以先忽略不看
        // Attempt to run as a different user
        Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
        if (runAs != null) {
            SecurityContext origCtx = SecurityContextHolder.getContext();
            SecurityContext newCtx = SecurityContextHolder.createEmptyContext();
            newCtx.setAuthentication(runAs);
            SecurityContextHolder.setContext(newCtx);

            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Switched to RunAs authentication %s", runAs));
            }
            // need to revert to token.Authenticated post-invocation
            return new InterceptorStatusToken(origCtx, true, attributes, object);
        }
        this.logger.trace("Did not switch RunAs authentication since RunAsManager returned null");
        // no further work post-invocation
        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

    }

这里有两个重点内容:

  1. 通过this.obtainSecurityMetadataSource().getAttributes(object);方法来获取访问所需要的权限信息
  2. 通过attemptAuthorization(object, attributes, authenticated)方法对访问所需的权限以及用户身份信息进行决策,判断是否允许访问

路径权限分析

上述的this.obtainSecurityMetadataSource()方法返回SecurityMetadataSource类型对象,该接口核心代码如下:

public interface SecurityMetadataSource extends AopInfrastructureBean {
    /// 依据object获取权限信息,我们可以把ConfigAttribute理解为String类型,在我们系统中可以理解保存着角色信息,例如ROLE_USER
    Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException;
    ///获取系统中配置的所有权限信息,用于后续验证器判断是否支持该类型决策
    Collection<ConfigAttribute> getAllConfigAttributes();
    /// object 类型,用于判断SecurityMetadataSource支持解析的object类型
    boolean supports(Class<?> clazz);
}

可以看出SecurityMetadataSource的主要作用是给出当前访问需要哪些权限,方便后续判断,可以理解为一个数据源,用来获取访问权限列表

访问权限控制分析

这里我们需要重点查看方法attemptAuthorization(object, attributes, authenticated);包含对用户访问控制权限进行判断,核心代码如下:

private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
            Authentication authenticated) {
        try {
            /// 委托accessDecisionManager进行决策判断
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            /// 异常请求直接向上抛出异常信息
            throw ex;
        }
}

这个方法很简单,就是委托accessDecisionManager来进行访问决策,我们来看一下这个接口的核心代码:

public interface AccessDecisionManager {
    //// 对访问进行决策,判断是否有权限
    void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException;
    /// 查看访问控制器是否支持该类型决策
    boolean supports(ConfigAttribute attribute);
    /// 查看访问控制器是否支持特定类型,这个类型就是 上面方法中object对应的类型
    boolean supports(Class<?> clazz);
}

接口也很简单,有点类似AuthenticationProvider接口,调用decide方法,如果允许访问那么不进行任何处理,如果不允许访问就抛出异常信息。

代码实现梳理分析

上述核心逻辑很简单,但是实现逻辑有点绕,不要紧我们画个流程图再来梳理一遍(觉得绕主要是不相关代码对理解造成了困扰,还有可能就是被这种俄罗斯套娃形式绕晕了)

img

通过上述分析我们可以发现,权限控制需要两个核心功能:

  1. 访问路径所需要的权限(实现接口SecurityMetadataSource)
  2. 依据用户权限、路径所需权限进行决策判断是否允许访问(实现接口AccessDecisionManager)

完成上述功能后,将这些功能组装成FilterSecurityInterceptor类型对象,然后放置到SpringSecurity过滤链中实现过滤功能

代码实现

直接动手实现动态权限控制

实现路径权限获取

这里为了更加贴近实际项目,将提供一个RoleService作为数据源,实现代码如下:

@Service
public class RoleService {
    public List<ConfigAttribute> roles = new ArrayList<>();
    private Map<String, List<ConfigAttribute>> urlRoleMaps = new HashMap<>();
    @PostConstruct
    public void init() {
        /// 初始化数据
        roles.addAll(SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN", "ROLE_VIP"));
        urlRoleMaps.put("/", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/hello", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/nickname", SecurityConfig.createList("ROLE_VIP"));
        urlRoleMaps.put("/admin/hello", SecurityConfig.createList("ROLE_ADMIN"));
    }
    /// 获取所有角色信息
    public Collection<ConfigAttribute> getAllRoles() {
        return Collections.unmodifiableList(roles);
    }
    ///依据请求路径查询所需权限
    public Collection<ConfigAttribute> getRoleByPath(String path) {
        Collection<ConfigAttribute> roles = urlRoleMaps.get(path);
        if (roles == null) {
            return Collections.EMPTY_LIST;
        }
        return Collections.unmodifiableCollection(roles);
    }
}

代码很简单,就是初始化数据,提供路径与权限对应的数据服务,在实际项目中通常从数据库中获取这些信息。

下面编写RolePermissionMetadataSource接口的实现类,代码如下:

/// FilterInvocationSecurityMetadataSource 是SecurityMetadataSource的子接口,实际上就是 SecurityMetadataSource,没有扩展任何方法
public class RolePermissionMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private RoleService roleService;
    @Override
    public Collection<ConfigAttribute> getAttributes(Object object)
            throws IllegalArgumentException {
        FilterInvocation invocation = (FilterInvocation) object;
        String url = invocation.getRequestUrl();
        /// 通过请求路径获取访问路径所需的权限列表
        Collection<ConfigAttribute> roles = roleService.getRoleByPath(url);
        if (roles != null && roles.size() > 0) {
            return roles;
        }
        //没有匹配上的资源,禁止访问,设置不存在的访问权限
        // 通过之前的分析知道,如果这里返回空,将会直接放行,运行登录用户访问,这是有风险的
        return SecurityConfig.createList(RoleEnums.ROLE_REFUSE.name());
    }
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return roleService.getAllRoles();
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

实现路径访问控制决策类

继承接口AccessDecisionManager,核心代码如下:

public class PathAccessDecisionManager implements AccessDecisionManager {
    ///拒绝访问权限名称
    private static final String BASE_REFUSE_NAME = RoleEnums.ROLE_REFUSE.name();
    @Override
    public void decide(Authentication authentication, Object object,
            Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException {
        Iterator<ConfigAttribute> iterator = configAttributes.iterator();
        //进行权限匹配,如果用户拥有资源权限那么进行放行操作
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            // 当前请求需要的权限
            String needRole = ca.getAttribute();
            if (RoleEnums.ROLE_ANONYMOUS.name().equalsIgnoreCase(needRole)) {
                return;
            }
            if (BASE_REFUSE_NAME.equalsIgnoreCase(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    //匿名用户
                    throw new AccessDeniedException("资源信息不存在");
                } else {
                    //登录用户
                    throw new AccessDeniedException("权限不足!");
                }
            }
            // 当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equalsIgnoreCase(needRole)) {
                    return;
                }
            }
        }
        //如果当前请求没有验证,返回未验证异常
        if (authentication instanceof AnonymousAuthenticationToken) {
            throw new AccessDeniedException("用户未登录");
        }
        throw new AccessDeniedException("权限不足!");
    }
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

组装Filter

我们将上述实现类与FilterSecurityInterceptor进行组装,实现权限动态过滤:

///动态权限控制 Filter, 默认会拦截所有请求进行权限判断
private FilterSecurityInterceptor filterSecurityInterceptor() {
    FilterSecurityInterceptor interceptor = new FilterSecurityInterceptor();
    /// 由于包含Spring Bean,因此需要注入实现,而不是直接new
    interceptor.setSecurityMetadataSource(rolePermissionMetadataSource);
    interceptor.setAccessDecisionManager(new PathAccessDecisionManager());
    return interceptor;
}
///加入到SpringSecurity过滤链中
httpSecurity.addFilterBefore(roleAuthFilter, FilterSecurityInterceptor.class);

运行验证

我们代码里创建了三个用户:

  1. 15000000000, 拥有:ROLE_USER
  2. 15666666666, 拥有:ROLE_USER、ROLE_VIP
  3. 15888888888, 拥有:ROLE_USER、ROLE_ADMIN

程序运行完成后使用15000000000进行手机验证码登录:

- POST http://localhost:8080/phone/login?phone=15000000000&code=888888

- 返回:

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

可以看到拥有ROLE_USER权限,那么我们访问 http://localhost:8080/user/hello, 返回:

{
    "code": 200,
    "data": "Hello User",
    "message": null
}

访问http://localhost:8080/admin/hello, 返回:

{
    "code": 400,
    "message": "请求受限"
}

我们看到以上结果符合预期,同理可以使用15888888888用户进行同样的访问操作,在这里我们就不做过多介绍,大家有兴趣可以下载代码自行运行测试,文章开头有代码地址。

如果要修改路径对应的权限,那么只要修改RoleService中的数据即可实现权限动态配置。

总结

通过上述文章分析,我们已经完成权限动态配置,当然运行中展现的JSON数据是配置了对应处理器处理的结果,细节处理请前往代码仓库下载源码自行查看。

为了完成动态权限我们需要完成三个步骤,实现两个接口,步骤如下:

  1. 实现路径权限数据访问接口(实现SecurityMetadataSource)
  2. 实现访问控制决策接口(实现AccessDecisionManager)
  3. 组装Filter并加入到过滤链中(FilterSecurityInterceptor)

熟练掌握上述步骤,实现动态权限控制将不再是难题。

在SpringSecurity 中不仅提供了FilterSecurityInterceptor实现类来对访问进行权限控制,在SpringSecyrity 5.4版本中还提供了AuthorizationFilter实现类来实现相同功能,具体实现方式自行前往仓库进行查看

参考文档

四、SpringSecurity OAuth2统一授权服务

代码

代码仓库:地址

代码分支:lesson4

简介

在先前文章中我们实战演练了在SpringBoot单体应用中使用SpringSecurity开发自定义登录流程以及动态权限控制,有兴趣的同学可以前往博客阅读SpringSecurity相关文章(所有代码都已上传到Gitee仓库, 每篇文章都有一个专属分支)。随着业务的扩张,单体应用无法满足业务需求,微服务是当前大型商业服务的主流架构,在Java领域中,SpringCloud 全家桶是微服务主流开发框架,Spring Cloud Alibaba 是在Spring Cloud 的基础上进行扩展,目的是为了更好的搭配使用Alibaba生态中的微服务组件(Nacos、Sentinel、Seata等),具体内容可查看文档

微服务框架如下所示:

img

上图中的服务集群代表具体业务服务,微服务下的权限控制是为了实现服务集群的安全访问,每个服务集群包含1~N个服务,如果每个服务都定义一套安全策略,那么后期维护将会是一个大工程,因此需要统一安全策略,实现全局安全访问。

OAuth2

OAuth是一个关于授权(authorization)的开放网络标准,2.0版本在全世界得到广泛应用,网上有很多讲解OAuth2的文档,如果你对OAuth没有概念,可以查看往期文章:理解OAuth2.0OAuth2.0的一个简单解释OAuth2.0的四种调用方式

假设我们是一家公司,公司内部有以下业务部门:

  • 微信:提供聊天、账户系统,同时对外提供账户授权登录服务
  • 电子支付:提供在线支付服务
  • 王者荣耀:提供王者荣耀游戏服务

img

为了实现各个业务系统之间相互调用,需要一套授权系统,对内外系统提供统一的授权访问服务,OAuth协议能够满足上述要求,只需要一套授权服务就能同时满足内外系统的调用要求。

SpringSecurity OAuth

OAuth 涉及四个角色:

  • 用户:实际拥有资源所有权的使用者,例如:张三
  • 客户端:提供应用功能的程序客户端,可以是APP形式、web形式,例如:时间海绵博客()
  • 授权服务:实现OAuth2授权协议,对外提供授权服务,例如:微信开发平台
  • 资源服务:对外提供资源访问服务,例如:我们使用微信的微信扫码登录时为网站提供用户信息的微信服务

下面以微信登录为例:

img

传统的模式中,我们默认web客户端是可信任的,所以没有用户授权的过程,可以访问任何数据。在OAuth协议中,客户端默认是不可信任的,需要进行授权处理。无论是内部客户端还是外部客户端都需要得到授权服务器的认证,但是内部和外部客户端可以使用不同的授权认证方式(比如最严格的授权码方式和最简单的客户端方式)。

授权服务器

使用 spring-security-oauth2 搭建授权服务器,对外提供以下功能:

  • /oauth/authorize 获取授权码
  • /oauth/token 提供客户端(client_credentials)、简化(implicit)、密码(password)、刷新Token(refresh_token),授权码(authorization_code)方式获取access_token
  • /oauth/check_token 依据access_token获取授权信息

同时需要管理客户端信息,客户端信息包含以下属性:

  • clientId 客户端Id
  • clientSecret 客户端密码
  • resourceId 资源Id,用于指定可以访问哪些资源服务
  • authorizedGrantTypes 授权模式,客户端(client_credentials)、简化(implicit)、密码(password)、授权码(authorization_code)
  • scopes 授权范围,可以指定客户端访问权限,这个在协议中没有明确指明作用,各个授权服务可以基于业务自行处理
  • authorities 权限,客户端授权模式下需要配置,其它模式下可以不配置
  • redirectUri 授权结果跳转地址

在先前的文章中提到了UserDetailsService用于查询用户信息,在OAuth中需要提供ClientDetailsService来查询客户端信息,代码如下:

public class AuthClientDetailService implements ClientDetailsService {
    private ClientDetailsService clientDetailsService;
    public AuthClientDetailService() {
        InMemoryClientDetailsServiceBuilder builder = new InMemoryClientDetailsServiceBuilder();
        builder.withClient("blog")// client_id
                .secret(new BCryptPasswordEncoder().encode("blog"))
                .resourceIds("blog", "resource")
                .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型 authorization_code,password,refresh_token,implicit,client_credentials
                .scopes("all", "user")// 允许的授权范围
                .autoApprove(false) //加上验证回调地址
                .authorities("blog")
                .accessTokenValiditySeconds(60 * 60 * 2) // 令牌默认有效期2小时
                .refreshTokenValiditySeconds(60 * 60 * 24 * 3) // 刷新令牌默认有效期3天
                .redirectUris("https://blog.hzchendou.com");
        try {
            this.clientDetailsService = builder.build();
        } catch (Exception e) {
            System.exit(-1);
        }
    }
    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        return this.clientDetailsService.loadClientByClientId(clientId);
    }
}

同时需要引入AuthorizationServerConfigurer对授权服务进行配置,代码如下:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthClientDetailService authClientDetailService;
    @Autowired
    private AuthenticationManager authenticationManager;
    /**
     * 配置客户端信息
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(authClientDetailService);
    }
    /**
     * 配置OAuth token相关配置
     *
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证
                .reuseRefreshTokens(false);
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        security.tokenKeyAccess("permitAll()")//oauth/token_key是公开
                .checkTokenAccess("permitAll()");//oauth/check_token公开
        /// 配置使用 客户端id 和密码的方式进行登录(使用明文传输,不推荐) 
        AuthClientCredentialsTokenEndpointFilter endpointFilter = new AuthClientCredentialsTokenEndpointFilter(security);
        endpointFilter.afterPropertiesSet();
        endpointFilter.setAuthenticationEntryPoint(new AuthAuthenticationEntryPoint());
        // 客户端认证之前的过滤器
        security.addTokenEndpointAuthenticationFilter(endpointFilter);
    }
}

使用@EnableAuthorizationServer注解配置会自动配置AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint接口,提供OAuth授权服务。

至此完成授权服务搭建

资源服务器搭建

资源服务器也是使用spring-security-oauth2进行搭建,通过上面的介绍,我们知道资源服务器需要识别access_token来获取用户授权的信息内容,配置信息如下:

@EnableResourceServer
@Configuration
public class SpringSecurityResourceServerConfig extends ResourceServerConfigurerAdapter {
    public static final String RESOURCE_ID = "resource";
    @Autowired
    TokenStore tokenStore;
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.formLogin().disable();
        httpSecurity.exceptionHandling()
                .accessDeniedHandler(new PathAccessDeniedHandler())
                .authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        httpSecurity.authorizeRequests()
                .antMatchers("/admin/**").hasAuthority("admin")
                .antMatchers("/user/**").hasAuthority("user")
                .and().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        //当前资源服务 id,用于校验授权信息是否能够访问
        resources.resourceId(RESOURCE_ID)
                .tokenStore(tokenStore)
                /// 设置Token服务,用于识别access_token授权信息
                .tokenServices(tokenService())//验证令牌的服务
                .stateless(true);
    }

    //资源服务令牌解析服务
    @Bean
    public ResourceServerTokenServices tokenService() {
        //使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
        RemoteTokenServices service = new RemoteTokenServices();
        service.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
        service.setClientId("blog");
        service.setClientSecret("blog");
        return service;
    }
}

通过配置@EnableResourceServer注解,将会在Filter过滤链中添加OAuth2AuthenticationProcessingFilter过滤器拦截请求,依据access_token解析授权信息, 查看请求头是否有Authorization属性,该属性代表access_token信息。

OAuth服务验证

启动授权服务以及资源服务,访问 POST http://localhost:8081/oauth/token,请求参数如下所示(使用密码模式访问):

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "db9898be-2aef-4f86-9486-b3735db6403e",
    "token_type": "bearer",
    "refresh_token": "44d9aeff-8839-4a9b-b6ea-1ea6310bab5e",
    "expires_in": 6971,
    "scope": "all user"
}

启动资源服务,访问 GET http://localhost:8082/admin/hello,请求头信息如下

Authorization:Bearer db9898be-2aef-4f86-9486-b3735db6403e

返回信息如下:

{
    "code": 200,
    "data": "Hello Admin"
}

说明访问成功

总结

SpringSecurity中的功能都是通过组装Filter链来完成特定功能实现,

  • SpringSecurity OAuth授权服务需要提供对外服务,因此还提供了AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint等接口模块,当然你也可以用自己的实现来替换这些服务。
  • SpringSecurity OAuth 资源服务提供了OAuth2AuthenticationProcessingFilter过滤器来解析access_token授权信息,获得权限信息,后续的权限验证流程与先前的SpringSecurity单体应用是一致的

参考文档

五、SpringSecurity OAuth2扩展自定义授权模式

代码

代码仓库:地址

代码分支:lesson5

简介

在上一篇文章中,我们使用SpringSecurity OAuth2搭建了一套授权服务,对业务系统进行统一授权管理。OAuth提供了四种授权方式:

  • 授权码模式(authorization_code)
  • 简化模式(implicit)
  • 客户端(client_credentials)
  • 密码(password)

在实际业务中上述四种模式不能满足所有要求,例如业务系统接入了短信验证码登录方式,需要进行扩展满足业务需求

手机验证码登录

原理分析

SpringSecurity OAuth在使用@EnableAuthorizationServer注解会自动装配TokenEndpoint对象,这个对象会提供一个POST /oauth/token接口,我们以密码授权模式分析调用流程,如下所示:

img

用户在时间海绵博客发起用户名密码登录请求,时间海绵博客服务器端接收到请求后,调用OAuth协议中的密码授权模式发送请求到OAuth授权服务器,请求信息如下:

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

在ClientCredentialsTokenEndpointFilter过滤器中对客户端信息(client_id和client_secret)进行校验,授权成功后才能访问TokenEndpoint接口(这里要注意,对于OAuth授权服务器来说,过滤链主要完成对客户端信息的校验,用户信息在TokenEndpoint中进行校验,这是因为不同的授权模式关注的用户信息类型不同,需要具体问题具体分析)。

在TokenEndpoint中依据验证的客户端信息以及请求的授权模式进行对比,校验客户端是否有权限进行特定类型授权请求,校验通过后委托给TokenGranter组件进行具体授权模式校验,如上图所示,ResourceOwnerPasswordTokenGranter负责对密码授权模式请求校验,SpringSecurity OAuth还提供了以下实现来校验授权请求:

  • AuthorizationCodeTokenGranter负责校验授权码模式(authorization_code)请求
  • ClientCredentialsTokenGranter负责校验客户端模式(client_credentials)请求
  • ImplicitTokenGranter负责校验简化模式(implicit)请求
  • ResourceOwnerPasswordTokenGranter负责校验密码模式(password)请求

TokenGranter校验成功将返回AccessToken信息,后续客户端可以使用AccessToken信息获取到授权用户信息完成对应操作。

通过上述可以知道,如果要扩展实现短信验证码模式,需要自定义实现TokenGranter组件来校验手机验证码授权请求,TokenGranter定义如下所示:

public interface TokenGranter {
    //// 对授权类型以及请求参数进行处理,如果成功则返回AccessToken信息
    OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest);
}

手机验证码模式代码实现

实现一个继承ToeknGranter接口的类,通过分析已有的TokenGranter子类,我们可以很容易实现,定义一个SmsCodeTokenGranter类,代码实现如下所示:

public class SmsCodeTokenGranter extends AbstractTokenGranter {

    /// 授权模式类型,需要与请求字段grant_type的值相等才会进入处理
    private static final String GRANT_TYPE = "sms_code";
    /// 验证手机与验证码信息是否匹配,这里只是简单的进行匹配处理,判断是否是否为15000000000,验证码是否为:888888
    private final PhoneSmsCodeService phoneSmsCodeService;

    public SmsCodeTokenGranter(AuthorizationServerTokenServices tokenServices,
            ClientDetailsService clientDetailsService,
            OAuth2RequestFactory requestFactory, PhoneSmsCodeService phoneSmsCodeService
    ) {
        super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
        this.phoneSmsCodeService = phoneSmsCodeService;
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());

        String mobile = parameters.get("mobile"); // 手机号
        String code = parameters.get("code"); // 短信验证码
        ///对参数进行基础校验
        if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }
        /// 校验手机验证码是否符合要求
        if (!phoneSmsCodeService.checkSmsCode(mobile, code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }

        /// 这里为了简单直接硬编码写入用户信息,通常需要在数据库中取出用户相关信息
        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add( new SimpleGrantedAuthority("user"));
        User user = new User(mobile, "", roles);
        /// 授权模式
        UsernamePasswordAuthenticationToken userAuth = new UsernamePasswordAuthenticationToken(user, null, roles);
        userAuth.setAuthenticated(true);
        OAuth2Request storedOAuth2Request = this.getRequestFactory()
                .createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, userAuth);
    }
}
继承AbstractTokenGranter抽象类(该类继承了TokenGranter接口),我们只需要关注核心逻辑"验证手机验证码信息是否正确",校验正确将返回一个OAuth2Authentication对象,这个对象包含了用户信息以及OAuth2请求信息。
完成这一步后,我们需要将SmsCodeTokenGranter装配到TokenEndpoint组件中对sms_code授权类型进行校验处理,配置逻辑如下:
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证信息
                .reuseRefreshTokens(false);
        //// 取出系统中的四种模式
        List<TokenGranter> granters = new ArrayList<>(Arrays.asList(endpoints.getTokenGranter()));
        /// 添加手机验证码的授权模式
        granters.add(new SmsCodeTokenGranter(endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), phoneSmsCodeService));
        /// 这是一个组装模式,实现了TokenGranter接口,循环调用List中的TokenGranter组件进行校验处理,直到返回验证成功信息或者是异常信息
        CompositeTokenGranter compositeTokenGranter = new CompositeTokenGranter(granters);
        endpoints.tokenGranter(compositeTokenGranter);
    }

上述在AuthorizationServerConfigurerAdapter配置类中进行,具体参见代码。

运行校验

需要注意,这是一个新的授权模式,因此需要先授权客户端拥有手机短信验证模式请求权限,配置客户端的authorizedGrantTypes属性包含sms_code权限(这一步参见代码)。

启动服务,发送手机验证码授权请求POST /oauth/token,请求参数:

client_id:blog
client_secret:blog
grant_type:sms_code
mobile:15000000000
code:888888

返回参数信息:

{
    "access_token": "cf4243c1-085e-4f82-b733-03fb38c90a7c",
    "token_type": "bearer",
    "refresh_token": "01474dd1-c5c5-436b-98b1-8f146cde2391",
    "expires_in": 7199,
    "scope": "all user"
}

手机短信验证码授权模式验证成功,具体代码参见代码仓库。

总结

  • 扩展自定义授权模式,需要继承TokenGranter接口,实现具体校验逻辑
  • 将实现的自定义授权TokenGraner类装配到TokenEndpoint组件中
  • 特别注意需要将新的授权模式grantType信息授权给指定的客户端,不然客户端无法发送自定义授权模式请求,例如本案例中的sms_code请求

参考文档

六、SpringSecurity OAuth2 + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支:lesson6

简介

在先前文章中,我们使用SpringSecurity OAuth2搭建了一套基于OAuth2协议的授权系统,并扩展了手机验证码授权模式。在微服务架构下,网关承担着流量入口的角色,所有的请求都要先经过网关,然后由网关负责转发到具体的服务,因此可以在网关实现统一鉴权,网关对请求中的权限进行鉴定,然后将权限信息转发到具体的资源服务,在资源服务中只需要简单校验请求中的权限信息即可(查看信息是否有效),整体流程如下所示:

img

统一鉴权

SpringCloud Gateway网关

我们在上一篇的基础上引入网关服务,在这里使用SpringCloud Gateway组件进行搭建,引入依赖:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

网关在OAuth2授权协议中承担着资源服务的角色,对请求进行身份鉴定和访问权限控制,身份鉴定需要访问OAuth2授权服务,因此需要引入OAuth2资源服务以及客户端依赖:

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

通过之前的文章,我们可以知道SpringSecurity 通过组装一系列的Filter来完成身份验证和权限访问控制功能,但是SpringCloud Gateway使用了新技术框架Reactive Stack(响应式编程),在Spring中提供了Spring WebFlux模块支持响应式编程,传统的Spring MVC都是基于阻塞I/O编程,而Spring WebFlux是基于非阻塞I/O,我们不再这里讨论这两个的区别,只需要知道WebFlux特别适合I/O密集型性应用,网关就是典型的I/O密集应用(网络I/O处理频繁)。SpringSecurity对WebFlux提供了支持,在WebFlux中WebFilter组件承担着与Filter相似的功能。

我们在先前的应用中通过HttpSecurity组件来组装SpringSecurity功能,在这里要使用新的组件ServerHttpSecurity来组装SpringSecurity功能,配置如下所示:

///启用WebFlux下的SpringSecurity配置
@EnableWebFluxSecurity
public class ResourceServerConfig {
    //// 访问权限验证
    @Autowired
    AuthManagerHandler authManagerHandler;
    //// 无权限访问处理器
    @Autowired
    AccessDeniedHandler accessDeniedHandler;
    /// 登录信息失效处理器
    @Autowired
    LoginLoseHandler loginLoseHandler;
    ////访问白名单,对白名单路径可以实现匿名访问
    @Autowired
    private WhiteUrlProperties whiteUrlProperties;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer()
                /// 这里配置对令牌的校验,从OAuth2授权服务中获取令牌对应的授权信息
                .opaqueToken()
                ///令牌校验地址,用于校验令牌是否有效,已经令牌对应的授权信息
                .introspectionUri("http://localhost:8081/oauth/check_token")
                //// 客户端信息
                .introspectionClientCredentials("blog", "blog")
                .and()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(loginLoseHandler)
                .and().authorizeExchange()
                .pathMatchers(HttpMethod.OPTIONS).permitAll() //o
                .pathMatchers("/**").access(authManagerHandler)
                .anyExchange().authenticated()
                .and()
                .addFilterBefore(securityGlobalFilter(whiteUrlProperties), SecurityWebFiltersOrder.FIRST)
                .cors().disable().csrf().disable();
        return http.build();
    }
    /// 该过滤器实现将获取到的授权信息转发到下游服务中,方便后续校验
    public WebFilter securityGlobalFilter(WhiteUrlProperties properties) {
        return new SecurityGlobalFilter(properties);
    }

}

网关路由配置以及其它细节信息可以前往代码仓库进行查看,在此不做过多解释。

资源服务器

资源服务器也需要做一些调整,不需要对请求进行严格的访问控制,只需要校验网关传递的授权信息即可,然后将授权信息放入到SecurityContext中方便后续处理,同时需要注意在资源服务中还是使用Spring MVC框架进行处理(Spring WebFlux可以提高系统吞吐量,但是也会增加编程难度,例如原先的线程变量将不适用,具体需要考量整体编程人员掌握的技术栈来做决定)。

这里的资源服务器不再依赖OAuth授权服务,因此可以移除@EnableResourceServer配置(不直接参与权限控制,只需要校验上游传递的授权信息是否有效即可),同时增加对上游SpringCloud Gateway传递的授权信息进行解析处理,增加自定义SecurityAuthTokenFilter组件:

public class SecurityAuthTokenFilter extends OncePerRequestFilter {
    private static final String AUTH_TOKEN_NAME = "token";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(AUTH_TOKEN_NAME);
        if (StringUtils.isEmpty(token)) {
            /// 继续处理
            filterChain.doFilter(request, response);
            return;
        }
        ///....省略处理细节,具体前往代码仓库进行查看
        //// 创建自定义的Authentication对象,必须申明为已授权,也就是isAuthenticated()方法返回为true
        BlogAuthentication authentication = new BlogAuthentication(userId, clientId, authorities);
        //....省略处理细节,具体前往代码仓库进行查看
        /// 将授权信息放入到SecurityContext中,方便后续使用
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}

运行验证

分别运行Gateway网关服务、OAuth授权服务、Resource资源服务

  • Gateway 8080端口
  • OAuth 8081端口
  • Resource 8082端口

授权登录

使用密码模式进行授权登录,发送请求POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "4aace702-cc9d-4a92-b507-9b65f192a65f",
    "token_type": "bearer",
    "refresh_token": "a50a6cff-97b0-4d0f-b3d2-e0fdcee6f142",
    "expires_in": 5591,
    "scope": "all user"
}

资源访问

使用得到的access_token访问资源服务器中的/admin/hello接口,发送请求GET http://localhost:8080/blog-resource/admin/hello,请求头中携带参数

Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}
访问其他权限的接口,发送请求GET http://localhost:8080/blog-resource/user/hello,请求头中携带参数:
Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 400,
    "message": "无权限访问"
}

至此得到期望的访问结果,实现了统一权限控制

总结

  • SpringCloud Gateway使用WebFlux技术进行开发
  • SpringSecurity提供了@EnableWebFluxSecurity来支持WebFlux
  • SpringSecurity使用ReactiveSecurityContextHolder.getContext()来实现SecurityContextHolder功能

参考文档

七、SpringSecurity OAuth2 + JWT + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支: lesson7

简介

在上一篇文章中,我们使用SpringSecurity OAuth2 + SpringCloud Gateway搭建了一套符合微服务架构的授权系统,在Gateway网关实现统一身份鉴定、访问权限控制,同时将授权信息下发到下游业务服务中,下游业务服务只需要关注核心业务逻辑。上述架构依赖于auth授权服务器,每一次业务请求都需要使用access_token请求auth授权服务器来获取用户授权信息,如果access_token自带授权信息,那么网关只需要鉴别access_token有效信息,这将会降低系统对auth授权服务器的依赖,JWT(JSON Web Token)将是很好的选择。

JWT

我们这里不详细介绍JWT,有兴趣的同学可以查看阮一峰老师的文章:JWT入门教程。JWT定义了一种数据结构,它由三部分组成:

  • Header,头部,定义了签名算法,令牌类型
  • Payload,负载,是一个JSON对象,包含实际应用中使用的数据,例如用户名,用户角色,注意这部分内容是不加密的,因此不能包含保密信息
  • Signature,签名,用于验证JWT是否有效,防止信息内容篡改

JWT内容是不加密的,可以使用在线工具解码信息,查看内容。例如有一个JWT格式Token:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk0MTc2LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiI2NTdjZmU0Yi05ZDBlLTRhNTUtYjJjOS1iZWE3MTA2YWJkYWIiLCJjbGllbnRfaWQiOiJibG9nIn0.CGQTlvCCwGWIuJBy_qNeX2YBEYYTy6W1FPXOll75P1jdEyvi_TDiTLE4AO2Fa9vtgdWKrtywgGi4kFWZw8mcRFmhVfl9ehdoPcN5Hmdnz-ybJuLWh0i1k0xqg6MsZryTR1wAweEggZkHsIdCZfOw-yPZFTKuhAgVL4d-12Uthb4

在线解析后得到信息如下:

img

项目改造优化

优化分析

在先前文章中,我们将授权信息保存在auth授权服务器中,客户端需要通过请求auth授权服务器来获取授权信息,如果使用JWT,并且在JWT中保存相关授权信息,那么可以直接解析JWT就可以获取授权信息(需要验证JWT是有有效)。

在先前的项目中,我们使用SpringSecurity OAuth2默认配置来创建access_token,实际上SpringSecurity OAuth2提供了对JWT格式access_token支持,我们需要更改access_token的生成方式,因此需要修改auth授权服务中的Token生成方式,同时需要对Gateway网关服务中的Token解析方式进行修改

生成JWT格式Token

使用SpringSecurity OAuth2时,如果没有配置TokenService对象,将会默认使用DefaultTokenServices组件来管理access_token, 使用UUID.randomUUID().toString()方式生成access_token和refresh_token,因此token中不包含任何信息。我们需要配置新的TokenService对象来生成JWT格式Token。

SpringSecurity OAuth2提供了以下组件来生成JWT格式Token:

  • JwtTokenStore实现了TokenStore接口,用来管理access_token和refresh_token
  • JwtAccessTokenConverter实现了TokenEnhancer, AccessTokenConverter接口,可以生成JWT格式token

JWT签名算法我们选用安全性更高的非对称加密算法:RSA(在代码auth/src/test/java/com/hzchendou/blog/demo/RSAKeyTest中提供生成RSA Key方法),配置TokenService:

@Bean
public TokenStore tokenStore() {
   return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
  JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
  converter.setKeyPair(keyPair()); //非对称秘钥,具体参见代码
  return converter;
}

@Bean
public AuthorizationServerTokenServices tokenService() {
   DefaultTokenServices service = new DefaultTokenServices();
   service.setClientDetailsService(authClientDetailService);
   service.setSupportRefreshToken(true);
   service.setTokenStore(tokenStore);
   //令牌增强
   TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
   List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
   tokenEnhancers.add(tokenEnhancer);
   tokenEnhancers.add(accessTokenConverter);
   tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

   service.setTokenEnhancer(tokenEnhancerChain);
   service.setAccessTokenValiditySeconds(60 * 60 * 2); // 令牌默认有效期2小时
   service.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3); // 刷新令牌默认有效期3天
   return service;
}

将TokenService配置到OAuth服务中:

endpoints.tokenServices(tokenService());//令牌管理服务

解析JWT格式Token

在网关中需要配置JWT格式解析器,使用JwtAuthenticationConverter来解析JWT中的字段:

@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
  JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
  jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
  jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
  JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
  return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}

因为使用RSA签名算法,因此在Gateway中需要配置RSA公钥来验证Token有效性, 有多种方式可以配置JWT解析验证器来验证JWT的有效性:

方法一、SpringSecurity OAuth2提供的方式:

配置public key信息来验证JWT有效性,在配置文件中配置(配置获取公约的接口地址):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8081/key/public-key /// 这里需要在oauth授权服务器中配置接口

@RestController
public class KeyController {
    @Autowired
    KeyPair keyPair;
    //获取公钥
    @GetMapping("/key/public-key")
    public Map<String, Object> getPublicKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

方法二、直接配置PublicKey方式(直接将公钥写入到配置文件中进行读取):

我们这里直接将public key 配置到gateway网关中:

jwt:
  rsa:
    publickey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCEfoWyfxqYz6j6tczCoELJfCwxpC+iHox7YEvz6slxNworp+CQAC86qt4Rx14lijoufiBMol0/mAABlG1lv3K1LOgQGcwueZDY5nk0uabOWv787moVbQHRTQoAwMIeSDPQ3SgSoEFyHM6Jj/We7XUpAyQEXKk9AabAvywEk2u9ewIDAQAB

然后手动创建JwtDecoder:

@Slf4j
@Configuration
public class TokenConfig {
    @Value("${jwt.rsa.publickey}")
    private String publicKey;
    public RSAPublicKey rsaPublicKey() {
        try {
            return (RSAPublicKey)RSAUtils.decodePublicKey(publicKey);
        } catch (Exception ex) {
            log.error("生成 KeyPair 失败", ex);
            System.exit(-1);
            return null;
        }
    }
    @Bean
    public NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder() {
        return NimbusReactiveJwtDecoder.withPublicKey(rsaPublicKey())
                .signatureAlgorithm(SignatureAlgorithm.from("RS256")).build();
    }
}

还有一步需要配置,JWT Token解析后的类型是JwtAuthenticationToken,因此需要修改SecurityGlobalFilter中ReactiveSecurityContextHolder.getContext()方法返回的authentication类型(具体参见代码)

运行校验

分别运行auth、resource、gateway服务:

  • gateway - 8080
  • auth - 8081
  • resource - 8082

发起OAuth2密码授权请求: POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

请求结果:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk4MzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiIyMmVlOTU4My00Y2U5LTRmNzEtOGI4MS02YjViNzdmYWNlYWEiLCJjbGllbnRfaWQiOiJibG9nIn0.atWwzwpCK1ycjf3-EkPUYs4DMqO7rGPIMwMjHKS3FrTKRjMW5DHkQjtilG2EB8qGNBlwQJo0xAnQ_RNMzOjVojGxyb-TUPCubqODnmnYhuee0ho2TurDT5YzfO-Ypkv2SDqEm6Kw38m-oV_93NofGtKNJD1or2kwdoZe6kn4qgw",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiYXRpIjoiMjJlZTk1ODMtNGNlOS00ZjcxLThiODEtNmI1Yjc3ZmFjZWFhIiwiZXhwIjoxNjU0ODUwMzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJjMzZhNDQ2Mi00ZmE3LTQ2OGUtODNiMS1iYzk4MDFjMzBjMWEiLCJjbGllbnRfaWQiOiJibG9nIn0.IyBeBQMjU-KYGIvvlQTrTkEtrPmTjLZIl1oFvyK0vytOlOFaE4Q5tMOLf1lt1UaBpmi2Tz4ElQSc6EMYX_OKmbyEHSidYxseUr8gE5MVM1raqOPCnR0Dyn7okQ0NvArOB9JuxLTXSa3NoSM3OxRQm2sUS55e6FKpifZ2q7xgGnY",
    "expires_in": 7199,
    "scope": "all user",
    "ex_username": "admin",
    "jti": "22ee9583-4ce9-4f71-8b81-6b5b77faceaa"
}

发起资源服务器请求:POST http://localhost:8080/blog-resource/admin/hello,请求头携带token参数

Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk2OTQyLCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJkOWRhZjhmYS1jOTY4LTQ2YzQtODMyMi1kOTQxNGU3YWZhY2UiLCJjbGllbnRfaWQiOiJibG9nIn0.Jkz_Tlk1W7opXspihyDkp1VFcIXu3ebfVPkshjFcKpktPmqkUIA4D2aWF5A13fq5QGUIDQKf89rVeGHaFfer657J7kqaax2qNT6yuNgmQAu4C8VQkG01VLDsOa-m9xaZnqR_--Af-Z7FbwpZNOT2pBuyP4M3efnMmGhRQQjB4hQ

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}

结果符合预期,到此完成JWT + SpringSecurity OAuth2 + SpringCloud Gateway 统一权限访问控制功能

总结

  • JWT自带用户信息,只需要验证Token有效性
  • OAuth授权服务添加JWT TokenService返回JWT格式token
  • Gateway网关服务添加JWTAuthenticationConverter解析JWT信息,同时添加NimbusReactiveJwtDecoder对JWT有效性进行验证

参考文档

Views: 401

SpringCloud与微服务-第7章-API 网关服务 Spring Cloud Gateway

API 网关服务: Spring Cloud Gateway

API 网关是一个更为智能的应用服务器,它的定义类似于面向对象设计模式中的 Façade (门面) 模式,它的存在就像是整个微服务架构系统的门面一样,所有的外部客户端访问都需要经过它来进行调度和过滤。它除了要实现请求路由、负载均衡、校验过滤等功能之外,还需要更多能力,比如于服务治理框架的结合、请求转发时的熔断机制、服务的聚合等一系列高级功能。

门面设计模式, 也叫外观模式, 是一种结构型设计模式, 它为子系统中的一组接口提供一个一致的界面, 此模式定义了一个高层接口, 这个接口使得这一子系统更加容易使用。比如 sl4j,它是一个日志门面,它的实现有 log4j,logback 等。


目标

在本章中,您将学习:

  • 介绍网关的作用和相关产品
  • GateWay 网关的快速入门
  • 路由详解
  • 过滤器详解
  • 动态路由
  • 动态过滤器

API 网关作用

对于整个微服务来说如果将每一个微服务的接口直接暴露给用户是错误的做法,这里主要体现出三个问题:

  1. 服务将所有 API 接口对外直接暴露给用户端,这本身就是不安全和不可控的,用户可能越权访问不属于它的功能,例如普通的用户去访问管理员的高级功能。

  2. 后台服务可能采用不同的通信方式,如服务 A 采用 RESTful 通信,服务 B 采用 RPC 通信,不同的接入方式让用户端接入困难。尤其是 App 端接入 RPC 过程更为复杂。

  3. 在服务访问前很难做到统一的前置处理,如服务访问前需要对用户进行鉴权,这就必须将鉴权代码分散到每个服务模块中,随着服务数量增加代码将难以维护。


bg fit


为了解决以上问题,API 网关应运而生,加入网关后应用架构变为下图所示。


bg fit


引入 API 网关后,在用户端与微服务之间建立了一道屏障,通过 API 网关为微服务访问提供了统一的访问入口,所有用户端的请求被 API 网关拦截并在此基础上可以实现额外功能,例如:

  • 针对所有请求进行统一鉴权、熔断、限流、日志等前置处理,让微服务专注自己的业务。

  • 统一调用风格,通常 API 网关对外提供 RESTful 风格 URL 接口。用户传入请求后,由 API 网关负责转换为后端服务需要的 RESTful、RPC、WebService 等方式,这样便大幅度简化用户的接入难度。

  • 更好的安全性,在通过 API 网关鉴权后,可以控制不同角色用户访问后端服务的权利,实现了服务更细粒度的权限控制。


API 网关是用户端访问 API 的唯一入口,从用户的角度来说只需关注 API 网关暴露哪些接口,至于后端服务的处理细节,用户是不需要知道的。从这方面讲,微服务架构通过引入 API 网关,将用户端与微服务的具体实现进行了解耦。


API 网关技术简介

以上便是 API 网关的作用,那 API 网关有哪些产品呢?

目前,市面上有两种网关组件比较流行。下面,我们将对这两种组件做一个简单的了解。


Spring Cloud Zuul

Spring Cloud Zuul 是 Spring Cloud Netflix 子项目的核心组件之一,可以作为微服务架构中的 API 网关使用,具有动态路由、过滤、压力测试、监控、弹性伸缩和安全等功能 ,为微服务架构中的服务提供了统一的访问入口。Zuul 和 Ribbon 以及 Eureka 相结合,可以实现智能路由和负载均衡的功能,可以将流量按照某种策略分发到集群中的多个实例。

zuul 本质上是一个 web servlet 应用,基于 JavaEE Servlet 技术栈,使用阻塞 API,处理的是 http 请求,没有提供异步支持,不支持任何长连接,比如 websocket。


bg fit


好景不长,后来 Netflix 内部产生分歧,Netflix 官方宣布 Zuul 停止维护,这让 Spring 机构也必须转型。于是 Spring Cloud 团队决定开发自己的第二代 API 网关产品:Spring Cloud Gateway。


Spring Cloud Gateway

与 Zuul 是“别人家的孩子”不同,Spring Cloud Gateway 是 Spring 自己开发的新一代 API 网关产品。它基于 NIO 异步处理,摒弃了 Zuul 基于 Servlet 同步通信的设计,因此拥有更好的性能。同时,Spring Cloud Gateway 对配置进行了进一步精简,比 Zuul 更加简单实用。


Spring Cloud Gateway 是基于 Spring 5.0 、Spring boot 2.0 和 Project Reactor,为微服务提供一个简单有效的网关 API 路由接口。

它作为 Spring Cloud 生态系统的网关,目标是为了代替 Zuul,Spring Cloud Gateway 是基于 webFlux 框架实现的,而 WebFlux 框架底层则使用了高性能的 Reactor 模式通信框架 Netty。
Spring Cloud Gateway 提供统一的路由方式,基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。


技术对比

  1. Gateway 对比 Zuul 多依赖了 spring-webflux,内部实现了限流、负载均衡等,扩展性也更强。Zuul 内部没有实现限流、负载均衡等。

  2. Zuul 仅支持同步,Gateway 支持异步。

  3. Gateway 线程开销少,支持各种长连接、websocket,spring 官方支持,但运维复杂,Zuul 编程模型简单,开发调试运维简单,有线程数限制,延迟堵塞会耗尽线程连接资源。

  4. Zuul 1.x,是一个基于阻塞 io 的 API Gateway。Zuul 已经发布了 Zuul 2.x,基于 Netty,也是非阻塞的,支持长连接,但 Spring Cloud 暂时还没有整合计划。Spring Cloud 官方更加推荐的网关组件是 Gateway。


本章重点介绍 Spring Cloud Gateway 的使用方法、配置属性等。下面我们将通过一个快速入门案例,了解网关是如何在我们的项目中发挥作用的。


Spring Cloud Gateway 的关键特征:

  • 基于 JDK 8+ 开发;
  • 基于 Spring Framework 5 + Project Reactor + Spring Boot 2.0 构建;
  • 支持动态路由,能够匹配任何请求属性上的路由;
  • 支持基于 HTTP 请求的路由匹配(Path、Method、Header、Host 等);
  • 过滤器可以修改 HTTP 请求和 HTTP 响应(增加/修改 Header、增加/修改请求参数、改写请求 Path 等等)

当下 Spring Cloud Gateway 已然是 Spring Cloud 体系上 API 网关标准组件。Spring Cloud Gateway 十分优秀,Spring Cloud Alibaba 也默认选用该组件作为网关产品,下面我们就通过实例讲解 Spring Cloud Gateway 的使用办法。


GateWay 快速入门

介绍了一下关于网关服务的概念和作用,在这一节中,不妨用实际的示例来直观的体验一下 Spring Cloud Gateway 是如何使用和运作,并应用到微服务架构中去的。在搭建完网关基础服务之后,我们的整体项目架构将如图所示:


h:16em


构建网关

首先,在实现各种 API 网关服务的高级功能之前,我们需要做一些准备工作,比如,构建起最基本的 API 网关服务,并且搭建几个用于路由和过滤使用的微服务应用等。对于微服务应用,我们可以直接使用之前章节实现的 order-service 和 user-service 。


虽然之前我们一直将 order-service 视为消费者,但是在 Nacos 的服务注册与发现体系中,每个服务既是提供者也是消费者,所以 order-service 实质上也是一个服务提供者。之前我们访问的 http://localhost:8080/consumer/order/ 等一系列接口就是它提供的服务。读者也可以使用自己实现的微服务应用,因为这部分不是本章的重点, 任何微服务应用都可以被用来进行后续的试验。这里,详细介绍一下 API 网关服务的构建过程。


1.引入依赖

创建一个基础的 Spring Boot 工程,命名为 gateway-server,并在 pom.xml 中引入相关依赖,具体如下:

<!--Gateway 网关依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--Nacos 服务发现依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

引入 Nacos 的服务发现依赖,可以结合 Spring Cloud Gateway 实现服务的拉取和负载均衡。对于 Nacos 的服务发现依赖,可以通过査看它的依赖内容了解到,它还包含了下面这些网关服务需要的重要依赖:

image-20220728114039071

  • spring-cloud-starter-hystrix :该依赖用来在网关服务中实现对微服务转发时候的保护机制,通过线程隔离和断路器,防止微服务的故障引发 API 网关资源无法释放,从而影响其他应用的对外服务。
  • spring-cloud-starter-ribbon :该依赖用来实现在网关服务进行路由转发时候的客户端负载均衡以及请求重试。

2.创建主启动类
@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class,args);
    }
}

3.配置文件

创建 application.yml 配置文件,并进行如下配置:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:9020
          predicates:
            - Path=/user/**
        - id: order-service
          uri: http://localhost:9010
          predicates:
            - Path=/order/**

完成上面的工作后,通过 Gateway 实现的 API 网关服务就构建完毕了。


4.测试

在上面的配置文件中,我们定义了两组路由规则,现在我们以其中的 order-service 为例,进行访问测试。 首先要启动 Nacos 服务,然后分别启动 gateway-server,order-service,user-service。

启动成功之后,访问: http://localhost:10010/order/101


image-20220728114819131

可以看到,我们访问的是网关路径,但实际上提供请求处理功能的是 order-service 服务。


路由请求

在入门案例中,我们在配置文件中进行了一些简单配置,就可以实现网关的路由功能,那么这些配置起到什么作用呢?


传统路由方式

在以往,我们发送 HTTP 请求至少需要知道服务端的 IP 和端口,这样我们就会固定的将请求路由到该服务端的具体 IP 和端口上。如下所示:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: http://127.0.0.1:8081 #路由的目标地址 http就是固定地址
          predicates:
            - Path=/user/**

该配置定义了发往 API 网关服务的请求中,所有符合 http://GATEWAYTHOST:10010/user/** 规则的访问都将被路由转发到http://127.0.0.1:8081/user/** 地址上,也就是说,当访问 http://127.0.0.1:10010/user/** 的时候, API 网关服务会将该请求路由到 http://127.0.0.1:8081/user提供的微服务接口上。其中,配置属性 gateway.routes 下的 id 部分为路由的名字可以任意定义,下面将要介绍的面向服务的映射方式也是如此。


面向服务的路由(添加负载均衡功能)

前面讲的 uri 的配置方式,需要运维人员手动维护各个路由 path 与 url 的关系, 并且当某个服务的实例发生变化时,还需要手动修改路由配置,这样的方式显然不够友好。


为了解决这个问题, Spring Cloud Gateway 实现了与 Spring Cloud Nacos 和 Ribbon 的无缝整合:

  • 可以让路由的 path 不是映射具体的 url, 而是让它映射到某个具体的服务,而具体的 url 则交给 Nacos 的服务发现机制去自动维护,称这类路由为面向服务的路由。
  • 并且,面向服务的路由还可以实现负载均衡的功能,这样就可以让 API 网关服务在转发请求时,将请求均匀的分发到多个服务实例上,从而实现对微服务的负载均衡。

在 Gateway 中使用服务路由也同样简单,我们在入门案例中采用的就是面向服务的路由。

server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      #网关路由配置
      routes:
        #路由id,自定义,只要唯一即可
        - id: user-service
          #路由的目标地址  lb就是负载均衡,后面跟着服务名称
          uri: lb://userservice
          #路由断言,也就是判断请求是否符合路由规则的条件
          predicates:
            #按照路径匹配,只要以/user/开头就符合要求 将会被路由到该组配置下的userservice服务中处理
            - Path=/user/**
        #可以自定义配置多组路由id,只要唯一即可
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**

针对之前准备的两个微服务应用 order-service 和 user-service, 在上面的配置中分别定义了两个名为 order-service 和 user-service 的路由来映射它们。另外,通过指定 Nacos Server 服务注册中心的位置,除了将自己注册成服务之外,同时也让 Gateway 能够获取 order-service 和 user-service 服务的实例清单,以实现 path 映射服务,再从服务中挑选实例来进行请求转发的完整路由机制。


在完成了上面的服务路由配置之后,可以将 nacos-server,order-service 和 user-service 以及这里用 Spring Cloud Gateway 构建的 gateway-server 都启动起来,并且我们可以启动多个 order-service 和 user-service 的实例。启动完毕,在 nacos-server 的信息面板中,可以看到,除了 order-service 和 user-service 之外,多了一个网关服务 gateway,并且 order-service 和 user-service 的实例不止一个。


image-20220728210322861


通过面向服务的路由配置方式,不需要再为各个路由维护微服务应用的具体实例的位置,而是通过简单的 path 与 serviceld 的映射组合,使得维护工作变得非常简单。这完全归功于 Spring Cloud Alibaba Nacos 的服务发现机制,它使得 API 网关服务可以自动化完成服务实例清单的维护,完美地解决了对路由映射实例的维护问题。


开启动态路由

在上面的案例中,我们已经实现了基于 Nacos 服务发现机制的面向服务的路由配置,但是这种路由配置方式还是有一个问题,那就是当我们新增了一个微服务应用时,需要手动去配置一个新的路由,这样的话,就不是很符合微服务的敏捷开发理念了。


为了解决这个问题, Spring Cloud Gateway 提供了动态路由的功能,它可以让我们在不重启网关服务的情况下,实现路由的动态刷新。在 Spring Cloud Gateway 中,动态路由的配置信息是存储在内存中的,所以我们可以通过 Spring Cloud Gateway 提供的 API 来实现动态路由的刷新。


动态路由默认是关闭的, 如果需要开启, 修改配置:

server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      discovery:
        #网关路由配置
        locator:
          #是否开启动态路由
          enabled: true
        #统一使用小写的微服务ID
        lower-case-service-id: true

在上面的配置中,我们通过 locator.enabled 属性来开启动态路由的功能,然后通过 lower-case-service-id 属性来统一使用小写的微服务 ID。这样,当我们新增了一个微服务应用时,只需要在 Nacos Server 中注册一个新的服务,然后在网关服务中调用 Spring Cloud Gateway 提供的 API,就可以实现动态路由的刷新。

比如, userservice 中有一个接口/user/{id}, 我们可以通过调用网关服务的动态路由进行调用 http://localhost:10010/userservice/user/1 , 非常方便。


动态路由也有一些限制, 比如需要调用的微服务必须能通过服务发现机制访问, 无法定制路由的规则 ,必须暴露所有的微服务接口等。

Gateway 也可以通过 Actuator API 接口实现动态路由的添加和删除, 这里不作介绍。


在 Spring Cloud Gateway 中,动态路由的刷新是通过 Spring Cloud Gateway 提供的 RouteLocator 接口来实现的。在 RouteLocator 接口中,有一个 refresh() 方法,它可以用来刷新路由信息。在 Spring Cloud Gateway 中,RouteLocator 接口的实现类有两个,一个是 PropertiesRouteLocator,另一个是 DiscoveryClientRouteLocator。其中, PropertiesRouteLocator 是通过配置文件来实现路由信息的加载,而 DiscoveryClientRouteLocator 是通过服务发现机制来实现路由信息的加载。


路由详解

在快速入门 一节的请求路由示例中,对 Spring Cloud Gateway 中的两类路由功能己经做了简单的使用介绍。在本节中,将进一步详细介绍关于 Spring Cloud Gateway 的路由功能,以方便用户更好地理解和使用它。


断言工厂

Spring Cloud Gateway 将路由作为 Spring WebFlux HandlerMapping 基础设施的一部分进行匹配。Spring Cloud Gateway 包括许多内置的路由断言工厂。所有这些断言都匹配 HTTP 请求的不同属性。您可以将多个路由断言工厂与逻辑和语句组合在一起。

在上一节快速入门中,同学们或许已经注意到配置文件有一个关键词:predicates ,这个就是路由断言工厂的配置标识。我们在此基础上进行路由配置。


spring:
  application:
    name: gateway
  cloud:
        gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**

我们在配置文件中写的断言规则只是字符串,这些字符串会被 Predicate Factory 读取并处理,转变为路由判断的条件,例如 Path=/user/\*\*是按照路径匹配,这个规则是由 org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory 类来处理的。

该配置的含义是,当请求路径以/user开头,那么 Gateway 网关就会把请求路由到 userservice 对应的实例上进行请求处理。如果请求路径不匹配该配置,则会寻找其他的配置项进行匹配。 假如最后所有的匹配项都不满足,网关将会响应一个包含 HTTP 404 状态码的响应信息。


PathRoutePredicateFactory 是我们在实际生产中最常用的一种断言工厂,除了 PathRoutePredicateFactory 之外,像这样的断言工厂在 SpringCloudGateway 还有十几个:


名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些 cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些 header - Header=X-Request-Id, \d+
Host 请求 host(域名)必须包含 - Host= .somehost.org,.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack 或者 - Query=name
RemoteAddr 请求者的 ip 必须是指定范围 - RemoteAddr=192.168.1.1/24

在 Spring Cloud 的官方文档上,对于断言工厂的用法有详细的介绍,具体可以参考如下网址: https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories


现在,我们以 After 为例,对入门案例进行改造。在配置文件中,我们做如下改动:


server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      #网关路由配置
      routes:
        #路由id,自定义,只要唯一即可
        - id: user-service
          #路由的目标地址  lb就是负载均衡,后面跟着服务名称
          uri: lb://userservice
          #路由断言,也就是判断请求是否符合路由规则的条件
          predicates:
            #按照路径匹配,只要以/user/开头就符合要求 将会被路由到该组配置下的userservice服务中处理
            - Path=/user/**
        #可以自定义配置多组路由id,只要唯一即可
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
            - After=2030-01-20T17:42:47.789-07:00[Asia/Shanghai]

我们在配置文件中添加了一条时间信息的配置,该配置表示访问/order/**的请求,同时还要满足另一个条件,那就是该请求的当前时间必须在规定的日期之后。只有同时满足路径信息和时间信息的规则,请求才会被路由到 orderservice 进行处理。


像入门案例一样,做好启动服务的准备工作之后,我们访问:http://localhost:10010/order/101, 如果在整个配置中找不到合适的路由匹配,那将如下所示:

image-20220729124406464


然后我们将 After 所指定的时间改为一个可以被断言通过的时间,比如:

- After=2020-01-20T17:42:47.789-07:00[Asia/Shanghai]

重启网关服务,再次访问:http://localhost:10010/order/101,可以看到如下结果:

image-20220729125454080

由此可以看出,After 所配置的规则,可以控制请求访问是否被允许。目前最新版本的断言工厂一共有十二种,用法大致类似,具体的使用案例我们可以在官方文档中轻松找到,此处不再一一赘述。


过滤器详解

在本章一开始的技术简介中,我们了解到 Spring Cloud Gateway 基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。在本节中,将对 Gateway 的请求过滤器功能做进一步的介绍和总结。


路由过滤器

GatewayFilter 是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理。在快速入门小节,当我们的请求被网关接受到之后,该请求就会立刻被路由到相应的微服务端吗? 其实并不是的,在请求被真正路由到微服务之前,其实还会经理一系列的过滤器的过滤,最后才会到达微服务。


bg right fit

客户端向 Spring Cloud Gateway 发出请求。如果网关处理程序映射确定请求与路由匹配,则将其发送到网关 Web 处理程序。

此处理程序通过特定于请求的过滤器链运行请求。过滤器用虚线划分的原因是过滤器可以在发送代理请求之前和之后运行逻辑。执行所有“预”过滤器逻辑。然后发出代理请求。发出代理请求后,将运行“发布”过滤器逻辑。


路由过滤器允许以某种方式修改传入的 HTTP 请求或传出的 HTTP 响应。路由过滤器的范围是特定的路由。Spring Cloud Gateway 包含许多内置的 GatewayFilter 工厂。

目前最新版本的 Spring Cloud Gateway 的官方文档,一共有 33 种路由过滤器,以后可能还有更多路由过滤器加入进来。同学们可以访问该文档: https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories 进行查看和学习。


如此之多的过滤器,我们不必每一个都进行学习。我们学会其中比较常用的一些过滤器的用法即可,至于其他的过滤器,往往在我们具体使用到它们的时候再去查看具体的官方文档。下面我们列举部分的路由过滤器:

名称 说明
AddRequestHeader 给当前请求添加一个请求头
RemoveRequestHeader 移除请求中的一个请求头
AddResponseHeader 给响应结果中添加一个响应头
RemoveResponseHeader 从响应结果中移除有一个响应头
RequestRateLimiter 限制请求的流量

AddRequestHeader

从名字上可以轻易看出来这个过滤器的功能,顾名思义是给当前请求添加一个请求头。现在,我们完成下面这个需求:

给所有进入userservice的请求添加一个请求头:Description=NIIT is freaking awesome!

完成这个需求非常简单,我们只需要修改 gateway 服务的 application.yml 文件,添加路由过滤即可:

server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
          filters:
            - AddRequestHeader=Description,NIIT is freaking awesome!

当前过滤器写在 userservice 路由下,因此仅仅对访问 userservice 的请求有效,而对于访问 orderservice 的请求则无效。AddRequestHeader 的值由两部分组成,并以英文逗号分隔。逗号左边是请求头的 name,而右边则是该请求头所对应的具体的信息。

现在,我们在 userservice 中使用 Spring MVC 的@RequestHeader 注解,接收 Description 请求头的值。


@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") String id
            ,@RequestHeader(value = "Description",required = false)String description) {
        System.out.println("接受到的请求头Description的值是: " + description);
        return userService.queryById(id);
    }
}

重启 Gateway 和 userservice 之后,我们访问:http://localhost:10010/user/1,可以在 IDEA 的控制台看到如下信息:

image-20220729172346325


其余三十余种路由过滤器,其用法与上述例子非常类似,因此我们不再进行一一赘述。具体的使用方式,可以进行查阅官方文档进行学习使用。


默认过滤器

上述例子是只针对某个具体的微服务生效的路由过滤器的配置方式,但是我们如果想要对所有的微服务添加同样的过滤器,那应该怎么办呢? 最容易想到的办法就是在每组微服务的路由下面加上相同的配置。这样做虽然可以实现需求,但是缺点也很明显,不但工作量大,而且容易出错,在后续的维护中也会增加更多的工作量。


Spring Cloud Gateway 的默认过滤器,正是为了解决这个问题而设计的。如果要对所有的路由都生效,则可以将过滤器工厂写到 default 下。格式如下:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
      default-filters: # 默认过滤项
        - AddRequestHeader=Description,NIIT is freaking awesome!

注意:default-filters 的层级与 routes 层级相同,可以对所有的路由都生效。


全局过滤器

上一节学习的路由过滤器,Spring Cloud Gateway 官方文档目前一共提供了 33 种,但每一种过滤器的作用都是固定的。如果我们希望拦截请求,做自己的业务逻辑则没办法实现。那么如果我们想自定义过滤器的实现逻辑,有什么办法呢? 我们可以实现 GlobalFilter 接口,编写自己的业务逻辑。


全局过滤器作用

全局过滤器的作用与 GatewayFilter 的作用一样,也是处理一切进入网关的请求和微服务响应。区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的,而 GlobalFilter 的逻辑需要自己写代码实现。


public interface GlobalFilter {
    /**
     *  处理当前请求,有必要的话通过 GatewayFilterChain将请求交给下一个过滤器处理
     *
     * @param exchange 请求上下文,里面可以获取Request、Response等信息
     * @param chain 用来把请求委托给下一个过滤器
     * @return  Mono<Void> 返回标示当前过滤器业务结束
     */
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

我们可以实现 GlobalFilter 接口,在 filter 函数中编写自定义逻辑,实现下列功能:

  • 登录状态判断
  • 权限校验
  • 请求限流等

自定义全局过滤器

我们可以使用全局过滤器实现自定义业务逻辑,现在就让我们完成一个小案例感受一下吧。首先,明确一下我们的需求:

需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
    1.参数中是否有auth
    2.auth参数值是否为niit
必须同时满足才放行,否则拦截

接下来,我们就动手实践一下吧。

创建一个类,实现 GlobalFilter 接口:


@Order(-1)
@Component
public class MyAuthFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //获取请求参数
        MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
        //获取auth参数
        String auth = queryParams.getFirst("auth");
        //判断是否符合要求
        if (auth != null) {
            if ("niit".equals(auth)) {
                //放行,交给过滤器链上的其他过滤器执行
                return chain.filter(exchange);
            }
        }
        //设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        //拦截请求
        return exchange.getResponse().setComplete();
    }
}

编写完成之后,我们重启网关,重新访问:http://localhost:10010/order/101,结果如图所示:

image-20220730100835237


由于请求中没有 auth=niit 的这样一个参数,无法通过自定义过滤器的逻辑校验。我们给请求中添加参数,访问:http://localhost:10010/order/101?auth=niit ,结果如图所示,可以再次正常访问:

image-20220730101200580


通过这个简单的案例,我们学会了如何自定义实现过滤器逻辑。当然,我们这里的案例是最简单的一种使用,在实际生产中肯定会有更为复杂的需求,但是基本原理我们掌握了,那么复杂的需求我们只需一步一步拆分实现即可。


过滤器链执行顺序

在上一节的自定义实现过滤器小案例中,细心的同学可能已经注意到我们在编写实现代码的时候,MyAuthFilter 类上除了@Component 这个注解外,还有一个注解@Order(-1)。 这个注解有什么用呢? order,顾名思义是顺序的意思,这个注解的作用正是为了给过滤器进行排序的。下面我们来探究一下过滤器的执行顺序。


我们现在一共接触了三大类过滤器: 当前路由的过滤器(三十多种)、DefaultFilter(默认过滤器)、GlobalFilter(全局过滤器)。网关接受请求并确定路由后,会将当前路由过滤器和 DefaultFilterGlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器,在默认情况下,三类过滤器的执行顺序如下:

h:10em


我们知道,每一类的过滤器,在整个的过滤器链上可能都不止一个,那么它们具体的排序规则又是什么呢? 我们先从最容易理解的全局过滤器开始。

我们可以在自定义的全局过滤器上添加@Order 注解进行排序,这个很好理解。


/**
@Order 定义注释组件的排序顺序。该值是可选的,表示 Ordered 接口中定义的顺序值。
较低的值具有较高的优先级。默认值为 Ordered.LOWEST_PRECEDENCE,表示最低优先级(输给任何其他指定的order值)。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
   /**
    * The order value.
    * <p>Default is {@link Ordered#LOWEST_PRECEDENCE}.
    * @see Ordered#getOrder()
    */
   int value() default Ordered.LOWEST_PRECEDENCE;
}

通过源码注释,我们得知如下结论:

  • @Order 注解的值越小,优先级越高,默认是最低优先级。

我们可以给自定义全局过滤器添加@Order 注解进行排序,那么默认过滤器和路由过滤器的排序顺序又是怎么样的呢?目前,我们只能对其进行配置,而无法手动添加 order 的值,这两类过滤器的顺序由 Spring 框架帮我们指定。每一类的过滤器的 order 值,默认是按照在配置文件中的声明顺序从 1 递增。


现在我们对 Gateway 服务的配置文件进行改动,增加一些过滤器,然后我们通过 IDEA 的断点调试功能来观察它们的执行顺序:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
          filters:
            - AddRequestHeader=Description,userservice AddRequestHeader filter has done!
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
            - After=2020-01-20T17:42:47.789-07:00[Asia/Shanghai]
          filters:
            - AddRequestHeader=Description,orderservice AddRequestHeader filter has done!
      default-filters:
        - AddRequestHeader=Description,NIIT is freaking awesome!
        - AddRequestParameter=Hello, World!
        - AddRequestHeader=Description, NIIT is amazing good!

配置文件做如上改动之后,我们在 AddRequestParameterGatewayFilterFactory 类的 filter 函数处打上断点,以 Debug 模式启动 Gateway 并重新访问:http://localhost:10010/order/101?auth=niit


image-20220730120155887


仔细观察之后,最终可以得出如下结论:

  • 路由过滤器和 defaultFilterorder 值由 Spring 指定,默认是按照声明顺序从 1 递增。
  • 当这两类过滤器的 order 值一样时,会按照 defaultFilter -> 路由过滤器的顺序执行。
  • 自定义全局过滤器总是在这两类过滤器执行之后再执行,多个自定义全局过滤器的执行顺序由其@Order 注解的值进行排序,@Order 注解的值越小,越先执行。

跨域配置

在实际生产中,网关不可避免的会接收到跨域 AJAX 请求,如果不做处理,浏览器会默认将跨域 AJAX 请求进行拦截。我们之前在学习 Servlet 的时候已经知道可以使用 CORS 的解决方案进行解决,而 Gateway 是基于 WebFlux 构建的网关服务,此前针对与 Servlet 体系所学习的方法不一定适用。但是我们即使没有学过 WebFlux 也不用担心,Gateway 底层已经将跨域问题的处理逻辑实现好了,我们只用进行简单配置即可。


跨域问题

跨域: 协议://域名:端口任何一个出现不一致都会导致跨域,主要包括:

  • 协议不同, 例如 http://xxxhttps://xxx
  • 域名不同: 例如 www.baidu.comwww.taobao.org
  • 域名相同,端口不同:localhost:8080localhost:8081

跨域问题:浏览器禁止请求的发起者与服务端发生跨域 ajax 请求,当出现跨域请求时浏览器会拦截该请求的问题。

因为跨域导致的报错示例: Access to XMLHttpRequest at 'http://localhost:10010/order/101?auth=niit' from origin 'http://localhost:8090' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.


解决方案

修改 Gateway 服务的 application.yml 文件:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        ...
      default-filters:
        ...
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
        corsConfigurations:
          '[/**]': #匹配哪些请求
            allowedOrigins: # 允许哪些网站的跨域请求
              - "http://localhost:8090"
              - "http://www.niit.com"
            allowedMethods: # 允许的跨域ajax的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowCredentials: true # 是否允许携带cookie
            maxAge: 360000 # 这次跨域检测的有效期 (秒)

Gateway 实现灰度发布

灰度发布是指在黑与白之间的过渡阶段,我们可以将灰度发布理解为一种渐进式发布,即将新版本的服务逐步引入到生产环境中,而不是一次性将新版本的服务全部引入到生产环境中。灰度发布的好处在于,当新版本的服务出现问题时,我们可以快速的回滚到旧版本的服务,而不会影响到整个系统的稳定性。


灰度发布的实现

灰度发布的实现方式有很多种,例如: 通过 Nginx 的反向代理实现灰度发布、通过 DNS 的负载均衡实现灰度发布、通过服务网关实现灰度发布等。而我们在这里主要介绍通过服务网关实现灰度发布的方式。


灰度发布的实现原理

灰度发布的实现原理是:通过服务网关对请求进行拦截,然后根据请求的特征信息(例如:请求的 IP 地址、请求的参数、请求的 Header 等)将请求转发到不同的服务实例中。例如: 发布新版本时, 谨慎起见, 为了提高发布质量, 降低风险, 减少全量发布带来的服务中断影响, 在老版本代码持续提供的基础上, 只切换一小部分流量到部署新版本代码的机器上测试, 来实现灰度发布.


灰度发布的实现步骤

分别在 Nacos 上运行老代码账户微服务(使用端口 3333), 新代码账户微服务,(使用端口 5555), 网关项目(使用端口 8080)


网关项目的配置如下:

server:
  port: 8080
spring:
  cloud:
    gateway:
      routes:
        - id: oldVersion_Route
          uri: http://localhost:3333/getAccount/{id}
          predicates:
            - Path=/getAccount/{id}
            - Weight=accountGroup, 9
        - id: newVersion_Route
          uri: http://localhost:5555/getAccount/{id}
          predicates:
            - Path=/getAccount/{id}
            - Weight=accountGroup, 1

这里是通过分组权重的设置实现流量控制的, 其中组 accountGroup 总权重为 10, 其中 oldVersion_Route 占 9, newVersion_Route 占 1.

测试:

http://localhost:8080/getAccount/123
->In Old Version, account Info, id is:123 (90%几率)
->In New Version, account Info, id is:123 (10%几率)

因此平均每 10 次请求 getAccount, 会有一次使用新 Api.


Gateway 添加 Hystrix 支持

网关项目添加 hystrix 依赖 spring-cloud-starter-netflix-hystrix 依赖,开启 hystrix 支持:

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>

配置实例

hystrix.command.fallbackcmd.execution.isolation.thread.timeoutInMilliseconds: 5000
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
      - id: employee-service
        uri: lb://employee-service
        predicates:
        - Path=/employee/**
        filters:
        - RewritePath=/employee/(?<path>.*), /$\{path}
        - name: Hystrix
          args:
            name: fallbackcmd
            fallbackUri: forward:/defaultfallback

  • 首先 filter 里头配置了 name 为Hystrix的 filter,实际是对应HystrixGatewayFilterFactory
  • 然后指定了 hystrix filter 的名称name,及fallbackUri,注意 fallbackUri 要以forward开头, 表示如果发生降级则转发到指定的 uri 上
  • 最后通过hystrix.command.fallbackcmd.execution.isolation.thread.timeoutInMilliseconds指定该 command 的超时时间

RewritePath filter 的作用是将请求的路径重写,这里是将/employee/xxx重写为/xxx,这样就可以将请求转发到 employee-service 服务的/xxx路径上.


fallback 示例:

@RestController
@RequestMapping("/defaultfallback")
public class FallbackController {

    @RequestMapping("")
    public String fallback(){
        return "服务器繁忙,请稍后再试!";
    }
}

总结:

spring cloud gateway 集成 hystrix,分为如下几步:

  1. 添加 spring-cloud-starter-netflix-hystrix 依赖
  2. 在对应 route 的 filter 添加 name 为 Hystrix 的 filter,同时指定 hystrix command 的名称,及其 fallbackUri(可选)
  3. 指定该 hystrix command 的超时时间等。

Hystrix GatewayFilter Factory - HystrixGatewayFilterFactory


活动 7.1

使用 Gateway 网关+Nacos 实现微服务统一调用


练习问题


  1. Spring Cloud Gateway 不支持异步。
    a. 错误
    b. 正确

    查看答案

    a


  1. Spring Cloud Gateway 内置了许多 Route Predicate 工厂。这些 PredicateFactory 都与 HTTP 请求的不同属性匹配。多个 RoutePredicateFactory 可以进行组合。
    a. 错误
    b. 正确

    查看答案

    b


  1. Spring Cloud Gateway 中不包含以下哪类过滤器?

    a.路由过滤器
    b.服务过滤器
    c.默认过滤器
    d.全局过滤器

    查看答案

    b


  1. 浏览器禁止请求的发起者与服务端发生跨域 ajax 请求,当出现跨域请求时浏览器不会拦截该请求。

    a. 错误
    b. 正确

    查看答案

    a


小结

在本章中,您学习了:

  • 使用 Spring Cloud Gateway 构建起最基本的 API 网关服务。
  • 为网关服务添加请求路由的功能。
  • 添加相应的请求过滤。
  • 断言工厂
  • 过滤器
  • 路由过滤器
  • 默认过滤器
  • 全局过滤器
  • 跨域问题处理

Views: 54

红帽认证考试流程指导-2024年4月版本

建议在PC端查看:

根据你提供的两份红帽认证考试流程文档,我为你整理出一份清晰的考前、考中、考后指导,方便你快速查阅和准备:


✅ 考前指导(账号 + 预约)

1. 注册红帽账号(RHN)

  • 注册地址:https://sso.redhat.com/…(或访问红帽官网点击注册)
  • 注意事项:
  • 国家/地区选 China
  • 用户名(RHN ID)至少5位字符,创建后不可修改
  • 使用常用个人邮箱(建议QQ邮箱,避免公司邮箱)
  • 公司职位等信息大概填下,不需要准确
  • 注册后查收确认邮件,务必点击确认条款

2. 报名预约考试

  • 模拟考试通过后,向负责老师预约
  • 一次考试需15–40人,至少提前10天预约
  • 考官会提前一天或半天搭建考场环境


✅ 考中指导(考试当天流程)

1. 考前准备

  • 必带证件(任一):身份证(或临时身份证)/ 驾驶证/ 社保卡
  • 在校生参加EX200考试:额外带学生证
  • RHN账号(红帽官网的登录账号名)
  • 证书号(如以前考过,格式:###-###-###, 第一次考试则不用)
  • 可自带键盘鼠标(禁止青轴等噪音大的机械键盘
  • 提前到场,早到可早开始

2. 考试流程

  • 听从考官安排,坐到指定机位
  • 填写考试信息(记不住的项目建议提前写在便签上贴证件上):
    • Name:汉语拼音,如 Xiaohu Wang(建议名在前,姓在后,中间空格分隔, 首字母大写)
      • Email:与RHN注册邮箱一致
        • Retype Email:手动输入,不要复制粘贴
          • redhat.com username:RHN ID
            • Country of Residence:China
              • Certification number:首次考试留空,非首次必须填写正确
  • 考试开始前:
  • 电子设备、纸质资料放指定区域
  • 考官讲解考试环境操作(如虚拟机使用)
  • 考试中:
  • 严禁作弊,发现即0分
  • 完成所有题目后:
    • 重启 node1 和 node2 虚拟机
    • 检查容器、挂载项是否正常启动
    • 若 node2 无法启动,可进救援模式修复后重启
  • 考完后:
  • 无需提交,报告考官后即可离场


✅ 考后指导(证书关联 + 下载)

1. 成绩与证书ID

  • 考试后3个工作日内收到成绩邮件,内含证书ID
  • 收到后第一时间将证书ID与RHN账号关联:
  • 登录红帽官网 → Certifications → Your Certifications → Add Certifications
  • 输入证书ID提交
  • 后续考试必须填写此ID,避免多个证书ID冲突

2. 下载电子证书(通过Credly)

  • 通过认证后7天内,会收到来自 Credly 的邮件(标题含 “Red Hat issued you a new badge”)
  • 点击邮件中的 Accept your badge 链接:
  • 注册或登录 Credly 账号(邮箱必须与RHN一致
  • 姓名填汉语拼音或英文名不要填中文(否则证书乱码)
  • 下载证书:
  • 登录 https://www.credly.com/earner/earned
  • 找到对应Badge → 点击 Share → Download Certificate
  • 证书过期后无法下载,请及时保存

⚠️ 常见问题提醒

  • 未收到Credly邮件:先查垃圾箱,仍无则通过红帽官网提交comment请求补发
  • 证书名字乱码:因考试填写中文,考试时必须用拼音
  • 多个证书ID:通常因未填写旧证书ID或注册多个RHN账号,确保只用一个账号

详细流程和注意事项见以下PDF, 需在PC端查看或下载后查看:

官方的考试流程指导文件: (需要在PC端查看或下载后查看)

Views: 997

SpringCloud与微服务-第6章-声明式服务调用(OpenFeign)

Spring Cloud OpenFeign 基于 Netflix Feign 实现,整合了 Spring Cloud Ribbon 与 Spring Cloud Hystrix ,除了提供这两者的强大功能之外,它还提供了一种声明式的 Web 服务客户端定义方式,让我们可以像使用本地方法一样来进行远程服务调用。

快速入门

通过一个简单的示例来展现 Spring Cloud Feign 在服务客户端定义上所带来的便利。

下面的示例将继续使用之前我们实现的 order-service 服务,这里我们会通过 Spring Cloud Feign 提供的声明式服务绑定功能来实现对该服务接口的调用。

配置代码

1.引入依赖

orderservice 服务的 pom.xml 文件中,引入如下依赖:

<!--spring cloud openFeign-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

openfeig 依赖中已然包含了 ribbon 的依赖


2.启用 Feign

在 orderservice 的启动类上,添加注解@EnableFeignClients

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableFeignClients
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

3.定义服务接口

定义一个接口,通过@FeignClient 注解指定服务名来绑定服务,然后再使用 Spring MVC 的注解来绑定具体该服务提供的 REST 接口。

@FeignClient("userservice")
public interface UserClient {
    /**
     * 根据ID获取用户信息
     */
    @GetMapping("/user/{id}")
    User findUserById(@PathVariable("id") String id);
}

注意:
注解中的服务名不区分大小写,所以使用 USERSERVICEuserservice 都 是可以的。

注意接口的访问路径要完整, 如果匹配的路径不对, 会报 404 错误.

4.创建 Controller

创建一个 Controller 来实现对 Feign 客户端的调用。使用 @Autowired 直接注入上面定义的 UserClient 实例,并在 helloConsumer 函数中调用这个绑定了 userservice 服务接口的客户端来向该服务发起接口的调用。


@RestController
@RequestMapping("/feign")
public class FeignDemoController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private UserClient userClient;

    @GetMapping("/order/{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        Order order = orderService.queryOrderById(orderId);
        // 使用 Feign 封装的客户端
        User forObject = userClient.findUserById(order.getUserId()+"");
        //封装返回的数据
        order.setUser(forObject);
        return order;
    }
}

测试验证

如之前验证 Ribbon 客户端负载均衡一样,我们先启动服务注册中心 Nacos 以及 orderservice 服务和多个 userservice 服务。此时我们在 Nacos 信息面板中可看到 如下内容:


image-20220725233238207


发送几次 GET 请求到 http://localhost:8080/feign/order/{orderId}, 可以得到如 之前 Ribbon 实现时一样的效果,正确返回了 userservice 的数据。

image-20220725233533961


并且根据控制台的输出,我们可以看到 Feign 实现的消费者,依然是利用 Ribbon 维护了针对 userservice 的服务列表信息,并且通过轮询实现了客户端负载均衡。而与 Ribbon 不同的是,通过 Feign 我们只需定义服务绑定接口,以声明式的方法,优雅而简单地实现了服务调用。

Feign 使用起来看起来更像是一个本地接口的实现,而不是一个远程服务的调用。这样的设计使得我们在使用 Feign 的时候,可以像调用本地方法一样调用远程服务。

小问题 :
RestTemplate 己经实现了对 __ 请求的封装处理,形成了一套模板化的调用方法?

  1. FTP
  2. HTTP
  3. TCP
  4. IP
答案
2. HTTP

参数绑定

在本节中,我们将详细介绍 Feign 中对几种不同形式参数的绑定方法。


扩展一下服务提供方 orderservice 。增加下面这些接口定义,其中包含:

  1. 带有 Request 参数的请求
  2. 带有 Header 信息的请求
  3. 带有 RequestBody 的请求并且请求响应体中是一个对象的请求。

@RestController
@RequestMapping("/provider/feign")
public class FeignDemoController {
    /* request参数的请求 */
    @GetMapping(value = "/request")
    public String hello (@RequestParam("name") String name) {
        return "Hello" + name;
    }
    /* header信息的请求 */
    @GetMapping(value = "/header" )
    public User hello(@RequestHeader String name, @RequestHeader String address) {
        User user = new User();
        user.setId(IdUtil.simpleUUID());
        user.setUsername(name);
        user.setAddress(address);
        return user;
    }
    /* requestbody的请求 */
    @PostMapping(value = "/body")
    public String hello(@RequestBody User user) {
        return "Hello, " + user .getUsername() + ", " + user .getAddress();
    }
}

实体类 User 的定义如下:

@Data
public class User {
    private String id;
    private String username;
    private String address;
}

需要注意的是,这里必须要有 User 的默认构造函数。不然, Spring Cloud Feign 根据 JSON 字符串转换 User 对象时会抛出异常。我们使用 Lombok 插件的 @Data 注解, 可以避免编写标准 java bean 的繁琐步骤,同时提供相应的默认构造。


在完成了对 orderservice 的改造之后,下面我们开始在快速入门示例的 UserClient 接口中实现这些新增的请求的绑定。


@FeignClient("userservice")
public interface UserClient {
    /**
     * 根据ID获取用户信息
     * @param id
     * @return
     */
    @GetMapping("/user/{id}")
    User findUserById(@PathVariable("id") String id);

    /**
     * request参数的请求
     * @param name
     * @return
     */
    @GetMapping(value = "/provider/feign/request")
    public String hello (@RequestParam("name") String name);

    /**
     * header信息的请求
     * @param name
     * @param address
     * @return
     */
    @GetMapping(value = "/provider/feign/header" )
    public User hello(@RequestHeader String name, @RequestHeader String address);

    /**
     * requestbody的请求
     * @param user
     * @return
     */
    @PostMapping(value = "/provider/feign/body")
    public String hello(@RequestBody User user) ;
}

最后,在 Controller 中新增方法,来对本节新增的声明接口进行调用,修改后的完整代码如下所示:


@RestController
@RequestMapping("/feign")
public class FeignConsumerController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private UserClient userClient;

    @GetMapping("/order/{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        Order order = orderService.queryOrderById(orderId);
       // 使用Feign 封装的客户端
        User forObject = userClient.findUserById(order.getUserId()+"");
        //封装返回的数据
        order.setUser(forObject);
        return order;
    }

    @GetMapping("/order/request")
    public String requestParamDemo(@RequestParam String param){
        return userClient.requestParam(param);
    }

    @GetMapping("/order/header")
    public User headerInfo(@RequestHeader String name,@RequestHeader String address){
        return userClient.headerInfo(name,address);
    }

    @GetMapping("/order/body")
    public String bodyInfo(@RequestBody User user){
        return userClient.bodyInfo(user);
    }

}

测试验证

在完成上述改造之后,启动服务注册中心 Nacos、order-service 服务以及 user-service 服务 。通过浏览器访问 order-service 的 FeignConsumerController 的 requestParamDemo 方法, 触发新增接口的调用。最终,我们会获得如下输出,代表接口绑定和调用成功:

image-20220726111444264


其他两种参数绑定的调用,我们可以使用 IDEA 的一款小插件FastRequest进行测试:(FastRequest 是收费插件, 可以用免费插件 RestfulTool 代替)

添加请求头:

image-20220726112008604


点击发送并查看响应结果:

image-20220726112113576


同样的, RequestBody 的参数也可以添加请求体,点击发送按钮进行测试,结果如下:

image-20220726111906304

实践方案

我们几乎完全可以从服务提供方的 Controller 中依靠复制操作,构建出相应的服务客户端绑定接口, 然后添加@FeignClient 注解即可。使用的时候,直接注入到 Controller 中即可。

这部分内容是否可以得到进一步的抽象呢?在 Spring Cloud Feign 中,针对该问题我们通常有两种解决方案,以进一步减少编码量。

  1. 通过继承的方式实现接口定义的共享
  2. 创建独立的 Api 模块, 通过依赖的方式实现接口定义的共享

继承方案

一样的代码可以通过继承来共享:

  1. 定义业务相关接口,利用接口定义,并基于 SpringMVC 注解做声明。

    • 注意定义 MappingUrl 时,需要使用绝对路径,否则会出现 404 错误。
  2. Feign 客户端和 Controller 都继承该接口。

    • 这样接口实际只需要定义一次,就可以在 Feign 客户端和 Controller 中使用。免去了复制操作。

image-20220726151709040

图示中, UserClient 继承了 UserApi 接口。这样,我们就可以在 UserClient 中直接使用 UserApi 中定义的接口,而不需要再次定义。在 Controller 中, 也可以直接注入 UserClient,并调用其中的方法。


使用 Spring Cloud Feign 继承特性的优点很明显,可以将接口的定义从 Controller 中剥离,实现在构建期的接口绑定,从而有效减少服务客户端的绑定配置。这么做虽然可以很方便地实现接口定义和依赖 的共享,不用再复制粘贴接口进行绑定.

但是这样通过共享接口的做法使用不当的话会带来副作用。


由于接口在构建期间就建立起了依赖,所以微服务之间的耦合度会变得很高。因此接口变动就会对项目构建造成影响。

可能服务提供方修改了一个接口定义,那么会直接导致客户端工程的构建失败。所以,如果开发团队通过此方法来实现接口共享的话,建议在开发评审期间严格遵守面向对象的开闭原则,尽可能地做好前后版本的兼容,防止牵一发而动全身的后果,增加团队不必要的维护工作量。


基于以上原因,官方并不推荐在项目中使用继承方案,而是更推荐我们由服务提供方新建立一个 API 服务,提供给所有的消费者使用。接下来,我们将会详细讲解这种实践方案。


独立模块

将 Feign 的 Client 抽取为独立的微服务模块,并且把接口有关的 POJO、默认的 Feign 配置都放到这个微服务模块中,提供给所有消费者微服务项目使用。例如,将 UserClient、User、Feign 的默认配置都抽取到一个 feign-api 包中,所有微服务引用该依赖包,即可直接使用。


下面是构建 feign-api 的具体步骤:

w:35em


1.创建 module

首先创建一个 module,命名为 feign-api:

image-20220726175321856


2.引入依赖

在 feign-api 中然后引入 feign 的 starter 依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

然后,order-service 中编写的 UserClient、User 都复制到 feign-api 项目中。

image-20220726175513116


通过 maven 的 install 对项目进行构建和安装,将 feign-api 安装到本地仓库中。


3.使用 feign-api

在 order-service 的 pom 文件中从本地 maven 仓库中引入 feign-api 的依赖:

<dependency>
    <groupId>com.niit.demo</groupId>
    <artifactId>feign-api</artifactId>
    <version>1.0</version>
</dependency>

修改 order-service 中的所有与上述三个组件有关的导包部分,改成导入 feign-api 中的包


4.修改启动类

由于 feign-api 与 order-service 项目的包路径并不完全一致,为了解决 spring boot 工程默认包扫描策略导致无法注入问题,需要在启动类上声明所用的 feign 客户端的列表。 修改 order-service 的启动类,补充@EnabledFeignClients 的属性内容。

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
@EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

5.启动测试

现在,像上一小节参数绑定中的案例一样,依次启动服务注册中心 Nacos,order-service 与 user-service 服务进行测试。测试结果如下:

image-20220726180548601


添加 Ribbon 配置

由于 Spring Cloud Feign 的客户端负载均衡是通过 Spring Cloud Ribbon 实现的,所以我们可以直接通过配置 Ribbon 客户端的方式来自定义各个服务客户端调用的参数。那么我们 如何在使用 Spring Cloud Feign 的工程中使用 Ribbon 的配置呢?


全局 Ribbon 配置

全局配置的方法非常简单,我们可以直接在配置文件中设置:

ribbon:
  NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule #负载均衡规则的全类名
  ConnectTimeout: 250 #请求连接的超时时间
  ReadTimeout: 1000 #请求处理的超时时间
  OkToRetryOnAllOperations: true #对所有操作请求都进行重试
  MaxAutoRetriesNextServer: 2 #切换实例的重试次数
  MaxAutoRetries: 1 #对当前实例的重试次数。

对具体服务进行 Ribbon 配置

在配置文件中, 除了针对所有服务进行全局配置, 还可以针对特定的服务进行个性化配置.


在定义 Feign 客户端的时候,我们使用了 @FeignClient 注解。在初始化 过程中,Spring Cloud Feign 会根据该注解的 name 属性或 value 属性指定的服务名,自动创建一个同名的 Ribbon 客户端。

例如使用 @FeignClient(value ="userservice")来创建 Feign 客户端的时候,同时也创建了一个名为 userservice 的 Ribbon 客户端。


既然如此,我们就可以使用 @FeignClient 注解中指定的服务提供者名称来设置对应的 Ribbon 参数,比如:

userservice: # 需要远程访问的服务名
  ribbon:
    NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule #负载均衡规则的全类名
    ConnectTimeout: 250 #请求连接的超时时间
    ReadTimeout: 1000 #请求处理的超时时间
    OkToRetryOnAllOperations: true #对所有操作请求都进行重试
    MaxAutoRetriesNextServer: 2 #切换实例的重试次数
    MaxAutoRetries: 1 #对当前实例的重试次数。

添加 Ribbon 重试机制

在 Spring Cloud Feign 中默认实现了请求的重试机制,而上面我们对于 orderservice 客户端的配置内容就是对于请求超时以及重试机制配置的详情,具体内 容可参考第 4 章最后一节关于 Spring Cloud Ribbon 重试机制的介绍。

这里需要注意一点,Ribbon 的超时与 Hystrix 的超时是两个概念。为了让上述实现有效,我们需要让 Hystrix 的超时时间大于 Ribbon 的超时时间,否则 Hystrix 命令超时后,该命令直接熔断,重试机制就没有任何意义了。


hystrix 的超时时间配置如下:

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 3000 #Hystrix 超时时间

Ribbon 的超时时间配置如下:

ribbon:
  ConnectTimeout: 250 #请求连接的超时时间
  ReadTimeout: 1000 #请求处理的超时时间

添加 Hystrix 配置

在 Spring Cloud Feign 中,除了引入了用于客户端负载均衡的 Spring Cloud Ribbon 之外,还引入了服务保护与容错的工具 Hystrix 。

默认情况Spring Cloud Feign 是关闭Hystrix支持的, 因此需要手动启用Hystrix。开启Hystrix后所有 Feign 客户端的方法都会封装成为 Hystrix 命令中进行服务保护。


在对 Hystrix 进行配置之前,我们需要确认 feign.hystrix.enabled 参数需要先设置为 true, 否则默认会关闭 Feign 客户端的 Hystrix 支持。

feign:
  hystrix:
    enabled: true # 启用ystrix功能

默认 feign.hystrix.enabled=false, Hystrix 支持是关闭的

使用 hystrix.command.default.execution.timeout.enabled=false 关闭超时熔断


添加 Hystrix 全局配置

对于 Hystrix 的全局配置同 Spring Cloud Ribbon 的全局配置一样,直接使用它的默认配置前缀 hystrix.command.default 就可以进行设置,比如设置全局的超时时间:

# 断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试。
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

局部禁用 Hystrix

如果全局启用 Hystrix 支持,但是想要针对某个服务客户端关闭 Hystrix 支持时,可以通过使用 @Scope("prototype") 注解为指定的客户端配置 Feign.Builder 实例.

详细实现步骤如下所示。


1.构建一个配置类
/**
 * 针对指定的Feign客户端禁用Hystrix的配置
 */
@Configuration
public class UserClientConfig {

    @Bean
    @Scope("prototype")
    public Feign.Builder feignBuilder() {
        return Feign.builder();
    }
}

2.引入配置到 Feign 客户端

在 UserClient 的@FeignClient 注解中,通过 configuration 参数引入上面实现的配置。

@FeignClient(value = "userservice",configuration = UserClientConfig.class)
public interface UserClient {
  ......
}

这样,就可以针对指定的 Feign 客户端来关闭 Hystrix 支持了。

原理是通过 @Scope("prototype") 注解为每个客户端创建一个新的实例,而此实例不受 Hystrix 配置的影响。


通过 Hystrix 命令的键值进行配置

对于 Hystrix 命令的配置,在实际应用时往往也会根据实际业务情况制定出不同的配置方案。配置方法也跟传统的 Hystrix 命令的参数配置相似,采用 hystrix.command.<commandKey> 作为前缀。而 <commandKey> 默认情况下会采用 Feign 客户端中的方法名作为标识,所以,针对上一节介绍的重试机制中对 /showAppInfo 接口的熔断超时时间的配置可以通过其方法名作为 <commandKey> 来进行配置.


具体如下:

# 断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试。
hystrix:
  showAppInfo: # @FeignClient中的方法名(Hystrix命令的键值)
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

在使用指定命令配置的时候,需要注意,由于方法名很有可能重复,这个时候相同方法名的 Hystrix 配置会共用,所以在进行方法定义与配置的时候需要做好一定的规划。


服务降级配置

Hystrix 提供的服务降级是服务容错的重要功能, Spring Cloud Feign 提供了一种简单的定义方式,可以优雅的完成服务降级的配置。下面我们在之前创建的 feign-api 工程中进行改造。

  • 服务降级逻辑的实现只需要为 Feign 客户端的定义接口编写一个具体的接口实现类。 比如为 UserClient 接口实现一个服务降级类 UserClientFallback ,其中每个重写方法的实现逻辑都可以用来定义相应的服务降级逻辑,具体如下:

/**
 * 服务降级实现类, 用于定义服务降级逻辑
 * 该类需要实现Feign客户端接口拿到所有需要配置降级的方法
 */
public class UserClientFallback implements UserClient {
    @Override
    public User findUserById(String id) {
        User user = new User();
        user.setUsername("findUserById 调用失败");
        user.setId(UUID.randomUUID().toString());
        return user;
    }

    @Override
    public String requestParam(String name) {
        return "requestParam 调用失败";
    }

    @Override
    public User headerInfo(String name, String address) {
        User user = new User();
        user.setUsername("headerInfo 调用失败");
        user.setId(UUID.randomUUID().toString());
        return user;
    }

    @Override
    public String bodyInfo(User user) {
        return "bodyInfo 调用失败";
    }
}

  • 在服务绑定接口 UserClient 中,通过 @FeignClient 注解的 fallback 属性来指定对应的服务降级实现类。
@FeignClient(value = "userservice",fallback = UserClientFallback.class)
public interface UserClient {
   ...
}

  • 在 order-service 服务的启动类上使用@Bean 注解声明服务降级类,解决 UserClientFallback 实例无法找到异常

    @MapperScan("com.niit.order.mapper")
    @SpringBootApplication
    @EnableCircuitBreaker //启用断路器功能
    @ServletComponentScan(basePackages = {"com.niit.order.filter"})
    @EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
    public class OrderApplication {
    
      public static void main(String[] args) {
          SpringApplication.run(OrderApplication.class, args);
      }
    
      @Bean
      public UserClientFallback userClientFallback(){
          return new UserClientFallback();
      }
    
    }

上面的做法,是使用了 feign-api 提供的默认的统一服务降级配置类, 对每个接口都配置了对应的服务降级逻辑.

如果,order-service 想自定义某个服务降级逻辑, 只需在 order-service 中创建一个新的类,继承 UserClientFallback,重写其方法, 注入到 spring 容器里即可:


创建新的自定义服务降级类:

public class MyUserClientFallback extends UserClientFallback {

    @Override
    public String requestParam(String name) {
        return "orderservice服务的自定义服务降级方法被触发了...";
    }
}

重新注入到 Spring 容器:

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
@EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }

    //注入order-service自定义的 MyUserClientFallback服务降级类
    @Bean
    public UserClientFallback userClientFallback(){
        return new MyUserClientFallback();
    }
}

测试验证

下面我们来验证一下服务降级逻辑的实现。启动服务注册中心 Nacos 和 order-service, 但是不启动 user-service 服务。发送 GET 请求到 http://localhost:8080/feign/order/request?param=world, 因为 user-service 服务没有启动,会直接触发服务降级.

如果没有降级发生, 检查order-service是否在springboot应用配置文件中启用hystrix:

feign:
  hystrix:
    enabled: true

注意 springcloud2020.0.1之后的版本改为 feign.circuitbreaker.enabled=true


正如我们在 MyUserClientFallback 类中实现的内容,每一个服务接口的断路器实际就是实现类中的重写函数的实现。


小问题:

在 Spring Cloud Feign 中,可以通过 _____ 来关闭 Hystrix 功能?

  1. feign.hystrix.enabled=false
  2. feign.hystrix.hystrixCommand=false
  3. feign.hystrix.disabled=false
  4. feign.hystrix.hystrix=false
答案
1. feign.hystrix.enabled=false

其他配置


请求压缩

Feign 是通过 http 调用的,那么就牵扯到一个数据大小的问题。如果不经过压缩就发送请求、获取响应,那么会因为流量过大导致浪费流量,这时就需要使用数据压缩,将大流量压缩成小流量。

Spring Cloud Feign 支持对请求与响应进行 GZIP 压缩,以减少通信过程中的性能损耗。


配置请求压缩支持, 找到需要调用 feign 的服务消费者 order-service 模块配置文件中添加如下配置,即可开启:

feign:
  compression:
    request:
      enabled: true # 开启请求压缩
    response:
      enabled: true # 开启响应压缩

同时,我们也可以对请求的数据类型,以及触发压缩的大小下限进行设置:

feign:
  compression:
    request:
      enabled: true # 开启请求压缩
      mime-types: text/html,application/xml,application/json # 设置压缩的数据类型
      min-request-size: 2048 # 设置触发压缩的大小下限

值得注意的是,上面的数据类型、压缩大小下限均为默认值。


日志配置

Spring Cloud Feign 在构建服务客户端时,会为每一个客户端都创建一个 feign. Logger 实例,我们可以利用该日志对象的 DEBUG 模式来帮助分析 Feign 的请求细节。

对于 Feign 的 Logger 级别主要有下面 4 类,可根据实际需要进行调整使用。

  • NONE :不记录任何信息。
  • BASIC :仅记录请求方法、 URL 以及响应状态码和执行时间。
  • HEADERS :除了记录 BASIC 级别的信息之外,还会记录请求和响应的头信息。
  • FULL :记录所有请求与响应的明细,包括头信息、请求体、元数据等

配置 Feign 日志有两种方式:

  • 配置文件方式
  • Java 配置类方式

日志配置可以配置全局生效,也可以配置针对某个微服务生效,下面我们详细介绍两种方式是如何开启日志配置。


配置文件方式

通过修改服务消费方的 application.yml 文件,配置 Spring Cloud Feign 的日志级别。


全局 FeignClient 配置:

feign:
  client:
    config:
      metaDataClient:
        connect-timeout: 3000
        read-timeout: 3000
      default: #default 代表全局配置
        loggerLevel: FULL

针对实例的 FeignClient 配置:

feign:
  client:
    config:
      metaDataClient:
        connect-timeout: 3000
        read-timeout: 3000
      userservice: #服务名称 针对某个微服务的配置
        loggerLevel: BASIC

我们以 FULL 级别的日志配置为例,发送一次请求测试,查看控制台日志输出,可以看到日志信息非常完整:

image-20220727163823119


通过 Java 配置类方式配置 FeignClient

需要先声明一个 Bean,用来定义日志的级别。

public class UserClientConfig {
    @Bean
    public Logger.Level feignLoggerLevel(){
        return Logger.Level.FULL;
    }
}

使用 Java 配置类进行全局 FeignClient 配置

全局配置,则需要在服务消费方的主启动类上进行配置指定:

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
//解决包扫描无法注入, 指定feign的全局配置属性
@EnableFeignClients(clients = {UserClient.class},defaultConfiguration = UserClientConfig.class)
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

使用 Java 配置类进行局部 FeignClient 配置

局部配置,则放在相应的 feign 客户端的 @FeignClient 这个注解中:

@FeignClient(value = "userservice",configuration = UserClientConfig.class)
public interface UserClient {
    ...
}

值得注意的是,当使用配置文件和使用 Java 代码两种方式同时存在的时候,配置文件的优先级更高。当然,我们不推荐同时配置两种方式,只保留一种方式即可。


活动 6.1:

使用 Feign 实现服务远程调用


练习问题

  1. 在服务绑定接口 HelloService 中,通过 @FeignClient 注解的 _____ 属性来指定对应的服务降级实现类。
    a. fallback
    b. back
    c. fullback
    d. fall

    查看答案

    fallback


  1. 对于 Hystrix 的全局配置同 Spring Cloud Ribbon 的全局配置一样,直接使用它的默认配置前缀 _____ 就可以进行设置
    a. hystrix.command.index
    b. hystrix.command.default
    c. hystrix.default.command
    d. hystrix.index.command

    查看答案

    hystrix.command.default


  1. Ribbon 的超时与 Hystrix 的超时是两个概念。为了让上述实现有效,我们需要让 Hystrix 的超时时间 _____ Ribbon 的超时时间?
    a. 小于
    b. 等于
    c. 大于
    d. 不等于

    查看答案

    大于


  1. 我们在使用 Spring Cloud Ribbon 时,通常都会利用它对 _____ 的请求拦截来实现对依赖服务的接口调用。
    a. FeignTemplate
    b. SpringTemplate
    c. CloudTemplate
    d. RestTemplate

    查看答案

    RestTemplate


小结

在本章中,您学习了:

  • 我们在使用 Spring Cloud Ribbon 时,通常都会利用它对 RestTemplate 的请求拦截来实现对依赖服务的接口调用,而 RestTemplate 己经实现了对 HTTP 请求的封装处理,形成了一套模板化的调用方法。
  • 在 Spring Cloud Feign 的实现下,我们只需创建一个接口并用注解的方式来配置它,即可完成对服务提供方的接口绑定,简化了在使用 Spring Cloud Ribbon 时自行封装服务调用客户端的开发量。
  • Spring Cloud Feign 具备可插拔的注解支持, 包括 Feign 注解和 JAX-RS 注解。同时,为了适应 Spring 的广大用户,它在 NetflixFeign 的基础上扩展了对 Spring MVC 的注解支持。

  • 由于 Spring Cloud Feign 的客户端负载均衡是通过 Spring Cloud Ribbon 实现的,所以我们可以直接通过配置 Ribbon 客户端的方式来自定义各个服务客户端调用的参数。
  • 在 Spring Cloud Feign 中,除了引入了用于客户端负载均衡的 Spring Cloud Ribbon 之外,还引入了服务保护与容错的工具 Hystrix。
  • 在对 Hystrix 进行配置之前,我们需要确认 feign.hystrix.enabled 参数没有被设置为 false, 否则该参数设置会关闭 Feign 客户端的 Hystrix 支持。
  • Hystrix 提供的服务降级是服务容错的重要功能。

SpringCloud与微服务-第5章-服务容错(Hystrix)

Spring Cloud Hystrix断路器快速入门


在微服务架构中,我们将系统拆分成了很多服务单元,各单元的应用间通过服务注册与订阅的方式互相依赖。由于每个单元都在不同
的进程中运行,依赖通过远程调用的方式执行,这样就有可能因为网络原因或是依赖服务自身问题出现调用故障或延迟,而这些问题
会直接导致调用方的对外服务也出现延迟,若此时调用方的请求不断增加,最后就会因等待出现故障的依赖方响应形成任务积压,最
终导致自身服务的瘫痪。


目标:

在本章中,您将学习:

  • 如何创建请求命令
  • 如何进行异常处理
  • 如何实现 turbine 集群监控
  • 原理分析
  • 断路器原理
  • 依赖隔离
  • 使用详解
  • 属性详解
  • 监控面板

服务雪崩

微服务架构下,会存在服务之间相互依赖调用的情况,当某个服务不可用时,很容易因为服务之间的依赖关系使故障扩大,甚至造成整个系统不可用的情况,这种现象称为服务雪崩效应


w:36em


  • 如上图所示,为服务雪崩效应发生的过程,首先是服务正常状态,当客户端对服务 A 发起请求,服务 A 依赖了服务 B,服务 B 又依赖了服务 C,当所有服务都处于正常状态时,整个请求链路是通畅的,结果会很快返回给客户端。

  • 如果这时服务 C 发生故障或出现性能问题,就会出现延迟,刚开始时延迟较小,随着时间的推移,延迟会越来越大,服务 B 对服务 C 的调用就会堵塞,服务 C 此时已经疲惫不堪。

  • 由于请求都堵在服务 C 上,服务 B 作为调用方,却迟迟等不到服务 C 的结果,服务 A 对服务 B 的请求又源源不断的发送过来,最终导致服务 B 的资源耗尽,从正常状态变成不正常状态,再也无法及时响应服务 A 的请求结果。

  • 依此类推,最终服务 A 也会被拖垮,导致整个系统不可用,这个过程就是服务雪崩效应。如果能从最开始的小问题进行预防,就不会出现后面的级联效果,本章我们将讨论如何通过对服务进行容错降级来保证系统的可用性。


比如在一个电商网站中,我们可能会将系统拆分成用户、订单、库存、积分、 评论等一系列服务单元。用户创建一个订单的时候,客户端将调用订单服务的创建订单接口,此时创建订单接口又会向库存服务来请求出货(判断是否有足够库存来出货)。此时若库存服务因自身处理逻辑等原因造成响应缓慢,会直接导致创建订单服务的线程被挂起,以等待库存申请服务的响应,在漫长的等待之后用户会因为请求库存失败而得到创建订单失败的结果。如果在高并发情况之下,因这些挂起的线程在等待库存服务的响应而未能释放,使得后续到来的创建订单请求被阻塞,最终导致订单服务也不可用。


服务雪崩产生原因

服务雪崩产生的原因肯定是服务提供者出了问题才导致后面的雪崩问题,在实际应用中无法预料服务提供者可能会出现什么样的问题,我们只能分析一些比较常见的问题。

  • 服务提供者方面
    由于某些代码问题导致 CPU 飙升,将资源耗尽等,比如服务器出现问题,磁盘出问题,导致数据读写特别慢,还比如说出现慢 SQL, 亦或请求量太大了已经超出了系统本身的承受能力, 又或者出现死循环, 死锁, 线程池配置等问题导致服务响应时间过长或报错。

  • 服务消费者这方面
    比如同步调用等待结果导致资源耗尽,另外一些服务消费者可能同时也是服务提供者。


服务雪崩的解决方案

既然分析了一些比较常见的会导致服务雪崩的问题,那么就需要出对应的策略来解决这些问题。当然对于代码的 Bug 问题,我们可以通过测试、Code Review 等方式来避免,对于慢 SQL 这种问题,我们需要去做数据库性能优化。对于服务器硬件故障问题,我们可以加大运维粒度,通过监控等手段来提前预防。

而对于服务提供者方面,对于这种请求量超出承受能力的问题,我们可以进行扩容来支持高并发或者进行限流,自己能处理多少请求就处理多少,处理不了的请求直接拒绝,这样才不会将自己拖垮。服务消费者方面,我们需要做的就是资源隔离,快速失败,这也是最有效的方式,当我们发现被调用方迟迟不响应出现问题的时候,就不要再继续发起调用请求了,此时应该停止并返回一个友好的响应(服务降级),等待被调用方恢复后再发起调用。


Hystrix 快速入门

类似实现限流、熔断以及降级等功能如果需要我们自己来实现的话,可能需要在每个调用的地方都要做一些逻辑处理并判断要不要发起调用,如果这样就太麻烦了。

好在 Spring Cloud Hystrix 就是专门处理此类场景问题的:核心功能有服务降级、服务熔断、服务限流、服务隔离、请求缓存、请求合并等。

断路器模式源于 Martin Fowler 的 CircuitBreaker 一文。“断路器”本身是一种开关装置, 用于在电路上保护线路过载,当线路中有电器发生短路时,“断路器”能够及时切断故障电 路,防止发生过载、发热甚至起火等严重后果。


在分布式架构中,断路器模式的作用也是类似的,当某个服务单元发生故障(类似用电器发生短路)之后,通过断路器的故障监控(类似熔断保险丝),向调用方返回一个错误响应,而不是长时间的等待,这样就不会使得线程因调用故障服务而被长时间占用不释放,避免了故障在分布式系统中的蔓延。


Spring Cloud Netflix Hystrix 实现了断路器,线程隔离等一系列服务保护功能。该框架的目的在于通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。Hystrix 具备服务降级、服务熔断、线程和信号隔离、请求缓存、请求合并以及服务监控等的强大功能。


  • 服务降级:当某个服务单元发生故障之后,通过断路器的故障监控,向调用方返回一个错误响应,而不是长时间的等待,这样就不会使得线程因调用故障服务而被长时间占用不释放,避免了故障在分布式系统中的蔓延。

    • 服务熔断: 是指调用方访问服务时通过断路器做代理进行访问,断路器会持续观察服务返回的成功、失败的状态,当失败超过设置的阈值时断路器打开,请求就不能真正地访问到服务了。
  • 线程和信号隔离:通过线程池的方式来为依赖服务调用提供隔离,避免因依赖服务故障导致的线程阻塞,从而导致整个系统的线程资源耗尽。还可以通过信号量(SEMAPHORE)的方式来实现, 信号量的方式相比于线程池的方式,它不会创建线程,而是通过计数器来实现线程的隔离。

SEMAPHORE: 信号量,是一种计数信号,用来控制同时访问特定资源的线程数量,或者同时执行某个指定操作的数量。读作: /ˈseməfɔːr/。


  • 请求缓存: 在 Hystrix 中,请求缓存是通过请求命令的 execute()queue() 方法来实现的,它们都会将请求结果缓存起来,当下次请求相同的命令时,就会直接从缓存中获取结果,而不是再次发送请求。

  • 请求合并:在 Hystrix 中,请求合并是通过请求命令的 queue() 方法来实现的,它会将多个请求合并为一个请求,然后再发送给依赖服务。

  • 服务监控:Hystrix 通过实现 HystrixCommandMetrics 和 HystrixThreadPoolMetrics 来实现对服务的监控,它们分别用于监控命令的执行情况和线程池的执行情况。


接下来,我们就从一个简单示例开始对 Spring Cloud Hystrix 的学习。


对消费端进行改造


1.在消费端引入 Hystrix 的启动依赖
<!--引入Hystrix-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<!--Hystrix仪表盘-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
<!--actuator端点-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

2.改造启动类

在消费侧启动类上添加@EnableCircuitBreaker 注解,启动断路器功能。

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

断路器的作用是当某个服务单元发生故障之后,通过断路器的故障监控,向调用方返回一个错误响应,而不是长时间的等待,这样就不会使得线程因调用故障服务而被长时间占用不释放,避免了故障在分布式系统中的蔓延。也叫做服务降级。


2.新增 Controller

在消费端新增一个 Controller,使用 RestTemplate 模拟 HTTP 请求


@RestController
@RequestMapping("/hystrix")
public class HystrixTestController {

   @Autowired
   private RestTemplate restTemplate;

    @GetMapping("/{param}")
    @HystrixCommand(fallbackMethod = "showAppInfoFallback")
    public String showAppInfo(@PathVariable("param") String param) {
        //通过RestTemplate查询用户信息 使用服务名代替ip和端口号
        String url = "http://userservice/hystrix/"+param;
        return restTemplate.getForObject(url, String.class);
    }

    //回调方法 - 服务降级 - 当前方法调用失败时,调用该方法
    public String showAppInfoFallback(@PathVariable("param") String param) {
        return "服务器繁忙,请稍后再试o(╥﹏╥)o";
    }
}

上述代码中,@HystrixCommand 中的 fallbackMethod 属性代表着当前方法的回调方法,属性值就是该 Controller 下的方法名.


对服务端进行改造


由于当前是快速入门的 demo,我们就只用简单定义一下 Controller 即可,提供一个新的对外开放的 API 即可。因此,可以声明一个新的 Controller,借由这个 Controller 来观察输出结果。

@Slf4j
@RestController
@RequestMapping("/hystrix")
public class HystrixProviderController {
    //自动注入端口号的值,方便区分查看应用
    @Value("${server.port}")
    private String port;

    @GetMapping("/{param}")
    public String showInfo(@PathVariable("param")String param){
        return "当前应用的端口是: " + port+ "传递过来的参数是: "+ param;
    }
}

启动要求


1.启动 Nacos

以本地(windows 系统)单机模式启动,在 Nacos 安装目录 bin 处打开命令窗口:

startup.cmd -m standalone

2.启动消费端

修改配置:

ribbon.staticServerList:
  userservice:
    - http://localhost:9020
    - http://localhost:9021
    - http://localhost:9022

并分别启动三个服务实例,端口号分别为 9020,9021,9022

实际测试的时候, 此配置无效


3.启动服务端

我们已经掌握了负载均衡的相关知识,可启动多个服务端,本案例将启动三个服务实例进行演示。

所有项目启动成功后,端口号如下图所示:

bg right fit


测试及结果

1.正常访问

本案例访问路径为: http://localhost:8080/consumer/hystrix/100 ,多次发送 Get 请求,可以看到如下三种可能的结果:

当前应用的端口是: 9020; 传递过来的参数是: 100;
当前应用的端口是: 9021; 传递过来的参数是: 100;
当前应用的端口是: 9022; 传递过来的参数是: 100;

2.服务器宕机

如果ribbon.staticServerList配置是有效的, 那么假如我们停掉 9022 端口的实例,那么刷新之后,将可能会出现以下这样的三种情境:

当前应用的端口是: 9020; 传递过来的参数是: 100;
当前应用的端口是: 9021; 传递过来的参数是: 100;
服务器繁忙,请稍后再试o(╥﹏╥)o

实际情况是, ribbon.staticServerList配置是无效的, 估计这个配置已经被废弃了, 因为使用静态的服务列表是无法感知动态同步示例的变化的, 所以实际上也不推荐使用这个配置.

为了模拟降级的发生, 可以干脆停止所有的实例, 这样一定会出现服务降级了:

服务器繁忙,请稍后再试o(╥﹏╥)o

3.服务端超时

除了通过断开具体的服务实例来模拟某个节点无法访问的情况之外,我们还可以模拟一下服务阻塞(长时间未响应)的情况。我们对 userservice 的/HystrixProviderController控制器并做一些修改,具体如下:


@Slf4j
@RestController
@RequestMapping("/hystrix")
public class HystrixProviderController {
    /**
     * 自动注入端口号的值,方便区分查看应用
     */
    @Value("${server.port}")
    private String port;

    @GetMapping("/{param}")
    public String showInfo(@PathVariable("param") String param) throws InterruptedException {
        //让处理线程等待几秒钟
        long begin = System.currentTimeMillis();
        int sleepTime = new Random().nextInt(2000);
        Thread.sleep(sleepTime);
        long end = System.currentTimeMillis();
        System.out.println("阻塞时间: " + (end-begin)+"毫秒");
        return "当前应用的端口是: " + port
                + "; 传递过来的参数是: "
                + param + "; 共花费执行时间: "
                + (end - begin) + " 毫秒";
    }
}

通过 Thread.sleep()函数可让/hello 接口的处理线程不是马上返回内容,而是在阻塞几秒之后才返回内容。由于 Hystrix 默认超时时间为 1000 毫秒,所以这里采用了 02000 的随机数以让处理过程有一定概率发生超时来触发断路器:

阻塞时间: 865毫秒
当前应用的端口是: 9020; 传递过来的参数是: 100; 共花费执行时间: 865 毫秒
阻塞时间: 1655毫秒
服务器繁忙,请稍后再试o(╥﹏╥)o

前面演示的是因为超时而触发服务降级的情况,那么如果是因为服务端抛出异常而触发服务降级呢?这里我们将userservice的/hystrix/{param}接口逻辑改为抛出异常:

    @GetMapping("/{param}")
    public String showInfo(@PathVariable("param")String param){
        // 制造异常
        int i = 1/0;
        return "当前应用的端口是: " + port+ "传递过来的参数是: "+ param;
    }

这样在测试的时候, 你会发现, 无论你发送多少次请求, 都会立即返回服务器繁忙,请稍后再试o(╥﹏╥)o, 并且没有任何停顿, 说明服务降级已经生效了。这时的服务降级是因为服务端抛出异常而触发的, 而不是因为超时而触发的。


注意:在该入门案例中,消费端的 application.yml 配置文件中不要设置自定义的超时时间。而是使用默认的超时时间.(1秒钟)


原理分析(How It Works)

通过上面的快速入门示例,我们对 Hystrix 的使用场景和使用方法已经有了一个基础的认识。接下来我们通过解读 Netflix Hystrix 官方的流程图来详细了解一下:当一个请求调用了相关服务依赖之后 Hystrix 是如何工作的。


bg fit


1. 构造一个HystrixCommandHystrixObservableCommand对象

第一步是构造一个HystrixCommandHystrixObservableCommand对象来表示您对依赖项发出的请求。向构造函数传递发出请求时需要的任何参数。代码中我们可以简单的通过使用@HystrixCommand注解实现.

Hystrix 采用了命令模式来实现对服务调用操作的封装。而这两个 Command 对象分别针对不同的应用场景。

  • HystrixCommand:用在依赖的服务返回单个操作结果的时候。
    • 用于包装将执行潜在风险功能(通常意味着通过网络进行服务调用)的代码,具有容错和延迟、统计信息和性能指标捕获、断路器和隔板功能。此命令本质上是一个阻塞命令,但如果与 observe() 一起使用,则会提供一个监听器模式的非阻塞实现。
  • HystrixObservableCommand:用在依赖的服务返回多个操作结果的时候。
    • 作用和HystrixCommand相同, 不同的是此命令应用于纯非阻塞调用模式。此命令的调用方将订阅 run() 方法返回的 Observable。

如何给一个接口构造一个HystrixCommandHystrixObservableCommand对象呢?我们可以通过继承HystrixCommand类或HystrixObservableCommand来实现,也可以通过@HystrixCommand注解来实现。


如何给一个接口构造一个HystrixObservableCommand对象呢?我们可以通过继承HystrixObservableCommand类来实现,也可以通过@HystrixCommand注解来实现。


设计模式之命令模式介绍

Hystrix 利用命令模式来实现对服务调用操作的封装。命令模式是一种数据驱动的设计模式,它属于行为型模式。在命令模式中,我们可以将请求封装成一个对象,以便使用不同的请求、队列或日志来参数化其他对象。命令模式也支持可撤销的操作。通过使用命令模式可以实现“行为请求者”与“行为实现者”的解耦,以便使两者可以适应变化。


下面的示例是对命令模式的简单实现:


  • 行为接收者类: Receiver, 负责具体的业务逻辑处理,它是真正执行命令的对象。

    public class Receiver {
      public void action(){
          //执行真正的业务逻辑
          System.out.println("Receiver 执行命令...");
      }
    }

  • 命令接口: ICommand, 它定义了一个命令对象应具备的一系列命令操作,比如 execute ()undo()redo()等。当命令操作被调用的时候就会触发接收者去做具体命令对应的业务逻辑。

    public interface ICommand {
      /**
      * 执行方法
      */
      void execute();
    }

  • 命令实现类: ConcreteCommand, 它是抽象命令类的具体实现类,它是一个具体的命令对象通过调用接收者的相关操作来实现业务逻辑。

    public class ConcreteCommand implements ICommand {
    
      private Receiver receiver; // 组合接收者对象
    
      public ConcreteCommand(Receiver receiver){
          this.receiver = receiver;
      }
    
      @Override
      public void execute() {
          this.receiver.action();
      }
    }

  • 命令调用者类: Invoker, 它是命令模式的核心,它负责调用命令对象执行请求,相关的方法叫做行动方法。它可以持有一个或多个命令对象,它在需要的时候可以去调用命令对象的执行方法来执行请求,它通过这种方式来实现间接调用请求接收者的相关操作来实现业务操作。

    public class Invoker {
      private ICommand  command; // 组合命令对象
    
      public void setCommand(ICommand command) {
          this.command = command;
      }
      public void action(){
          this.command.execute();
      }
    }

  • 客户端: Client, 它是命令模式的使用者,它通过调用者来执行命令。

    public class Client {
      public static void main(String[] args) {
          Receiver receiver = new Receiver();
          ConcreteCommand command = new ConcreteCommand(receiver);
          Invoker invoker = new Invoker();
          invoker.setCommand(command);
          //客户端通过调用者来执行命令
          invoker.action();
      }
    }

w:30em


从上面的示例中,我们可以看到,调用者 Invoker 与操作者 Receiver 通过 Command 命令接口实现了解耦。对于调用者来说,我们可以为其注入多个命令操作,比如新建文件、 复制文件、删除文件这样三个操作,调用者只需在需要的时候直接调用即可,而不需要知道这些操作命令的具体对象是来自哪些操作者。

Invoker 和 Receiver 的关系非常类似于“请求 - 响应”模式,所以它比较适用于实现记录日志、撤销操作、队列请求等。


在下面这些情况下应考虑使用命令模式。

  • 使用命令模式作为“回调(CallBack)”在面向对象系统中的替代。“CallBack”讲的便是先将一个函数登记上,并不马上执行, 然后在以后需要的时候再调用此函数。
  • 需要在不同的时间指定请求、将请求排队。一个命令对象和原先的请求发出者可以有不同的生命周期。换言之,原先的请求发出者可能己经不在了,而命令对象本身仍然是活动的(通过命令接口解耦命令调用方和命令操作方)。这时命令的接收者可以是在本地,也可以在网络的另外一个地址。命令对象可以在序列化之后传送到另外一台机器上去。
  • 系统需要支持命令的撤销。命令对象可以把状态存储起来,等到客户端需要撤销命令所产生的效果时,可以调用 undo()方法,把命令所产生的效果撤销掉。
  • 命令对象还可以提供 redo()方法,以供客户端在需要时再重新实施命令效果。
  • 如果要将系统中所有的数据更新到日志里,以便在系统崩溃时,可以根据日志读回所有的数据更新命令,重新调用 executed 方法一条一条执行这些命令,从而恢复系统在崩溃前所做的数据更新。

2. Hystrix 命令模式

有四种方法可以执行 HystrixCommandHystrixObservableCommand,通过使用 Hystrix 命令对象的以下四种方法之一:

bg right fit


  • 执行 HystrixCommand
    • execute() — 同步执行,从依赖的服务返回一个单一的结果对象,或是在发生错误的时候抛出异常。
    • queue() — 异步执行,直接返回一个 Future 对象,其中包含了服务执行结束时要返回的单一结果对象。
  • 执行 HystrixObservableCommand,通过使用

    • observe() — 返回来自依赖项的响应的 Observable 对象 ,它代表了操作的多个结果,它是一个 hot Observable
    • toObservable() — 同样会返回 Observable 对象,也代表了操作的多个结果,但它返回的是一个 cold Observable
    K             value   = command.execute();
    Future<K>     fValue  = command.queue();
    Observable<K> ohValue = command.observe();         //hot observable
    Observable<K> ocValue = command.toObservable();    //cold observable

execute() - 同步执行

@HystrixCommand(fallbackMethod = "fallbackMethod")
public String strConsumer() {
 ResponseEntity<String> result = restTemplate.getForEntity("http://cloud-eureka-client/hello", String.class);
 return result.getBody();
}

fallbackMethod —— 回调方法,在服务调用异常、断路器打开、线程池/请求队列/信号量占满时会走回调逻辑。必须和服务方法定义在同一个类中,对修饰符没有特定的要求,定义为 private、 protected、 public 均可。


queue() - 异步执行

@HystrixCommand(fallbackMethod = "fallbackMethod", ignoreExceptions = {IllegalAccessException.class})
public Future<String> asyncStrConsumer() {

 Future<String> asyncResult = new AsyncResult<String>() {
  @Override
  public String invoke() {
   ResponseEntity<String> result = restTemplate.getForEntity("http://cloud-eureka-client/hello", String.class);
   return result.getBody();
  }
 };
 return asyncResult;
}

ignoreExceptions 表示抛出该异常时不走降级回调逻辑,忽略此异常。


observe () 执行方式

 @HystrixCommand(observableExecutionMode = ObservableExecutionMode.EAGER)
 protected Observable<String> construct() {
  ResponseEntity<String> result = restTemplate.getForEntity("http://cloud-eureka-client/hello", String.class);
  return Observable.just(result.getBody());
 }

ObservableExecutionMode.EAGER —— 代表使用 observe() 执行方式,返回的是一个 hot Observable,即便没有订阅者,它也会立即执行。


toObservable() 执行方式

@HystrixCommand(observableExecutionMode = ObservableExecutionMode.LAZY)
protected Observable<String> construct() {
 ResponseEntity<String> result = restTemplate.getForEntity("http://cloud-eureka-client/hello", String.class);
 return Observable.just(result.getBody());
}

ObservableExecutionMode.LAZY —— 代表使用 toObservable() 执行方式,返回的是一个 cold Observable,只有在有订阅者时才会执行。


这里返回的 Observable 对象如何理解呢?我们可以把它理解为“事件源”或是“被观察者”,与其对应的 Subscriber 对象,可以理解为“订阅者”或是“观察者”。这两个对象是 RxJava 响应式编程的重要组成部分。


在 Hystrix 的底层实现中大量地使用了 RxJava, RxJava 是一个在 Java 虚拟机上使用可观测的序列来组成异步的、基于事件的程序的库。主要用于事件处理、数据集操作、异步任务以及基于回调的架构。

为了更容易地理解后续内容,在这里 对 RxJava 的观察者-订阅者模式做一个简单的入门介绍。


  • Observable 用来向订阅者 Subscriber 对象发布事件,Subscriber 对象则在接收到事件后对其进行处理,而在这里所指的事件通常就是对依赖服务的调用。
  • 一个 Observable 可以发出多个事件,直到结束或是发生异常。
  • Observable 对象每发出一个事件,就会调用对应观察者 Subscriber 对象的 onNext()方法。
  • 每一个 Observable 的执行,最后一定会通过调用 Subscriber.onCompleted()或者 Subscriber.onError()来结束该事件的操作流。

下面我们通过一个简单的例子来直观理解一下:

// 1. 创建一个被观察者 Observable
Observable<String> observable = Observable.create(new Observable.OnSubscribe<String>() {
    // 2. 在被观察者 Observable 被订阅时,回调该方法
    @Override
    public void call(Subscriber<? super String> subscriber) {
        // 3. 发送事件:onNext()
        subscriber.onNext("Hello RxJava");
        subscriber.onNext("I am Jeff");
        // 4. 发送事件:onCompleted()
        subscriber.onCompleted();
    }
});
// 5. 创建一个观察者 Observer
Observer<String> observer = new Observer<String>() {
    // 6. 注册观察者时,回调该方法
    @Override
    public void onCompleted() {
        // 7. 观察者接收完事件后,回调该方法
    }

    // 6. 注册观察者时,回调该方法
    @Override
    public void onError(Throwable e) {
        // 7. 发生错误时,回调该方法
    }
    // 6. 注册观察者时,回调该方法
    @Override
    public void onNext(String str) {
        // 7. 接收到事件时,回调该方法
        System.out.println("Subscriber: " + str);
    }
};
// 8. 通过订阅(Subscribe)连接观察者和被观察者
observable.subscribe(observer);

在该示例中,创建了一个简单的事件源 observable, —个对事件传递内容输出的订阅者 subscriber,通过 observable.subscribe (subscriber)来触发事件的发布。

调用过程如下:

  1. 主线程调用 Observable.create() 方法,创建一个被观察者 Observable
  2. 被观察者调用 Observable.subscribe() 方法,订阅观察者 Subscriber
  3. 被观察者调用观察者的 Subscriber.onNext() 方法,让观察者接收到事件
  4. 这时观察者调用通过 next() 方法输出事件内容
  5. 被观察者调用 Subscriber.onCompleted() 方法,用来通知观察者事件已经结束
  6. 这时观察者调用 complete() 方法,用来结束事件流.

这在设计模式上一般称为观察者模式,也叫发布-订阅模式: 定义了对象之间的一对多的依赖关系,让多个观察者对象同时监听某一个主题对象(被观察者),这个主题对象在状态发生变化时,会通知所有观察者对象执行更新方法。

特点: 观察者和被观察者之间是松散耦合的,观察者和被观察者之间建立了一套触发机制。


在这里我们对于 Hystrix 的事件源 observable 提到了两个不同的概念:Hot ObservableCold Observable, 别对应了上面 command.observe ()command.toObservable ()的返回对象。

  • Hot Observable:
    Hot Observable 是指在订阅之前就开始发射数据的 Observable。observe()方法返回的 Hot Observable 代表了一个可观察的事件流,它可以被订阅,以便在事件流中接收数据。
  • Cold Observable:
    和 Hot Observable 的区别是, Cold Observable 是 toObservable()方法返回的, 并且只有在订阅之后才开始发射数据。

3. 结果是否被缓存

若当前命令的请求缓存功能是被启用的,并且该命令缓存命中,那么缓存的结果会立即以 Observable 对象的形式返回。

启用缓存的办法: 在创建 HystrixCommand 对象时,通过 HystrixCommandProperties.Setter().withRequestCacheEnabled(true)来启用缓存功能。


4. 断路器是否打开

在命令结果没有缓存命中的时候,Hystrix 在执行命令前需要检查断路器是否为打开状态.

  • 如果断路器是打开的,那么 Hystrix 不会执行命令,而是转接到 fallback 处理逻辑(对应下面第 8 步)。
  • 如果断路器是关闭的,那么 Hystrix 会继续检查是否有可用资源来执行命令。(关于断路器的具体实现细节,后续会做更加详细的分析。)

5. 线程池/请求队列/信号量是否占满

如果与命令相关的线程池和请求队列,或者信号量(不使用线程池的时候)己经被占满,那么 Hystrix 也不会执行命令,而是转接到 fallback 处理逻辑(对应下面第 8 步)。需要注意的是,这里 Hystrix 所判断的线程池并非容器的线程池,而是每个依赖服务的专有线程池。Hystrix 为了保证不会因为某个依赖服务的问题影响到其他依赖服务而采用了“舱壁模式”(Bulkhead Pattern)来隔离每个依赖的服务。关于依赖服务的隔离与线程池相关的内容见后续详细介绍。


6. 执行HystrixObservableCommand.construct()HystrixCommand.run()

Hystrix 会根据我们编写的方法来决定采取什么样的方式去请求依赖服务。

  • HystrixCommand.run(): 返回一个单一的结果,或者抛出异常。
  • HystrixObservableCommand.construct(): 返回一个 Observable 对象来发射多个结果,或通过 onError 发送错误通知。

如果 run()construct()方法的执行时间超过了命令设置的超时阈值,当前处理线程将会抛出一个 TimeoutException (如果该命令不在其自身的线程中执行,则会通过单独的计时线程来抛出)。在这种情况下,Hystrix 会转接到 fallback 处理逻辑(第 8 步)。 同时,如果当前命令没有被取消或中断,那么它最终会忽略 run()或者 construct()方法的返回。

如果命令没有抛出异常并返回了结果,那么 Hystrix 在记录一些日志并采集监控报告之后将该结果返回。在使用 run()的情况下,Hystrix 会返回一个 Observable,它发射单个结果并产生 onCompleted 的结束通知;而在使用 construct() 的情况下,Hystrix 会 直接返回该方法产生的 Observable 对象。


7. 计算断路器的健康度

Hystrix 会将“成功”、“失败”、“拒绝”、“超时”等信息报告给断路器,而断路器会维护一组计数器来统计这些数据。断路器会使用这些统计数据来决定是否要将断路器打开,来对某个依赖服务的请求进 行“熔断/短路”,直到恢复期结束。若在恢复期结束后,根据统计数据判断如果还是未达到健康指标,就再次“熔断/短路”。


8. fallback 处理

当命令执行失败的时候,Hystrix 会进入 fallback 尝试回退处理,我们通常也称该操作为“服务降级”。而能够引起服务降级处理的情况有下面几种:

  • 当前命令处于“熔断/短路”状态,断路器是打开的时候。
  • 当前命令的线程池、请求队列或者信号量被占满的时候。
  • HystrixObservableCommand.construct() HystrixCommand.run() 抛出异常的时候。

在服务降级逻辑中,我们需要实现一个通用的响应结果,并且该结果的处理逻辑应当是从缓存或是根据一些静态逻辑来获取,而不是依赖网络请求获取。如果一定要在降级逻辑中包含网络请求,那么该请求也必须被包装在 HystrixCommand 或是 HystrixObservableCommand 中,从而形成级联的降级策略,而最终的降级逻辑一定不是一个依赖网络请求的处理,而是一个能够稳定地返回结果的处理逻辑。


在 HystrixCommand 和 HystrixObservableCommand 中实现降级逻辑时还略有不同:

  • 当使用 HystrixCommand 的时候,通过实现 HystrixCommand.getFallback()来实现服务降级逻辑。
  • 当使用 HystrixObservableCommand 的时候,通过 HystrixObservableCommand.resumeWithFallback()实现服务降级逻辑,该方法会返回一个 Observable 对象来发射一个或多个降级结果。

当命令的降级逻辑返回结果之后,Hystrix 就将该结果返回给调用者。当使用 HystrixCommand.getFallback()的时候,它会返回一个 Observable 对象,该对象会发射 getFallback()的处理结果。而使用 HystrixObservableCommand.resumeWithFallback ()实现的时候,它会将 Observable 对象直接返回。


如果我们没有为命令实现降级逻辑或者降级处理逻辑中抛出了异常,Hystrix 依然会返回一个 Observable 对象,但是它不会发射任何结果数据,而是通过 onError 方法通知命令立即中断请求,并通过 onError ()方法将引起命令失败的异常发送给调用者。实现一个有可能失败的降级逻辑是一种非常糟糕的做法,我们应该在实现降级策略时尽可能避免失败的情况。


降级执行失败时, Hystrix 会根据不同的执行方法做出不同的处理。

  • execute(): 抛出异常。
  • queue():正常返回 Future 对象,但是当调用 get()来获取结果的时候会抛出异常。
  • observe():正常返回 Observable 对象,当订阅它的时候,将立即通过调用订阅者的 onError 方法来通知中止请求。
  • toObservable(): 正常返回 Observable 对象,当订阅它的时候,将通过调用订阅者的 onError 方法来通知中止请求。

  1. 返回成功的响应
    当 Hystrix 命令执行成功之后,它会将处理结果直接返回或是以 Observable 的形式 返回。而具体以哪种方式返回取决于之前第 2 步中我们所提到的对命令的 4 种不同执行方 式,下图中总结了这 4 种调用方式之间的依赖关系。我们可以将此图与在第 2 步中对前两 者源码的分析联系起来,并且从源头 toObservable ()来开始分析。

bg fit


  • execute() - 以与 queue()相同的方式获取一个 Future,然后在这个 Future 上调用 get()来获取由 Observable 发出的单个值
  • queue() - 将 Observable 转换为一个 BlockingObservable,以便它可以被转换为一个 Future,然后返回这个 Future
  • observe() - 在 toObservable()产生原始 Observable 之后立即订阅它,让命令能够马上开始异步执行,并返回一个 Observable 对象,当调用它的 subscribe 时,将重新产生结果和通知给订阅者。
  • toObservable() - 返回最原始的 Observable,必须通过订阅它才会真正触发命令的执行流程。

断路器原理

断路器在 HystrixCommand 和 HystrixObservableCommand 执行过程中起到了举足轻重的作用,它是 Hystrix 的核心部件。

断路器有三种状态:


bg right fit

  • 关闭状态(Closed):断路器关闭,所有请求会被发送到服务端。
  • 打开状态(Open):当触发熔断阈值后,断路器打开,所有请求都不会被发送到服务端,直接返回错误。
  • 半开状态(Half-Open):断路器半开,部分请求会被发送到服务端,如果请求成功,断路器关闭,如果请求失败,断路器打开。
    • 当断路器打开后,会启动一个定时器,定时器到期后,断路器进入半开状态。默认情况下,断路器会在 5 秒后进入半开状态。
    • 当断路器处于半开状态时,如果请求成功,断路器关闭,如果请求失败,断路器打开。

默认触发熔断并造成断路器打开的条件是:

  1. 快照时间窗(默认 10 秒)
  2. 请求总数阈值(默认 20)
  3. 错误百分比阈值(默认 50%)

涉及断路器的三个参数:快照时间窗、请求总数阀值、错误百分比阀值。

  1. 快照时间窗:断路器确定是否打开需要统计一些请求和错误数据,而统计的时间范围就是快照时间窗,默认为最近的 10 秒。它是一个滚动时间窗. 每过一段时间,比如 10 秒,就会统计最近 10 秒内的请求总数,如果请求数量超过了阀值,就会根据错误百分比来判断是否需要打开断路器。

  2. 请求总数阀值:在快照时间窗内,必须满足请求总数阀值才有资格熔断。默认为 20,意味着在 10 秒内,如果该 hystrix 命令的调用次数不足 20 次,即使所有的请求都超时或其他原因失败,断路器都不会打开。

  3. 错误百分比阀值:当请求总数在快照时间窗内超过了阀值,比如发生了 30 次调用,如果在这 30 次调用中,有 15 次发生了超时异常,也就是超过 50%的错误百分比,在默认设定 50%阀值情况下,这时候就会将断路器打开。


配置断路器参数:

@HystricCommand(fallbackMethod = "fallback", commandProperties = {
    @HystrixProperty(name = "circuitBreaker.enabled", value = "true"), // 是否开启断路器
    @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"), // 请求总数阀值
    @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "10000"), // 时间窗口期
    @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "60"), // 错误百分比阀值
})

底层原理 (了解)

那么断路器是如何决策熔断和记录信息的呢?

下图是 Netflix Hystrix 官方文档中关于断路器的详细执行逻辑,可以帮助我们理解断路器的工作流程。

https://github.com/Netflix/Hystrix/wiki/


bg fit


  1. 假设大量的请求数量超过了 HystrixCommandProperties.circuitBreakerRequestVolumeThreshold() 的阈值(默认 20);或者在滚动时间窗口内,失败的请求数量占总请求数量的百分比超过了 HystrixCommandProperties.circuitBreakerErrorThresholdPercentage() 的阈值(默认 50%),那么断路器将会被打开,此时所有的请求都会被拒绝(失败), 熔断器将会从闭合状态变成打开状态;

  1. 在熔断器处于打开状态的期间,所有对这个依赖进行的调用都会短路,即不进行真正的依赖调用,快速失败;
    在等待(冷却)的时间超过 HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds() 的值(默认 5 秒)后,断路器将会进入半开状态,此时断路器将会允许一个请求去调用依赖,如果这个请求调用成功,断路器将会闭合, 后续依赖调用可正常执行。,如果这个请求调用失败,断路器将会继续打开,继续等待下一次的半开状态(冷却)。

  1. 这里 10 1-second "buckets" 的意思是,每 1 秒,会统计最近 10 秒内的请求总数,如果请求数量超过了阀值,就会根据错误百分比来判断是否需要打开断路器。

这个桶数可以通过 HystrixCommandProperties.metricsRollingStatisticalWindowBuckets() 来配置,默认为 10 个桶,每个桶 1 秒,所以默认的快照时间窗为 10 秒。

现在我们来看看断路器 HystrixCircuitBreaker 的定义:

public interface HystrixCircuitBreaker {
    boolean allowRequest();

    boolean isOpen();

    void markSuccess();

    public static class NoOpCircuitBreaker implements HystrixCircuitBreaker {...}

    public static class HystrixCircuitBreakerImpl implements HystrixCircuitBreaker {...}

    public static class Factory {...}

可以看到它的接口定义并不复杂,主要定义了三个断路器的抽象方法。

  • allowRequest()
    每个 Hystrix 命令的请求都通过它判断是否被执行。
  • isOpen()
    返回当前断路器是否打开。
  • markSuccess()
    用来闭合断路器。

另外还有三个静态类。

  • 静态类 Factory 中维护了一个 Hystrix 命令与 HystrixCircuitBreaker 的关系集合:

    private static ConcurrentHashMap<String, HystrixCircuitBreaker> circuitBreakersByCommand = new ConcurrentHashMap();

    其中 String 类型的 key 通过 HystrixCommandKey 定义,每一个 Hystrix 命令需要有一个 key 来标识,同时一个 Hystrix 命令也会在该集合中 找到它对应的断路器 HystrixCircuitBreaker 实例。


  • 静态类 NoOpCircuitBreaker 定义了一个什么都不做的断路器实现,它允许所有请求,并且断路器状态始终闭合(允许请求通过)。

  • 静态类 HystrixCircuitBreakerlmpl 是断路器接口 HystrixCircuitBreaker 的实现类,在该类中定义了断路器的 4 个核心对象。

    • HystrixCommandProperties properties:断路器对应 HystrixCommand 实例的属性对象,它的详细内容我们将在后续章节做具体的介绍。
    • HystrixCommandMetrics metrics:用来让 HystrixCommand 记录各类度量指标的对象。
    • AtomicBoolean circuitOpen:断路器是否打开的标志,默认为 false。
    • AtomicLong circuitOpenedOrLastTestedTime:断路器打开或是上一次测试的时间 戳。

​HystrixCircuitBreakerlmplHystrixCircuitBreaker 接口的各个方法实现如下所示。


  • isOpen ():判断断路器的打开/关闭状态。
    public boolean isOpen() {
        if (this.circuitOpen.get()) {
            return true;
        } else {
            HealthCounts health = this.metrics.getHealthCounts();
            if (health.getTotalRequests() < (long)(Integer)this.properties.circuitBreakerRequestVolumeThreshold().get()) {
                return false;
            } else if (health.getErrorPercentage() < (Integer)this.properties.circuitBreakerErrorThresholdPercentage().get()) {
                return false;
            } else if (this.circuitOpen.compareAndSet(false, true)) {
                this.circuitOpenedOrLastTestedTime.set(System.currentTimeMillis());
                return true;
            } else {
                return true;
            }
        }
    }

详细逻辑如下所示。

  • 如果断路器打开标识为 true,则直接返回 true,表示断路器处于打开状态。否则,就从度量指标对象 metrics 中获取 HealthCounts 统计对象做进一步判断(该 对象记录了一个滚动时间窗内的请求信息快照,默认时间窗为 10 秒)。
  • 如果它的请求总数(QPS)在预设的阈值范围内就返回 false,表示断路器处于未打开状态。该阈值的酉己置参数为 circuitBreakerRequestVolumeThreshold, 默认值为 20
  • 如果错误百分比在阈值范围内就返回 false,表示断路器处于未打开状态。该阈值的配置参数为 circuitBreakerErrorThresholdPercentage,默认值 为 50
  • 如果上面的两个条件都不满足,则将断路器设置为打开状态(熔断/短路)。同时,如果是从关闭状态切换到打开状态的话,就将当前时间记录到上面提到的circuitOpenedOrLastTestedTime 对象中。

  • allowRequest():判断请求是否被允许,这个实现非常简单。
public boolean allowRequest() {
    if ((Boolean)this.properties.circuitBreakerForceOpen().get()) {
        return false;
    } else if ((Boolean)this.properties.circuitBreakerForceClosed().get()) {
        this.isOpen();
        return true;
    } else {
        return !this.isOpen() || this.allowSingleTest();
    }
}

执行逻辑如下所示。

  • 先根据配置对象 properties 中的断路器判断强制打开或关闭属性是否被设置。如果强制打开,就 直接返回 false,拒绝请求。如果强制关闭,它会允许所有请求,但是同时也会调用 isOpen ()来执行断路器的计算逻辑,用来模拟断路器打开/关闭的行为。

  • 在默认情况下,断路器并不会进入这两个强制打开或关闭的分支中去,而是通过!isOpen()|| allowSingleTest()来判断是否允许请求访问。!isOpen ()之前己经介绍过,用来判断和计算当前断路器是否打开,如果是断开状态就不允许请求。那么 allowSingleTest()是用来做什么的呢?


public boolean allowSingleTest() {
    long timeCircuitOpenedOrWasLastTested = this.circuitOpenedOrLastTestedTime.get();

    return
      this.circuitOpen.get()
      &&
      System.currentTimeMillis() >
         timeCircuitOpenedOrWasLastTested
         + (long)(Integer)this.properties.circuitBreakerSleepWindowInMilliseconds().get()
      &&
      this.circuitOpenedOrLastTestedTime.compareAndSet(
        timeCircuitOpenedOrWasLastTested,
        System.currentTimeMillis()
      );
}

执行逻辑如下所示:

  • allowSingleTest()的实现中我们可以看到,这里使用了在 isOpen()函数中当断路器从闭合到打开时候所记录的时间戳。当断路器在打开状态的时候,这里会判断 断开时的时间戳 + 配置中的 circuitBreakerSleepWindowInMilliseconds 时间是否小于当前时间,是的话,就将当前时间更新到记录断路器打开的时间对象 circuitOpenedOrLastTestedTime 中,并且允许此次请求。简单地说,通过 circuitBreakerSleepWindowInMilliseconds 属性设置了一个断路器打幵之后的休眠时间(默认为 5 秒),在该休眠时间到达之后,将再次允许请求尝试访问,此时断路器处于“半开”状态,若此时请求继续失败,断路器又进入打开状态,并继续等待下一个休眠窗口过去之后再次尝试;若请求成功,则将断路器重新置于关 闭状态。所以通过 allowSingleTest()isOpen()方法的配合,实现了断路器打开和关闭状态的切换。
  • markSucces():该函数用来在“半开路”状态时使用。若 Hystrix 命令调用成功, 通过调用它将打开的断路器关闭,并重置度量指标对象。

依赖隔离

Hystrix 提供了两种资源隔离策略:线程和信号量。

  • 在线程隔离中,HystrixCommand 将在单独的线程上执行,并且并发请求受线程池中线程数量的限制。
  • 在信号量隔离中,HystrixCommand 将在调用线程上执行,开销相对较小,并且请求受信号量数量的限制 。

“舱壁模式”对于熟悉 Docker 的读者一定不陌生,Docker 通过“舱壁模式”实现进程 的隔离,使得容器与容器之间不会互相影响。而 Hystrix 则使用该模式实现线程池的隔离, 它会为每一个依赖服务创建一个独立的线程池,这样就算某个依赖服务出现延迟过高的情况,也只是对该依赖服务的调用产生影响,而不会拖慢其他的依赖服务。


bg fit


  • 总之,通过对依赖服务实现线程池隔离,可让我们的应用更加健壮,不会因为个别依赖服务出现问题而引起非相关服务的异常。同时,也使得我们的应用变得更加灵活,可以在不停止服务的情况下,配合动态配置刷新实现性能配置上的调整。

虽然线程池隔离的方案带来如此多的好处,但是很多使用者可能会担心为每一个依赖服务都分配一个线程池是否会过多地增加系统的负载和开销。对于这一点,使用者不用过于担心,因为这些顾虑也是大部分工程师们会考虑到的,Netflix 在设计 Hystrix 的时候,认为线程池上的开销相对于隔离所带来的好处是无法比拟的。同时,Netflix 也针对线程池的 开销做了相关的测试,以用结果打消 Hystrix 实现对性能影响的顾虑。


下图是 Netflix Hystrix 官方提供的一个 Hystrix 命令的性能监控图,该命令以每秒 60 个请求的速度(QPS)对一个单服务实例进行访问,该服务实例每秒运行的线程数峰值为 350 个。


bg fit


每种情况都有两个曲线,一个是未使用线程池隔离,一个是使用了线程池隔离。根据图表统计,不难得出如下结论:

比较情况 未使用线程池隔离 使用了线程池隔离 耗时差距
中位数 2ms 2ms 2ms
90 百分位 5ms 8ms 3ms
99 百分位 28ms 37ms 9ms

对于大多数 Netflix 用例而言,使用线程池隔离的开销已被认为是可以接受的,因为可以实现弹性优势。


虽然对于大部分的请求我们可以忽略线程池的额外开销,而对于小部分延迟本身就非常小的请求(可能只需要 lms),那么 9ms 的延迟开销还是非常昂贵的。Hystrix 为此设计了另外的解决方案:信号量

在 Hystrix 中除了可使用线程池之外,还可以使用信号量来控制单个依赖服务的并发度,信号量的开销远比线程池的开销小,但是它不能设置超时和实现异步访问。所以,只有在依赖服务是足够可靠的情况下才使用信号量。


在 HystrixCommand 和 HystrixObservableCommand 中有两处支持信号量的使用:

  • 命令执行:如果将隔离策略参数 execution.isolation.strategy 设置为 SEMAPHORE, Hystrix 会使用信号量替代线程池来控制依赖服务的并发。(默认值为 THREAD)。
  • 降级逻辑:当 Hystrix 尝试降级逻辑时,它会在调用线程中使用信号量。信号量的默认值为 10,我们也可以通过动态刷新配置的方式来控制并发线程的数量。 对于信号量大小的估算方法与线程池并发度的估算类似。仅访问内存数据的请求一般耗时 在 lms 以内,性能可以达到 5000 RPS(RPS 指每秒的请求数),这样级别的请求可以将信号量 设置为 1 或者 2,我们可以按此标准并根据实际请求耗时来设置信号量。

这两种策略之间的选择取决于您的使用情况。如果您每秒有大量请求,则可以考虑使用信号量。其次,使用信号量时,命令将在调用者的线程内执行。这意味着并发调用并未完全与其他调用隔离(不像使用线程时)。

一般来说,在认为开销足够小的情况下,Netflix 在实践中通常更喜欢单独线程的隔离优势而选择线程的隔离策略。


使用详解和属性详解

使用详解

在“快速入门”一节中我们己经使用过 Hystrix 中的核心注解@HystrixCommand 通过它创建了 HystrixCommand 的实现,同时利用 fallback 属性指定了服务降级的实现 方法。接下来我们将详细介绍 @HystrixCommand 注解的使用方法。


创建请求命令

Hystrix 命令就是我们之前所说的 HystrixCommand,它用来封装具体的依赖服务调用逻辑。我们可以通过@HystrixCommand 注解来更为优雅地实现 Hystrix 命令的定义:


@GetMapping("/{param}")
@HystrixCommand(fallbackMethod = "showAppInfoFallback")
public String showAppInfo(@PathVariable("param") String param) {
    String url = "http://userservice/hystrix/"+param;
    return restTemplate.getForObject(url, String.class);
}
  • 当触发回调函数执行条件时,将执行 fallbackMethod 属性所定义的 名字为showAppInfoFallback的函数。

定义服务降级

fallbackMethod —— Hystrix 命令执行失败时使用的回退方法, 用来实现服务降级,在服务调用异常、断路器打开、线程池/请求队列/信号量占满时会走回调逻辑。必须和服务方法定义在同一个类中,对修饰符没有特定的要求,定义为 private、 protected、 public 均可, 如:

/**
 * 服务降级方法
 * @param param 源方法传入的参数
 * @return 和源方法的返回值类型保持一致
 */
public String showAppInfoFallback(String param) {
    return "服务器繁忙,请稍后再试o(╥﹏╥)o" + param;
}

  • 在上面的例子中,假如 showAppInfoFallback 方法实现的并不是一个稳定逻辑(有可能会再次进行网络请求),它依然可能会发生异常,那么我们也可以为它添加@HystrixCommand 注解以生成 Hystrix 命令,同时使用 fallbackMethod 来指定服务降级逻辑,比如:

@RestController
@RequestMapping("/hystrix")
public class HystrixConsumerController {

   @Autowired
   private RestTemplate restTemplate;

    @GetMapping("/{param}")
    @HystrixCommand(fallbackMethod = "showAppInfoFallback")
    public String showAppInfo(@PathVariable("param") String param) {
        String url = "http://userservice/hystrix/"+param;
        return restTemplate.getForObject(url, String.class);
    }

    /**
     * 服务降级方法
     * @param param 源方法传入的参数
     * @return 和源方法的返回值类型保持一致
     */
    @HystrixCommand(fallbackMethod = "showAppInfoFallbackEnd")
    public String showAppInfoFallback(String param) {
        String url = "http://userservice/hystrix/"+param+"abc";
        return restTemplate.getForObject(url, String.class);
    }

    /**
     * 终极服务降级方法
     * @param param 源方法传入的参数
     * @return 和源方法的返回值类型保持一致
     */

    public String showAppInfoFallbackEnd(String param) {
        return "最终降级方法,逻辑稳定..." + param;
    }
}

值得注意的是,定义服务降级方法时,最终要降级到一个逻辑稳定的方法上,避免这个方法再发生任何的异常情况。这样的设计对于系统来说才是有意义的,可以确保服务稳定性。


在实际使用时,我们需要为大多数执行过程中可能会失败的 Hystrix 命令实现服务降级逻辑,但是也有一些情况可以不去实现降级逻辑,如下所示。

  • 执行写操作的命令:当 Hystrix 命令是用来执行写操作而不是返回一些信息的时候,通常情况下这类操作的返回类型是 void 或是为空的 Observable,实现服务降级的意义不是很大。当写入操作失败的时候,我们通常只需要通知调用者即可。

  • 执行批处理或离线计算的命令:当 Hystrix 命令是用来执行批处理程序生成一份报告或是进行任何类型的离线计算时,那么通常这些操作只需要将错误传播给调用者,然后让调用者稍后重试而不是发送给调用者一个静默的降级处理响应。

不论 Hystrix 命令是否实现了服务降级,命令状态和断路器状态都会更新,并且我们可以由此了解到命令执行的失败情况。


异常处理

异常传播

在使用注册配置实现 Hystrix 命令时,它还支持忽略指定异常类型功能,只需要通过设置@HystrixCommand 注解的 ignoreExceptions 参数,比如:



@HystrixCommand(fallbackMethod = "showAppInfoFallbackEnd",
                ignoreExceptions = {BadRequestException.class})
public String showAppInfo(String param) {
    String url = "http://userservice/hystrix/"+param+"abc";
    return restTemplate.getForObject(url, String.class);
}

如上面代码的定义,当 showAppInfo 方法抛出了类型为 BadRequestException 的异常时 Hystrix 会将它包装在 HystrixBadRequestException 中抛出,这样就不会触发后续的 fallback 逻辑。


异常获取

当 Hystrix 命令因为异常(除了 HystrixBadRequestException 的异常)进入服务降级逻辑之后,往往需要对不同异常做针对性的处理,那么我们如何来获取当前抛出的异常呢?
注解配置方式的实现非常简单,只需要在 fallback 实现方法的参数中增加 Throwable e 对象的定义,这样在方法内部就可以获取触发服务降级的具体异常内容了,比如:


@RestController
@RequestMapping("/hystrix")
public class HystrixConsumerController {

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("/{param}")
    @HystrixCommand(fallbackMethod = "showAppInfoFallback")
    public String showAppInfo(@PathVariable("param") String param) {
        throw new RuntimeException("showAppInfo fail");
    }

    /**
     * 服务降级方法
     *
     * @param param     源方法传入的参数
     * @param exception 接收到的异常信息
     * @return 和源方法的返回值类型保持一致
     */
    public String showAppInfoFallback(String param,Throwable exception) {
        return param+"====" + exception.getMessage();
    }
}

命令名称、分组以及线程划分

当我们使用 HystrixCommand 注解的时候,通过设置@HystrixCommand 注解的 CommandKeygroupKey 以及 threadPoolKey 属性即可设置命令名称、分组以及线程池划分,比如我们可以像下面这样进行设置:

@GetMapping("/{param}")
@HystrixCommand(fallbackMethod = "showAppInfoFallback",
  ignoreExceptions = {IOException.class, FileNotFoundException.class},
  commandKey = "showAppInfo",
  groupKey = "userGroup",
  threadPoolKey = "showAppInfoThread"
)
public String showAppInfo(@PathVariable("param") String param) {
    //通过RestTemplate查询用户信息 使用服务名代替ip和端口号
    String url = "http://userservice/hystrix/" + param;
    return restTemplate.getForObject(url, String.class);
}

Hystrix 会根据组来组织和统计命令的告警、仪表盘等信息。
默认情况下,Hystrix 会让相同组名的命令使用同一个线程池。
通常情况下,尽量通过 HystrixThreadPoolKey 的方式来指定线程池的划分,而不是通过组名的默认方式实现划分,因为多个不同的命令可能从业务逻辑上来看属于同一个组,但是往往从实现本身上需要跟其他命令进行隔离。


另外我们可以通过命令名称和线程池进行 Hystrix 的一些针对具体命令和线程池的相关配置,比如我们可以像下面这样进行设置:

hystrix:
  command:
    showAppInfo:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 1000
  threadpool:
    showAppInfoThread:
      coreSize: 10
      maxQueueSize: 100
      queueSizeRejectionThreshold: 50

请求缓存

当系统用户不断增长时,每个微服务需要承受的并发压力也越来越大。在分布式环境下,通常压力来自于对依赖服务的调用,因为请求依赖服务的资源需要通过通信来实现,这样的依赖方式比起进程内的调用方式会引起一部分的性能损失,同时 HTTP 相比于其他 高性能的通信协议在速度上没有任何优势,所以它有些类似于对数据库这样的外部资源进 行读写操作,在高并发的情况下可能会成为系统的瓶颈。既然如此,我们很容易地可以联想到,类似数据访问的缓存保护是否也可以应用到依赖服务的调用上呢?


答案显而易见,在高并发的场景之下,Hystrix 中提供了请求缓存的功能,我们可以方便地开启和使用请求缓存来优化系统,达到减轻高并发时的请求线程消耗、降低请求响应时间的效果。

Hystrix 的请求缓存可以通过注解的方式进行配置实现。注解配置的定义实现同 JSR 107 的定义非常相似,但由于 Hystrix 不需要独立外置的缓存系统来支持,所以没有 JSR 107 的定义那么复杂,它只提供了三个专用于请求 缓存的注解。


注解 描述 属性
@CacheResult 该注解用来标记请求命令返回的结果应该被缓存,它必须与@HystrixCommand 注解结合使用 cacheKeyMethod
@CacheRemove 该注解用来让请求命令的缓存失效,失效的缓存根据定义的 Key 决定 commandKey,cacheKeyMethod
@CacheKey 该注解用来在请求命令的参数上标记,使其作为缓存的 Key 值,如果没有标注则会使用所有参数。如果同时还使用了@CacheResult@CacheRemove 注解的 cacheKeyMethod 方法指定缓存 Key 的生成,那么该注解将不会起作用 value

JSR 107 是 Java 缓存 API 的定义,也被称为 JCache。它定义了一系列开发人 员使用的标准化 Java 缓存 API 和服务提供商使用的标准 SPI。下面我们从几个方面的实例来看看这几个注解的具体使用方法。


缓存生命周期

缓存生命周期是从HystrixRequestContext.initializeContext(); 初始化开始, 到HystrixRequestContext#shudown();结束。

初始化HystrixRequestContext

1、在每个用到请求缓存的Controller方法的第一行加上如下代码:

//初始化Hystrix请求上下文
@Cleanup("shutdown") HystrixRequestContext.initializeContext();

@Cleanup 是 lombok注解, 可以自动生成释放资源的代码,默认是调用资源的 close() 方法,也可以自己指定释放的方法

2、使用Filter方式:

在启动类加入@ServletComponentScan注解, 指定

创建HystrixRequestContextServletFilter.java,实现Filter接口,在doFilter方法中添加方法1中的那一行代码,并在一次请求结束后关掉这个上下文

@WebFilter(filterName = "hystrixRequestContextServletFilter",urlPatterns = "/*",asyncSupported = true)

public class HystrixRequestContextServletFilter implements Filter {    
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //初始化Hystrix请求上下文
        HystrixRequestContext context = HystrixRequestContext.initializeContext();        try {                      
            chain.doFilter(request, response);//请求正常通过
        } finally {                           
            context.shutdown();//关闭Hystrix请求上下文
        }
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }    @Override
    public void destroy() {

    }
}

在应用启动类上开启包扫描:

@ServletComponentScan(basePackages = {"com.niit.order.filter"})

有了过滤器后, 每个Controller方法中的HystrixRequestContext.initializeContext();代码就可以去掉了.

如果没有为 HystrixRequestContext 进行初始化,那么系统将报如下错误:

image-20220722220530907

这样, Hystrix 的缓存设置是针对单次请求的: 因为每次请求来之前都执行了 HystrixRequestContext.initializeContext();进行初始化,每请求一次 controller 就会走一次 filter,上下文又会初始化一次,前面缓存的就失效了,又得重新来。所以如果测试缓存,得在 Controller 请求中多次调用那个加了缓存的 HystrixCommand 命令。

Hystrix 的书上写的是:在同一用户请求的上下文中,相同依赖服务的返回数据始终保持一致。在当次请求内对同一个依赖进行重复调用,只会真实调用一次。在当次请求内数据可以保证一致性。

测试请求缓存

通过注解为请求命令开启缓存功能非常简单,如下例所示,我们只需添加@CacheResult 注解即可。当该依赖服务被调用并返回 User 对象时,Hystrix 会将该结果置入请求缓存中,而 它的缓存 Key 值默认会使用所有的参数,也就是这里 String 类型的 id 值。若要为请求命令指定具体的缓存 Key 生成规则,我们可以使用@CacheResult 的 cacheKeyMethod 属性来指定具体的生成函数;也可以通过使 @CacheKey 注解 在方法参数中指定用于组装缓存 Key 的元素。这里我们使用 @CacheKey 注解(由于只有一个参数, 这个注解其实也可以直接省略):

    @CacheResult
    public String sayHello(@CacheKey String msg) {
        log.info("未走缓存 ↓");
        return  msg;
    }
    // fallback
    public String hiConsumerFallBack(String msg) {
        return "hi," + msg + ",sorry,error!";
    }

    /**
     * 使用注解请求缓存 方式1
     *
     * @CacheResult 标记这是一个缓存方法,结果会被缓存
     */

    @HystrixCommand
    @CacheRemove(commandKey = "testCommand")
    public String sayGoodbye(@CacheKey String msg) {
        log.info("清除缓存 ↓");
        return msg;
    }

    // test say hello
    @RequestMapping(value = "/hello/test", method = RequestMethod.GET)
    String sayHelloTest() {
        // @Cleanup  HystrixRequestContext hystrixRequestContext = HystrixRequestContext.initializeContext();
        log.info(sayHello("123")); 
        log.info(sayHello("123"));
        log.info(sayHello("321"));
        log.info(sayGoodbye("123"));
        log.info(sayHello("123"));
        log.info(sayHello("321"));
        return "测试";
    }

测试

未走缓存 ↓
123
未走缓存 ↓
123
未走缓存 ↓
321
清除缓存 ↓
123
未走缓存 ↓
123
未走缓存 ↓
321

发现所有调用都没有走缓存.

实践中发现, 必须通过实例来调用标记缓存的方法缓存才会生效, 也就是说必须通过new 或者自动装配的方式先获取标记缓存的方法所在类的实例, 然后通过实例调用缓存的方法, 缓存才可以生效.

修改如下:

把缓存方法放到service层:

@Slf4j
@Service("orderServiceCache")
public class OrderServiceCacheImpl {

    // LOGGER
    private static final Logger LOGGER = LoggerFactory.getLogger(OrderServiceCacheImpl.class);

    @Resource
    private RestTemplate restTemplate;

    @CacheResult(cacheKeyMethod = "getMsg")
    public String sayHello(@CacheKey String msg) {
        log.info("未走缓存 ↓");
        return  msg;
    }

    @HystrixCommand
    @CacheRemove(commandKey = "testCommand")
    public String sayGoodbye(@CacheKey String msg) {
        log.info("清除缓存 ↓");
        return msg;
    }
}

测试方法:

@Slf4j // lombok
@RestController
@RequestMapping("/consumer")
public class HystrixConsumerController03 {
    ...

    @RequestMapping(value = "/hello/test", method = RequestMethod.GET)
    String sayHelloTest() {
        // @Cleanup  HystrixRequestContext hystrixRequestContext = HystrixRequestContext.initializeContext();
        log.info(orderServiceCache.sayHello("123")); // 首次调用123不走缓存
        log.info(orderServiceCache.sayHello("123")); // 第二次调用123走缓存
        log.info(orderServiceCache.sayHello("321")); // 首次调用312不走缓存
        log.info(orderServiceCache.sayGoodbye("123")); // 清除123缓存
        log.info(orderServiceCache.sayHello("123"));  // 123缓存已清除,不走缓存
        log.info(orderServiceCache.sayHello("321")); //  321走缓存
        return "测试";
    }

测试接口:

GET http://localhost:9010/consumer/hello/test

输出:

未走缓存 ↓
123
123
未走缓存 ↓
321
清除缓存 ↓
123
未走缓存 ↓
123
321

使用cacheKeyMethod 定义缓存 Key

当使用注解来定义请求缓存时,若要为请求命令指定具体的缓存 Key 生成规则,我们可以使用@CacheResult 的 cacheKeyMethod 属性来指定具体的生成函数;也可以通过使 @CacheKey 注解 在方法参数中指定用于组装缓存 Key 的元素。

需要注意,@CacheKey 注解的优先级比 cacheKeyMethod 的优先级低,如果己经使用 cacheKeyMethod 指定缓存 Key 的生成函数,那么@CacheKey 注解不会生效。


使用 cacheKeyMethod 方法的示例如下,它通过在Hystrix命令的同一个类中定义一个专门生成 Key 的方法,并用@CacheResult 注解的 cacheKeyMethod 方法来指定它即可。

@Slf4j
@Service("orderServiceCache")
public class OrderServiceCacheImpl {

    @Resource
    private RestTemplate restTemplate;

    /**
     * 使用注解请求缓存 方式1
     *
     * @CacheResult 标记这是一个缓存方法,结果会被缓存
     */
    @CacheResult(cacheKeyMethod = "getCacheKey")
    @HystrixCommand(commandKey = "commandKey1")
    public Integer openCacheByAnnotation1(Long id) {
        //此次结果会被缓存
        return restTemplate.getForObject("http://orderservice/hystrix/cache/randomInt", Integer.class);
    }
    /**
     * 第一种方法没有使用@CacheKey注解,而是使用这个方法进行生成cacheKey的替换办法
     * 这里有两点要特别注意:
     * 1、这个方法的入参的类型必须与缓存方法的入参类型相同,如果不同被调用会报这个方法找不到的异常
     * 2、这个方法的返回值一定是String类型
     */
    public String getCacheKey(Long id) {
        return String.valueOf(id);
    }

}
缓存清理

在之前的例子中,我们己经通过 @CacheResult 注解将请求结果置入 Hystrix 的请求缓存之中。如果对缓存的内容进行修改或者删除操作,那么此时请求缓存中的结果与实际结果就会产生不一致(缓存中的结果实际上己经过期失效了),所以我们需要在这类操作上对失效的缓存进行清理。在 Hystrix 的注解配置中, 可以通注解来实现失效缓存的清理,因此需要再加上这个方法:


    /**
     * 使用注解清除缓存 方式1
     *
     * @CacheRemove 必须指定commandKey才能进行清除指定缓存
     */
    @CacheRemove(commandKey = "commandKey1", cacheKeyMethod = "getCacheKey")
    @HystrixCommand
    public void flushCacheByAnnotation1(Long id) {
        log.info("请求缓存已清空!");
        //这个@CacheRemove注解直接用在更新方法上效果更好
    }

需要注意的是,@CacheRemove 注解的 commandKey 属性是必须要指定的,它用来指明需要使用请求缓存的请求命令,因为只有通过该属性的配置,Hystrix 才能找到正确的请求命令缓存位置。

测试控制器

@RestController
@RequestMapping("/hystrix/cache/")
public class HystrixCacheTest {
    // LOGGER
    private static final Logger LOGGER = LoggerFactory.getLogger(HystrixCacheTest.class);

    @Resource
    private OrderServiceCacheImpl service;

    /**
     * 为了请求测试Hystrix请求缓存提供的返回随机数的接口
     */
    @GetMapping("randomInt")
    public Integer getRandomInteger(){
        Random random = new Random();
        int randomInt = random.nextInt(99999);
        return randomInt;
    }

    /**
     * 注解方式请求缓存,第一种
     */
    @GetMapping("/cacheAnnotation1")
    public void openCacheByAnnotation1(){
        //初始化Hystrix请求上下文, 也可以添加过滤器
        // HystrixRequestContext.initializeContext();
        //访问并开启缓存
        Integer result1 = service.openCacheByAnnotation1(1L);
        Integer result2 = service.openCacheByAnnotation1(1L);
        LOGGER.info("first request result is:{} ,and secend request result is: {}", result1, result2);
        //清除缓存
        service.flushCacheByAnnotation1(1L);
        //再一次访问并开启缓存
        Integer result3 = service.openCacheByAnnotation1(1L);
        Integer result4 = service.openCacheByAnnotation1(1L);
        LOGGER.info("first request result is:{} ,and secend request result is: {}", result3, result4);
    }
}

测试:

GET http://localhost:9010/hystrix/cache/cacheAnnotation1

日志输出:

image-20240413233357924


请求合并

微服务架构中的依赖通常通过远程调用实现,而远程调用中最常见的问题就是通信消耗与连接数占用。在高并发的情况之下,因通信次数的增加,总的通信时间消耗将会变得不那么理想。同时,因为依赖服务的线程池资源有限,将出现排队等待与响应延迟的情况。所以在资源有限并且短时间内会产生高并发请求的时候,为避免连接不够用而引起的延迟可以考虑使用请求合并器的方式来处理和优化优化这两个问题,Hystrix 提供了 HystrixCollapser 来实现请求的合并,以减少通 信消耗和线程数的占用。


HystrixCollapser 实现了在 HystrixCommand 之前放置一个合并处理器,将处于一个很短的时间窗(默认 10 毫秒)内对同一依赖服务的多个请求进行整合并以批量方式 发起请求的功能(服务提供方也需要提供相应的批量实现接口)。通过 HystrixCollapser 的封装,开发者不需要关注线程合并的细节过程,只需关注批量化服务和处理。
在快速入门的例子中,我们使用@HystrixCommand 注解优雅地实现了 HystrixCommand 的定义,那么对于请求合并器是否也可以通过注解来定义呢?答案是肯定的!


使用注解进行请求合并
@GetMapping("/findUser/{id}")
@HystrixCollapser(
    batchMethod = "findAll", collapserProperties = {
        @HystrixProperty(name = "timerDelayInMilliseconds", value = "100")
    })
public User findUser(@PathVariable("id") String id) {
    return null;
}

@HystrixCommand()
public List<User> findAll(List<String> ids) {
    User[] forObject = restTemplate.getForObject("http://userservice/user/findAll/{1}", User[].class, StringUtils.join(ids, ","));
    assert forObject != null;
    return Arrays.asList(forObject);
}

我们之前己经介绍过 @HystrixCommand 了,可以看到,这里通过它定义了两个 Hystrix 命令,一个用于请求/users/{id} 接口,一个用于请求/users?ids={ids}接口。

而在请求/users/{id}接口的方法上通过@HystrixCollapser 注解为其创建了合并请求器,通过 batchMethod 属性指定了批量请求的实现方法为 findAll 方法(即请求 /users?ids={ids}接口的命令),同时通过 collapserProperties 属性为合并请求器设置了相关属性,这里使用@HystrixProperty(name=”timerDelayInMilliseconds”, value = ”100”)将合并时间窗设置为 100 毫秒。

这样通过注解简单而又优雅地实现了在/users/{id}依赖服务之前设置了一个批量请求合并器。

测试:

GET http://localhost:9010/consumer/findUser/1
GET http://localhost:9010/consumer/findUser/2
GET http://localhost:9010/consumer/findUser/3
GET http://localhost:9010/consumer/findUser/4
GET http://localhost:9010/consumer/findUser/5
GET http://localhost:9010/consumer/findUser/6

orderservice输出日志:

请求合并的ID: [1]
请求合并的ID: [2]
请求合并的ID: [3]
请求合并的ID: [4]
请求合并的ID: [5]
请求合并的ID: [6]

可见并没有发生请求合并.

为什么请求合并没有生效

需要注意:

  1. @HystrixCollapser中有scope属性,scope的取值为REQUEST, GLOBAL
    • REQUEST范围只对一个request请求内的多次服务请求进行合并
    • GLOBAL是多单个应用中的所有线程的请求中的多次服务请求进行合并。
  2. 同步请求,不会进行请求合并。只有异步请求才会发生请求合并

修改如下:

@RestController
@RequestMapping("/consumer")
public class HystrixConsumerController04 {
    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("/findUser/{id}")
    @HystrixCollapser(
            batchMethod = "findAll", scope = com.netflix.hystrix.HystrixCollapser.Scope.GLOBAL ,collapserProperties = {
            @HystrixProperty(name = "timerDelayInMilliseconds", value = "3000"),
            @HystrixProperty( name = "maxRequestsInBatch", value = "100")
    })
    public User findUser(@PathVariable("id") String id) {
        return null;
    }

    @HystrixCommand
    public List<User> findAll(List<String> ids) {
        System.out.println("请求合并的ID: " + ids);
        User[] forObject = restTemplate.getForObject("http://userservice/user/findAll/{1}", User[].class, StringUtils.join(ids, ","));
        assert forObject != null;
        return Arrays.asList(forObject);
    }

测试, 可以使用线程池来模拟异步请求:

    public static void main(String[] args) throws InterruptedException, IOException {
        CloseableHttpClient httpClient = HttpClients.custom().setMaxConnPerRoute(100).build();
        String url = "http://localhost:9010/consumer/findUser/";
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        int requestCount = 6;
        for(int i = 1;i <= requestCount;i++){
            final int id = i;
            executorService.execute(() -> {
                try{
                    HttpGet httpGet = new HttpGet(url + id);
                    httpGet.addHeader("Content-Type",  "application/json; charset=UTF-8");
                    HttpResponse response = httpClient.execute(httpGet);
                    HttpEntity entity = response.getEntity();
                    InputStream inputStream = entity.getContent();
                    InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
                    BufferedReader bufferedReader = new BufferedReader(reader);
                    String line;
                    StringBuilder stringBuilder = new StringBuilder();
                    while ((line = bufferedReader.readLine())  != null) {
                        stringBuilder.append(line);
                    }
                    String responseBody = stringBuilder.toString();
                    System.out.println(responseBody);
                }catch (Exception e){
                    e.printStackTrace();
                }
            });
        }

        Thread.sleep(1000*10);
        executorService.shutdown();
        httpClient.close();
    }

orderservice输出日志:

请求合并的ID: [2, 6, 5, 3, 1, 4]

main方法输出日志:

{"id":6,"username":"范兵兵","address":"山东省青岛市","serverPort":"9020"}
{"id":2,"username":"文二狗","address":"陕西省西安市","serverPort":"9020"}
{"id":5,"username":"郑爽爽","address":"辽宁省沈阳市大东区","serverPort":"9020"}
{"id":1,"username":"柳岩","address":"湖南省衡阳市","serverPort":"9020"}
{"id":4,"username":"张必沉","address":"天津市","serverPort":"9020"}
{"id":3,"username":"华沉鱼","address":"湖北省十堰市","serverPort":"9020"}
...

请求合并的额外开销

虽然通过请求合并可以减少请求的数量以缓解依赖服务线程池的资源,但是在使用的时候也需要注意它所带来的额外开销:用于请求合并的延迟时间窗会使得依赖服务的请求延迟增高。比如,某个请求不通过请求合并器访问的平均耗时为 5ms,请求合并的延迟时 间窗为 10ms (默认值),那么当该请求设置了请求合并器之后,最坏情况下(在延迟时间 窗结束时才发起请求)该请求需要 15ms 才能完成。


由于请求合并器的延迟时间窗会带来额外开销,所以我们是否使用请求合并器需要根据依赖服务调用的实际情况来选择,主要考虑下面两个方面。

  • 请求命令本身的延迟。如果依赖服务的请求命令本身是一个高延迟的命令,那么可以使用请求合并器,因为延迟时间窗的时间消耗显得微不足道了。
  • 延迟时间窗内的并发量。如果一个时间窗内只有 1〜2 个请求,那么这样的依赖服务不适合使用请求合并器。这种情况不但不能提升系统性能,反而会成为系统瓶颈,因为每个请求都需要多消耗一个时间窗才响应。相反,如果一个时间窗内具有很高 的并发量,并且服务提供方也实现了批量处理接口,那么使用请求合并器可以有效减少网络连接数量并极大提升系统吞吐量,此时延迟时间窗所增加的消耗就可以忽略不计了。

属性详解

@HystrixCommand(fallbackMethod = "fallbackMethod", groupKey = "strGroupCommand", commandKey = "strCommand", threadPoolKey = "strThreadPool",
  commandProperties = {
    // 设置隔离策略,THREAD 表示线程池 SEMAPHORE:信号池隔离
    @HystrixProperty(name = "execution.isolation.strategy", value = "THREAD"),
    // 当隔离策略选择信号池隔离的时候,用来设置信号池的大小(最大并发数)
    @HystrixProperty(name = "execution.isolation.semaphore.maxConcurrentRequests", value = "10"),
    // 配置命令执行的超时时间
    @HystrixProperty(name = "execution.isolation.thread.timeoutinMilliseconds", value = "10"),
    // 是否启用超时时间
    @HystrixProperty(name = "execution.timeout.enabled", value = "true"),
    // 执行超时的时候是否中断
    @HystrixProperty(name = "execution.isolation.thread.interruptOnTimeout", value = "true"),
    // 执行被取消的时候是否中断
    @HystrixProperty(name = "execution.isolation.thread.interruptOnCancel", value = "true"),
    // 允许回调方法执行的最大并发数
    @HystrixProperty(name = "fallback.isolation.semaphore.maxConcurrentRequests", value = "10"),
    // 服务降级是否启用,是否执行回调函数
    @HystrixProperty(name = "fallback.enabled", value = "true"),

    // 是否启用断路器
    @HystrixProperty(name = "circuitBreaker.enabled", value = "true"),
    // 该属性用来设置在滚动时间窗中,断路器熔断的最小请求数。例如,默认该值为 20 的时候,如果滚动时间窗(默认10秒)内仅收到了19个请求, 即使这19个请求都失败了,断路器也不会打开。
    @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
    // 该属性用来设置在滚动时间窗中,表示在滚动时间窗中,在请求数量超过 circuitBreaker.requestVolumeThreshold 的情况下,如果错误请求数的百分比超过50, 就把断路器设置为 "打开" 状态,否则就设置为 "关闭" 状态。
    @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
    // 该属性用来设置当断路器打开之后的休眠时间窗。 休眠时间窗结束之后,会将断路器置为 "半开" 状态,尝试熔断的请求命令,如果依然失败就将断路器继续设置为 "打开" 状态,如果成功就设置为 "关闭" 状态。
    @HystrixProperty(name = "circuitBreaker.sleepWindowinMilliseconds", value = "5000"),
    // 断路器强制打开
    @HystrixProperty(name = "circuitBreaker.forceOpen", value = "false"),
    // 断路器强制关闭
    @HystrixProperty(name = "circuitBreaker.forceClosed", value = "false"),

    // 滚动时间窗设置,该时间用于断路器判断健康度时需要收集信息的持续时间
    @HystrixProperty(name = "metrics.rollingStats.timeinMilliseconds", value = "10000"),
    // 该属性用来设置滚动时间窗统计指标信息时划分"桶"的数量,断路器在收集指标信息的时候会根据设置的时间窗长度拆分成多个 "桶" 来累计各度量值,每个"桶"记录了一段时间内的采集指标。
    // 比如 10 秒内拆分成 10 个"桶"收集这样,所以 timeinMilliseconds 必须能被 numBuckets 整除。否则会抛异常
    @HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "10"),
    // 该属性用来设置对命令执行的延迟是否使用百分位数来跟踪和计算。如果设置为 false, 那么所有的概要统计都将返回 -1。
    @HystrixProperty(name = "metrics.rollingPercentile.enabled", value = "false"),
    // 该属性用来设置百分位统计的滚动窗口的持续时间,单位为毫秒。
    @HystrixProperty(name = "metrics.rollingPercentile.timeInMilliseconds", value = "60000"),
    // 该属性用来设置百分位统计滚动窗口中使用 “ 桶 ”的数量。
    @HystrixProperty(name = "metrics.rollingPercentile.numBuckets", value = "60000"),
    // 该属性用来设置在执行过程中每个 “桶” 中保留的最大执行次数。如果在滚动时间窗内发生超过该设定值的执行次数,
    // 就从最初的位置开始重写。例如,将该值设置为100, 滚动窗口为10秒,若在10秒内一个 “桶 ”中发生了500次执行,
    // 那么该 “桶” 中只保留 最后的100次执行的统计。另外,增加该值的大小将会增加内存量的消耗,并增加排序百分位数所需的计算时间。
    @HystrixProperty(name = "metrics.rollingPercentile.bucketSize", value = "100"),
    // 该属性用来设置采集影响断路器状态的健康快照(请求的成功、 错误百分比)的间隔等待时间。
    @HystrixProperty(name = "metrics.healthSnapshot.intervalinMilliseconds", value = "500"),

    // 是否开启请求缓存
    @HystrixProperty(name = "requestCache.enabled", value = "true"),
    // HystrixCommand的执行和事件是否打印日志到 HystrixRequestLog 中
    @HystrixProperty(name = "requestLog.enabled", value = "true"),
  },
  threadPoolProperties = {
    // 该参数用来设置执行命令线程池的核心线程数,该值也就是命令执行的最大并发量
    @HystrixProperty(name = "coreSize", value = "10"),
    // 该参数用来设置线程池的最大队列大小。当设置为 -1 时,线程池将使用 SynchronousQueue 实现的队列,否则将使用 LinkedBlockingQueue 实现的队列。
    @HystrixProperty(name = "maxQueueSize", value = "-1"),
    // 该参数用来为队列设置拒绝阈值。 通过该参数, 即使队列没有达到最大值也能拒绝请求。
    // 该参数主要是对 LinkedBlockingQueue 队列的补充,因为 LinkedBlockingQueue 队列不能动态修改它的对象大小,而通过该属性就可以调整拒绝请求的队列大小了。
    @HystrixProperty(name = "queueSizeRejectionThreshold", value = "5"),
  }
)
public String strConsumer() {
 ResponseEntity<String> result = restTemplate.getForEntity("http://cloud-eureka-client/hello", String.class);
 return result.getBody();
}

各属性配置指南 https://www.cnblogs.com/throwable/p/11961016.html

在之前介绍 Hystrix 的使用方法时,己经涉及过一些 Hystrix 属性的配置,我们可以根据实现 HystrixCommand 的不同方式将配置方法分为如下两类:

  • 当通过继承的方式实现时,可使用 Setter 对象来对请求命令的属性进行设置

  • 当通过注解的方法实现时,只需使用@HystrixCommand 中的 commandProperties 属性来设置


实际上,Hystrix 为我们提供的配置内容和配置方式远不止上面这些,它提供了非常丰富和灵活的配置方法,下面我们将以注解为例,详解介绍各项配置属性。在具体说明这些属性之前,我们需要了解一下这些属性都存在下面 4 个不同优先级别的配置(优先级由低到高)

  • 全局默认值:如果没有设置下面三个级别的属性,那么这个属性就是默认值。由于该属性通过代码定义,所以对于这个级别,我们主要关注它在代码中定义的默认值即可。

  • 全局配置属性:通过在配置文件中定义全局属性值,在应用启动时或在与 SpringCloud Config 和 Spring Cloud Bus 实现的动态刷新配置功能配合下,可以实现对“全局默认值”的覆盖以及在运行期对“全局默认值”的动态调整。


  • 实例默认值:通过代码为实例定义的默认值。通过代码的方式为实例设置属性值来覆盖默认的全局配置。

  • 实例配置属性:通过配置文件来为指定的实例进行属性配置,以覆盖前面的三个默认值。

  • 通过理解 Hystrix 4 个级别的属性配置,对设置 Hystrix 的默认值以及在线上如何根据 实际情况去调整配置非常有帮助,下面我们来具体看看它有哪些具体的属性配置。


commandProperties 属性

Command 属性主要用来控制 HystrixCommand 命令的行为。

它主要有下面 5 种不同类型的属性配置。


1.execution 配置
  • execution 配置控制的是 HystrixCommand.run()的执行。

  • execution.isolation.strategy:该属性用来设置 HystrixCommand. run ()执行的隔离策略,它有如下两个选项:

    • THREAD:通过线程池隔离的策略。它在独立的线程上执行,并且它的并发限制受线程池中线程数量的限制。
    • SEMAPHORE:通过信号量隔离的策略。它在调用线程上执行,并且它的并发限制受信号量计数的限制。

属性级别 默认值、配置方式、配置属性
全局默认值 THREAD
全局配置属性 hystrix.command.default.execution.isolation.strategy
实例默认值 可通过注解设置: @HystrixProperty(name="execution.isolation.strategy",value="THREAD")
实例配置属性 hystrix.command.[HystrixCommandKey].execution.isolation.strategy

execution.isolation.thread,timeoutInMilliseconds:该属性用来配置 HystrixCommand 执行的超时时间,单位为毫秒。当 HystrixCommand 执行时间超过该配置值之后,Hystrix 会将该执行命令标记为 TIMEOUT 并进入服务降级处理逻辑。

属性级别 默认值、配置方式、配置属性
全局配置属性 hystrix.command.default.execution<br>.isolation.thread.timeoutInMilliseconds
实例默认值 可通过注解设置, 例如@HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds",value="1000")
实例配置属性 hystrix.command.[HystrixCommandKey].execution<br>.isolation.thread.timeoutInMilliseconds

execution.timeout.enabled:该属性用来配置 HystrixCommand.run()的执行是否启用超时时间。默认为 true,如果设置为 false,那么 execution.Isolation.thread.timeoutInMilliseconds 属性的配置将不再起作用。

属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.execution.timeout.enabled
实例默认值 可通过@HystrixProperty(name="execution.timeout.enabled",value="false")注解设置
实例配置属性 hystrix.command.[HystrixCommandKey].execution.timeout.enabled

execution.isolation.thread.interruptOnTimeout:该属性用来配置当 HystrixCommand.run()执行超时的时候是否要将它中断

属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.execution.isolation.thread.interruptOnTimeout
实例默认值 可通过@HystrixProperty(name="execution.isolation.thread.interruptOnTimeout",value="false")注解设置
实例配置属性 hystrix.command.[HystrixCommandKey].execution.isolation.thread.interruptOnTimeout

execution.Isolation.semaphore.maxConcurrentRequests: 当 HystrixCommand 的隔离策略使用信号量的时候,该属性用来配置信号量的大小(并发请求数)。当最大并发请求数达到该设置值时,后续的请求将会被拒绝。

属性级别 默认值、配置方式、配置属性
全局默认值 10
全局配置属性                 hystrix.command.default.execution.isolation.semaphore.maxConcurrentRequests
实例默认值 可通过注解设置:
@HystrixProperty(name="execution.isolation.semaphore.maxConcurrentRequests",value="2")
实例配置属性 hystrix.command.[HystrixCommandKey].execution.isolation.semaphore.maxConcurrentRequests

2.fallback 配置

下面这些属性用来控制 HystrixCommand.getFallback()的执行。这些属性同时适用于线程池的信号量的隔离策略。


fallback.isolation.semaphore.maxConcurrentRequests:该属性用来设置从调用线程中允许 HystrixCommand.getFallback()方法执行的最大并发请求数。当达到最大并发请求数时,后续的请求将会被拒绝并抛出异常(因为它己经没有后续的 fallback 可以被调用了)。

属性级别 默认值、配置方式、配置属性
全局默认值 10
全局配置属性                 hystrix.command.default.fallback.isolation.semaphore.maxConcurrentRequests
实例默认值 可通过注解设置: @HystrixProperty(name="fallback.isolation.semaphore.maxConcurrentRequests",value="20")
实例配置属性 hystrix.command.HystrixCommandKey.fallback.isolation.semaphore.maxConcurrentRequests

fallback.enabled:该属性用来设置服务降级策略是否启用,如果设置为 false,那么当请求失败或者拒绝发生时,将不会调用 HystrixCommand.getFallback() 来执行服务降级逻辑。

属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.fallback.enabled
实例默认值 可通过注解设置@HystrixProperty(name="fallback.enabled",value="false")
实例配置属性 hystrix.command.[HystrixCommandKey].fallback.enabled

3.circuitBreaker 配置

下面这些是断路器的属性配置,用来控制 HystrixCircuitBreaker 的行为。


  • circuitBreaker.enabled:该属性用来确定当服务请求命令失败时,是否使用断路器来跟踪其健康指标和熔断请求。
属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.circuitBreaker.enabled
实例默认值 可通过注解设置: @HystrixProperty(name="circuitBreaker.enabled",value="false")
实例配置属性 hystrix.command.[HystrixCommandKey].circuitBreaker.enabled

  • circuitBreaker.requestVolumeThreshold:该属性用来设置在滚动时间窗中,断路器熔断的最小请求数。例如,默认该值为 20 的时候,如果滚动时间窗(默 认 10 秒)内仅收到了 19 个请求,即使这 19 个请求都失败了,断路器也不会打开。
属性级别 默认值、配置方式、配置属性
全局默认值 20
全局配置属性 hystrix.command.default.circuitBreaker.requestVolumeThreshold
实例默认值 可通过注解设置: @HystrixProperty(name="circuitBreaker.requestVolumeThreshold",value="30")
实例配置属性 hystrix.command.HystrixCommandKey.circuitBreaker.requestVolumeThreshold

  • circuitBreaker.sleepWindowInMilliseconds:该属性用来设置当断路器 打开之后的休眠时间窗。休眠时间窗结束之后,会将断路器置为“半幵”状态,尝试熔断的请求命令,如果依然失败就将断路器继续设置为“打开”状态,如果成功 就设置为“关闭”状态。
属性级别 默认值、配置方式、配置属性
全局默认值 5000
全局配置属性
实例默认值 可通过@HystrixProperty(name="circuitBreaker.sleepWindowInMilliseconds",value="3000")注解设置
实例配置属性 hystrix.command.[HystrixCommandKey].circuitBreaker.sleepWindowInMilliseconds

  • circuitBreaker.errorThresholdPercentage:该属性用来设置断路器打开的错误百分比条件。例如,默认值为 50%的情况下,表示在滚动时间窗中,在请求数量超过 circuitBreaker.requestVolumeThreshold 阈值的前提下,如果错误请求数的百分比超过 50,就把断路器设置为“打开”状态,否则就设置为“关闭”状态。
属性级别 默认值、配置方式、配置属性
全局默认值 50
全局配置属性 hystrix.command.default.circuitBreaker.errorThresholdPercentage
实例默认值 可通过@HystrixProperty(name="circuitBreaker.errorThresholdPercentage",value="40")注解设置
实例配置属性 hystrix.command.[HystrixCommandKey].circuitBreaker.errorThresholdPercentage

  • circuitBreaker.forceOpen:如果将该属性设置为 true,断路器将强制进入“打开”状态,它会拒绝所有请求。该属性优先于 circuitBreaker.forceClosed 属性。
属性级别 默认值、配置方式、配置属性
全局默认值 false
全局配置属性 hystrix.command.default.circuitBreaker.forceOpen
实例默认值 可通过注解设置: @HystrixProperty(name="circuitBreaker.forceOpen",value="true")
实例配置属性 hystrix.command.[HystrixCommandKey].circuitBreaker.forceOpen

  • circuitBreaker. forceClosed:如果将该属性设置为 true,断路器将强制进入 “关闭”状态,它会接收所有请求。如果 circuitBreaker.forceOpen 属性为 true,该属性不会生效。
属性级别 默认值、配置方式、配置属性
全局默认值 false
全局配置属性 hystrix.command.default.circuitBreaker.forceClosed
实例默认值 可通过注解设置: @HystrixProperty(name="circuitBreaker.forceClosed",value="true")
实例配置属性 hystrix.command.[HystrixCommandKey].circuitBreaker.forceClosed

4.metrics 配置

下面的属性均与 HystrixCommand 和 HystrixObservableCommand 执行中捕获的指标信息有关。


  • metrics.rollingstats.timelnMilliseconds:该属性用来设置滚动时间窗的长度,单位为毫秒。该时间用于断路器判断健康度时需要收集信息的持续时间。断路器在收集指标信息的时候会根据设置的时间窗长度拆分成多个“桶”来累计各度量值,每个“桶”记录了一段时间内的采集指标。例如,当采用默认值 10000 毫 秒时,断路器默认将其拆分成 10 个桶(桶的数量也可通过 metrics.rollingStats.numBuckets 参数设置),每个桶记录 1000 毫秒内的指标信息。
属性级别 默认值、配置方式、配置属性
全局默认值 10000
全局配置属性 hystrix.command.default.metrics.rollingStats.timeInMilliseconds
实例默认值 可通过注解设置: @HystrixProperty(name="metrics.rollingStats.timeInMilliseconds",value="20000")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingStats.timeInMilliseconds

注意:

该属性从 Hystrix 1.4.2 版本开始(Brixton.SR5 版本中使用了 1.5.3 版本),只有在应用初始化的时候生效,通过动态刷新配置不会产生效果,这样做是为了避免出现运行期检测数据丢失的情况。


  • metrics.rollingStats.numBuckets:该属性用来设置滚动时间窗统计指标信息时划分“桶”的数量。
属性级别 默认值、配置方式、配置属性
全局默认值 10
全局配置属性 hystrix.command.default.metrics.rollingStats.numBuckets
实例默认值 可通过注解设置 @HystrixProperty(name="metrics.rollingStats.numBuckets",value="20")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingStats.numBuckets

注意:

metrics.rollingStats.timeInMilliseconds 参数的设置必须能够被 metrics.rollingStats.numBuckets 参数整除,不然将拋出异常。该参数与 metrics.rollingStats.timelnMilliseconds —样,从 Hystrix 1.4.12 版本开始(Brixton.SR5 版本中使用了 1.5.3 版本),只有在应用初始化的时候生效,通过动态刷新配置不会产生效果,这样做是为了避免出现运行期监测数据丢失的情况。


  • metrics.rollingPercentile.enabled:该属性用来设置对命令执行的延迟是否使用百分位数来跟踪和计算。如果设置为 false,那么所有的概要统计都将返回-1
属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.metrics.rollingPercentile.enabled
实例默认值 可通过注解设置@HystrixProperty(name="metrics.rollingPercentile.enabled",value="false")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingPercentile.enabled

  • metrics.rollingPercentile.timelnMilliseconds:该属性用来设置百分位统计的滚动窗口的持续时间,单位为毫秒。
属性级别 默认值、配置方式、配置属性
全局默认值 60000
全局配置属性 hystrix.command.default.metrics.rollingPercentile.timelnMilliseconds
实例默认值 可通过注解设置@HystrixProperty(name="metrics.rollingPercentile.timelnMilliseconds",value="50000")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingPercentile.timelnMilliseconds

注意:

该属性从 Hystrix 1.4.12 版本开始(Brixton.SR5 版本中使用了 1.5.3 版本),只有在应用初始化的时候生效,通过动态刷新配置不会产生效果,这样做是为了避免出现运行期监测数据丢失的情况。


  • metrics.rollingPercentile.numBuckets:该属性用来设置百分位统计滚动窗口中使用“桶”的数量。
属性级别 默认值、配置方式、配置属性
全局默认值 6
全局配置属性 hystrix.command.default.metrics.rollingPercentile.numBuckets
实例默认值 可通过注解设置@HystrixProperty(name="metrics.rollingPercentile.numBuckets",value="5")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingPercentile.numBuckets

注意:

metrics.rollingPercentile.timelnMilliseconds 参数的设置必须能够被 metrics.rollingPercentile.numBuckets 参数整除,不然将会抛出异常。该属性从 Hystrix 1.4.12 版本开始(Brixton.SR5 版本中使用了 1.5.3 版本),只有在应用初始化的时候生效,通过动态刷新配置不会产生效果,这样做 是为了避免出现运行期监测数据丢失的情况。


  • metrics.rollingPercentile.bucketSize:该属性用来设置在执行过程中 每个“桶”中保留的最大执行次数。如果在滚动时间窗内发生超过该设定值的执行次数,就从最初的位置开始重写。例如,将该值设置为 100,滚动窗口为 10 秒,若在 10 秒内一个“桶”中发生了 500 次执行,那么该“桶”中只保留最后的 100 次执行的统计。另外,增加该值的大小将会增加内存量的消耗,并增加排序百分位数所需的计算时间。
属性级别 默认值、配置方式、配置属性
全局默认值 100
全局配置属性 hystrix.command.default.metrics.rollingPercentile.bucketSize
实例默认值 可通过注解设置@HystrixProperty(name="metrics.rollingPercentile.bucketSize",value="120")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.rollingPercentile.bucketSize

注意:
该属性从 Hystrix 1.4.12 版本开始(Brixton.SR5 版本中使用了 L5.3 版本),只有在应用初始化的时候生效,通过动态刷新配置不会产生效果,这样做是为了避免出现运行期监测数据丢失的情况。


  • metrics.healthSnapshot.intervalInMilliseconds:该属性用来设置采集影响断路器状态的健康快照(请求的成功、错误百分比)的间隔等待时间。
属性级别 默认值、配置方式、配置属性
全局默认值 500
全局配置属性 hystrix.command.default.metrics.healthSnapshot.intervalInMilliseconds
实例默认值 可通过注解设置@HystrixProperty(name="metrics.healthSnapshot.intervalInMilliseconds",value="600")
实例配置属性 hystrix.command.[HystrixCommandKey].metrics.healthSnapshot.intervalInMilliseconds

5.requestContext 配置

下面这些属性涉及 HystrixCommand 使用的 HystrixRequestContext 的设置。


  • requestCache.enabled:此属性用来配置是否开启请求缓存。
属性级别 默认值、配置方式、配置属性
全局默认值 true
全局配置属性 hystrix.command.default.requestCache.enabled
实例默认值 可通过注解设置@HystrixProperty(name="requestCache.enabled",value="false")
实例配置属性 hystrix.command.[HystrixCommandKey].requestCache.enabled

  • requestLog.enabled:该属性用来设置 HystrixCommand 的执行和事件是否打印日志到 HystrixRequestLog 中。
  • collapser 属性该属性除了在代码中用 set 和配置文件配置之外,也可使用注解进行配置。可使用@HystrixCollapser 中的 collapserProperties 属性来设置:
@HystrixCollapser (batchMethod ="batch1",collapserProperties ={
    @HystrixProperty(name="timerDelayInMilliseconds", value ="20")
})

下面这些属性用来控制命令合并相关的行为。

  • maxRequestsInBatch:该参数用来设置一次请求合并批处理中允许的最大请求数
属性级别 默认值、配置方式、配置属性
全局默认值 Integer.MAX_VALUE
全局配置属性 hystrix.collapser.default.maxRequestsInBatch
实例默认值 可通过注解设置@HystrixProperty(name="maxRequestsInBatch",value="100000")
实例配置属性 hystrix.collapser.[HystrixCollapserKey].maxRequestsInBatch

  • timerDelaylnMilliseconds:该参数用来设置批处理过程中每个命令延迟的时间,单位为毫秒。
属性级别 默认值、配置方式、配置属性
全局默认值 10
全局配置属性 hystrix.collapser.default.timerDelaylnMilliseconds
实例默认值 可通过注解设置@HystrixProperty(name="timerDelaylnMilliseconds",value="20")
实例配置属性 hystrix.collapser.[HystrixCollapserKey].timerDelaylnMilliseconds

threadPool 属性

该属性除了在代码中用 set 和配置文件配置之外,还可使用注解进行配置。可使用@HystrixCommand 中的 threadPoolProperties 属性来设置,比如:

@HystrixCommand(fallbackMethod ="helloFallback" commandKey = "helloKey",threadPoolProperties = {
    @HystrixProperty(name="coreSize",value ="20")
})

下面这些属性用来控制 Hystrix 命令所属线程池的配置。


  • coreSize:该参数用来设置执行命令线程池的核心线程数,该值也就是命令执行的最大并发量。
属性级别 默认值、配置方式、配置属性
全局默认值 10
全局配置属性 hystrix.threadpool.default.coreSize
实例默认值 可通过注解设置@HystrixProperty(name="coreSize",value="16")
实例配置属性 hystrix.threadpool.[hystrixThreadPoolKey].coreSize

  • maxQueueSize:该参数用来设置线程池的最大队列大小。当设置为-1 时,线程池将使用 SynchronousQueue 实现的队列,否则将使用 LinkedBlockingQueue 实现的队列。
属性级别 默认值、配置方式、配置属性
全局默认值 -1
全局配置属性 hystrix.threadpool.default.maxQueueSize
实例默认值 可通过注解设置@HystrixProperty(name="maxQueueSize",value="10")
实例配置属性 hystrix.threadpool.[hystrixThreadPoolKey].maxQueueSize

注意:

该属性只有在初始化的时候才有用,无法通过动态刷新的方式来调整。


  • queueSizeRejectionThreshold:该参数用来为队列设置拒绝阈值。通过该参数,即使队列没有达到最大值也能拒绝请求。该参数主要是对 LinkedBlocking- Queue 队列的补充,因为 LinkedBlockingQueue 队列不能动态修改它的对象大小,而通过该属性就可以调整拒绝请求的队列大小了。
属性级别 默认值、配置方式、配置属性
全局默认值 5
全局配置属性 hystrix.threadpool.default.queueSizeRejectionThreshold
实例默认值 可通过注解设置@HystrixProperty(name="queueSizeRejectionThreshold",value="10")
实例配置属性 hystrix.threadpool.[hystrixThreadPoolKey].queueSizeRejectionThreshold

注意:
当 maxQueueSize 属性为-1 的时候,该属性不会生效。


活动 5.1: Hystrix 容错保护

Hystrix 仪表盘

通过之前的内容,我们己经体验到了 Spring Cloud 对 Hystrix 的优雅整合。除此之外,SpringCloud 还完美地整合了它的仪表盘组件 Hystrix Dashboard,它主要用来实时监控 Hystrix 的各项指标信息。通过 Hystrix Dashboard 反馈的实时信息,可以帮助我们快速发现系统中存在的问题,从而及时地采取应对措施。


本节中我们将在 Hystrix 入门例子的基础上,构建一个 Hystrix Dashboard 来对 Order-Service 服务实现监控。

在 Spring Cloud 中构建一个 Hystrix Dashboard 非常简单,只需要下面 4 步:

  1. 创建一个标准的 Spring Boot 工程,命名为 hystrix-dashboard。

  2. 编辑 pom.xml,具体依赖内容如下所示:


<!--引入Hystrix-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<!--Hystrix仪表盘-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
<!--actuator端点-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

  1. 为应用主类加上@EnableHystrixDashboard,启用 Hystrix Dashboard 功能。

  2. 根据实际情况修改 application.properties 配置文件,比如选择一个未被占用的端口等,此步不是必需的。

    server:
     port: 2022

到这里我们己经完成了基本配置,接下来可以启动该应用,并访问 http://localhost:2022/hystrix。可以看到如下页面:


image-20220724232410110


这是 Hystrix Dashboard 的监控首页,Hystrix Dashboard 共支持三种不同的监控方式,如下所示。

  • 默认的集群监控:通过 URL http://turbine-hostname:port/turbine.stream 开启,实现对默认集群的监控。
  • 指定的集群监控:通过 URL http://turbine-hostname:port/turbine.stream?cluster=[clusterName]开启,实现对 clusterName 集群的监控。
  • 单体应用的监控:通过 URL http://hystrix-app:port/hystrix.stream 开启,实现对具体某个服务实例的监控。

前两者都是对集群的监控,需要整合 Turbine 才能实现,这部分内容我们将在下一节中做详细介绍。在本节中,我们主要实现对单个服务实例的监控,这里我们先来实现单个服务实例的监控。


既然 Hystrix Dashboard 监控单实例节点需要通过访问实例的/hystrix. stream 接口 来实现,我们自然需要为服务实例添加这个端点,而添加该功能的步骤也同样简单,只需要下面三步,我们以 orderservice 服务(默认端口 8080)为例进行演示:


  • 在服务实例 pom.xml 中的 dependencies 节点中新增 spring-boot-starter-actuator 监控模块以开启监控相关的端点,并确保己经引入断路器的依赖 spring-cloud-starter-hystrix

    <!--引入Hystrix-->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
    </dependency>
    <!--actuator端点-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

  • 确保在服务实例的主类中己经使用@EnableCircuitBreaker 注解,开启了断路器功能。

  • 在配置文件中开启端点功能

    #暴露全部的监控信息
    management:
    endpoints:
      web:
        exposure:
          include: "*"

    这里使用了 management.endpoints.web.exposure.include 属性来开启所有的端点,也可以通过 management.endpoints.web.exposure.exclude 属性来关闭某些端点。具体的端点信息可以参考 Spring Boot 的官方文档。"*" 表示开启所有端点,也可以使用具体的端点名称,比如 "hystrix.stream"。


到这里己经完成了所有的配置,在 Hystrix Dashboard 的首页还有另外两个参数。

  • Delay:该参数控制服务器上轮询监控信息的延迟时间,默认为 2000 毫秒,可以通过配置该属性来降低客户端的网络和 CPU 消耗。
  • Title:该参数对应了下图头部标题 Hystrix Stream 之后的内容,默认会使用具体监控实例的 URL,可以通过配置该信息来展示更合适的标题。

输入 http://localhost:8080/actuator/hystrix.stream,填写 Title,单击 Monitor Stream 按钮,可以看到如下页面。

image-20220724233454538


回到监控页面,我们来详细说说其中各元素的具体含义。

  • 可以在监控信息的左上部找到两个重要的图形信息:一个实心圆和一条曲线。
    • 实心圆:其有两种含义。通过颜色的变化代表了实例的健康程度,如下图所示,它的健康度从绿色、黄色、橙色、红色递减。该实心圆除了颜色的变化之外, 它的大小也会根据实例的请求流量发生变化,流量越大该实心圆就越大。所以通过该实心圆的展示,我们可以在大量的实例中快速发现故障实例和高压力实例。
    • 曲线:用来记录 2 分钟内流量的相对变化,可以通过它来观察流量的上升和下降趋势。

image-20220724234005430


  • 其他一些数量指标如下图所示。
    • 实心圆: 代表了最近 10 秒内请求的总数。其颜色代表了请求的健康程度,如下图所示,它的健康度从绿色、黄色、橙色、红色递减。
    • 七种颜色: 代表了最近 10 秒内请求的状态,分别是:绿色代表成功,黄色代表超时,橙色代表拒绝,红色代表失败,灰色代表断路器打开,蓝色代表短路,紫色代表线程池拒绝。
    • 曲线: 代表了最近 10 秒内请求的相对变化,可以通过它来观察请求的上升和下降趋势。
    • Circle Breaker: 代表了最近 10 秒内断路器的打开和关闭情况,当断路器打开时,该实心圆会变成红色,当断路器关闭时,该实心圆会变成绿色。

bg right fit


通过本节内容我们己经能够使用 Hystrix Dashboard 来对单个实例做信息监控了,但是在分布式系统中,往往有非常多的实例需要去维护和监控。到目前为止,我们能做的就是通过开启多个窗口来监控多个实例,很显然这样的做法并不合理。在下一节中,我们将介绍利用 Turbine 和 HystrixDashboard 配合实现对集群的监控。


活动 5.2 : 基于图形化的 DashBoard(仪表板)监控


Turbine 集群监控

在上一节介绍 Hystrix Dashboard 的首页时,我们提到过除了可以开启单个实例的监控 页面之外,还有一个监控端点/turbine . stream 是对集群使用的。从端点的命名中,可猜测到这里我们将引入 Turbine,通过它来汇集监控信息,并将聚合后的信息提供给 Hystrix Dashboard 来集中展示和监控。


构建监控聚合服务

下面我们将在上一节内容的基础上做一些扩展,通过引入 Turbine 来聚合多个服务的监控信息,并在 Hystrix Dashboard 上面展示。

具体实现步骤如下:

  1. 创建一个标准的 Spring Boot 工程,命名为 hystrix-turbine。

  1. 编辑 pom.xml,引入相关依赖。

    <dependencies>
       <dependency>
           <groupId>org.springframework.cloud</groupId>
           <artifactId>spring-cloud-starter-netflix-turbine</artifactId>
           <!--它默认依赖的是EurekaClient,需要排除,随后引入Nacos相关依赖即可-->
           <exclusions>
               <exclusion>
                   <groupId>org.springframework.cloud</groupId>
                   <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
               </exclusion>
           </exclusions>
       </dependency>
       <dependency>
           <groupId>org.springframework.cloud</groupId>
           <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.cloud</groupId>
           <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
       </dependency>
    
       <!--引入Nacos Client即可-->
       <dependency>
           <groupId>com.alibaba.cloud</groupId>
           <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
       </dependency>
    </dependencies>

  1. 创建应用主类 TurbineApplication,并使用@EnableTurbine 注解幵启 Turbine。

    @SpringBootApplication
    @EnableHystrixDashboard
    @EnableTurbine
    public class TurbineApplication {
       public static void main(String[] args) {
           SpringApplication.run(TurbineApplication.class,args);
       }
    }

  1. 在 application.yml 配置文件中加入 Nacos 和 Turbine 的相关配置

    server:
     port: 2023
    
    spring:
     application:
       name: turbine
     cloud:
       nacos:
         server-addr: localhost:8848 #nacos 服务端地址
    
    turbine:
     app-config: orderservice,userservice #监控服务列表
     aggregator:
       cluster-config: default #聚合集群名称
     cluster-name-expression: new String('default') #聚合集群名称
     combine-host-port: true
    
    hystrix:
     dashboard:
       proxy-stream-allow-list:
         - "localhost"
    
    #hystrix dashboard用,actuator暴露端口
    management:
     endpoints:
       web:
         exposure:
           include: "hystrix.stream,turbine.stream"
         cors:
           allowed-origins: "*"
           allowed-methods: "*"

  • turbine.app-config: 指定需要监控的服务列表,多个服务之间用逗号分隔。
  • turbine.cluster-name-expression : 指定聚合集群名称, 该
    参数指定了集群名称为 default,当服务数量非常多的时候,可以启动多个 Turbine 服务来构建不同的聚合集群,而该参数可以用来区分这些不同的聚合集群,同时该参数值可以在 Hystrix 仪表盘中用来定位不同的聚合集群,只需在 Hystrix Stream 的 URL 中通过 cluster 参数来指定;
  • turbine.combine-host-port: 此参数设置为 true 可以让同一主机上的服务通过主机名与端口号的组合来进行区分,默认情况下会以 host 来区分不同的服务,这 会使得在本地调试的时候,本机上的不同服务聚合成一个服务来统计。

在完成了上面的内容构建之后,我们来体验一下 Turbine 对集群的监控能力。分别启动 nacos-server、orderservice、userservice、Turbine 以及 Hystrix Dashboard。访问 HystrixDashboard,并开启对 http://localhost:2023/turbine.stream 的监控,我们可以看到如下页面:


image-20220725171915058

监控面板

在断路器原理的介绍中,我们多次提到关于请求命令的度量指标的判断。这些度量指标都是HystrixCommand 和 HystrixObservableCommand 实例在执行过程中记录的重要信息,它们除了在Hystrix 断路器实现中使用之外,对于系统运维也有非常大的帮助。 这些指标信息会以“滚动时间窗”与“桶”结合的方式进行汇总,并在内存中驻留一段时间,以供内部或外部进行查询使用,Hystrix 仪表盘就是这些指标内容的消费者之一。

通过之前的内容,我们己经体验到了 Spring Cloud 对 Hystrix 的优雅整合。除此之外,SpringCloud 还完美地整合了它的仪表盘组件 Hystrix Dashboard,它主要用来实时监控 Hystrix 的各项指标信息。通过 Hystrix Dashboard 反馈的实时信息,可以帮助我们快速发现系统中存在的问题,从而及时地采取应对措施。


本节中我们将在 Hystrix 入门例子的基础上,构建一个 Hystrix Dashboard项目 来对 Order-Service 服务实现监控。

通过actuator端点产生统计信息

微服务如果想要被监控, 需要暴露相应端点, 这里为了方便暴露了所有端点.

#暴露全部的监控信息
management:
  endpoints:
    web:
      exposure:
        include: "*"

这里使用了 management.endpoints.web.exposure.include 属性来开启所有的端点,也可以通过 management.endpoints.web.exposure.exclude 属性来关闭某些端点。具体的端点信息可以参考 Spring Boot 的官方文档。"*" 表示开启所有端点,也可以使用具体的端点名称,比如 "hystrix.stream"。

另外因为 springboot2.0起监控的默认路径不是 "/hystrix.stream", 需要在自己的项目里面手动配置映射路径:

@Configuration
public class OrderServiceConfiguration {

    @Bean
    public ServletRegistrationBean getServlet(){
        HystrixMetricsStreamServlet streamServlet = new HystrixMetricsStreamServlet();
        ServletRegistrationBean<HystrixMetricsStreamServlet> registrationBean = new ServletRegistrationBean<>(streamServlet);
        registrationBean.setLoadOnStartup(1);
        registrationBean.addUrlMappings("/actuator/hystrix.stream");
        registrationBean.setName("HystrixMetricsStreamServlet");

        return registrationBean;
    }
}

以orderservice为例进行测试:

http://localhost:9010/actuator/hystrix.stream

image-20240407160515165

userservice也是一样.

创建turbine项目聚合统计信息

如果需要聚合多个微服务信息显示在面板上, 需要是创建一个turbine项目, 并进行统计信息的聚合:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-turbine</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>my-turbine</name>
    <description>my-turbine</description>

    <parent>
        <groupId>com.niit</groupId>
        <artifactId>chapter05</artifactId>
        <version>1.0</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-turbine</artifactId>
            <!--它默认依赖的是EurekaClient,需要排除,随后引入Nacos相关依赖即可-->
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.cloud</groupId>
                    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!--引入Nacos Client即可-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
    </dependencies>
</project>

由于turbine需要借助nacos获取服务信息, 需要使用spring-cloud-starter-alibaba-nacos-discovery依赖.

启动类:

@SpringBootApplication
@EnableHystrix
@EnableTurbine // 开启turbine, 用于聚合监控数据从而实现集群监控
public class MyTurbineApplication {

    public static void main(String[] args) {
        SpringApplication.run(MyTurbineApplication.class, args);
    }

}

配置:

server:
  port: 2023

spring:
  application:
    name: turbine
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 服务端地址
      discovery:
        namespace: a52af3ff-ee9b-4c83-b6af-1e9fe53258e4 # 处于同一个命名空间,才能进行监控
        ephemeral: false

turbine:
  cluster-name-expression: new String('default') #聚合集群名称, default 为默认值
  aggregator: #聚合器
    # 指定聚合哪些集群,多个使用","分割,默认为default。可使用http://.../turbine.stream?cluster={clusterConfig之一}访问
    cluster-config: default #聚合集群名称, default 为默认值
  app-config: orderservice,userservice #监控服务列表, 需要从nacos中获取
  combine-host-port: true #是否将主机名和端口号组合在一起来区分不同的实例, 默认仅使用主机名区分

访问聚合信息http://localhost:2023/turbine.stream:

image-20240407162249822

创建dashboard项目实现监控

新创建一个项目 my-dashboard , 用于展示单体项目或者聚合项目的统计信息:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-dashboard</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>my-dashboard</name>
    <description>my-dashboard</description>
    <parent>
        <groupId>com.niit</groupId>
        <artifactId>chapter05</artifactId>
        <version>1.0</version>
    </parent>

    <dependencies>
        <!--引入Hystrix-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>
        <!--Hystrix仪表盘-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
        </dependency>
    </dependencies>
</project>

启动类:

@SpringBootApplication
@EnableHystrixDashboard
public class MyDashboardApplication {

    public static void main(String[] args) {
        SpringApplication.run(MyDashboardApplication.class, args);
    }

}

配置:

server:
  port: 2022

hystrix:
  dashboard:
    proxy-stream-allow-list: '*'  #允许任何 IP 地址访问仪表板

启动项目后, 访问:http://localhost:2022/hystrix

image-20240407162949781

这是 Hystrix Dashboard 的监控首页,Hystrix Dashboard 共支持三种不同的监控方式,如下所示。

  • 单体服务的监控:通过 URL http://hystrix-app:port/hystrix.stream 开启,实现对具体某个服务实例的监控。
  • 集群监控:通过 URL http://turbine-hostname:port/turbine.stream?cluster=[clusterName]开启,实现对 clusterName 集群的监控。

前者是对集群的监控,需要整合 Turbine 才能实现,后者是对单个服务实例的监控。

监控单体项目:

比如监控orderservice

在上面输入框输入http://localhost:9010/actuator/hystrix.stream

image-20240407163155609

监控聚合项目:

在上面输入框输入http://localhost:2023/turbine.stream, 点击Monitor Stream按钮开始监控:

image-20240413234750492

解读监控内容:

一开始是没有监控信息的, 直到请求发生:

image-20220725171915058

各个指标的含义:

bg right fit

熔断如果触发, 则可以看到红色圆圈:

  • 实心圆:其有两种含义。通过颜色的变化代表了实例的健康程度,绿色表示正常,红色表示熔断。该实心圆除了颜色的变化之外, 它的大小也会根据实例的请求流量发生变化,流量越大该实心圆就越大。所以通过该实心圆的展示,我们可以在大量的实例中快速发现故障实例和高压力实例。

  • Circuit:表示熔断器的状态, Closed(绿字)表示熔断器闭合接收请求, Open(红字)表示熔断器打开走熔断降级流程. 正常的情况实心圆是淡绿色, 熔断器显示闭合; 当熔断发生时, 实心圆红色, 熔断器打开;当熔断器处于半开状态时, 实心圆回复淡绿色, 熔断器显示打开.

  • 曲线:代表时间窗口内流量变化,可以通过它来观察流量的上升和下降趋势。

  • 六个数字代表不同状态的请求数, 灰色百分号为失败率.

不同颜色代表的含义:

image-20240407170903329

其他文字代表的含义:

  • Cluster 集群每秒请求数

  • Host 主机每秒请求数

  • Hosts 主机数量

  • Median 响应时间的中位数, Mean 平均延迟, 90th 表示90百分位的延迟情况.

可以点击sort右边的排序字段进行排序:

image-20240407170959357

Views: 52