SpringCloudAlibaba之服务容错组件-Sentinel[代码篇]

Sentinel与控制台通信原理

在基础篇中我们学习了如何为项目整合Sentinel,并搭建了Sentinel的可视化控制台,介绍及演示了各种Sentinel所支持的规则配置方式。本文则对Sentinel进行更进一步的介绍。

为洞头等地区用户提供了全套网页设计制作服务,及洞头网站建设行业解决方案。主营业务为成都网站设计、成都网站制作、洞头网站设计,以传统方式定制建设网站,并提供域名空间备案等一条龙服务,秉承以专业、用心的态度为用户提供真诚的服务。我们深信只要达到每一位用户的要求,就会得到认可,从而选择与我们长期合作。这样,我们也可以走得更远!

首先我们来了解控制台是如何获取到微服务的监控信息的:

微服务集成Sentinel需要添加spring-cloud-starter-alibaba-sentinel依赖,该依赖中包含了sentinel-transport-simple-http模块。集成了该模块后,微服务就会通过配置文件中所配置的连接地址,将自身注册到Sentinel控制台上,并通过心跳机制告知存活状态,由此可知Sentinel是实现了一套服务发现机制的。

如下图:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

通过该机制,从Sentinel控制台的机器列表中就可以查看到Sentinel客户端(即微服务)的通信地址及端口号:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

如此一来,Sentinel控制台就可以实现与微服务通信了,当需要获取微服务的监控信息时,Sentinel控制台会定时调用微服务所暴露出来的监控API,这样就可以实现实时获取微服务的监控信息。

另外一个问题就是使用控制台配置规则时,控制台是如何将规则发送到各个微服务的呢?同理,想要将配置的规则推送给微服务,只需要调用微服务上接收推送规则的API即可。

我们可以通过访问http://{微服务注册的ip地址}:8720/api接口查看微服务暴露给Sentinel控制台调用的API,如下:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

相关源码:

  • 注册/心跳机制:com.alibaba.csp.sentinel.transport.heartbeat.SimpleHttpHeartbeatSender
  • 通信API:com.alibaba.csp.sentinel.command.CommandHandler的实现类

Sentinel API的使用

本小节简单介绍一下在代码中如何使用Sentinel API,Sentinel主要有以下三个API:

  • SphU:添加需要让sentinel监控、保护的资源
  • Tracer:对业务异常进行统计(非 BlockException 异常)
  • ContextUtil:上下文工具类,通常用于标识调用来源

示例代码如下:

@GetMapping("/test-sentinel-api")
public String testSentinelAPI(@RequestParam(required = false) String a) {
    String resourceName = "test-sentinel-api";
    // 这里不使用try-with-resources是因为Tracer.trace会统计不上异常
    Entry entry = null;
    try {
        // 定义一个sentinel保护的资源,名称为test-sentinel-api
        entry = SphU.entry(resourceName);
        // 标识对test-sentinel-api调用来源为test-origin(用于流控规则中“针对来源”的配置)
        ContextUtil.enter(resourceName, "test-origin");
        // 模拟执行被保护的业务逻辑耗时
        Thread.sleep(100);
        return a;
    } catch (BlockException e) {
        // 如果被保护的资源被限流或者降级了,就会抛出BlockException
        log.warn("资源被限流或降级了", e);
        return "资源被限流或降级了";
    } catch (InterruptedException e) {
        // 对业务异常进行统计
        Tracer.trace(e);
        return "发生InterruptedException";
    } finally {
        if (entry != null) {
            entry.exit();
        }

        ContextUtil.exit();
    }
}

对几个可能有疑惑的点说明一下:

  • 资源名:可任意填写,只要是唯一的即可,通常使用接口名
  • ContextUtil.enter:在该例子中,用于标识对test-sentinel-api的调用来源均为test-origin。例如使用postman或其他请求方式调用了该资源,其来源都会被标识为test-origin
  • Tracer.trace:降级规则中可以针对异常比例或异常数的阈值进行降级,而Sentinel只会对BlockException及其子类进行统计,其他异常不在统计范围,所以需要使用Tracer.trace手动统计。1.3.1 版本开始支持自动统计,将在下一小节进行介绍

相关官方文档:

  • 如何使用#其它-api

@SentinelResource注解

经过上一小节的代码示例,可以看到这些Sentinel API的使用方式并不是很优雅,有点类似于使用I/O流API的感觉,显得代码比较臃肿。好在Sentinel在1.3.1 版本开始支持@SentinelResource注解,该注解可以让我们避免去写这种臃肿不美观的代码。但即便如此,也还是有必要去学习Sentinel API的使用方式,因为其底层还是得通过这些API来实现。

学习一个注解除了需要知道它能干什么之外,还得了解其支持的属性作用,下表总结了@SentinelResource注解的属性:

属性作用是否必须
value 资源名称
entryType entry类型,标记流量的方向,取值IN/OUT,默认是OUT
blockHandler 处理BlockException的函数名称
blockHandlerClass 存放blockHandler的类。对应的处理函数必须static修饰,否则无法解析,其他要求:同blockHandler
fallback 用于在抛出异常的时候提供fallback处理逻辑。fallback函数可以针对所有类型的异常(除了exceptionsToIgnore 里面排除掉的异常类型)进行处理
fallbackClass【1.6支持】 存放fallback的类。对应的处理函数必须static修饰,否则无法解析,其他要求:同fallback
defaultFallback【1.6支持】 用于通用的 fallback 逻辑。默认fallback函数可以针对所有类型的异常(除了exceptionsToIgnore 里面排除掉的异常类型)进行处理。若同时配置了 fallback 和 defaultFallback,以fallback为准
exceptionsToIgnore【1.6支持】 指定排除掉哪些异常。排除的异常不会计入异常统计,也不会进入fallback逻辑,而是原样抛出
exceptionsToTrace 需要trace的异常 Throwable

blockHandler,处理BlockException函数的要求:

  1. 必须是public
  2. 返回类型与原方法一致
  3. 参数类型需要和原方法相匹配,并在最后加BlockException类型的参数
  4. 默认需和原方法在同一个类中。若希望使用其他类的函数,可配置 blockHandlerClass ,并指定blockHandlerClass里面的方法

fallback函数要求:

  1. 返回类型与原方法一致
  2. 参数类型需要和原方法相匹配,Sentinel 1.6开始,也可在方法最后加Throwable类型的参数
  3. 默认需和原方法在同一个类中。若希望使用其他类的函数,可配置 fallbackClass ,并指定fallbackClass里面的方法

defaultFallback函数要求:

  1. 返回类型与原方法一致
  2. 方法参数列表为空,或者有一个Throwable类型的参数
  3. 默认需要和原方法在同一个类中。若希望使用其他类的函数,可配置 fallbackClass ,并指定 fallbackClass 里面的方法

现在我们已经对@SentinelResource注解有了一个比较全面的了解,接下来使用@SentinelResource注解重构之前的代码,直观地了解下该注解带来了哪些便利,重构后的代码如下:

@GetMapping("/test-sentinel-resource")
@SentinelResource(
        value = "test-sentinel-resource",
        blockHandler = "blockHandlerFunc",
        fallback = "fallbackFunc"
)
public String testSentinelResource(@RequestParam(required = false) String a)
        throws InterruptedException {
    // 模拟执行被保护的业务逻辑耗时
    Thread.sleep(100);

    return a;
}

/**
 * 处理BlockException的函数(处理限流)
 */
public String blockHandlerFunc(String a, BlockException e) {
    // 如果被保护的资源被限流或者降级了,就会抛出BlockException
    log.warn("资源被限流或降级了.", e);
    return "资源被限流或降级了";
}

/**
 * 1.6 之前处理降级
 * 1.6 开始可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理
 */
public String fallbackFunc(String a) {
    return "发生异常了";
}

注:@SentinelResource注解目前不支持标识调用来源

Tips:

1.6.0 之前的版本 fallback 函数只针对降级异常(DegradeException)进行处理,不能针对业务异常进行处理

blockHandlerfallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入
blockHandler 处理逻辑。若未配置 blockHandlerfallbackdefaultFallback,则被限流降级时会将 BlockException 直接抛出

从 1.3.1 版本开始,注解方式定义资源支持自动统计业务异常,无需手动调用 Tracer.trace(ex) 来记录业务异常。Sentinel 1.3.1 以前的版本需要自行调用 Tracer.trace(ex) 来记录业务异常

@SentinelResource注解相关源码:

  • com.alibaba.csp.sentinel.annotation.aspectj.AbstractSentinelAspectSupport
  • com.alibaba.csp.sentinel.annotation.aspectj.SentinelResourceAspect

相关官方文档:

  • 注解支持
  • 官方代码示例

RestTemplate整合Sentinel

如果有了解过Hystrix的话,应该就会知道Hystrix除了可以对当前服务的接口进行容错,还可以对服务提供者(被调用方)的接口进行容错。到目前为止,我们只介绍了在Sentinel控制台对当前服务的接口添加相关规则进行容错,但还没有介绍如何对服务提供者的接口进行容错。

实际上有了前面的铺垫,现在想要实现对服务提供者的接口进行容错就很简单了,我们都知道在Spring Cloud体系中可以通过RestTemplate或Feign实现微服务之间的通信。所以只需要在RestTemplate或Feign上做文章就可以了,本小节先以RestTemplate为例,介绍如何整合Sentinel实现对服务提供者的接口进行容错。

很简单,只需要用到一个注解,在配置RestTemplate的方法上添加@SentinelRestTemplate注解即可,代码如下:

package com.zj.node.contentcenter.configuration;

import org.springframework.cloud.alibaba.sentinel.annotation.SentinelRestTemplate;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class BeanConfig {

    @Bean
    @LoadBalanced
    @SentinelRestTemplate
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

注:@SentinelRestTemplate注解包含blockHandler、blockHandlerClass、fallback、fallbackClass属性,这些属性的使用方式与@SentinelResource注解一致,所以我们可以利用这些属性,在触发限流、降级时定制自己的异常处理逻辑

然后我们再来写段测试代码,用于调用服务提供者的接口,代码如下:

package com.zj.node.contentcenter.controller.content;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

    private final RestTemplate restTemplate;

    @GetMapping("/test-rest-template-sentinel/{userId}")
    public UserDTO test(@PathVariable("userId") Integer userId) {
        // 调用user-center服务的接口(此时user-center即为服务提供者)
        return restTemplate.getForObject(
                "http://user-center/users/{userId}", UserDTO.class, userId);
    }
}

编写完以上代码重启项目并可以正常访问该测试接口后,此时在Sentinel控制台的簇点链路中,就可以看到服务提供者(user-center)的接口已经注册到这里来了,现在只需要对其添加相关规则就可以实现容错:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

若我们在开发期间,不希望Sentinel对服务提供者的接口进行容错,可以通过以下配置进行开关:

# 用于开启或关闭@SentinelRestTemplate注解
resttemplate:
  sentinel:
    enabled: true

Sentinel实现与RestTemplate整合的相关源码:

  • org.springframework.cloud.alibaba.sentinel.custom.SentinelBeanPostProcessor

Feign整合Sentinel

上一小节介绍RestTemplate整合Sentinel时已经做了相关铺垫,这里就不废话了直接上例子。首先在配置文件中添加如下配置:

feign:
  sentinel:
    # 开启Sentinel对Feign的支持
    enabled: true

定义一个FeignClient接口:

package com.zj.node.contentcenter.feignclient;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "user-center")
public interface UserCenterFeignClient {

    @GetMapping("/users/{id}")
    UserDTO findById(@PathVariable Integer id);
}

同样的来写段测试代码,用于调用服务提供者的接口,代码如下:

package com.zj.node.contentcenter.controller.content;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class TestFeignController {

    private final UserCenterFeignClient feignClient;

    @GetMapping("/test-feign/{id}")
    public UserDTO test(@PathVariable Integer id) {
        // 调用user-center服务的接口(此时user-center即为服务提供者)
        return feignClient.findById(id);
    }
}

编写完以上代码重启项目并可以正常访问该测试接口后,此时在Sentinel控制台的簇点链路中,就可以看到服务提供者(user-center)的接口已经注册到这里来了,行为与RestTemplate整合Sentinel是一样的:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]


默认当限流、降级发生时,Sentinel的处理是直接抛出异常。如果需要自定义限流、降级发生时的异常处理逻辑,而不是直接抛出异常该如何做?@FeignClient注解中有一个fallback属性,用于指定当远程调用失败时使用哪个类去处理。所以在这个例子中,我们首先需要定义一个类,并实现UserCenterFeignClient接口,代码如下:

package com.zj.node.contentcenter.feignclient.fallback;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserCenterFeignClientFallback implements UserCenterFeignClient {

    @Override
    public UserDTO findById(Integer id) {
        // 自定义限流、降级发生时的处理逻辑
        log.warn("远程调用被限流/降级了");
        return UserDTO.builder().
                wxNickname("Default").
                build();
    }
}

然后在UserCenterFeignClient接口的@FeignClient注解上指定fallback属性,如下:

@FeignClient(name = "user-center", fallback = UserCenterFeignClientFallback.class)
public interface UserCenterFeignClient {
    ...

接下来做一个简单的测试,看看当远程调用失败时是否调用了fallback属性所指定实现类里的方法。为服务提供者的接口添加一条流控规则,如下图:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

使用postman频繁发生请求,当QPS超过1时,返回结果如下:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

可以看到,返回了代码中定义的默认值。由此可证当限流、降级或其他原因导致远程调用失败时,就会调用UserCenterFeignClientFallback类里所实现的方法。


但是又有另外一个问题,这种方式无法获取到异常对象,并且控制台不会输出任何相关的异常信息,若业务需要打印异常日志或针对异常进行相关处理的话该怎么办呢?此时就得用到@FeignClient注解中的另一个属性:fallbackFactory,同样需要定义一个类,只不过实现的接口不一样。代码如下:

package com.zj.node.contentcenter.feignclient.fallbackfactory;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import feign.hystrix.FallbackFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserCenterFeignClientFallbackFactory implements FallbackFactory {

    @Override
    public UserCenterFeignClient create(Throwable cause) {

        return new UserCenterFeignClient() {
            @Override
            public UserDTO findById(Integer id) {
                // 自定义限流、降级发生时的处理逻辑
                log.warn("远程调用被限流/降级了", cause);
                return UserDTO.builder().
                        wxNickname("Default").
                        build();
            }
        };
    }
}

在UserCenterFeignClient接口的@FeignClient注解上指定fallbackFactory属性,如下:

@FeignClient(name = "user-center", fallbackFactory = UserCenterFeignClientFallbackFactory.class)
public interface UserCenterFeignClient {
    ...

需要注意的是,fallback与fallbackFactory只能二选一,不能同时使用。

重复之前的测试,此时控制台就可以输出相关异常信息了:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

Sentinel实现与Feign整合的相关源码:

  • org.springframework.cloud.alibaba.sentinel.feign.SentinelFeign

Sentinel使用姿势总结

Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]


扩展 - 错误信息优化

Sentinel默认在当前服务触发限流或降级时仅返回简单的异常信息,如下:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

并且限流和降级返回的异常信息是一样的,导致无法根据异常信息区分是触发了限流还是降级。

所以我们需要对错误信息进行相应优化,以便可以细致区分触发的是什么规则。Sentinel提供了一个UrlBlockHandler接口,实现该接口即可自定义异常处理逻辑。具体如下示例:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.UrlBlockHandler;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.authority.AuthorityException;
import com.alibaba.csp.sentinel.slots.block.degrade.DegradeException;
import com.alibaba.csp.sentinel.slots.block.flow.FlowException;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowException;
import com.alibaba.csp.sentinel.slots.system.SystemBlockException;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 自定义流控异常处理
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyUrlBlockHandler implements UrlBlockHandler {

    @Override
    public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException e) throws IOException {
        MyResponse errorResponse = null;
        // 不同的异常返回不同的提示语
        if (e instanceof FlowException) {
            errorResponse = MyResponse.builder()
                    .status(100).msg("接口限流了")
                    .build();
        } else if (e instanceof DegradeException) {
            errorResponse = MyResponse.builder()
                    .status(101).msg("服务降级了")
                    .build();
        } else if (e instanceof ParamFlowException) {
            errorResponse = MyResponse.builder()
                    .status(102).msg("热点参数限流了")
                    .build();
        } else if (e instanceof SystemBlockException) {
            errorResponse = MyResponse.builder()
                    .status(103).msg("触发系统保护规则")
                    .build();
        } else if (e instanceof AuthorityException) {
            errorResponse = MyResponse.builder()
                    .status(104).msg("授权规则不通过")
                    .build();
        }

        response.setStatus(500);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        new ObjectMapper().writeValue(response.getWriter(), errorResponse);
    }
}

/**
 * 简单的响应结构体
 */
@Data
@Builder
class MyResponse {
    private Integer status;
    private String msg;
}

此时再触发流控规则就可以响应代码中自定义的提示信息了:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]


扩展 - 实现区分来源

当配置流控规则或授权规则时,若需要针对调用来源进行限流,得先实现来源的区分,Sentinel提供了RequestOriginParser接口来处理来源。只要Sentinel保护的接口资源被访问,Sentinel就会调用RequestOriginParser的实现类去解析访问来源。

写代码:首先,服务消费者需要具备有一个来源标识,这里假定为服务消费者在调用接口的时候都会传递一个origin的header参数标识来源。具体如下示例:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.RequestOriginParser;
import com.alibaba.nacos.client.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

/**
 * 实现区分来源
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyRequestOriginParser implements RequestOriginParser {

    @Override
    public String parseOrigin(HttpServletRequest request) {
        // 从header中获取名为 origin 的参数并返回
        String origin = request.getHeader("origin");
        if (StringUtils.isBlank(origin)) {
            // 如果获取不到,则抛异常
            String err = "origin param must not be blank!";
            log.error("parse origin failed: {}", err);
            throw new IllegalArgumentException(err);
        }

        return origin;
    }
}

编写完以上代码并重启项目后,此时header中不包含origin参数就会报错了:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]


扩展 - RESTful URL支持

了解过RESTful URL的都知道这类URL路径可以动态变化,而Sentinel默认是无法识别这种变化的,所以每个路径都会被当成一个资源,如下图:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]

这显然是有问题的,好在Sentinel提供了UrlCleaner接口解决这个问题。实现该接口可以让我们对来源url进行编辑并返回,这样就可以将RESTful URL里动态的路径转换为占位符之类的字符串。具体实现代码如下:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.UrlCleaner;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Component;

import java.util.Arrays;

/**
 * RESTful URL支持
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyUrlCleaner implements UrlCleaner {

    @Override
    public String clean(String originUrl) {
        String[] split = originUrl.split("/");

        // 将数字转换为特定的占位标识符
        return Arrays.stream(split)
                .map(s -> NumberUtils.isNumber(s) ? "{number}" : s)
                .reduce((a, b) -> a + "/" + b)
                .orElse("");
    }
}

此时该RESTful接口就不会像之前那样一个数字就注册一个资源了:
Spring Cloud Alibaba之服务容错组件 - Sentinel [代码篇]


分享标题:SpringCloudAlibaba之服务容错组件-Sentinel[代码篇]
转载注明:http://scyanting.com/article/ijchdp.html