Netty版-简易聊天室

前言

  • 本文通过简易的聊天室案例,讲述Netty的基本使用,同时分享案例代码。
  • 项目还用到了log4j2,junit5,awt的使用,当然,大家也可以根据自己习惯修改。

功能说明

本聊天室为简易版,实现了聊天室的基本功能,主要用于练习Netty的使用。
功能如下:

  1. 聊天室支持多客户端,每个客户端都可以看到其他客户端的消息。
  2. 服务端的客户端列表中可以看到所有的客户端。
  3. 某个客户端关闭,同时在服务端的客户端列表中也删除。

环境说明

操作系统: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>

开发

开发思路

下文将按如下顺序进行开发

  1. 客户端-Netty开发
  2. 客户端-界面开发
  3. 服务端-Netty开发
  4. 服务端-界面开发

开发步骤

客户端-Netty开发

  1. 新建聊天室客户端Netty类:ClientNetty.java,此类主要有如下功能
  2. 与服务端建立连接的方法:connect()
  3. 向服务端发送聊天消息的方法:send()
  4. 读取服务端信息,更新客户端聊天内容方法: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);
    }
}

客户端-界面开发

  1. 新建聊天室客户端界面类:ClientFrame.java
  2. 在main方法中,初始化聊天室控件及其样式
  3. 客户端界面包含2个部分,上面文本域显示当前聊天室的所有聊天内容。底部文本框输入当前用户的聊天内容
  4. 聊天室窗口初始化时,使用Netty和服务端建立连接。
  5. 当用户输入完聊天内容后回车,将聊天内容通过Netty客户端发送给服务端。
  6. 当用户关闭窗口时,关闭当前客户端,同时在服务端的客户端列表中也删除。

参考代码

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开发

  1. 新建聊天室服务端Netty类:ServerNetty.java
  2. 当服务端启动时会调用start方法启动Netty服务端。
  3. 当服务端接收到入站信息时,使用自定义处理器处理:MyChildHandler
  4. 当客户端连接接入时,触发方法:channelActive()
    1. 服务端记录所有接入的客户端,可能有多个。
  5. 当有客户端消息发送到服务端时,触发方法:channelRead()
    1. 当某个客户端发来消息之后,需要将消息转发给其他客户端。
    2. 当接收到客户端关闭特殊消息时,将客户端从列表中移除。

参考代码

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();
    }
}

服务端-界面开发

  1. 新建聊天室服务端界面类:ServerFrame.java
  2. 服务端界面,左边显示消息,右边显示客户端的连接情况。
  3. 初始化时自动启动Netty服务端。将接收的消息打印到消息窗口中。
  4. 有客户端连上或者关闭时显示到右边的窗口中。

参考代码

/**
 * 聊天室-服务端界面<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);
    }
}

运行调试

  1. 启动服务端界面:ServerFrame
  2. 启动多个客户端:ChatFrame
  3. 注意:idea默认同一个类只能启动1个进程,想要要启动多个,请按如下操作
  4. 勾选 "Edit Configurations > Modify Options > Allow mutiple instances "
    在这里插入图片描述运行效果图:
    最左侧是服务端,右边2个是客户端
    在这里插入图片描述

其他功能

  1. 以下功能仅实现思路,目前暂未实现。
    1. 通知客户端,服务器准备关闭。
    2. 拒绝新的连接接入
    3. 等待所有客户端都处理完成。
    4. 开始关闭流程,发送消息给客户端,客户端自动处理。
    5. 确认所有客户端断开。
    6. server保存现有的工作数据。
    7. 停止线程组
    8. 退出。

单元测试

确认项目已加入Junit5依赖,就是如下这段。

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.9.3</version>
            <scope>test</scope>
        </dependency>

新建单元测试类的步骤。

  1. 在要创建单元测试的功能类上,依次点Code > generate > Test
  2. 然后在弹出的窗口中,选择Junit版本为5,测试类名,测试方法等。然后点确定。
  3. 在这里插入图片描述
  4. IDEA会自动根据功能类的路径在test目录中创建相同路径但以Test结尾的测试类。并且会自动生成勾选方法的默认测试代码。
  5. 根据程序的输入和输出,编写单元测试代码。
  6. 点击方法左边的绿色三角形就可以执行单元测试用例了。

为什么要单元测试?

  1. 方法内部可以很复杂,如果靠肉眼观察,比较耗时间。单元测试可以根据入参和返回值测试方法是否达到要求。
  2. 代码是开发人员写的,最了解代码逻辑的还是开发人员。测试人员测试不到代码细节。
  3. 在一个大的功能中,可能会有很多方法,每个方法都要写Main方法来一个个测试比较复杂,而且也不知道测了哪些场景。

为什么有的公司不做单元测试。

  1. 代码业务可能比较简单,程序员读代码不是很费力。
  2. 写单元测试需要额外花时间,程序员工作比较忙,没时间写。

参考说明

本文内容主要参考了马士兵老师的视频教程(Java经典实战项目-坦克大战

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值