ARTICLE DETAIL

资讯详情

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

Java+MySQL实战:手写局域网聊天工具,深入Socket与多线程

Java+MySQL实战:手写局域网聊天工具,深入Socket与多线程 简介本资源是一套基于 Java8 与 MySQL 实现的局域网即时通信工具定位为简易微信适合正在学习 Socket 编程、数据库操作或需要完成课程设计的学生参考。系统涵盖用户注册登录、修改与找回密码、添加好友、文字与图片聊天、好友列表展示、群发消息等功能并支持局域网内客户端通信服务器未启动时客户端无响应服务器断开或聊天对象下线均有提示可满足部分局域网通信场景。压缩包共 120 个文件约 11.36MB包含 26 个 java 源码、30 个 class 编译文件、26 个 png 与 18 个 jpg 界面素材以及 jar、exe、md、doc 等配置与说明文件目录结构清晰。目前已有 257 人学习。读者可从中获取完整的客户端与服务端源码、界面资源与数据库设计思路便于理解 Socket 通信流程、消息收发机制与异常处理逻辑也可作为二次开发或课程设计答辩的参考方案。1. 局域网聊天工具为什么值得自己写一遍很多人第一次听到「基于 JavaMySQL 实现基于局域网通信的简易微信」这个题目第一反应是微信那么复杂局域网里能做出什么其实真正做过的人都知道局域网通信才是即时通讯最核心的那层骨架——客户端连上服务端、消息怎么发、在线状态怎么维护、聊天记录怎么落库这些才是每天写业务代码时绕不开的东西。把它做一遍比刷十道 Java 面试题更能让你理解 Socket、多线程、JDBC 事务到底在解决什么问题。这个方案适合三类人正在学 Java 基础、想找一个能跑起来又不至于烂尾的项目练手的学生需要给内网工具加一个轻量聊天模块的后端开发以及想搞清楚「消息从 A 到 B 中间到底发生了什么」的工程师。它不追求公网可用也不做音视频目标很明确同一局域网内多台机器能互相发文字消息服务端把用户和聊天记录存进 MySQL重启之后数据还在。下面按「先跑通最小链路再补功能最后讲坑」的顺序展开。2. 先把通信骨架搭起来Socket 长连接与消息协议2.1 为什么选 TCP 长连接而不是 HTTP 轮询局域网聊天最怕的就是消息延迟。如果用 HTTP 轮询客户端每隔一秒问一次服务端「有没有新消息」在局域网里虽然延迟能接受但连接开销大而且服务端要反复处理无状态的请求在线状态很难维护。TCP 长连接的好处是客户端启动就连上服务端之后所有消息都走这条通道服务端能主动推消息在线列表也能实时更新。Java 里做这件事最直接的就是ServerSocketSocket。服务端在某个端口监听每个客户端连上来就分配一个线程处理它的输入流。这里有个关键点一个客户端对应一个线程但线程里读消息是阻塞的所以不能用主线程去读否则一个客户端卡住整个服务就废了。常见做法是用线程池或者每个连接起一个独立线程连接数不大时后者更简单。消息格式不能随便发字符串否则粘包和拆包会让你怀疑人生。我一般会定义一个简单的协议前 4 个字节表示消息体长度后面跟 JSON 字符串。这样服务端先读 4 个字节知道后面要读多少再一次性读满就不会出现两条消息粘在一起的情况。2.2 最小可运行的服务端代码下面这段代码只做一件事监听 8888 端口接受客户端连接把收到的消息打印出来并原样回一条确认。它是后面所有功能的地基。import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ChatServer { private static final int PORT 8888; // 线程池大小按局域网内预计在线人数设置20 人以内 10 个线程够用 private static final ExecutorService POOL Executors.newFixedThreadPool(10); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(聊天服务已启动端口 PORT); while (true) { // accept 会阻塞直到有客户端连上来 Socket socket serverSocket.accept(); POOL.submit(() - handleClient(socket)); } } private static void handleClient(Socket socket) { try (DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { while (true) { // 先读 4 字节长度再读对应长度的内容 int length in.readInt(); if (length 0 || length 1024 * 1024) { break; // 长度异常直接断开防止内存被撑爆 } byte[] body new byte[length]; in.readFully(body); String msg new String(body, StandardCharsets.UTF_8); System.out.println(收到 msg); // 回一条确认实际项目里这里会转发给目标用户 byte[] ack 服务端已收到.getBytes(StandardCharsets.UTF_8); out.writeInt(ack.length); out.write(ack); out.flush(); } } catch (IOException e) { System.out.println(客户端断开 e.getMessage()); } } }这段代码里有两个参数值得注意。PORT选 8888 只是习惯实际部署时如果被占用就换 1024 以上的任意端口。线程池大小10是保守值局域网内同时在线超过 10 人时后面的连接会排队等待表现是「连上了但发消息没反应」这时候把newFixedThreadPool(10)改成newCachedThreadPool()或者调大数字即可。length 1024 * 1024这个判断是防止有人发一个超大长度字段导致服务端分配巨大数组属于最基本的防护。2.3 客户端连接与消息发送客户端要做的事更简单连上服务端开一个线程专门读服务端推来的消息主线程负责读键盘输入并发送。这里用BufferedReader读控制台用DataOutputStream按同样的协议发出去。import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Scanner; public class ChatClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8888); DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream()); // 单独开线程读服务端消息否则主线程会被阻塞 new Thread(() - { try { while (true) { int len in.readInt(); byte[] body new byte[len]; in.readFully(body); System.out.println(新消息 new String(body, StandardCharsets.UTF_8)); } } catch (IOException e) { System.out.println(与服务端断开); } }).start(); Scanner scanner new Scanner(System.in); while (scanner.hasNextLine()) { String line scanner.nextLine(); byte[] data line.getBytes(StandardCharsets.UTF_8); out.writeInt(data.length); out.write(data); out.flush(); } } }把这两段代码分别跑起来你就有了一个最原始的「聊天」客户端发什么服务端打印什么并回一条确认。虽然还不能转发给别的客户端但通信链路已经通了。接下来要做的所有事情——用户登录、消息转发、聊天记录入库——都是在这个骨架上加东西。提示如果客户端连不上先确认服务端是否已经启动再检查防火墙有没有拦 8888 端口。Windows 上可以在「高级安全 Windows Defender 防火墙」里给 Java 放行Linux 上用firewall-cmd --add-port8888/tcp临时开放。3. 用户、在线状态与消息转发怎么落到 MySQL3.1 表结构设计三张表撑起核心功能聊天工具的数据模型不复杂但设计不好后面改起来很痛苦。我一般会建三张表用户表、在线状态表、消息记录表。用户表存账号密码和昵称在线状态表可以放在内存里但为了重启后能恢复也可以落库消息记录表存每一条聊天内容。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(32) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0, INDEX idx_to_user (to_user_id, is_read) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;username加唯一索引是防止重复注册。message表上的idx_to_user索引很关键因为拉取未读消息时查询条件是to_user_id ? AND is_read 0没有这个索引消息一多查询就会变慢。content用TEXT而不是VARCHAR(255)因为聊天内容可能很长VARCHAR截断会丢消息。在线状态我倾向于放在服务端内存里用一个ConcurrentHashMapLong, Socket维护「用户 ID → 连接」。这样发消息时直接查 map 就能找到目标用户的连接不用查数据库。用户下线时从 map 里移除。如果服务端重启所有人重新登录即可不需要持久化在线状态。3.2 登录与消息转发的完整流程登录的逻辑是客户端发一条 JSON包含type: login、用户名、密码服务端查数据库验证成功就把这个连接放进在线 map并返回登录成功。之后客户端发type: chat的消息服务端根据toUserId找到目标连接把消息推过去同时写一条记录到message表。// 服务端处理登录和转发的核心逻辑片段 private static final MapLong, DataOutputStream ONLINE new ConcurrentHashMap(); private static void handleMessage(String json, DataOutputStream out) throws IOException { JSONObject obj JSON.parseObject(json); String type obj.getString(type); if (login.equals(type)) { String username obj.getString(username); String password obj.getString(password); // 实际项目里密码要加盐哈希这里为了演示先明文比对 Long userId userDao.checkLogin(username, password); if (userId ! null) { ONLINE.put(userId, out); send(out, {\type\:\login\,\success\:true,\userId\: userId }); } else { send(out, {\type\:\login\,\success\:false}); } } else if (chat.equals(type)) { Long fromId obj.getLong(fromUserId); Long toId obj.getLong(toUserId); String content obj.getString(content); // 先落库再转发保证消息不丢 messageDao.save(fromId, toId, content); DataOutputStream target ONLINE.get(toId); if (target ! null) { send(target, {\type\:\chat\,\from\: fromId ,\content\:\ content \}); } } }这里有一个顺序问题先落库再转发。如果先转发再落库转发成功但落库失败消息就丢了先落库再转发即使转发失败对方不在线消息还在数据库里对方下次登录可以拉取。ONLINE用ConcurrentHashMap是因为多个线程会同时读写普通HashMap在并发下会出问题。3.3 离线消息与未读拉取对方不在线时消息已经存进message表is_read为 0。用户登录成功后服务端主动查一次SELECT * FROM message WHERE to_user_id ? AND is_read 0把结果推给客户端然后把is_read更新为 1。这一步用事务包起来避免推送成功但更新失败导致重复推送。public ListMessage pullUnread(Long userId) { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); ListMessage list messageDao.selectUnread(conn, userId); messageDao.markRead(conn, userId); conn.commit(); return list; } catch (SQLException e) { if (conn ! null) conn.rollback(); throw new RuntimeException(e); } finally { if (conn ! null) try { conn.close(); } catch (SQLException ignored) {} } }setAutoCommit(false)开启事务查询和更新要么都成功要么都回滚。markRead的 SQL 是UPDATE message SET is_read 1 WHERE to_user_id ? AND is_read 0注意条件里带上is_read 0避免把已经读过的消息重复标记。这个逻辑在局域网内用户不多时完全够用如果以后要支持群聊把to_user_id换成group_id再建一张群成员表即可。4. 避坑与排查那些让我加班到凌晨的细节4.1 现象客户端发第一条消息正常第二条就卡住原因服务端读消息时用了in.read()而不是readFully()或者客户端发送时没有flush()。TCP 是流式协议数据不会自动按你的「一条消息」分界如果读的时候没有读满指定长度剩下的字节会留在缓冲区下一次读就会错位。解决统一用DataInputStream.readInt()读长度、readFully()读内容发送端每次写完必须flush()。这个坑我踩过不止一次后来养成习惯只要用DataOutputStream写完立刻 flush。4.2 现象MySQL 连接过一会儿就报「Communications link failure」原因MySQL 默认wait_timeout是 8 小时但局域网项目经常一跑就是一整天连接池里的空闲连接被服务端断掉客户端拿到的却是一个已经失效的连接。解决在 JDBC URL 里加上autoReconnecttrue或者用连接池HikariCP、Druid并配置validationQuerySELECT 1和testWhileIdletrue。最省事的做法是直接用 HikariCP它默认就会做连接有效性检测。4.3 现象中文消息变成乱码原因服务端和客户端编码不一致。Windows 控制台默认是 GBK而代码里用 UTF-8 编码两边对不上。解决所有String转byte[]的地方显式指定StandardCharsets.UTF_8不要用无参的getBytes()。MySQL 建表时指定CHARSETutf8mb4JDBC URL 加上characterEncodingutf8。如果控制台还是乱码那是终端显示问题不影响数据本身。4.4 现象多个客户端同时登录同一个账号消息只发给最后登录的那个原因ONLINEmap 的 key 是用户 ID同一个账号第二次登录会覆盖第一次的DataOutputStream前一个连接还在但已经收不到消息。解决看产品需求。如果允许同一账号多端在线map 的 value 要改成ListDataOutputStream如果不允许第二次登录时先给前一个连接发一条「你的账号在其他地方登录」并关闭它。我一般选后者实现简单且符合大多数人对聊天工具的预期。4.5 现象消息发送成功但数据库里没有记录原因messageDao.save()抛了异常但被 catch 后没有打印日志或者事务没有提交。还有一种可能是from_user_id或to_user_id在数据库里是NOT NULL但传了 null 进去插入失败。解决DAO 层不要吞异常至少e.printStackTrace()。检查表结构是否允许 null插入前确认用户 ID 已经拿到。如果用了连接池确认每次操作后连接是否正确归还没有归还的话连接耗尽后所有数据库操作都会失败。5. 进阶技巧把「简易微信」做得更像样5.1 用心跳包检测断线TCP 连接断开时服务端不一定能立刻感知——网线拔了、客户端进程被 kill 了服务端可能还傻傻地以为对方在线。解决办法是心跳客户端每隔 30 秒发一条{type:ping}服务端收到后回{type:pong}同时更新该用户的最后活跃时间。服务端再起一个定时任务扫描超过 90 秒没有心跳的连接主动关闭并从ONLINE里移除。// 服务端定时清理死连接 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { long now System.currentTimeMillis(); ONLINE.forEach((userId, out) - { Long lastActive LAST_ACTIVE.get(userId); if (lastActive null || now - lastActive 90_000) { try { out.close(); } catch (IOException ignored) {} ONLINE.remove(userId); LAST_ACTIVE.remove(userId); } }); }, 30, 30, TimeUnit.SECONDS);LAST_ACTIVE是一个ConcurrentHashMapLong, Long每次收到该用户的任何消息就更新为当前时间。90 秒的阈值可以根据实际网络情况调整局域网内 60 秒也够用。5.2 消息内容做 XSS 过滤和长度限制虽然是局域网但消息内容最终可能显示在 Swing 或 JavaFX 界面上如果直接拼接 HTML 会有注入风险。更实际的问题是有人发一条 10 万字的文本服务端读的时候分配大数组客户端渲染也卡死。我一般会在服务端加两道限制单条消息不超过 2000 字符超过就截断并提示内容里的、转义成lt;、gt;。private static String sanitize(String content) { if (content null) return ; if (content.length() 2000) { content content.substring(0, 2000) ...(已截断); } return content.replace(, lt;).replace(, gt;); }5.3 用配置文件管理数据库连接和端口把端口、数据库地址、用户名密码写死在代码里换一台机器就要重新编译这是新手常犯的错。正确做法是放一个config.properties在 classpath 下用Properties类读取。配置项示例值说明server.port8888服务端监听端口db.urljdbc:mysql://127.0.0.1:3306/chat?useSSLfalsecharacterEncodingutf8数据库连接串db.usernameroot数据库账号db.password123456数据库密码heartbeat.interval30心跳间隔单位秒Properties props new Properties(); try (InputStream in ChatServer.class.getClassLoader() .getResourceAsStream(config.properties)) { props.load(in); } int port Integer.parseInt(props.getProperty(server.port, 8888));getProperty的第二个参数是默认值配置文件里没写就用默认值避免空指针。useSSLfalse在局域网内可以关掉省去证书配置的麻烦如果对安全有要求再单独配 SSL。5.4 一个我坚持了很久的习惯每次改完服务端代码我不会直接让所有人重新登录测试而是先在本机开两个客户端一个用正常账号一个用测试账号互相发 20 条消息然后关掉服务端再重启看消息有没有丢、未读能不能拉出来。这个流程跑一遍只要两分钟但能挡住八成以上的低级问题。局域网项目最怕的就是「在我机器上是好的」多开一个客户端就能提前发现端口占用、编码不一致、连接池配置错误这些玄学问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表