在控制台上接一个外部系统,动作只有四下:新建数据源、选数据来源、把字段拉成节点、点发布。
第一下决定了数据从哪来------是一个 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 是整个模块里信息密度最高的一个函数。它做的事情按顺序是:
-
建物理表 (仅接口源和数据库源);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://github.com/orgs/sagoo-cloud/repositories
本文代码取自本地专业版发行包 internal/logic/datahub(数据源、数据节点、数据模型、聚合、多数据库方言五部分),社区版的协议接入、设备管理、规则引擎等模块可直接对照阅读。