ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Socket路灯控制系统:C/S架构下的TCP长连接与协议设计

Java Socket路灯控制系统:C/S架构下的TCP长连接与协议设计 简介一套基于C/S架构的Java Socket通信路灯控制模拟项目面向正在学习网络编程、需要完成课程设计或大作业的高校学生也适合作为Socket通信入门的练习案例。项目分为服务端与客户端两个模块客户端采用Swing构建图形界面提供远程控制路灯开关的按钮并展示服务端返回的环境数据服务端通过ServerSocket监听连接接收指令后执行开关动作同时使用随机数模拟温湿度采集并用ObjectInputStream/ObjectOutputStream完成对象序列化传输覆盖Socket通信与Java GUI开发的核心流程。压缩包共36个文件主要包含13个Java源码、3个prefs/properties配置类文件、项目工程文件与多张界面截图整体仅2.16MB轻量易下载导入IDE即可运行。目前已有309人学习相关实现思路和代码结构对同类远程控制项目具有参考价值。资源附有README说明与LampsServer、Lamps两个可运行模块目录清晰便于对照代码理解通信握手、指令解析与界面刷新逻辑也可在现有基础上接入真实温湿度传感器或增加安全认证机制方便二次扩展。1. 用Java Socket做路灯控制系统C/S架构下的开关控制与温湿度采集先说一个反直觉的结论这个项目真正让你翻车的不是并发量、不是线程池而是最基础的TCP半包读取。问题描述很清晰——基于C/S架构的socket通信用Java模拟一套路灯控制系统既要远程控制路灯开关还要采集周围环境信息比如温湿度。整套系统的核心是一条稳定的长连接服务端要把开关命令下行到每一盏路灯路灯把温湿度和运行状态上行回服务端。这看起来像一个课设Demo但它覆盖了Java网络编程的常用面试点长连接怎么保活、粘包拆包怎么处理、连接异常怎么恢复。适合正在学Java网络编程的学生也适合想脱离单纯HTTP请求、往设备通信方向走的工程师。2. 协议先行C/S架构与Socket通信的路灯命令设计2.1 路灯控制场景为什么选C/S架构而不是B/S路灯控制这种场景命令下行要求低延迟状态上行要求服务端能主动感知。用B/S那套HTTP请求轮询不是不行但路灯数量一多查询轮询的报文大部分是无意义的空转。C/S架构让控制端和路灯之间维持一条TCP长连接服务端随时可以主动下发开灯、关灯、查询状态命令路灯也能按自己的节奏上报温湿度和电压。这套模型在车载终端、充电桩这类设备上都很常见不是过时的技术选型。我用一张对比表给你看清楚三者的边界选型下行实时性服务端主动推送实现成本适合路数C/STCP长连接高毫秒级天然支持中需要处理粘包与心跳几十到几百路B/SHTTP轮询低取决于轮询周期不支持只能等请求低上手快适合演示路数多会空转MQTT消息代理高支持通过主题发布高还要部署Broker几千路以上所以这个标题定为C/S架构是合理的几十个路灯场景里TCP长连接在实时性和代码可读性之间最平衡。真跑到几千路再考虑换Netty或者引入MQTT模拟控制和课设阶段原生Socket反而最直观。2.2 命令帧格式帧头、长度、命令字与校验Socket通信传的是字节流不是对象流。所以第一个要定死的是每一条命令的帧格式。我一般会这样设计一个最小可用的帧2字节帧头、2字节数据长度、1字节命令字、4字节设备ID、N字节数据区、1字节累加和校验。帧头固定成0x7E 0x7E用来在字节流里找起点数据长度只包含命令字、设备ID和数据区不包含帧头和校验位。public class LampProtocol { public static final byte FRAME_HEAD_1 0x7E; public static final byte FRAME_HEAD_2 0x7E; public static final byte CMD_SWITCH_ON 0x01; public static final byte CMD_SWITCH_OFF 0x02; public static final byte CMD_QUERY_STATUS 0x03; public static final byte CMD_REPORT_DATA 0x04; public static final byte CMD_HEARTBEAT 0x05; public static byte[] encode(byte cmd, short devId, byte[] body) { // 长度字段 命令字1字节 设备ID 2字节 数据区长度 int len 1 2 (body null ? 0 : body.length); byte[] frame new byte[4 len 1]; // 帧头2 长度2 len 校验1 frame[0] FRAME_HEAD_1; frame[1] FRAME_HEAD_2; frame[2] (byte) ((len 8) 0xFF); frame[3] (byte) (len 0xFF); frame[4] cmd; frame[5] (byte) ((devId 8) 0xFF); frame[6] (byte) (devId 0xFF); int idx 7; if (body ! null) { System.arraycopy(body, 0, frame, idx, body.length); idx body.length; } byte checksum 0; // 校验范围长度字段到数据区结尾 for (int i 2; i idx; i) { checksum ^ frame[i]; } frame[idx] checksum; return frame; } }代码逻辑不复杂先算出长度字段然后按顺序铺帧最后从长度字段开始到数据区结尾做一次异或累加得到校验字节。这里两个参数你需要留意长度字段用的是大端序也就是高位在前Java里直接用移位和与操作就能写不用ByteBuffer校验用异或累加优点是计算快、代码短用于路灯这种误码率不高的模拟链路完全够没必要一上来就上CRC32。2.3 温湿度数据段如何编码整数传输和单位换算温湿度在Java里最自然的表达是float但Socket传输浮点数要考虑字节序、NaN和精度问题调试起来和看天书一样。常见做法是传输放大后的整数温度乘以10、湿度乘以10接收端再除以10还原。比如温度23.4摄氏度就传short值234湿度65.8%就传short值658。一个温湿度上报帧的数据区设计成2字节温度、2字节湿度、2字节电压单位0.1V一共6字节。public static byte[] encodeEnvReport(float tempC, float humidity, float voltage) { short tempRaw (short) (tempC * 10); short humRaw (short) (humidity * 10); short voltRaw (short) (voltage * 10); return new byte[]{ (byte) ((tempRaw 8) 0xFF), (byte) (tempRaw 0xFF), (byte) ((humRaw 8) 0xFF), (byte) (humRaw 0xFF), (byte) ((voltRaw 8) 0xFF), (byte) (voltRaw 0xFF) }; }这里的核心参数是放大倍数。放大10倍可以覆盖-328.9到327.6摄氏度对路灯环境完全够用如果你要精确到0.01度就放大100倍但要注意short溢出。温湿度范围也要在接收端限幅比如温度在-40到80之间湿度在0到100之间。超出这个区间直接丢弃这一帧否则一个传感器异常会把整条链路的日志全部污染。按这个思路做协议还有一个隐性问题必须说为什么不用Java自带的ObjectOutputStream传对象序列化流和具体类绑定换语言、升级类结构都不友好而且序列化数据里带了很多类元信息对温湿度这种小报文来说浪费带宽。自己定义紧凑的帧格式是这一章花大力气定协议的根本原因。协议定得越薄两端的代码越好写。3. 服务端落地Socket监听、命令解析与路灯状态机3.1 服务端线程模型与最小启动骨架服务端第一版只要做对一件事主线程负责accept业务处理丢给线程池不能让一个连接拖死整个服务端。用原生ServerSocket写代码量不大逻辑也清楚。源码分包上我习惯按职责切protocol放帧编解码server放入口和连接处理device放路灯对象client放模拟端。这样后面加电容、电流监测不用动主流程。目录/文件职责protocol/LampProtocol.java帧格式常量、编解码方法server/LampServer.java服务端入口、线程池、会话管理server/SocketHandler.java连接读写、命令解析server/Lamp.java路灯状态机client/LampClient.java模拟路灯客户端入口client/SensorSimulator.java温湿度和电压模拟import java.net.*; import java.util.concurrent.*; public class LampServer { private static final int PORT 9100; private final ExecutorService workerPool new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100)); private final ConcurrentHashMapShort, Lamp lamps new ConcurrentHashMap(); public void start() throws IOException { try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(路灯服务端启动监听端口 PORT); while (true) { Socket socket serverSocket.accept(); socket.setSoTimeout(30_000); workerPool.execute(new SocketHandler(socket, this)); } } } public void switchLight(short devId, boolean on) { Lamp lamp lamps.computeIfAbsent(devId, k - new Lamp(devId)); lamp.applyCommand(on); System.out.println(设备 devId 收到开关命令 (on ? ON : OFF)); } public static void main(String[] args) throws IOException { new LampServer().start(); } }注意几个默认参数核心线程8、最大32、队列100路灯模拟场景几十个连接富余。setSoTimeout(30000)是血泪经验不设读超时一个客户端断网不彻底线程就永远阻塞在read上连接数只增不减。3.2 命令解析与开关控制写在SocketHandler里每个客户端连接进来SocketHandler负责循环读帧、解析命令、执行开关动作。解析的核心是根据长度字段读取完整一帧避免只读到半个包就开工。import java.io.*; import java.net.*; public class SocketHandler implements Runnable { private final Socket socket; private final LampServer server; public SocketHandler(Socket socket, LampServer server) { this.socket socket; this.server server; } Override public void run() { try (DataInputStream in new DataInputStream(socket.getInputStream()); OutputStream out socket.getOutputStream()) { byte[] head new byte[2]; while (true) { in.readFully(head); if (head[0] ! 0x7E || head[1] ! 0x7E) { continue; // 没找到帧头继续滑动 } int len in.readUnsignedShort(); byte cmd in.readByte(); short devId in.readShort(); byte[] body new byte[len - 3]; in.readFully(body); byte check in.readByte(); if (!validCheck(len, cmd, devId, body, check)) { continue; // 校验失败丢弃本帧 } if (cmd LampProtocol.CMD_SWITCH_ON) { server.switchLight(devId, true); } else if (cmd LampProtocol.CMD_SWITCH_OFF) { server.switchLight(devId, false); } } } catch (IOException e) { // 客户端断开或读超时都会走到这里统一交给上层清理 System.out.println(连接关闭 socket.getRemoteSocketAddress()); } } private boolean validCheck(int len, byte cmd, short devId, byte[] body, byte check) { byte sum 0; sum ^ (byte) ((len 8) 0xFF); sum ^ (byte) (len 0xFF); sum ^ cmd; sum ^ (byte) ((devId 8) 0xFF); sum ^ (byte) (devId 0xFF); for (byte b : body) { sum ^ b; } return sum check; } }readUnsignedShort把两个字节按大端读成整数和编码端对应。长度字段是“命令字设备ID数据区”的总长所以body长度要减掉3。校验失败不抛异常只丢弃这一帧继续读后面的数据这样一条脏数据不会弄死整条连接。注意这里用的是DataInputStream而不是BufferedReader二进制协议必须用字节流读按行读文本会把0x7E这类字节搞坏。3.3 路灯状态机与心跳掉线判定路灯不能只有开和关两个布尔值至少要有三种状态ON、OFF、FAULT。FAULT表示灯具异常这样一来查询命令才有意义也方便你在日志里区分是路灯坏了还是网络断了。public class Lamp { private final short devId; private volatile boolean on; private volatile boolean fault; public Lamp(short devId) { this.devId devId; } public synchronized void applyCommand(boolean target) { this.on target; this.fault false; // 手动控制后清除故障标志 } public synchronized void markFault() { this.fault true; this.on false; } public synchronized String status() { return fault ? FAULT : (on ? ON : OFF); } }心跳机制我建议让客户端每30秒发一帧心跳服务端在会话里记录lastHeartbeat同时用后台线程每分钟巡检一次超过90秒没收到心跳的会话直接关闭。提示心跳超时阈值一般取心跳周期的2到3倍太短会误杀慢网络下的正常设备太长又发现不了假死。状态变化统一收敛到Lamp的synchronized方法里不要在下发参数时直接在Map里散改避免并发脏写。命令处理完后要不要回ACK我建议回。开灯/关灯命令执行成功后回一帧0x06应答客户端收到应答才认为下发成功。否则客户端只能靠猜某个命令丢了你根本不知道是网络断了还是灯坏了。这一条在调试阶段尤其重要能把问题快速定位到链路层还是应用层。4. 客户端实现远程下发开关指令与温湿度周期上报4.1 模拟路灯客户端的连接与命令下发客户端这一侧承担两个职责接收服务端下发的控制命令同时周期上报温湿度。连接建议做成可重连的循环连不上就每隔5秒重试直到连上为止。连接参数上connectTimeout给3秒避免一个不可达的IP把线程挂半天。import java.io.*; import java.net.*; public class LampClient { private Socket socket; private final String host; private final int port; public LampClient(String host, int port) { this.host host; this.port port; } public void connect() throws IOException { socket new Socket(); socket.connect(new InetSocketAddress(host, port), 3000); socket.setSoTimeout(10_000); System.out.println(已连接服务端 host : port); } public void switchLight(short devId, boolean on) throws IOException { byte cmd on ? LampProtocol.CMD_SWITCH_ON : LampProtocol.CMD_SWITCH_OFF; byte[] frame LampProtocol.encode(cmd, devId, null); socket.getOutputStream().write(frame); socket.getOutputStream().flush(); } public void reconnectLoop() { while (!Thread.currentThread().isInterrupted()) { try { connect(); return; } catch (IOException e) { // 固定5秒重试一次真实项目可改成指数退避 try { Thread.sleep(5_000); } catch (InterruptedException ie) { return; } } } } }注意connect和setSoTimeout是两回事connectTimeout管的是三次握手的最大等待时间setSoTimeout管的是读数据时的阻塞间隔。很多初学者只设了connect超时没设读超时服务端不发数据时客户端线程就无限期挂起重连机制也形同虚设。实际运行时程序跑起来后你会发现客户端不是被动等命令它手上的同一个Socket既往下发指令也要预留读应答的入口。这个入口可以单独起一个读线程也可以放在主循环里关键是别让读超时把写命令堵住。4.2 温湿度采集模拟与上报策略真实路灯周围会挂温湿度传感器模拟时用Random生成就好但范围要符合场景温度15到35摄氏度湿度40%到90%电压210到240伏。上报策略我建议固定周期加阈值变化结合模拟阶段周期上报最直观默认5秒一次。上报间隔不要小于1秒否则写日志都写不过来服务端的半包问题也会被放大。import java.util.Random; public class SensorSimulator { private final Random random new Random(); private float lastTemp 25.0f; private float lastHumidity 60.0f; public float readTemperature() { // 在上次读数基础上做小步长漂移更接近真实传感器 lastTemp (random.nextFloat() - 0.5f) * 0.8f; if (lastTemp 15) lastTemp 15; if (lastTemp 35) lastTemp 35; return lastTemp; } public float readHumidity() { lastHumidity (random.nextFloat() - 0.5f) * 1.5f; if (lastHumidity 40) lastHumidity 40; if (lastHumidity 90) lastHumidity 90; return lastHumidity; } public float readVoltage() { return 210 random.nextFloat() * 30; } }这里的随机数据不能完全独立否则服务端看到的温湿度曲线一跳一跳的一点不像真实环境。我一般会让数值在上一次读数附近做小步长漂移这样排查通信错乱时更容易看出问题。还有一种常见做法是事件触发上报温度变化超过0.5度才上报。路灯场景没有强实时需求周期上报更简单事件触发更适合烟雾报警、温感报警这类一旦超阈值就要立刻通知的设备。上报帧里也建议加设备时间戳。路灯分散在不同位置时钟可能不一致服务端用收到时刻打点客户端用本地时间打点两者一对比就能判断是网络延时还是设备时钟漂移。5. 避坑指南Socket路灯系统最容易翻车的五个问题5.1 粘包半包把温湿度数据读串了现象客户端连续发送多条帧服务端收到的第一条命令缺了后半截第二条命令又带着上一帧的尾巴路灯出现无故开关。原因TCP是流协议底层不知道你的帧边界。客户端write多次内核和网络可能合并成一次到达反过来服务端也可能一次只读到半帧。解决在服务端维护一个累积缓冲区每次读到新数据先追加然后循环尝试从缓冲区里解析完整帧解析不成就等下一批数据。这是普通工程师最容易漏掉的一步也是最值得写在简历上的一步。public class FrameDecoder { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); public Listbyte[] decode(byte[] data) { buffer.write(data, 0, data.length); byte[] all buffer.toByteArray(); Listbyte[] frames new ArrayList(); int pos 0; while (pos 7 all.length) { if (all[pos] ! 0x7E || all[pos 1] ! 0x7E) { pos; continue; } int len ((all[pos 2] 0xFF) 8) | (all[pos 3] 0xFF); int frameLen 4 len 1; if (pos frameLen all.length) { break; // 半包等下一次数据到达 } frames.add(Arrays.copyOfRange(all, pos, pos frameLen)); pos frameLen; } buffer.reset(); if (pos all.length) { buffer.write(all, pos, all.length - pos); } return frames; } }这个解码器的逻辑是读不完整帧就保留在缓冲区里绝不丢弃也不提前消费。调试时你可以用一个短延时发送线程每帧间隔20ms复现粘包确认修复效果。5.2 客户端一断开服务端就抛Connection reset现象路灯断电或程序退出服务端控制台疯狂刷SocketException: Connection reset有时候还会误判成正常业务异常。原因一端关闭Socket另一端还往这个连接上写数据内核就会回RST。常见误操作是在finally里二次关闭把真正的异常覆盖掉。排查时只有异常能告诉我们连接状态你把它吞了问题就从“知道是连接断了”变成“不知道为什么卡住”。解决客户端被动断开本来就是常态服务端把SocketException当成会话结束信号就好不用打ERROR级别日志打个INFO并清理session。关键是别在清理逻辑里再去写这条已经死了的连接也别在close时再次触发异常导致日志刷屏。5.3 多个客户端控制同一盏灯状态被覆盖现象两个控制端同时发开灯和关灯命令服务端显示的状态和实际最后一条命令不一致路灯状态在服务端和路灯本地出现分叉。原因SocketHandler线程接收到命令后没有对同一盏灯的状态做同步。两个线程同时读写后到的命令不一定会成为最终值。解决用ConcurrentHashMap存灯设备每个路灯对象内部用synchronized锁住状态变更。这样不管哪个客户端先到最终状态都是串行执行的后到的命令覆盖先到的逻辑上就一致了。public class Lamp { private volatile boolean on; private volatile boolean fault; public synchronized void applyCommand(boolean target) { this.on target; this.fault false; // 手动控制后清除故障标志 } }这里的volatile保证多线程可见性synchronized保证状态变更原子性。如果你连状态查询也要实时一致就在读取时也走同一个锁或者用AtomicBoolean。5.4 跑一晚上服务端变卡连接数只增不减现象服务端跑一晚上内存和线程数缓慢上升第二天命令响应明显变慢。原因不少连接处于假死状态客户端网络切换后旧连接一直没人回收。没设读超时、没有心跳清理是这一类翻车最常见的原因。解决前面3.1里已经埋了setSoTimeout再配合定时巡检线程。还要给线程池一个有界队列满了之后拒绝新连接宁可让客户端重连也不能让服务端被无限堆积搞崩。巡检线程建议单独一个ScheduledExecutorService每30秒遍历会话清理超时会话和关闭的Socket不要写在业务线程里顺手做。5.5 局域网能连跨网段就失败现象程序在本机和同一局域网里一切正常换一个网络环境就控制不了路灯。原因这往往不是Java代码问题是网络环境问题。跨三层通信需要明确端口映射或者是服务端跑在没开安全组策略的云主机上。很多新人在这一步怀疑代码实际上是在与光猫和防火墙搏斗。解决先telnet一下目标IP和端口通不通一目了然。公网场景不要图省事把裸Socket暴露到公网容易招扫描至少加IP白名单和Token握手。这只是模拟链路真要商用还要上TLS和应用层加密。6. 进阶验证用日志和压测把整个路灯网络的状态重建出来6.1 日志格式固定下来一行awk统计开关次数调试阶段最忌讳肉眼盯控制台。我一般会在服务端每次收到命令时打一行结构化日志格式固定成CSV时间、设备ID、命令字、动作、结果。这样不需要看界面一条awk就能统计出某盏灯的开关次数。awk -F, {print $2, $4} lamp.log | sort | uniq -c | sort -rn日志是这套模拟系统唯一的黑匣子真实设备出了问题也只能靠日志回放。字段顺序定下来就不要改中途加字段会破坏所有统计脚本。这个习惯能让你在系统翻车时最快回到现场。6.2 用50个模拟客户端做稳定性验证功能通了只是第一步。我会写一个压测脚本同时拉起50个客户端进程每个进程每2秒发一次状态查询或切换命令跑72小时第二天起来看日志里有没有异常帧、有没有线程数上涨。for i in $(seq 1 50); do java -cp lamp-system.jar LampClient 192.168.1.100 9100 $i /dev/null 21 done sleep 259200 pkill -f LampClient压测的重点不是看功能正常而是看异常场景拔网线、杀进程、重启服务端看客户端能不能按重连逻辑恢复服务端连接数会不会回落。我自己的习惯是只保留最后一次调试用的协议帧常量表其余调试代码全删掉让客户端逻辑保持干净。这套东西做完再回头去看Java面试题里那些Socket半包、ReadTimeout和线程池问题你会觉得它们不再是八股文。希望帮到你。本文还有配套的精品资源点击获取
返回列表