简介:这是一份面向Java初学者与毕业设计学生的环境监测系统完整源码,涵盖空气、温度、湿度等多类环境数据的采集、展示与管理流程。项目采用Spring、Servlet/JSP与DAO分层架构,覆盖业务处理、数据持久化与请求响应链路,适合课程设计、毕业设计或Java Web入门练习。压缩包共46个文件,核心为35个Java源文件,按controller、service、dao、daoimpl分包,另含数据库设计脚本、数据库更新脚本、监控管理方案及README说明,整体约29KB。目前已有701人学习下载。通过阅读源码可快速理解Java Web项目从实体建模、持久层操作到控制器响应的完整链路,掌握环境监测相关模块的接口写法与数据流转方式,也能参考其中用户登录与权限管理、数据实时展示等模块进行二次开发。
1. 环境监测软件选Java,本质是选一条能落地的数据链路
一套环境监测系统真正难的不是画曲线,而是处理“数据从哪来、怎么存、怎么在页面上接近实时地出现”这条链路。以这套Java实现的环境监测系统源码为例,核心流程是:设备端按周期上报温湿度、PM2.5、VOC等指标,服务端先做校验再写入MySQL,前端页面通过WebSocket订阅增量数据,独立的报警模块按预设规则判断是否触发告警。对正在做环境监测软件相关项目的Java开发者来说,这套源码的价值在于把采集任务、数据模型、消息推送、规则判断拆成独立的模块,既容易讲清楚设计思路,也方便后续替换成生产级组件。下面把这条链路逐段拆开。
2. 表结构与实体映射:环境监测系统的数据底座
2.1 设备、指标、规则、日志四张表的职责边界
如果按业务的一时冲动建表,很容易陷入字段冗余。覆盖主流程只需要四张核心表,具体的表结构如下。
| 表名 | 作用 | 关键字段 | 索引建议 |
|---|---|---|---|
| device | 采集终端信息 | id、device_code、name、location、status | device_code 唯一索引 |
| env_record | 环境指标明细 | id、device_code、temperature、humidity、pm25、voc、collect_time | (device_code, collect_time) 联合索引 |
| alarm_rule | 设备报警规则 | id、device_code、metric、operator、threshold、notify_type | device_code 索引 |
| alarm_log | 报警触发记录 | id、rule_id、device_code、metric、real_value、alarm_time、handle_status | handle_status 索引 |
我一般会强调一个容易被忽略的设计点:在env_record里存逻辑外键device_code,而不是device_id。原因有两个,一是设备编号在排查问题时可以直接从日志里看出来,不用再做一次ID映射;二是InnoDB的外键约束在写入量大时会放大锁开销,尤其是采集周期短、批量插入频繁的场景。既然本项目选择用Java直连MySQL,就没必要增加这道约束。
env_record表的联合索引(device_code, collect_time)是整个查询性能的关键。页面上最常见的请求是“查某个设备某段时间的温度曲线”,没有这个联合索引时MySQL会走全表扫描,数据量超过一万条就已经有可感知的延迟。反过来,如果查询条件只有collect_time而没有device_code,这个联合索引也无效,所以接口层必须保证两个条件同时传入。
2.2 实体类与MyBatis批量写入
Java侧采用标准的Entity、Mapper、Service三层结构。环境指标实体类如下。
public class EnvRecord { private Long id; private String deviceCode; private BigDecimal temperature; private BigDecimal humidity; private BigDecimal pm25; private BigDecimal voc; private LocalDateTime collectTime; public static EnvRecord of(String deviceCode, BigDecimal temperature, BigDecimal humidity, BigDecimal pm25, BigDecimal voc, LocalDateTime collectTime) { EnvRecord record = new EnvRecord(); record.deviceCode = deviceCode; record.temperature = temperature; record.humidity = humidity; record.pm25 = pm25; record.voc = voc; record.collectTime = collectTime; return record; } // getter、setter 省略 }这里保留BigDecimal而不是double,是有实际考量的。温湿度在后续计算体感温度、空气质量指数时需要做乘除运算,double的累积误差会以小数点后第三位的形式暴露在报表里。MySQL侧对应字段用DECIMAL(5,2)、DECIMAL(5,2)、DECIMAL(7,1)、DECIMAL(6,2),分别对应温度、湿度、PM2.5和VOC。
Mapper层逐条insert会有明显的网络往返成本,单次采集周期如果有几百个设备,这个开销是叠加放大的。推荐用批量插入:
<insert id="batchInsert" parameterType="list"> INSERT INTO env_record (device_code, temperature, humidity, pm25, voc, collect_time) VALUES <foreach collection="list" item="r" separator=","> (#{r.deviceCode}, #{r.temperature}, #{r.humidity}, #{r.pm25}, #{r.voc}, #{r.collectTime}) </foreach> </insert>MyBatis的foreach批量拼接是最常见的做法。需要注意MySQL的max_allowed_packet默认是64MB,单次batch超过上千条记录时,由于每条记录都带字符串和时间戳,预估包大小后按500条一批拆分更稳妥,否则会出现类似“PacketTooBigException”的连接中断。采集频率越高,这个参数越值得确认,不能只在小数据量下跑通就认为没问题。
2.3 Service层的事务边界
Service层建议拆成三个入口:采集入口、查询入口、报警入口。采集入口只负责写入,查询入口只负责读取,报警入口在采集完成后异步触发。这样无论环境监测软件的需求怎么演进,核心读写路径都不会互相干扰。
事务边界是一个容易踩坑的地方。env_record写入不应该和alarm_rule查询放在同一个REQUIRED事务里,报警规则属于读多写少的配置数据,每轮采集都重新开启大事务会放大锁等待时间。把报警判断放到事务提交之后执行,既保证环境数据先落库,又不会让规则查询拖累写入性能。
3. 定时采集、数据校验与报警:环境监测服务端的核心链路
3.1 定时任务的防重入与失败补偿
Java环境监测系统的采集任务通常用Spring的@Scheduled驱动。下面的代码是一个带防重入保护的采集骨架。
@Component public class CollectTask { private final DeviceMapper deviceMapper; private final EnvRecordService recordService; private final AtomicBoolean running = new AtomicBoolean(false); @Scheduled(cron = "0 */5 * * * ?") public void collect() { if (!running.compareAndSet(false, true)) { return; } try { List<Device> devices = deviceMapper.selectOnlineDevices(); for (Device device : devices) { // 实际工程中通过 Modbus、MQTT 或 HTTP 上报获取指标 Map<String, BigDecimal> metrics = readMetrics(device.getDeviceCode()); recordService.save(device.getDeviceCode(), metrics); } } finally { running.set(false); } } }防重入的核心是AtomicBoolean的compareAndSet,它能保证同一时刻只有一个采集线程在执行。Spring Boot默认调度线程池大小是1,但如果手动调整过线程池参数,或者未来改成多实例部署,任务就可能重叠执行。用running标志可以避免双写和重复报警。
失败补偿的策略要分场景。设备离线导致采集异常时,我会在readMetrics里把失败原因写入device_status_log表,下一次定时任务再尝试连接;连续三次失败就把device表的状态改为offline,前端显示“设备掉线”而不是继续画一条假曲线。采集间隔建议用cron表达式控制,它能精确到整点执行,而fixedRate是任务结束后才开始计时,任务耗时一旦不稳定,采集节奏就会漂移。
3.2 数据校验与异常指标标记
从设备侧拿到的数据不能直接入库,要先做范围校验。
public class MetricValidator { private static final Map<String, double[]> RANGES = new HashMap<>() {{ put("temperature", new double[]{-40, 60}); put("humidity", new double[]{0, 100}); put("pm25", new double[]{0, 2000}); put("voc", new double[]{0, 60000}); }}; public static boolean check(String metric, BigDecimal value) { double[] range = RANGES.get(metric); if (range == null) { return false; } return value.doubleValue() >= range[0] && value.doubleValue() <= range[1]; } }参数说明:temperature范围按户外设备定义,室内场景可以收窄到0~50;pm25上限取2000是考虑到沙尘等极端天气时实际值可能超过999。每个指标的范围要和传感器量程匹配,这是环境监测软件开发里经常被忽略的环节。校验失败的数据写入alarm_log并标记quality_flag=0,而不是静默丢弃。这个设计后续在评估数据质量时很有价值,因为它保留了原始异常记录,而不只是丢了一行数据。
3.3 规则判断与通知解耦
报警模块不推荐把规则硬编码在if-else里。用alarm_rule表存储规则,判断逻辑只依赖操作符和阈值即可。
public boolean judge(String metric, BigDecimal value, AlarmRule rule) { int cmp = value.compareTo(rule.getThreshold()); return switch (rule.getOperator()) { case ">" -> cmp > 0; case ">=" -> cmp >= 0; case "<" -> cmp < 0; case "<=" -> cmp <= 0; case "EQ" -> cmp == 0; default -> false; }; }关键点是compareTo而不是equals。BigDecimal的compareTo忽略精度差异,2.0和2.00在它看来相等,equals反而会判不等。规则判断如果直接用doubleValue做比较,在阈值是小数的场景容易出错。报警通知建议用Spring事件机制解耦,采集完成后发布AlarmCheckEvent,监听器里做规则判断,命中后异步发送通知。这样采集主链路不会被通知耗时阻塞。
| 通知方式 | 平均耗时 | 可靠性 | 适用场景 |
|---|---|---|---|
| 站内信(入库) | 小于10ms | 高 | 默认必选 |
| 邮件 | 200ms~2s | 中 | 值班通知 |
| 短信/Webhook | 300ms~5s | 依赖第三方 | 紧急告警 |
4. 实时监测面板:WebSocket推送与曲线可视化
4.1 WebSocket推送增量数据
环境监测软件最影响体验的地方是数据刷新方式。传统方案是前端轮询接口,每秒请求一次,页面看着还行,但服务端会积累大量无效请求。更合适的方式是建立WebSocket长连接,服务端在数据落库后主动推送。
@ServerEndpoint("/ws/env/{deviceCode}") public class EnvWebSocket { private static final Map<String, Session> SESSIONS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("deviceCode") String deviceCode) { session.getUserProperties().put("deviceCode", deviceCode); SESSIONS.put(session.getId(), session); } @OnMessage public void onMessage(String message, Session session) { if ("ping".equals(message)) { session.getAsyncRemote().sendText("pong"); } } public static void sendToDevice(String deviceCode, String payload) { SESSIONS.values().forEach(s -> { if (deviceCode.equals(s.getUserProperties().get("deviceCode"))) { s.getAsyncRemote().sendText(payload); } }); } }这段代码有两个需要留意的点。第一,用session.getId()做key可以避免同一个浏览器开多个标签页时相互误关;第二,sendToDevice遍历所有会话,在设备数几十到上百的监测场景是够用的,设备量到千级后就应该改成deviceCode映射到Set<Session>的结构。
推送时机要与采集任务错开。采集任务在整点前后执行,推送则在数据完成校验和入库之后触发。这里用Spring的ApplicationEvent做解耦比较合适,采集完成后发布EnvRecordInsertedEvent,WebSocket监听器收到事件后把最新的指标推给对应设备的订阅端点。这样采集线程和推送线程各自独立,互不阻塞。
4.2 曲线查询与聚合SQL
前端曲线图请求的数据通常不是原始明细,而是聚合后的采样点。接口用一个参数控制聚合粒度。
@GetMapping("/api/env/trend") public List<EnvTrendVO> trend(String deviceCode, @RequestParam String start, @RequestParam String end, @RequestParam(defaultValue = "raw") String interval) { if ("hour".equals(interval)) { return recordService.selectHourly(deviceCode, start, end); } return recordService.selectRaw(deviceCode, start, end); }interval的作用是控制前端拿到的数据点数。查询一天的数据时按小时聚合,查询一周的数据时按天聚合,否则前端要接收上千个原始点,ECharts渲染时会明显卡顿。聚合SQL与明细查询走的是不同路径:
SELECT DATE_FORMAT(collect_time, '%Y-%m-%d %H:00') AS bucket, ROUND(AVG(temperature), 2) AS avg_temp, ROUND(AVG(humidity), 2) AS avg_hum, MAX(pm25) AS max_pm25 FROM env_record WHERE device_code = #{deviceCode} AND collect_time BETWEEN #{start} AND #{end} GROUP BY bucket ORDER BY bucket;注意聚合口径不能一刀切。温度可以算平均值,PM2.5应该看日均值和峰值,VOC则需要看超标频次,这些指标的业务含义不同。聚合周期和指标维度要一起传给前端,前端按照指标类型渲染不同形式的图表,才能分辨哪些是持续性问题、哪些是瞬时超标。
4.3 ECharts页面集成
前端用原生HTML加ECharts足矣,环境监测的曲线图没有用到特别复杂的图表类型。典型做法是页面加载后请求一次接口,然后通过WebSocket增量更新最后几个点。
const chart = echarts.init(document.getElementById('trend')); fetch('/api/env/trend?deviceCode=DEV-001&start=2025-06-01 00:00&end=2025-06-01 23:59&interval=hour') .then(res => res.json()) .then(data => { chart.setOption({ xAxis: { type: 'time' }, yAxis: { type: 'value', name: '温度(℃)' }, toolbox: { feature: { dataZoom: {}, saveAsImage: {} } }, series: [{ type: 'line', data: data.map(item => [item.bucket, item.avgTemp]), smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 } }] }); });接口字段与图表的映射放在VO层完成,Controller只返回VO对象。VO对应EnvTrendVO和AlertVO两个结构化类型,这样实体的字段变更不会直接波及前端契约。图表下方的指标卡片直接复用同一条接口数据,展示当前值、超标时长和设备状态,卡片信息与曲线数据保持一致,避免出现前端各组件改一处漏一处的情况。
5. 部署、性能优化与常见坑位
5.1 设备掉线检测的实现技巧
设备是否在线不能只看最后一条记录的时间。环境监测系统里传感器偶发断连是常态,断电、网线松动、网关重启都会造成采集中断。我的做法是单独维护device.last_heartbeat_time字段,每一条采集记录写入时同步更新心跳时间,同时用一个清理任务把心跳超过三倍采集周期的设备标记为离线。
UPDATE device SET status = 'offline' WHERE status = 'online' AND last_heartbeat_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE);这段SQL里的15分钟来自采集周期5分钟乘以3。这个倍率不是拍脑袋定的,而是考虑到网络抖动、设备重启、服务端GC停顿等情况,2倍周期可能误判,3倍是一个相对合理的值。执行频率设为一分钟一次即可,带来的开销可以忽略。状态变更后通过WebSocket推送一条设备离线事件,前端把这个设备的所有指标卡片置灰,避免用户看到一条断掉的时间线还以为是服务端出了问题。
5.2 Linux部署环境检查清单
Java环境监测系统部署到服务器时,最先要确认的是JDK版本和编码格式。源码在Windows本地开发,代码用了中文注释,Linux服务器上如果不加JVM参数,日志和数据库读写会出现乱码。在启动脚本中显式指定UTF-8:
nohup java -jar env-monitor.jar \ -Dfile.encoding=UTF-8 \ -Xms512m -Xmx1024m \ -Dspring.datasource.url="jdbc:mysql://localhost:3306/env_monitor?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true" \ --spring.profiles.active=prod > app.log 2>&1 &注意rewriteBatchedStatements=true这个参数。JDBC如果没有开启它,rewriteBatchedStatement不会把多个insert改写为多值insert,MyBatis的批量插入会退化成逐条执行。很多环境监测软件源码在本地跑没问题,一上服务器就慢,原因往往就在数据库连接串少这个参数。排查时同时检查MySQL的max_allowed_packet,确保两者匹配。
5.3 排查报警延迟的路径
报警延迟出现时,不要先怀疑规则引擎,按“采集任务是否准时执行 -> 数据是否落库 -> 事件是否发布 -> 监听器是否消费”的链路逐段排查。第一步看定时任务日志里有没有超时记录,第二步确认env_record的collect_time和当前时间是否吻合,第三步看事件发布日志与消费日志的时间差。90%以上延迟问题出在采集任务被前一批数据拖住,而不是推送或判断逻辑有问题。
# 查看最近一次采集任务的时间与当前时间差 mysql -e "SELECT MAX(collect_time), NOW() FROM env_monitor.env_record;"这条SQL几毫秒就能确认数据链路是否在流转。环境监测系统的实时性不是靠某个单点组件保证的,而是采集、入库、推送、展示四个环节的时间差总和,任何一个环节出现漂移,前端都会表现为数据陈旧。把这个排查路径固化下来,比反复看代码更高效。
本文还有配套的精品资源,点击获取