Skip to content

ThingsBoard 自定义华为云 SMS 规则节点 ​

正文与配图来自原稿。在源码中自定义规则节点对接华为云短信;思路可同样扩展到阿里、腾讯、七牛等。
相关:如何发送告警邮件 · 配置发送邮件 · 项目结构(规则引擎)

1、概述 ​

一个物联网平台承载着很多设备的连接,当设备出现异常的时候,能够快速通知到运维管理员非常重要。ThingsBoard 提供了自定义配置邮箱,但是它对支持发送短信不是很友好,内置多是国外的 SMS 服务商。

在国内常见就是阿里、腾讯、华为、七牛等 SMS 服务商。如果需要发短信,肯定优先用国内厂商。但 ThingsBoard 里没看到哪里可以直接配这些,于是自定义规则节点来实现这个功能,同时把规则节点自定义也介绍了。本文首先以华为 SMS 为例。

规则链中使用自定义短信节点示意

2、自定义规则节点 ​

概述 ​

我是直接在本地的 ThingsBoard 程序中增加的节点,也可以按照官方使用官方提供的那个程序来写,然后打成 jar 引入。

官方教程 ​

这个官方教程说得不清楚,另外一个类如何定义也没说:

https://thingsboard.io/docs/user-guide/contribution/rule-node-development/#step-7-rule-node-ui-customization-optional

创建包 ​

在本地程序创建了一个 external 包,意思是扩展 SMS。

创建 external 扩展包

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

按厂商分子目录

定义接口 ​

定义了一个发送短信的接口 SendSms,其它每个厂商来实现这个接口:

java
package org.thingsboard.rule.engine.sms.external;

public interface SendSms {
    boolean sendMsg();
    ExternalSmsType getType();
}

定义枚举类 ​

定义了 SMS 厂商的名字:

java
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

java
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 / 接收号码,切勿把真实密钥提交到公开仓库。

java
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 即可。

java
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)**分类下面。

规则链面板出现 hua wei sms 节点

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

节点配置面板

参数不解释,请看华为云 Java 案例,说得很清楚。最重要的就是你需要配置短信模板 templateId,以及模板需要的参数 templateParas:

https://support.huaweicloud.com/devg-msgsms/sms_04_0002.html

第三步: 很多人好奇为什么一打开节点就自动显示这些参数,是从何而来?首先看 HuaWeiSendSmsNodeConfiguration 类中的代码(同上节)。

此类中定义的变量就会渲染到规则节点的输入框中,相当于为这些变量赋予了默认值。规则链在运行的时候,会调用 defaultConfiguration 方法,将参数配置到 HuaWeiSendSmsNodeConfiguration 对象,然后传递到 HuaWeiSendSmsNode 类中就可以使用传入的参数了。

配置类字段渲染到 UI

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

onMsg 调用 sendMsg

4、验证 ​

由于我使用的是测试,没有权限开通华为云的 SMS(一般要企业才能开通),但是我调试过,规则节点都没有问题,就是在执行发送短信的时候提示没有开通服务,那些 appKey 都是无效的。如果你们公司开通了就可以配置进行测试。


补充(辅) ​

  • 自定义节点放在 rule-engine 相关模块更常见;改完需重新编译 / 重启,UI 才会出现新 EXTERNAL 节点。
  • uiResources 指向的 custom-nodes-config.js 需能被前端加载到,否则节点配置界面可能异常。
  • 华为云返回码 000000 表示成功(与原稿判断一致)。
  • 原稿中的 trustAllHttpsCertificates 会信任所有证书,仅适合试验;生产请使用正规证书校验。
  • 密钥、通道号、模板 ID、手机号一律用控制台真实值;不要把密钥写进公开文档或 Git。
  • 打 jar / 部署可参考:打成可执行 JAR、制作镜像。

相关文章 ​