Spring Boot 农业物联网平台:从 0 到 1 搭建
传感器数据上了 MQTT,摄像头跑起了 YOLO,现在需要一个「大脑」把它们管起来。这篇从零搭一套 Spring Boot 3 物联网中台:设备管理、TDengine 时序存储、告警规则引擎、ECharts 可视化。
项目初始化
bash
# Spring Boot 3.2 + JDK 17
spring init \
-d web,websocket,mybatis,mysql,lombok,validation \
-g com.farmer \
-a farm-iot-platform \
farm-iot-platform
额外依赖:
xml
<!-- TDengine 连接器 -->
<dependency>
<groupId>com.taosdata.jdbc</groupId>
<artifactId>taos-jdbcdriver</artifactId>
<version>3.3.0</version>
</dependency>
<!-- EMQX MQTT 客户端 -->
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
<!-- Sa-Token 权限 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.38.0</version>
</dependency>
目录结构:
farm-iot-platform
├── controller/ → REST API 层
├── service/ → 业务逻辑
├── mapper/ → MyBatis Mapper
├── model/
│ ├── entity/ → MySQL 实体
│ └── dto/ → 数据传输对象
├── mqtt/ → MQTT 消息处理
├── alarm/ → 告警引擎
├── websocket/ → 实时推送
└── config/ → 配置类
设备管理:CRUD + 分组 + 标签
MySQL 表设计:
sql
CREATE TABLE device (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL UNIQUE COMMENT '设备唯一ID, esp32_a1b2c3',
name VARCHAR(128) COMMENT '设备名称, 大棚A东区传感器1',
type VARCHAR(32) NOT NULL COMMENT 'sensor/actuator/camera',
location VARCHAR(256) COMMENT '安装位置',
group_id BIGINT COMMENT '所属分组',
tags VARCHAR(512) DEFAULT '' COMMENT '标签,逗号分隔',
mqtt_topic VARCHAR(256) COMMENT 'MQTT topic 前缀',
online TINYINT DEFAULT 0 COMMENT '0离线 1在线',
firmware VARCHAR(32) COMMENT '固件版本',
lat DOUBLE COMMENT '纬度',
lng DOUBLE COMMENT '经度',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_device_id (device_id),
INDEX idx_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE device_group (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL COMMENT '分组名, 大棚A/果园B',
parent_id BIGINT DEFAULT 0 COMMENT '父分组ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
实体 + Mapper 用 MyBatis-Plus 一把梭:
java
@TableName("device")
public class Device {
@TableId(type = IdType.AUTO)
private Long id;
private String deviceId;
private String name;
private String type; // sensor / actuator / camera
private String location;
private Long groupId;
private String tags;
private String mqttTopic;
private Boolean online;
private String firmware;
}
public interface DeviceMapper extends BaseMapper<Device> {}
Service 层提供分页查询、按分组筛选、按标签搜索:
java
@Service
public class DeviceService {
public IPage<DeviceVO> pageDevices(DevicePageQuery query) {
LambdaQueryWrapper<Device> wrapper = new LambdaQueryWrapper<>();
// 关键搜索逻辑:模糊匹配名称或设备ID
if (StrUtil.isNotBlank(query.getKeyword())) {
wrapper.and(w -> w
.like(Device::getName, query.getKeyword())
.or()
.eq(Device::getDeviceId, query.getKeyword()));
}
// 按分组筛选(含子分组)
if (query.getGroupId() != null) {
List<Long> groupIds = getGroupWithChildren(query.getGroupId());
wrapper.in(Device::getGroupId, groupIds);
}
// 按标签筛选(FIND_IN_SET)
if (StrUtil.isNotBlank(query.getTag())) {
wrapper.apply("FIND_IN_SET({0}, tags)", query.getTag());
}
return deviceMapper.selectPage(query.getPage(), wrapper)
.convert(this::toVO);
}
}
TDengine 时序存储------写入快、查询快、压缩率高
TDengine 专门为物联网场景设计,写入性能是 InfluxDB 的 10 倍,数据压缩率 10:1 以上。
建超级表 + 自动子表
sql
-- 超级表:定义数据结构和标签
CREATE STABLE IF NOT EXISTS farm.sensor_data (
ts TIMESTAMP,
air_temp FLOAT,
air_humidity FLOAT,
soil_temp FLOAT,
soil_moisture FLOAT,
light INT,
battery FLOAT,
rssi INT
) TAGS (
device_id BINARY(64),
location BINARY(256)
);
-- 插入时自动创建子表(子表名 = d_设备ID)
INSERT INTO farm.d_esp32_a1b2c3
USING farm.sensor_data TAGS ('esp32_a1b2c3', '大棚A-东区-1号位')
VALUES (NOW, 26.5, 68.2, 22.1, 35.0, 42000, 3.82, -65);
Spring Boot 连接 TDengine
java
@Configuration
public class TDengineConfig {
@Bean
public JdbcTemplate tdengineJdbcTemplate(
@Value("${tdengine.url}") String url,
@Value("${tdengine.username}") String username,
@Value("${tdengine.password}") String password) {
return new JdbcTemplate(new DriverManagerDataSource(url, username, password));
}
}
application.yml:
yaml
tdengine:
url: jdbc:TAOS://localhost:6030/farm
username: root
password: taosdata
监听 MQTT 消息并写入 TDengine
java
@Component
public class SensorDataListener {
@Autowired
private JdbcTemplate tdengine;
@MqttListener(topic = "farm/+/sensor/+/data")
public void onSensorData(String topic, String payload) {
SensorData data = JSON.parseObject(payload, SensorData.class);
// 提取 sensor 之后的设备ID(topic 第 3 段)
String deviceId = topic.split("/")[3];
String tableName = "farm.d_" + deviceId.replace("-", "_");
tdengine.update("INSERT INTO " + tableName
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
data.getTs(), data.getAirTemp(), data.getAirHumidity(),
data.getSoilTemp(), data.getSoilMoisture(),
data.getLight(), data.getBattery(), data.getRssi());
}
}
查询------TDengine 标准 SQL
sql
-- 最近 24 小时数据(每 5 分钟一个点降采样)
SELECT _wstart, AVG(air_temp), AVG(air_humidity), AVG(soil_moisture)
FROM farm.sensor_data
WHERE ts >= NOW - 1d
INTERVAL(5m);
-- 多设备对比:大棚 A 和 B 过去 1 小时温度
SELECT ts, air_temp FROM farm.d_esp32_a1b2c3 WHERE ts >= NOW - 1h
UNION ALL
SELECT ts, air_temp FROM farm.d_esp32_d4e5f6 WHERE ts >= NOW - 1h;
告警规则引擎------不要整 DSL,正则就够了
告警配置存 MySQL:
sql
CREATE TABLE alarm_rule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(128),
device_id VARCHAR(64), -- NULL = 所有设备
field VARCHAR(32), -- air_temp / soil_moisture / battery / online
operator VARCHAR(8), -- > / < / == / !=
threshold DOUBLE,
duration INT DEFAULT 0, -- 持续秒数,0 = 立即触发
level VARCHAR(16) DEFAULT 'warn', -- warn / error / critical
enabled TINYINT DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
告警引擎------每次收到传感器数据后触发评估:
java
@Service
public class AlarmEngine {
@Autowired
private AlarmRuleMapper ruleMapper;
@Autowired
private AlarmRecordMapper recordMapper;
@Autowired
private WebSocketService wsService;
private Map<String, LocalDateTime> conditionMetSince = new ConcurrentHashMap<>();
public void evaluate(SensorData data) {
List<AlarmRule> rules = ruleMapper.selectList(
new LambdaQueryWrapper<AlarmRule>()
.eq(AlarmRule::getEnabled, true)
.and(w -> w.isNull(AlarmRule::getDeviceId)
.or().eq(AlarmRule::getDeviceId, data.getDev())));
for (AlarmRule rule : rules) {
double value = data.getValueByField(rule.getField());
boolean triggered = evaluateRule(rule.getOperator(), value, rule.getThreshold());
String ruleKey = rule.getId() + "_" + data.getDev();
if (triggered) {
LocalDateTime since = conditionMetSince.get(ruleKey);
if (since == null) {
conditionMetSince.put(ruleKey, LocalDateTime.now());
} else if (ChronoUnit.SECONDS.between(since, LocalDateTime.now()) >= rule.getDuration()) {
fireAlarm(rule, data, value);
}
} else {
conditionMetSince.remove(ruleKey);
}
}
}
private void fireAlarm(AlarmRule rule, SensorData data, double value) {
// 记录告警
AlarmRecord record = new AlarmRecord();
record.setRuleId(rule.getId());
record.setRuleName(rule.getName());
record.setDeviceId(data.getDev());
record.setField(rule.getField());
record.setValue(value);
record.setThreshold(rule.getThreshold());
record.setLevel(rule.getLevel());
recordMapper.insert(record);
// 推送告警到前端
wsService.push(MessageType.ALARM, record);
}
}
告警类型示例:
| 规则 | 条件 | 级别 |
|---|---|---|
| 土壤过干 | soil_moisture < 20% 持续 10min | warn |
| 大棚高温 | air_temp > 40℃ | error |
| 设备离线 | online == false | critical |
| 电池低电 | battery < 3.4V | warn |
| CO₂ 不足 | co2 < 300ppm | warn |
WebSocket 实时推送 + ECharts 可视化
后端 WebSocket 配置:
java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new FarmWebSocketHandler(), "/ws/farm")
.setAllowedOrigins("*");
}
}
前端用 ECharts 动态刷新(Vue 3 组件):
vue
<script setup>
import * as echarts from 'echarts';
import { ref, onMounted, onUnmounted } from 'vue';
const chartRef = ref(null);
let chart = null;
let ws = null;
onMounted(() => {
chart = echarts.init(chartRef.value);
chart.setOption({
title: { text: '大棚 A 实时温湿度' },
xAxis: { type: 'time' },
yAxis: [
{ type: 'value', name: '温度(℃)' },
{ type: 'value', name: '湿度(%)' }
],
series: [
{ name: '温度', type: 'line', yAxisIndex: 0, data: [] },
{ name: '湿度', type: 'line', yAxisIndex: 1, data: [] }
]
});
// WebSocket 连接
ws = new WebSocket('ws://your-server/ws/farm');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const now = new Date(data.ts * 1000);
// 向图表追加数据点
chart.setOption({
series: [
{ data: [...chart.getOption().series[0].data, [now, data.air_temp]] },
{ data: [...chart.getOption().series[1].data, [now, data.air_humidity]] }
]
});
};
});
onUnmounted(() => {
chart?.dispose();
ws?.close();
});
</script>
<template>
<div ref="chartRef" style="width:100%;height:400px"></div>
</template>
部署清单
bash
# 1. 阿里云 / 腾讯云 2C4G 服务器
# 2. 安装 Docker
# 3. 一条命令起三个容器
docker run -d --name emqx -p 1883:1883 -p 18083:18083 emqx/emqx:5.7.0
docker run -d --name tdengine -p 6030:6030 -p 6041:6041 tdengine/tdengine:3.3.0
docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=xxx mysql:8.0
# 4. 打包 Spring Boot 应用
mvn clean package -DskipTests
# 5. 运行
java -jar farm-iot-platform.jar --spring.profiles.active=prod
下一篇:《微信小程序:农户手机上的「农场管家」》------UniApp 开发,一个码农扫二维码就能看到自家大棚实时数据,手指一点远程灌溉。