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 整体架构图
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 图
3.2 表结构说明
用户表 t_user
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INT | 主键,自增 | 用户 ID |
| username | VARCHAR(50) | 唯一,非空 | 用户名 |
| password | VARCHAR(100) | 非空 | 密码(BCrypt 加密) |
| nickname | VARCHAR(50) | 非空 | 昵称 |
| create_time | DATETIME | 默认当前时间 | 注册时间 |
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
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INT | 主键,自增 | 消息 ID |
| from_user | VARCHAR(50) | 非空 | 发送者用户名 |
| to_user | VARCHAR(50) | 非空 | 接收者:all 表示群聊 |
| content | TEXT | 非空 | 消息内容 |
| send_time | DATETIME | 默认当前时间 | 发送时间 |
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 测试登录
- 打开
index.html,在登录界面输入用户名alice、密码123456,点击「登录」。 - 若登录成功,页面会切换到聊天主界面,并收到系统消息「alice 加入了聊天室」。
- 若密码错误,浏览器会弹出「密码错误」提示。
6.4.3 测试群聊
- 再打开一个浏览器窗口(或使用无痕窗口),用
bob/123456登录。 - 在任意一个窗口的输入框中输入消息,点击「发送」或按回车。
- 两个窗口都应实时收到该群聊消息,消息格式为「发送者: 内容」。
6.4.4 测试私聊
- 在
alice的窗口中,点击左侧在线用户列表中的bob,聊天标题变为「与 bob 私聊中」。 - 输入私聊消息并发送,
bob窗口会收到「alice (私聊): 内容」。 - 只有
alice和bob两个窗口能看到这条私聊消息,其他用户不会收到。
6.4.5 验证聊天记录持久化
- 关闭两个浏览器窗口,重新打开
index.html并登录。 - 登录后发送
type: history消息(当前前端页面尚未自动加载历史记录,可在浏览器控制台手动执行):
ws.send(JSON.stringify({
type: 'history',
username: 'alice',
target: 'all',
limit: 50
}));
- 服务端会返回最近 50 条群聊记录,说明消息已成功持久化到数据库。
6.5 常见问题排查
6.5.1 端口占用
现象:启动服务器时报 Address already in use 或 BindException。
排查步骤:
- 查看 8080 端口是否被占用:
# Windows
netstat -ano | findstr 8080
# Linux / macOS
lsof -i :8080
- 找到占用进程的 PID,结束该进程:
# Windows(PID 为 1234)
taskkill /PID 1234 /F
# Linux / macOS
kill -9 1234
- 或修改
ChatServer中的端口号,改用其他未被占用的端口。
6.5.2 数据库连接失败
现象:启动或登录时报 Communications link failure、Access denied 或 Unknown database。
排查步骤:
- 确认 MySQL 已启动:在命令行执行
mysql -u root -p,能进入 MySQL 说明服务正常。 - 确认数据库已创建:执行
SHOW DATABASES;,检查是否存在chat_db。 - 确认连接参数正确:检查
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";
- 确认驱动版本匹配: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,或连接建立后立即断开。
排查步骤:
- 确认服务端已启动:浏览器连接前,先确认控制台输出「聊天服务器启动成功,端口:8080」。
- 确认握手路径一致:前端连接地址为
ws://localhost:8080/ws,后端WebSocketServerProtocolHandler("/ws")的路径必须一致,否则握手失败。 - 确认端口一致:前端
new WebSocket('ws://localhost:8080/ws')中的端口必须与ChatServer绑定的端口一致。 - 检查防火墙:若前端页面与后端不在同一台机器,需放行 8080 端口:
# Linux 开放 8080 端口
firewall-cmd --zone=public --add-port=8080/tcp --permanent
firewall-cmd --reload
- 查看服务端日志:若连接建立后立即断开,查看服务端控制台是否打印异常堆栈,定位具体原因。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_43565087/article/details/168552123




