简介:这是一份基于Java的安卓简易聊天应用服务端设计源码,面向初入移动开发与服务端编程的开发者,可用于学习聊天服务端的用户管理、消息分发等核心逻辑。压缩包共38个文件,包含29个Java源代码、2个XML配置、2个properties属性配置、1个jar包、1个Markdown文档及构建脚本等,整体大小仅133KB,结构紧凑且分类清晰。已有319人学习浏览,适合作为服务端入门与安卓联调实践的参考。通过研读源码与配置文件,可掌握服务端端口配置、数据库连接、网络通信协议设计及并发请求处理思路;源码实现核心逻辑,配置文件定义端口与连接参数,文档与脚本辅助快速构建部署;同时项目包含Maven构建相关文件与命令行脚本,有助于理解自动化构建流程,是一份小而实用的学习素材。项目内附说明文档,便于快速上手。
1. 聊天服务端到底在做什么:先从一次登录说起
你打开安卓聊天 app,输入账号密码点登录,几毫秒后好友列表刷新,有人给你发来一条消息,手机震动,你回了一句,对面几乎同时看到。这条消息能到对方手里,靠的不是 app 本身,而是你标题里那个「基于 Java 的安卓简易聊天 app 服务端」——它就是整个聊天系统的中继站和仓库。服务端负责三件事:维护每个人的在线状态、把消息转发给正确的人、以及在离线时把消息存下来等人来取。这篇文章就是围绕这样一个服务端的设计源码展开,讲清楚它的架构怎么搭、代码怎么写、参数怎么设、以及哪些坑是你照着做必然要踩一遍的。适合刚学完 Java 基础、想拿一个完整项目练手的人,也适合要为公司做内部 IM 工具、需要先跑通一个最小服务端的人。
2. 服务端架构定生死:协议、线程模型和数据库三件事先想明白
2.1 为什么用原生 Socket 而不是 Netty:简易方案的取舍
做个简易聊天服务端,首当其冲的问题是选型:用纯 Java Socket 还是上 Netty?我的建议很直接——如果你就是要把「简易」两个字做实,或者你还在学习阶段,原生ServerSocket加线程池完全够用;如果你预期在线人数会超过几百,或者你已经写过几次 Socket,那就直接 Netty。简易版的核心目标是要让你快速看清一个聊天服务端的全貌:连接怎么管、消息怎么收发、数据怎么落库。Netty 的 pipeline、eventLoop、ByteBuf 这些概念对新手是额外负担,对老手则是过度设计。
这里顺带说一个热词相关的话题:「服务端和客户端区别」常常被初学者搞混。聊天场景里,客户端主动连接服务端,服务端被动监听;连接建立后双方地位对等,谁都能随时发数据。但服务端要额外承担「路由」职责——A 发给 B 的消息,服务端得知道 B 在哪条连接上。这就是聊天服务端和普通的 HTTP API 服务端最大的不同:HTTP 是一次性的请求响应,聊天是长连接上的持续收发。
所以我的简易架构是三层的:
- 接入层:
ServerSocket监听端口,接住所有安卓客户端的 TCP 连接 - 路由层:根据消息里的
toUserId找到目标连接,把字节流写过去 - 存储层:对方不在线时,消息落到 SQLite 或 MySQL
2.2 协议设计与粘包对策:长度头如何工作
协议是聊天服务端最容易翻车的地方。HTTP 那种「请求-响应」天然有边界,而 TCP 是字节流,它不管你怎么分割消息。你发两条消息,接收方可能一次性读到两段拼接的字节,这叫粘包;也可能一条消息拆成两段到达,这叫半包。解决粘包的办法有很多种:分隔符、定长消息、长度头。简易聊天我用的是「4 字节长度头 + JSON 消息体」。
为什么选 JSON?因为 Java 里有Gson或Jackson直接就能序列化反序列化,调试时肉眼可读,安卓端和服务端不用各自维护一套复杂的二进制解码逻辑。对于简易聊天这种低频小消息场景,JSON 的冗余开销完全可以接受。协议格式如下:
// 消息体结构,服务端与安卓端共用 { "type": "LOGIN", // 消息类型:LOGIN / CHAT / HEARTBEAT / ACK "fromUserId": "10001", // 发送方用户 ID "toUserId": "10002", // 接收方用户 ID,群聊时填 groupId "content": "你好", // 文本内容 "timestamp": 1714900000 // 毫秒时间戳 }写消息时,先把这个 JSON 转成字节数组,再在前面拼上 4 字节的消息长度。读消息时,先读满 4 字节得到长度 N,再继续读 N 字节得到完整的 JSON 字符串,然后才做反序列化。这样粘包半包问题就全部交给长度头解决,业务代码里再也见不到「读半条消息」的诡异情况。
长度头的字节序要特别注意,很多坑就出在这。我的惯例是统一用大端序,Java 的DataOutputStream.writeInt()默认就是大端序,安卓端用相同 API,两边不会对不上。如果有一端是 C 或 Python 实现,记得确认对方用什么字节序。
2.3 数据层:两张表还是三张表,字段怎么定
简易聊天服务端需要考虑持久化的数据不多:用户、好友关系、离线消息。用户表必须有,好友关系表决定了你能跟谁聊,离线消息表是聊天记录和补推的源头。三张表是底线,缺了任意一张这个聊天 app 都不完整。建表脚本直接用标准 SQL 写成,方便在 MySQL 或 SQLite 之间切换:
-- 用户表:账号、密码、昵称、头像字段 CREATE TABLE t_user ( user_id INTEGER PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50) DEFAULT '', avatar VARCHAR(255) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 好友关系表:单向好友,一条记录代表一个关系 CREATE TABLE t_friend ( id INTEGER PRIMARY KEY AUTO_INCREMENT, user_id INTEGER NOT NULL, friend_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_friend (user_id, friend_id) ); -- 离线消息表:消息落库与补推 CREATE TABLE t_message ( msg_id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id INTEGER NOT NULL, to_user_id INTEGER NOT NULL, content TEXT, msg_type TINYINT DEFAULT 1, -- 1 文本 2 图片 3 语音 status TINYINT DEFAULT 0, -- 0 未读 1 已投递 2 已读 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_to_user_status (to_user_id, status) );密码字段我建议直接存加盐哈希,而不是明文。简易项目用SHA-256(username + password + salt)就够了,生产项目再去接 BCrypt。这里有个新手常犯的错:把密码字段设为VARCHAR(20),结果 BCrypt 哈希有 60 个字符,入库直接报错。密码字段至少给到VARCHAR(64)。t_friend表的联合唯一键uk_user_friend是防止重复加好友的兜底,一定要加,不加的话并发请求下好友列表会出现重复记录。
离线消息表用status标识消息投递状态,每次客户端上线后服务端查询status = 0的消息补推,推完批量更新为已投递。这个字段是离线消息方案的核心,后面第 3 章会说清楚它的流转流程。如果你想要更好的扩展性,可以把msg_type拆成独立的表存媒体文件 URL,但简易版一个字段就够了。
3. 核心代码落位:从启动入口到离线补推的完整链路
3.1 启动入口:端口、线程池与全局会话管理器
服务端的启动入口要做四件事:加载配置、初始化数据库连接池、绑定端口、开始接受连接。聊天服务端和 Web 服务不同,它没有 Spring Boot 那一套自动装配,手动管理生命周期反而让逻辑更透明。下面是启动类的核心代码:
public class ChatServer { private static final int PORT = 8899; // 全局会话管理器:userId -> 连接会话 private static final ConcurrentHashMap<Integer, Session> sessionMap = new ConcurrentHashMap<>(); public static void main(String[] args) throws IOException { // 初始化数据库连接池(简易版用 Druid 或 HikariCP) DbManager.init(); // 清理上次异常断线留下的在线状态 DbManager.resetAllOnlineStatus(); ExecutorService bossPool = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors()); ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("聊天服务端已启动,端口:" + PORT); while (true) { Socket socket = serverSocket.accept(); // 每个连接交给独立线程处理,避免阻塞接入循环 bossPool.submit(new ClientHandler(socket)); } } // 会话注册与移除 public static void addSession(int userId, Session session) { sessionMap.put(userId, session); } public static void removeSession(int userId) { sessionMap.remove(userId); } public static Session getSession(int userId) { return sessionMap.get(userId); } }代码的逻辑很直白:主线程死循环accept()新连接,每来一个连接就丢给线程池处理,主线程永远不阻塞。sessionMap是全局的核心数据结构,以userId为键,值为一条活跃连接。查在线、踢下线、转发消息都靠它。这里有两个参数需要你根据实际情况调:端口PORT默认 8899,如果你的服务器上已有其他服务占用 8899,换一个 8000-9000 区间内空闲的端口;线程池大小用availableProcessors()作为基础值,对简易项目够了——它表示 CPU 核数,每个核跑一个处理线程不会过载。
注意:如果操作系统是 Windows,
accept()在端口被占用时会直接抛BindException,启动日志里看到Address already in use就是端口冲突。用netstat -ano | findstr 8899查占用进程,换端口或杀掉进程即可。
3.2 消息分发:登录、单聊、群聊的 handler 设计
ClientHandler是每个连接的骨干,它做的事情可以拆成一条清晰的流水线:读消息 → 解析类型 → 分发处理。登录验证通过后,这个连接正式成为「在线会话」,可以收发消息;登录失败则直接关闭连接。下面是ClientHandler的完整实现骨架:
public class ClientHandler implements Runnable { private Socket socket; private DataInputStream in; private DataOutputStream out; private volatile boolean running = true; private int currentUserId; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try { in = new DataInputStream(socket.getInputStream()); out = new DataOutputStream(socket.getOutputStream()); // 登录超时:10 秒内没有发来 LOGIN 就断开 socket.setSoTimeout(10000); while (running) { int len = in.readInt(); // 读 4 字节长度头 byte[] body = new byte[len]; in.readFully(body); // 确保读满 len 字节 String json = new String(body, StandardCharsets.UTF_8); Message msg = GsonHolder.GSON.fromJson(json, Message.class); switch (msg.getType()) { case "LOGIN": handleLogin(msg); break; case "CHAT": handleChat(msg); break; case "HEARTBEAT": handleHeartbeat(); break; case "LOGOUT": handleLogout(); break; default: sendAck(msg, "UNSUPPORTED_TYPE"); } } } catch (SocketTimeoutException e) { System.out.println("用户登录超时,断开连接: " + socket.getRemoteSocketAddress()); } catch (IOException e) { System.out.println("连接异常断开: " + e.getMessage()); } finally { close(); } } private void handleLogin(Message msg) throws IOException { User user = UserDao.findByUsernameAndPassword(msg.getUsername(), msg.getPassword()); if (user == null) { sendError("用户名或密码错误"); running = false; // 校验失败直接关闭连接 return; } currentUserId = user.getUserId(); // 同一个账号踢掉旧连接,防重复登录 Session oldSession = ChatServer.getSession(currentUserId); if (oldSession != null) { oldSession.sendKickNotice(); oldSession.close(); } ChatServer.addSession(currentUserId, new Session(socket, out, currentUserId)); sendAck(msg, "LOGIN_OK"); // 拉取离线消息并补推 List<Message> offlineMsgs = MessageDao.pollOfflineMessages(currentUserId); for (Message m : offlineMsgs) { send(json(m)); // 逐条发送 } MessageDao.markDelivered(currentUserId); // 批量更新为已投递 } private void handleChat(Message msg) throws IOException { if (currentUserId == 0) { sendError("未登录,请先登录"); return; } // 单聊:目标是 toUserId Session target = ChatServer.getSession(msg.getToUserId()); if (target != null && target.isActive()) { msg.setStatus(1); // 已投递 target.send(json(msg)); } else { // 对方不在线,落库为离线消息 msg.setStatus(0); MessageDao.saveMessage(msg); } // 回 ACK,让客户端知道消息已到达服务端 sendAck(msg, "DELIVERED"); } private void handleHeartbeat() { // 更新最后活跃时间,供超时判定使用 ChatServer.getSession(currentUserId).updateLastActiveTime(); out.writeBytesWrapper(null); // 心跳不额外回包,减少流量 } }这段代码是服务端的心脏。readInt()读长度头,readFully保证读满消息体,两个方法配合就把粘包半包挡住了。socket.setSoTimeout(10000)是登录超时,防止有人连上了不发数据占着连接不放手——这是个非常实用的防呆设计,不设的话,恶意连接可以直接把你的线程池拖垮。
handleLogin里有一个关键操作:同一个账号的旧连接要被踢下线。这个逻辑不做,用户会在两台手机上登录同一个账号,消息随机推送到其中一台,体验极其分裂。踢掉旧连接的实现是拿到oldSession后先发一个踢下线通知,再关闭连接。安卓端收到这个通知会提示「账号在其他设备登录」。handleChat里的分支是聊天的核心:目标在线就转发,目标离线就落库。注意我用target.isActive()而不是单纯判断target != null,因为在连接关闭但尚未从 sessionMap 移除时,连接对象是存在的但不可用,不判断的话会把消息写进一个已经关闭的 socket,抛出SocketException。
3.3 离线消息:落库与上线补推的完整流程
离线消息是简易聊天服务端里最容易被做砸的一块。新手常犯的错是:用户一下线就把消息丢弃,或者上线时把整张消息表全量拉出来。前者丢消息,后者消息一多直接把客户端内存打爆。正确的做法是:消息入库时打上status=0的标记,用户登录成功后只拉取属于他的未读消息,推完再统一更新状态。为了给这个流程兜底,补推的逻辑要保证消息不重不漏,下面是用 JDBC 实现的关键代码:
public class MessageDao { // 保存离线消息 public static boolean saveMessage(Message msg) { String sql = "INSERT INTO t_message (from_user_id, to_user_id, content, msg_type, status) VALUES (?, ?, ?, ?, 0)"; try (Connection conn = DbManager.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, msg.getFromUserId()); ps.setInt(2, msg.getToUserId()); ps.setString(3, msg.getContent()); ps.setInt(4, msg.getMsgType() == null ? 1 : msg.getMsgType()); return ps.executeUpdate() == 1; } catch (SQLException e) { e.printStackTrace(); return false; } } // 拉取某用户的全部未读消息,按时间排序返回 public static List<Message> pollOfflineMessages(int userId) { List<Message> list = new ArrayList<>(); String sql = "SELECT * FROM t_message WHERE to_user_id = ? AND status = 0 ORDER BY msg_id ASC"; try (Connection conn = DbManager.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Message m = new Message(); m.setMsgId(rs.getLong("msg_id")); m.setFromUserId(rs.getInt("from_user_id")); m.setToUserId(rs.getInt("to_user_id")); m.setContent(rs.getString("content")); m.setMsgType(rs.getInt("msg_type")); m.setTimestamp(rs.getDate("created_at").getTime()); list.add(m); } } } catch (SQLException e) { e.printStackTrace(); } return list; } // 将某用户的所有未读消息标记为已投递 public static void markDelivered(int userId) { String sql = "UPDATE t_message SET status = 1 WHERE to_user_id = ? AND status = 0"; try (Connection conn = DbManager.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } }这个 DAO 用了标准的 JDBCPreparedStatement,注意两点:所有资源都放进try-with-resources,保证连接不泄漏——这是「连接泄漏」这类血泪经验的根源,一个连接忘了关,跑一晚上服务端就假死;pollOfflineMessages用ORDER BY msg_id ASC排序,保证补推的消息按发送先后到达客户端,否则客户端上消息顺序是乱的,这比丢消息还让人难受。
提示:
status=0拉取出来以后客户端收到消息,服务端立即调用markDelivered更新。如果你的消息要支持「已读回执」,再增加一个status=2状态,由客户端显式上报「我已经看到了某条消息」。
4. 避坑点什么:TCP 粘包、心跳玄学和线程阻塞的 5 个现场
4.1 现象:服务端收到一堆乱码,JSON 解析直接抛异常
原因:这是最经典的粘包半包问题。客户端连续发了两条聊天消息,TCP 把两个数据包合并成一个发送,服务端按「读 4 字节长度 + 读对应字节」的逻辑去解,读到的长度和实际内容对不上,Gson解析就崩了;或者一条消息被拆成两个 TCP 包,readFully没读够就返回了,解出来的 JSON 是不完整的。解决:严格用DataInputStream.readInt()读长度头,再用readFully读消息体。readFully会阻塞直到读满指定字节数,天然解决半包;长度头保证每次消费的消息边界正好是一条完整消息,天然解决粘包。千万不要用in.read(byte[])代替readFully——read不保证读满你给的数组长度,这是最容易踩的位置。
4.2 现象:安卓客户端频繁掉线,日志里全是SocketTimeoutException
原因:这是个隐蔽的坑——我设置了socket.setSoTimeout(10000)作为登录超时,但登录成功后没有把它取消或延长。结果就是:任何一条连接只要 10 秒内没有数据往来就会被服务端主动断开,客户端那边表现为「读超时、连接被关闭」。安卓端的用户正打着字呢,连接就断了。解决:登录验证通过后,立即执行socket.setSoTimeout(0),把超时关掉;或者改成socket.setSoTimeout(30000)作为心跳超时的兜底,配合心跳机制使用,见下一条。
4.3 现象:心跳包一直在发,服务端还是把连接断了
原因:心跳包处理起来有个反直觉的地方。很多初版代码在handleHeartbeat里只更新内存里的lastActiveTime,但这个超时判定如果有独立线程在做,它读的是lastActiveTime,而那个值在并发下没有用volatile修饰,线程间不可见,判定线程看到的一直是旧值,就误判超时了。解决:Session里的lastActiveTime用volatile long修饰;超时判定线程每 30 秒扫描一次 sessionMap,把当前时间 - lastActiveTime > 60秒的连接强制关闭。心跳间隔设 30 秒,超时阈值设 60 秒,比一倍多一点,给网络抖动留余量。客户端心跳用安卓的Handler.postDelayed做定时任务就行,不要在 UI 线程里发,网络 IO 放到子线程。
4.4 现象:服务端内存暴涨,然后「假死」,CPU 100%
原因:某个连接进入死循环,或者线程池满了排不上队。查下来十有八九是连接流没关——IOException一抛,后续的close()没执行,socket、输入流输出流、线程全部泄漏。另一个高频场景是:有人把ConcurrentHashMap随意嵌套使用,在线用户多的时候并发写冲突导致死锁,表现就是 CPU 打满、请求全部超时。解决:ClientHandler的finally块里必须close(),关闭顺序是输出流 → 输入流 → socket;同时要在关闭前调用ChatServer.removeSession(currentUserId),不然 sessionMap 里残留一个死会话,转发的消息全写进一个不存在的 socket。线程池的拒绝策略也配上CallerRunsPolicy,让线程池满的时候由主线程直接执行而不是抛RejectedExecutionException,至少消息不会丢得无声无息。
4.5 现象:消息发过去了,对方一直收不到
原因:离线消息框架里的一个经典遗漏——用户 A 给用户 B 发消息,B 在线但连接已经失效(比如手机切了网络),sessionMap里有 B 的记录,target.isActive()也返回 true,但消息写进 socket 后因为网络已断,写失败只抛了一个IOException,被catch吞了没做处理,消息既没送达也没落库。解决:在输出流写失败时,要把目标用户从sessionMap移除,然后把这条消息转存离线表。更简单可靠的方案是:写消息成功后等客户端回一个应用层的ACK,比如收到DELIVERED回执才确认送达,否则重试或落库。简易项目里我一般不做 ACK 重传,但至少要在失败分支里手动转离线:catch 到 IOException 就调MessageDao.saveMessage(msg),这样消息不会丢。
5. 验证与演进:从单机压测到缓存与网关的下一步
验证这套服务端,不必急着写安卓端。用命令行工具nc先测通登录,再写一段简单的 Java 客户端脚本验证单聊和离线消息,能省很多调试时间。我是这么干的:先nc -v 127.0.0.1 8899确认端口通着,然后写一个测试类,模拟两个客户端。
// 模拟客户端:登录后互相发一条消息 public class MockClient { public static void main(String[] args) throws Exception { Socket socket = new Socket("127.0.0.1", 8899); DataOutputStream out = new DataOutputStream(socket.getOutputStream()); DataInputStream in = new DataInputStream(socket.getInputStream()); String login = "{\"type\":\"LOGIN\",\"username\":\"test01\",\"password\":\"123456\"}"; byte[] body = login.getBytes(StandardCharsets.UTF_8); out.writeInt(body.length); out.write(body); int len = in.readInt(); byte[] ack = new byte[len]; in.readFully(ack); System.out.println("登录响应: " + new String(ack)); String chat = "{\"type\":\"CHAT\",\"fromUserId\":1,\"toUserId\":2,\"content\":\"hello\"}"; body = chat.getBytes(StandardCharsets.UTF_8); out.writeInt(body.length); out.write(body); Thread.sleep(500); socket.close(); } }这个模拟客户端跑通后,整个服务端的主链路就验证完了。把test01换成test02再跑一次,就能看到离线消息补推的效果:用test01给test02发一条消息,然后test02登录,它会立刻收到这条消息。这一步验证完,你的服务端已经可以接真实安卓端了。进阶的排查手段是用jstack看线程状态——如果服务端「假死」,jstack <pid>能看出哪些线程卡在哪个方法上,这比瞎猜高效得多。
再往后走,这个简易架构的扩展方向有三个。第一,把 SQLite 换成 MySQL,再把MessageDao的 JDBC 操作换成 MyBatis-Plus 的ServiceImpl,省掉大量手写资源管理的样板代码——这也正是热词里用 Java 实体类直接生成建表 SQL 的典型场景。第二,在线状态和未读计数挪到 Redis,用SET存在线用户集合、用过期 key 做会话超时,减轻数据库压力。第三,线程模型从「一连接一线程」升级到 Netty 的 NIO 事件驱动,支持上万连接不换机器。
说实话,这个项目我有一次就是因为第 4.2 条那个setSoTimeout忘了取消,上了测试环境被安卓同事追着问「为什么聊着聊着就掉线」,排查了一个下午最后发现是自己埋的雷。后来我形成了个习惯:登录超时和业务超时永远分开两个变量配置,写完立即检查登录成功路径有没有把它们切换回来。做服务端,很多问题都不是复杂逻辑造成的,就是你埋下的一个个小状态没有清理干净。做聊天服务端尤其如此,因为长连接把一切都放大了。希望这些设计思路和踩坑记录能帮你把这个简易聊天服务端做得比预期更稳。
本文还有配套的精品资源,点击获取