Spring Boot Admin 集成钉钉群机器人报警通知

前言

实现流程

创建钉钉群机器人后,得到 Webhook 与 Secret。Java 代码 实现 Admin 的 Notifier 接口,当监听到 Admin 服务状态变更后,直接调用 Webhook 发送消息给钉钉群机器人,群成员就可以收到报警消息通知,这个过程与 Github 的 Webhook 实现流程一致。

钉钉官方文档

值得一提的是,本文使用的是钉钉提供的 自定义机器人 接口,而不是 开发企业内部机器人 接口,同时 Webhook 里包含的 access_token 不存在有效期(永久有效),即不需要定时刷新 access_token

创建钉钉群机器人

首先登录钉钉的 PC 版,创建钉钉群机器人,得到钉钉群机器人的 Webhook;在群机器人的安全设置页面,若选择加签名,加签一栏下面还可以获取到 SEC 开头的字符串(签名秘钥)。

spring-boot-admin-dingtalk-2

spring-boot-admin-dingtalk-3

spring-boot-admin-dingtalk-4

Admin 集成钉钉群机器人通知

Java 核心代码

钉钉群机器人的配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DingTalkConfig {

/**
* 是否启用钉钉群机器人通知(默认否)
*/
@Value("${notify.dingtalk.enable:false}")
private boolean robotEnable;

/**
* 钉钉群机器人所给URL后面的access_token参数值
*/
@Value("${notify.dingtalk.access-token:}")
private String accessToken;

/**
* 钉钉群机器人的签名秘钥
*/
@Value("${notify.dingtalk.sign-secret:}")
private String signSecret;

/**
* 是否加签名(默认是)
*/
@Value("${notify.dingtalk.enable-signature:true}")
private boolean enableSignature;

public boolean isRobotEnable() {
return robotEnable;
}

public String getAccessToken() {
return accessToken;
}

public String getSignSecret() {
return signSecret;
}

public boolean isEnableSignature() {
return enableSignature;
}

}

钉钉群机器人的消息类型枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public enum DingTalkMessageType {

TEXT("text", "文本消息"),

LINK("link", "链接消息"),

MARK_DOWN("markdown", "MarkDown消息"),

FEED_CARD("feedCard", "FeedCard消息"),

ACTION_CARD("actionCard", "ActionCard消息");

private String value;

private String name;

DingTalkMessageType(String value, String name) {
this.value = value;
this.name = name;
}

public String getValue() {
return value;
}

public String getName() {
return name;
}

}

钉钉群机器人的常量类

1
2
3
4
5
6
7
8
9
public class DingTalkConstants {

public static final String SERVER_URL = "https://oapi.dingtalk.com";

public static final String API_SEND_MESSAGE = SERVER_URL + "/robot/send?access_token=%s";

public static final String API_SEND_MESSAGE_SIGN = SERVER_URL + "/robot/send?access_token=%s&timestamp=%s&sign=%s";

}

钉钉群机器人的消息发送类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import cn.hutool.core.codec.Base64;
import com.dingtalk.api.DefaultDingTalkClient;
import com.dingtalk.api.DingTalkClient;
import com.dingtalk.api.request.OapiRobotSendRequest;
import com.monitor.notify.config.DingTalkConfig;
import com.monitor.notify.constants.DingTalkConstants;
import com.monitor.notify.enums.DingTalkMessageType;
import com.taobao.api.TaobaoResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;

/**
* 钉钉群机器人消息发送<br>
* 每个钉钉群机器人每分钟最多发送20条,如果超过20条,会限流10分钟<br>
* 支持多种消息类型,发起POST请求时,必须将字符集编码设置成UTF-8<br>
*/
@Component
@ConditionalOnExpression("${notify.dingtalk.enable:false}")
public class DingTalkMessageSender {

private static final Logger logger = LoggerFactory.getLogger(DingTalkMessageSender.class);

private DingTalkConfig dingTalkConfig;

public DingTalkMessageSender(DingTalkConfig dingTalkConfig) {
this.dingTalkConfig = dingTalkConfig;
}

/**
* 发送文本消息
*
* @param msgText 消息内容
* @return
*/
public boolean sendTextMessage(String msgText) {
String logContent = msgText.replace(" ", "").replace("\n", "");
try {
logger.info("钉钉群机器人发送Text消息: {}", logContent);
DingTalkClient client = new DefaultDingTalkClient(getUrl());
OapiRobotSendRequest.Text text = new OapiRobotSendRequest.Text();
text.setContent(msgText);
OapiRobotSendRequest request = new OapiRobotSendRequest();
request.setMsgtype(DingTalkMessageType.TEXT.getValue());
request.setText(text);
TaobaoResponse response = client.execute(request);
return response.isSuccess();
} catch (Exception e) {
logger.error("钉钉群机器人发送Text消息失败 : {} : {}", logContent, e.getLocalizedMessage());
}
return false;
}

/**
* 发送Markdown消息
*
* @param title 标题
* @param msgText 消息内容
* @return
*/
public boolean sendMarkdownMessage(String title, String msgText) {
String logContent = msgText.replace(" ", "").replace("\n", "");
try {
logger.info("钉钉群机器人发送Markdown消息: {}", logContent);
DingTalkClient client = new DefaultDingTalkClient(getUrl());
OapiRobotSendRequest.Markdown markdown = new OapiRobotSendRequest.Markdown();
markdown.setTitle(title);
markdown.setText(msgText);
OapiRobotSendRequest request = new OapiRobotSendRequest();
request.setMsgtype(DingTalkMessageType.MARK_DOWN.getValue());
request.setMarkdown(markdown);
TaobaoResponse response = client.execute(request);
return response.isSuccess();
} catch (Exception e) {
logger.error("钉钉群机器人发送Markdown消息失败 : {} : {}", logContent, e.getLocalizedMessage());
}
return false;
}

/**
* 获取URL
*
* @return
* @throws Exception
*/
private String getUrl() throws Exception {
String accessToken = dingTalkConfig.getAccessToken();
if (!dingTalkConfig.isEnableSignature()) {
return String.format(DingTalkConstants.API_SEND_MESSAGE, accessToken);
} else {
Long timestamp = System.currentTimeMillis();
String sign = getSign(timestamp, dingTalkConfig.getSignSecret());
return String.format(DingTalkConstants.API_SEND_MESSAGE_SIGN, accessToken, timestamp, sign);
}
}

/**
* 获取签名
*
* @param timestamp 时间戳
* @param secret 钉钉群机器人的签名秘钥
* @return
* @throws Exception
*/
private String getSign(Long timestamp, String secret) throws Exception {
String stringToSign = timestamp + "\n" + secret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
byte[] signData = mac.doFinal(stringToSign.getBytes("UTF-8"));
return URLEncoder.encode(new String(Base64.encode(signData)), "UTF-8");
}

}

消息通知模板类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MessageTemplate {

/**
* 默认的监控消息模板
*/
public static final String MONITOR_TEXT_TEMPLATE = "服务名: %s(%s) \n服务状态: %s(%s) \n服务 IP: %s \n发送时间: %s";

/**
* 钉钉群机器人的MarkDown监控消息模板
*/
public static final String MONITOR_MARKDOWN_TEMPLATE_DINGTALK =
"**服务名称:**\n\n" +
"%s(%s)\n\n" +
"**服务状态:**\n\n" +
"%s(%s)\n\n" +
"**服务IP:**\n\n" +
"%s\n\n" +
"**发送时间:**\n\n" +
"%s\n\n";

}

实现 Admin 的 Notifier 接口来添加钉钉群机器人的消息通知,若需要监听服务的所有事件变更,还可以改为继承 AbstractEventNotifier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
import de.codecentric.boot.admin.server.notify.AbstractStatusChangeNotifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

public abstract class CustomNotifier extends AbstractStatusChangeNotifier {

private Logger logger = LoggerFactory.getLogger(CustomNotifier.class);

protected CustomNotifier(InstanceRepository repository) {
super(repository);
}

@Override
protected Mono<Void> doNotify(InstanceEvent event, Instance instance) {
return Mono.fromRunnable(() -> {
if (event instanceof InstanceStatusChangedEvent) {
logger.info("Instance {} ({}) is {}", instance.getRegistration().getName(), event.getInstance(), ((InstanceStatusChangedEvent) event).getStatusInfo().getStatus());

String status = ((InstanceStatusChangedEvent) event).getStatusInfo().getStatus();
switch (status) {
// 健康检查没通过
case "DOWN":
sendMessage(event, instance, "健康检查没通过");
break;
// 服务下线
case "OFFLINE":
sendMessage(event, instance, "服务下线");
break;
// 服务上线
case "UP":
sendMessage(event, instance, "服务上线");
break;
// 服务未知状态
case "UNKNOWN":
sendMessage(event, instance, "服务出现未知状态");
break;
default:
break;
}
} else {
logger.info("Instance {} ({}) {}", instance.getRegistration().getName(), event.getInstance(), event.getType());
}
});
}

public abstract void sendMessage(InstanceEvent event, Instance instance, String content);

}

Admin 的服务状态变更钉钉群机器人通知实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import com.monitor.notify.message.DingTalkMessageSender;
import com.monitor.notify.template.MessageTemplate;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
@ConditionalOnExpression("${notify.dingtalk.enable:false}")
public class DingtalkNotifier extends CustomNotifier {

private DingTalkMessageSender messageSender;

protected DingtalkNotifier(InstanceRepository repository, DingTalkMessageSender messageSender) {
super(repository);
this.messageSender = messageSender;
}

/**
* 发送钉钉群机器人消息
*
* @param event
* @param instance
* @param content
*/
@Override
public void sendMessage(InstanceEvent event, Instance instance, String content) {
String instanceName = instance.getRegistration().getName();
String instanceId = event.getInstance().toString();
String status = ((InstanceStatusChangedEvent) event).getStatusInfo().getStatus();
String serviceUrl = instance.getRegistration().getServiceUrl();
String dateTime = DateUtil.format(new Date(), DatePattern.NORM_DATETIME_MS_PATTERN);
String message = String.format(MessageTemplate.MONITOR_MARKDOWN_TEMPLATE_DINGTALK, instanceName, instanceId, status, content, serviceUrl, dateTime);
this.messageSender.sendMarkdownMessage("监控消息", message);
}

}

YML 配置内容

1
2
3
4
5
6
notify:
dingtalk:
enable: true
access-token: xxxxxxxxxxx
sign-secret: SECxxxxxxxxxxx
enable-signature: true

钉钉的 Java SDK

由于钉钉官方没有将钉钉的 SDK 发布到 Maven 仓库,因此需要在钉钉官网手动下载最新版的 SDK,然后发布到 Maven 私有仓库,或者安装在本地的 Maven 仓库,最后在项目的 pom.xml 配置文件里添加以下依赖。

1
2
3
4
5
<dependency>
<groupId>com.dingtalk</groupId>
<artifactId>dingtalk-api-sdk</artifactId>
<version>1.0.0</version>
</dependency>

安装钉钉 SDK 到本地 Maven 仓库的命令如下:

1
$ mvn install:install-file -Dfile=taobao-sdk-java-auto_1455552377940-20200322.jar -DgroupId=com.dingtalk -DartifactId=dingtalk-api-sdk -Dversion=1.0.0 -Dpackaging=jar

值得一提的是,不同版本的 钉钉 SDK,其 Maven 坐标中的 groupIdartifactIdversion 可能会发生变化,此时只需要将上面对应的参数值替换掉即可。

生产环境扩展建议

上述给出的是 Spring Boot Admin 集成钉钉群机器人消息通知的 Demo 代码,生产环境下还需要考虑到如下的实际问题:

  • 报警消息重复发送:若 Admin 应用以集群的方式部署,当 A 应用 DOWN 掉后,那么钉钉群成员将会收到多条 A 应用服务状态变更的报警消息
  • 报警消息的持久化:若大量报警消息积压在 Admin 应用里,但还没来得及发送,此时如果 Admin 应用挂掉,那么报警消息将会丢失,建议使用 消息中间件 的持久化特性来解决
  • 报警消息的发送频率:每个钉钉群机器人每分钟最多发送 20 条,如果超过 20 条,会限流 10 分钟;这里建议利用 任务调度线程池 来实现报警消息的调度发送,同时考虑将多条报警消息合并后再发送,以此来解决报警消息发送频率受限制的问题

参考博客