mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-18 20:49:45 +08:00
1192 lines
34 KiB
Go
1192 lines
34 KiB
Go
package db
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/logger"
|
||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||
"GoNavi-Wails/internal/ssh"
|
||
|
||
pahomqtt "github.com/eclipse/paho.mqtt.golang"
|
||
)
|
||
|
||
const (
|
||
defaultMQTTPort = 1883
|
||
defaultMQTTQueryTimeout = 30 * time.Second
|
||
defaultMQTTPreviewLimit = 100
|
||
defaultMQTTFetchWait = 4 * time.Second
|
||
maxMQTTFetchWait = 30 * time.Second
|
||
mqttSyntheticDatabase = "topics"
|
||
mqttDefaultClientID = "GoNavi"
|
||
)
|
||
|
||
type mqttRuntime interface {
|
||
Close() error
|
||
Ping(ctx context.Context) error
|
||
FetchMessages(ctx context.Context, request mqttFetchRequest) ([]mqttMessageRecord, error)
|
||
Publish(ctx context.Context, command mqttPublishCommand) (int64, error)
|
||
}
|
||
|
||
type mqttFetchRequest struct {
|
||
Topic string
|
||
Limit int
|
||
Offset int
|
||
QoS byte
|
||
Wait time.Duration
|
||
}
|
||
|
||
type mqttPublishCommand struct {
|
||
Topic string
|
||
Payload interface{}
|
||
QoS byte
|
||
Retain bool
|
||
}
|
||
|
||
type mqttMessageRecord struct {
|
||
Topic string
|
||
QoS byte
|
||
Retained bool
|
||
Duplicate bool
|
||
MessageID uint16
|
||
Payload []byte
|
||
Decoded interface{}
|
||
Encoding string
|
||
ReceivedAt time.Time
|
||
}
|
||
|
||
type mqttTopicDescriptor struct {
|
||
Filter string
|
||
Default bool
|
||
Wildcard bool
|
||
Source string
|
||
}
|
||
|
||
type pahoMQTTRuntime struct {
|
||
client pahomqtt.Client
|
||
timeout time.Duration
|
||
}
|
||
|
||
var newMQTTRuntime = func(config connection.ConnectionConfig) (mqttRuntime, error) {
|
||
return newPahoMQTTRuntime(config)
|
||
}
|
||
|
||
type MQTTDB struct {
|
||
runtime mqttRuntime
|
||
forwarders []*ssh.LocalForwarder
|
||
brokers []string
|
||
defaultTopic string
|
||
topics []mqttTopicDescriptor
|
||
defaultQoS byte
|
||
defaultRetain bool
|
||
cleanSession bool
|
||
fetchWait time.Duration
|
||
}
|
||
|
||
func (m *MQTTDB) Connect(config connection.ConnectionConfig) error {
|
||
_ = m.Close()
|
||
|
||
runConfig := normalizeMQTTConfig(config)
|
||
if runConfig.UseSSH {
|
||
sshConfig, brokers, forwarders, err := mqttForwardBrokersOverSSH(runConfig)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m.forwarders = forwarders
|
||
runConfig = sshConfig
|
||
runConfig.Hosts = brokers[1:]
|
||
host, port, ok := parseHostPortWithDefault(brokers[0], defaultMQTTPort)
|
||
if !ok {
|
||
_ = m.Close()
|
||
return fmt.Errorf("解析 MQTT SSH 转发地址失败:%s", brokers[0])
|
||
}
|
||
runConfig.Host = host
|
||
runConfig.Port = port
|
||
runConfig.UseSSH = false
|
||
logger.Infof("MQTT 通过 SSH 端口转发连接:brokers=%s", strings.Join(brokers, ","))
|
||
}
|
||
|
||
runtime, err := newMQTTRuntime(runConfig)
|
||
if err != nil {
|
||
_ = m.Close()
|
||
return err
|
||
}
|
||
m.runtime = runtime
|
||
m.defaultTopic = mqttDefaultTopic(runConfig)
|
||
m.topics = mqttConfiguredTopics(runConfig, m.defaultTopic)
|
||
m.defaultQoS = mqttDefaultQoS(runConfig)
|
||
m.defaultRetain = mqttDefaultRetain(runConfig)
|
||
m.cleanSession = mqttCleanSession(runConfig)
|
||
m.fetchWait = mqttFetchWait(runConfig)
|
||
m.brokers, _ = mqttBrokerAddresses(runConfig)
|
||
|
||
if err := m.Ping(); err != nil {
|
||
_ = m.Close()
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m *MQTTDB) Close() error {
|
||
var firstErr error
|
||
if m.runtime != nil {
|
||
if err := m.runtime.Close(); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
m.runtime = nil
|
||
}
|
||
for _, forwarder := range m.forwarders {
|
||
if forwarder == nil {
|
||
continue
|
||
}
|
||
if err := forwarder.Close(); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
m.forwarders = nil
|
||
m.brokers = nil
|
||
m.defaultTopic = ""
|
||
m.topics = nil
|
||
m.defaultQoS = 0
|
||
m.defaultRetain = false
|
||
m.cleanSession = false
|
||
m.fetchWait = 0
|
||
return firstErr
|
||
}
|
||
|
||
func (m *MQTTDB) Ping() error {
|
||
if m.runtime == nil {
|
||
return fmt.Errorf("连接未打开")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
return m.runtime.Ping(ctx)
|
||
}
|
||
|
||
func (m *MQTTDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), defaultMQTTQueryTimeout)
|
||
defer cancel()
|
||
return m.QueryContext(ctx, query)
|
||
}
|
||
|
||
func (m *MQTTDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||
if m.runtime == nil {
|
||
return nil, nil, fmt.Errorf("连接未打开")
|
||
}
|
||
text := strings.TrimSpace(query)
|
||
if text == "" {
|
||
return nil, nil, fmt.Errorf("查询语句不能为空")
|
||
}
|
||
parsed, ok := parseMQTTSQL(text)
|
||
if !ok {
|
||
return nil, nil, fmt.Errorf("MQTT 查询仅支持 SHOW TOPICS、DESCRIBE TOPIC、SELECT * FROM topic 与 CONSUME FROM topic")
|
||
}
|
||
|
||
switch parsed.Action {
|
||
case "show_topics":
|
||
rows := mqttTopicRows(m.topics, m.defaultQoS, m.defaultRetain)
|
||
if parsed.Limit > 0 && len(rows) > parsed.Limit {
|
||
rows = rows[:parsed.Limit]
|
||
}
|
||
return rows, collectColumns(rows), nil
|
||
case "describe_topic":
|
||
topic := mqttResolveTopic(parsed.Topic, m.defaultTopic)
|
||
if topic == "" {
|
||
return nil, nil, fmt.Errorf("MQTT topic 不能为空")
|
||
}
|
||
rows := []map[string]interface{}{mqttDescribeTopicRow(topic, m.topics, m.defaultQoS, m.defaultRetain, m.cleanSession, m.fetchWait, m.brokers)}
|
||
return rows, collectColumns(rows), nil
|
||
case "select", "consume":
|
||
if parsed.Count {
|
||
return nil, nil, fmt.Errorf("MQTT 不支持 COUNT(*) 总量统计;请使用 SELECT * FROM topic LIMIT n 预览实时消息")
|
||
}
|
||
topic := mqttResolveTopic(parsed.Topic, m.defaultTopic)
|
||
if topic == "" {
|
||
return nil, nil, fmt.Errorf("MQTT topic 不能为空")
|
||
}
|
||
records, err := m.runtime.FetchMessages(ctx, mqttFetchRequest{
|
||
Topic: topic,
|
||
Limit: parsed.Limit,
|
||
Offset: parsed.Offset,
|
||
QoS: m.defaultQoS,
|
||
Wait: m.fetchWait,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
rows := mqttMessageRows(records)
|
||
return rows, collectColumns(rows), nil
|
||
default:
|
||
return nil, nil, fmt.Errorf("未实现的 MQTT 查询类型:%s", parsed.Action)
|
||
}
|
||
}
|
||
|
||
func (m *MQTTDB) Exec(query string) (int64, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), defaultMQTTQueryTimeout)
|
||
defer cancel()
|
||
return m.ExecContext(ctx, query)
|
||
}
|
||
|
||
func (m *MQTTDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||
if m.runtime == nil {
|
||
return 0, fmt.Errorf("连接未打开")
|
||
}
|
||
var cmd map[string]interface{}
|
||
if err := decodeJSONWithUseNumber([]byte(strings.TrimSpace(query)), &cmd); err != nil {
|
||
return 0, fmt.Errorf("MQTT 写入命令必须是 JSON:%w", err)
|
||
}
|
||
|
||
topic := mqttResolveTopic(firstStringValue(cmd, "publish", "topic", "destination"), m.defaultTopic)
|
||
if err := mqttValidatePublishTopic(topic); err != nil {
|
||
return 0, err
|
||
}
|
||
if !hasAnyKey(cmd, "payload", "value", "body", "message") {
|
||
return 0, fmt.Errorf("MQTT publish 命令缺少 payload")
|
||
}
|
||
qos, err := mqttQoSFromAny(firstExisting(cmd, "qos"), m.defaultQoS)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
retain := mqttBoolFromAny(firstExisting(cmd, "retain", "retained"), m.defaultRetain)
|
||
|
||
return m.runtime.Publish(ctx, mqttPublishCommand{
|
||
Topic: topic,
|
||
Payload: firstExisting(cmd, "payload", "value", "body", "message"),
|
||
QoS: qos,
|
||
Retain: retain,
|
||
})
|
||
}
|
||
|
||
func (m *MQTTDB) GetDatabases() ([]string, error) {
|
||
if m.runtime == nil {
|
||
return nil, fmt.Errorf("连接未打开")
|
||
}
|
||
return []string{mqttSyntheticDatabase}, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetTables(dbName string) ([]string, error) {
|
||
if m.runtime == nil {
|
||
return nil, fmt.Errorf("连接未打开")
|
||
}
|
||
names := make([]string, 0, len(m.topics))
|
||
for _, topic := range m.topics {
|
||
if strings.TrimSpace(topic.Filter) != "" {
|
||
names = append(names, topic.Filter)
|
||
}
|
||
}
|
||
sort.Strings(names)
|
||
return names, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||
if m.runtime == nil {
|
||
return "", fmt.Errorf("连接未打开")
|
||
}
|
||
topic := mqttResolveTopic(tableName, m.defaultTopic)
|
||
if topic == "" {
|
||
return "", fmt.Errorf("MQTT topic 不能为空")
|
||
}
|
||
payload, _ := json.MarshalIndent(
|
||
mqttDescribeTopicRow(topic, m.topics, m.defaultQoS, m.defaultRetain, m.cleanSession, m.fetchWait, m.brokers),
|
||
"",
|
||
" ",
|
||
)
|
||
return fmt.Sprintf("// MQTT topic filter: %s\n%s", topic, string(payload)), nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||
if m.runtime == nil {
|
||
return nil, fmt.Errorf("连接未打开")
|
||
}
|
||
topic := mqttResolveTopic(tableName, m.defaultTopic)
|
||
if topic == "" {
|
||
return nil, fmt.Errorf("MQTT topic 不能为空")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
records, err := m.runtime.FetchMessages(ctx, mqttFetchRequest{
|
||
Topic: topic,
|
||
Limit: 20,
|
||
QoS: m.defaultQoS,
|
||
Wait: m.fetchWait,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows := mqttMessageRows(records)
|
||
columns := []connection.ColumnDefinition{
|
||
{Name: "topic", Type: "string", Nullable: "NO", Comment: "MQTT topic"},
|
||
{Name: "qos", Type: "tinyint", Nullable: "NO", Comment: "MQTT QoS level"},
|
||
{Name: "retained", Type: "bool", Nullable: "YES", Comment: "Whether the message is retained"},
|
||
{Name: "duplicate", Type: "bool", Nullable: "YES", Comment: "Whether the message is marked as duplicate"},
|
||
{Name: "message_id", Type: "int", Nullable: "YES", Comment: "MQTT message id"},
|
||
{Name: "payload", Type: "json", Nullable: "YES", Comment: "Decoded MQTT payload"},
|
||
{Name: "payload_encoding", Type: "string", Nullable: "YES", Comment: "json / text / base64"},
|
||
{Name: "payload_bytes", Type: "int", Nullable: "YES", Comment: "Payload size in bytes"},
|
||
{Name: "received_at", Type: "timestamp", Nullable: "YES", Comment: "Client receive timestamp"},
|
||
}
|
||
seen := map[string]struct{}{
|
||
"topic": {}, "qos": {}, "retained": {}, "duplicate": {}, "message_id": {},
|
||
"payload": {}, "payload_encoding": {}, "payload_bytes": {}, "received_at": {},
|
||
}
|
||
for _, row := range rows {
|
||
for key, value := range row {
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(key, "payload.") {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
columns = append(columns, connection.ColumnDefinition{
|
||
Name: key,
|
||
Type: inferChromaValueType(value),
|
||
Nullable: "YES",
|
||
Comment: "Derived MQTT payload field",
|
||
})
|
||
}
|
||
}
|
||
return columns, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||
tables, err := m.GetTables(dbName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var result []connection.ColumnDefinitionWithTable
|
||
for _, table := range tables {
|
||
cols, err := m.GetColumns(dbName, table)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, col := range cols {
|
||
result = append(result, connection.ColumnDefinitionWithTable{
|
||
TableName: table,
|
||
Name: col.Name,
|
||
Type: col.Type,
|
||
Comment: col.Comment,
|
||
})
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||
return []connection.IndexDefinition{
|
||
{Name: "TOPIC_RECEIVED_AT", ColumnName: "topic", NonUnique: 1, SeqInIndex: 1, IndexType: "SUBSCRIPTION"},
|
||
{Name: "TOPIC_RECEIVED_AT", ColumnName: "received_at", NonUnique: 1, SeqInIndex: 2, IndexType: "SUBSCRIPTION"},
|
||
}, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||
return []connection.ForeignKeyDefinition{}, nil
|
||
}
|
||
|
||
func (m *MQTTDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||
return []connection.TriggerDefinition{}, nil
|
||
}
|
||
|
||
func (m *MQTTDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||
if len(changes.Inserts) == 0 && len(changes.Updates) == 0 && len(changes.Deletes) == 0 {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("MQTT 结果集仅支持只读预览;如需写入请在 SQL 编辑器执行 JSON publish 命令")
|
||
}
|
||
|
||
func normalizeMQTTConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||
runConfig := applyMQTTURI(config)
|
||
if strings.TrimSpace(runConfig.Host) == "" && len(runConfig.Hosts) == 0 {
|
||
runConfig.Host = "localhost"
|
||
}
|
||
if runConfig.Port <= 0 {
|
||
runConfig.Port = defaultMQTTPort
|
||
}
|
||
params := mqttConnectionParams(runConfig)
|
||
transport := mqttTransportScheme(runConfig)
|
||
if transport == "ssl" || transport == "wss" || mqttBoolValue(firstNonEmpty(params.Get("ssl"), params.Get("tls"), params.Get("useSSL"), params.Get("use_ssl"))) {
|
||
runConfig.UseSSL = true
|
||
}
|
||
if strings.TrimSpace(runConfig.SSLMode) == "" && runConfig.UseSSL {
|
||
if mqttBoolValue(firstNonEmpty(params.Get("skip_verify"), params.Get("skipVerify"), params.Get("insecure"))) {
|
||
runConfig.SSLMode = "skip-verify"
|
||
} else {
|
||
runConfig.SSLMode = "required"
|
||
}
|
||
}
|
||
return runConfig
|
||
}
|
||
|
||
func applyMQTTURI(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||
uriText := strings.TrimSpace(config.URI)
|
||
if uriText == "" {
|
||
return config
|
||
}
|
||
parsed, err := url.Parse(uriText)
|
||
if err != nil {
|
||
return config
|
||
}
|
||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
||
switch scheme {
|
||
case "mqtt", "mqtts", "tcp", "ssl", "tls", "ws", "wss":
|
||
default:
|
||
return config
|
||
}
|
||
|
||
if parsed.User != nil {
|
||
if strings.TrimSpace(config.User) == "" {
|
||
config.User = parsed.User.Username()
|
||
}
|
||
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
|
||
config.Password = pass
|
||
}
|
||
}
|
||
|
||
hosts := make([]string, 0, 4)
|
||
for _, entry := range strings.Split(strings.TrimSpace(parsed.Host), ",") {
|
||
host, port, ok := parseHostPortWithDefault(strings.TrimSpace(entry), defaultMQTTPort)
|
||
if !ok {
|
||
continue
|
||
}
|
||
hosts = append(hosts, mqttFormatHostPort(host, port))
|
||
}
|
||
if len(hosts) > 0 {
|
||
host, port, ok := parseHostPortWithDefault(hosts[0], defaultMQTTPort)
|
||
if ok {
|
||
config.Host = host
|
||
config.Port = port
|
||
}
|
||
if len(hosts) > 1 {
|
||
config.Hosts = append([]string(nil), hosts[1:]...)
|
||
}
|
||
}
|
||
if topic := strings.Trim(strings.TrimSpace(parsed.Path), "/"); topic != "" && strings.TrimSpace(config.Database) == "" {
|
||
config.Database = topic
|
||
}
|
||
params := parsed.Query()
|
||
if strings.TrimSpace(config.Topology) == "" {
|
||
if topology := strings.ToLower(strings.TrimSpace(firstNonEmpty(params.Get("topology"), params.Get("mode")))); topology != "" {
|
||
config.Topology = topology
|
||
} else if len(hosts) > 1 {
|
||
config.Topology = "cluster"
|
||
}
|
||
}
|
||
if scheme == "ssl" || scheme == "tls" || scheme == "mqtts" || scheme == "wss" {
|
||
config.UseSSL = true
|
||
if strings.TrimSpace(config.SSLMode) == "" {
|
||
config.SSLMode = "required"
|
||
}
|
||
}
|
||
return config
|
||
}
|
||
|
||
func mqttConnectionParams(config connection.ConnectionConfig) url.Values {
|
||
params := url.Values{}
|
||
mergeConnectionParamValues(params, connectionParamsFromURI(config.URI, "mqtt", "mqtts", "tcp", "ssl", "tls", "ws", "wss"))
|
||
mergeConnectionParamValues(params, connectionParamsFromText(config.ConnectionParams))
|
||
return params
|
||
}
|
||
|
||
func mqttDefaultTopic(config connection.ConnectionConfig) string {
|
||
if topic := strings.TrimSpace(config.Database); topic != "" {
|
||
return topic
|
||
}
|
||
params := mqttConnectionParams(config)
|
||
return strings.TrimSpace(firstNonEmpty(params.Get("defaultTopic"), params.Get("default_topic"), params.Get("topic")))
|
||
}
|
||
|
||
func mqttConfiguredTopics(config connection.ConnectionConfig, defaultTopic string) []mqttTopicDescriptor {
|
||
seen := make(map[string]struct{})
|
||
topics := make([]mqttTopicDescriptor, 0, 8)
|
||
appendTopic := func(raw string, isDefault bool, source string) {
|
||
filter := strings.TrimSpace(raw)
|
||
if filter == "" {
|
||
return
|
||
}
|
||
if _, ok := seen[filter]; ok {
|
||
if isDefault {
|
||
for index := range topics {
|
||
if topics[index].Filter == filter {
|
||
topics[index].Default = true
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
seen[filter] = struct{}{}
|
||
topics = append(topics, mqttTopicDescriptor{
|
||
Filter: filter,
|
||
Default: isDefault,
|
||
Wildcard: strings.ContainsAny(filter, "#+"),
|
||
Source: source,
|
||
})
|
||
}
|
||
|
||
appendTopic(defaultTopic, defaultTopic != "", "default")
|
||
|
||
params := mqttConnectionParams(config)
|
||
for _, key := range []string{"topics", "topicFilters", "topic_filters", "subscriptions", "subscription", "subscribe"} {
|
||
for _, value := range params[key] {
|
||
for _, part := range splitMQTTTopicList(value) {
|
||
appendTopic(part, false, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
sort.SliceStable(topics, func(i, j int) bool {
|
||
if topics[i].Default != topics[j].Default {
|
||
return topics[i].Default
|
||
}
|
||
return topics[i].Filter < topics[j].Filter
|
||
})
|
||
return topics
|
||
}
|
||
|
||
func splitMQTTTopicList(raw string) []string {
|
||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||
return r == ',' || r == ';' || r == '\n' || r == '\r'
|
||
})
|
||
result := make([]string, 0, len(fields))
|
||
for _, field := range fields {
|
||
if text := strings.TrimSpace(field); text != "" {
|
||
result = append(result, text)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func mqttDefaultQoS(config connection.ConnectionConfig) byte {
|
||
value, err := mqttQoSFromAny(firstNonEmpty(mqttConnectionParams(config).Get("qos"), "0"), 0)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return value
|
||
}
|
||
|
||
func mqttDefaultRetain(config connection.ConnectionConfig) bool {
|
||
params := mqttConnectionParams(config)
|
||
return mqttBoolValue(firstNonEmpty(params.Get("retain"), params.Get("retained")))
|
||
}
|
||
|
||
func mqttCleanSession(config connection.ConnectionConfig) bool {
|
||
params := mqttConnectionParams(config)
|
||
value := strings.TrimSpace(firstNonEmpty(params.Get("cleanSession"), params.Get("clean_session")))
|
||
if value == "" {
|
||
return true
|
||
}
|
||
return mqttBoolValue(value)
|
||
}
|
||
|
||
func mqttFetchWait(config connection.ConnectionConfig) time.Duration {
|
||
params := mqttConnectionParams(config)
|
||
for _, key := range []string{"fetchWaitMs", "fetch_wait_ms", "waitMs", "wait_ms"} {
|
||
if value := strings.TrimSpace(params.Get(key)); value != "" {
|
||
if ms, err := strconv.Atoi(value); err == nil && ms > 0 {
|
||
wait := time.Duration(ms) * time.Millisecond
|
||
if wait > maxMQTTFetchWait {
|
||
return maxMQTTFetchWait
|
||
}
|
||
return wait
|
||
}
|
||
}
|
||
}
|
||
for _, key := range []string{"fetchWait", "wait"} {
|
||
if value := strings.TrimSpace(params.Get(key)); value != "" {
|
||
if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 {
|
||
wait := time.Duration(seconds) * time.Second
|
||
if wait > maxMQTTFetchWait {
|
||
return maxMQTTFetchWait
|
||
}
|
||
return wait
|
||
}
|
||
}
|
||
}
|
||
return defaultMQTTFetchWait
|
||
}
|
||
|
||
func mqttClientID(config connection.ConnectionConfig) string {
|
||
params := mqttConnectionParams(config)
|
||
if clientID := strings.TrimSpace(firstNonEmpty(params.Get("clientId"), params.Get("client_id"))); clientID != "" {
|
||
return clientID
|
||
}
|
||
if id := strings.TrimSpace(config.ID); id != "" {
|
||
return mqttDefaultClientID + "-" + id
|
||
}
|
||
return fmt.Sprintf("%s-%d", mqttDefaultClientID, time.Now().UnixNano())
|
||
}
|
||
|
||
func mqttTransportScheme(config connection.ConnectionConfig) string {
|
||
if parsed, err := url.Parse(strings.TrimSpace(config.URI)); err == nil {
|
||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
||
case "ssl", "tls", "mqtts":
|
||
return "ssl"
|
||
case "wss":
|
||
return "wss"
|
||
case "ws":
|
||
return "ws"
|
||
case "tcp", "mqtt":
|
||
return "tcp"
|
||
}
|
||
}
|
||
params := mqttConnectionParams(config)
|
||
switch strings.ToLower(strings.TrimSpace(firstNonEmpty(params.Get("transport"), params.Get("scheme")))) {
|
||
case "ssl", "tls", "mqtts":
|
||
return "ssl"
|
||
case "wss":
|
||
return "wss"
|
||
case "ws":
|
||
return "ws"
|
||
}
|
||
if config.UseSSL {
|
||
return "ssl"
|
||
}
|
||
return "tcp"
|
||
}
|
||
|
||
func mqttBrokerAddresses(config connection.ConnectionConfig) ([]string, error) {
|
||
hosts := make([]string, 0, 4)
|
||
if host, port, ok := parseHostPortWithDefault(net.JoinHostPort(strings.TrimSpace(config.Host), strconv.Itoa(config.Port)), defaultMQTTPort); ok && strings.TrimSpace(host) != "" {
|
||
hosts = append(hosts, mqttFormatHostPort(host, port))
|
||
}
|
||
for _, entry := range config.Hosts {
|
||
host, port, ok := parseHostPortWithDefault(strings.TrimSpace(entry), defaultMQTTPort)
|
||
if !ok {
|
||
continue
|
||
}
|
||
hosts = append(hosts, mqttFormatHostPort(host, port))
|
||
}
|
||
hosts = uniqueStringsPreserveOrder(hosts)
|
||
if len(hosts) == 0 {
|
||
return nil, fmt.Errorf("MQTT 至少需要一个 broker 地址")
|
||
}
|
||
return hosts, nil
|
||
}
|
||
|
||
func mqttFormatHostPort(host string, port int) string {
|
||
return net.JoinHostPort(strings.TrimSpace(host), strconv.Itoa(port))
|
||
}
|
||
|
||
func mqttForwardBrokersOverSSH(config connection.ConnectionConfig) (connection.ConnectionConfig, []string, []*ssh.LocalForwarder, error) {
|
||
brokers, err := mqttBrokerAddresses(config)
|
||
if err != nil {
|
||
return connection.ConnectionConfig{}, nil, nil, err
|
||
}
|
||
runConfig := config
|
||
forwarders := make([]*ssh.LocalForwarder, 0, len(brokers))
|
||
rewritten := make([]string, 0, len(brokers))
|
||
for _, broker := range brokers {
|
||
host, port, ok := parseHostPortWithDefault(broker, defaultMQTTPort)
|
||
if !ok {
|
||
return connection.ConnectionConfig{}, nil, nil, fmt.Errorf("解析 MQTT broker 地址失败:%s", broker)
|
||
}
|
||
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, host, port)
|
||
if err != nil {
|
||
return connection.ConnectionConfig{}, nil, nil, fmt.Errorf("创建 MQTT SSH 隧道失败:%w", err)
|
||
}
|
||
forwarders = append(forwarders, forwarder)
|
||
rewritten = append(rewritten, forwarder.LocalAddr)
|
||
}
|
||
return runConfig, rewritten, forwarders, nil
|
||
}
|
||
|
||
func newPahoMQTTRuntime(config connection.ConnectionConfig) (mqttRuntime, error) {
|
||
brokers, err := mqttBrokerAddresses(config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
timeout := getConnectTimeout(config)
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Second
|
||
}
|
||
transport := mqttTransportScheme(config)
|
||
if config.UseProxy && (transport == "ws" || transport == "wss") {
|
||
return nil, fmt.Errorf("MQTT 当前暂不支持通过代理建立 WebSocket 连接,请改用 tcp/ssl")
|
||
}
|
||
tlsConfig, err := resolveGenericTLSConfig(config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
options := pahomqtt.NewClientOptions().
|
||
SetClientID(mqttClientID(config)).
|
||
SetCleanSession(mqttCleanSession(config)).
|
||
SetOrderMatters(false).
|
||
SetAutoReconnect(false).
|
||
SetConnectRetry(false).
|
||
SetConnectTimeout(timeout).
|
||
SetWriteTimeout(timeout)
|
||
|
||
if user := strings.TrimSpace(config.User); user != "" {
|
||
options.SetUsername(user)
|
||
options.SetPassword(config.Password)
|
||
}
|
||
if transport == "ssl" || transport == "wss" {
|
||
options.SetTLSConfig(tlsConfig)
|
||
}
|
||
for _, broker := range brokers {
|
||
options.AddBroker(fmt.Sprintf("%s://%s", transport, broker))
|
||
}
|
||
if config.UseProxy {
|
||
options.SetCustomOpenConnectionFn(mqttProxyOpenConnectionFn(config.Proxy, timeout, tlsConfig))
|
||
}
|
||
|
||
client := pahomqtt.NewClient(options)
|
||
token := client.Connect()
|
||
if !token.WaitTimeout(timeout + 5*time.Second) {
|
||
return nil, fmt.Errorf("MQTT 连接超时")
|
||
}
|
||
if err := token.Error(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &pahoMQTTRuntime{
|
||
client: client,
|
||
timeout: timeout,
|
||
}, nil
|
||
}
|
||
|
||
func mqttProxyOpenConnectionFn(proxyConfig connection.ProxyConfig, timeout time.Duration, tlsConfig *tls.Config) func(uri *url.URL, options pahomqtt.ClientOptions) (net.Conn, error) {
|
||
return func(uri *url.URL, options pahomqtt.ClientOptions) (net.Conn, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
|
||
conn, err := proxytunnel.DialContext(ctx, proxyConfig, "tcp", uri.Host)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if uri.Scheme != "ssl" && uri.Scheme != "wss" {
|
||
return conn, nil
|
||
}
|
||
|
||
effectiveTLS := tlsConfig
|
||
if effectiveTLS == nil {
|
||
effectiveTLS = options.TLSConfig
|
||
}
|
||
if effectiveTLS == nil {
|
||
effectiveTLS = &tls.Config{}
|
||
}
|
||
cloned := effectiveTLS.Clone()
|
||
if cloned.ServerName == "" {
|
||
host, _, splitErr := net.SplitHostPort(uri.Host)
|
||
if splitErr == nil {
|
||
cloned.ServerName = host
|
||
} else {
|
||
cloned.ServerName = uri.Host
|
||
}
|
||
}
|
||
|
||
tlsConn := tls.Client(conn, cloned)
|
||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||
_ = conn.Close()
|
||
return nil, err
|
||
}
|
||
return tlsConn, nil
|
||
}
|
||
}
|
||
|
||
func (r *pahoMQTTRuntime) Close() error {
|
||
if r == nil || r.client == nil {
|
||
return nil
|
||
}
|
||
r.client.Disconnect(250)
|
||
r.client = nil
|
||
return nil
|
||
}
|
||
|
||
func (r *pahoMQTTRuntime) Ping(ctx context.Context) error {
|
||
if r == nil || r.client == nil {
|
||
return fmt.Errorf("连接未打开")
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
if !r.client.IsConnectionOpen() {
|
||
return fmt.Errorf("MQTT 连接已断开")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *pahoMQTTRuntime) FetchMessages(ctx context.Context, request mqttFetchRequest) ([]mqttMessageRecord, error) {
|
||
if r == nil || r.client == nil {
|
||
return nil, fmt.Errorf("连接未打开")
|
||
}
|
||
if !r.client.IsConnectionOpen() {
|
||
return nil, fmt.Errorf("MQTT 连接已断开")
|
||
}
|
||
|
||
limit := request.Limit
|
||
if limit <= 0 {
|
||
limit = defaultMQTTPreviewLimit
|
||
}
|
||
offset := request.Offset
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
wait := request.Wait
|
||
if wait <= 0 {
|
||
wait = defaultMQTTFetchWait
|
||
}
|
||
if wait > maxMQTTFetchWait {
|
||
wait = maxMQTTFetchWait
|
||
}
|
||
|
||
bufferSize := limit + offset + 8
|
||
if bufferSize < 8 {
|
||
bufferSize = 8
|
||
}
|
||
if bufferSize > 1024 {
|
||
bufferSize = 1024
|
||
}
|
||
messageCh := make(chan mqttMessageRecord, bufferSize)
|
||
callback := func(_ pahomqtt.Client, msg pahomqtt.Message) {
|
||
record := mqttRecordFromMessage(msg)
|
||
select {
|
||
case messageCh <- record:
|
||
default:
|
||
}
|
||
}
|
||
|
||
token := r.client.Subscribe(request.Topic, request.QoS, callback)
|
||
if !token.WaitTimeout(r.timeout) {
|
||
return nil, fmt.Errorf("MQTT 订阅超时")
|
||
}
|
||
if err := token.Error(); err != nil {
|
||
return nil, fmt.Errorf("MQTT 订阅失败:%w", err)
|
||
}
|
||
defer func() {
|
||
unsub := r.client.Unsubscribe(request.Topic)
|
||
if !unsub.WaitTimeout(r.timeout) {
|
||
logger.Warnf("MQTT 取消订阅超时:%s", request.Topic)
|
||
return
|
||
}
|
||
if err := unsub.Error(); err != nil {
|
||
logger.Warnf("MQTT 取消订阅失败:topic=%s err=%v", request.Topic, err)
|
||
}
|
||
}()
|
||
|
||
timer := time.NewTimer(wait)
|
||
defer timer.Stop()
|
||
|
||
result := make([]mqttMessageRecord, 0, limit)
|
||
for len(result) < limit {
|
||
select {
|
||
case <-ctx.Done():
|
||
if len(result) > 0 {
|
||
return result, nil
|
||
}
|
||
return nil, ctx.Err()
|
||
case <-timer.C:
|
||
return result, nil
|
||
case record := <-messageCh:
|
||
if offset > 0 {
|
||
offset--
|
||
continue
|
||
}
|
||
result = append(result, record)
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (r *pahoMQTTRuntime) Publish(ctx context.Context, command mqttPublishCommand) (int64, error) {
|
||
if r == nil || r.client == nil {
|
||
return 0, fmt.Errorf("连接未打开")
|
||
}
|
||
if !r.client.IsConnectionOpen() {
|
||
return 0, fmt.Errorf("MQTT 连接已断开")
|
||
}
|
||
payload, err := mqttEncodePayload(command.Payload)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
token := r.client.Publish(command.Topic, command.QoS, command.Retain, payload)
|
||
wait := r.timeout
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
if remaining := time.Until(deadline); remaining > 0 && remaining < wait {
|
||
wait = remaining
|
||
}
|
||
}
|
||
if !token.WaitTimeout(wait) {
|
||
return 0, fmt.Errorf("MQTT 发布超时")
|
||
}
|
||
if err := token.Error(); err != nil {
|
||
return 0, err
|
||
}
|
||
return 1, nil
|
||
}
|
||
|
||
func mqttEncodePayload(payload interface{}) ([]byte, error) {
|
||
switch typed := payload.(type) {
|
||
case nil:
|
||
return []byte{}, nil
|
||
case []byte:
|
||
return typed, nil
|
||
case string:
|
||
return []byte(typed), nil
|
||
default:
|
||
return json.Marshal(typed)
|
||
}
|
||
}
|
||
|
||
func mqttRecordFromMessage(message pahomqtt.Message) mqttMessageRecord {
|
||
decoded, encoding := mqttDecodePayload(message.Payload())
|
||
return mqttMessageRecord{
|
||
Topic: message.Topic(),
|
||
QoS: message.Qos(),
|
||
Retained: message.Retained(),
|
||
Duplicate: message.Duplicate(),
|
||
MessageID: message.MessageID(),
|
||
Payload: append([]byte(nil), message.Payload()...),
|
||
Decoded: decoded,
|
||
Encoding: encoding,
|
||
ReceivedAt: time.Now(),
|
||
}
|
||
}
|
||
|
||
func mqttDecodePayload(payload []byte) (interface{}, string) {
|
||
if payload == nil {
|
||
return nil, "text"
|
||
}
|
||
var decoded interface{}
|
||
if err := decodeJSONWithUseNumber(payload, &decoded); err == nil {
|
||
return decoded, "json"
|
||
}
|
||
if utf8.Valid(payload) {
|
||
return string(payload), "text"
|
||
}
|
||
return base64.StdEncoding.EncodeToString(payload), "base64"
|
||
}
|
||
|
||
type mqttParsedSQL struct {
|
||
Action string
|
||
Topic string
|
||
Limit int
|
||
Offset int
|
||
Count bool
|
||
}
|
||
|
||
var (
|
||
mqttSQLFromRE = regexp.MustCompile(`(?i)\bFROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([^\s;]+))`)
|
||
mqttSQLLimitRE = regexp.MustCompile(`(?i)\bLIMIT\s+(\d+)`)
|
||
mqttSQLOffsetRE = regexp.MustCompile(`(?i)\bOFFSET\s+(\d+)`)
|
||
mqttShowTopicsRE = regexp.MustCompile(`(?i)^\s*SHOW\s+TOPICS(?:\s+LIMIT\s+(\d+))?\s*;?\s*$`)
|
||
mqttDescribeTopicRE = regexp.MustCompile(`(?i)^\s*(?:SHOW|DESCRIBE)\s+TOPIC\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([^\s;]+))\s*;?\s*$`)
|
||
mqttConsumeTopicRE = regexp.MustCompile(`(?i)^\s*CONSUME\s+FROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([^\s;]+))`)
|
||
)
|
||
|
||
func parseMQTTSQL(sqlText string) (mqttParsedSQL, bool) {
|
||
text := strings.TrimSpace(sqlText)
|
||
if text == "" {
|
||
return mqttParsedSQL{}, false
|
||
}
|
||
if matches := mqttShowTopicsRE.FindStringSubmatch(text); len(matches) > 0 {
|
||
parsed := mqttParsedSQL{Action: "show_topics"}
|
||
if len(matches) > 1 && strings.TrimSpace(matches[1]) != "" {
|
||
parsed.Limit, _ = strconv.Atoi(matches[1])
|
||
}
|
||
return parsed, true
|
||
}
|
||
if matches := mqttDescribeTopicRE.FindStringSubmatch(text); len(matches) > 0 {
|
||
return mqttParsedSQL{
|
||
Action: "describe_topic",
|
||
Topic: mqttTrimIdentifier(firstNonEmpty(matches[1], matches[2], matches[3])),
|
||
}, true
|
||
}
|
||
if matches := mqttConsumeTopicRE.FindStringSubmatch(text); len(matches) > 0 {
|
||
parsed := mqttParsedSQL{
|
||
Action: "consume",
|
||
Topic: mqttTrimIdentifier(firstNonEmpty(matches[1], matches[2], matches[3])),
|
||
Limit: defaultMQTTPreviewLimit,
|
||
}
|
||
if limitMatch := mqttSQLLimitRE.FindStringSubmatch(text); len(limitMatch) > 1 {
|
||
parsed.Limit, _ = strconv.Atoi(limitMatch[1])
|
||
}
|
||
if offsetMatch := mqttSQLOffsetRE.FindStringSubmatch(text); len(offsetMatch) > 1 {
|
||
parsed.Offset, _ = strconv.Atoi(offsetMatch[1])
|
||
}
|
||
return parsed, true
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(text), "select") {
|
||
return mqttParsedSQL{}, false
|
||
}
|
||
matches := mqttSQLFromRE.FindStringSubmatch(text)
|
||
if len(matches) == 0 {
|
||
return mqttParsedSQL{}, false
|
||
}
|
||
parsed := mqttParsedSQL{
|
||
Action: "select",
|
||
Topic: mqttTrimIdentifier(firstNonEmpty(matches[1], matches[2], matches[3])),
|
||
Limit: defaultMQTTPreviewLimit,
|
||
Count: strings.Contains(strings.ToLower(text), "count("),
|
||
}
|
||
if limitMatch := mqttSQLLimitRE.FindStringSubmatch(text); len(limitMatch) > 1 {
|
||
parsed.Limit, _ = strconv.Atoi(limitMatch[1])
|
||
}
|
||
if offsetMatch := mqttSQLOffsetRE.FindStringSubmatch(text); len(offsetMatch) > 1 {
|
||
parsed.Offset, _ = strconv.Atoi(offsetMatch[1])
|
||
}
|
||
return parsed, true
|
||
}
|
||
|
||
func mqttTrimIdentifier(value string) string {
|
||
return strings.TrimSuffix(strings.TrimSpace(value), ";")
|
||
}
|
||
|
||
func mqttResolveTopic(raw string, fallback string) string {
|
||
return strings.TrimSpace(firstNonEmpty(raw, fallback))
|
||
}
|
||
|
||
func mqttValidatePublishTopic(topic string) error {
|
||
text := strings.TrimSpace(topic)
|
||
if text == "" {
|
||
return fmt.Errorf("MQTT publish 命令缺少 topic")
|
||
}
|
||
if strings.ContainsAny(text, "#+") {
|
||
return fmt.Errorf("MQTT publish topic 不能包含通配符:%s", text)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func mqttQoSFromAny(value interface{}, fallback byte) (byte, error) {
|
||
if value == nil {
|
||
return fallback, nil
|
||
}
|
||
qosValue := intFromAny(value, int(fallback))
|
||
if qosValue < 0 || qosValue > 2 {
|
||
return 0, fmt.Errorf("MQTT QoS 仅支持 0、1、2")
|
||
}
|
||
return byte(qosValue), nil
|
||
}
|
||
|
||
func mqttBoolFromAny(value interface{}, fallback bool) bool {
|
||
if value == nil {
|
||
return fallback
|
||
}
|
||
switch typed := value.(type) {
|
||
case bool:
|
||
return typed
|
||
case string:
|
||
return mqttBoolValue(typed)
|
||
default:
|
||
return mqttBoolValue(fmt.Sprintf("%v", value))
|
||
}
|
||
}
|
||
|
||
func mqttBoolValue(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "1", "true", "yes", "on", "required":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func mqttTopicRows(topics []mqttTopicDescriptor, defaultQoS byte, defaultRetain bool) []map[string]interface{} {
|
||
rows := make([]map[string]interface{}, 0, len(topics))
|
||
for _, topic := range topics {
|
||
rows = append(rows, map[string]interface{}{
|
||
"topic": topic.Filter,
|
||
"default": topic.Default,
|
||
"wildcard": topic.Wildcard,
|
||
"default_qos": int(defaultQoS),
|
||
"retain": defaultRetain,
|
||
"source": topic.Source,
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func mqttDescribeTopicRow(topic string, topics []mqttTopicDescriptor, defaultQoS byte, defaultRetain bool, cleanSession bool, fetchWait time.Duration, brokers []string) map[string]interface{} {
|
||
configured := false
|
||
isDefault := false
|
||
wildcard := strings.ContainsAny(topic, "#+")
|
||
source := ""
|
||
for _, entry := range topics {
|
||
if entry.Filter == topic {
|
||
configured = true
|
||
isDefault = entry.Default
|
||
wildcard = entry.Wildcard
|
||
source = entry.Source
|
||
break
|
||
}
|
||
}
|
||
return map[string]interface{}{
|
||
"topic": topic,
|
||
"configured": configured,
|
||
"default": isDefault,
|
||
"wildcard": wildcard,
|
||
"source": source,
|
||
"default_qos": int(defaultQoS),
|
||
"default_retain": defaultRetain,
|
||
"clean_session": cleanSession,
|
||
"fetch_wait_ms": fetchWait.Milliseconds(),
|
||
"broker_count": len(brokers),
|
||
"brokers": append([]string(nil), brokers...),
|
||
}
|
||
}
|
||
|
||
func mqttMessageRows(records []mqttMessageRecord) []map[string]interface{} {
|
||
rows := make([]map[string]interface{}, 0, len(records))
|
||
for _, record := range records {
|
||
row := map[string]interface{}{
|
||
"topic": record.Topic,
|
||
"qos": int(record.QoS),
|
||
"retained": record.Retained,
|
||
"duplicate": record.Duplicate,
|
||
"message_id": int(record.MessageID),
|
||
"payload": record.Decoded,
|
||
"payload_encoding": record.Encoding,
|
||
"payload_bytes": len(record.Payload),
|
||
"received_at": record.ReceivedAt.Format(time.RFC3339Nano),
|
||
}
|
||
if payloadMap, ok := record.Decoded.(map[string]interface{}); ok {
|
||
flattenMQTTMap("payload", payloadMap, row)
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func flattenMQTTMap(prefix string, values map[string]interface{}, row map[string]interface{}) {
|
||
for key, value := range values {
|
||
if strings.TrimSpace(key) == "" {
|
||
continue
|
||
}
|
||
name := prefix + "." + key
|
||
row[name] = value
|
||
if nested, ok := value.(map[string]interface{}); ok {
|
||
flattenMQTTMap(name, nested, row)
|
||
}
|
||
}
|
||
}
|
||
|
||
func uniqueStringsPreserveOrder(values []string) []string {
|
||
seen := make(map[string]struct{}, len(values))
|
||
result := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
key := strings.TrimSpace(value)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, key)
|
||
}
|
||
return result
|
||
}
|