由于 Modbus 功能比较复杂,这里单独做一个使用说明;
更为详细的 Modbus 全场景应用白皮书 点我查看
Modbus 网关功能支持对设备数据的自动读写、转换,直接上传 JSON 到服务器;
read:true 主动读取;msgid 的读写关联;通用 Modbus JSON 采集上传指南视频介绍: [在线观看]

最新版本已经支持整点时间采集,自动对齐到 0s。
如果读取设备过程中,某一条采集指令无回复或者数据错误,会重复读取 3 次;若 3 次都错误,DTU 上传时会忽略该指令对应的字段。
关于数据源的说明:定义数据源是 Modbus 模块输出结果的名称,需要唯一;输入数据源则是从机数据来源,可以是串口、网络、其他自定义。要上传 Modbus 结果,一般情况下网络通道数据源请选当前 Modbus 设置的输出数据源。
页面提示红色文字“映射地址错误”时,原因通常是映射地址没有包含在这条指令的起始地址和读取长度范围内。
当前脚本实际支持
msgid关联、read:true主动读取、写入回复模板、模板模式上下行转换、写入后自动补读上报;文档示例建议优先按这些实际格式接入。
| 属性 | 说明 | 必填 |
|---|---|---|
| 读取超时 | 串口数据返回超时,单位秒 | 是 |
| 协议类型 | 当前协议类型。常用 RTU、ASCII、TCP、R2T。其中 R2T 表示 RTU 转 TCP |
是 |
| 输出数据源 | 定义当前功能的数据源名称,需要唯一;其他模块上传时要选择这个名称 | 是 |
| 输入数据源 | 当前功能的数据来源,可以多选,一般是串口、网络、自定义数据源 | 是 |
| 数据结构 | 支持混合模式、设备模式、模板模式 | 是 |
| 写入回复 | 服务端写入新数据到 Modbus 设备后,是否返回结果 | 否 |
| 时间戳 | 将会在数据里面插入 ts 字段 |
否 |
| 回复主题 | 启用写入回复时,可指定回复输出主题 | 否 |
| 回复模板 | 写入回复模板,使用 Lua 匿名函数定义回复格式 | 否 |
| 模板输入 | 模板模式下的上行/下行/回复模板函数内容 | 否 |
| 用户参数 | 在结果中携带用户参数,支持魔法值 ${ts} ${date} ${imei} ${iccid} ${csq} ${vbat} ${vbatmv} ${lon} ${lat} ${spd} ${fix} ${alt} ${geo} ${ip} ${link} |
否 |
| 指令间隔 | 一条指令读写完后等待的延迟时间,单位 ms | 是 |
| 变化容差 | 变化上传使用,大于这个范围才认为发生变化 | 否 |
| 小数位数 | 浮点结果保留的小数位数 | 否 |
| 重试次数 | 读取失败重试次数,最少为 3 | 是 |
| 整点对齐 | 采集周期自动对齐到整点或整分,必须输入 10 的倍数 | 否 |
| 写入后补读 | 写入成功后是否触发一次采集并上报 | 否 |
| 属性 | 说明 | 必填 |
|---|---|---|
| 周期 | 采集指令读取周期,单位秒 | 是 |
| 单独上传 | 勾选后,此采集指令读取成功即上传,会组成单独的 JSON | 是 |
| 变化上传 | 读取的数据和上次比较,发生变化则上传 | 是 |
| 只写 | 当数据只需要写入时,设置为只写;此时读取周期无效 | 是 |
| 写功能码 | 写入时使用 06 还是 10 |
是 |
| ID | Modbus 设备地址,最大 255 | 是 |
| 功能 | 采集指令功能码,支持 01、02、03、04、05、06 等 |
是 |
| 地址 | 采集寄存器起始地址,十进制;如果是 PLC 地址需要按实际协议换算 | 是 |
| 长度 | 读写长度,必须覆盖当前指令下所有映射地址 | 是 |
| 指定通道 | 某条指令单独指定使用某个输入通道发送 | 否 |
| 属性 | 说明 | 必填 |
|---|---|---|
| 键值 | 数据映射别名,转换成 JSON 时使用的字段名称 | 是 |
| 地址 | 映射地址,必须包含在采集指令范围内 | 是 |
| 类型 | 支持 Coils、Short、Unsigned Short、Long、Unsigned Long、Float、Short BCD、Long BCD、bit 位、字符串等 |
是 |
| 顺序 | 字节顺序,常见 ABCD、CDAB、BADC、DCBA |
是 |
| 比例 | 数据转换公式 y = ax + b 中的 a |
是 |
| 偏移 | 数据转换公式 y = ax + b 中的 b |
是 |
用户参数用于在上传结果、模板模式或回复模板中自动附带一些动态信息。
配置方式通常是自己定义一个键名,然后把值写成普通字符串或魔法值,例如:
{
"devid": "${imei}",
"time": "${date}",
"signal": "${csq}"
}
如果启用了用户参数,脚本会在上行时自动把这些字段加入结果中;模板模式下 ex 参数里也能直接拿到这些值。
用户参数适合放设备标识、时间、电压、定位、链路状态等附加信息,不适合替代采集映射本身。
| 魔法值 | 说明 | 返回示例 |
|---|---|---|
${ts} |
当前时间戳,单位秒 | 1712131200 |
${date} |
当前格式化时间字符串 | 2026-04-03 10:20:30 |
${imei} |
设备 IMEI | 861241057766154 |
${iccid} |
SIM 卡 ICCID | 8986xxxxxxxxxxxx |
${ip} |
当前 IP 地址 | 10.10.20.30 |
${csq} |
当前信号强度 | 25 |
${link} |
当前链路状态 | 1 |
${vbat} |
当前电池电压,单位 V | 4.08 |
${vbatmv} |
当前电池电压,单位 mV | 4080 |
${lon} |
当前经度,数值格式 | 120.123456 |
${lng} |
当前经度,和 ${lon} 等效 |
120.123456 |
${lons} |
当前经度,字符串格式,保留 9 位小数 | 120.123456789 |
${lngs} |
当前经度,和 ${lons} 等效 |
120.123456789 |
${lat} |
当前纬度,数值格式 | 30.123456 |
${lats} |
当前纬度,字符串格式,保留 9 位小数 | 30.123456789 |
${spd} |
当前速度 | 0 |
${fix} |
当前定位状态,通常 0/1 |
1 |
${alt} |
当前海拔 | 12.5 |
${geo} |
定位结构体,适合阿里云等平台 | {"Latitude":30.1,"Longitude":120.1,"Altitude":12,"CoordinateSystem":1} |
${rand} |
随机字符串,长度 5 | a8K2p |
${ts}:返回当前 Unix 时间戳,单位是秒,适合平台做排序、时序存储或消息去重。${date}:返回格式化时间字符串,格式固定为 YYYY-MM-DD HH:mm:ss,适合直接展示或作为普通文本字段上传。${imei}:返回 DTU 的 IMEI,通常作为设备唯一标识使用。${iccid}:返回当前 SIM 卡 ICCID,适合排查卡槽、流量卡和现场设备绑定关系。${ip}:返回设备当前 IP 地址;如果当前拿不到,实际值可能为空字符串或 NULL。${csq}:返回当前信号强度。这个值通常是一个整数,越大代表信号越好。${link}:返回数据链路状态,适合用来判断当前网络是否在线。${vbat}:返回当前电池电压,单位是伏特,适合直接展示。${vbatmv}:返回当前电池电压,单位是毫伏,适合做更精细的阈值判断。${lon} / ${lng}:返回当前经度,数值格式。一般有 GPS 时优先取 GPS,没有 GPS 时通常取基站位置。${lat}:返回当前纬度,数值格式。${lons} / ${lngs}:返回字符串格式的经度,保留 9 位小数。适合对精度敏感、又不想让 JSON 被自动转成浮点数的场景。${lats}:返回字符串格式的纬度,保留 9 位小数。${spd}:返回当前速度。${fix}:返回定位是否有效,通常 1 表示有效,0 表示无效。${alt}:返回当前海拔高度。${geo}:返回一个结构体对象,格式固定为:{
"Latitude": 30.123456,
"Longitude": 120.123456,
"Altitude": 12.5,
"CoordinateSystem": 1
}
${geo}适合直接对接需要经纬度对象的云平台;如果你的平台只接受普通数值字段,建议分别使用${lat}和${lon}。
${rand}:返回一个 5 位随机字符串,适合做简单流水号后缀、测试标记或防缓存参数。{
"devid": "${imei}",
"ts_str": "${date}",
"ts_num": "${ts}"
}
{
"lat": "${lat}",
"lon": "${lon}",
"vbat": "${vbat}",
"fix": "${fix}"
}
exfunction(tm,td,ex)
return {
devid = ex.imei,
time = ex.date,
signal = ex.csq,
data = tm
}
end
魔法值不仅可以单独写,也可以和普通字符串拼接,例如:
{
"topic": "/device/${imei}/up",
"tag": "time_${ts}",
"desc": "csq_${csq}"
}
这种写法适合主题、标签、消息描述等需要拼接字符串的场景。
如果用户参数的值以 0x 开头,脚本会按十六进制字节串处理,再进行魔法值替换。
例如:
0x31323334
表示原始字节内容 1234。
这种写法通常用于某些需要原始字节、二进制标识或特殊主题格式的场景。普通 JSON 上传一般不需要这样写。
${lat} ${lon} ${alt} ${spd} ${fix} 等字段可能返回默认值。${geo} 返回的是对象,不是字符串;如果目标平台只支持字符串字段,不要直接把它放到纯文本位置。${lats} ${lons} 这类带 s 的字段返回字符串,更适合保精度场景。ex 里只会拿到你已经启用并配置好的用户参数,不会自动包含所有魔法值。Modbus 结果支持 3 种数据结构;一般情况下以及对接内置平台时,推荐选择混合模式。如果要对接自定义协议或平台格式,推荐使用模板模式。
所有映射的键值全局唯一,结果是一个只有一层的 JSON Map
设置两条指令,输出结果:
{"a":10,"b":11}
下发格式通常和上传格式一致,网关会根据映射自动转换成 Modbus 写入指令。
若当前上传结果为:
{"a":10,"b":11}
要修改属性 a 的值,下发:
{"a":20}
启用主动读取时,下发:
{"a":0,"b":0,"read":true}
read:true的含义是“主动读取这些键值对应的映射”,value 仅作为占位,实际读取时不会按这个值写入。
所有映射的键值按设备唯一,结果是一个两层 JSON Array
输出结果示例:
[{"c":3,"v":{"a":0},"i":1},{"c":3,"v":{"b":0},"i":2}]
c表示功能码,v表示映射列表,i表示设备 ID。
设备模式下发示例:
[{"v":{"a":10},"i":1}]
也可以使用更常见的包层格式:
{
"data":[{"v":{"a":10},"i":1}],
"msgid":"12345",
"read":false
}
如果要主动读取:
{
"tab":[{"i":"1","v":{"a":0,"b":0}}],
"msgid":"12345",
"read":true
}
数据结构切换到模板模式后,可以自定义上行、下行和写入回复格式。
模板输入使用 Lua 匿名函数,脚本会按:
function(tm,td,ex) return tm end,
function(sub,ex) return sub end,
function(reply,ex) return reply end
这样的格式进行 load("return " .. 模板内容) 编译。
模板保存后,脚本会先编译,再用测试参数试运行校验:
- 下行模板会以
subTemp({a = 2})测试;- 上行模板会以
pubTemp({a = 2}, {}, ex)测试;- 回复模板会以
replyTemp({a = 2}, {}, ex)测试。
因此模板函数必须能在测试输入下正常运行。
模板里必须做好判空。电表或从机没读到数据时,字段可能不存在;设备模式下
td也可能为空。
判空推荐写法:
(tm.a or 0)
if not td or #td < 1 then return {} end
三个函数的含义:
-- tm = 混合模式结果 table
-- td = 设备模式结果 table
-- ex = 用户参数 table
-- 返回值:任意可序列化结果,通常是 table
function(tm,td,ex)
return tm
end
-- sub = 下行 JSON 反序列化后的 table
-- ex = 用户参数 table
-- 返回最多 4 个值:
-- 1. 转换后的数据
-- 2. bool,是否主动读取
-- 3. 原始串口命令,用于某些自定义透传
-- 4. msgid
function(sub,ex)
return sub, sub.read, nil, sub.msgid
end
-- reply = { suc = suc, prop = prop, errlist = errlist }
-- ex = 用户参数 table(如果有 msgid,会在 ex 里)
function(reply,ex)
return reply
end
实际脚本在默认写入回复中,成功返回
{suc=true, prop=...},失败返回{suc=false, err=...};但 reply 模板的输入参数里使用的是errlist字段。
{"a":0,"b":0,"read":true,"msgid":"12313"}
{"tab":[{"i":"1","v":{"a":0,"b":0}}],"read":true,"msgid":"12313"}
如果网络通道会把数据包成 {topic, payload},只要 payload 解出来后仍然是上面的格式即可,例如:
{"topic":"down/test","payload":"{\"a\":0,\"b\":0,\"read\":true,\"msgid\":\"12313\"}"}
当前脚本也支持
{topic, tab}、{b={...}}这类包层输入。对使用者来说,只需要保证最终进入 Modbus 模块的数据结构正确即可。
启用写入回复后,对下行写入消息会返回一条结果数据。
从较新版本开始,回复内容建议使用模板函数定义,等效于模板模式第三个函数;回复主题用于指定回复的主题。
写入成功:
{
"suc": true,
"prop": {
"DO1": 1
},
"msgid": "12345"
}
写入失败:
{
"suc": false,
"err": ["timeout"],
"msgid": "12345"
}
msgid 的实际行为如果下行带 msgid,脚本会先在 msgid 这个主题上发布一个简单结果:
{"suc":true}
如果同时启用了写入回复,还会再按默认格式或回复模板输出一条回复消息。
如果是 read:true 且带 msgid,每次读到映射值后会把本次读取结果直接发布到 msgid 关联的通道。
示例:
{"DO1":6718,"DO2":6721}
主动读取通过
msgid拿到的通常是“本次读到的属性结果”;写入通过msgid拿到的通常是{suc:true/false}。
对原始属性做运算时必须判空,例如
(tm.a or 0)和(tm.b or 0);否则测试校验或实际读空值时都会报错。
function(tm,td,ex)
tm.c = (tm.a or 0) + (tm.b or 0)
return table.merge(tm, ex)
end
若混合模式上传:
{"a":10,"b":11}
模板处理后上传:
{"a":10,"b":11,"c":21}
若服务器下发:
{"msgid":1234,"ts":123456778,"param":{"a":1,"b":2}}
这里需要写入参数 a 和 b,但数据在第二层嵌套里面;使用下行模板转换成混合模式输入:
function(tm,td,ex)
return table.merge(tm, ex)
end,
function(sub, ex)
return sub.param or {}, sub.read or false, nil, sub.msgid
end
结果为:
{"a":1,"b":2}
若服务器下发:
{"msgid":1234,"ts":123456778,"read":{"a":1,"b":2}}
需要把嵌套的 read 对象展开,并触发主动读取:
function(tm,td,ex)
return table.merge(tm, ex)
end,
function(sub, ex)
return sub.read or {}, true, nil, sub.msgid
end
function(tm,td,ex)
return table.merge(tm, ex)
end,
function(sub, ex)
return sub.data or {}, sub.read or false, nil, sub.msgid
end
当服务器下发:
{"msgid":123,"data":{"DO1":0},"read":false}
表示写入;如果改成:
{"msgid":123,"data":{"DO1":0},"read":true}
则表示主动读取。
function(tm,td,ex)
local template = {
version = "1.0",
type = "variant_data",
ts = os.time(),
params = {},
time = 0
}
tm.c = (tm.a or 0) + (tm.b or 0)
template.params = table.merge(tm, ex)
return template
end
若混合模式上传:
{"a":10,"b":11}
处理后:
{"type":"variant_data","version":"1.0","ts":1656642045,"time":0,"params":{"a":10,"b":11,"c":21}}
function(tm,td,ex)
if not td or #td < 1 then return {} end
return {
msgtype = "test2",
data = td
}
end
假设用户参数设置:
"1":"device1"
"2":"device2"
function(tm,td,ex)
if not td or #td < 1 then return {} end
local id = tostring(td[1].i)
return {
DeviceName = ex[id] or "defaultname",
Timestamp = os.time() * 1000,
DataMap = tm
}
end
function(tm,td,ex)
if not td or #td < 1 then return {} end
local template = {}
for _, value in ipairs(td) do
local id = tostring(value.i)
template["slaveNo" .. id] = value.v or {}
end
return template
end
若设备模式上传:
[{"c":3,"v":{"IA":12,"Temp":10.2},"i":1},{"c":3,"v":{"IA":13,"Temp":10},"i":2}]
处理后:
{
"slaveNo1":{"IA":12,"Temp":10.2},
"slaveNo2":{"IA":13,"Temp":10}
}
目标格式:
{
"sensorDatas":[
{"addTime":"2015-01-01 12:00:00","value":"10.0","flag":"Temp"}
]
}
上行模板:
function(tm,td,ex)
local template = { sensorDatas = {} }
for key, value in pairs(tm or {}) do
table.insert(template.sensorDatas, {
addTime = os.date("%Y-%m-%d %H:%M:%S", os.time()),
value = value,
flag = key
})
end
return template
end
例如某些流量计把整数和小数拆成两个映射值,最终需要在模板里重新合成:
function(tm,td,ex)
if tm.lj_h == nil or tm.lj_l == nil then
return tm
end
local lj = (tm.lj_h or 0) + (tm.lj_l or 0) / 65536.0
tm.lj_h = nil
tm.lj_l = nil
tm.lj = lj
return table.merge(tm, ex)
end
function(tm,td,ex)
local template = {"<south>\n<MsgType>1</MsgType>\n"}
for key, value in pairs(tm or {}) do
table.insert(template, "<")
table.insert(template, key)
table.insert(template, ">")
table.insert(template, tostring(value))
table.insert(template, "</")
table.insert(template, key)
table.insert(template, ">\n")
end
table.insert(template, "</south>")
return table.concat(template)
end
服务器下发:
[
{"1234":{"key1":"10","key2":"20"}},
{"5678":{"key1":"30","key2":"40"}}
]
要转换成设备模式写入:
[
{"v":{"key1":"10","key2":"20"},"i":"1234"},
{"v":{"key1":"30","key2":"40"},"i":"5678"}
]
模板:
function(tm,td,ex)
return td or {}
end,
function(sub, ex)
if not sub or #sub < 1 then return {} end
local devTemp = {}
for _, value in ipairs(sub) do
for key, val in pairs(value) do
table.insert(devTemp, { i = key, v = val })
end
end
return devTemp, false
end
function(reply, ex)
return {
stat = reply.suc or false,
msgid = ex and ex.msgid,
prop = reply.prop or {},
err = reply.errlist or {}
}
end
以下内容建议在较新版本中使用。
msgid下发:
{"DO1":1,"msgid":"12345","read":false}
msgid 关联结果:
{"suc":true}
如果启用写入回复,回复可能是:
{
"suc": true,
"prop": {
"DO1": 1
},
"msgid": "12345"
}
下发:
{"data":{"DO1":1},"msgid":"12345","read":false}
模板:
function(tm,td,ex)
return table.merge(tm, ex)
end,
function(sub, ex)
return sub.data or {}, sub.read or false, nil, sub.msgid
end
下发:
{"data":[{"v":{"DO1":1},"i":1}],"msgid":"12345","read":false}
模板:
function(tm,td,ex)
return td or {}
end,
function(sub, ex)
return sub.data or {}, sub.read or false, nil, sub.msgid
end
有些项目里,不同的数据需要发送到不同的 MQTT 主题,例如:
这种场景下,模板返回值本身不能直接修改网络模块的默认发布主题,但可以在模板里主动调用:
sys.publish("N_SEND_1", { d = json.encode(temp), t = topic })
其中:
N_SEND_1 表示网络通道 1 的发送入口;d 表示要发送的字符串内容;t 表示目标 MQTT 主题。这种方式只适用于 MQTT/SM 网络通道;TCP、UDP、HTTP 等网络通道不会使用
t字段作为目标主题。
如果模板里已经主动把数据发到指定主题,通常建议最后
return {},避免 Modbus 默认上传再重复发一份。
当前
net.lua的 MQTT 离线缓存重发流程,对自定义主题t的保留并不完整。在线时可以按指定主题正常发送;如果消息离线缓存后再补发,可能退回到默认发布主题。这个场景上线前必须实测。
sys.publish("N_SEND_x", ...) 直接把消息交给网络模块;t 作为发布主题;t,则使用网络配置里的默认发布主题。这意味着:
sys.publish;sys.publish("N_SEND_x", ...) 发送。如果你的配置页面要求网络模块至少选择一个输入源,可以把“规则引擎”作为占位输入源;但真正起作用的是
N_SEND_x这个内部发送入口,不是普通数据源订阅。
sys.publish("N_SEND_1", {
d = json.encode(temp),
t = "/project/topic"
})
也可以直接传 table,让网络模块自动编码:
sys.publish("N_SEND_1", {
b = temp,
t = "/project/topic"
})
d是已经编码好的字符串;b是 table。两种写法都可以,通常推荐d = json.encode(...),这样更直观,也更方便自己控制最终内容。
function(tm,td,ex)
local runtime = {
imei = ex.imei,
ts = ex.date,
data = {
ua = tm.ua,
ub = tm.ub,
uc = tm.uc,
ia = tm.ia,
ib = tm.ib,
ic = tm.ic
}
}
sys.publish("N_SEND_1", {
d = json.encode(runtime),
t = "/device/runtime"
})
if (tm.temp or 0) > 60 then
local alarm = {
imei = ex.imei,
ts = ex.date,
alarm = "TEMP_HIGH",
value = tm.temp
}
sys.publish("N_SEND_1", {
d = json.encode(alarm),
t = "/device/alarm"
})
end
return {}
end
function(tm,td,ex)
if not td or #td < 1 then return {} end
for _, item in ipairs(td) do
local id = tostring(item.i)
local topic = "/modbus/device/" .. id .. "/up"
local payload = {
imei = ex.imei,
ts = ex.date,
slave = id,
data = item.v or {}
}
sys.publish("N_SEND_1", {
d = json.encode(payload),
t = topic
})
end
return {}
end
function(tm,td,ex)
local env = {
imei = ex.imei,
ts = ex.date,
temp = tm.Temp,
hum = tm.Hum
}
local io = {
imei = ex.imei,
ts = ex.date,
do1 = tm.DO1,
do2 = tm.DO2
}
sys.publish("N_SEND_1", {
d = json.encode(env),
t = "/project/env"
})
sys.publish("N_SEND_1", {
d = json.encode(io),
t = "/project/io"
})
return {}
end
return {} 可以通过模板初始化测试,也适合这种“模板里自行完成发送”的场景。sys.publish("N_SEND_x", ...)。N_SEND_1、N_SEND_2 等。如果你的平台同时接 Modbus 和 645,建议统一成同样的上下行格式,后续二次开发会更简单。
Modbus 结果:
{
"ts":"2023-08-10 21:54:57",
"devid":"861241057766154",
"data":{
"100":{"AI2":0.386032,"AI1":2.98898},
"200":{"DO2":0,"DO1":0}
}
}
{
"data":[
{
"v":{"DO1":1},
"i":"200"
}
],
"msgid":"12455",
"read":true
}
{
"data":[
{
"v":{"DO1":1},
"i":"200"
}
],
"msgid":"12455",
"read":false
}
function(tm, td, ex)
if not td or #td < 1 then return {} end
local imei = misc.getImei()
local list = {}
for _, value in ipairs(td) do
local idx = tostring(value.i)
local tab = list[idx] or {}
list[idx] = table.merge(tab, value.v or {})
end
return {
devid = imei,
ts = os.date("%Y-%m-%d %H:%M:%S"),
data = list,
msgid = ex and ex.msgid
}
end
function(sub, ex)
return sub.data or {}, sub.read or false, nil, sub.msgid
end
function(reply, ex)
return {
stat = reply.suc or false,
msgid = ex and ex.msgid,
prop = reply.prop or "null"
}
end
function(tm, td, ex)
if not td or #td < 1 then return {} end
local imei = misc.getImei()
local list = {}
for _, value in ipairs(td) do
local idx = tostring(value.i)
local tab = list[idx] or {}
list[idx] = table.merge(tab, value.v or {})
end
return {
devid = imei,
ts = os.date("%Y-%m-%d %H:%M:%S"),
data = list,
msgid = ex and ex.msgid
}
end,
function(sub, ex)
return sub.data or {}, sub.read or false, nil, sub.msgid
end,
function(reply, ex)
return {
stat = reply.suc or false,
msgid = ex and ex.msgid,
prop = reply.prop or "null"
}
end
tm 字段值、td 数组、sub.data 等。msgid,方便业务侧同步等待。msgid 是两套机制:msgid 更多用于读写关联,写入回复更多用于统一返回业务结构。