☰
城市能源管理系统实战:Modbus采集与WebSocket实时可视化
2026/10/21 2:42:23 网站建设 项目流程

简介:基于Web的城市能源管理系统是一套面向能源监测、预警与设备管理的Web前端资源,适合Web开发学习者、系统设计人员用于课程设计或项目参考。压缩包共170个文件、大小约3MB,以HTML页面、CSS/LESS/SCSS样式、JS脚本为主要文件类型,辅以PNG/JPG图片、字体图标与数据库文件,覆盖登录界面、系统主界面、设备入库与检修、能耗预警等模块,并包含Bootstrap框架相关样式,便于快速搭建响应式管理后台。目前已有899人学习。整体规模紧凑、目录结构清晰,可直接在浏览器中打开体验,也可对照样式与脚本梳理响应式布局、数据展示、异常预警等实现思路,为城市能源管理类系统开发提供一套可复用的前端参考。

1. 城市能源管理系统:让每一度电的流向都看得见

城市里每栋楼的电表每分钟都在产生数据,但大多数物业管理者根本不知道这些数据意味着什么。这套基于Web的城市能源管理系统,就是为了把这个黑匣子打开——它通过Modbus协议对接智能电表,把电压、电流、功率、电量这些原始读数采集上来,清洗后写入MySQL,再通过WebSocket推送到前端看板。运维人员打开浏览器就能看到整栋楼的实时负荷曲线、分项用电占比和越限告警。适合需要做能耗监测、能源审计的开发者,项目本身就是一套完整可运行的前后端分离工程。

2. 系统架构与技术选型:前后端分离的能源数据中台

2.1 整体架构:从电表到浏览器分四层

这套系统的数据链路不复杂,但每一层都有明确的职责边界。最底层是采集层,通过Modbus TCP协议定时轮询智能电表,读取电压、电流、有功功率、电能等寄存器值;往上是服务层,Spring Boot负责把采集到的原始数据做清洗换算、落库、按需聚合,同时对外提供REST接口和WebSocket推送;再往上是数据层,MySQL存历史明细和告警记录,Redis扛实时缓存和分布式锁;最顶层是展示层,Vue 3搭建的页面通过ECharts把数据画成曲线、柱状图和饼图。

整个系统最核心的入口是实时看板页面,它同时挂了三个数据源:REST接口拉取昨天的能耗汇总,WebSocket接收当前各电表的实时功率,定时器驱动图表每分钟自动刷新。采集、计算、展示三者解耦,哪一层出问题都不会把整条链路拖死。比如采集层挂了,看板还能展示历史数据,只是实时曲线不再更新。

层级技术选型职责
展示层Vue 3 + Element Plus + ECharts看板、监控、告警、报表页面
服务层Spring Boot + MyBatis-PlusREST API、定时采集、告警引擎
数据层MySQL 8 + Redis历史数据存储、缓存、分布式锁
采集层modbus4j(Modbus TCP)轮询电表寄存器、解析原始数据

技术选型上没有追求新潮。后端用Spring Boot加MyBatis-Plus,是因为这个领域大量代码是CRUD加定时任务,Spring生态的调度、事务、连接池管理最省心。前端选Vue 3加Element Plus,组件库够全,ECharts的图表演示能力在能耗场景里几乎没有对手。MySQL负责持久化,Redis做缓存和分布式锁。采集协议这块,modbus4j是Java社区用得最多的Modbus客户端库,直接封装它省去自己写协议帧的麻烦。

2.2 工程目录:拿到源码后按这个地图找代码

整个工程是标准的前后端分离结构,根目录下分frontend和backend两个子工程,外加sql目录放初始化脚本。第一次打开项目的人,建议先看sql目录,里面除了建表语句,还放了一个模拟电表数据的脚本——没有真实电表的时候,用它生成假数据就能把整条链路跑通。

energy-management-system/ ├── sql/ │ ├── init_schema.sql # 建库建表:电表、点位、指标、告警、用户 │ └── mock_meter_data.sql # 模拟电表数据生成脚本 ├── backend/ │ ├── pom.xml │ └── src/main/ │ ├── java/com/city/energy/ │ │ ├── EnergyApplication.java # Spring Boot 启动类 │ │ ├── controller/ # REST 接口层 │ │ │ ├── MeterController.java │ │ │ ├── EnergyStatsController.java │ │ │ └── AlarmController.java │ │ ├── service/ # 业务逻辑层 │ │ │ ├── collect/MeterDataService.java │ │ │ ├── stats/EnergyStatsService.java │ │ │ └── alarm/AlarmService.java │ │ ├── mapper/ # MyBatis-Plus Mapper 接口 │ │ ├── collect/ # 采集核心 │ │ │ ├── ModbusCollector.java │ │ │ └── CollectTask.java # 定时采集任务 │ │ ├── websocket/ │ │ │ └── MonitorWebSocket.java # 实时数据推送 │ │ ├── entity/ # 实体类 │ │ └── config/ # 配置类 │ └── resources/ │ ├── application.yml │ └── mapper/ # MyBatis XML └── frontend/ ├── package.json └── src/ ├── api/ # axios 接口封装 ├── views/ │ ├── Dashboard.vue # 能耗看板 │ ├── Monitor.vue # 实时监控 │ ├── Alarm.vue # 告警中心 │ └── Report.vue # 报表页面 ├── components/ │ └── charts/ # ECharts 封装组件 └── router/index.ts

这个目录结构遵循了我在多个项目里验证过的惯例:controller只做参数接收和结果返回,service放业务逻辑,mapper只碰SQL。采集相关的代码单独放到collect包里,和业务代码隔离——它的依赖(Modbus协议栈、线程池、重试机制)和普通CRUD差别很大,混在一起后面会很难维护。

2.3 REST接口设计:看板和报表页面对应的API

后端接口按业务域划分,基本是一个控制器管一类资源的套路。能耗看板页面主要依赖三个接口:GET /api/stats/summary返回昨天的总用电量、峰值负荷、分项占比;GET /api/stats/trend?meterId=M001&granularity=hour返回指定电表按小时聚合的用电曲线;GET /api/alarm/list?status=ACTIVE拉取当前未恢复的告警列表。接口返回格式统一包装成{code, message, data},data里放业务数据,前端拦截器统一处理错误码,不用在每个页面里重复写异常分支。

做这套接口时有一个经验值得分享:所有和时间相关的查询参数,统一用时间戳(long型)而不是字符串。一开始图省事用了"2025-01-01 08:00:00"这种字符串传参,结果前端格式化、后端解析、时区转换三处各踩一遍坑,后来全部改成时间戳,整个世界清净了。

2.4 选型背后的取舍:为什么不用PLC直连方案

做能耗采集还有一种常见路线是PLC加SCADA系统,很多老牌楼宇用的是那套。这个项目没有选它,原因有三:一是成本,支持Modbus TCP的PLC网关不便宜,而且必须到现场部署;二是技术栈割裂,PLC的配置逻辑和Web系统是两套体系,调试升级都要专人维护;三是定位不同,这个项目的核心是数据分析和展示,不是设备控制。如果你要做远程分闸、自动调节空调这类控制功能,PLC几乎是必须的,但纯监测计量场景,软件协议栈直接对接电表是维护成本最低的方案。

数据库选型上,我见过不少团队一上来就上时序数据库,实际这个项目MySQL完全够用。原因很直接:数据量不大,一块电表一分钟一条记录,一年也就50万条,MySQL建好索引后毫秒级查询没问题;而且项目里大量是联表查询和按需聚合,关系模型处理起来更顺手。数据量涨到几百块电表、需要长时间序列分析的时候,再考虑迁到TDengine或者ClickHouse也不迟。

3. 核心模块拆解:一条能耗数据从电表到图表的完整链路

3.1 采集模块:Modbus协议下的寄存器读取与数据换算

先看数据进系统的第一站——采集层。Modbus TCP的本质很简单:客户端往电表的502端口发一个读保持寄存器的请求帧,电表返回一串字节,按点位表解析就能得到原始整数值。这段逻辑封装在ModbusCollector里:

@Component public class ModbusCollector { private static final int TIMEOUT = 3000; // 单次读取超时,单位毫秒 private static final int RETRY_TIMES = 2; // 失败后的重试次数 /** * 读取指定电表的全部点位数据 * * @param host 电表IP地址 * @param port Modbus端口,默认502 * @param slaveId 从站地址,单块电表一般填1 * @param points 点位配置列表,从数据库加载 */ public Map<String, Double> readMeterData(String host, int port, int slaveId, List<PointConfig> points) { Map<String, Double> result = new HashMap<>(); try { // 1. 创建Modbus TCP主站连接 ModbusFactory factory = new ModbusFactory(); IpParameters params = new IpParameters(); params.setHost(host); params.setPort(port); ModbusMaster master = factory.createMaster(params); master.setTimeout(TIMEOUT); master.setRetries(RETRY_TIMES); // 2. 按点位配置逐项读取保持寄存器 for (PointConfig point : points) { ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(slaveId, point.getAddress(), point.getLength()); ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) master.send(request); int rawValue = response.getRegisterValue(0).intValue(); // 3. 原始值乘以缩放系数,得到真实物理量 double realValue = rawValue * point.getScaleFactor(); result.put(point.getCode(), realValue); } master.disconnect(); } catch (Exception e) { log.error("读取电表失败: {}:{}", host, port, e); } return result; } }

代码里三个参数值得细看。TIMEOUT设成3秒,因为电表正常响应在毫秒级,超过3秒基本是网络或设备问题,没必要等;RETRY_TIMES设成2,容忍偶发网络抖动,但重试不能多,否则采集线程会被拖死。scaleFactor是实际项目中最容易翻车的点——很多电表的寄存器原始值是整数,要乘以0.1或0.01才是真实的电压、电流、功率值,这个系数必须配在点位表里,写死在代码里后期维护就是灾难。

点位表存在meter_point表里,每条记录包含电表ID、寄存器地址、寄存器长度、缩放系数、单位、采集开关。新增一块电表只需要插一条点位配置,不用动代码。这种点位配置化的设计是采集系统的底线要求,避免出现加一台设备就要发一次版本的尴尬。

3.2 实时推送:WebSocket把数据送到前端图表

采集到的最新读数由定时任务写入数据库,同时通过WebSocket推给在线页面。后端用Spring原生WebSocket,端点路径是/ws/monitor。注意,Spring Boot里要用@ServerEndpoint,需要先注册一个ServerEndpointExporter的Bean,这块放在config包的WebSocketConfig里:

@Component @ServerEndpoint("/ws/monitor") public class MonitorWebSocket { // 在线连接集合,CopyOnWriteArraySet保证并发安全 private static final CopyOnWriteArraySet<Session> SESSIONS = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { SESSIONS.add(session); } @OnClose public void onClose(Session session) { SESSIONS.remove(session); } /** 广播实时数据给所有在线客户端 */ public void broadcast(String message) { for (Session session : SESSIONS) { try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } catch (IOException e) { log.error("WebSocket推送失败,移除连接", e); SESSIONS.remove(session); } } } }

前端Vue组件在mounted钩子里建立连接,拿到消息后直接更新ECharts:

// Monitor.vue 中的关键逻辑 const ws = new WebSocket(`ws://${location.host}/ws/monitor`) ws.onmessage = (event) => { const data = JSON.parse(event.data) // data.payload 形如 { meterId: 'M001', power: 12.5, voltage: 220.3, ts: 1690000000 } const point = { time: formatTime(data.ts), power: data.power } // appendData 是 ECharts 5 的流式追加接口,比 setOption 省性能 chart.appendData({ seriesIndex: 0, data: [point] }) }

推送粒度是每块电表的最新读数,而不是批量历史数据。历史数据走REST接口按需查询,这样实时看板低延迟,又不会因为推送数据太多把浏览器内存打爆。appendData在每秒刷新一次的场景下非常合适,它只追加新数据点,不会触发全量重绘。

3.3 告警引擎:阈值判断如何不打扰运维人员

告警引擎的核心逻辑是阈值比较,但直接写有一个大坑:同一台电表连续越限一分钟一次,五分钟就能刷出五条一模一样的告警。这里用一个状态机来控制告警的产生和恢复:

public class AlarmEvaluator { /** * 评估是否产生告警 * * @param current 当前读数 * @param config 阈值配置 * @param lastState 上一次的告警状态 * @return 告警记录,null表示当前不需要生成记录 */ public AlarmRecord evaluate(Double current, ThresholdConfig config, AlarmState lastState) { if (current == null) return null; boolean isOver = current > config.getUpperLimit(); if (isOver && lastState != AlarmState.ALARMING) { // 从正常进入告警,生成一条ACTIVE告警 return AlarmRecord.builder() .meterId(config.getMeterId()) .type(config.getAlarmType()) .value(current) .status("ACTIVE") .build(); } else if (!isOver && lastState == AlarmState.ALARMING) { // 从告警恢复,原有的告警置为RECOVERED return AlarmRecord.builder() .meterId(config.getMeterId()) .status("RECOVERED") .build(); } return null; } }

关键在lastState这个入参。最朴素的写法是每次越限都插一条告警,但连续越限十分钟会产生10条重复记录,运维人员会被告警短信淹没。状态机保证只有正常到告警和告警到正常两个边界才生成记录,中间过程只更新状态不落库。告警记录生成后,通过WebSocket推给前端弹窗,同时在告警中心列表里滚动展示。

4. 本地跑通:从环境准备到前后端联调

4.1 环境清单:JDK、MySQL、Redis 和 Node.js 的版本要求

跑这个项目需要的东西不多,按下面的清单准备就行。后端要JDK 11以上,项目基于Spring Boot 2.x,JDK 8虽然也能编译但建议直接用11,省去后面遇到模块化问题的麻烦。MySQL用8.0,Redis要6以上版本,主要用到了setnx命令做分布式锁。前端需要Node.js 16以上,包管理用npm,没有特殊要求。

依赖版本要求用途
JDK11+编译运行Spring Boot后端
MySQL8.0存储电表点位、能耗数据、告警记录
Redis6+缓存实时数据、分布式锁
Node.js16+运行Vue 3前端工程

4.2 数据库初始化:先建表再灌模拟数据

源码的sql目录下有两个脚本。第一个init_schema.sql负责建库建表,涉及电表表、点位表、指标表、告警记录表、用户表,一共七张表;第二个mock_meter_data.sql插入5块模拟电表以及对应的点位配置,每块电表12个点位,覆盖三相电压、三相电流、有功功率、频率、电能这些常见指标。

mysql -uroot -p < sql/init_schema.sql mysql -uroot -p < sql/mock_meter_data.sql

执行完后可以用show tables;确认七张表都建出来了。如果MySQL提示编码问题,检查一下数据库默认字符集,建议在建库时指定utf8mb4,避免中文乱码。

提示:如果本机没有装Redis,macOS可以用brew install redis,Windows可以用Docker跑一个redis:7容器,本地开发不需要专门配哨兵集群。

4.3 启动后端:配置文件里的三个必改项

后端配置文件在backend/src/main/resources/application.yml,启动前需要改三个地方:数据源地址、Redis连接、采集开关。

spring: datasource: url: jdbc:mysql://localhost:3306/energy?serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379 collect: enabled: false # true开启真实Modbus采集,false走模拟数据生成器

collect.enabled这个开关值得多说一句。设为false时,系统会启动一个模拟数据生成器,每10秒生成一组随机负荷数据,通过WebSocket推送到前端看板。没有真实电表的时候,这就是跑通全流程的关键——曲线会自己动起来,告警阈值也能触发,虽然没有真实数据那么规整,但足以验证系统功能。

cd backend mvn spring-boot:run

后端默认监听8080端口,启动日志出现"Started EnergyApplication"就说明起来了。验证一下:浏览器访问http://localhost:8080/api/stats/summary,能返回JSON数据包就正常。

4.4 启动前端:Vite代理解决了跨域问题

cd frontend npm install npm run dev

前端开发服务器默认跑在5173端口,vite.config.ts里配了代理,把/api和/ws开头的请求都转发到8080,所以浏览器访问http://localhost:5173就能直接看到登录页,不需要手动处理跨域。系统内置了演示账号admin/admin123,登录后默认进入能耗看板。

一个需要注意的细节:如果后端和前端不在同一台机器,要把vite配置里的代理目标从localhost:8080改成后端机器的IP。同理,WebSocket的ws://${location.host}是拿当前页面的host拼的,反向代理部署时保证/ws路径也被转发就行。

4.5 验证链路:打开浏览器确认三个数据源都在工作

登录进入看板后,别急着到处点,先做三个确认。第一,左上角的昨日用电量卡片有数字,说明REST接口通了;第二,中间实时负荷曲线的图表每10秒跳动一次,说明WebSocket推送正常;第三,右上角的电表下拉框能切换不同电表,切换后下面的分项占比饼图跟着变,说明REST查询的meterId参数生效。

如果曲线不动,优先看浏览器控制台有没有WebSocket报错。如果是Connection closed,多半是后端没起来或者代理没配好;如果是401鉴权失败,检查登录token是否过期了,重新登录一次就行。

5. 避坑指南:部署和调试中容易踩的五个坑

先说结论:这个项目代码本身不难懂,难的是部署和调试过程中那些看上去哪里都没错但就是不对的问题。下面五条都是实际跑项目时踩过的,按现象、原因、解决三步记录。

5.1 MySQL时区导致能耗统计整体错位

现象:按小时聚合的用电量曲线整体偏移8小时,白天的负荷跑到了凌晨。原因:JDBC连接串里没配时区参数,Java默认的Asia/Shanghai和MySQL实例的会话时区不一致,group by hour聚合时把时间戳转换错了。解决:JDBC URL里显式加serverTimezone=Asia/Shanghai,同时MySQL侧执行set global time_zone = '+08:00'。这个坑在云数据库上尤其常见,因为很多云厂商默认时区是UTC。

5.2 Modbus读取超时导致采集线程堆积

现象:系统跑了一天,采集线程数从5涨到200,数据库连接池被打满,接口响应变慢。原因:某块电表网络抖动,Modbus读取请求一直卡着不返回,定时任务下一轮又新建线程去读,导致线程无限堆积。只设置了超时时间,但没控制线程池大小。解决:给ModbusMaster设置setTimeout(3000)和setRetries(2),同时把采集线程池配成有界队列加CallerRunsPolicy拒绝策略——线程池满了就丢弃本次采集,等下一轮,绝不让任务堆积。

5.3 WebSocket断线后图表悄悄停止更新

现象:实时曲线看了一上午,突然不再刷新,页面不报错,控制台也没有红色报错,只有手动刷新页面才能恢复。原因:WebSocket连接被中间设备断开,比如Nginx默认的proxy_read_timeout是60秒,超过这个时间没有数据交互就断开连接。前端没有实现重连,断开后只能手动刷新。解决:前端加心跳机制,每30秒发一个ping帧,收到pong就说明连接还活着;onclose回调里做3秒延时重连。Nginx侧把proxy_read_timeout调到180秒。这两步缺一不可,一个保活一个重连。

5.4 定时任务重复执行导致数据翻倍

现象:同一时间点的采集数据出现了两条,能耗统计翻了一倍。原因:测试环境用两个后端实例做负载均衡,定时采集任务在每个实例上都跑了一遍,没有加分布式锁。解决:用Redis的setnx命令实现分布式锁,任务开始时抢占锁,拿到锁才执行采集,执行完释放锁,锁超时时间设为采集周期的两倍。这样即使有多个实例,同一时刻只有一个在采集。

// 伪代码示意:采集任务入口加分布式锁 Boolean locked = redisTemplate.opsForValue().setIfAbsent("collect:lock", "1", Duration.ofMinutes(2)); if (Boolean.TRUE.equals(locked)) { try { collectService.runOnce(); } finally { redisTemplate.delete("collect:lock"); } }

5.5 ECharts渲染一个月数据掉帧

现象:报表页面加载30天历史曲线,页面卡顿2到3秒,拖动图表时明显掉帧。原因:默认查询粒度是一分钟一个点,一个月就是43200个点,ECharts线图在几万数据点上渲染开销很大。解决:后端做采样降级——查询跨度超过7天按小时聚合,超过30天按天聚合。前端再用ECharts的sampling: 'lttb'参数对数据抽稀,保留曲线形状的同时大幅减少渲染点数。粒度降级和前端抽稀两个方向同时做,效果最好。

6. 二次开发实战:给系统加一个用电预测模块

6.1 改造思路与算法选择

选一个轻量的改造点:给看板加未来24小时用电预测。算法不引入机器学习,用过去7天同时段的均值做预测,先跑通展示链路。选这个点还有一个原因:预测功能改动范围被限制在service层和看板组件两个文件里,即使预测结果不理想,回滚成本也很低。

6.2 核心代码与验证

@Service public class ConsumptionPredictService { public List<PredictedPoint> predictNext24Hours(String meterId) { LocalDateTime now = LocalDateTime.now(); List<PredictedPoint> result = new ArrayList<>(); for (int hour = 1; hour <= 24; hour++) { LocalDateTime target = now.plusHours(hour); // 取过去7天中与target同一小时的平均用电量 Double avg = statsMapper.selectAvgConsumption( meterId, target.minusDays(7), target, target.getHour()); result.add(new PredictedPoint(target, avg == null ? 0 : avg)); } return result; } }

算法逻辑就一句话:预测值等于过去7天同一小时的平均用电量。前端接入也简单,Dashboard.vue里加一条虚线series,lineStyle: { type: 'dashed' },配合symbol: 'none',预测曲线和实际曲线一眼就能区分。

上线前我连续跑了三天做对比验证,峰值时段预测偏差在20%以内。这次改动让我养成了一个习惯:每次往看板上加新指标,先确认数据粒度、刷新频率和异常值处理策略,再想图表怎么画——数据可靠是第一步,图表好看是最后一步。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询