ARTICLE DETAIL

资讯详情

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

Java Socket C/S架构路灯控制系统:协议、线程与避坑指南

Java Socket C/S架构路灯控制系统:协议、线程与避坑指南 简介基于C/S架构的Java模拟路灯控制系统源码面向学习套接字编程、需完成Java课程大作业的同学。系统使用Swing图形界面库搭建客户端界面以套接字方式实现双向通信支持远程开关路灯同时采集温湿度等模拟环境信息并将数据实时反馈给客户端。压缩包共36个文件大小约2.16MB以13个Java源文件为主体辅以界面运行截图、项目说明文档和工程配置文件便于导入开发环境后直接阅读源码。已有309人学习项目将服务端与客户端分离覆盖对象流序列化传输、用户事件响应、环境数据随机模拟等核心环节结构清晰。通过该案例可系统理解C/S架构下网络通信的完整流程掌握图形界面与网络编程结合的实战方式也可作为课程设计或大作业答辩的可靠参考。1. 先搞清楚这套源码在解决什么问题C/S 架构下的 Java 路灯控制系统拿到这套基于 C/S 架构的 socket 通信 Java 模拟路灯控制系统源码我建议你先别急着点运行。从表面看这是典型的 Java 大作业配置一个服务端监听端口一个 Swing 客户端远程控制路灯开关顺带采集温湿度等环境信息。但真正决定你能不能快速跑通、答辩能不能讲清楚的是藏在代码里的通信协议、线程模型和连接生命周期管理——这三样恰恰是 socket 编程里最容易翻车的地方。这套源码适合两类人拿它交 Java 课程设计、不想从零写协议和界面的学生以及想找个完整 C/S 通信案例练手、想搞懂 socket 底层行为的开发者。前者照着跑通改参数后者可以直接替换模拟传感器和命令解析做二次开发。2. 先把协议和线程模型定死C/S 架构下两个影响成败的设计点2.1 消息格式为什么用文本帧而不是 Java 对象流这个项目里服务端和客户端唯一的沟通渠道是 socket。很多同学一上来就用 ObjectOutputStream 把命令对象整体传过去比如一个 LightControl 对象带个 switchOn 字段。这样做代码是短了但坑非常大两端类路径必须完全一致包前缀不一样反序列化就炸调试时抓包全是二进制看不出客户端到底发了什么以后想换别的语言写客户端协议得完全重写。所以在这个量级的课程设计里常见做法是自定义文本帧——把所有命令和响应拼成一行字符串用分隔符切分字段。协议定义其实不复杂关键是先定死两端按同一张表实现帧类型方向格式服务端返回心跳客户端→服务端PINGPONG开灯客户端→服务端CMD:LIGHT:ONACK:LIGHT:ON:OK关灯客户端→服务端CMD:LIGHT:OFFACK:LIGHT:OFF:OK查询灯状态客户端→服务端CMD:LIGHT:GETDATA:LIGHT:ON 或 OFF查询温湿度客户端→服务端CMD:ENV:GETDATA:ENV:26.5:60.3断开客户端→服务端CMD:QUITACK:QUIT:OK字段统一用冒号分隔帧尾以换行符结束。为什么选冒号不选逗号温湿度值里带小数点逗号在视觉上和十进制小数点容易混淆而且 String.split(:) 不需要转义按冒号切最省事。响应帧统一 ACK 或 DATA 开头错误帧用 ERR 开头比如 ERR:UNKNOWN_CMD:FOO这样客户端收到任何不认识的返回都能落到统一的错误分支。服务端的解析入口就是一个切分方法public static String[] parseFrame(String line) { // 去掉可能残留的 \rWindows 下 telnet 或 println 会带 \r\n line line.replace(\r, ); if (line.isEmpty()) { return new String[0]; } return line.split(:); // 按冒号切分数组第 0 位永远是命令类型 }逻辑说明先清理 \r 再切分避免 Windows 换行符混进最后一个字段空行直接返回空数组调用方跳过。参数说明返回数组里下标 0 是命令大类比如 CMD 或 PING后面的下标依次是子命令和参数具体怎么消费在第 3 章讲。选择按行传输还有一个好处天然对抗粘包问题。TCP 是字节流没有消息边界客户端连发两条命令服务端一次 read 可能同时读到两条也可能一条命令被拆成两半到达。但只要约定一帧一行读端用 readLine() 等换行符粘过来的两条会被重新切成两行半包会在换行符到了之后再返回逻辑上规避掉了这个经典坑。第 5 章我会专门讲一次这个现象的排查过程。2.2 线程模型一连接一线程的取舍与线程池参数课程设计的并发规模一般不超过 10 个客户端最朴素的一连接一线程完全够用。但直接 new Thread 有两个问题线程不可控、连接断开后线程不回收。常见做法是用 ExecutorService 包一层既保留一连接一线程的简单性又拿到线程复用和回收能力。服务端主循环长这样ExecutorService pool new ThreadPoolExecutor( 2, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲超过 60 秒回收 new LinkedBlockingQueueRunnable(100), // 等待队列容量 new ThreadFactory() { private final AtomicInteger id new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, light-server- id.getAndIncrement()); } } ); try (ServerSocket serverSocket new ServerSocket(6666)) { System.out.println([server] listening on 6666); while (true) { Socket socket serverSocket.accept(); // 阻塞等待新连接 pool.execute(new ClientHandler(socket)); // 每个连接一个任务 } }逻辑说明accept() 阻塞在 6666 端口每进来一个客户端就把 Socket 封装成 ClientHandler 丢进线程池执行。线程池核心 2、最大 8、队列 100超过队列容量时任务会被拒绝——课程设计场景里几乎不会触发。参数说明核心线程数一般设成 CPU 核数或略低这里 2 是保守值最大 8 是因为每个连接同时只有一个阻塞点readLine8 个线程能扛 8 个并发连接再多就要扩到 16 或 32。线程命名工厂是排错利器服务端日志里出现 light-server-3 就能知道是哪个连接出问题。为什么不推荐 CachedThreadPool它最大线程数是 Integer.MAX_VALUE客户端瞬时大量重连时线程数疯涨每个线程默认栈 512KB 到 1MB内存和上下文切换都会被拖垮。而固定线程池 FixedThreadPool 的问题是队列无界连接超过最大线程数后全部排队连不上也不报错排查起来非常玄学。ThreadPoolExecutor 显式给队列上限至少出问题时有明确报错。这里有一个容易被忽略的细节服务端主线程和 ClientHandler 都在同一个 JVM 里灯开关状态是所有客户端共享的。所以第 3 章那个 lightOn 状态变量必须保证跨线程可见性这直接决定了该用普通 boolean 还是 volatile——这也是 socket 项目的经典考察点答辩时基本必问。3. 服务端落地灯控命令处理与温湿度采集3.1 灯控状态共享与命令解析路灯的核心状态就两个开和关。这个状态在服务端是全局唯一的不管哪个客户端发 CMD:LIGHT:ON灯都应该亮再发一次 ON也应该返回成功而不是报错。多个客户端连接线程同时读写这个状态就需要考虑可见性。常见做法是声明成 volatile boolean它保证一个线程改了另一个线程立刻能看到新值但对于先查再改这种复合操作volatile 不够得加 synchronized课程设计到 volatile 这层基本就够了。ClientHandler 的 run() 方法把连接生命周期和命令处理分开public class ClientHandler implements Runnable { private final Socket socket; private static volatile boolean lightOn false; private final Sensor sensor new SimulatedSensor(); // 模拟温湿度传感器 public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line in.readLine()) ! null) { String ack dispatch(parseFrame(line)); if (ack ! null) { out.println(ack); } } } catch (IOException e) { System.out.println([server] connection error: e.getMessage()); } finally { closeQuietly(socket); // 无论异常还是正常退出都释放 socket } } }逻辑说明try-with-resources 保证流自动关闭readLine() 返回 null 表示对端关闭循环退出后 finally 兜底释放 socket。参数说明InputStreamReader 和 OutputStreamWriter 都显式用 UTF-8这是中文协议帧和日志不变成问号的先决条件后面避坑章还会再强调。命令解析用 switch 按大类分派private String dispatch(String[] parts) { if (parts.length 2) { return ERR:EMPTY_CMD; } String cmd parts[0] : parts[1]; // 拼成 CMD:LIGHT 这种形式 switch (cmd) { case CMD:LIGHT: if (parts.length 3) { return ERR:LIGHT_NO_ARG; } if (ON.equalsIgnoreCase(parts[2])) { lightOn true; System.out.println([server] light - ON); return ACK:LIGHT:ON:OK; } else if (OFF.equalsIgnoreCase(parts[2])) { lightOn false; System.out.println([server] light - OFF); return ACK:LIGHT:OFF:OK; } else if (GET.equalsIgnoreCase(parts[2])) { return DATA:LIGHT: (lightOn ? ON : OFF); } return ERR:LIGHT_UNKNOWN_ARG: parts[2]; case CMD:ENV: return handleEnv(parts); default: return ERR:UNKNOWN_CMD: parts[0]; } }逻辑说明parseFrame 切出来的 parts[0] 固定是 CMDparts[1] 是子命令parts[2] 是操作参数CMD 和 LIGHT 拼起来和 case 比较。参数说明equalsIgnoreCase 让客户端发小写 on/off 也能被识别减少联调时的大小写翻车返回的字符串统一由 run() 里的 println 输出dispatch 只负责构造响应。这里最容易写错的是把查询灯状态和控制灯状态的返回值混在一起。查询 CMD:LIGHT:GET 必须走 DATA:LIGHT:ON/OFF 返回当前真实状态不能直接返回上次操作成功否则客户端重启后再连接显示的状态全是缓存和实际灯的状态对不上答辩现场演示时很尴尬。3.2 温湿度采集模拟传感器与真实接口预留课程设计没有真实硬件温湿度常用随机数模拟。但如果在 ClientHandler 里直接 new Random().nextDouble()以后想接 DHT11 这类真实传感器就得动命令处理代码。常见做法是先抽一个 Sensor 接口模拟实现和以后的真实实现都实现同一个接口public interface Sensor { /** 返回温湿度数组 [0] 是温度[1] 是湿度 */ double[] readEnv(); } public class SimulatedSensor implements Sensor { private static final double TEMP_LOW 22.0; private static final double TEMP_HIGH 28.0; private static final double HUMI_LOW 40.0; private static final double HUMI_HIGH 70.0; Override public double[] readEnv() { double temp ThreadLocalRandom.current().nextDouble(TEMP_LOW, TEMP_HIGH); double humi ThreadLocalRandom.current().nextDouble(HUMI_LOW, HUMI_HIGH); return new double[] { round1(temp), round1(humi) }; } private double round1(double v) { return Math.round(v * 10) / 10.0; } }逻辑说明SimulatedSensor 在 2228℃、4070%RH 范围内取随机值并保留一位小数模拟正常室外的环境波动。参数说明TEMP_LOW、TEMP_HIGH 等四个边界值就是量程真实接硬件时换成传感器的量程和读取代码接口不变上层不用改。温湿度查询的响应处理放在 handleEnvprivate String handleEnv(String[] parts) { if (parts.length 3 GET.equalsIgnoreCase(parts[2])) { double[] env sensor.readEnv(); String frame String.format(DATA:ENV:%.1f:%.1f, env[0], env[1]); System.out.println([server] env - frame); return frame; } return ERR:ENV_UNKNOWN_ARG; }逻辑说明收到 CMD:ENV:GET 才读一次传感器组帧后返回其他参数一律视为未知命令。参数说明%.1f 控制小数位为 1 位客户端解析 DATA:ENV:26.5:60.3 时按冒号切分后下标 2 和 3 直接转 float 就行不需要额外的精度处理。如果想让演示效果更主动可以加一个定时广播服务端每 5 秒把当前温湿度推给所有已连接客户端客户端不查询也能看到数据变化。实现上需要一个线程安全的客户端输出流集合以及一个全局的 Sensor 实例private static final ListPrintWriter clients Collections.synchronizedList(new ArrayList()); private static final Sensor sensor new SimulatedSensor(); // 连接建立时 clients.add(out)finally 里 clients.remove(out) java.util.Timer timer new java.util.Timer(env-broadcast, true); timer.scheduleAtFixedRate(new TimerTask() { Override public void run() { double[] env sensor.readEnv(); String frame String.format(DATA:ENV:%.1f:%.1f, env[0], env[1]); synchronized (clients) { for (PrintWriter c : clients) { c.println(frame); // 单个客户端写失败不影响广播循环 } } } }, 0, 5000);逻辑说明Timer 第二个参数 true 表示 daemon 线程JVM 退出时不会因为广播线程挂着而无法结束synchronizedList 的迭代遍历必须手动 synchronized否则边遍历边被 remove 会抛 ConcurrentModificationException。参数说明0 是首次执行延迟5000 是广播周期毫秒数想改频繁就调小但不要低于 1000否则打印刷屏日志。这份源码里模拟生成温湿度的逻辑就是这一套答辩时把 Sensor 接口拿出来讲预留了真实硬件接入点比单纯说我生成随机数当温湿度要加分得多。4. 客户端落地远程开关界面与接收线程4.1 Swing 连接管理把阻塞 IO 请出事件线程客户端的界面结构不复杂IP 输入框、端口输入框、连接按钮、状态标签、路灯开关按钮、温湿度显示标签最多再加一个日志区。真正的坑不在布局在 Swing 的线程模型。Swing 是单线程模型所有 UI 操作必须在事件派发线程EDT上执行。如果直接在按钮监听里写 socket.connect()而目标 IP 不可达connect 会卡在那儿好几秒整个窗口拖都拖不动——因为 repaint 事件排不上队。常见做法是把连接动作丢到子线程connect 加超时private void connectAsync(String host, int port) { connectBtn.setEnabled(false); connectBtn.setText(连接中...); new Thread(() - { try { socket new Socket(); socket.connect(new InetSocketAddress(host, port), 3000); // 3 秒超时 out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8), true); in new BufferedReader(new InputStreamReader( socket.getInputStream(), StandardCharsets.UTF_8)); startReceiverThread(); // 启动接收线程见 4.2 SwingUtilities.invokeLater(() - { stateLabel.setText(已连接); connectBtn.setText(断开); }); } catch (IOException e) { SwingUtilities.invokeLater(() - { stateLabel.setText(连接失败: e.getMessage()); connectBtn.setEnabled(true); connectBtn.setText(连接); }); } }, connector-thread).start(); }逻辑说明connect 放进独立线程3 秒超时阻止客户端在死 IP 上无限等待connect 成功后再启动接收线程所有 UI 更新用 SwingUtilities.invokeLater 切回 EDT。参数说明3000 是连接超时毫秒数局域网内可以缩到 1000跨网段或服务端有防火墙可以放宽到 5000socket、out、in 都是客户端类的字段供后面的按钮和接收线程共用。连接成功后开灯关灯按钮的监听器就两行out.println(CMD:LIGHT:ON) 或 out.println(CMD:LIGHT:OFF)。这里不需要在按钮监听里做任何等待服务端回包由接收线程异步处理。很多新手在这里犯的毛病是点了开灯立刻读 in.readLine() 拿结果——这个 readLine 会阻塞事件线程而且可能读到的是上一次的残留响应完全没必要因为接收线程已经在持续消费响应了。4.2 响应接收与心跳感知断线接收线程是整个客户端的核心。它单独跑一个 while 循环持续 readLine()读到一行就交给 UI 更新逻辑private void startReceiverThread() { receiverThread new Thread(() - { String line; try { while (!socket.isClosed() (line in.readLine()) ! null) { final String msg line; SwingUtilities.invokeLater(() - handleServerFrame(msg)); } } catch (IOException e) { if (!socket.isClosed()) { SwingUtilities.invokeLater(() - stateLabel.setText(连接断开)); } } }, receiver-thread); receiverThread.start(); }逻辑说明循环条件是本地 socket 未关且还能读到行readLine() 返回 null 表示对端关闭抛 IOException 多半是连接被重置。参数说明socket.isClosed() 只反映本地关闭状态对端异常掉线时靠 readLine() 的返回和异常来感知——这正是为什么要配心跳。收到服务端帧后的分发逻辑private void handleServerFrame(String msg) { if (msg.startsWith(DATA:ENV:)) { String[] p msg.split(:); envLabel.setText(温度: p[2] ℃湿度: p[3] %RH); } else if (msg.startsWith(ACK:LIGHT:)) { String[] p msg.split(:); lightLabel.setText(p[2].equalsIgnoreCase(ON) ? 灯: 亮 : 灯: 灭); } else if (msg.startsWith(PONG)) { lastPongTime System.currentTimeMillis(); } }逻辑说明按前缀分发不同帧类型DATA:ENV:26.5:60.3 切分后 p[2] 是温度、p[3] 是湿度ACK:LIGHT:ON:OK 切分后 p[2] 直接反映灯的最终状态。参数说明lastPongTime 必须用 volatile 修饰因为心跳线程和接收线程是两个线程在读写同一个字段不加 volatile 可能出现心跳线程读到的永远是旧值误判断线。心跳用 java.util.Timer 做定时任务private volatile long lastPongTime System.currentTimeMillis(); java.util.Timer heartbeat new java.util.Timer(heartbeat, true); heartbeat.scheduleAtFixedRate(new TimerTask() { Override public void run() { if (out ! null) { out.println(PING); long gap System.currentTimeMillis() - lastPongTime; if (gap 15000) { SwingUtilities.invokeLater(() - stateLabel.setText(心跳超时正在重连)); // 触发重连逻辑关闭旧 socket再走一遍 connectAsync } } } }, 0, 5000);逻辑说明每 5 秒发一个 PING15 秒没收到 PONG 就判定链路失效。参数说明5000 是心跳发送周期15000 是超时阈值阈值一般是发送周期的 23 倍给网络抖动留余量Timer 的 daemon 标志保证客户端关窗后 JVM 能正常退出。心跳解决的是拔网线式断线。TCP 本身不会在物理链路断掉的瞬间通知应用层如果没有心跳客户端会一直显示已连接直到下一次真正读写数据才报错。课程设计的演示环境里经常有人直接拔网线换网口这个细节能救一次答辩。5. 联调避坑从连不上到数据错乱的五条血泪记录5.1 三个运行时翻车现场第一条服务端启动直接报 BindException。现象运行 Server 主类控制台立刻抛出 java.net.BindException: Address already in use端口起不来。原因上一个服务端进程没退干净6666 端口还被占用。Windows 下最常见的场景是 IDE 里上一次运行的任务没停掉又点了一次 Run或者程序在后台挂着关闭控制台窗口时 JVM 没有退出。解决先确认端口被谁占了。Windows 执行 netstat -ano | findstr 6666 拿到 PID再 taskkill /F /PID 那个 PIDLinux 用 lsof -i:6666。也可以偷懒直接改 ServerSocket 的端口但答辩前最好养成先查端口的习惯因为改了端口还得同步改客户端忘改就是下一个坑。第二条客户端显示已连接但点开关服务端没有任何日志。现象连接按钮状态正常按钮事件也触发了但服务端控制台一动不动。原因命令帧没被服务端 readLine 读到最典型的是发送端用了 print() 而不是 println()数据还在发送缓冲区里没有换行符readLine 会一直等或者命令串里多了尾随空格比如 CMD:LIGHT:ON parseFrame 切出来 parts[2] 是 ON equalsIgnoreCase 匹配失败。解决统一用 println 发送服务端在 dispatch 前加一行 System.out.println(received: [ line ])把收到的原始帧打出来。括号一框住尾随空格立刻现形。我一般调 socket 的第一动作就是服务端打印原始行先确认服务端看到了什么再去谈解析逻辑对不对。第三条界面点了连接就卡死窗口拖不动。现象点连接按钮后整个 Swing 界面变成假死Windows 上甚至出现无响应的标题。原因connect 或 readLine 写在了事件派发线程上阻塞了 EDT所有 UI 刷新事件全部排不上队。解决按第 4 章的模式connect 放子线程、readLine 放接收线程UI 更新用 SwingUtilities.invokeLater 切回 EDT。这条属于结构性错误改对了线程模型就再也不会出现而不是靠换一台性能更好的电脑。5.2 协议层两个隐蔽坑粘包半包与编码乱码第四条两条命令粘在一起或者一条命令被切了一半。现象服务端 readLine 一次读到 CMD:LIGHT:ONCMD:ENV:GET 这种拼接串或者收到 CMD:L 这种半截。原因TCP 是字节流协议没有消息边界底层一次发送可能聚合多条消息也可能拆分一条消息这是 TCP 的固有行为不是代码 bug。解决把协议定成一帧一行换行符就是边界readLine() 会等到一个完整换行帧才返回粘在一起的多条会被重新切成多行半截的会等到换行符到了再返回。前提是发送端绝不能把两条命令拼在一行里也不能在命令中间加换行。第 2 章的协议表之所以要求帧尾必须换行就是为了让这一条成立。第五条界面显示正常但日志和标签里的中文全是问号。现象温度数字没问题灯状态和日志里的中文全变成 ??。原因字符集不统一。Windows 控制台默认 GBKJava 的 InputStreamReader 不指定字符集就用平台默认编码服务端按 UTF-8 解码、客户端按 GBK 编码发送数据时中文必然乱码。解决两端所有 Reader 和 Writer 都显式指定 StandardCharsets.UTF_8如果是控制台输出乱码给 JVM 启动参数加 -Dfile.encodingUTF-8IDEA 里把 File Encoding 和 Console Encoding 全部改成 UTF-8。协议帧本身尽量纯英文中文只出现在日志和界面标签里这样即使编码配置漏了一处也不影响命令解析。注意以上五条是 socket 课程设计里出现频率最高的联调问题。复现这套源码时如果卡住按先看服务端日志原始行、再查监听端口、最后查两端编码的顺序排查能少走一半弯路。6. 进阶玩法协议扩展与 telnet 自测6.1 加一个亮度调节命令基础版只支持开和关答辩想加分可以加亮度调节。协议表新增一行 CMD:LIGHT:BRIGHT:80服务端解析时多取一个参数范围限定 0100存到 volatile int。核心改动就一处dispatch 的 CMD:LIGHT 分支里加一个 else if} else if (BRIGHT.equalsIgnoreCase(parts[2])) { int value Integer.parseInt(parts[3]); // 从帧里取出亮度值 brightness Math.max(0, Math.min(100, value)); // 限幅到 0~100 return ACK:LIGHT:BRIGHT: brightness :OK; }逻辑说明Integer.parseInt 可能抛 NumberFormatException可以用 try-catch 包住返回 ERR 帧这是扩展命令时最容易漏的防御。参数说明亮度值限幅到 0100 是防止客户端传 999 把状态搞坏客户端用下拉框或滑块限制输入范围服务端再做一次兜底两层校验。6.2 用 telnet 在不开界面的情况下验证协议我强烈建议每次改完协议先用 telnet 冒充客户端测一遍再打开 Swing 界面。这一步能把协议问题和界面问题彻底隔离开。Windows 的 telnet 客户端可能要手动开启Linux 和 Mac 自带。连接命令和手动输入如下telnet 127.0.0.1 6666 PING CMD:LIGHT:ON CMD:LIGHT:GET CMD:ENV:GET正常会依次看到 PONG、ACK:LIGHT:ON:OK、DATA:LIGHT:ON、DATA:ENV:26.5:60.3。如果在这里就断了说明协议或服务端有问题根本不用碰客户端代码如果 telnet 正常但 Swing 客户端不对那问题一定在客户端的发送或接收线程排查范围直接缩小一半。从那以后我每次接手 socket 相关的课程设计都强制自己先用 telnet 把协议层完整走一遍再打开界面点按钮。这个习惯帮我过滤掉了至少一半的联调问题希望帮到你。本文还有配套的精品资源点击获取
返回列表