SagooIoT 的数据接入层只有四张表,却能接住三种外部数据

在控制台上接一个外部系统,动作只有四下:新建数据源、选数据来源、把字段拉成节点、点发布。

第一下决定了数据从哪来------是一个 HTTP 接口、一套现成的业务库,还是一台已经在线的设备;第二下把地址、账号、表名或者产品设备标识填进去;第三下逐条给字段起名、定类型、必要时挂一条正则清洗;第四下点发布。

点完这一下,界面上什么都没变,但后台同时发生了三件事:一张物理表被建了出来、一个同步任务被注册进了定时任务中心、一次立即执行被触发。此后每到 cron 表达式的点,数据就会从源端流进那张表。

这篇文章拆的就是这四下背后的东西。代码来自本地专业版的 internal/logic/datahub,一共四千四百多行,是这个平台里唯一一处"把外部世界翻译成平台内部表结构"的地方。

一、先算一笔账:为什么 IoT 平台要长出一个数据接入层

一个纯设备平台的数据模型很干净:设备上来一条属性报文,按物模型解析,落到时序库,按产品分超级表、按设备分子表。查询路径也干净------给我某台设备某个属性某段时间的值。

但现场很少只有设备。举三个常被问到的情况:

  • 车间里的电表已经在跑一套能耗系统,数据在对方的 MySQL 里,客户不想再装一遍采集器;
  • 天气预报、电价、汇率这类数据,只能从第三方 HTTP 接口拿;
  • 设备自身的历史数据在平台的时序库里,但需要和上面两类数据放在一张表里做对比。

这三类数据的共同点是:它们都不符合物模型,也不该被塞进时序库。硬塞的代价是每个项目都要写一套一次性代码,换个客户重写一遍。

datahub 的做法是给这三类数据一个统一的形状:数据源(DataSource)是一张表,数据节点(DataNode)是这张表的列定义,数据模型(DataTemplate)是可以跨源拼装的视图,模型节点(DataTemplateNode)是这个视图的列定义。

四张元数据表,加上每次发布时按 ID 生成的物理表,就把三种数据源收进了同一套读写接口。

元数据表 作用 对应物理表
data_source 一个外部数据源:来源类型 + 配置 JSON + 状态 data_source_<sourceId>
data_node 数据源的列:key、类型、取值项、清洗规则 同上(就是它的列)
data_template 一个数据模型:节点集合 + 聚合窗口 + 排序 data_template_<tid>
data_template_node 模型的列:来自某个源的某列,或一个常量 同上(就是它的列)

二、数据源:一个 From 字段分三路

数据源的来源类型是一个整型常量:

复制代码
const (
	DataSourceFromApi    = iota + 1 // api数据源
	DataSourceFromDb                // 数据库数据源
	DataSourceFromFile              // 文件数据源
	DataSourceFromDevice            // 设备数据源
)

四个位置,当前落地的是三种:接口、数据库、设备。文件源留了位,接口层已经把 in:1,2,3,4 写进了校验标签,后面接上不用改表结构。

三种来源的配置长得完全不一样,但都序列化进同一个 config 字段:

复制代码
// api 数据源配置
type DataSourceConfigApi struct {
	Method         string                        `json:"method" dc:"请求方法(get、post、put)"`
	Url            string                        `json:"url" dc:"请求地址" v:"url"`
	RequestParams  [][]DataSourceApiRequestParam `json:"requestParams" dc:"请求参数"`
	CronExpression string                        `json:"cronExpression" dc:"任务执行表达式"`
}

// 数据库 数据源配置
type DataSourceConfigDb struct {
	Type      string `json:"type" dc:"数据库类型(mysql/mssql)"`
	Host      string `json:"host"`
	Port      int    `json:"port"`
	User      string `json:"user"`
	Passwd    string `json:"passwd"`
	DbName    string `json:"dbName"`

	QueryType string `json:"queryType" dc:"数据获取方式"`
	TableName string `json:"tableName" dc:"表名称"`
	Pk        string `json:"pk" dc:"主键字段"`
	Num       int    `json:"num" dc:"每次获取数量" d:"100"`
	PkMax     uint64 `json:"pkmax" dc:"主键最大值"`

	CronExpression string `json:"cronExpression"`
}

// 设备 数据源配置
type DataSourceConfigDevice struct {
	ProductKey string `json:"productKey" dc:"产品标识"`
	DeviceKey  string `json:"deviceKey" dc:"设备标识"`
}

读的时候按 from 分派,把 JSON 扫进对应的结构体,其余两个留空:

复制代码
if p.Config != "" {
	j, _ := gjson.DecodeToJson(p.Config)
	switch p.From {
	case model.DataSourceFromApi:
		out.ApiConfig = &model.DataSourceConfigApi{}
		err = j.Scan(out.ApiConfig)
	case model.DataSourceFromDevice:
		out.DeviceConfig = &model.DataSourceConfigDevice{}
		err = j.Scan(out.DeviceConfig)
	case model.DataSourceFromDb:
		out.DbConfig = &model.DataSourceConfigDb{}
		err = j.Scan(out.DbConfig)
	default:
		err = errors.New("unhandled default case")
	}
}

这个设计的好处是:一张表存三种配置,新增一种来源不用加表也不用加列 。DataSourceOutput 里三个指针字段带 omitempty,接口返回时天然只吐当前来源那一段,前端按 from 取对应块渲染表单即可。

数据库源还可以选"取数方式":tableName 直接读整表,sql 则把 tableName 当作一条 SQL 语句执行。后者让"从对方的宽表里挑几列"变成一次配置,不用先建视图。

复制代码
if conf.QueryType == model.DataSourceDbQueryTypeSql {
	sql := conf.TableName
	if strings.Contains(sql, "%d") {
		sql = fmt.Sprintf(conf.TableName, conf.PkMax)
	}
	rs, err = db.GetAll(ctx, sql)
	return
}

%d 的占位符是个小设计:分表场景下写 select * from meter_%d where ...,每次同步时用上一次记录的主键最大值去填,天然带上了增量条件。

三、数据节点:外部字段到平台字段的翻译表

数据源只解决"从哪拿",节点解决"拿哪些、叫什么、什么类型"。

复制代码
type DataNode struct {
	SourceId uint64
	Name     string // 给人看的名字,最终会写进列的 COMMENT
	Key      string // 平台内的字段名,写进列名
	DataType string // int / long / float / double / string / boolean / date
	Value    string // 取值项:DB 侧是列名,API 侧是 JSON 路径
	IsPk     int    // 是否唯一键
	Rule     []DataSourceRule
}

type DataSourceRule struct {
	Expression string `json:"expression" dc:"正则表达式"`
	Replace    string `json:"replace" dc:"替换内容"`
}

Value 是最值得说的一个字段。它把"外部世界怎么表示这个值"和"平台里这个字段叫什么"彻底分开了:

  • 数据库源里,Value 就是源表列名,可能是 TOTAL_KWH、meter_no、采集时间;
  • 接口源里,Value 是一条 JSON 路径。接口返回 {"data":{"items":[{"city":"杭州","temp":23}]}},节点 Value 就写 data.items.0.temp。

接口侧取值的实现只有几行,靠 gjson 直接按路径取:

复制代码
func getValue(value string, apiData *gjson.Json, rule *model.DataSourceRule) string {
	sv := apiData.Get(value)
	if sv == nil {
		return ""
	}

	s := sv.String()

	if rule != nil && rule.Expression != "" {
		rs, _ := gregex.ReplaceString(rule.Expression, rule.Replace, s)
		return gconv.String(rs)
	}

	return s
}

一条正则规则就能处理掉大量现场脏数据。例如源库里的设备编号带前缀 DEV-0001,节点上挂一条 ^DEV- → 空串的替换,落库就是 0001,和平台里的设备标识对齐。规则挂在节点上而不是挂在源上,意味着同一张源表的不同列可以有不同清洗方式。

节点写入是并发的。同一行数据的每个节点各起一个 goroutine,WaitGroup 收口,最后合成一个 map:

复制代码
for _, row := range rs {
	m := gmap.New(true)

	var wg sync.WaitGroup
	for _, v := range nodeList {
		wg.Add(1)
		go func(v *model.DataNodeOutput) {
			defer wg.Done()

			var rule *model.DataSourceRule
			if len(v.NodeRule) > 0 {
				rule = v.NodeRule[0]
			}

			sv, ok := row[v.Value]
			if !ok {
				return
			}

			rs := sv.String()
			if rule != nil && rule.Expression != "" {
				rs, err = gregex.ReplaceString(rule.Expression, rule.Replace, rs)
				if err != nil {
					return
				}
			}
			m.Set(v.Key, rs)
		}(v)
	}
	wg.Wait()
	insertData = append(insertData, m)
}

一个源几十个节点、几百行数据,串行做正则替换会明显拖慢同步;并发之后这段时间基本被网络往返掩盖掉了。

四、发布:一次点击,三件事同时发生

Deploy 是整个模块里信息密度最高的一个函数。它做的事情按顺序是:

  1. 建物理表 (仅接口源和数据库源);2. 把 status 置为 1、lock_key 置为 1 ,同时把表名回写到 data_table;3. 注册同步任务到平台的定时任务中心,然后启动它,并立刻执行一次。

    err = dao.DataSource.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
    m := g.Map{
    dao.DataSource.Columns().Status: model.DataSourceStatusOn,
    dao.DataSource.Columns().LockKey: 1,
    }
    if table != "" {
    m[dao.DataSource.Columns().DataTable] = table
    }

    复制代码
     _, err = dao.DataSource.Ctx(ctx).
     	Data(m).
     	Where(dao.DataSource.Columns().SourceId, sourceId).
     	Update()
    
     return err

    })

lock_key 这个字段容易被忽略,但它是一道很实在的闸门。一旦置 1,后续的 Edit 会把 key 从更新字段里剔除:

复制代码
param.UpdatedBy = uint(loginUserId)
param.SourceId = nil
if out.LockKey == 1 {
	param.Key = nil // 已发布,标识不可再改
}

原因很直接:key 是别人引用这个源的锚点,表名 data_source_<id> 已经建好,字段名已经落到物理列上,这时候再改标识只会让引用方失联。发布即锁定,是把"改名"这件事的成本提前暴露在设计阶段,而不是留到运行阶段。

任务注册这段用了 goto 做排重,写法朴素但意图清楚------先查有没有同名任务,没有就建一个再回来查:

复制代码
Job:
	job := new(model.GetJobListInput)
	job.JobGroup = "dataSourceJob"
	job.JobName = "dataSource-" + gconv.String(sourceId)
	job.PaginationInput = &model.PaginationInput{PageNum: 1, PageSize: 1}
	_, list, _ := service.SysJob().JobList(ctx, job)
	if len(list) == 0 {
		sysJob := new(model.SysJobAddInput)
		sysJob.JobName = "dataSource-" + gconv.String(sourceId)
		sysJob.JobParams = gconv.String(sourceId)
		sysJob.JobGroup = "dataSourceJob"
		sysJob.InvokeTarget = consts.DatahubSourceJob // "DataSourceSync"
		sysJob.CronExpression = cron
		sysJob.MisfirePolicy = 1
		sysJob.Concurrent = 0
		sysJob.Status = 0
		sysJob.Remark = out.Name
		sysJob.CreateBy = uint64(loginUserId)
		err = service.SysJob().AddJob(ctx, sysJob)
		goto Job
	}
	err = service.SysJob().JobStart(ctx, list[0])
	// 初始执行一次
	err = service.SysJob().JobRun(ctx, list[0])

两个参数值得注意:Concurrent = 0 表示禁止并发,上一次没跑完不会叠加下一次;MisfirePolicy = 1 表示错过执行时间后立即补一次。对一个"拉数据"的任务来说,这两个默认值都是对的------并发跑两次等于同一批数据覆盖两次,而错过就跳过会让数据出现空窗。

发布时立即执行一次,是体验上最关键的一步。点完发布,界面上就能看到数据,而不是等下一个整点。

任务最终落到 internal/tasks 里的一个方法上,方法名和 InvokeTarget 一一对应:

复制代码
// DataSourceSync 数据源获取数据
func (t TaskJob) DataSourceSync(sid string) {
	ctx := context.Background()
	err := service.DataSource().UpdateData(ctx, gconv.Uint64(sid))
	if err != nil {
		g.Log().Debugf(ctx, "获取数据失败:%s", err)
	}
}

设备源是另一条路:发布时只置状态,不建表也不建任务。因为设备数据本来就在时序库里,按产品分超级表、按设备分子表,同步一份到中间表纯属多余。查的时候直接查时序库。

复制代码
if out.From != model.DataSourceFromApi && out.From != model.DataSourceFromDb {
	return
}

五、表结构是算出来的

接口源和数据库源发布时要建一张物理表。这张表的每个列都来自节点定义,一个字都不是手写的。

复制代码
for i, v := range nodeList {
	switch v.DataType {
	case "int":
		columns[i] = "`" + v.Key + "` int(11) DEFAULT 0 COMMENT '" + v.Name + "'"
	case "long":
		columns[i] = "`" + v.Key + "` bigint(20) DEFAULT 0 COMMENT '" + v.Name + "'"
	case "float":
		columns[i] = "`" + v.Key + "` float DEFAULT 0 COMMENT '" + v.Name + "'"
	case "double":
		columns[i] = "`" + v.Key + "` double DEFAULT 0 COMMENT '" + v.Name + "'"
	case "string":
		columns[i] = "`" + v.Key + "` varchar(255) DEFAULT '' COMMENT '" + v.Name + "'"
	case "boolean":
		columns[i] = "`" + v.Key + "` tinyint DEFAULT 0 COMMENT '" + v.Name + "'"
	case "date":
		columns[i] = "`" + v.Key + "` datetime DEFAULT NULL COMMENT '" + v.Name + "'"
	default:
		columns[i] = "`" + v.Key + "` varchar(255) DEFAULT '' COMMENT '" + v.Name + "'"
	}

	// 主键
	if v.IsPk == 1 {
		pk = append(pk, fmt.Sprintf("UNIQUE KEY `uni_%s` (`%s`)", v.Key, v.Key))
	}
}

类型只有七种,映射关系是刻意收窄的:

节点类型 MySQL PostgreSQL 说明
int int(11) DEFAULT 0 integer DEFAULT 0 计数、状态码
long bigint(20) DEFAULT 0 bigint(20) DEFAULT 0 主键、累计量
float float DEFAULT 0 float DEFAULT 0 单精度测量值
double double DEFAULT 0 double DEFAULT 0 高精度测量值
string varchar(255) DEFAULT '' character varying(255) DEFAULT '' 编号、名称
boolean tinyint DEFAULT 0 boolean DEFAULT 0 开关量
date datetime DEFAULT NULL timestamp(6) without time zone 时间

每个节点还会额外带四列,由 CreateTable 统一追加:

复制代码
// 扩展字段
columns[len(nodeList)] = "`created_at` datetime DEFAULT NULL COMMENT '创建时间'"
columns[len(nodeList)+1] = "`updated_at` datetime DEFAULT NULL COMMENT '更新时间'"
columns[len(nodeList)+2] = "`__id__` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID'"
columns[len(nodeList)+3] = "`__count__` int(10) unsigned NOT NULL DEFAULT 1 COMMENT '聚合数量'"

__id__ 是自增主键,保证表一定有主键;created_at 是读侧默认排序字段,也是数据模型判断"最新一条"的依据;__count__ 是给聚合用的------一行数据背后压缩了多少条原始记录,看这个数就知道。

IsPk = 1 的节点不会变成整表主键,而是生成一个额外的约束:MySQL 上是 UNIQUE KEY uni_<key>,PostgreSQL 上是 CONSTRAINT <table>_pkey PRIMARY KEY (<key>)。这个选择有讲究:如果把源表主键直接设成物理主键,源端两条记录主键相同时整批插入会失败回滚;做成附加约束,冲突退化成单条记录的问题,其余数据照常进来。

六、同步:快照覆盖,加一个主键游标

每次同步的逻辑只有三行:清表、插入、记游标。

复制代码
// 入库
if len(insertData) > 0 {
	table := database.GetTableName(sourceId)
	err = g.DB(database.DataCenter()).GetCore().ClearTableFields(ctx, table)
	if err != nil {
		return err
	}
	_, err = g.DB(database.DataCenter()).Insert(ctx, table, insertData)
	if err != nil {
		return err
	}
}

// 主键最大值存储
if pkMax > 0 {
	conf := ds.DbConfig
	conf.PkMax = pkMax
	c, _ := json.Marshal(conf)
	_, err = dao.DataSource.Ctx(ctx).
		Data(g.Map{dao.DataSource.Columns().Config: c}).
		Where(dao.DataSource.Columns().SourceId, sourceId).
		Update()
}

ClearTableFields 是 GoFrame 提供的方法,清空表数据但保留表结构。先清后插,语义是快照:中间表永远等于"源端此刻的样子",不是"源端历史累积"。

这个语义对报表场景是对的。客户问"这个月用了多少电",要的是源系统当前算出来的值,不是平台自己攒的副本;源端删了一条记录,平台这边下一轮也跟着消失,不会留下对不上账的幽灵行。

表名里的 data_source_<id> 和业务表完全隔离,同步再频繁也不会碰到平台自己的表。如果希望这批中间表干脆放到另一个库,配置里有 database.dataCenter.link 这一项:

复制代码
// 获取数据库分组
func DataCenter() string {
	name, _ := g.Cfg().Get(context.TODO(), "database.dataCenter.link")
	if name.String() != "" {
		return "dataCenter"
	}
	return ""
}

配了就用 dataCenter 分组指向的库,没配就回落主库。所有建表、清表、插入、查询都走这个函数,一处开关切换整批中间表的存放位置。

游标 PkMax 是数据库源的增量手段。取值时自动带上 where pk > PkMax,只拉新增的行:

复制代码
where := ""
if limit <= 0 {
	limit = conf.Num

	if conf.Pk != "" {
		var tmap g.MapStrAny
		tmap, err = s.GetDbFields(ctx, sourceId)
		if err != nil {
			return
		}
		if t, ok := tmap[conf.Pk]; ok {
			tf := t.(gdb.TableField)
			switch tf.Type {
			case "int", "long":
				where = fmt.Sprintf("where %s > %d", conf.Pk, conf.PkMax)
			}
		}
	}
}
order := ""
if conf.Pk != "" {
	order = fmt.Sprintf("order by %s asc", conf.Pk)
}
sql := fmt.Sprintf("select * from %s %s %s limit %d", conf.TableName, where, order, limit)
if conf.Type == "mssql" {
	sql = fmt.Sprintf("select top %d * from %s %s %s", limit, conf.TableName, where, order)
}

三处细节:主键类型先查一次表结构,只有整型主键才拼 > 条件;order by pk asc 保证游标单调;SQL Server 不支持 limit,走 top N 分支。

没有主键或者主键不是整型时,行为自动退化为"取最新 N 条"(conf.Num 默认 100)。这是一个务实的默认值:源表没有可靠主键的情况很常见,此时按批次限量拉比全表拉安全得多。

七、数据模型:把多个源拼成一行

数据源解决单表,数据模型解决"拼"。一个模型里的节点有两种来源:

  • from = 1:自定义节点,值来自节点上配的 default,没配就生成一个 GUID;
  • from = 2:数据源节点,指向某个源的某个节点。

拼装方式有两条,取决于有没有配主源。

第一条:按索引行合并。 没配主源时,从每个源各取一批数据,按行号对齐,第 0 行拼第 0 行。

复制代码
// 合并数据
for i := 0; i < recordNum; i++ {
	m := gmap.New()
	var sid uint64
	for _, node := range nodes {
		if sid == 0 {
			sid = node.SourceId
		}
		if node.From == 2 {
			// 数据源关联节点
			if row, ok := recordData[node.SourceId]; ok {
				m.Set(node.Key, row[i].Get(node.Key))
			}
		} else {
			// 自定义节点
			if node.Default != "" {
				m.Set(node.Key, node.Default)
			} else {
				m.Set(node.Key, guid.S())
			}
		}
	}
	...
}

第二条:按关联键合并。 配了 mainSourceId 和 sourceNodeKey 之后,逻辑就变成一次左连接。主源的每一条记录拿出来,用关联节点上的值去其他源里找对应的行。

复制代码
// 合并数据
for rKey, row := range mainSource {
	m := gmap.New()
	...
	for _, node := range nodes {
		if node.From == 2 {
			if node.SourceId == dt.MainSourceId {
				m.Set(node.Key, row.Get(node.Key))
			} else {
				if row, ok := recordData[node.SourceId]; ok {
					if rowD, ok := row[rKey]; ok {
						m.Set(node.Key, rowD.Get(node.Key))
					} else {
						if node.DataType == "string" {
							m.Set(node.Key, "")
						} else if node.DataType == "date" {
							m.Set(node.Key, nil)
						} else {
							m.Set(node.Key, 0)
						}
					}
				}
			}
		} else {
			if node.Default != "" {
				m.Set(node.Key, node.Default)
			} else {
				m.Set(node.Key, guid.S())
			}
		}
	}
	...
}

左连接一定会遇到"右表没有对应行"的情况。这里的处理值得单独说一句:缺值不是统一填 NULL,而是按节点类型填该类型的零值 ------字符串填空串、日期填 NULL、数值填 0。这样拼出来的行可以直接参与聚合计算,不会因为一个 NULL 让整列 sum 变成空。

关联键取不到时还有一个兜底:

复制代码
relationKey := row[dt.SourceNodeKey].String()
if relationKey == "" {
	relationKey = strconv.Itoa(i)
}

空键退化成行号,至少保证这一行不会把整批合并打断。

设备数据源在模型里还有一条专用通道。如果模型里引用到了设备源,建表时会多出一列:

复制代码
if len(sourceIds) > 0 {
	list, err := service.DataSource().GetDeviceSource(ctx, sourceIds)
	if err != nil {
		return "", errors.New("获取设备模式数据源失败")
	}
	if len(list) > 0 {
		extendLen += 1
	}
}
...
if extendLen > 4 {
	columns[len(tplNodes)+4] = "`deviceKey` varchar(255) DEFAULT '' COMMENT '设备标识'"
}

合并时也会把 deviceKey 一路透传下去,从源记录里读出来,写进最终那一行:

复制代码
var deviceKey any
if row, ok := recordData[sid]; ok {
	if rowD, ok := row[rKey]; ok {
		deviceKey = rowD.Get("deviceKey")
	}
}
m.Set("deviceKey", deviceKey)

这一列的意义在于"这条数据是谁的"。设备数据混进业务模型之后,如果没有这一列,行就失去了主语;有了它,前端点一行就能跳回那台设备的详情页。

模型写入物理表时按 1000 条一批切分:

复制代码
for i := 0; i < len(insertData); i = i + 1000 {
	s := 1000 + i
	if len(insertData)-i < 1000 {
		s = len(insertData)
	}
	d := insertData[i:s]
	if _, err = g.DB(database.DataCenter()).Insert(ctx, table, d); err != nil {
		return err
	}
}

单条 INSERT 拼一万个 values 会撞上 max_allowed_packet,分批是从工程经验里长出来的默认值。

八、聚合:把明细压成一条曲线

数据源同步的是明细,数据模型可以再往上压一层。是否聚合由三个字段决定:时间窗口节点 timeNodeKey、时间值 duration、时间单位 timeUnit(1 秒、2 分、3 时、4 天)。

聚合方式挂在模型节点上,只有四个:max、min、avg、sum。

复制代码
// 校验聚合方法
func checkMethod(m string) bool {
	if m == "max" || m == "min" || m == "avg" || m == "sum" {
		return true
	}
	return false
}

取值时先算出时间窗 [sTime, eTime),再拼一条带窗口条件的聚合 SQL:

复制代码
field := []string{
	fmt.Sprintf("max(%s) as %s", pk, pk),
}
for _, v := range fld {
	field = append(field, fmt.Sprintf("round(%s(%s), 6) as %s", v.Method, v.Key, v.Key))
}

round(..., 6) 保留六位小数,够表达绝大多数测量量,又不至于让浮点尾巴把报表撑宽。

分组聚合时,做法是"两步走":先按分组节点聚合出指标,再按主键把这些行原样取回来,把聚合值回填进去。

复制代码
// 分组聚合模式
if dt.GroupNodeKey != "" {
	sql = fmt.Sprintf("select %s, %s from %s %s group by %s order by %s asc", dt.GroupNodeKey, strings.Join(field, ","), table, where, dt.GroupNodeKey, pk)
	data, err := g.DB(database.DataCenter()).GetAll(ctx, sql)
	if err != nil {
		return nil, err
	}

	// 抽取数据
	var maxpk []string
	m := make(map[string]gdb.Record, len(data))
	for _, v := range data {
		maxpk = append(maxpk, "'"+v[pk].String()+"'")
		m[v[dt.GroupNodeKey].String()] = v
	}

	// 获取其他字段数据,并填充聚合数据
	sql = fmt.Sprintf("select * from %s where %s in (%s) order by %s asc", table, pk, strings.Join(maxpk, ","), pk)
	rs, err = g.DB(database.DataCenter()).GetAll(ctx, sql)
	...
	for i, v := range rs {
		for _, f := range fld {
			rs[i][f.Key] = m[v[dt.GroupNodeKey].String()][f.Key]
		}
	}
}

两步走的收益是:结果行里既有聚合值,也有这一组最后一次上报时的全部原始字段。 如果只跑一条 group by,除了分组列和聚合列,其余字段要么被 ONLY_FULL_GROUP_BY 拦住,要么取值不确定。两步走之后,大屏上可以同时显示"这小时的平均温度"和"这小时最后一次上报的地点"。

聚合还有断点续跑。模型上记录 last_pk 和 last_time,每次从上次停下的地方继续;时间窗还没走完就直接返回,不做无意义的计算:

复制代码
// 如果eTime大于当前时间,直接返回
if gtime.Now().Before(eTime) {
	return nil, nil
}

设备源的聚合走的是另一条路------不进中间表,直接对时序库发聚合查询:

复制代码
// 聚合模式
if len(fld) > 0 && dt.Duration > 0 && dt.TimeUnit > 0 {
	for _, v := range fld {
		ar := comm.AggregateQuery{
			Table:     getTableForConfigDevice(ds.DeviceConfig),
			Func:      v.Method,
			Field:     v.Value,
			TimeRange: timeRange,
		}
		num, err := aggregateForConfigDevice(ctx, ds.DeviceConfig, ar)
		if err != nil {
			return nil, err
		}
		row[v.Key] = gvar.New(num)
	}
}

时序库的降采样本来就在行内完成,没必要先搬出来再算一遍。同一个模型里,业务数据走 SQL 聚合,设备数据走时序库聚合,对上层是同一张结果表。

aggregate 包里还定义了归档粒度的三档,对应三张结构相同的表:

复制代码
const (
	StoreRuleForMinute StoreRule = iota + 1 // 5分钟一个点,存一天
	StoreRuleForHour                        // 1小时一个点,存一个月
	StoreRuleForDay                         // 1天一个点,存一年
)

func createAggregateTplTable(ctx context.Context, templateId uint64) error {
	table := "data_template_" + strconv.FormatUint(templateId, 10)
	list := []string{
		table + "_m",
		table + "_h",
		table + "_d",
	}

	for _, tb := range list {
		sql := fmt.Sprintf("create table IF NOT EXISTS %s like %s", tb, table)
		if _, err := g.DB().Exec(ctx, sql); err != nil {
			return err
		}
	}

	return nil
}

create table ... like ... 直接复制主表结构,三张归档表和明细表字段完全一致,查的时候换表名即可,不用改 SQL。还有一道频率闸门,看最近一条记录的时间决定这次要不要真的算:

复制代码
t := gtime.Now()
switch rule {
case StoreRuleForMinute:
	t = t.Add(-5 * time.Second)
case StoreRuleForHour:
	t = t.Add(-1 * time.Hour)
case StoreRuleForDay:
	t = t.AddDate(0, 0, -1)
}
if !t.Before(rs.GTime()) {
	return true, nil
}

窗口没到就跳过,避免同一个时间点被反复重算。

九、三种数据库方言,一套接口

中间表可以放在 MySQL、PostgreSQL 或 SQLite 里。差异被三个接口吃掉:

复制代码
type DatabaseOperations interface {
	CreateTable(ctx context.Context, sourceId uint64) (string, error)
	DropTable(ctx context.Context, sourceId uint64) error
	AddColumn(ctx context.Context, nodeId uint64) error
	DropColumn(ctx context.Context, nodeId uint64) error
	CheckTableExist(ctx context.Context, table string) bool
}

type DatabaseTpl interface {
	CreateTable(ctx context.Context, tid uint64) (string, error)
	DropTable(ctx context.Context, tid uint64) error
	AddColumn(ctx context.Context, nodeId uint64) error
	DropColumn(ctx context.Context, nodeId uint64) error
	CheckTableExist(ctx context.Context, table string) bool
}

type DatabaseComputeIndex interface {
	CreateTable(ctx context.Context, dataTemplateKey string) (string, error)
	DropTable(ctx context.Context, dataTemplateKey string) error
	AddColumn(ctx context.Context, dataTemplateKey, key string) error
	DropColumn(ctx context.Context, dataTemplateKey, key string) error
	CheckTableExist(ctx context.Context, table string) bool
}

三个接口方法集几乎一样,区别只在入参:数据源按 sourceId 找节点,数据模型按 tid 找模型节点,指标按 templateKey + key 找指标节点。分成三个而不是一个泛型接口,是因为三者查的是三张不同的元数据表,硬合并只能靠 interface{} 抹平,反而更难读。

工厂按驱动名返回实现:

复制代码
func NewDatabaseOperations(driverName string) (DatabaseOperations, error) {
	switch driverName {
	case "mysql":
		return &MySQLDatabase{}, nil
	case "pgsql":
		return &PostgresDatabase{}, nil
	case "sqlite3":
		return &SqliteDatabase{}, nil
	default:
		return nil, fmt.Errorf("unsupported database driver: %s", driverName)
	}
}

func NewDatabaseTpl(driverName string) (DatabaseTpl, error) {
	switch driverName {
	case "mysql":
		return &MySQLTpl{}, nil
	case "pgsql":
		return &PostgresTpl{}, nil
	default:
		return nil, fmt.Errorf("unsupported database driver: %s", driverName)
	}
}

注意 NewDatabaseOperations 支持三种,NewDatabaseTpl 支持两种。数据模型建表会用到 create table ... like ... 这类语法,SQLite 上行为不完全一致,所以暂时只在 MySQL 和 PostgreSQL 上开放,接口留好了,实现补上就能开。

方言差异最集中的地方是列定义。MySQL 习惯把注释直接内联进去,PostgreSQL 没有这个语法:

复制代码
// MySQL:注释跟着列走
column = "`" + p.Key + "` int(11) DEFAULT 0 COMMENT '" + p.Name + "'"

// PostgreSQL:列定义不带注释,另外发一条 COMMENT ON
columns[i] = v.Key + " integer DEFAULT 0 "
comments[i] = fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'", table, v.Key, v.Name)

PostgreSQL 的实现因此多攒了一个 comments 数组,最后一次性拼进 CREATE TABLE 之后的语句里;自增列也不是 AUTO_INCREMENT,而是 __id__ serial NOT NULL。

表存在性检查也是各查各的 information_schema:

复制代码
// MySQL
sql := "select * from information_schema.tables where table_name = ? and table_schema = (select database()) limit 1"

// PostgreSQL
sql := "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?)"

这些差异被封在各自的文件里,上层的 Deploy、Add、Del 完全不用关心当前连的是哪种库------它们只调 dbOps.CreateTable(ctx, sourceId)。

节点增删会同步改表结构。新增一个节点时,如果表已经建好,立刻补一列:

复制代码
dataSource, _ := service.DataSource().Detail(ctx, in.SourceId)
if dataSource != nil && dataSource.DataTable != "" {
	// 表结构已存在,字段新增
	var dbOps database.DatabaseOperations
	dbOps, err = database.NewDatabaseOperations(g.DB().GetConfig().Type)
	if err != nil {
		return err
	}
	err = dbOps.AddColumn(ctx, uint64(nodeId))
	if err != nil {
		return err
	}
}

删节点同理,先 DropColumn 再软删元数据。整个过程在同一个事务里,表结构和元数据不会各走各的。

十、读侧:一个 GET 接口就够

数据接进来是为了用。读侧的接口很少:

接口 路径 用途
源数据分页 /source/getdata 按条件分页查源记录
模型数据分页 /template/getdata 按条件分页查模型数据
模型最新一条 服务层 GetLastData 只要最新值,不关心历史
模型全量 服务层 GetAllData 一次性取全量,给图表用

排序不是写死的。模型上可以指定排序节点和方向:

复制代码
sortNode := "created_at desc"
if dt.SortNodeKey != "" {
	sortDesc := "desc"
	if dt.SortDesc == 2 {
		sortDesc = "asc"
	}
	sortNode = dt.SortNodeKey + " " + sortDesc
}

查询条件用 map 传进来,键是"字段名 + 运算符",值可以是标量也可以是切片。切片会被展开成 IN 的参数:

复制代码
m := g.Model(table)
if len(exp) > 0 {
	m = m.Wheref(strings.Join(exp, " and "), value...)
}

out.Total, _ = m.Count()
out.CurrentPage = in.PageNum

rs, err := m.Order(sortNode).Limit((in.PageNum-1)*in.PageSize, in.PageSize).All()

同一份模型数据还能以 dataframe 的形式取出,交给指标计算用:

复制代码
// 获取数据,返回dataframe
func (s *sDataTemplate) GetDataByTableName(ctx context.Context, tableName string) (df dataframe.DataFrame, err error) {
	rs, err := g.DB(database.DataCenter()).Model(tableName).All()
	if err != nil || rs.Len() == 0 {
		return
	}
	df = dataframe.LoadMaps(rs.List())
	return
}

到这里,数据链路就闭环了:外部数据进来是一张表,拼装之后还是一个表,聚合之后依然是表,指标算完写在 compute_index_<key> 里还是表。整条链路上没有第二种数据形状 ,这是这个模块最省心的地方------每一层都能用 SQL 验证,出了问题直接 select * from 就能定位。

十一、这套设计适合什么,边界在哪

写到这里,把它的取舍摊开说清楚,比只讲功能有用。

它适合的是"外部有系统、平台要汇总"的场景。 车间能耗、第三方气象、ERP 里的工单、供应商的接口------这些数据的共同点是不归平台管,但业务上必须和设备的实时数据放在一起看。数据源 + 数据模型这套组合,本质是给这类数据一个"只读镜像",镜像的形状由平台自己定,源系统不用改一行代码。

它是一种快照语义,不是 CDC。 每次同步先清后插,表里永远是源端当前的样子。这个语义适合报表和看板,不适合审计留痕。如果业务需要"每一条变更都要留档",那应该在源端做,或者用规则引擎把变化写进时序库------平台里两套机制都有,选哪套取决于业务问的是"现在多少"还是"什么时候变成这样"。

发布是一次性动作,之后改结构要付出成本。 发布后 key 锁定、表名固定、列已经落到物理表上。这是刻意的:中间表一旦被模型引用、被大屏引用,字段名就是契约。所有设计上的犹豫,最好在点发布之前解决完。

聚合是"按窗口推进",不是"实时重算"。 模型记 last_pk、last_time,每次从上次停下的地方接着算。窗口没走完就直接返回。这意味着它可以被很低的频率驱动而不丢数据,也意味着它不适合做秒级实时指标------秒级的东西该走 SSE 推送和实时计算,不该进这张表。

三种数据源,三种节奏。 接口源按 cron 拉;数据库源按 cron 拉增量;设备源不落中间表,实时查时序库。同一个模型里可以混用,代价是同步时要多查一次时序库,收益是设备数据永远是最新的。

一句话概括:这套东西不是"又一个 ETL 工具",它是平台内部的一张翻译表------把外面各说各话的数据,翻译成平台能算、能画、能告警的形状。

结语

四张元数据表,三个来源类型,两个合并策略,一个 GET 接口。

从代码量上看,datahub 在专业版里不算大,四千四百多行,比设备管理、告警、规则引擎都小。但它解决的问题很具体:让"第三方数据"和"设备数据"在同一个模型里对话,而不需要为每个项目写一套一次性脚本。

下次再有人问"你们平台能不能对接我们现有的 ERP 数据",答案不是"可以定制开发",而是"新建一个数据源,把字段拉一下,点发布"。

项目地址

主仓库 https://github.com/sagoo-cloud

文档站 https://iotdoc.sagoo.cn

社区版源码 https://github.com/orgs/sagoo-cloud/repositories

本文代码取自本地专业版发行包 internal/logic/datahub(数据源、数据节点、数据模型、聚合、多数据库方言五部分),社区版的协议接入、设备管理、规则引擎等模块可直接对照阅读。

相关推荐
sun153690275721 小时前
基坑边坡监测:振弦式土压力计选型、安装埋设与数据采集方案
大数据·物联网·自动化
fly-iot2 小时前
【FDE知识库】(4):企业知识库怎么选型,开源知识库分享几个,选择开源协议友好的版本,开发wiki-go项目
golang·开源·开源协议
福大大架构师每日一题4 小时前
excelize v2.11.1发布:Go 1.26.0起步,16项安全修复与嵌入对象能力全面升级
开发语言·安全·golang
TDengine (老段)4 小时前
TDengine IDMP 十大实用功能(7)
数据库·物联网·时序数据库·tdengine·涛思数据·工业数据·idmp
yusur5 小时前
万物智联 中科驭数受邀参加2026世界物联网博览会
物联网
小小龙学IT6 小时前
Go 语言 pprof 性能剖析与调优深度解析:把“卡在哪“变成一张可执行的数据图
开发语言·后端·golang
十二同学啊7 小时前
Go 工程化详解:项目结构、代码规范、测试体系与 CI/CD 实践
ci/cd·golang·代码规范
CServer_017 小时前
开源!汽车零部件仓储的数字孪生协同方案
物联网·开源·汽车·制造
利威尔xu17 小时前
Go IM 源码实战(14):本地缓存快 3000 倍,失效却难 10 倍
后端·缓存·微服务·面试·golang