薛之谦_头像
基于 Java Netty 的实时网络聊天室:从架构设计到部署实战封面图

基于 Java Netty 的实时网络聊天室:从架构设计到部署实战

1. 项目概述

本教程将带你从零搭建一个基于 Java Netty 的实时网络聊天室,涵盖架构设计、数据库表结构、后端核心代码、前端页面四大模块。项目采用前后端分离模式,后端基于 Netty 实现高性能长连接通信,前端使用原生 HTML + WebSocket 实现实时收发消息,数据持久化采用 MySQL。

1.1 技术选型

技术用途版本建议
Java后端开发语言JDK 8+
Netty高性能网络通信框架4.1.x
MySQL数据持久化5.7+ / 8.0
JDBC数据库连接与 MySQL 版本匹配
WebSocket前端实时通信协议浏览器原生支持
HTML/CSS/JavaScript前端页面无版本限制

1.2 功能需求

  • 用户注册、登录
  • 在线用户列表展示
  • 群聊消息实时收发
  • 私聊消息发送
  • 聊天记录持久化存储
  • 用户上下线状态通知

2. 系统架构设计

2.1 整体架构图

WebSocket 长连接

浏览器前端
HTML + WebSocket

Netty 服务端
端口 8080

ChannelHandler 链
编解码 + 业务处理

消息路由分发
群聊 / 私聊

MySQL 数据库
用户表 + 消息表

在线用户管理
ChannelGroup

2.2 分层架构说明

表现层(前端):浏览器页面通过 WebSocket 协议与 Netty 服务端建立长连接,实现实时双向通信。

网络层(Netty):负责 TCP 连接的建立、维护与关闭,通过 ChannelHandler 链处理消息的编解码与业务分发。

业务层:处理用户认证、消息路由、在线状态管理等核心业务逻辑。

数据层(MySQL):负责用户信息与聊天记录的持久化存储。

2.3 通信协议设计

前端与后端通过 WebSocket 传输 JSON 格式消息,消息结构如下:

{
  "type": "chat",
  "from": "user1",
  "to": "all",
  "content": "大家好!",
  "timestamp": 1697000000000
}
字段说明
type消息类型:login / chat / private / system
from发送者用户名
to接收者:all 表示群聊,具体用户名表示私聊
content消息内容
timestamp消息时间戳

3. 数据库设计

3.1 ER 图

发送

USER

int

id

PK

varchar

username

varchar

password

varchar

nickname

datetime

create_time

MESSAGE

int

id

PK

varchar

from_user

varchar

to_user

text

content

datetime

send_time

3.2 表结构说明

用户表 t_user
字段名类型约束说明
idINT主键,自增用户 ID
usernameVARCHAR(50)唯一,非空用户名
passwordVARCHAR(100)非空密码(BCrypt 加密)
nicknameVARCHAR(50)非空昵称
create_timeDATETIME默认当前时间注册时间
CREATE TABLE `t_user` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密后的密码',
  `nickname` VARCHAR(50) NOT NULL,
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
消息表 t_message
字段名类型约束说明
idINT主键,自增消息 ID
from_userVARCHAR(50)非空发送者用户名
to_userVARCHAR(50)非空接收者:all 表示群聊
contentTEXT非空消息内容
send_timeDATETIME默认当前时间发送时间
CREATE TABLE `t_message` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `from_user` VARCHAR(50) NOT NULL,
  `to_user` VARCHAR(50) NOT NULL DEFAULT 'all',
  `content` TEXT NOT NULL,
  `send_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_from_user` (`from_user`),
  KEY `idx_to_user` (`to_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4. 后端代码实现

4.1 项目结构

netty-chat-server/
├── pom.xml
└── src/main/java/com/chat/
    ├── ChatServer.java          // 服务端启动类
    ├── handler/
    │   ├── WebSocketHandler.java // WebSocket 业务处理器
    │   └── HttpRequestHandler.java // HTTP 握手处理器
    ├── model/
    │   ├── User.java            // 用户实体
    │   └── Message.java         // 消息实体
    ├── dao/
    │   ├── UserDao.java         // 用户数据访问
    │   └── MessageDao.java      // 消息数据访问
    └── util/
        └── DbUtil.java          // 数据库连接工具

4.2 Maven 依赖配置(pom.xml)

<dependencies>
    <!-- Netty 核心依赖 -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.100.Final</version>
    </dependency>
    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    <!-- JSON 处理 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.32</version>
    </dependency>
    <!-- Spring Security Crypto(提供 BCrypt 密码加密) -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-crypto</artifactId>
        <version>5.8.8</version>
    </dependency>
    <!-- Spring Security Crypto 依赖 commons-logging -->
    <dependency>
        <groupId>commons-logging</groupId>
        <artifactId>commons-logging</artifactId>
        <version>1.2</version>
    </dependency>
</dependencies>

4.3 服务端启动类 ChatServer.java

package com.chat;

import com.chat.handler.HttpRequestHandler;
import com.chat.handler.WebSocketHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;

public class ChatServer {

    private final int port;

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

    public void start() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 128)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ChannelPipeline pipeline = ch.pipeline();
                            // HTTP 编解码器,用于 WebSocket 握手
                            pipeline.addLast(new HttpServerCodec());
                            // 聚合 HTTP 请求
                            pipeline.addLast(new HttpObjectAggregator(65536));
                            // 支持大文件传输
                            pipeline.addLast(new ChunkedWriteHandler());
                            // HTTP 请求处理器(处理握手请求)
                            pipeline.addLast(new HttpRequestHandler());
                            // WebSocket 协议处理器
                            pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
                            // WebSocket 业务处理器
                            pipeline.addLast(new WebSocketHandler());
                        }
                    });
            ChannelFuture future = bootstrap.bind(port).sync();
            System.out.println("聊天服务器启动成功,端口:" + port);
            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws Exception {
        new ChatServer(8080).start();
    }
}

4.4 HTTP 握手处理器 HttpRequestHandler.java

package com.chat.handler;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.QueryStringDecoder;

public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
        // 只处理 WebSocket 握手请求
        if (request.uri().equals("/ws") && request.method() == HttpMethod.GET) {
            // 放行给下一个 Handler 处理握手
            ctx.fireChannelRead(request.retain());
        } else {
            // 其他请求返回 404
            ctx.close();
        }
    }
}

4.5 WebSocket 业务处理器 WebSocketHandler.java

package com.chat.handler;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.chat.dao.MessageDao;
import com.chat.model.Message;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    // 保存所有在线连接的 Channel
    private static final ChannelGroup CHANNEL_GROUP = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    // 保存用户名与 Channel 的映射关系
    private static final Map<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();

    private final MessageDao messageDao = new MessageDao();

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        CHANNEL_GROUP.add(ctx.channel());
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) {
        CHANNEL_GROUP.remove(ctx.channel());
        // 用户下线,从映射中移除并广播通知
        USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
        broadcastSystemMessage("有用户下线了");
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        JSONObject json = JSON.parseObject(frame.text());
        String type = json.getString("type");

        switch (type) {
            case "login":
                handleLogin(ctx, json);
                break;
            case "chat":
                handleChat(ctx, json);
                break;
            case "private":
                handlePrivateChat(ctx, json);
                break;
            default:
                ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"error\",\"content\":\"未知消息类型\"}"));
        }
    }

    /**
     * 处理用户登录
     */
    private void handleLogin(ChannelHandlerContext ctx, JSONObject json) {
        String username = json.getString("username");
        USER_CHANNEL_MAP.put(username, ctx.channel());
        // 广播上线通知
        JSONObject response = new JSONObject();
        response.put("type", "system");
        response.put("content", username + " 加入了聊天室");
        CHANNEL_GROUP.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
    }

    /**
     * 处理群聊消息
     */
    private void handleChat(ChannelHandlerContext ctx, JSONObject json) {
        String from = json.getString("from");
        String content = json.getString("content");

        // 构造群聊消息
        JSONObject response = new JSONObject();
        response.put("type", "chat");
        response.put("from", from);
        response.put("to", "all");
        response.put("content", content);
        response.put("timestamp", System.currentTimeMillis());

        // 广播给所有在线用户
        CHANNEL_GROUP.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));

        // 持久化到数据库
        Message message = new Message();
        message.setFromUser(from);
        message.setToUser("all");
        message.setContent(content);
        messageDao.save(message);
    }

    /**
     * 处理私聊消息
     */
    private void handlePrivateChat(ChannelHandlerContext ctx, JSONObject json) {
        String from = json.getString("from");
        String to = json.getString("to");
        String content = json.getString("content");

        // 构造私聊消息
        JSONObject response = new JSONObject();
        response.put("type", "private");
        response.put("from", from);
        response.put("to", to);
        response.put("content", content);
        response.put("timestamp", System.currentTimeMillis());

        // 发送给指定用户
        Channel targetChannel = USER_CHANNEL_MAP.get(to);
        if (targetChannel != null && targetChannel.isActive()) {
            targetChannel.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
            // 同时回发给发送者
            ctx.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
        } else {
            ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"error\",\"content\":\"对方不在线\"}"));
        }

        // 持久化到数据库
        Message message = new Message();
        message.setFromUser(from);
        message.setToUser(to);
        message.setContent(content);
        messageDao.save(message);
    }

    /**
     * 广播系统消息
     */
    private void broadcastSystemMessage(String content) {
        JSONObject response = new JSONObject();
        response.put("type", "system");
        response.put("content", content);
        CHANNEL_GROUP.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

4.5.1 用户登录认证

在 handleLogin 方法中,除了将用户名与 Channel 建立映射外,还需要校验用户名和密码是否合法。这里引入 UserDao 完成数据库查询,并使用 Spring Security 的 BCryptPasswordEncoder 对密码进行校验(不再使用 MD5,BCrypt 自带盐值,安全性更高)。

package com.chat.handler;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.chat.dao.MessageDao;
import com.chat.dao.UserDao;
import com.chat.model.Message;
import com.chat.model.User;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    private static final ChannelGroup CHANNEL_GROUP = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    private static final Map<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();

    private final MessageDao messageDao = new MessageDao();
    private final UserDao userDao = new UserDao();
    // Spring Security 的 BCrypt 密码编码器
    private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();

    // ... 其余方法保持不变 ...

    /**
     * 处理用户登录(带用户名密码校验)
     */
    private void handleLogin(ChannelHandlerContext ctx, JSONObject json) {
        String username = json.getString("username");
        String password = json.getString("password");

        // 1. 参数校验
        if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
            sendError(ctx, "用户名和密码不能为空");
            return;
        }

        // 2. 防止重复登录:同一用户名已在线则拒绝
        if (USER_CHANNEL_MAP.containsKey(username)) {
            sendError(ctx, "该用户已在线,请勿重复登录");
            return;
        }

        // 3. 数据库校验用户名密码(使用 Spring Security BCrypt 校验)
        User user = userDao.findByUsername(username);
        if (user == null) {
            sendError(ctx, "用户不存在");
            return;
        }
        // 用 BCrypt 校验明文密码与数据库中存储的密文是否匹配
        if (!PASSWORD_ENCODER.matches(password, user.getPassword())) {
            sendError(ctx, "密码错误");
            return;
        }

        // 4. 登录成功,建立映射并广播上线通知
        USER_CHANNEL_MAP.put(username, ctx.channel());
        JSONObject response = new JSONObject();
        response.put("type", "system");
        response.put("content", username + " 加入了聊天室");
        CHANNEL_GROUP.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
    }

    /**
     * 发送错误消息
     */
    private void sendError(ChannelHandlerContext ctx, String content) {
        JSONObject error = new JSONObject();
        error.put("type", "error");
        error.put("content", content);
        ctx.writeAndFlush(new TextWebSocketFrame(error.toJSONString()));
    }
}
防止重复登录的说明
  • 在线映射检查:USER_CHANNEL_MAP 以用户名为 key 保存在线 Channel。登录前先 containsKey(username) 判断,若已存在则直接拒绝,避免同一账号被多个连接同时占用。
  • 下线清理:handlerRemoved 中通过 removeIf 将断开的 Channel 从映射中移除,保证用户下线后可以重新登录。
  • 并发安全:USER_CHANNEL_MAP 使用 ConcurrentHashMap,保证多线程环境下登录/下线操作的原子性与可见性。

4.6 数据库连接工具 DbUtil.java

package com.chat.util;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class DbUtil {

    private static final String URL = "jdbc:mysql://localhost:3306/chat_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8";
    private static final String USERNAME = "root";
    private static final String PASSWORD = "123456";

    static {
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(URL, USERNAME, PASSWORD);
    }
}

4.7 用户实体 User.java

package com.chat.model;

public class User {
    private int id;
    private String username;
    private String password;
    private String nickname;

    // 无参构造
    public User() {}

    // 有参构造
    public User(String username, String password, String nickname) {
        this.username = username;
        this.password = password;
        this.nickname = nickname;
    }

    // Getter 和 Setter
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }

    public String getNickname() { return nickname; }
    public void setNickname(String nickname) { this.nickname = nickname; }
}

4.8 消息实体 Message.java

Message 实体对应数据库中的 t_message 表,包含 id、fromUser、toUser、content、sendTime 五个字段,分别对应表中的 id、from_user、to_user、content、send_time 列。完整代码如下:

package com.chat.model;

public class Message {
    private int id;
    private String fromUser;
    private String toUser;
    private String content;
    private String sendTime;

    // 无参构造
    public Message() {}

    // 有参构造
    public Message(String fromUser, String toUser, String content) {
        this.fromUser = fromUser;
        this.toUser = toUser;
        this.content = content;
    }

    // Getter 和 Setter
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }

    public String getFromUser() { return fromUser; }
    public void setFromUser(String fromUser) { this.fromUser = fromUser; }

    public String getToUser() { return toUser; }
    public void setToUser(String toUser) { this.toUser = toUser; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    public String getSendTime() { return sendTime; }
    public void setSendTime(String sendTime) { this.sendTime = sendTime; }
}

4.9 聊天记录查询

聊天记录查询功能用于在用户登录后或切换聊天对象时,从数据库加载历史消息并展示。这里在 MessageDao 中新增 findHistory 方法,支持查询群聊记录(to_user = 'all')和与指定用户的私聊记录,并按时间倒序返回最近 50 条。

MessageDao.java 中新增查询方法
package com.chat.dao;

import com.chat.model.Message;
import com.chat.util.DbUtil;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class MessageDao {

    /**
     * 保存一条聊天消息到数据库
     * @param message 消息对象(fromUser、toUser、content 必填)
     */
    public void save(Message message) {
        String sql = "INSERT INTO t_message (from_user, to_user, content) VALUES (?, ?, ?)";
        try (Connection conn = DbUtil.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            // 参数绑定
            ps.setString(1, message.getFromUser());
            ps.setString(2, message.getToUser());
            ps.setString(3, message.getContent());

            // 执行插入
            ps.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    /**
     * 查询历史聊天记录(支持群聊和私聊)
     * @param username 当前登录用户名
     * @param target   聊天对象:'all' 表示群聊,具体用户名表示私聊
     * @param limit    返回条数上限
     * @return 按时间倒序排列的消息列表
     */
    public List<Message> findHistory(String username, String target, int limit) {
        List<Message> list = new ArrayList<>();
        String sql;
        if ("all".equals(target)) {
            // 群聊:查询所有 to_user = 'all' 的消息
            sql = "SELECT id, from_user, to_user, content, send_time FROM t_message " +
                  "WHERE to_user = 'all' ORDER BY send_time DESC LIMIT ?";
        } else {
            // 私聊:查询当前用户与目标用户之间的双向消息
            sql = "SELECT id, from_user, to_user, content, send_time FROM t_message " +
                  "WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) " +
                  "ORDER BY send_time DESC LIMIT ?";
        }

        try (Connection conn = DbUtil.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            if ("all".equals(target)) {
                ps.setInt(1, limit);
            } else {
                ps.setString(1, username);
                ps.setString(2, target);
                ps.setString(3, target);
                ps.setString(4, username);
                ps.setInt(5, limit);
            }

            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    Message msg = new Message();
                    msg.setId(rs.getInt("id"));
                    msg.setFromUser(rs.getString("from_user"));
                    msg.setToUser(rs.getString("to_user"));
                    msg.setContent(rs.getString("content"));
                    msg.setSendTime(rs.getString("send_time"));
                    list.add(msg);
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return list;
    }
}
WebSocketHandler.java 中增加 history 消息处理

在 channelRead0 的 switch 中新增 case "history",调用 handleHistory 方法查询历史记录并返回给前端:

@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
    JSONObject json = JSON.parseObject(frame.text());
    String type = json.getString("type");

    switch (type) {
        case "login":
            handleLogin(ctx, json);
            break;
        case "chat":
            handleChat(ctx, json);
            break;
        case "private":
            handlePrivateChat(ctx, json);
            break;
        case "history":
            handleHistory(ctx, json);
            break;
        default:
            ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"error\",\"content\":\"未知消息类型\"}"));
    }
}

/**
 * 处理历史聊天记录查询
 * 前端发送 { "type": "history", "username": "当前用户", "target": "all 或对方用户名" }
 * 服务端返回最近 50 条记录,按时间倒序排列
 */
private void handleHistory(ChannelHandlerContext ctx, JSONObject json) {
    String username = json.getString("username");
    String target = json.getString("target");
    if (username == null || username.isEmpty() || target == null || target.isEmpty()) {
        sendError(ctx, "参数不完整");
        return;
    }

    // 查询最近 50 条历史记录
    List<Message> history = messageDao.findHistory(username, target, 50);

    // 构造返回消息
    JSONObject response = new JSONObject();
    response.put("type", "history");
    response.put("target", target);
    response.put("list", history);

    ctx.writeAndFlush(new TextWebSocketFrame(response.toJSONString()));
}

说明:handleHistory 返回的 list 字段是 Message 对象的 JSON 数组,前端收到 type: history 消息后,遍历 list 渲染到消息列表即可。查询结果按 send_time 倒序返回,前端如需正序展示可自行反转数组。

package com.chat.model;

public class Message {
    private int id;
    private String fromUser;
    private String toUser;
    private String content;
    private String sendTime;

    // Getter 和 Setter
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }

    public String getFromUser() { return fromUser; }
    public void setFromUser(String fromUser) { this.fromUser = fromUser; }

    public String getToUser() { return toUser; }
    public void setToUser(String toUser) { this.toUser = toUser; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    public String getSendTime() { return sendTime; }
    public void setSendTime(String sendTime) { this.sendTime = sendTime; }

## 5. 前端页面实现

前端使用原生 HTML + CSS + JavaScript 实现,通过 WebSocket 与 Netty 服务端建立长连接,完成登录、群聊、私聊和在线用户列表展示。完整代码如下:

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Netty 网络聊天室</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5; height: 100vh; }

        /* 登录界面 */
        #loginPanel {
            width: 360px; margin: 120px auto; background: #fff;
            padding: 30px; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.1);
        }
        #loginPanel h2 { text-align: center; margin-bottom: 20px; color: #333; }
        #loginPanel input {
            width: 100%; padding: 10px; margin-bottom: 12px;
            border: 1px solid #ddd; border-radius: 4px; font-size: 14px;
        }
        #loginPanel button {
            width: 100%; padding: 10px; background: #1890ff; color: #fff;
            border: none; border-radius: 4px; font-size: 16px; cursor: pointer;
        }
        #loginPanel button:hover { background: #40a9ff; }

        /* 聊天主界面 */
        #chatPanel { display: none; height: 100vh; }
        #chatPanel .container {
            display: flex; height: 100%; max-width: 1200px; margin: 0 auto;
            background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,.1);
        }
        /* 左侧在线用户列表 */
        #userList {
            width: 220px; border-right: 1px solid #eee; padding: 15px;
            background: #fafafa; overflow-y: auto;
        }
        #userList h3 { margin-bottom: 10px; color: #333; }
        #userList .user-item {
            padding: 8px 10px; margin-bottom: 4px; border-radius: 4px;
            cursor: pointer; color: #333; font-size: 14px;
        }
        #userList .user-item:hover { background: #e6f7ff; }
        #userList .user-item.active { background: #bae7ff; }
        #userList .user-item .online-dot {
            display: inline-block; width: 8px; height: 8px; border-radius: 50%;
            background: #52c41a; margin-right: 6px;
        }

        /* 右侧聊天区域 */
        #chatArea { flex: 1; display: flex; flex-direction: column; }
        #chatHeader {
            padding: 12px 20px; border-bottom: 1px solid #eee;
            font-size: 16px; font-weight: bold; color: #333;
        }
        #messageList {
            flex: 1; padding: 15px 20px; overflow-y: auto;
            background: #fafafa;
        }
        .msg { margin-bottom: 12px; }
        .msg .msg-from { font-size: 12px; color: #999; margin-bottom: 2px; }
        .msg .msg-content {
            display: inline-block; padding: 8px 12px; border-radius: 6px;
            background: #fff; font-size: 14px; color: #333;
            box-shadow: 0 1px 3px rgba(0,0,0,.08);
        }
        .msg.system .msg-content { background: #fffbe6; color: #d48806; }
        .msg.self { text-align: right; }
        .msg.self .msg-content { background: #1890ff; color: #fff; }

        /* 底部输入区 */
        #inputArea {
            padding: 12px 20px; border-top: 1px solid #eee; display: flex;
        }
        #inputArea input {
            flex: 1; padding: 10px; border: 1px solid #ddd;
            border-radius: 4px; font-size: 14px; margin-right: 10px;
        }
        #inputArea button {
            padding: 10px 20px; background: #1890ff; color: #fff;
            border: none; border-radius: 4px; cursor: pointer;
        }
        #inputArea button:hover { background: #40a9ff; }
    </style>
</head>
<body>

<!-- 登录界面 -->
<div id="loginPanel">
    <h2>Netty 聊天室登录</h2>
    <input type="text" id="username" placeholder="用户名" />
    <input type="password" id="password" placeholder="密码" />
    <button onclick="login()">登录</button>
</div>

<!-- 聊天主界面 -->
<div id="chatPanel">
    <div class="container">
        <!-- 左侧在线用户列表 -->
        <div id="userList">
            <h3>在线用户</h3>
            <div id="userItems"></div>
        </div>

        <!-- 右侧聊天区域 -->
        <div id="chatArea">
            <div id="chatHeader">群聊</div>
            <div id="messageList"></div>
            <div id="inputArea">
                <input type="text" id="msgInput" placeholder="输入消息,按回车发送" onkeydown="if(event.key==='Enter')sendMsg()" />
                <button onclick="sendMsg()">发送</button>
            </div>
        </div>
    </div>
</div>

<script>
    // ========== 全局变量 ==========
    let ws = null;              // WebSocket 连接对象
    let currentUser = '';       // 当前登录用户名
    let currentTarget = 'all';  // 当前聊天对象:'all' 表示群聊,否则为私聊用户名

    /**
     * 建立 WebSocket 连接并登录
     * 连接地址为 ws://localhost:8080/ws,与后端 ChatServer 端口及握手路径保持一致
     */
    function login() {
        const username = document.getElementById('username').value.trim();
        const password = document.getElementById('password').value.trim();
        if (!username || !password) {
            alert('请输入用户名和密码');
            return;
        }

        // 创建 WebSocket 连接
        ws = new WebSocket('ws://localhost:8080/ws');

        // 连接建立成功后发送登录消息
        ws.onopen = function () {
            currentUser = username;
            // 发送登录消息,后端 handleLogin 会校验用户名密码
            ws.send(JSON.stringify({
                type: 'login',
                username: username,
                password: password
            }));
        };

        // 接收服务端消息
        ws.onmessage = function (event) {
            const msg = JSON.parse(event.data);
            handleMessage(msg);
        };

        // 连接关闭
        ws.onclose = function () {
            alert('连接已断开');
        };

        // 连接异常
        ws.onerror = function () {
            alert('连接出错');
        };
    }

    /**
     * 处理服务端推送的消息
     * @param {Object} msg - 服务端返回的 JSON 消息对象
     */
    function handleMessage(msg) {
        switch (msg.type) {
            case 'system':
                // 系统消息:用户上线/下线通知
                appendMessage('system', msg.content);
                break;
            case 'chat':
                // 群聊消息
                appendMessage('chat', msg.from + ': ' + msg.content);
                break;
            case 'private':
                // 私聊消息
                appendMessage('private', msg.from + ' (私聊): ' + msg.content);
                break;
            case 'error':
                // 错误消息:登录失败等
                alert(msg.content);
                break;
            default:
                break;
        }
    }

    /**
     * 发送消息
     * 根据当前聊天对象决定发送群聊还是私聊消息
     */
    function sendMsg() {
        const input = document.getElementById('msgInput');
        const content = input.value.trim();
        if (!content) return;

        if (currentTarget === 'all') {
            // 群聊消息
            ws.send(JSON.stringify({
                type: 'chat',
                from: currentUser,
                to: 'all',
                content: content
            }));
        } else {
            // 私聊消息
            ws.send(JSON.stringify({
                type: 'private',
                from: currentUser,
                to: currentTarget,
                content: content
            }));
        }
        input.value = '';
    }

    /**
     * 选择聊天对象
     * 点击左侧在线用户列表中的用户,切换到私聊模式
     * @param {string} username - 目标用户名
     */
    function selectUser(username) {
        currentTarget = username;
        document.getElementById('chatHeader').textContent = '与 ' + username + ' 私聊中';
        // 高亮当前选中的用户
        const items = document.querySelectorAll('.user-item');
        items.forEach(item => item.classList.remove('active'));
        event.currentTarget.classList.add('active');
    }

    /**
     * 在消息列表中追加一条消息
     * @param {string} type - 消息类型:system / chat / private
     * @param {string} content - 消息内容
     */
    function appendMessage(type, content) {
        const list = document.getElementById('messageList');
        const div = document.createElement('div');
        div.className = 'msg ' + type;
        div.innerHTML = '<div class="msg-content">' + content + '</div>';
        list.appendChild(div);
        // 滚动到底部
        list.scrollTop = list.scrollHeight;
    }
</script>
</body>
</html>

4.10 性能优化建议

当聊天室用户量增长后,默认配置可能成为瓶颈。下面从 Netty 线程模型、内存管理、数据库写入和前端加载四个维度给出优化方案。

4.10.1 Netty 线程模型调优

默认 bossGroup = new NioEventLoopGroup(1) 只用一个线程处理连接接入,workerGroup = new NioEventLoopGroup() 使用默认的 2 * CPU 核数 线程处理 IO 事件。对于高并发场景,可显式指定线程数:

// 优化前:默认配置
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

// 优化后:显式指定线程数
// bossGroup 负责接受新连接,1~2 个线程通常足够
EventLoopGroup bossGroup = new NioEventLoopGroup(2);
// workerGroup 负责读写 IO 与业务处理,建议设置为 CPU 核数的 2~4 倍
int workerThreads = Math.max(Runtime.getRuntime().availableProcessors() * 2, 8);
EventLoopGroup workerGroup = new NioEventLoopGroup(workerThreads);

说明:workerGroup 线程数并非越大越好。线程过多会增加上下文切换开销,建议结合压测结果调整。若业务处理(如数据库写入)耗时较长,可考虑将耗时操作提交到独立的业务线程池,避免阻塞 Netty 的 IO 线程。

4.10.2 ChannelGroup 与 ConcurrentHashMap 的内存管理

CHANNEL_GROUP 和 USER_CHANNEL_MAP 长期持有所有在线连接,若用户频繁上下线,可能残留失效的 Channel 引用,导致内存泄漏。优化措施如下:

  • 下线时主动清理:在 handlerRemoved 中不仅移除映射,还应主动关闭 Channel 并清空引用。
  • 定期清理失效连接:通过定时任务扫描 USER_CHANNEL_MAP,移除 !channel.isActive() 的条目。
  • 限制在线用户数量:当在线用户数超过阈值时拒绝新登录,防止内存被耗尽。
// 在 WebSocketHandler 中增加定时清理任务
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

// 定时清理失效连接(在类中声明)
private static final ScheduledExecutorService CLEANER = Executors.newSingleThreadScheduledExecutor();

static {
    // 每 60 秒清理一次失效连接
    CLEANER.scheduleAtFixedRate(() -> {
        USER_CHANNEL_MAP.entrySet().removeIf(entry -> !entry.getValue().isActive());
        System.out.println("清理失效连接,当前在线用户数:" + USER_CHANNEL_MAP.size());
    }, 60, 60, TimeUnit.SECONDS);
}
4.10.3 消息批量写入数据库(JDBC Batch)

当前 handleChat 和 handlePrivateChat 中每条消息都单独调用 messageDao.save(),高并发下会产生大量数据库连接开销。优化方案是使用 JDBC Batch 批量插入,将多条消息合并为一次提交:

// MessageDao 中新增批量保存方法
public void saveBatch(List<Message> messages) {
    if (messages == null || messages.isEmpty()) {
        return;
    }
    String sql = "INSERT INTO t_message (from_user, to_user, content) VALUES (?, ?, ?)";
    try (Connection conn = DbUtil.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {

        // 关闭自动提交,统一提交事务
        conn.setAutoCommit(false);
        for (Message message : messages) {
            ps.setString(1, message.getFromUser());
            ps.setString(2, message.getToUser());
            ps.setString(3, message.getContent());
            ps.addBatch();
        }
        // 批量执行
        ps.executeBatch();
        // 统一提交
        conn.commit();
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

在 WebSocketHandler 中,可先将消息暂存到队列,达到一定数量或时间间隔后再批量写入:

// 在 WebSocketHandler 中增加消息缓冲队列
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;

// 消息缓冲队列(在类中声明)
private static final LinkedBlockingQueue<Message> MESSAGE_QUEUE = new LinkedBlockingQueue<>(10000);
// 批量写入阈值
private static final int BATCH_SIZE = 100;

/**
 * 将消息加入缓冲队列,达到阈值时批量写入数据库
 */
private void enqueueMessage(Message message) {
    MESSAGE_QUEUE.offer(message);
    // 达到阈值时触发批量写入
    if (MESSAGE_QUEUE.size() >= BATCH_SIZE) {
        List<Message> batch = new ArrayList<>();
        MESSAGE_QUEUE.drainTo(batch, BATCH_SIZE);
        messageDao.saveBatch(batch);
    }
}

说明:批量写入能显著减少数据库连接次数,但需注意事务一致性。若消息量极大,可进一步引入消息队列(如 Kafka、RabbitMQ)做异步削峰,将写入压力从 Netty 线程中彻底解耦。

4.10.4 前端消息分页加载

当前 handleHistory 一次性返回最近 50 条记录,当聊天记录积累到成千上万条时,一次性加载会拖慢页面渲染。优化方案是采用分页加载,前端滚动到顶部时请求更早的历史消息:

// MessageDao 中新增分页查询方法
public List<Message> findHistoryByPage(String username, String target, int offset, int limit) {
    List<Message> list = new ArrayList<>();
    String sql;
    if ("all".equals(target)) {
        sql = "SELECT id, from_user, to_user, content, send_time FROM t_message " +
              "WHERE to_user = 'all' ORDER BY send_time DESC LIMIT ? OFFSET ?";
    } else {
        sql = "SELECT id, from_user, to_user, content, send_time FROM t_message " +
              "WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) " +
              "ORDER BY send_time DESC LIMIT ? OFFSET ?";
    }

    try (Connection conn = DbUtil.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {

        if ("all".equals(target)) {
            ps.setInt(1, limit);
            ps.setInt(2, offset);
        } else {
            ps.setString(1, username);
            ps.setString(2, target);
            ps.setString(3, target);
            ps.setString(4, username);
            ps.setInt(5, limit);
            ps.setInt(6, offset);
        }

        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                Message msg = new Message();
                msg.setId(rs.getInt("id"));
                msg.setFromUser(rs.getString("from_user"));
                msg.setToUser(rs.getString("to_user"));
                msg.setContent(rs.getString("content"));
                msg.setSendTime(rs.getString("send_time"));
                list.add(msg);
            }
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
    return list;
}

前端在滚动到消息列表顶部时,发送分页请求并追加渲染:

// 前端分页加载历史消息
let historyOffset = 0;  // 当前已加载的偏移量
const PAGE_SIZE = 50;   // 每页条数

// 滚动到顶部时加载更早的消息
document.getElementById('messageList').addEventListener('scroll', function () {
    if (this.scrollTop === 0) {
        historyOffset += PAGE_SIZE;
        ws.send(JSON.stringify({
            type: 'history',
            username: currentUser,
            target: currentTarget,
            offset: historyOffset,
            limit: PAGE_SIZE
        }));
    }
});

说明:分页加载能显著降低首屏渲染压力,同时减少单次网络传输的数据量。实际项目中还可结合 send_time 游标分页(WHERE send_time < ?)替代 OFFSET,避免深分页时的性能下降。

5.1 前端关键逻辑说明

功能实现方式
WebSocket 连接new WebSocket('ws://localhost:8080/ws'),与后端 ChatServer 端口及握手路径 /ws 保持一致
登录认证连接建立后发送 type: login 消息,携带用户名密码,由后端 handleLogin 校验
群聊发送 type: chat 消息,to 字段为 all,后端广播给所有在线用户
私聊发送 type: private 消息,to 字段为目标用户名,后端定向转发
在线用户列表通过 system 类型消息接收用户上线/下线通知,维护在线状态
消息展示根据消息类型(system / chat / private)区分样式,自动滚动到底部

说明:前端通过 system 消息感知用户上下线,但当前后端 WebSocketHandler 尚未主动推送完整的在线用户列表。如需实时刷新在线用户列表,可在后端登录/下线时广播 type: userList 消息,携带 USER_CHANNEL_MAP 的全部用户名,前端收到后渲染到左侧列表即可。

6. 部署与测试

本章介绍如何将聊天室项目部署到本地环境并完成功能测试,涵盖数据库初始化、Maven 打包、服务启动、浏览器联调以及常见问题排查。

6.1 数据库初始化

首先在 MySQL 中创建数据库 chat_db,并执行建表脚本创建 t_user 和 t_message 两张表。

6.1.1 创建数据库

打开 MySQL 命令行或 Navicat,执行以下 SQL 创建数据库:

-- 创建数据库(若已存在可先删除)
DROP DATABASE IF EXISTS `chat_db`;
CREATE DATABASE `chat_db` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `chat_db`;

说明:数据库名必须与 DbUtil.java 中的连接地址 jdbc:mysql://localhost:3306/chat_db 保持一致,否则后端无法连接。

6.1.2 创建用户表 t_user
CREATE TABLE `t_user` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密后的密码',
  `nickname` VARCHAR(50) NOT NULL,
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
6.1.3 创建消息表 t_message
CREATE TABLE `t_message` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `from_user` VARCHAR(50) NOT NULL,
  `to_user` VARCHAR(50) NOT NULL DEFAULT 'all',
  `content` TEXT NOT NULL,
  `send_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_from_user` (`from_user`),
  KEY `idx_to_user` (`to_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
6.1.4 插入测试用户

由于登录使用 Spring Security 的 BCrypt 校验,需要插入一条密码为 BCrypt 加密的用户数据。下面提供一段 Java 代码生成加密密码,或直接使用下方预生成的密文:

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordGenerator {
    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        // 明文密码为 123456,输出即为可存入数据库的密文
        System.out.println(encoder.encode("123456"));
    }
}

插入测试用户(password 字段填入上面生成的 BCrypt 密文):

INSERT INTO `t_user` (`username`, `password`, `nickname`) VALUES ('alice', '$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPga1VbW6', '爱丽丝');
INSERT INTO `t_user` (`username`, `password`, `nickname`) VALUES ('bob', '$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPga1VbW6', '鲍勃');

说明:上面两条密文对应明文密码 123456,可直接用于测试登录。实际项目中请通过 PasswordGenerator 为每个用户生成独立的密文。

6.2 Maven 打包

在项目根目录(包含 pom.xml 的目录)打开终端,执行以下命令打包:

mvn clean package

打包成功后,会在 target 目录下生成可执行的 JAR 包:

netty-chat-server/target/
├── netty-chat-server-1.0-SNAPSHOT.jar   # 可执行 JAR 包
└── original-netty-chat-server-1.0-SNAPSHOT.jar  # 原始 JAR(不含依赖)

说明:若 pom.xml 中未配置 maven-shade-plugin 或 spring-boot-maven-plugin 生成可执行 JAR,则 netty-chat-server-1.0-SNAPSHOT.jar 可能不含第三方依赖,直接 java -jar 会报 ClassNotFoundException。此时需在 pom.xml 中补充打包插件,将依赖一并打入 JAR:

<build>
    <plugins>
        <!-- 将依赖打入可执行 JAR -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.4.1</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>com.chat.ChatServer</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

6.3 启动服务器

打包完成后,在 target 目录下执行以下命令启动服务:

java -jar netty-chat-server-1.0-SNAPSHOT.jar

启动成功后,控制台会输出:

聊天服务器启动成功,端口:8080

说明:默认端口为 8080,如需修改端口,可在 ChatServer.main 中调整 new ChatServer(8080) 的参数,或通过命令行参数传入。

6.4 浏览器测试

6.4.1 准备前端页面

将第 5 章的 index.html 保存到本地任意目录(如 D:\chat\index.html),用浏览器直接打开该文件即可,无需启动额外的 Web 服务器。

6.4.2 测试登录
  1. 打开 index.html,在登录界面输入用户名 alice、密码 123456,点击「登录」。
  2. 若登录成功,页面会切换到聊天主界面,并收到系统消息「alice 加入了聊天室」。
  3. 若密码错误,浏览器会弹出「密码错误」提示。
6.4.3 测试群聊
  1. 再打开一个浏览器窗口(或使用无痕窗口),用 bob / 123456 登录。
  2. 在任意一个窗口的输入框中输入消息,点击「发送」或按回车。
  3. 两个窗口都应实时收到该群聊消息,消息格式为「发送者: 内容」。
6.4.4 测试私聊
  1. 在 alice 的窗口中,点击左侧在线用户列表中的 bob,聊天标题变为「与 bob 私聊中」。
  2. 输入私聊消息并发送,bob 窗口会收到「alice (私聊): 内容」。
  3. 只有 alice 和 bob 两个窗口能看到这条私聊消息,其他用户不会收到。
6.4.5 验证聊天记录持久化
  1. 关闭两个浏览器窗口,重新打开 index.html 并登录。
  2. 登录后发送 type: history 消息(当前前端页面尚未自动加载历史记录,可在浏览器控制台手动执行):
ws.send(JSON.stringify({
    type: 'history',
    username: 'alice',
    target: 'all',
    limit: 50
}));
  1. 服务端会返回最近 50 条群聊记录,说明消息已成功持久化到数据库。

6.5 常见问题排查

6.5.1 端口占用

现象:启动服务器时报 Address already in use 或 BindException。

排查步骤:

  1. 查看 8080 端口是否被占用:
# Windows
netstat -ano | findstr 8080

# Linux / macOS
lsof -i :8080
  1. 找到占用进程的 PID,结束该进程:
# Windows(PID 为 1234)
taskkill /PID 1234 /F

# Linux / macOS
kill -9 1234
  1. 或修改 ChatServer 中的端口号,改用其他未被占用的端口。
6.5.2 数据库连接失败

现象:启动或登录时报 Communications link failure、Access denied 或 Unknown database。

排查步骤:

  1. 确认 MySQL 已启动:在命令行执行 mysql -u root -p,能进入 MySQL 说明服务正常。
  2. 确认数据库已创建:执行 SHOW DATABASES;,检查是否存在 chat_db。
  3. 确认连接参数正确:检查 DbUtil.java 中的 URL、USERNAME、PASSWORD 是否与本地 MySQL 一致:
private static final String URL = "jdbc:mysql://localhost:3306/chat_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
  1. 确认驱动版本匹配:MySQL 8.x 使用 com.mysql.cj.jdbc.Driver,MySQL 5.7 使用 com.mysql.jdbc.Driver,需与 pom.xml 中的驱动版本对应。
6.5.3 WebSocket 连接断开

现象:浏览器控制台报 WebSocket connection to 'ws://localhost:8080/ws' failed,或连接建立后立即断开。

排查步骤:

  1. 确认服务端已启动:浏览器连接前,先确认控制台输出「聊天服务器启动成功,端口:8080」。
  2. 确认握手路径一致:前端连接地址为 ws://localhost:8080/ws,后端 WebSocketServerProtocolHandler("/ws") 的路径必须一致,否则握手失败。
  3. 确认端口一致:前端 new WebSocket('ws://localhost:8080/ws') 中的端口必须与 ChatServer 绑定的端口一致。
  4. 检查防火墙:若前端页面与后端不在同一台机器,需放行 8080 端口:
# Linux 开放 8080 端口
firewall-cmd --zone=public --add-port=8080/tcp --permanent
firewall-cmd --reload
  1. 查看服务端日志:若连接建立后立即断开,查看服务端控制台是否打印异常堆栈,定位具体原因。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_43565087/article/details/168552123

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--