Sentinel 全系列之四 —— Sentinel-dashboard 模块源码分析


1.dashboard模块简介

  Sentinel 提供一个轻量级的开源控制台,它提供机器发现以及健康情况管理、监控、规则管理和推送的功能。这里,我们将会详细分析Sentinel如何实现这些功能。

Sentinel 控制台包含如下功能:

  • 查看机器列表以及健康情况:收集 Sentinel 客户端发送的心跳包,用于判断机器是否在线。
  • 监控 (单机和集群聚合) :通过 Sentinel 客户端暴露的监控 API,定期拉取并且聚合应用监控信息,最终可以实现秒级的实时监控。
  • 规则管理和推送:统一管理推送规则。
  • 鉴权:生产环境中鉴权非常重要。这里每个开发者需要根据自己的实际情况进行定制。

2.持久化规则的增删改查

  注:此模块是使用zookeeper做了规则信息的持久化存储

2.1 增加规则

  在dashboard填写新增流控规则并点击新增

image.png

  根据控制台输出我们去源码中寻找新增规则的controller

@PostMapping("/rule")
@AuthAction(value = AuthService.PrivilegeType.WRITE_RULE)
public Result<FlowRuleEntity> apiAddFlowRule(@RequestBody FlowRuleEntity entity) {
    //对 entity 参数做校验,如果参数都符合条件,则返回 null
    Result<FlowRuleEntity> checkResult = checkEntityInternal(entity);
    if (checkResult != null) {
        return checkResult;
    }
    //封装规则相关信息
    entity.setId(null);
    Date date = new Date();
    entity.setGmtCreate(date);
    entity.setGmtModified(date);
    entity.setLimitApp(entity.getLimitApp().trim());
    entity.setResource(entity.getResource().trim());
    try {
        //将数据存入到本地map中
        entity = repository.save(entity);
        //将数据从本地内存推送至远端zookeeper
        publishRules(entity.getApp());
    } catch (Throwable throwable) {
        logger.error("Failed to add flow rule", throwable);
        return Result.ofThrowable(-1, throwable);
    }
    return Result.ofSuccess(entity);
}

  由此段代码可见dashboard对数据的操作都分为两步:

  • 第一步操作内存数据
private Map<MachineInfo, Map<Long, T>> machineRules = new ConcurrentHashMap<>(16);
private Map<Long, T> allRules = new ConcurrentHashMap<>(16);
private Map<String, Map<Long, T>> appRules = new ConcurrentHashMap<>(16);

@Override
public T save(T entity) {
    //生成规则id
    if (entity.getId() == null) {
        entity.setId(nextId());
    }
    T processedEntity = preProcess(entity);
    //将规则entity实体存入至machineRules,allRules,appRules三个map中,这三个map会保存在内存中。
    if (processedEntity != null) {
        allRules.put(processedEntity.getId(), processedEntity);
        machineRules.computeIfAbsent(MachineInfo.of(processedEntity.getApp(), processedEntity.getIp(),
            processedEntity.getPort()), e -> new ConcurrentHashMap<>(32))
            .put(processedEntity.getId(), processedEntity);
        appRules.computeIfAbsent(processedEntity.getApp(), v -> new ConcurrentHashMap<>(32))
            .put(processedEntity.getId(), processedEntity);
    }
    return processedEntity;
}
  • 第二步将内存数据推送至zk
private void publishRules(/*@NonNull*/ String app) throws Exception {
    //从本地appRules这个map中读取所有规则
    List<FlowRuleEntity> rules = repository.findAllByApp(app);
    //将新规则推送至zk并更新数据
    rulePublisher.publish(app, rules);
}

  看到这里大家可能会有疑惑,为什么dashboard要多此一举多加这一步内存的操作?为什么不能直接将修改数据推送至zk?且听我慢慢道来:

  首先大家要明白一个点就是zk某一个节点下的数据是一个整体,我们用程序去修改节点数据时是要将所有数据读出来并转化为我们需要的数据格式,然后再进行修改,最后推送上去覆盖旧信息,并不是说我们传一个新的数据给zk,zk就会帮我们匹配更新。明白这个点其实就能明白dashboard为什么要加内存这一层操作了,就是把更新数据的操作在本地完成然后将新数据推送至zk覆盖掉旧信息,也减少了读取zk数据的次数。

2.2 删除规则,修改规则

  新增规则的流程明白后,删除和修改都是大同小异,在这里不过多赘述,具体流程请大家自行查看源码

2.3 查询规则

@GetMapping("/rules")
public Result<List<FlowRuleEntity>> apiQueryMachineRules(HttpServletRequest request, @RequestParam String app) {
    if (StringUtil.isEmpty(app)) {
        return Result.ofFail(-1, "app can't be null or empty");
    }
    try {
        //1.根据app从zk中读取对应的规则并封装成list集合
        List<FlowRuleEntity> rules = ruleProvider.getRules(app);
        if (rules != null && !rules.isEmpty()) {
            for (FlowRuleEntity entity : rules) {
                entity.setApp(app);
                if (entity.getClusterConfig() != null && entity.getClusterConfig().getFlowId() != null) {
                    entity.setId(entity.getClusterConfig().getFlowId());
                }
            }
        }
        //2.将读取到的数据存入到内存map中
        rules = repository.saveAll(rules);
        return Result.ofSuccess(rules);
    } catch (Throwable throwable) {
        logger.error("Error when querying flow rules", throwable);
        return Result.ofThrowable(-1, throwable);
    }
}

  saveall(rules)方法流程:

@Override
public List<T> saveAll(List<T> rules) {
    // 清空三个map中的所有就数据
    allRules.clear();
    machineRules.clear();
    appRules.clear();

    if (rules == null) {
        return null;
    }
    List<T> savedRules = new ArrayList<>(rules.size());
    //将新数据添加进缓存map中
    for (T rule : rules) {
        savedRules.add(save(rule));
    }
    return savedRules;
}

2.4 小结

  其实规则增删改查的这段代码逻辑并不复杂,重要的就是要明白源码中关于三个存储信息的map集合的应用目的。


3.dashboard和client的心跳分析

image.png

  如上图所示,dashboard可以实时展示出app下所有客户端的状态以及对应信息,此功能依赖于客户端会定时发送心跳请求至dashboard,dashboard会根据每一个客户端的心跳请求获取对应信息在页面上展示,以下是对应的源码分析:

3.1 client定时发送心跳流程

  客户端在初始化的时候,会进行初始化加载,执行了一个InitExecutor.init的方法,该方法会触发所有InitFunc实现类的init方法,其中就包括两个最重要的实现类:

  • HeartbeatSenderInitFunc
  • CommandCenterInitFunc

  HeartbeatSenderInitFunc会启动一个HeartbeatSender来定时的向dashboard 送自己的心跳包,而CommandCenterInitFunc则会启动一个CommandCenter对外提供客户端的数据处理功能,而这些数据请求是通过一个一个的CommandHandler来处理的。

  初始化源码如下:

    public class Env { 
    public static final Sph sph = new CtSph(); 
    static { 
            // If init fails, the process will exit. 
            InitExecutor.doInit(); //<====初始化過程 
        } 
    }

  初始化过程源码:

    // 通过SPI获取实现了InitFunc接口的实现类,
    // 其中初始化发送心跳包的类是HeartbeatSenderInitFunc。
    ServiceLoader<InitFunc> loader = ServiceLoaderUtil.getServiceLoader(InitFunc.class);
    List<OrderWrapper> initList = new ArrayList<OrderWrapper>();
    // 按照InitOrder注解的值对实现类进行排序
    for (InitFunc initFunc : loader) {
        RecordLog.info("Found init func: " + initFunc.getClass().getCanonicalName());
        insertSorted(initList, initFunc);
    }
    // 按照顺序调用每一个实现类的init方法,
    // 其中也包括HeartbeatSenderInitFunc实现类。
    for (OrderWrapper w : initList) {
        w.func.init();
        RecordLog.info(String.format("Executing %s with order %d",
            w.func.getClass().getCanonicalName(), w.order));
    }

  HeartbeatSenderInitFunc的初始化方法:

@Override
public void init() {
    HeartbeatSender sender = HeartbeatSenderProvider.getHeartbeatSender();
    if (sender == null) {
        RecordLog.warn("[HeartbeatSenderInitFunc] WARN: No HeartbeatSender loaded");
        return;
    }

    initSchedulerIfNeeded();
    long interval = retrieveInterval(sender);
    setIntervalIfNotExists(interval);
    scheduleHeartbeatTask(sender, interval); //定时发送请求任务
}

  定时任务源码:

private void scheduleHeartbeatTask(/*@NonNull*/ final HeartbeatSender sender, /*@Valid*/ long interval) {
    pool.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            try {
                sender.sendHeartbeat();//发送心跳请求
            } catch (Throwable e) {
                RecordLog.warn("[HeartbeatSender] Send heartbeat error", e);
            }
        }
    }, 5000, interval, TimeUnit.MILLISECONDS);
    RecordLog.info("[HeartbeatSenderInit] HeartbeatSender started: "
        + sender.getClass().getCanonicalName());
}

  心跳请求请求是通过HeartbeatSender接口以心跳的方式发送的,并将自己的ip和port告知 dashboard。HeartbeatSender 有两个实现类,一个是通过http,另一个是通过netty,这里主要介绍通过http的实现方式:

private final HeartbeatMessage heartBeat = new HeartbeatMessage();
@Override
public boolean sendHeartbeat() throws Exception {
    if (TransportConfig.getRuntimePort() <= 0) {
        RecordLog.info("[SimpleHttpHeartbeatSender] Command server port not initialized, won't send heartbeat");
        return false;
    }
    // 通过csp.sentinel.dashboard.server配置, 
    // 获取第一个服务端的IP和端口
    Endpoint addrInfo = getAvailableAddress();
    if (addrInfo == null) {
        return false;
    }

    SimpleHttpRequest request = new SimpleHttpRequest(addrInfo, TransportConfig.getHeartbeatApiPath());
    // 构建心跳包的参数, 
    // 包括客户端IP、端口、应用名称等信息。
    request.setParams(heartBeat.generateCurrentMessage());
    try {
        SimpleHttpResponse response = httpClient.post(request);//发送POST请求
        if (response.getStatusCode() == OK_STATUS) {
            return true;
        } else if (clientErrorCode(response.getStatusCode()) || serverErrorCode(response.getStatusCode())) {
            RecordLog.warn("[SimpleHttpHeartbeatSender] Failed to send heartbeat to " + addrInfo
                + ", http status code: " + response.getStatusCode());
        }
    } catch (Exception e) {
        RecordLog.warn("[SimpleHttpHeartbeatSender] Failed to send heartbeat to " + addrInfo, e);
    }
    return false;
}

  实现过程就是通过一个HttpClient向dashboard发送了自己的信息,包括ip,port和版本号等信息。dashboard 在接收到客户端的连接之后,就会与客户端建立连接,并将客户端上报的ip和port的信息包装成一个MachineInfo对象,然后通过SimpleMachineDiscovery将该对象保存在一个map中。

  在客户端连接上dashboard之后,并不是就结束了,客户端会通过HeartbeatSenderInitFunc下的scheduleHeartbeatTask定时任务每隔10秒钟向dashboard 发送一次心跳信息。发送心跳的目的主要是告诉dashboard我这台sentinel的实例还活着,你可以继续向我请求数据。

  如果客户端宕机了,那么这时dashboard中保存在内存里面的机器列表还是存在的。dashboard没有将 “失联”的客户端实例给去除的。而是页面上每次查询的时候,会去用当前时间减去机器上次心跳包的时间,如果时间差大于5分钟了,才会将该机器标记为 “失联”。

image.png

3.2 dashboard接受心跳请求分析

@ResponseBody
@RequestMapping("/machine")
public Result<?> receiveHeartBeat(String app,@RequestParam(value = "app_type", required = false, defaultValue = "0")Integer appType, Long version, String v, String hostname, String ip,Integer port) {
    //校验机器发送心跳请求的参数
    if (StringUtil.isBlank(app) || app.length() > 256) {
        return Result.ofFail(-1, "invalid appName");
    }
    if (StringUtil.isBlank(ip) || ip.length() > 128) {
        return Result.ofFail(-1, "invalid ip: " + ip);
    }
    if (port == null || port < -1) {
        return Result.ofFail(-1, "invalid port");
    }
    if (hostname != null && hostname.length() > 256) {
        return Result.ofFail(-1, "hostname too long");
    }
    if (port == -1) {
        logger.warn("Receive heartbeat from " + ip + " but port not set yet");
        return Result.ofFail(-1, "your port not set yet");
    }
    String sentinelVersion = StringUtil.isBlank(v) ? "unknown" : v;

    version = version == null ? System.currentTimeMillis() : version;
    try {
        //封装机器信息
        MachineInfo machineInfo = new MachineInfo();
        machineInfo.setApp(app);
        machineInfo.setAppType(appType);
        machineInfo.setHostname(hostname);
        machineInfo.setIp(ip);
        machineInfo.setPort(port);
        machineInfo.setHeartbeatVersion(version);
        machineInfo.setLastHeartbeat(System.currentTimeMillis());
        machineInfo.setVersion(sentinelVersion);
        //将机器信息存入缓存中,在dashboard页面重启后依然可以展示
        appManagement.addMachine(machineInfo);
        return Result.ofSuccessMsg("success");
    } catch (Exception e) {
        logger.error("Receive heartbeat error", e);
        return Result.ofFail(-1, e.getMessage());
    }
}

  appManagement.addMachine(machineInfo)流程如下:

private final ConcurrentMap<String, AppInfo> apps = new ConcurrentHashMap<>();

@Override
public long addMachine(MachineInfo machineInfo) {
    AssertUtil.notNull(machineInfo, "machineInfo cannot be null");
    //判断新加入的machine机器所属的app是否已存入内存中,若不在则会将app信息存入到类中声明的apps这个ConcurrentHashMap中
    AppInfo appInfo = apps.computeIfAbsent(machineInfo.getApp(), o -> new AppInfo(machineInfo.getApp(), machineInfo.getAppType())); 
    appInfo.addMachine(machineInfo); //将机器信息加入到对应的app下
    return 1;
}

  appInfo.addMachine(machineInfo)流程如下:

  这里是在类中先声明了machines这个set集合,这里使用set集合的原因是同一台机器的每一次心跳信息都会存入到内存中,使用set是为了避免机器信息重复。

private Set<MachineInfo> machines = ConcurrentHashMap.newKeySet();

public boolean addMachine(MachineInfo machineInfo) {
    //删除旧信息
    machines.remove(machineInfo);
    //增加新信息
    return machines.add(machineInfo);
}

4.实时获取监控数据

  当dashboard有了具体的客户端实例的信息后,就可以去请求所需要的数据了。具体请求限流规则列表的代码在SentinelApiClient中,如下所示:

public List<FlowRuleEntity> fetchFlowRuleOfMachine(String app, String ip, int port) {
    List<FlowRule> rules = fetchRules(ip, port, FLOW_RULE_TYPE, FlowRule.class);//发送请求的方法
    if (rules != null) {
        return rules.stream().map(rule -> FlowRuleEntity.fromFlowRule(app, ip, port, rule))
            .collect(Collectors.toList());
    } else {
        return null;
    }
}

  最终走流程可以发现在SentinelApiClient中executeCommand()方法中将拉取限流规则的请求发了出去。此时获取数据的请求从dashboard中发出去了,那客户端中是怎么进行相应处理的呢?请移步至本篇3.1开头处。


5.小结

1.dashborad模块代码比较简单,主要数据都是从内中存取的
2.在实际的生产环境中,还是缺少一些内容的,比如权限控制,数据持久化等
3.dashboard实时监控流程如下:

  1. 首先 客户端向 dashboard 发送心跳包
  2. dashboard 将 客户端 的机器信息保存在内存中
  3. dashboard 根据 客户端 的机器信息通过 httpClient 获取实时的数据
  4. 客户端 接收到请求之后,会找到具体的 CommandHandler 来处理
  5. 客户端 将处理好的结果返回给 dashboard