文章目录
前言
- 本文通过简易的聊天室案例,讲述Netty的基本使用,同时分享案例代码。
- 项目还用到了log4j2,junit5,awt的使用,当然,大家也可以根据自己习惯修改。
功能说明
本聊天室为简易版,实现了聊天室的基本功能,主要用于练习Netty的使用。
功能如下:
- 聊天室支持多客户端,每个客户端都可以看到其他客户端的消息。
- 服务端的客户端列表中可以看到所有的客户端。
- 某个客户端关闭,同时在服务端的客户端列表中也删除。
环境说明
操作系统:windows11;开发工具:idea2023,jdk:1.8
Maven:3.6.3,Netty:4.1.96
maven依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xxx</groupId>
<artifactId>xxx</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>xxx</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.21</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</dependency>
<!-- log4j2-slf4j-适配器 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.20.0</version>
</dependency>
<!-- log4j2 日志核心 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.20.0</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.96.Final</version>
</dependency>
<!-- 单元测试,Junit5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
日志配置
src/main/resources/log4j2.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!-- log4j2配置文件 -->
<!-- monitorInterval="30" 自动加载配置文件的间隔时间,不低于10秒;生产环境中修改配置文件,是热更新,无需重启应用
status="info" 日志框架本身的输出日志级别,可以修改为info, -->
<Configuration status="warn" monitorInterval="30">
<!-- 集中配置属性,使用时通过:${LOG_HOME} -->
<properties>
<!-- 当前项目名称,供下方引用 -->
<property name="PROJECT_NAME" value="tank-battle"/>
<!-- 默认日志格式-包名自动缩减(同步异步通用) -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS}|%-5level|%-5t|%logger{1.}: %msg%n"/>
<!-- 日志格式-打印代码的精确位置信息,类,方法,行。(建议同步使用)。异步如果打印位置信息,会有严重性能问题 -->
<property name="LOG_PATTERN_ALL" value="%d{yyyy-MM-dd HH:mm:ss.SSS}|%-5level|%-5t|%location: %msg%n"/>
<!-- 日志主目录。如果想把日志输出到tomcat底下时使用。 -->
<property name="LOG_HOME">${web:rootDir}/WEB-INF/logs</property>
</properties>
<!-- 日志打印输出方式 -->
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout charset="UTF-8" Pattern="${LOG_PATTERN}"/>
</Console>
<RollingFile name="FileLog" fileName="logs/${PROJECT_NAME}.log" filePattern="logs/${PROJECT_NAME}-%d_%i.log">
<PatternLayout charset="UTF-8" Pattern="${LOG_PATTERN}"/>
<Policies>
<!-- 每天生成一个,同时如果超过10MB还会再生成 -->
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="50 MB"/>
</Policies>
<DefaultRolloverStrategy max="99"/>
</RollingFile>
</Appenders>
<!-- 将代码路径与上面的日志打印关联起来 -->
<Loggers>
<!-- 当前项目日志 -->
<Logger name="com.sjj" level="INFO" additivity="false">
<AppenderRef ref="STDOUT"/>
<AppenderRef ref="FileLog"/>
</Logger>
<!-- 第三方依赖项目日志 -->
<logger name="org.springframework" level="info"/>
<logger name="org.jboss.netty" level="warn"/>
<!--日志级别以及优先级排序: OFF > FATAL > ERROR > WARN > INFO > DEBUG > TRACE > ALL -->
<!-- 根节点日志,除了上面配置的之外的日志 -->
<Root level="WARN">
<AppenderRef ref="STDOUT"/>
<AppenderRef ref="FileLog"/>
</Root>
</Loggers>
</Configuration>
开发
开发思路
下文将按如下顺序进行开发
- 客户端-Netty开发
- 客户端-界面开发
- 服务端-Netty开发
- 服务端-界面开发
开发步骤
客户端-Netty开发
- 新建聊天室客户端Netty类:ClientNetty.java,此类主要有如下功能
- 与服务端建立连接的方法:connect()
- 向服务端发送聊天消息的方法:send()
- 读取服务端信息,更新客户端聊天内容方法:channelRead()
参考代码
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
/**
* 聊天室-客户端-Netty<br>
* @author namelessmyth
* @version 1.0
* @date 2023/8/13
*/
@Slf4j
public class ClientNetty {
// 用于与服务端通信的 SocketChannel 对象
private static SocketChannel channel;
/**
* 与服务端建立连接的方法 <p>
* 使用 Netty 框架来建立与服务器的连接,并初始化连接管道。
* 一旦连接成功,客户端将被阻塞直到服务器关闭。
*/
public static void connect() {
// 事件循环组,处理 I/O 操作
EventLoopGroup group = new NioEventLoopGroup(1);
try {
// 初始化Bootstrap对象,用于客户端连接的引导配置。
Bootstrap b = new Bootstrap();
// 设置处理 I/O 操作的事件循环组
b.group(group);
// 使用 NioSocketChannel 以支持非阻塞通信
b.channel(NioSocketChannel.class);
// 初始化 Channel,添加自定义的处理器 MyClientHandler
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 将当前连接的通道,赋值给外部的channel变量
channel = ch;
// 将用户自定义的处理器添加到通道的管道(pipeline)中。
ch.pipeline().addLast(new MyClientHandler());
}
});
// 连接到服务器并等待连接完成
ChannelFuture cf = b.connect("localhost", 8888).addListener(future -> {
if (future.isSuccess()) {
log.info("client connected");
}
}).sync();
// 阻塞程序,直到服务器关闭连接
cf.channel().closeFuture().sync();
log.info("the chat client has been closed.");
} catch (Exception e) {
log.error("ClientNetty.connect.Exception.", e);
} finally {
// 优雅地关闭事件循环组
group.shutdownGracefully();
}
}
/**
* 向服务端发送聊天消息的方法<p>
* 将消息转换为字节并通过已建立的通道发送到服务器。
* @param msg 聊天内容
*/
public static void send(String msg) {
channel.writeAndFlush(Unpooled.copiedBuffer(msg.getBytes()));
log.info("client.send().{}", msg);
}
/**
* 关闭客户端的方法<p>
* 向服务端发送特定消息以告知其删除本客户端,并关闭通道。
*/
public static void close() {
send("__88__");
channel.close();
}
}
/**
* 自定义客户端处理器类,用于处理通道的各类事件。
*/
@Slf4j
class MyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 读取服务端发送的数据
* <p>
* 将接收到的字节数据转换为字符串,并更新到聊天界面。
*
* @param ctx 通道处理上下文
* @param msg 服务端发送的数据
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
String text = buf.toString(StandardCharsets.UTF_8);
ClientFrame.INSTANCE.updateText(text);
log.info("channelRead.msg:{}", text);
}
/**
* 处理连接建立事件
* <p>
* 当客户端成功连接到服务器时,执行相应的操作。
*
* @param ctx 通道处理上下文
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("channel Active.");
}
/**
* 处理异常事件
* <p>
* 当通道中发生异常时,记录异常信息并执行默认的异常处理。
*
* @param ctx 通道处理上下文
* @param cause 引发的异常
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("chat client exceptionCaught:", cause);
super.exceptionCaught(ctx, cause);
}
}
客户端-界面开发
- 新建聊天室客户端界面类:ClientFrame.java
- 在main方法中,初始化聊天室控件及其样式
- 客户端界面包含2个部分,上面文本域显示当前聊天室的所有聊天内容。底部文本框输入当前用户的聊天内容
- 聊天室窗口初始化时,使用Netty和服务端建立连接。
- 当用户输入完聊天内容后回车,将聊天内容通过Netty客户端发送给服务端。
- 当用户关闭窗口时,关闭当前客户端,同时在服务端的客户端列表中也删除。
参考代码
import com.sjj.mashibing.tank.util.ConfigUtil;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
import java.awt.event.*;
/**
* 聊天室客户端-界面 <br>
* @author Gem
* @version 1.0
* @date 2023/8/15
*/
@Slf4j
public class ClientFrame extends Frame {
public static final int GAME_WIDTH = ConfigUtil.getInt("chat.frame.width");
public static final int GAME_HEIGHT = ConfigUtil.getInt("chat.frame.height");
TextArea ta = new TextArea();
TextField tf = new TextField();
public static final ClientFrame INSTANCE = new ClientFrame();
public static void main(String[] args) throws Exception {
INSTANCE.setVisible(true);
ClientNetty.connect();
}
private ClientFrame() throws HeadlessException {
//创建游戏的主Frame
this.setTitle("Netty聊天室-客户端");
this.setSize(GAME_WIDTH, GAME_HEIGHT);
this.setLocation(800, 100);
this.add(ta, BorderLayout.CENTER);
this.add(tf, BorderLayout.SOUTH);
ta.setText("客户端消息显示区,请在下方输入消息!");
tf.setText("请在此处输入消息并按回车");
// 为文本输入框添加一个动作监听器,当用户按下回车键时触发
tf.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
log.info("begin to send msg:" + tf.getText());
ClientNetty.send(tf.getText());
tf.setText("");
}
});
// 为主窗口添加一个窗口监听器,当用户关闭窗口时触发
this.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
// 关闭客户端连接
ClientNetty.close();
// 退出程序
System.exit(0);
}
});
log.info("chat room Main frame initialization completed");
}
public void updateText(String text) {
ta.setText(ta.getText() + Constants.LINE_SEPERATOR + text);
}
}
服务端-Netty开发
- 新建聊天室服务端Netty类:ServerNetty.java
- 当服务端启动时会调用start方法启动Netty服务端。
- 当服务端接收到入站信息时,使用自定义处理器处理:MyChildHandler
- 当客户端连接接入时,触发方法:channelActive()
- 服务端记录所有接入的客户端,可能有多个。
- 当有客户端消息发送到服务端时,触发方法:channelRead()
- 当某个客户端发来消息之后,需要将消息转发给其他客户端。
- 当接收到客户端关闭特殊消息时,将客户端从列表中移除。
参考代码
import cn.hutool.core.util.StrUtil;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.util.concurrent.GlobalEventExecutor;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
/**
* 聊天室-服务端类
*/
@Slf4j
public class ServerNetty {
// 用于存储所有已连接的客户端通道的组
static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 服务端启动方法
*/
public static void start(){
// 主线程组,仅用于接受客户端连接
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
// 从线程组,处理实际的I/O操作,例如:读写数据,处理接收到的数据
EventLoopGroup workerGroup = new NioEventLoopGroup(4);
try {
// 服务器启动辅助类
ServerBootstrap b = new ServerBootstrap();
// 配置主线程组和从线程组
b.group(bossGroup, workerGroup);
// 指定通道类型为NioServerSocketChannel(异步全双工)
b.channel(NioServerSocketChannel.class);
// 设置通道处理器来处理新连接
b.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel sc) throws Exception {
log.info("a client connected:{}", sc);
// 添加自定义的通道处理器到新的通道管道中
sc.pipeline().addLast(new MyChildHandler());
}
});
log.info("chat server is starting");
// 绑定服务器到端口并开始接受连接
ChannelFuture cf = b.bind(8888).addListener(future -> {
if (future.isSuccess()) {
log.info("chat server started");
}
}).sync();
// 等待服务器通道关闭
cf.channel().closeFuture().sync();
} catch (Exception e) {
log.error("TankServer.exception", e);
} finally {
// 优雅地关闭线程组
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
log.info("chat server has been closed");
}
}
}
/**
* 自定义通道处理器,用于处理客户端连进来的事件和消息
*/
@Slf4j
class MyChildHandler extends ChannelInboundHandlerAdapter {
/**
* 当有客户端连接时触发
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 更新服务器界面显示连接的客户端
ServerFrame.INSTANCE.updateClient("client connected:"+ctx.channel().remoteAddress());
// 将新的客户端通道加入通道组
ServerNetty.clients.add(ctx.channel());
}
/**
* 读取客户端通道中的数据
*
* @param msg 客户端发送的消息
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
// 将消息内容转换为字符串
String str = buf.toString(StandardCharsets.UTF_8);
log.info("channelRead().input,string:{},buf:{}", str, buf);
// 检查是否为结束标记消息
if (StrUtil.equalsIgnoreCase(str, "__88__")) {
// 从通道组中移除客户端并关闭连接
ServerNetty.clients.remove(ctx.channel());
ctx.close();
// 更新服务器界面显示断开的客户端
ServerFrame.INSTANCE.updateClient("client closed>"+ctx.channel().remoteAddress());
log.info("The chat client has been closed:{}", ctx.channel());
} else {
// 将消息转发给所有客户端
ServerNetty.clients.writeAndFlush(msg);
// 更新服务器界面显示的消息
ServerFrame.INSTANCE.updateMsg(ctx.channel().remoteAddress() + ">" + str);
log.info("TankServer.clients.writeAndFlush:{}", msg);
}
}
/**
* 处理异常
*
* @param ctx 上下文对象
* @param cause 异常原因
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("TankServer.exceptionCaught:", cause);
// 从通道组中移除客户端并关闭连接
ServerNetty.clients.remove(ctx.channel());
ctx.close();
}
}
服务端-界面开发
- 新建聊天室服务端界面类:ServerFrame.java
- 服务端界面,左边显示消息,右边显示客户端的连接情况。
- 初始化时自动启动Netty服务端。将接收的消息打印到消息窗口中。
- 有客户端连上或者关闭时显示到右边的窗口中。
参考代码
/**
* 聊天室-服务端界面<br>
*
* @author namelessmyth
* @version 1.0
* @date 2023/8/15
*/
@Slf4j
public class ServerFrame extends Frame {
public static final int GAME_WIDTH = ConfigUtil.getInt("server.frame.width");
public static final int GAME_HEIGHT = ConfigUtil.getInt("server.frame.height");
TextArea tmsg = new TextArea("messages:");
TextArea tclient = new TextArea("clients:");
public static final ServerFrame INSTANCE = new ServerFrame();
public static void main(String[] args) throws Exception {
INSTANCE.setVisible(true);
ChatServer.start();
}
/**
* 初始化服务端界面
*/
private ServerFrame() throws HeadlessException {
//创建游戏的主Frame
this.setTitle("Netty聊天室-服务端");
this.setSize(GAME_WIDTH, GAME_HEIGHT);
this.setLocation(100, 100);
//左右两个文本域设置字体
tmsg.setFont(new Font("Calibri",Font.PLAIN,20));
tclient.setFont(new Font("Calibri",Font.PLAIN,20));
//创建一个1行2列的面板,放入左右2个文本域
Panel p = new Panel(new GridLayout(1, 2));
p.add(tmsg);
p.add(tclient);
this.add(p);
this.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
System.exit(0);
}
});
log.info("Server Main frame initialization completed");
}
public void updateMsg(String text) {
tmsg.setText(tmsg.getText() + Constants.LINE_SEPERATOR + text);
}
public void updateClient(String text) {
tclient.setText(tclient.getText() + Constants.LINE_SEPERATOR + text);
}
}
运行调试
- 启动服务端界面:ServerFrame
- 启动多个客户端:ChatFrame
- 注意:idea默认同一个类只能启动1个进程,想要要启动多个,请按如下操作
- 勾选 "Edit Configurations > Modify Options > Allow mutiple instances "
运行效果图:
最左侧是服务端,右边2个是客户端
其他功能
- 以下功能仅实现思路,目前暂未实现。
- 通知客户端,服务器准备关闭。
- 拒绝新的连接接入
- 等待所有客户端都处理完成。
- 开始关闭流程,发送消息给客户端,客户端自动处理。
- 确认所有客户端断开。
- server保存现有的工作数据。
- 停止线程组
- 退出。
单元测试
确认项目已加入Junit5依赖,就是如下这段。
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
新建单元测试类的步骤。
- 在要创建单元测试的功能类上,依次点Code > generate > Test
- 然后在弹出的窗口中,选择Junit版本为5,测试类名,测试方法等。然后点确定。
- IDEA会自动根据功能类的路径在test目录中创建相同路径但以Test结尾的测试类。并且会自动生成勾选方法的默认测试代码。
- 根据程序的输入和输出,编写单元测试代码。
- 点击方法左边的绿色三角形就可以执行单元测试用例了。
为什么要单元测试?
- 方法内部可以很复杂,如果靠肉眼观察,比较耗时间。单元测试可以根据入参和返回值测试方法是否达到要求。
- 代码是开发人员写的,最了解代码逻辑的还是开发人员。测试人员测试不到代码细节。
- 在一个大的功能中,可能会有很多方法,每个方法都要写Main方法来一个个测试比较复杂,而且也不知道测了哪些场景。
为什么有的公司不做单元测试。
- 代码业务可能比较简单,程序员读代码不是很费力。
- 写单元测试需要额外花时间,程序员工作比较忙,没时间写。
参考说明
本文内容主要参考了马士兵老师的视频教程(Java经典实战项目-坦克大战)