局域网即时通信系统 | JavaSE
局域网即时通信系统
一、项目目标
前面我们已经分别学习了:
- Swing GUI 与事件驱动;
- TCP、Socket、ServerSocket;
- IO 流;
- 多线程与线程池;
- Java 集合框架;
- 日期时间 API;
- 异常处理。
这一章不再孤立学习某一个 API,而是把这些 JavaSE 技术真正组合起来,完成一个可以在局域网中运行的 即时通信系统(Instant Messaging System)。
系统采用经典的 C/S(Client/Server,客户端/服务器)架构:
客户端 A ──┐
客户端 B ──┼── TCP ──> 聊天服务器
客户端 C ──┘
最终系统需要实现:
- 用户输入昵称登录聊天室;
- 客户端与服务器建立 TCP 长连接;
- 多个客户端能够同时在线;
- 用户能够发送群聊消息;
- 服务端能够把消息广播给所有在线客户端;
- 在线用户发生变化时,所有客户端实时刷新在线列表;
- 客户端拥有 Swing 图形化聊天界面;
- 服务端能够处理客户端异常断开和正常下线;
- 理解一套简单的应用层通信协议如何被设计和实现。
完成本项目后,你应该真正理解下面这条完整链路:
GUI事件
↓
客户端业务逻辑
↓
DataOutputStream
↓
Socket
↓
TCP网络
↓
ServerSocket / Socket
↓
服务端线程
↓
协议解析
↓
业务处理
↓
广播消息
↓
客户端接收线程
↓
更新GUI
这已经不再是简单的“Socket Demo”。
它是一个真正具有:
客户端
+
服务端
+
通信协议
+
并发处理
+
状态管理
+
GUI
的软件系统雏形。
二、需求分析
2.1 用户登录
程序启动后首先显示登录窗口。
用户只需要输入:
昵称
例如:
星雨
然后点击:
登录
客户端完成:
创建 Socket
↓
连接服务器
↓
发送登录消息
↓
发送昵称
↓
打开聊天室窗口
本项目属于 JavaSE 综合训练,不引入数据库,因此暂时不设计:
- 用户名密码;
- 注册;
- 用户表;
- Token;
- Session;
- 权限认证。
登录的本质只是:
为当前 Socket 连接绑定一个昵称。
2.2 聊天窗口
登录成功后进入聊天界面。
聊天窗口主要由三部分组成:
┌─────────────────────────────────────────┐
│ │
│ 群聊消息区域 │ 在线用户
│ │ 张三
│ │ 李四
│ │ 星雨
├─────────────────────────────────────────┤
│ 消息输入框 [发送] │
└─────────────────────────────────────────┘
需要包含:
- 消息显示区域;
- 消息输入区域;
- 发送按钮;
- 在线用户列表。
2.3 群聊需求
例如现在有三个客户端:
张三
李四
星雨
星雨发送:
大家好,我正在学习 Java 网络编程。
消息首先发送到服务器:
星雨客户端
↓
服务器
服务器不能只把消息返回给星雨,而需要遍历全部在线 Socket:
服务器
├─→ 张三
├─→ 李四
└─→ 星雨
于是所有客户端都会显示:
星雨 2026-09-04 10:30:00
大家好,我正在学习 Java 网络编程。
这种模式叫:
广播(Broadcast)。
2.4 在线用户列表
服务器必须维护:
当前有哪些客户端在线?
每个 Socket 对应什么昵称?
可以建立这样的映射关系:
Socket A → 张三
Socket B → 李四
Socket C → 星雨
因此核心数据结构可以抽象成:
Map<Socket, String>
每当:
用户登录
用户退出
网络连接异常断开
服务器都需要重新广播在线用户列表。
2.5 非功能需求
除了“功能能跑”,还应该考虑基本的软件质量要求。
多客户端
服务器不能只能处理一个用户。
必须能够:
客户端1
客户端2
客户端3
...
同时连接服务器。
实时接收消息
客户端不能只有点击发送按钮时才读取数据。
否则服务器主动推送的数据无法及时显示。
因此客户端需要一个独立线程:
GUI线程
↓
处理界面和用户操作
ClientReaderThread
↓
持续等待服务器消息
异常下线
例如用户:
- 直接关闭聊天窗口;
- 网络断开;
- 电脑掉线;
- 程序异常退出。
服务端应该移除对应 Socket,并通知其他客户端更新在线列表。
三、技术选型
本项目坚持 JavaSE 技术栈,不引入 Spring、MySQL、Redis 等 Java Web 技术。
| 需求 | 技术 |
| ------------ | ------------------------ |
| 图形界面 | Swing |
| 网络通信 | TCP |
| 客户端连接 | Socket |
| 服务端监听 | ServerSocket |
| 数据发送 | DataOutputStream |
| 数据接收 | DataInputStream |
| 多客户端处理 | Thread / ExecutorService |
| 在线用户管理 | Map |
| 时间显示 | LocalDateTime |
| 消息拼接 | StringBuilder |
| GUI 事件 | ActionListener / Lambda |
整体关系:
Swing
↓
Socket
↓
TCP
↓
ServerSocket
↓
多线程
↓
Map管理在线用户
↓
IO流广播数据
为什么使用 TCP?
即时聊天最重要的要求之一是:
消息可靠到达。
TCP 提供:
- 面向连接;
- 可靠传输;
- 有序传输;
- 字节流通信。
如果:
A发送:
Java
Network
Programming
TCP负责保证数据可靠、有序地传递。
UDP 更适合:
- 可以容忍一定数据丢失;
- 更强调低延迟;
- 不希望维持连接;
的场景。
对于这个教学版聊天系统,TCP 更合适。
四、系统设计
4.1 整体架构
系统分成两个程序:
chat-server
chat-client
整体结构:
┌─────────────────────┐
│ Server │
│ │
客户端A ─── Socket ───→│ ServerSocket │
客户端B ─── Socket ───→│ │
客户端C ─── Socket ───→│ 在线Socket集合 │
│ │
│ ClientHandler × N │
└─────────────────────┘
4.2 服务端职责
服务端负责:
监听端口
↓
接收Socket连接
↓
为客户端分配处理任务
↓
读取消息类型
↓
解析消息
↓
执行对应业务
主要业务包括:
- 用户登录;
- 用户下线;
- 在线用户同步;
- 群聊消息转发。
4.3 客户端职责
客户端负责:
显示登录窗口
↓
连接服务器
↓
发送昵称
↓
打开聊天窗口
↓
启动接收线程
↓
发送 / 接收消息
↓
更新GUI
4.4 为什么需要通信协议?
Socket 本身只负责传输字节。
服务器收到:
1
星雨
它怎么知道这是登录?
又比如:
2
Hello
怎么知道这是群聊?
因此客户端和服务器必须提前约定:
数据按照什么格式发送。
这就是一个最简单的:
应用层通信协议(Application Layer Protocol)。
本项目定义:
| type | 含义 |
| ---: | ---------------------- |
| 1 | 登录 / 在线用户列表 |
| 2 | 群聊消息 |
| 3 | 私聊消息,作为扩展预留 |
注意这里 1 在两个方向中的语义不同:
客户端 → 服务端
1 = 登录
服务端 → 客户端
1 = 在线用户列表
这是教学项目中一种简单协议设计。
生产系统通常会设计更加明确的消息结构,例如:
LOGIN
ONLINE_USERS
GROUP_MESSAGE
PRIVATE_MESSAGE
LOGOUT
甚至统一定义:
{
"type": "GROUP_MESSAGE",
"sender": "星雨",
"content": "Hello"
}
但本章暂时不引入 JSON 库,把重点放在 JavaSE 本身。
4.5 登录协议
客户端发送:
int 1
UTF nickname
对应代码:
dos.writeInt(1);
dos.writeUTF(nickname);
服务器按照相同顺序读取:
int type = dis.readInt();
String nickname = dis.readUTF();
必须特别注意:
网络协议不仅要求双方“字段一样”,还要求读取顺序与写入顺序完全一致。
如果客户端:
writeInt();
writeUTF();
服务端却:
readUTF();
readInt();
双方协议就彻底错位。
4.6 群聊协议
客户端:
2
聊天内容
发送:
dos.writeInt(2);
dos.writeUTF(msg);
服务端收到后:
读取type
↓
type == 2
↓
读取msg
↓
拼接昵称和时间
↓
遍历全部在线客户端
↓
广播
服务器再向客户端发送:
2
完整聊天消息
4.7 在线列表协议
服务器发送:
1
在线人数
昵称1
昵称2
昵称3
...
例如:
1
3
张三
李四
星雨
对应:
dos.writeInt(1);
dos.writeInt(3);
dos.writeUTF("张三");
dos.writeUTF("李四");
dos.writeUTF("星雨");
客户端:
int type = dis.readInt();
int count = dis.readInt();
for (int i = 0; i < count; i++) {
String name = dis.readUTF();
}
4.8 项目目录
可以按照下面的结构创建:
lan-chat
├─ server
│ ├─ Constant.java
│ ├─ Server.java
│ └─ ServerReaderThread.java
│
└─ client
├─ App.java
├─ Constant.java
├─ ChatEntryFrame.java
├─ ClientChatFrame.java
└─ ClientReaderThread.java
五、核心功能实现
5.1 服务端常量
package com.xingyu.chat.server;
public final class Constant {
private Constant() {
}
public static final int PORT = 6666;
}
端口:
6666
只是本项目自定义端口。
客户端必须连接到相同端口。
5.2 服务端主程序
package com.xingyu.chat.server;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Server {
// Socket -> nickname
public static final Map<Socket, String> ONLINE_SOCKETS =
new ConcurrentHashMap<>();
private static final ExecutorService POOL =
Executors.newFixedThreadPool(20);
public static void main(String[] args) {
System.out.println("聊天服务器启动...");
try (ServerSocket serverSocket =
new ServerSocket(Constant.PORT)) {
while (true) {
System.out.println("等待客户端连接...");
Socket socket = serverSocket.accept();
System.out.println(
"客户端连接成功:" +
socket.getRemoteSocketAddress()
);
POOL.submit(
new ServerReaderThread(socket)
);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
核心代码仍然是:
Socket socket = serverSocket.accept();
accept() 会等待客户端建立连接。
如果没有客户端连接:
服务器线程
↓
阻塞在accept()
客户端执行:
new Socket(serverIp, 6666);
以后,TCP 连接建立:
客户端Socket
↕
服务端Socket
随后将这个 Socket 交给线程池。
5.3 为什么不能直接在主线程处理聊天?
假设写成:
while (true) {
Socket socket = serverSocket.accept();
while (true) {
// 一直读取这个客户端
}
}
第一个客户端连接以后:
主线程
↓
一直处理客户端A
于是:
客户端B
客户端C
客户端D
无法及时得到处理。
因此必须实现:
主线程
负责accept()
工作线程
负责每一个客户端的通信
结构变成:
┌─ ClientHandler A
Server ───────┼─ ClientHandler B
├─ ClientHandler C
└─ ClientHandler D
这正是前面:
多线程 + 网络编程
真正组合起来的地方。
5.4 服务端客户端处理任务
package com.xingyu.chat.server;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
public class ServerReaderThread implements Runnable {
private final Socket socket;
public ServerReaderThread(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
DataInputStream dis =
new DataInputStream(
socket.getInputStream()
);
while (true) {
int type = dis.readInt();
switch (type) {
case 1 -> login(dis);
case 2 -> groupChat(dis);
case 3 -> {
// 私聊功能在扩展部分实现
}
default ->
System.out.println(
"未知消息类型:" + type
);
}
}
} catch (Exception e) {
System.out.println(
"客户端下线:" +
socket.getRemoteSocketAddress()
);
} finally {
Server.ONLINE_SOCKETS.remove(socket);
broadcastOnlineUsers();
try {
socket.close();
} catch (Exception ignored) {
}
}
}
private void login(DataInputStream dis)
throws Exception {
String nickname = dis.readUTF();
Server.ONLINE_SOCKETS.put(
socket,
nickname
);
System.out.println(
nickname + " 登录成功"
);
broadcastOnlineUsers();
}
private void groupChat(DataInputStream dis)
throws Exception {
String msg = dis.readUTF();
broadcastMessage(msg);
}
private void broadcastMessage(String msg) {
String nickname =
Server.ONLINE_SOCKETS.get(socket);
LocalDateTime now =
LocalDateTime.now();
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss"
);
String result =
nickname
+ " "
+ now.format(formatter)
+ "\n"
+ msg
+ "\n\n";
for (Socket target :
Server.ONLINE_SOCKETS.keySet()) {
try {
synchronized (target) {
DataOutputStream dos =
new DataOutputStream(
target.getOutputStream()
);
dos.writeInt(2);
dos.writeUTF(result);
dos.flush();
}
} catch (Exception e) {
System.out.println(
"消息发送失败:" +
target.getRemoteSocketAddress()
);
}
}
}
private void broadcastOnlineUsers() {
Collection<String> users =
Server.ONLINE_SOCKETS.values();
for (Socket target :
Server.ONLINE_SOCKETS.keySet()) {
try {
synchronized (target) {
DataOutputStream dos =
new DataOutputStream(
target.getOutputStream()
);
dos.writeInt(1);
dos.writeInt(users.size());
for (String user : users) {
dos.writeUTF(user);
}
dos.flush();
}
} catch (Exception e) {
System.out.println(
"在线列表发送失败:" +
target.getRemoteSocketAddress()
);
}
}
}
}
5.5 登录的完整过程
例如:
昵称 = 星雨
客户端:
dos.writeInt(1);
dos.writeUTF("星雨");
服务器:
int type = dis.readInt();
得到:
1
然后:
String nickname = dis.readUTF();
得到:
星雨
接下来:
Server.ONLINE_SOCKETS.put(
socket,
nickname
);
在线状态变成:
Socket@001 → 星雨
最后调用:
broadcastOnlineUsers();
通知所有客户端:
有人上线了。
5.6 群聊广播
客户端 A:
星雨:Hello Java
客户端实际只需要发送:
2
Hello Java
因为服务器已经保存:
Socket A → 星雨
因此服务器可以根据:
Server.ONLINE_SOCKETS.get(socket)
得到发送者昵称。
服务器最终构造:
星雨 2026-09-04 10:35:20
Hello Java
再广播:
┌─→ A
A ─→ Server ────┼─→ B
└─→ C
这说明服务器不仅是:
“接收数据的机器”。
它承担的是:
中心消息路由(Message Routing)。
5.7 客户端配置
package com.xingyu.chat.client;
public final class Constant {
private Constant() {
}
public static final String SERVER_IP =
"127.0.0.1";
public static final int SERVER_PORT =
6666;
}
测试时:
127.0.0.1
表示服务器和客户端运行在同一台电脑。
真正局域网测试时需要修改成服务器电脑的局域网 IP,例如:
public static final String SERVER_IP =
"192.168.1.100";
因此:
127.0.0.1
并不代表“局域网服务器地址”。
它代表:
当前机器自己,即回环地址(Loopback Address)。
5.8 客户端启动类
package com.xingyu.chat.client;
public class App {
public static void main(String[] args) {
new ChatEntryFrame();
}
}
程序入口非常简单:
main()
↓
ChatEntryFrame
5.9 登录界面
package com.xingyu.chat.client;
import javax.swing.*;
import java.awt.*;
import java.io.DataOutputStream;
import java.net.Socket;
public class ChatEntryFrame extends JFrame {
private final JTextField nicknameField =
new JTextField(12);
private final JButton loginButton =
new JButton("登录");
private final JButton cancelButton =
new JButton("取消");
private Socket socket;
public ChatEntryFrame() {
initView();
initEvent();
setVisible(true);
}
private void initView() {
setTitle("星雨局域网聊天室");
setSize(380, 170);
setDefaultCloseOperation(
JFrame.EXIT_ON_CLOSE
);
setLocationRelativeTo(null);
setResizable(false);
JPanel root =
new JPanel(
new BorderLayout()
);
JPanel inputPanel =
new JPanel(
new FlowLayout(
FlowLayout.CENTER,
10,
20
)
);
inputPanel.add(
new JLabel("聊天昵称:")
);
inputPanel.add(
nicknameField
);
JPanel buttonPanel =
new JPanel(
new FlowLayout(
FlowLayout.CENTER
)
);
buttonPanel.add(loginButton);
buttonPanel.add(cancelButton);
root.add(
inputPanel,
BorderLayout.CENTER
);
root.add(
buttonPanel,
BorderLayout.SOUTH
);
add(root);
}
private void initEvent() {
loginButton.addActionListener(e -> {
String nickname =
nicknameField
.getText()
.trim();
if (nickname.isEmpty()) {
JOptionPane.showMessageDialog(
this,
"请输入聊天昵称"
);
return;
}
try {
login(nickname);
new ClientChatFrame(
nickname,
socket
);
dispose();
} catch (Exception ex) {
JOptionPane.showMessageDialog(
this,
"连接服务器失败:"
+ ex.getMessage()
);
}
});
cancelButton.addActionListener(
e -> System.exit(0)
);
}
private void login(String nickname)
throws Exception {
socket =
new Socket(
Constant.SERVER_IP,
Constant.SERVER_PORT
);
DataOutputStream dos =
new DataOutputStream(
socket.getOutputStream()
);
dos.writeInt(1);
dos.writeUTF(nickname);
dos.flush();
}
}
这里第一次真正把:
Swing事件
+
网络通信
结合起来。
用户点击:
登录
触发:
loginButton.addActionListener(...)
事件内部再执行:
new Socket(...)
从而完成网络连接。
5.10 聊天界面
package com.xingyu.chat.client;
import javax.swing.*;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.DataOutputStream;
import java.net.Socket;
public class ClientChatFrame extends JFrame {
private final JTextArea messageArea =
new JTextArea();
private final JTextArea inputArea =
new JTextArea(4, 40);
private final JList<String> onlineUsers =
new JList<>();
private final JButton sendButton =
new JButton("发送");
private final Socket socket;
public ClientChatFrame(
String nickname,
Socket socket
) {
this.socket = socket;
initView(nickname);
initEvent();
new ClientReaderThread(
socket,
this
).start();
setVisible(true);
}
private void initView(String nickname) {
setTitle(
nickname + " - 星雨聊天室"
);
setSize(760, 600);
setLayout(
new BorderLayout(5, 5)
);
setLocationRelativeTo(null);
setDefaultCloseOperation(
JFrame.DISPOSE_ON_CLOSE
);
messageArea.setEditable(false);
JScrollPane messageScroll =
new JScrollPane(messageArea);
JScrollPane inputScroll =
new JScrollPane(inputArea);
JScrollPane userScroll =
new JScrollPane(onlineUsers);
userScroll.setPreferredSize(
new Dimension(150, 500)
);
JPanel bottom =
new JPanel(
new BorderLayout(5, 5)
);
bottom.add(
inputScroll,
BorderLayout.CENTER
);
bottom.add(
sendButton,
BorderLayout.EAST
);
add(
messageScroll,
BorderLayout.CENTER
);
add(
userScroll,
BorderLayout.EAST
);
add(
bottom,
BorderLayout.SOUTH
);
}
private void initEvent() {
sendButton.addActionListener(
e -> sendMessage()
);
addWindowListener(
new WindowAdapter() {
@Override
public void windowClosed(
WindowEvent e
) {
try {
socket.close();
} catch (Exception ignored) {
}
}
}
);
}
private void sendMessage() {
String msg =
inputArea
.getText()
.trim();
if (msg.isEmpty()) {
return;
}
try {
synchronized (socket) {
DataOutputStream dos =
new DataOutputStream(
socket.getOutputStream()
);
dos.writeInt(2);
dos.writeUTF(msg);
dos.flush();
}
inputArea.setText("");
} catch (Exception e) {
JOptionPane.showMessageDialog(
this,
"消息发送失败"
);
}
}
public void updateOnlineUsers(
String[] users
) {
onlineUsers.setListData(users);
}
public void appendMessage(
String msg
) {
messageArea.append(msg);
messageArea.setCaretPosition(
messageArea
.getDocument()
.getLength()
);
}
}
5.11 为什么客户端也必须有线程?
假设没有:
ClientReaderThread
程序只能在:
用户主动执行某个操作
时读取服务器消息。
但聊天系统中的消息具有:
服务端主动推送
的特点。
例如:
李四发消息
此时星雨什么按钮都没有点击。
服务器依然必须主动向星雨客户端发送数据。
因此客户端需要一个线程始终执行:
while (true) {
int type = dis.readInt();
}
线程会阻塞等待:
服务器什么时候来新消息?
一旦收到数据:
立即处理。
这就是最简单的:
消息监听循环(Message Receiving Loop)。
5.12 客户端读取线程
package com.xingyu.chat.client;
import javax.swing.*;
import java.io.DataInputStream;
import java.net.Socket;
public class ClientReaderThread
extends Thread {
private final Socket socket;
private final ClientChatFrame window;
public ClientReaderThread(
Socket socket,
ClientChatFrame window
) {
this.socket = socket;
this.window = window;
}
@Override
public void run() {
try {
DataInputStream dis =
new DataInputStream(
socket.getInputStream()
);
while (true) {
int type =
dis.readInt();
switch (type) {
case 1 ->
readOnlineUsers(dis);
case 2 ->
readGroupMessage(dis);
default ->
System.out.println(
"未知服务端消息:" +
type
);
}
}
} catch (Exception e) {
SwingUtilities.invokeLater(
() ->
JOptionPane.showMessageDialog(
window,
"与服务器的连接已断开"
)
);
}
}
private void readGroupMessage(
DataInputStream dis
) throws Exception {
String msg =
dis.readUTF();
SwingUtilities.invokeLater(
() ->
window.appendMessage(msg)
);
}
private void readOnlineUsers(
DataInputStream dis
) throws Exception {
int count =
dis.readInt();
String[] users =
new String[count];
for (int i = 0;
i < count;
i++) {
users[i] =
dis.readUTF();
}
SwingUtilities.invokeLater(
() ->
window.updateOnlineUsers(
users
)
);
}
}
5.13 完整登录时序
当:
星雨点击登录
完整流程是:
ChatEntryFrame
│
│ new Socket()
▼
Server
│
│ accept()
▼
ServerReaderThread
▲
│
│ type = 1
│ nickname = 星雨
│
ChatEntryFrame
然后服务器:
ONLINE_SOCKETS.put(socket, "星雨")
随后:
broadcastOnlineUsers()
发送:
1
在线人数
所有昵称
所有客户端收到以后:
ClientReaderThread
↓
readOnlineUsers()
↓
ClientChatFrame
↓
JList刷新
5.14 完整群聊时序
星雨输入:
Hello Java
点击发送:
ClientChatFrame
↓
writeInt(2)
writeUTF("Hello Java")
↓
Socket
↓
ServerReaderThread
↓
readInt()
readUTF()
↓
broadcastMessage()
↓
遍历ONLINE_SOCKETS
↓
发送给A
发送给B
发送给C
↓
ClientReaderThread
↓
appendMessage()
这是整个项目最核心的数据流。
只要你真正能够脱离代码口述这条链路,本项目最重要的思想就已经掌握了。
六、关键技术解析
6.1 Socket 是什么?
可以把 Socket 理解成:
两台程序之间已经建立好的通信管道端点。
连接建立以后:
客户端 Socket
║
║ TCP
║
服务端 Socket
双方都可以:
socket.getInputStream();
socket.getOutputStream();
进行数据通信。
6.2 ServerSocket 和 Socket 的区别
ServerSocket:
负责监听端口、接收连接。
ServerSocket serverSocket =
new ServerSocket(6666);
然后:
Socket socket =
serverSocket.accept();
注意:
ServerSocket
不是直接负责与某个客户端聊天的Socket。
真正与客户端通信的是:
accept()
返回的:
Socket
可以理解为:
ServerSocket
↓
前台接待员
Socket
↓
专门负责某个客户的服务窗口
6.3 为什么使用 DataInputStream 和 DataOutputStream?
普通字节流:
InputStream
OutputStream
主要处理:
byte
而本项目需要频繁发送:
int
String
因此使用:
DataInputStream
DataOutputStream
非常方便。
例如:
dos.writeInt(2);
dos.writeUTF("Hello");
另一端可以直接:
int type = dis.readInt();
String msg = dis.readUTF();
不用自己把整数和字符串手动转换成 byte 数组。
6.4 为什么必须先发送 type?
因为 TCP 本质上只是:
字节流。
TCP 不知道:
这是登录。
这是群聊。
这是在线人数。
这些含义全部由应用程序自己定义。
因此:
writeInt(type);
实际上就是在告诉对方:
后面的数据应该按照哪一种协议解析。
这也是学习网络协议最重要的思想之一:
TCP
只负责运输
应用层协议
负责定义货物是什么意思
6.5 在线用户为什么适合使用 Map?
我们需要同时知道:
某个Socket是谁?
某个昵称对应哪个连接?
教学版至少需要:
Socket → nickname
因此:
Map<Socket, String>
非常自然。
例如:
Socket@001 → 张三
Socket@002 → 李四
Socket@003 → 星雨
登录:
map.put(socket, nickname);
下线:
map.remove(socket);
获取在线昵称:
map.values();
广播:
map.keySet();
同一个集合同时服务了:
身份绑定
在线状态
消息广播
三个需求。
6.6 为什么教程实现使用 ConcurrentHashMap?
原始教学实现中可以使用:
HashMap
帮助理解 Socket → 昵称 的映射。
但是当服务器同时存在多个客户端处理线程时,多个线程可能同时:
put
remove
遍历
同一个 Map。
因此完整项目更合理的实现是:
ConcurrentHashMap
它属于:
线程安全的并发 Map。
这也是集合框架与多线程知识第一次真正形成组合应用。
6.7 为什么发送时还进行了 synchronized?
假设两个服务端线程同时向客户端 A 发送:
线程1
在线用户列表
线程2
群聊消息
如果同时向同一个 Socket 输出流写数据,有可能破坏:
type + payload
原本应该保持的消息边界。
因此示例中:
synchronized (target) {
...
}
保证:
同一时刻只允许一个线程向同一个客户端连接写入一条完整消息。
这实际上已经涉及一个重要的网络服务器问题:
并发读写与消息完整性。
6.8 TCP 没有天然的“消息边界”
这是非常重要的一点。
TCP 是:
字节流协议(Byte Stream Protocol)。
假设发送:
消息A
消息B
TCP 本身只看到:
连续的一串字节
所以应用程序必须自己定义:
如何知道一条消息从哪里开始、在哪里结束?
本项目通过:
type
+
固定协议字段
解决这个问题。
例如群聊:
int type
UTF message
在线列表:
int type
int count
UTF name × count
只要发送端和接收端严格按照相同结构处理,就能够正确解析。
6.9 GUI 为什么不能直接由网络线程随意更新?
Swing 有一个非常重要的线程模型:
大多数 Swing 组件应该在 Event Dispatch Thread(EDT,事件派发线程)上更新。
但:
ClientReaderThread
不是 EDT。
因此更稳妥的方式是:
SwingUtilities.invokeLater(() -> {
window.appendMessage(msg);
});
相当于:
网络线程
↓
收到消息
↓
把GUI更新任务交给EDT
↓
EDT更新界面
这也是 GUI 编程与并发编程真正发生交汇的位置。
6.10 即时通信系统的本质
表面看这是:
聊天室。
从架构角度看,它实际上包含:
连接管理
+
用户状态管理
+
消息协议
+
消息路由
+
并发处理
+
客户端状态同步
+
事件驱动GUI
这几乎已经具备现代即时通信系统最基本的骨架。
微信、QQ、Discord、Slack 等大型系统当然复杂得多,但是最基础的数据流仍然可以抽象成:
Client
↓
Gateway / Server
↓
Message Routing
↓
Target Client
本项目正是在 JavaSE 层面第一次建立这个架构认知。
七、测试与 Debug
7.1 正确启动顺序
先启动:
Server
看到:
聊天服务器启动...
等待客户端连接...
再启动:
App
否则客户端可能出现:
Connection refused
因为:
客户端找到了目标机器
但对应端口没有服务器监听
7.2 单机测试
首先不要急着使用两台电脑。
在同一台电脑:
SERVER_IP = 127.0.0.1
连续启动三个客户端:
张三
李四
星雨
检查:
在线用户:
张三
李四
星雨
然后:
张三发送消息
三个聊天窗口都应该收到。
再关闭:
李四
剩余两个客户端的用户列表应该自动变成:
张三
星雨
7.3 真正局域网测试
假设:
电脑A
运行Server
电脑B
运行Client
首先在服务器电脑执行:
ipconfig
找到 IPv4,例如:
192.168.1.100
客户端配置:
public static final String SERVER_IP =
"192.168.1.100";
然后检查网络:
ping 192.168.1.100
通信模型:
电脑B
192.168.1.101
↓
局域网
↓
电脑A
192.168.1.100:6666
如果程序仍无法连接,还需要检查:
- 两台机器是否处于同一局域网;
- Windows 防火墙;
- 6666 端口是否被允许;
- 服务端是否真的已经启动;
- SERVER_IP 是否填写正确。
7.4 Connection refused
典型异常:
java.net.ConnectException:
Connection refused
重点检查:
Server启动了吗?
↓
IP正确吗?
↓
端口正确吗?
7.5 Address already in use
服务器出现:
BindException:
Address already in use
通常说明:
6666端口
已经被其他程序占用。
可能原因:
启动了两个Server
或者:
其他软件占用了6666
解决:
- 关闭旧服务器进程;
- 更换端口;
- 查找占用端口的程序。
7.6 在线用户不更新
检查整个协议链路:
客户端
writeInt(1)
writeUTF(nickname)
↓
服务端
readInt()
readUTF()
↓
ONLINE_SOCKETS.put()
↓
broadcastOnlineUsers()
↓
客户端
readInt()
readInt(count)
readUTF() × count
↓
JList.setListData()
不要只盯着 GUI。
网络程序 Debug 最重要的思维是:
沿数据流逐层定位。
7.7 消息发送后自己能看到,但别人看不到
重点检查服务端是否:
for (Socket target :
ONLINE_SOCKETS.keySet())
真正遍历全部客户端。
如果只写:
socket.getOutputStream()
那么你实际上只是在:
给当前客户端回消息。
而没有广播。
7.8 客户端关闭后在线用户仍然存在
检查:
finally {
ONLINE_SOCKETS.remove(socket);
}
或者异常处理逻辑是否执行。
连接关闭后,服务端读取:
readInt()
通常会发生异常或检测到连接终止。
此时应该完成:
移除Socket
↓
关闭资源
↓
重新广播在线用户
7.9 GUI 卡死
如果把:
while (true) {
dis.readInt();
}
直接放进 Swing 事件线程:
EDT
↓
readInt()
↓
等待网络数据
↓
界面无法响应
于是:
- 窗口拖不动;
- 按钮点不了;
- GUI 像“死机”。
所以网络读取必须交给独立线程。
7.10 协议错位
假设服务端发送:
dos.writeInt(1);
dos.writeInt(3);
dos.writeUTF("A");
dos.writeUTF("B");
dos.writeUTF("C");
客户端却:
int type = dis.readInt();
String msg = dis.readUTF();
此时就会错误解析。
必须牢记:
发送顺序
=
接收顺序
网络程序出现奇怪数据问题时,应首先检查:
双方通信协议是否完全一致。
7.11 空消息
发送之前应该:
String msg =
inputArea.getText().trim();
if (msg.isEmpty()) {
return;
}
否则聊天室可能出现大量:
空白消息
这是典型的:
输入校验。
八、项目扩展
完成基础版本之后,可以按照难度逐步升级。
8.1 私聊
协议已经预留:
3 = 私聊
可以设计:
客户端 → 服务端
3
目标昵称
消息内容
例如:
3
李四
今晚一起刷算法吗?
服务器收到以后不再遍历全部客户端,而是寻找:
nickname == 李四
对应的 Socket。
最终形成:
星雨
↓
Server
↓
李四
这就是:
点对点消息路由。
8.2 上下线系统消息
用户进入时广播:
【系统】星雨进入聊天室
用户退出时:
【系统】星雨离开聊天室
这样就开始出现:
普通消息
系统消息
不同消息模型。
8.3 昵称重复检测
当前:
Socket → nickname
允许:
张三
张三
同时存在。
可以增加:
昵称是否已经存在?
如果存在:
拒绝登录
此时协议需要新增:
LOGIN_SUCCESS
LOGIN_FAILED
8.4 聊天记录保存
利用已经学习的 File 和 IO:
聊天消息
↓
服务端
↓
BufferedWriter
↓
chat.log
实现:
2026-09-04 10:30:01 星雨:Hello
2026-09-04 10:30:04 张三:Hi
这样:
网络编程
+
IO
又进一步组合。
8.5 文件传输
可以新增:
4 = 文件消息
协议可能包含:
消息类型
文件名称
文件大小
文件二进制数据
此时就会真正涉及:
字节流
缓冲流
网络流
的综合使用。
8.6 心跳机制
现在服务器判断客户端离线主要依赖:
Socket读取失败
更加完整的即时通信系统通常会设计:
心跳(Heartbeat)。
例如客户端每隔一段时间发送:
PING
服务器返回:
PONG
如果长时间没有收到:
PING
服务器可以判断:
客户端可能已经掉线。
8.7 更完整的协议对象
现在:
1
2
3
属于最简单的数字协议。
后续可以设计:
public enum MessageType {
LOGIN,
LOGIN_SUCCESS,
ONLINE_USERS,
GROUP_MESSAGE,
PRIVATE_MESSAGE,
SYSTEM_MESSAGE,
LOGOUT
}
进一步抽象:
public class Message {
private MessageType type;
private String sender;
private String receiver;
private String content;
}
这意味着项目正在从:
能通信
走向:
有消息模型的通信系统。
8.8 从 JavaSE 聊天室到真正即时通信系统
本项目现在的架构是:
Swing Client
↓
TCP Socket
↓
Java Server
↓
内存Map
真正的大型即时通信系统则可能继续演进:
客户端
↓
网关集群
↓
消息服务
↓
用户服务
↓
消息队列
↓
Redis
↓
数据库
↓
离线消息
↓
推送系统
因此这个 JavaSE 项目虽然小,但已经建立了后续学习:
- Java Web;
- Netty;
- Redis;
- MQ;
- WebSocket;
- 分布式系统;
所需要的最基础通信模型。
九、项目验收
完成本项目以后,不应该以:
“代码复制下来能跑。”
作为掌握标准。
真正的验收要求如下。
9.1 功能验收
独立完成并验证:
- [ ] 能启动聊天服务器;
- [ ] 客户端能够连接服务器;
- [ ] 能输入昵称登录;
- [ ] 能同时启动至少三个客户端;
- [ ] 在线用户列表能够实时刷新;
- [ ] 任意客户端发送群聊消息后所有用户都能收到;
- [ ] 消息能够显示昵称;
- [ ] 消息能够显示时间;
- [ ] 客户端关闭后服务器能够删除其在线状态;
- [ ] 其他客户端的在线列表能够同步刷新。
9.2 架构验收
能够脱离代码画出:
Client
↓
Socket
↓
ServerSocket
↓
Server
↓
ClientHandler
↓
ONLINE_SOCKETS
并解释每个组件职责。
9.3 TCP 验收
能够回答:
ServerSocket与Socket分别负责什么?accept()为什么会阻塞?- 为什么每个客户端都会对应服务端的一个
Socket? - TCP 为什么适合本项目?
- 为什么连接建立后双方都可以发送和接收数据?
9.4 IO 验收
能够解释:
DataInputStream
DataOutputStream
为什么适合本项目。
能够独立写出:
dos.writeInt(2);
dos.writeUTF(msg);
dos.flush();
以及对应:
int type = dis.readInt();
String msg = dis.readUTF();
9.5 协议验收
能够脱离代码说明:
登录消息
群聊消息
在线用户消息
分别按照什么顺序发送。
并能够解释:
为什么发送端和接收端字段顺序必须完全一致?
9.6 多线程验收
能够解释:
为什么服务端不能只使用一个线程处理所有客户端?
以及:
为什么客户端必须单独启动一个接收消息线程?
能够画出:
Server Main Thread
│
├── Client Handler A
├── Client Handler B
└── Client Handler C
9.7 集合验收
能够解释:
Map<Socket, String>
为什么适合管理在线用户。
能够说明:
put()
remove()
keySet()
values()
在本项目分别解决什么问题。
进一步能够解释为什么并发服务端更适合使用:
ConcurrentHashMap
而不是直接共享普通:
HashMap
9.8 GUI 验收
能够独立说明:
JFrame
JTextArea
JList
JButton
JScrollPane
BorderLayout
在聊天室中的作用。
能够解释:
Swing事件线程
与:
网络读取线程
为什么不能完全混在一起。
9.9 Debug 验收
不查看答案,独立排查以下问题:
- 客户端连接服务器出现
Connection refused; - 服务端出现
Address already in use; - 一个用户发送消息其他用户收不到;
- 客户端关闭后在线列表没有变化;
- GUI 登录后出现卡死;
- 服务端能够收到
type,但后续数据读取异常; - 使用
127.0.0.1时本机正常,另一台电脑却无法连接。
要求不是背异常名称,而是能够沿着:
GUI
↓
发送端
↓
协议
↓
Socket
↓
服务器
↓
业务处理
↓
广播
↓
接收端
↓
GUI
逐层定位问题。
9.10 最终闭卷任务
关闭本教程和 AI 自动补全。
从空项目开始,仅根据下面的需求独立实现:
开发一个局域网聊天室。用户输入昵称登录,多个客户端可以同时连接服务器;所有用户能够进行群聊;客户端实时显示在线用户;用户退出后在线列表自动刷新。
编码前必须先自行画出:
系统架构图
通信流程图
消息协议
类职责划分
然后再开始编码。
如果你已经能够独立完成这一过程,那么你掌握的就不再只是:
Socket怎么写
而是已经能够把:
面向对象
+ 集合框架
+ IO
+ 多线程
+ 网络编程
+ GUI
+ 异常处理
+ 日期时间API
组合成一个真正的软件系统。
这正是 JavaSE 综合实战最重要的意义。