ThingsBoard 自定义华为云 SMS 规则节点
正文与配图来自原稿。在源码中自定义规则节点对接华为云短信;思路可同样扩展到阿里、腾讯、七牛等。
相关:如何发送告警邮件 · 配置发送邮件 · 项目结构(规则引擎)
1、概述
一个物联网平台承载着很多设备的连接,当设备出现异常的时候,能够快速通知到运维管理员非常重要。ThingsBoard 提供了自定义配置邮箱,但是它对支持发送短信不是很友好,内置多是国外的 SMS 服务商。
在国内常见就是阿里、腾讯、华为、七牛等 SMS 服务商。如果需要发短信,肯定优先用国内厂商。但 ThingsBoard 里没看到哪里可以直接配这些,于是自定义规则节点来实现这个功能,同时把规则节点自定义也介绍了。本文首先以华为 SMS 为例。

2、自定义规则节点
概述
我是直接在本地的 ThingsBoard 程序中增加的节点,也可以按照官方使用官方提供的那个程序来写,然后打成 jar 引入。
官方教程
这个官方教程说得不清楚,另外一个类如何定义也没说:
创建包
在本地程序创建了一个 external 包,意思是扩展 SMS。

然后在里面分别创建了国内常见的 SMS 服务商文件夹:

定义接口
定义了一个发送短信的接口 SendSms,其它每个厂商来实现这个接口:
package org.thingsboard.rule.engine.sms.external;
public interface SendSms {
boolean sendMsg();
ExternalSmsType getType();
}定义枚举类
定义了 SMS 厂商的名字:
package org.thingsboard.rule.engine.sms.external;
/**
* 扩展外部的 sms 厂商
*/
public enum ExternalSmsType {
ALI_SMS,
TENCENT_SMS,
HUAWEI_SMS,
QINIU_SMS,
YUNPIAN_SMS;
}华为云发送短信服务
封装了华为云发送短信依赖的方法,可以参考华为云官方文档:
https://support.huaweicloud.com/devg-msgsms/sms_04_0002.html
package org.thingsboard.rule.engine.sms.external.huawei;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.rule.engine.sms.external.ExternalSmsType;
import org.thingsboard.rule.engine.sms.external.SendSms;
import javax.net.ssl.*;
import java.io.*;
import java.net.URL;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
@Slf4j
public class HuaWeiSendSmsService implements SendSms {
private static final ObjectMapper mapper = new ObjectMapper();
// 无需修改,用于格式化鉴权头域,给"X-WSSE"参数赋值
private static final String WSSE_HEADER_FORMAT =
"UsernameToken Username=\"%s\",PasswordDigest=\"%s\",Nonce=\"%s\",Created=\"%s\"";
// 无需修改,用于格式化鉴权头域,给"Authorization"参数赋值
private static final String AUTH_HEADER_VALUE =
"WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\"";
private String url;
private String appKey;
private String appSecret;
private String sender;
private String templateId;
private String signature;
private String receiver;
private String statusCallBack;
private String templateParas;
public HuaWeiSendSmsService(String url, String appKey, String appSecret, String sender,
String templateId, String signature, String receiver,
String statusCallBack, String templateParas) {
this.url = url;
this.appKey = appKey;
this.appSecret = appSecret;
this.sender = sender;
this.templateId = templateId;
this.signature = signature;
this.receiver = receiver;
this.statusCallBack = statusCallBack;
this.templateParas = templateParas;
}
@Override
public boolean sendMsg() {
Writer out = null;
BufferedReader in = null;
StringBuffer result = new StringBuffer();
HttpsURLConnection connection = null;
InputStream is = null;
try {
String body = buildRequestBody(sender, receiver, templateId, templateParas, statusCallBack, signature);
if (null == body || body.isEmpty()) {
log.info("body is null.");
return false;
}
String wsseHeader = buildWsseHeader(appKey, appSecret);
if (null == wsseHeader || wsseHeader.isEmpty()) {
log.info("wsse header is null.");
return false;
}
HostnameVerifier hv = new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
};
trustAllHttpsCertificates();
URL realUrl = new URL(url);
connection = (HttpsURLConnection) realUrl.openConnection();
connection.setHostnameVerifier(hv);
connection.setDoOutput(true);
connection.setDoInput(true);
connection.setUseCaches(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
connection.setRequestProperty("Authorization", AUTH_HEADER_VALUE);
connection.setRequestProperty("X-WSSE", wsseHeader);
connection.connect();
out = new OutputStreamWriter(connection.getOutputStream());
out.write(body);
out.flush();
out.close();
int status = connection.getResponseCode();
if (200 == status) {
is = connection.getInputStream();
} else {
is = connection.getErrorStream();
}
in = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String line = "";
while ((line = in.readLine()) != null) {
result.append(line);
}
log.info("response result: {}", result.toString());
JsonNode resultNode = mapper.readTree(result.toString());
String code = resultNode.get("code").asText();
if ("000000".equals(code)) {
return true;
}
return false;
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != out) {
out.close();
}
if (null != is) {
is.close();
}
if (null != in) {
in.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return false;
}
private String buildRequestBody(String sender, String receiver, String templateId, String templateParas,
String statusCallBack, String signature) {
if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty()
|| templateId.isEmpty()) {
log.info("buildRequestBody(): sender, receiver or templateId is null.");
return null;
}
Map<String, String> map = new HashMap<String, String>();
map.put("from", sender);
map.put("to", receiver);
map.put("templateId", templateId);
if (null != templateParas && !templateParas.isEmpty()) {
map.put("templateParas", templateParas);
}
if (null != statusCallBack && !statusCallBack.isEmpty()) {
map.put("statusCallback", statusCallBack);
}
if (null != signature && !signature.isEmpty()) {
map.put("signature", signature);
}
StringBuilder sb = new StringBuilder();
String temp = "";
for (String s : map.keySet()) {
try {
temp = URLEncoder.encode(map.get(s), "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
sb.append(s).append("=").append(temp).append("&");
}
return sb.deleteCharAt(sb.length() - 1).toString();
}
private String buildWsseHeader(String appKey, String appSecret) {
if (null == appKey || null == appSecret || appKey.isEmpty() || appSecret.isEmpty()) {
System.out.println("buildWsseHeader(): appKey or appSecret is null.");
return null;
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
String time = sdf.format(new Date());
String nonce = UUID.randomUUID().toString().replace("-", "");
MessageDigest md;
byte[] passwordDigest = null;
try {
md = MessageDigest.getInstance("SHA-256");
md.update((nonce + time + appSecret).getBytes());
passwordDigest = md.digest();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
String passwordDigestBase64Str = Base64.getEncoder().encodeToString(passwordDigest);
return String.format(WSSE_HEADER_FORMAT, appKey, passwordDigestBase64Str, nonce, time);
}
private void trustAllHttpsCertificates() throws Exception {
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
return;
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
return;
}
public X509Certificate[] getAcceptedIssuers() {
return null;
}
}
};
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, null);
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
}
@Override
public ExternalSmsType getType() {
return ExternalSmsType.HUAWEI_SMS;
}
}定义规则节点配置
这个类的定义我在官方文档没看到有说明。这个类的作用就是接收规则节点的参数,同时需要实现 NodeConfiguration<HuaWeiSendSmsNodeConfiguration> 接口,并实现 defaultConfiguration 方法来进行参数封装。
注意: 下面字段默认值仅为示意(常与华为文档示例类似)。上线前务必改成你在华为云控制台申请的真实
appKey/appSecret/ 通道号 / 模板 ID / 接收号码,切勿把真实密钥提交到公开仓库。
package org.thingsboard.rule.engine.sms.external.huawei;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.rule.engine.api.NodeConfiguration;
@Data
@Slf4j
public class HuaWeiSendSmsNodeConfiguration implements NodeConfiguration<HuaWeiSendSmsNodeConfiguration> {
// 必填,请参考"开发准备"获取如下数据,替换为实际值
String url = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1";
String appKey = "YOUR_APP_KEY";
String appSecret = "YOUR_APP_SECRET";
String sender = "YOUR_SENDER";
String templateId = "YOUR_TEMPLATE_ID";
// 条件必填: 通用模板时必填已审核签名名称
String signature = "YOUR_SIGNATURE";
// 必填,全局号码格式(包含国家码),多个号码英文逗号分隔
String receiver = "+86151****6789";
String statusCallBack = "";
/**
* 选填,无变量模板请赋空值 String templateParas = "";
* 单变量示例: "[\"369751\"]"
* 双变量示例: "[\"3\",\"人民公园正门\"]"
*/
String templateParas = "[\"369751\"]";
@Override
public HuaWeiSendSmsNodeConfiguration defaultConfiguration() {
HuaWeiSendSmsNodeConfiguration configuration = new HuaWeiSendSmsNodeConfiguration();
configuration.setUrl(url);
configuration.setAppKey(appKey);
configuration.setAppSecret(appSecret);
configuration.setSender(sender);
configuration.setTemplateId(templateId);
configuration.setSignature(signature);
configuration.setReceiver(receiver);
configuration.setStatusCallBack(statusCallBack);
configuration.setTemplateParas(templateParas);
return configuration;
}
}定义规则节点
主要关注 @RuleNode 中的配置。这个节点 type 是 EXTERNAL,name 是 hua wei sms,然后就是 uiResources。这个 uiResources 指向一个静态文件,用来渲染节点的 js 文件。因为是自定义的,所以引入的是官方示例程序里的 static/rulenode/custom-nodes-config.js,并把这个文件拷贝到 ThingsBoard 的 resources 目录下。指定 icon 为 sms 即可。
package org.thingsboard.rule.engine.sms.external.huawei;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.rule.engine.api.*;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.msg.TbMsg;
@Slf4j
@RuleNode(
type = ComponentType.EXTERNAL,
name = "hua wei sms",
relationTypes = {"True", "False"},
configClazz = HuaWeiSendSmsNodeConfiguration.class,
nodeDescription = "Use hua wei sms send msg.",
nodeDetails = "If send msg success - send Message via <b>True</b> chain, otherwise <b>False</b> chain is used.",
uiResources = {"static/rulenode/custom-nodes-config.js"},
icon = "sms")
public class HuaWeiSendSmsNode implements TbNode {
HuaWeiSendSmsService huaWeiSendSmsService;
@Override
public void init(TbContext tbContext, TbNodeConfiguration configuration) throws TbNodeException {
HuaWeiSendSmsNodeConfiguration config =
TbNodeUtils.convert(configuration, HuaWeiSendSmsNodeConfiguration.class);
String url = config.getUrl();
String appKey = config.getAppKey();
String appSecret = config.getAppSecret();
String sender = config.getSender();
String templateId = config.getTemplateId();
String signature = config.getSignature();
String receiver = config.getReceiver();
String statusCallBack = config.getStatusCallBack();
String templateParas = config.getTemplateParas();
huaWeiSendSmsService = new HuaWeiSendSmsService(url, appKey, appSecret, sender,
templateId, signature, receiver, statusCallBack, templateParas);
}
@Override
public void onMsg(TbContext ctx, TbMsg msg) {
try {
ctx.tellNext(msg, huaWeiSendSmsService.sendMsg() ? "True" : "False");
} catch (Exception e) {
ctx.tellFailure(msg, e);
}
}
@Override
public void destroy() {
}
}
3、如何使用
第一步: 首先重启程序,然后打开规则链页面,可以看到自定义的节点已经出现在**外部(EXTERNAL)**分类下面。

第二步: 配置自定义的规则节点,按照华为云官方配置指定的参数。需要配置的参数全部抽出来了:

参数不解释,请看华为云 Java 案例,说得很清楚。最重要的就是你需要配置短信模板 templateId,以及模板需要的参数 templateParas:
https://support.huaweicloud.com/devg-msgsms/sms_04_0002.html
第三步: 很多人好奇为什么一打开节点就自动显示这些参数,是从何而来?首先看 HuaWeiSendSmsNodeConfiguration 类中的代码(同上节)。
此类中定义的变量就会渲染到规则节点的输入框中,相当于为这些变量赋予了默认值。规则链在运行的时候,会调用 defaultConfiguration 方法,将参数配置到 HuaWeiSendSmsNodeConfiguration 对象,然后传递到 HuaWeiSendSmsNode 类中就可以使用传入的参数了。

然后触发 onMsg 方法进行执行:

4、验证
由于我使用的是测试,没有权限开通华为云的 SMS(一般要企业才能开通),但是我调试过,规则节点都没有问题,就是在执行发送短信的时候提示没有开通服务,那些 appKey 都是无效的。如果你们公司开通了就可以配置进行测试。
补充(辅)
- 自定义节点放在
rule-engine相关模块更常见;改完需重新编译 / 重启,UI 才会出现新 EXTERNAL 节点。 uiResources指向的custom-nodes-config.js需能被前端加载到,否则节点配置界面可能异常。- 华为云返回码
000000表示成功(与原稿判断一致)。 - 原稿中的
trustAllHttpsCertificates会信任所有证书,仅适合试验;生产请使用正规证书校验。 - 密钥、通道号、模板 ID、手机号一律用控制台真实值;不要把密钥写进公开文档或 Git。
- 打 jar / 部署可参考:打成可执行 JAR、制作镜像。
