从0到1搭建智慧农业平台:Java全栈实战与物联网接入

14 阅读15分钟

从0到1搭建智慧农业平台:Java全栈实战与物联网接入

大家好,我是一名软件开发工程师,平时白天上班搬砖,晚上搞副业折腾项目。今天想跟大家聊聊我业余时间做的一套智慧农业平台——从架构设计到前后端开发,再到物联网接入,算是走了一遍全栈的完整链路。

一、为什么做这个项目

做副业选方向很重要。我选智慧农业赛道,主要三个考量:

政策风口——数字农业、乡村振兴这些概念不是空喊的,各地确实在推进农业数字化改造,区县一级的农业园区有真实的信息化需求。技术挑战有深度——不是写几个CRUD页面就能交差的,环境监测的时序数据、物联网的协议解析、预警规则引擎,每个模块都有技术含量。市场有空隙——大厂做的是大农场大园区,动辄百万起步;中小农场、合作社那块,缺少低成本可落地的方案,这就是个人开发者的机会。

从2023年底开始搞,到现在前端已经迭代到v17,后端6大微服务稳定运行,物联网接入链路完整跑通。过程踩了不少坑,这篇文章把架构思路和关键技术点分享出来,希望对做类似全栈项目的同学有帮助。

二、整体架构设计

2.1 架构选型思路

微服务 vs 单体,这个争论太多人了。我的选择是微服务,理由很实际:模块解耦后可以独立部署,后续还能按模块售卖——比如客户只买环境监测模块,单独部署就行,不用把整个系统搬过去。当然,微服务的代价是运维复杂度上来了,个人项目这点需要权衡。

Vue 2 vs Vue 3,选Vue 2不是因为我不会Vue 3,而是Element UI在Vue 2下的组件生态更成熟、踩坑记录更多。做交付项目,稳定性优先,Vue 3 + Element Plus那会儿还不太稳。而且说实话,这个项目的复杂度Vue 2完全扛得住,没必要追新。

MyBatis-Plus vs JPA,选MyBatis-Plus是因为智慧农业场景下复杂查询很多——环境数据的多维度聚合、预警规则的动态拼接,JPA的Criteria API写着太痛苦,MyBatis-Plus的LambdaQueryWrapper加手写SQL的混合方案更灵活,性能也好控制。

2.2 系统架构分层

整体分为四层:

plaintext

┌─────────────────────────────────────────────┐
│              前端层                           │
│   Vue 2 + Element UI + ECharts + Axios       │
├─────────────────────────────────────────────┤
│              网关层                           │
│   Spring Cloud Gateway + JWT鉴权 + 路由转发   │
├─────────────────────────────────────────────┤
│            微服务层                           │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐       │
│  │环境监测│ │预警管理│ │农田管理│ │作物管理│      │
│  └──────┘ └──────┘ └──────┘ └──────┘       │
│  ┌──────┐ ┌──────────┐                      │
│  │农事作业│ │物联网接入 │                      │
│  │ Service │ │(Smart    │                     │
│  └──────┘ │Collector) │                     │
│           └──────────┘                       │
├─────────────────────────────────────────────┤
│              数据层                           │
│         MySQL 8 + Redis + Nacos              │
└─────────────────────────────────────────────┘

Nacos同时承担注册中心和配置中心的职责。Redis主要缓存环境监测的实时数据和权限信息。MySQL 8存业务数据,环境时序数据也在MySQL里(后面会说为什么没用时序数据库)。

2.3 服务拆分原则

6个业务服务按业务域拆分:环境监测、预警管理、农田管理、作物管理、农事作业、物联网接入。拆分边界主要看三个维度:数据归属(谁的数据谁管)、调用频次(高频低频分离)、独立变更频率(改一个不影响另一个)。

这里踩过一个坑:初期拆太细了。我把"环境数据采集"和"环境数据分析"拆成了两个服务,结果发现这俩几乎每次需求变更都要一起改,部署也要一起发,拆了等于没拆,还多了服务间调用的调试成本。后来果断合并成现在的"环境监测服务",代码内用模块隔离就好。

经验就是:个人项目拆服务别太追求细粒度,按业务域粗拆即可,代码层面用包结构做隔离,等真有独立部署需求再拆不迟。

三、前端:数据可视化与动态路由实践

3.1 仪表盘设计

仪表盘是整个智慧农业系统的门面,用ECharts做了三块核心可视化:

  • 折线图:环境数据趋势(温度、湿度、光照等24小时变化曲线)
  • 仪表盘:关键实时指标(当前土壤湿度百分比、空气温度等)
  • 地图:农田分布概览(用ECharts的geo组件标注地块位置)

数据刷新策略纠结过一阵。一开始想用WebSocket推送,后来发现环境数据其实5秒刷新一次就够用,WebSocket反而增加了服务端维护连接的负担。最终方案是前端轮询,5秒间隔,请求最近1小时数据,服务端用Redis缓存,查询几乎零耗时。

javascript

// 仪表盘数据轮询
data() {
  return {
    dashboardTimer: null
  }
},
methods: {
  loadDashboardData() {
    getDashboardOverview().then(res => {
      this.updateCharts(res.data)
    })
  },
  startPolling() {
    this.loadDashboardData()
    this.dashboardTimer = setInterval(() => {
      this.loadDashboardData()
    }, 5000)
  }
},
mounted() {
  this.startPolling()
},
beforeDestroy() {
  // 重要:清除定时器,防止内存泄漏
  clearInterval(this.dashboardTimer)
  this.dashboardTimer = null
}

大屏适配方案用的 rem + scale混合方案。以1920设计稿为基准,1366分辨率用transform: scale(0.71)缩放,移动端单独做一版简化布局。纯rem方案在不同屏幕比例下会变形,scale方案虽然有小黑边但布局不乱,实际交付更稳。

3.2 动态路由与权限

这是Vue前后端分离项目里的经典问题——权限不同的人看到的菜单不同。方案是后端返回菜单树,前端动态生成路由

javascript

// 后端返回的菜单结构扁平化后动态注册路由
function generateRoutes(menuTree) {
  const routes = []
  menuTree.forEach(menu => {
    const route = {
      path: menu.path,
      component: () => import(`@/views/${menu.component}`),
      name: menu.name,
      meta: { title: menu.title, icon: menu.icon }
    }
    if (menu.children && menu.children.length) {
      route.children = generateRoutes(menu.children)
    }
    routes.push(route)
  })
  return routes
}

// 在路由守卫中动态添加
router.beforeEach(async (to, from, next) => {
  if (getStore('token')) {
    if (!store.getters.menuLoaded) {
      await store.dispatch('GenerateRoutes')
      router.addRoutes(store.getters.addRouters)
      next({ ...to, replace: true })
    } else {
      next()
    }
  } else {
    next('/login')
  }
})

按钮级权限用的是自定义指令 v-permission

javascript

Vue.directive('permission', {
  inserted(el, binding) {
    const permissions = store.getters.permissions
    const required = binding.value
    if (!permissions.includes(required)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
})
// 使用:<el-button v-permission="['farm:add']">新增地块</el-button>

关于keep-alive的取舍:列表页需要缓存搜索状态,但详情页不能缓存。最后用了include动态控制,只缓存需要缓存的组件名,避免内存无限增长。

3.3 农田管理模块的交互细节

农田管理是业务核心模块之一,几个交互设计值得说下:

区域联动选择器:省→市→区县三级联动,数据字典存后端,前端懒加载。选完区域后面积字段会根据地块类型自动填入默认值,减少用户输入。

一块地多种作物:农田和作物是多对多关系。前端用el-tag展示已关联的作物列表,点击弹出el-dialog做关联操作,后端用中间表farm_crop_relation维护关系。

多条件组合筛选:封装了一个SearchForm组件,传入配置数组即可生成搜索栏,支持输入框、下拉框、日期范围等类型,统一处理参数拼接和重置逻辑。导出功能在组件里也做了封装,调用后端导出接口直接下载Excel。

四、后端:微服务落地与关键技术点

4.1 SpringBoot微服务通信

服务间调用用OpenFeign,没什么特别的,但超时配置容易被忽略:

yaml

# OpenFeign超时配置,默认1秒太短了
feign:
  client:
    config:
      default:
        connectTimeout: 3000
        readTimeout: 10000

统一异常处理是必须的,不然前端拿到的错误格式五花八门:

java

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusinessException(BusinessException e) {
        return Result.fail(e.getCode(), e.getMessage());
    }
    
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

统一返回结构Result<T>就不贴了,就code/msg/data三字段,前后端约定好就行。关键是所有接口都走这个结构,包括分页、异常、空数据,前端解析逻辑才能统一。

4.2 环境监测服务的时序数据方案

很多人问我为什么不用时序数据库(InfluxDB、TDengine之类的)。原因很简单:预估数据量MySQL扛得住。我这个场景,100个传感器5秒一条,一天也就170万条,按天分表后单表数据量可控。上时序数据库多一套中间件的运维成本,个人项目不划算。

方案是 MySQL按天分表 + Redis缓存最近1小时数据

java

// 按天动态表名
String tableName = "env_data_" + DateUtil.format(new Date(), "yyyyMMdd");

// Redis缓存最近1小时,key带设备ID
String cacheKey = "env:realtime:" + deviceId;
redisTemplate.opsForValue().set(cacheKey, jsonData, 1, TimeUnit.HOURS);

聚合查询的索引设计要讲究:设备ID + 时间戳的联合索引,查询时带上时间范围,避免全表扫描。1分钟粒度用GROUP BY device_id, FLOOR(timestamp/60000),5分钟和1小时粒度同理,SQL里整除就行。

sql

-- 5分钟粒度聚合查询
SELECT device_id, 
       FLOOR(unix_timestamp(create_time) / 300) * 300 AS time_bucket,
       AVG(temperature) AS avg_temp,
       AVG(humidity) AS avg_humidity
FROM env_data_20260515
WHERE device_id = ? AND create_time BETWEEN ? AND ?
GROUP BY device_id, time_bucket
ORDER BY time_bucket

4.3 预警规则引擎

预警是智慧农业的核心功能之一。规则设计成 字段 + 运算符 + 阈值 + 持续时间 的组合,比如"土壤湿度 < 30% 持续10分钟"才告警,避免瞬时波动误报。

java

// 规则配置实体
public class AlertRule {
    private Long id;
    private String field;        // 监测字段:temperature/humidity/soil_moisture
    private String operator;     // 运算符:GT/GTE/LT/LTE/EQ
    private BigDecimal threshold; // 阈值
    private Integer duration;    // 持续时间(秒)
    private Integer level;       // 预警级别:1预警 2告警
}

规则匹配有两种方式:定时任务扫描适合批量检查,简单可靠;事件驱动(数据上报时实时匹配)适合低延迟场景。我两种都实现了,定时任务5分钟扫一轮兜底,物联网数据上报时也做一次实时匹配做快速响应。

告警去重很关键——同一设备同一规则5分钟内不重复告警:

java

String dedupeKey = "alert:dedupe:" + deviceId + ":" + ruleId;
Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(
    dedupeKey, "1", 5, TimeUnit.MINUTES);
if (!isFirst) {
    return; // 5分钟内已告警过,跳过
}

4.4 MyBatis-Plus的高效用法

MyBatis-Plus在这个项目里用得很深,分享几个实用技巧:

多表关联查询:简单查询用LambdaQueryWrapper,复杂关联直接手写SQL。别强求全用Wrapper,有些多表查询Wrapper写出来比SQL还难读。

java

// 简单查询用Wrapper
List<Farm> farms = farmMapper.selectList(
    new LambdaQueryWrapper<Farm>()
        .eq(Farm::getStatus, 1)
        .like(StringUtils.isNotBlank(name), Farm::getName, name)
        .orderByDesc(Farm::getCreateTime)
);

// 复杂关联用XML手写SQL
// mapper.xml中定义关联查询,Service层直接调用

自动填充 + 逻辑删除 + 乐观锁,这三个一起配好能少写大量重复代码:

java

// 自动填充处理器
@Component
public class AutoFillHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", Date::new, Date.class);
        this.strictInsertFill(metaObject, "createBy", () -> getCurrentUserId(), Long.class);
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", Date::new, Date.class);
        this.strictUpdateFill(metaObject, "updateBy", () -> getCurrentUserId(), Long.class);
    }
}

分页插件用PaginationInnerInterceptor,注意配置maxLimit防止前端传个超大pageSize把数据库查崩了。

五、物联网接入:Smart Collector的设计与实现

5.1 为什么需要Smart Collector

做物联网项目最尴尬的问题是——开发阶段没有硬件。传感器买回来还得搞电路、配网关,成本高周期长。所以我自研了Smart Collector,它同时承担两个角色:

  1. 模拟器:模拟传感器设备,按配置规则生成环境数据并上报,开发调试全靠它
  2. 协议解析层:真实设备接入时,不同厂商的协议差异在这一层消化,上层业务代码不用改

5.2 数据上报链路

完整链路:模拟器生成数据 → MQTT/HTTP上报 → 协议解析 → 入库 → 前端展示

每一层的数据格式都有明确定义,比如模拟器上报的JSON:

json

{
  "deviceId": "SENSOR-TEMP-001",
  "timestamp": 1715788800000,
  "protocol": "MQTT",
  "data": {
    "temperature": 26.5,
    "humidity": 68.2,
    "soilMoisture": 35.1,
    "lightIntensity": 12000
  }
}

协议解析后转成内部统一格式:

json

{
  "deviceId": "SENSOR-TEMP-001",
  "collectTime": "2026-05-15 14:00:00",
  "metrics": [
    {"field": "temperature", "value": 26.5, "unit": "℃"},
    {"field": "humidity", "value": 68.2, "unit": "%RH"},
    {"field": "soilMoisture", "value": 35.1, "unit": "%"},
    {"field": "lightIntensity", "value": 12000, "unit": "lux"}
  ],
  "quality": "NORMAL"
}

5.3 协议解析设计

协议解析用了策略模式,不同设备协议走不同的Parser,新增协议只需实现接口注册即可:

java

public interface DataParser {
    boolean support(String protocolType);
    ParsedData parse(RawMessage rawMessage);
}

// 温湿度传感器协议解析器
@Component
public class TempHumidityParser implements DataParser {
    @Override
    public boolean support(String protocolType) {
        return "TEMP_HUMIDITY".equals(protocolType);
    }
    
    @Override
    public ParsedData parse(RawMessage rawMessage) {
        // 协议解析 + CRC校验 + 字段合法性检查
        // 异常数据标记quality=ABNORMAL,不丢弃,保留审计痕迹
    }
}

这里有个设计决策:异常数据(比如温度突然跳到200℃)不丢弃,而是标记为ABNORMAL。保留脏数据是为了事后追溯问题,万一不是传感器故障而是真实的环境异常呢?丢数据是不可逆的,标记是可逆的。

5.4 模拟器的灵活配置

Smart Collector的模拟器支持三种数据生成模式:

  • 正常波动:在基准值附近随机波动,模拟日常环境变化
  • 渐变模式:数据缓慢偏移,模拟季节变化或设备老化
  • 突变模式:突然跳到极端值,专门用来测试预警链路

java

// 模拟器配置示例
@DataSimulatorRule(field = "temperature", mode = "FLUCTUATE")
public class TemperatureSimulator {
    private double baseValue = 25.0;     // 基准温度
    private double fluctuation = 2.0;    // 波动范围±2℃
    private int intervalSeconds = 5;     // 5秒上报一次
    
    public double generate() {
        return baseValue + (Math.random() - 0.5) * 2 * fluctuation;
    }
}

测试预警时切到突变模式,手动把温度拉到40℃以上,观察预警服务是否能正确触发告警、前端是否能实时展示,整个链路一测便知。比接真实硬件灵活太多了。

六、踩坑记录与经验总结

6.1 前端踩坑

ECharts内存泄漏——这个坑藏得很深。仪表盘页面频繁切换后,浏览器内存一直涨,最终页面卡死。原因是组件销毁时没有调用chart.dispose(),ECharts实例和DOM事件监听都还在。解决方案在beforeDestroy里手动dispose:

javascript

beforeDestroy() {
  if (this.chart) {
    this.chart.dispose()
    this.chart = null
  }
  clearInterval(this.dashboardTimer)
}

Element UI表格大数据量卡顿——环境监测历史数据列表,一次查几百条时el-table渲染明显卡顿。引入虚拟滚动方案后只渲染可视区域的行,滚动丝滑。可以用el-table配合vxe-table或自己实现虚拟列表。

动态路由刷新白屏——F5刷新页面后白屏,控制台报路由找不到。原因是路由守卫beforeEach里动态添加路由是异步的,但next()已经放行了。解决方法是next({ ...to, replace: true })让路由重新走一遍,此时动态路由已经注册好了。

6.2 后端踩坑

Redis缓存与数据库一致性——经典问题了。先删缓存再更新库,高并发下可能读到旧数据再写回缓存;先更新库再删缓存,删除失败也会不一致。最终用的是先更新库再删缓存 + 删除失败重试,简单场景够用。更严谨的可以用Canal监听binlog,但个人项目没必要。

微服务循环依赖——环境监测服务和预警服务初期互相调用:监测服务上报数据后调预警服务检查规则,预警服务查询监测数据做历史对比。两个服务形成了循环依赖,部署时谁先启动都不行。解耦方案:引入消息事件,监测服务上报数据后发一个MQTT消息,预警服务订阅消息做检查,不再直接调用。

MySQL慢查询——环境数据聚合查询,数据量上来后一个5分钟粒度聚合要3秒。排查发现是索引没覆盖到查询条件。加上(device_id, create_time)联合索引后,查询降到50ms以内。另外,按天分表后单表数据量从千万级降到百万级,也是重要优化。

6.3 整体经验

  1. 先跑通MVP再拆微服务。我第一个版本是单体,功能跑通后再按模块拆。上来就微服务,连个能跑的东西都没有,拆着拆着就放弃了。
  2. 前后端接口文档先行。用Swagger/Apifox把接口定义好,前端可以mock数据并行开发,联调时返工率大幅降低。这个项目前期没做文档,联调阶段吃了很多亏。
  3. 个人项目也要写单元测试。不是要追求覆盖率,而是核心逻辑(预警规则匹配、协议解析)必须有测试用例,后期改代码心里有底。我吃过亏——改了预警逻辑觉得没问题,上线后告警全没了,回滚排查半天才找到bug。

七、项目展示与获取方式

7.1 核心页面展示

仪表盘数据大屏——ECharts多图表联动,5秒刷新,环境数据一目了然。

农田管理列表页——多条件筛选、分页、导出,CRUD标准操作都有了。

环境监测详情页——时序数据折线图 + 实时指标仪表盘,支持1分钟/5分钟/1小时粒度切换。

预警规则配置页——字段+运算符+阈值+持续时间,可视化配置预警规则,不用改代码。

7.2 在线演示

系统已部署到线上,可以直接体验:

  • 演示地址:联系我获取,373712413
  • 账号为只读权限,可浏览全部功能,不能修改数据

7.3 源码获取

有同学问源码的事,完整源码(前端Vue项目 + 后端6个微服务 + Smart Collector物联网模拟器 + 数据库脚本 + 部署文档)可以交流获取。闲鱼搜索 "智慧农业系统源码" 就能找到,也可以私信我聊技术细节。

做这个项目最大的收获不是代码本身,而是从0到1把一个完整系统跑通的体验——架构怎么选、服务怎么拆、物联网怎么接、上线怎么部署,这些是写CRUD学不到的。如果你也在做类似的全栈项目,欢迎交流,一起进步。

相关标签Java Vue.js Spring Boot 物联网 前端 后端 微服务 全栈 项目实战 智慧农业