本文内容主要来源于马士兵老师的视频教程(Java经典实战项目-坦克大战),结合了老师的讲课内容以及自己的实践做了一些补充。
开发工具:idea2023,jdk:1.8,Maven:3.6.3
<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.0modelVersion>
<groupId>com.xxxgroupId>
<artifactId>xxxartifactId>
<version>0.0.1-SNAPSHOTversion>
<name>xxxname>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.21version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.28version>
dependency>
<dependency>
<groupId>org.apache.logging.log4jgroupId>
<artifactId>log4j-slf4j-implartifactId>
<version>2.20.0version>
dependency>
<dependency>
<groupId>org.apache.logging.log4jgroupId>
<artifactId>log4j-coreartifactId>
<version>2.20.0version>
dependency>
<dependency>
<groupId>io.nettygroupId>
<artifactId>netty-allartifactId>
<version>4.1.96.Finalversion>
dependency>
<dependency>
<groupId>org.junit.jupitergroupId>
<artifactId>junit-jupiter-engineartifactId>
<version>5.9.3version>
<scope>testscope>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
src/main/resources/log4j2.xml
<Configuration status="warn" monitorInterval="30">
<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"/>
<property name="LOG_HOME">${web:rootDir}/WEB-INF/logsproperty>
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>
<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"/>
<Root level="WARN">
<AppenderRef ref="STDOUT"/>
<AppenderRef ref="FileLog"/>
Root>
Loggers>
Configuration>
确认项目已加入Junit5依赖,就是如下这段。
<dependency>
<groupId>org.junit.jupitergroupId>
<artifactId>junit-jupiter-engineartifactId>
<version>5.9.3version>
<scope>testscope>
dependency>
新建单元测试类的步骤。
为什么要进行单元测试?
为什么有的公司不做单元测试。
简易版聊天室程序。主要用于练习Netty的使用。聊天室功能如下:
首先写一个聊天室的界面(ChatFrame.java)
参考坦克大战的界面部分,设置好聊天室的长宽和坐标。
界面包含2个输入部分,中间文本域显示当前聊天室的所有聊天内容。底部文本框输入当前用户的聊天内容
聊天室窗口初始化时,需要与服务端建立连接。
当用户输入完聊天内容后回车,需要将聊天内容通过Netty客户端发送给服务端。
当用户关闭窗口时,关闭当前客户端,同时在服务端的客户端列表中也删除。
/**
* 聊天室客户端-界面
*
* @author namelessmyth
* @version 1.0
* @date 2023/8/15
*/
@Slf4j
public class ChatFrame 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 ChatFrame INSTANCE = new ChatFrame();
public static void main(String[] args) throws Exception {
INSTANCE.setVisible(true);
ChatClient.connect();
}
private ChatFrame() throws HeadlessException {
//创建游戏的主Frame
this.setTitle("chat room");
this.setSize(GAME_WIDTH, GAME_HEIGHT);
this.setLocation(800, 100);
this.add(ta, BorderLayout.CENTER);
this.add(tf, BorderLayout.SOUTH);
tf.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
ChatClient.send(tf.getText());
tf.setText("");
}
});
this.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
ChatClient.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客户端与服务端进行消息通信(ChatClient.java)。
参考上面的描述,客户端需要实现如下方法。
@Slf4j
public class ChatClient {
private static SocketChannel channel;
/**
* 与服务端建立连接的方法
*/
public static void connect() {
EventLoopGroup group = new NioEventLoopGroup(1);
try {
Bootstrap b = new Bootstrap();
b.group(group);
b.channel(NioSocketChannel.class);
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
channel = ch;
ch.pipeline().addLast(new MyClientHandler());
}
});
ChannelFuture cf = b.connect("localhost", 8888).sync();
//直到服务器被关闭,否则一直阻塞。
cf.channel().closeFuture().sync();
log.info("the chat client has been closed.");
} catch (Exception e) {
log.error("ChatClient.connect.Exception.", e);
} finally {
group.shutdownGracefully();
}
}
/**
* 向服务端发送聊天消息的方法
* @param msg 聊天内容
*/
public static void send(String msg) {
channel.writeAndFlush(Unpooled.copiedBuffer(msg.getBytes()));
log.info("client.send().{}", msg);
}
/**
* 关闭客户端方法,向服务端发送特定消息告知其删除本客户端。
*/
public static void close() {
send("__88__");
channel.close();
}
}
@Slf4j
class MyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 读取服务端数据
* @param msg 服务端数据
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
String text = buf.toString(StandardCharsets.UTF_8);
ChatFrame.INSTANCE.updateText(text);
log.info("channelRead.msg:{}", text);
}
/**
* 连接刚建立时的事件处理
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("connected to server.");
}
/**
* 异常处理
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("chat client exceptionCaught:", cause);
super.exceptionCaught(ctx, cause);
}
}
聊天室服务端(ChatServer.java)。
服务端需要记录所有的客户端。(可能有多个)
当某个客户端发来消息之后,需要将消息转发给所有客户端。
当接收到特殊消息时(客户端关闭),需要将客户端从列表中移除。
@Slf4j
public class ChatServer {
static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
public static void main(String[] args) throws Exception {
//总管线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//接待员线程
EventLoopGroup workerGroup = new NioEventLoopGroup(2);
//服务器启动辅助类
ServerBootstrap b = new ServerBootstrap();
//放在第一位的是总管线程组,第二位的就是接待员线程组。
b.group(bossGroup, workerGroup);
//异步全双工
b.channel(NioServerSocketChannel.class);
//接收到客户端连接的处理,相当于BIO的accept
b.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel sc) throws Exception {
log.info("a client connected:{}", sc);
sc.pipeline().addLast(new MyChildHandler());
}
});
b.bind(8888).sync();
}
}
@Slf4j
class MyChildHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ChatServer.clients.add(ctx.channel());
}
/**
* 读取客户端通道内的数据
* @param msg 客户端消息
* @throws Exception
*/
@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__")) {
ChatServer.clients.remove(ctx.channel());
ctx.close();
log.info("The chat client has been closed:{}", ctx.channel());
} else {
ChatServer.clients.writeAndFlush(msg);
log.info("ChatServer.clients.writeAndFlush:{}", msg);
}
}
/**
* 异常处理
*
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("exceptionCaught:", cause);
ChatServer.clients.remove(ctx.channel());
ctx.close();
}
}
补充服务端关闭的处理(仅思路,未实现)。
服务端UI
为了可以方便的看到所有客户端的连接情况和消息,以及后续进一步实现服务端的关闭效果考虑在服务端实现UI
新增一个ServerFrame类,实现服务端UI,服务端左边显示消息,右边显示客户端的连接情况。
ServerFrame类初始化时自动启动服务端。服务端接收消息时打印到消息窗口中。
有客户端连上或者关闭时显示到右边的窗口中。
实现效果如下图
参考代码如下。(只需要修改服务端代码,客户端不变)
@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("chat room");
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));
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);
}
}
@Slf4j
public class ChatServer {
static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
public static void start(){
//总管线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//接待员线程
EventLoopGroup workerGroup = new NioEventLoopGroup(2);
try {
//服务器启动辅助类
ServerBootstrap b = new ServerBootstrap();
//放在第一位的是总管线程组,第二位的就是接待员线程组。
b.group(bossGroup, workerGroup);
//异步全双工
b.channel(NioServerSocketChannel.class);
//接收到客户端连接的处理,相当于BIO的accept
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 has been started");
ChannelFuture cf = b.bind(8888).sync();
cf.channel().closeFuture().sync();
} catch (Exception e) {
log.error("ChatServer.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());
ChatServer.clients.add(ctx.channel());
}
/**
* 读取客户端通道内的数据
*
* @param msg 客户端消息
* @throws Exception
*/
@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__")) {
ChatServer.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 {
ChatServer.clients.writeAndFlush(msg);
ServerFrame.INSTANCE.updateMsg(ctx.channel().remoteAddress() + ">" + str);
log.info("ChatServer.clients.writeAndFlush:{}", msg);
}
}
/**
* 异常处理
*
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("ChatServer.exceptionCaught:", cause);
ChatServer.clients.remove(ctx.channel());
ctx.close();
}
}
启动顺序。先启动ServerFrame,然后启动ChatFrame,ChatFrame可以启动多个。
多个客户端发送消息都会在服务端显示。