局域网即时通信系统 | JavaSE

局域网即时通信系统

一、项目目标

前面我们已经分别学习了:

  • Swing GUI 与事件驱动;
  • TCP、Socket、ServerSocket;
  • IO 流;
  • 多线程与线程池;
  • Java 集合框架;
  • 日期时间 API;
  • 异常处理。

这一章不再孤立学习某一个 API,而是把这些 JavaSE 技术真正组合起来,完成一个可以在局域网中运行的 即时通信系统(Instant Messaging System)

系统采用经典的 C/S(Client/Server,客户端/服务器)架构:

客户端 A ──┐
客户端 B ──┼── TCP ──> 聊天服务器
客户端 C ──┘

最终系统需要实现:

  1. 用户输入昵称登录聊天室;
  2. 客户端与服务器建立 TCP 长连接;
  3. 多个客户端能够同时在线;
  4. 用户能够发送群聊消息;
  5. 服务端能够把消息广播给所有在线客户端;
  6. 在线用户发生变化时,所有客户端实时刷新在线列表;
  7. 客户端拥有 Swing 图形化聊天界面;
  8. 服务端能够处理客户端异常断开和正常下线;
  9. 理解一套简单的应用层通信协议如何被设计和实现。

完成本项目后,你应该真正理解下面这条完整链路:

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 验收

能够回答:

  1. ServerSocketSocket 分别负责什么?
  2. accept() 为什么会阻塞?
  3. 为什么每个客户端都会对应服务端的一个 Socket
  4. TCP 为什么适合本项目?
  5. 为什么连接建立后双方都可以发送和接收数据?

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 验收

不查看答案,独立排查以下问题:

  1. 客户端连接服务器出现 Connection refused
  2. 服务端出现 Address already in use
  3. 一个用户发送消息其他用户收不到;
  4. 客户端关闭后在线列表没有变化;
  5. GUI 登录后出现卡死;
  6. 服务端能够收到 type,但后续数据读取异常;
  7. 使用 127.0.0.1 时本机正常,另一台电脑却无法连接。

要求不是背异常名称,而是能够沿着:

GUI
 ↓
发送端
 ↓
协议
 ↓
Socket
 ↓
服务器
 ↓
业务处理
 ↓
广播
 ↓
接收端
 ↓
GUI

逐层定位问题。


9.10 最终闭卷任务

关闭本教程和 AI 自动补全。

从空项目开始,仅根据下面的需求独立实现:

开发一个局域网聊天室。用户输入昵称登录,多个客户端可以同时连接服务器;所有用户能够进行群聊;客户端实时显示在线用户;用户退出后在线列表自动刷新。

编码前必须先自行画出:

系统架构图
通信流程图
消息协议
类职责划分

然后再开始编码。

如果你已经能够独立完成这一过程,那么你掌握的就不再只是:

Socket怎么写

而是已经能够把:

面向对象
+ 集合框架
+ IO
+ 多线程
+ 网络编程
+ GUI
+ 异常处理
+ 日期时间API

组合成一个真正的软件系统。

这正是 JavaSE 综合实战最重要的意义。