一个最小可扩展聊天室系统的设计与实现(Java + Swing + TCP)(2)

这一版的更新,说白了就是:我们终于把"聊天室"从一个只存在于内存里的即时通信小玩具,推到一个具备"可追溯性"和"可恢复性"的系统雏形------它不再只关心消息能不能发出去,而是开始关心"消息有没有被可靠地记录下来、以后还能不能再被拿回来"。如果把第一版理解成一次纯粹的 Socket 通信练习,那么第二版的重点已经悄悄变成了工程系统里非常真实的一件事:服务端必须承担状态与事实的最终责任。也正因为这个转变,你会发现代码结构的主角从"广播逻辑"和"UI 展示"慢慢移到了"协议边界""事件时机"和"持久化落盘"这种更偏后端的概念上。

最核心的变化,是引入了一个专门负责持久化的组件 ChatLog,它把"写文件"这件事从 ChatServer 的业务逻辑里剥离出来,让服务端不再到处散落着 Files.writeString 这样的 I/O 细节。这个拆分非常重要,因为它让系统重新获得了一个清晰的分层:ChatServer 只负责"什么时候应该记录",ChatLog 只负责"怎么记录得正确"。这种分工虽然看起来只是多了一个类,但工程意义很大------你以后想把 txt 换成 SQLite、MySQL、甚至消息队列,都不需要把服务端的协议处理和线程模型全重写,你只需要替换或扩展 ChatLog 的实现即可。换句话说,我们用非常小的代价,把"存储机制"变成了一个可插拔模块。

存储格式这次也刻意选成了 TSV(制表符分隔)并且按房间分文件,这背后的思路是"让日志既是机器友好的,也是人眼友好的"。一行日志完整描述一次事件:时间戳、房间号、类型、发送者、文本内容,任何字段都不靠隐式推断;同时这些字段又足够规整,以后你要做回放、过滤、统计,甚至直接用脚本导入数据库都很顺手。按房间分文件的意义则更直接:房间是聊天室系统里最天然的隔离边界,日志按房间切分意味着未来加载历史、清除历史、甚至做权限控制都会自然许多,而不是把所有房间混在一个巨大文件里再用字符串去硬筛。你现在已经能明显感受到:我们不只是"把消息写进了 txt",而是在用日志结构表达系统的业务模型。

这版最讲究的不是"写文件",而是"写的时机"。你会注意到我们把落盘放在了广播之前:先记录,再发送。这是一个很典型的工程取舍,它在语义上更强:只要用户能看到一条消息(因为服务器广播了),那条消息就必然已经被服务端写进历史记录里。反过来如果写入失败,服务器宁愿只打印日志失败而不因此把整个聊天室炸掉,这也是工程上更成熟的策略------日志属于增强能力,不应该成为让系统整体不可用的单点。这里其实隐含了一个更大的系统设计观:在不引入复杂事务的前提下,我们至少让"消息可追溯"这个目标尽可能接近事实。

线程与并发方面,这版也做了一个很现实的处理:文件写入必须避免被多个线程同时插队,否则你会得到乱序或内容互相穿插的"脏日志"。真正专业的做法会用单独日志线程 + 队列异步写入,但第二版我们先用"每房间一把锁"的方式解决本质问题:同一房间的写入严格串行,不同房间互不影响。这个粒度非常合适:既保证了一个房间内聊天记录的顺序性,也不会让全服务器的写入都被一把全局锁拖慢。你从这里可以学到一个很关键的工程直觉:并发控制不是越少越好,也不是越多越好,而是要沿着业务边界切锁------房间就是天然边界。

在体验层面,你提到的两个功能点------自动回放历史、手动清除历史------本质上都是建立在这套日志体系之上的"能力释放"。自动回放很自然:用户 JOIN 成功后,服务端读取房间日志最后 N 行,把它们按顺序发给新加入者,这样聊天室就从"瞬时通信"变成了"带上下文的房间"。清除则同样自然:只要服务端收到一个明确命令(比如 /clear),它就能在服务器端截断对应房间的日志文件,然后广播一条系统消息告诉所有人"历史已被清除"。这两个功能看起来像是 UI 需求,但实际上真正的权威行为都发生在服务端:记录在服务端生成,清除在服务端执行,客户端只是触发和展示。你会发现我们一直在坚持一个原则:客户端永远不应拥有"篡改事实"的能力,它最多提出请求,最终状态由服务端决定。这也是你从练习走向"系统设计"时最需要建立的那条底线。

而你遇到的那些"很多 bug",从根上看也正是因为系统开始具备模块边界之后,包名、职责归属、调用方向变得更敏感了。像 MessageType 的包名不一致、把 clearRoom() 错误地放进 ChatServer 这种问题,其实不是小失误,它们对应的是"模块边界被打穿"的症状:当你开始分包(common/server/client),代码的结构就不允许再像以前那样随手把功能塞进任何一个类里。你这次修复的过程,本质是在把边界重新扶正:协议类型归 common,日志行为归 server 的 ChatLog,聊天流程归 ChatServer,UI 行为归 client。结构一旦正确,很多 bug 会在编译期就被拦住;结构一旦混乱,问题就会以各种诡异的运行时方式反噬回来。这也是读源代码能提升技能的原因:你学到的不是某条 API,而是"每一层应该负责什么"。

所以这次更新最值得记住的并不是"多了 txt 文件",而是你已经让这个系统具备了一个真实后端系统的基本特征:事件被归档、状态可追溯、逻辑按职责分层、并发控制沿业务边界落地。下一步你如果想继续往前走,路线会非常清晰:从 TSV 日志进化到结构化存储(比如 SQLite),从"读最后 N 行"进化到按时间分页,从"任何人都能清除"进化到房主权限,从同步写入进化到异步队列写日志。你会发现这些升级都不需要推倒重来,因为你已经把最难的那一步做完了:把系统从"能跑"变成了"能演进"。

java 复制代码
package wt20260129_2.client;



import wt20260129_2.common.MessageType;
import wt20260129_2.common.Packet;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.io.*;
import java.net.Socket;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class ChatClientSwing {
    // ====== 网络 ======
    private Socket socket;
    private DataInputStream in;
    private DataOutputStream out;
    private Thread receiver;

    private volatile boolean joining = false;
    private volatile String pendingNickname = null;
    private volatile Integer pendingRoomId = null;


    // ====== 状态 ======
    private volatile int roomId = -1;
    private volatile String nickname = "me";

    // ====== UI ======
    private final JFrame frame = new JFrame("Mini Chat");
    private final CardLayout cards = new CardLayout();
    private final JPanel root = new JPanel(cards);

    // join 面板
    private final JTextField roomField = new JTextField(8);
    private final JTextField nameField = new JTextField(10);
    private final JButton joinBtn = new JButton("Join");

    // chat 面板
    private final JTextArea chatArea = new JTextArea();
    private final JTextField inputField = new JTextField();
    private final JButton sendBtn = new JButton("Send");
    private final JLabel topLabel = new JLabel("Not connected");

    public ChatClientSwing() {
        buildUI();
    }

    private void buildUI() {
        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        frame.setSize(640, 480);
        frame.setLocationRelativeTo(null);

        root.add(buildJoinPanel(), "JOIN");
        root.add(buildChatPanel(), "CHAT");

        frame.setContentPane(root);
        cards.show(root, "JOIN");
        frame.setVisible(true);

        // 默认值:更顺手
        roomField.setText("1234");
        nameField.setText("alice");
    }

    private JPanel buildJoinPanel() {
        JPanel p = new JPanel(new GridBagLayout());
        GridBagConstraints gc = new GridBagConstraints();
        gc.insets = new Insets(8, 8, 8, 8);
        gc.fill = GridBagConstraints.HORIZONTAL;

        JLabel title = new JLabel("Enter 4-digit RoomID to join");
        title.setFont(title.getFont().deriveFont(Font.BOLD, 18f));

        gc.gridx = 0; gc.gridy = 0; gc.gridwidth = 2;
        p.add(title, gc);

        gc.gridwidth = 1;
        gc.gridx = 0; gc.gridy = 1;
        p.add(new JLabel("RoomID:"), gc);

        gc.gridx = 1;
        p.add(roomField, gc);

        gc.gridx = 0; gc.gridy = 2;
        p.add(new JLabel("Nickname:"), gc);

        gc.gridx = 1;
        p.add(nameField, gc);

        gc.gridx = 0; gc.gridy = 3; gc.gridwidth = 2;
        joinBtn.addActionListener(this::onJoin);
        p.add(joinBtn, gc);

        JLabel hint = new JLabel("<html><div style='color:#666'>Server: 127.0.0.1:9999<br/>RoomID will be checked on send/receive.</div></html>");
        gc.gridy = 4;
        p.add(hint, gc);

        // 回车触发 join
        roomField.addActionListener(this::onJoin);
        nameField.addActionListener(this::onJoin);

        return p;
    }

    private JPanel buildChatPanel() {
        JPanel p = new JPanel(new BorderLayout(8, 8));
        p.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));

        topLabel.setFont(topLabel.getFont().deriveFont(Font.BOLD));
        p.add(topLabel, BorderLayout.NORTH);

        chatArea.setEditable(false);
        chatArea.setLineWrap(true);
        chatArea.setWrapStyleWord(true);
        p.add(new JScrollPane(chatArea), BorderLayout.CENTER);

        JPanel bottom = new JPanel(new BorderLayout(8, 8));
        bottom.add(inputField, BorderLayout.CENTER);
        bottom.add(sendBtn, BorderLayout.EAST);
        p.add(bottom, BorderLayout.SOUTH);

        sendBtn.addActionListener(this::onSend);
        inputField.addActionListener(this::onSend);

        return p;
    }

    private void onJoin(ActionEvent e) {
        String roomText = roomField.getText().trim();
        String nameText = nameField.getText().trim();
        if (!roomText.matches("\\d{4}")) {
            JOptionPane.showMessageDialog(frame, "RoomID must be exactly 4 digits (0000-9999).");
            return;
        }
        int rid = Integer.parseInt(roomText);
        if (nameText.isEmpty()) nameText = "user";

        if (joining) return; // 防止连点

        try {
            connectIfNeeded();

            joining = true;
            pendingRoomId = rid;
            pendingNickname = nameText;

            joinBtn.setEnabled(false);
            joinBtn.setText("Joining...");

            // 先发 JOIN,但不切界面
            Packet.send(out, MessageType.JOIN, rid, nameText);

        } catch (Exception ex) {
            joining = false;
            pendingRoomId = null;
            pendingNickname = null;
            joinBtn.setEnabled(true);
            joinBtn.setText("Join");
            JOptionPane.showMessageDialog(frame, "Connect/JOIN failed: " + ex.getMessage());
        }
    }


    private void onSend(ActionEvent e) {
        String text = inputField.getText().trim();
        if (text.isEmpty()) return;

        if (roomId == -1) {
            appendLine("ERROR: Not in a room.");
            return;
        }

        try {
            // 发送时也"检测/携带" RoomID
            Packet.send(out, MessageType.CHAT, roomId, text);
            inputField.setText("");
        } catch (Exception ex) {
            appendLine("ERROR: send failed: " + ex.getMessage());
        }
    }

    private void connectIfNeeded() throws IOException {
        if (socket != null && socket.isConnected() && !socket.isClosed()) return;

        socket = new Socket("127.0.0.1", 9999);
        in = new DataInputStream(new BufferedInputStream(socket.getInputStream()));
        out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));

        receiver = new Thread(this::recvLoop, "receiver");
        receiver.setDaemon(true);
        receiver.start();
    }

    private void recvLoop() {
        try {
            while (true) {
                Packet p = Packet.read(in);

                // 接收时再次检测 roomId(系统消息 roomId 可能为 0)
                if (p.roomId != 0 && roomId != -1 && p.roomId != roomId) {
                    // 不匹配:直接丢弃
                    continue;
                }

// === JOIN 流程:等待服务器确认 ===
                if (joining) {
                    if (p.type == MessageType.ERROR) {
                        joining = false;
                        pendingRoomId = null;
                        pendingNickname = null;

                        SwingUtilities.invokeLater(() -> {
                            joinBtn.setEnabled(true);
                            joinBtn.setText("Join");
                            JOptionPane.showMessageDialog(frame, p.text, "Join Failed", JOptionPane.ERROR_MESSAGE);
                        });
                        continue;
                    }

                    // 服务器在 JOIN 成功时会发 SYSTEM: "JOIN OK..."
                    if (p.type == MessageType.SYSTEM && p.text != null && p.text.startsWith("JOIN OK")) {
                        int rid = pendingRoomId == null ? -1 : pendingRoomId;
                        String name = pendingNickname == null ? "me" : pendingNickname;

                        roomId = rid;
                        nickname = name;

                        joining = false;
                        pendingRoomId = null;
                        pendingNickname = null;

                        SwingUtilities.invokeLater(() -> {
                            joinBtn.setEnabled(true);
                            joinBtn.setText("Join");
                            topLabel.setText("Room " + String.format("%04d", roomId) + " | " + nickname);
                            cards.show(root, "CHAT");
                            inputField.requestFocus();
                        });

                        // 把 JOIN OK 这条也显示出来
                        appendLine(p.text);
                        continue;
                    }
                }

// 非 JOIN 流程:正常显示
                String prefix = switch (p.type) {
                    case MessageType.SYSTEM -> "";
                    case MessageType.ERROR -> "ERROR: ";
                    default -> "";
                };
                appendLine(prefix + p.text);

            }
        } catch (Exception ex) {
            appendLine("SYSTEM: Disconnected (" + ex.getMessage() + ")");
        }
    }

    private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");



    private void appendLine(String line) {
        SwingUtilities.invokeLater(() -> {
            String ts = LocalDateTime.now().format(TS);
            chatArea.append("[" + ts + "] " + line + "\n");
            chatArea.setCaretPosition(chatArea.getDocument().getLength());
        });
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(ChatClientSwing::new);
    }
}
java 复制代码
package wt20260129_2.common;

public interface MessageType {
    int SYSTEM = 1;
    int ERROR  = 2;
    int JOIN   = 3;
    int CHAT   = 4;
}
java 复制代码
package wt20260129_2.common;

import java.io.*;

public class Packet {
    public final int type;
    public final int roomId;
    public final String text;

    public Packet(int type, int roomId, String text) {
        this.type = type;
        this.roomId = roomId;
        this.text = text == null ? "" : text;
    }

    public static void send(DataOutputStream out, int type, int roomId, String text) throws IOException {
        byte[] data = (text == null ? "" : text).getBytes("UTF-8");
        out.writeInt(type);
        out.writeInt(roomId);
        out.writeInt(data.length);
        out.write(data);
        out.flush();
    }

    public static Packet read(DataInputStream in) throws IOException {
        int type = in.readInt();
        int roomId = in.readInt();
        int len = in.readInt();
        if (len < 0 || len > 10_000_000) throw new IOException("Invalid packet length: " + len);

        byte[] data = new byte[len];
        in.readFully(data);
        String text = new String(data, "UTF-8");
        return new Packet(type, roomId, text);
    }
}
java 复制代码
package wt20260129_2.server;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;

public class ChatLog {
    private final Path baseDir;
    private final ConcurrentHashMap<Integer, Object> roomLocks = new ConcurrentHashMap<>();
    private final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public ChatLog(String dir) {
        this.baseDir = Paths.get(dir);
    }

    public void appendChat(int roomId, String from, String text) {
        append(roomId, "CHAT", from, text);
    }

    public void appendSystem(int roomId, String text) {
        append(roomId, "SYSTEM", "SYSTEM", text);
    }

    private void append(int roomId, String type, String from, String text) {
        // 每个 room 一把锁,保证同房间写入顺序不乱
        Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object());
        synchronized (lock) {
            try {
                Files.createDirectories(baseDir);

                String ts = LocalDateTime.now().format(fmt);
                String safeFrom = sanitize(from);
                String safeText = sanitize(text);

                // TSV: ts \t room \t type \t from \t text
                String line = ts + "\t" + String.format("%04d", roomId) + "\t" + type + "\t" + safeFrom + "\t" + safeText + "\n";

                Path file = baseDir.resolve("room-" + String.format("%04d", roomId) + ".txt");
                Files.writeString(
                        file,
                        line,
                        StandardCharsets.UTF_8,
                        StandardOpenOption.CREATE,
                        StandardOpenOption.WRITE,
                        StandardOpenOption.APPEND
                );
            } catch (IOException e) {
                // 日志失败不应该炸服务器,只打印一下
                System.out.println("[LOG] write failed: " + e.getMessage());
            }
        }
    }

    public void clearRoom(int roomId) {
        Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object());
        synchronized (lock) {
            try {
                Files.createDirectories(baseDir);
                Path file = baseDir.resolve("room-" + String.format("%04d", roomId) + ".txt");
                Files.writeString(
                        file,
                        "",
                        StandardCharsets.UTF_8,
                        StandardOpenOption.CREATE,
                        StandardOpenOption.TRUNCATE_EXISTING
                );
            } catch (IOException e) {
                System.out.println("[LOG] clear failed: " + e.getMessage());
            }
        }
    }


    public List<String> tailLines(int roomId, int n) {
        Path file = baseDir.resolve("room-" + String.format("%04d", roomId) + ".txt");
        if (!Files.exists(file) || n <= 0) return List.of();

        // 简单实现:把文件逐行读一遍,只保留最后 n 行(小文件够用)
        Deque<String> dq = new ArrayDeque<>(n);
        try {
            for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
                if (dq.size() == n) dq.removeFirst();
                dq.addLast(line);
            }
        } catch (IOException e) {
            System.out.println("[LOG] tail failed: " + e.getMessage());
            return List.of();
        }
        return List.copyOf(dq);
    }

    private static String sanitize(String s) {
        if (s == null) return "";
        // 避免换行把日志"劈开"
        return s.replace("\r", "\\r").replace("\n", "\\n");
    }
}
java 复制代码
package wt20260129_2.server;


import wt20260129_2.common.MessageType;
import wt20260129_2.common.Packet;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ChatServer {
    private final int port;
    private final ExecutorService pool = Executors.newCachedThreadPool();

    private final ChatLog chatLog = new ChatLog("logs");

    // 在线连接(不分房间存,广播时按 roomId 过滤)
    private final ConcurrentHashMap<Integer, ClientConn> online = new ConcurrentHashMap<>();
    private final AtomicInteger nextConnId = new AtomicInteger(1);

    public ChatServer(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        try (ServerSocket ss = new ServerSocket(port)) {
            System.out.println("[SERVER] Listening on " + port);
            while (true) {
                Socket socket = ss.accept();
                int connId = nextConnId.getAndIncrement();
                pool.submit(new Handler(connId, socket));
            }
        }
    }

    private static class ClientConn {
        final int connId;
        final Socket socket;
        final DataOutputStream out;
        volatile int roomId = -1;
        volatile String nickname = "anon";

        ClientConn(int connId, Socket socket, DataOutputStream out) {
            this.connId = connId;
            this.socket = socket;
            this.out = out;
        }
    }

    private class Handler implements Runnable {
        private final int connId;
        private final Socket socket;

        Handler(int connId, Socket socket) {
            this.connId = connId;
            this.socket = socket;
        }

        @Override
        public void run() {
            ClientConn me = null;
            try (Socket s = socket) {
                DataInputStream in = new DataInputStream(new BufferedInputStream(s.getInputStream()));
                DataOutputStream out = new DataOutputStream(new BufferedOutputStream(s.getOutputStream()));

                me = new ClientConn(connId, s, out);
                online.put(connId, me);

                Packet.send(out, MessageType.SYSTEM, 0, "Connected. Please JOIN with a 4-digit RoomID.");

                while (true) {
                    Packet p = Packet.read(in);

                    if (p.type == MessageType.JOIN) {
                        // JOIN 的 text: nickname(可空),roomId 在 header 里
                        int roomId = p.roomId;
                        if (!isValidRoomId(roomId)) {
                            Packet.send(out, MessageType.ERROR, roomId, "RoomID must be 4 digits (0000-9999).");
                            continue;
                        }
                        String name = p.text == null || p.text.trim().isEmpty() ? ("user" + connId) : p.text.trim();
                        int oldRoom = me.roomId;

                        me.roomId = roomId;
                        me.nickname = name;

                        Packet.send(out, MessageType.SYSTEM, roomId, "JOIN OK. You are " + me.nickname + " in room " + fmt(roomId));
                        // 自动回放最近 50 条
                        var lines = chatLog.tailLines(roomId, 50);
                        if (!lines.isEmpty()) {
                            Packet.send(out, MessageType.SYSTEM, roomId, "----- Last " + lines.size() + " messages -----");
                            for (String l : lines) {
                                // 你日志是 TSV:ts room type from text
                                // 这里简单直接原样发(更漂亮可解析成"ts from: text")
                                Packet.send(out, MessageType.CHAT, roomId, "[HIST] " + l);
                            }
                            Packet.send(out, MessageType.SYSTEM, roomId, "----- End of history -----");
                        }

                        chatLog.appendSystem(roomId, me.nickname + " JOINED");

                        if (oldRoom != -1 && oldRoom != roomId) {
                            broadcast(oldRoom, MessageType.SYSTEM, "SYSTEM: " + name + " left the room.", connId);
                        }
                        broadcast(roomId, MessageType.SYSTEM, "SYSTEM: " + name + " joined. (#" + connId + ")", connId);
                        continue;
                    }

                    // 未加入房间,拒绝聊天
                    if (me.roomId == -1) {
                        Packet.send(out, MessageType.ERROR, 0, "Please JOIN first.");
                        continue;
                    }

                    // 所有聊天消息必须带正确 roomId
                    if (p.roomId != me.roomId) {
                        Packet.send(out, MessageType.ERROR, me.roomId, "RoomID mismatch. Ignored.");
                        continue;
                    }

                    if (p.type == MessageType.CHAT) {
                        String msg = (p.text == null ? "" : p.text).trim();

                        // 命令:清空
                        if ("/clear".equalsIgnoreCase(msg)) {
                            chatLog.clearRoom(me.roomId);
                            chatLog.appendSystem(me.roomId, me.nickname + " CLEARED HISTORY");
                            broadcast(me.roomId, MessageType.SYSTEM, "SYSTEM: " + me.nickname + " cleared chat history.", -1);
                            Packet.send(out, MessageType.SYSTEM, me.roomId, "SYSTEM: history cleared.");
                            continue;
                        }

                        // 正常聊天:先落盘再广播
                        chatLog.appendChat(me.roomId, me.nickname, msg);
                        String line = me.nickname + ": " + msg;
                        broadcast(me.roomId, MessageType.CHAT, line, connId);
                        Packet.send(out, MessageType.CHAT, me.roomId, "(me) " + line);
                    }

                    else {
                        Packet.send(out, MessageType.ERROR, me.roomId, "Unknown message type: " + p.type);
                    }
                }

            } catch (EOFException eof) {
                // 正常断开
            } catch (Exception e) {
                System.out.println("[SERVER] Conn#" + connId + " error: " + e.getMessage());
            } finally {
                if (me != null) {
                    online.remove(connId);
                    if (me.roomId != -1) {
                        chatLog.appendSystem(me.roomId, me.nickname + " DISCONNECTED");
                        broadcast(me.roomId, MessageType.SYSTEM, "SYSTEM: " + me.nickname + " disconnected.", connId);
                    }

                }
            }
        }
    }




    private void broadcast(int roomId, int type, String text, int exceptConnId) {
        for (ClientConn c : online.values()) {
            if (c.connId == exceptConnId) continue;
            if (c.roomId != roomId) continue;
            try {
                Packet.send(c.out, type, roomId, text);
            } catch (IOException e) {
                // 发送失败:关闭并移除
                try { c.socket.close(); } catch (IOException ignored) {}
                online.remove(c.connId);
            }
        }
    }

    private static boolean isValidRoomId(int roomId) {
        return roomId >= 0 && roomId <= 9999;
    }

    private static String fmt(int roomId) {
        return String.format("%04d", roomId);
    }

    public static void main(String[] args) throws Exception {
        new ChatServer(9999).start();
    }
}
相关推荐
独处东汉1 小时前
freertos开发空气检测仪之输入子系统按键驱动测试
android·java·数据库
allway21 小时前
统信UOS桌面专业版开启 ROOT权限并设置 SSH 登录
java·数据库·ssh
科技块儿1 小时前
平台需展示用户IP属地,如何操作?
网络·网络协议·tcp/ip
别会,会就是不问1 小时前
Junit4下Mockito包的使用
java·junit·单元测试
好好沉淀1 小时前
Java 开发环境概念速查笔记(JDK / SDK / Maven)
java·笔记·maven
凹凸曼coding1 小时前
Java业务层单元测试通用编写流程(Junit4+Mockito实战)
java·单元测试·log4j
程序猿编码1 小时前
深入浅出Linux内核级防火墙:IP/端口黑白名单的高性能实现
linux·c语言·c++·tcp/ip·内核
..过云雨1 小时前
数据链路层核心全解:以太网、MAC 地址、MTU 与 ARP 协议深度剖析
网络·网络协议·tcp/ip·计算机网络
NaclarbCSDN1 小时前
OSI模型与TCP/IP模型
网络·网络协议·tcp/ip