这一版的更新,说白了就是:我们终于把"聊天室"从一个只存在于内存里的即时通信小玩具,推到一个具备"可追溯性"和"可恢复性"的系统雏形------它不再只关心消息能不能发出去,而是开始关心"消息有没有被可靠地记录下来、以后还能不能再被拿回来"。如果把第一版理解成一次纯粹的 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();
}
}