一个基于NioServer的TCP通信实现方案

一个基于NioServer的TCP通信实现方案

## 需要的依赖

<dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.8</version>
        </dependency>

TCP服务器端

package cn.demo.tcp.server;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.BufferUtil;
import cn.hutool.core.lang.Console;
import cn.hutool.core.util.StrUtil;
import cn.hutool.socket.nio.NioServer;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.CopyOnWriteArrayList;

public class TcpServerUtil {

    private static final List<SocketChannel> scLoginList = new CopyOnWriteArrayList<>();

    public static void start() {
        //启动后定时打印 客户端连接信息
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                try {
                    printCurrentConns();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }, 1000, 5000); //延迟1s后,每5秒打印一次


        //启动一个tcp服务器端
        NioServer nioServer = new NioServer(8713);
        nioServer.setChannelHandler((sc) -> {
            //创建一个大小为1024的字节缓冲区
            ByteBuffer readBuf = ByteBuffer.allocate(1024);
            int readBytes = sc.read(readBuf);
            if (readBytes > 0) {
                //flip从起始位置开始读取数据
                readBuf.flip();
                //remaining要读取的字节长度
                byte[] bytes = new byte[readBuf.remaining()];
                //将缓冲区的数据读到bytes数组里
                readBuf.get(bytes);
                //byte[]转utf8字符串
                String body = StrUtil.utf8Str(bytes);
                Console.log("[{}] [{}]:报文内容-{}", DateUtil.now(), sc.getRemoteAddress(), body);
                doWrite(sc, body);
            } else if (readBytes < 0) {
                Console.log("[{}] [{}]:一个cli连接主动关闭了", DateUtil.now(), sc.getRemoteAddress());
                sc.close();
                scLoginList.remove(sc);
            }
        });
        //开启服务器监听
        nioServer.listen();


    }

    private static void printCurrentConns() throws IOException {
        int size = scLoginList.size();
        List<SocketChannel> socketChannels = new ArrayList<>();
        Console.log("[{}] ---当前共有{}个tcp连接---", DateUtil.now(), size);
        if (size > 0) {
            for (int i = 0; i < scLoginList.size(); i++) {
                SocketChannel sc = scLoginList.get(i);
                if (sc != null && sc.isOpen()) {
                    Console.log("[{}] 第{}个连接地址:{}", DateUtil.now(), i + 1, sc.getRemoteAddress());
                } else {
                    //去除异常断开的cli连接
                    socketChannels.add(sc);
                }
            }

        }
        scLoginList.removeAll(socketChannels);
        Console.log("[{}] ----------------------", DateUtil.now());
    }


    /**
     * 对一个已登陆的tcp连接推送消息
     *
     * @param sc  SocketChannel
     * @param msg String
     * @throws IOException
     */
    public static void pushMsg(SocketChannel sc, String msg) throws IOException {
        //tcp 服务器端可以通过在连接上发送数据来实现主动推送
        //一旦tcp连接建立,服务器和客户端都可以通过写入/读取数据 来进行通信

        //但是需要注意的是
        //由于tcp是面向连接的协议,双方通信需要遵循一定的规则和协议,
        //如,发送数据包的频率应合理控制,不要过于频繁,以免造成网络拥塞或过多数据包丢失
        //在实际应用中,应根据具体的场景和需求来确定服务器端向客户端进行推送的频率和方式

        //另外,如果是实时推送的场景,建议使用websocket协议,因为websocket提供了双向通信和更好的实时性.

        if (sc != null && sc.isOpen()) {
            Console.log("[{}] [{}]:服务器主动推送-{}", DateUtil.now(), sc.getRemoteAddress(), msg);
            sc.write(BufferUtil.createUtf8(msg));
        }
    }

    public static SocketChannel getFirstClient() {
        int size = scLoginList.size();
        if (size > 0) {
            return scLoginList.get(0);
        } else {
            return null;
        }
    }


    /**
     * 获取一个已登录的cli客户端
     *
     * @param addr /127.0.0.1:25626
     * @return SocketChannel
     */
    public static SocketChannel getOneSocketChannel(String addr) throws IOException {
        int size = scLoginList.size();
        if (size > 0) {
            for (SocketChannel sc : scLoginList) {
                if (sc.isOpen() && sc.getRemoteAddress().toString().equals(addr)) {
                    return sc;
                }
            }
        }
        return null;
    }

    /**
     * 处理NioServer监听到的客户端+报文
     *
     * @param sc   SocketChannel
     * @param body String
     * @throws IOException
     */
    private static void doWrite(SocketChannel sc, String body) throws IOException {
        if (!checkLogin(sc)) { //新cli连接
            //处理 tcp登录包
            handleTcpLogin(sc, body);
        } else { //已登录的cli连接
            //处理 正常请求包
            handleTcpReq(sc, body);
        }
    }

    /**
     * 检查是否登录过
     *
     * @param sc SocketChannel
     * @return boolean
     */
    private static boolean checkLogin(SocketChannel sc) {
        return scLoginList.contains(sc);
    }

    /**
     * 处理 tcp登录包
     *
     * @param sc   SocketChannel
     * @param body String   cli-login
     * @throws IOException
     */
    private static void handleTcpLogin(SocketChannel sc, String body) throws IOException {
        //tcp登录包是用于进行用户身份验证的数据包,包含了用户登陆所需的凭证信息,如用户名,密码,令牌等...
        //登录包 的内容可以是 定长字符串、JSON或XML等等...
        //登陆包的设计 应根据具体的应用需要进行扩展和定制,以满足安全性和身份验证要求


        //这里要求cli客户端登录报文(连接后的第一次send) 必须是 cli-login
        if ("cli-login".equals(body)) {
            Console.log("[{}] [{}]:{}", DateUtil.now(), sc.getRemoteAddress(), "tcp客户端登录成功");
            sc.write(BufferUtil.createUtf8("acl-ok!"));
            scLoginList.add(sc);
        } else {
            //登录失败,直接关掉cli连接
            Console.log("[{}] [{}]:{}", DateUtil.now(), sc.getRemoteAddress(), "tcp client Access denied!");
            sc.close();
        }
    }

    /**
     * 处理 正常请求包
     *
     * @param sc     SocketChannel
     * @param reqStr String
     * @throws IOException
     */
    private static void handleTcpReq(SocketChannel sc, String reqStr) throws IOException {
        //客户端心跳包检测 (tcp客户端应该定时发送一段报文,来作为心跳包,告知服务器端tcp client还存活着)
        heartBeatCheck(sc, reqStr);

        //一些业务触发 报文处理逻辑
        if ("aaa".equals(reqStr)) {
            //do something1...
            sc.write(BufferUtil.createUtf8("bbb"));
        } else if ("ccc".equals(reqStr)) {
            //do something2...
            sc.write(BufferUtil.createUtf8("ddd"));
        } else if ("EDFC".equals(reqStr)) {
            //do something3...
            //这里无论write多少次,客户端只会收到1次reply,在一次reply中返回带换行符的3组文本
            sc.write(BufferUtil.createUtf8("biz-STARTED-1\n"));
            sc.write(BufferUtil.createUtf8("biz-STARTED-2\n"));
            sc.write(BufferUtil.createUtf8("biz-STARTED-3\n"));

        } else {
            //不是C/S双方约定的报文内容
            if (!StrUtil.equals(reqStr, "cli-login") && !StrUtil.equals(reqStr, "cli-beat")) {
                Console.log("[{}] [{}]:{},不是约定的报文内容!", DateUtil.now(), sc.getRemoteAddress(), reqStr);
                sc.write(BufferUtil.createUtf8("not-correct-req-data!"));
            }
        }
    }


    /**
     * 客户端心跳包检测
     *
     * @param sc   SocketChannel
     * @param body String  cli-beat
     * @throws IOException
     */
    private static void heartBeatCheck(SocketChannel sc, String body) throws IOException {
        //tcp心跳包是为了维持连接的状态以及验证对方是否存活
        //心跳包 的频率和内容可以根据应用具体需求进行调整和定义
        if ("cli-beat".equals(body)) {
            Console.log("[{}] [{}]:{}", DateUtil.now(), sc.getRemoteAddress(), "cli心跳包正常");
            //tcp服务器不需要 回复 客户端发送的心跳包
//            sc.write(BufferUtil.createUtf8("beat-ok!"));
        }
    }
}
package cn.demo.tcp;

import cn.demo.tcp.server.TcpServerUtil;

import java.io.IOException;
import java.nio.channels.SocketChannel;
import java.util.Timer;
import java.util.TimerTask;

public class Test {

    public static void main(String[] args) throws IOException {


        //测试tcp 服务器主动推送消息到客户端
        Timer timer2 = new Timer();
        timer2.schedule(new TimerTask() {
            @Override
            public void run() {
                try {

                    SocketChannel firstClient = TcpServerUtil.getFirstClient();
                    if (firstClient != null && firstClient.isOpen()) {
                        TcpServerUtil.pushMsg(firstClient, "push-test-" + System.currentTimeMillis());
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }, 2000, 3000); //延迟2s后,每3秒打印一次

        //启动tcp服务
        TcpServerUtil.start();
    }
}

TCP客户端

package cn.demo.tcp.client;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.BufferUtil;
import cn.hutool.core.lang.Console;
import cn.hutool.core.util.StrUtil;
import cn.hutool.socket.nio.NioClient;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Scanner;
import java.util.Timer;
import java.util.TimerTask;

public class TcpClientTest {
    public static void main(String[] args) throws IOException, InterruptedException {

        NioClient nioClient = new NioClient("127.0.0.1", 8713);
        nioClient.setChannelHandler((sc)->{
            ByteBuffer readBuffer = ByteBuffer.allocate(1024);
            int readBytes = sc.read(readBuffer);
            if (readBytes>0){
                //flip从起始位置开始读取数据
                readBuffer.flip();
                //remaining要读取的字节长度
                byte[] bytes = new byte[readBuffer.remaining()];
                //将缓冲区的数据读到bytes数组里
                readBuffer.get(bytes);
                String body = StrUtil.utf8Str(bytes);
                //监听打印服务器端的报文
                Console.log("[{}]收到Tcp服务器响应报文:{}",sc.getRemoteAddress(),body);
            }else if (readBytes < 0){
                sc.close();
            }
        });
        //开启监听模式
        nioClient.listen();
        //发送 登录包
        nioClient.write(BufferUtil.createUtf8("cli-login"));


        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                //发送 心跳包
                nioClient.write(BufferUtil.createUtf8("cli-beat"));
            }
        },1000,15000);

        Thread.sleep(1300);



        //轮询读取控制台,实现 应声虫
        Scanner scanner = new Scanner(System.in);
        Console.log("请输入发送的消息: ");
        while (scanner.hasNextLine()){
            String reqStr = scanner.nextLine();
            if (reqStr!=null && reqStr.trim().length()>0){

                Console.log("[{}] [{}]:cli发送报文-{}", DateUtil.now(),
                        nioClient.getChannel().getLocalAddress(),
                        reqStr);
                nioClient.write(BufferUtil.createUtf8(reqStr));
                if (reqStr.equals("end")){
                    //告知服务器 client要关闭了,然后断开连接
                    nioClient.close();
                    break;
                }
            }
        }
        Console.log("tcp客户端主动关闭... ");
        System.exit(0);

    }
}

测试效果

服务器端

[2023-07-19 19:54:26] ---当前共有0个tcp连接---
[2023-07-19 19:54:26] ----------------------
[2023-07-19 19:55:31] [DEBUG] cn.hutool.socket.nio.AcceptHandler: Client [/127.0.0.1:54992] accepted.
[2023-07-19 19:55:31] [/127.0.0.1:54992]:报文内容-cli-login
[2023-07-19 19:55:31] [/127.0.0.1:54992]:tcp客户端登录成功
[2023-07-19 19:55:33] [/127.0.0.1:54979]:报文内容-cli-beat
[2023-07-19 19:55:33] [/127.0.0.1:54979]:cli心跳包正常
[2023-07-19 19:55:33] [/127.0.0.1:54992]:报文内容-aaa
[2023-07-19 19:55:34] [/127.0.0.1:54979]:服务器主动推送-push-test-1689767734333
[2023-07-19 19:55:36] ---当前共有2个tcp连接---
[2023-07-19 19:55:36] 第1个连接地址:/127.0.0.1:54979
[2023-07-19 19:55:36] 第2个连接地址:/127.0.0.1:54992
[2023-07-19 19:55:36] ----------------------
[2023-07-19 19:55:37] [/127.0.0.1:54979]:服务器主动推送-push-test-1689767737334
[2023-07-19 19:55:41] ---当前共有2个tcp连接---
[2023-07-19 19:55:41] 第1个连接地址:/127.0.0.1:54979
[2023-07-19 19:55:41] 第2个连接地址:/127.0.0.1:54992
[2023-07-19 19:55:41] ----------------------
[2023-07-19 19:55:41] [/127.0.0.1:54992]:报文内容-cli-beat
[2023-07-19 19:55:41] [/127.0.0.1:54992]:cli心跳包正常
[2023-07-19 19:55:43] [/127.0.0.1:54979]:服务器主动推送-push-test-1689767743334
[2023-07-19 19:55:46] ---当前共有2个tcp连接---
[2023-07-19 19:55:46] 第1个连接地址:/127.0.0.1:54979
[2023-07-19 19:55:46] 第2个连接地址:/127.0.0.1:54992
[2023-07-19 19:55:46] ----------------------
[2023-07-19 19:55:48] [/127.0.0.1:54979]:报文内容-cli-beat
[2023-07-19 19:55:48] [/127.0.0.1:54979]:cli心跳包正常
[2023-07-19 19:55:49] [/127.0.0.1:54979]:服务器主动推送-push-test-1689767749334

客户端

[/127.0.0.1:8713]收到Tcp服务器响应报文:acl-ok!
请输入发送的消息: 
[/127.0.0.1:8713]收到Tcp服务器响应报文:push-test-1689767671333
aaa
[2023-07-19 19:54:32] [/127.0.0.1:54979]:cli发送报文-aaa
[/127.0.0.1:8713]收到Tcp服务器响应报文:bbb
[/127.0.0.1:8713]收到Tcp服务器响应报文:push-test-1689767674333
ccc
[2023-07-19 19:54:35] [/127.0.0.1:54979]:cli发送报文-ccc
[/127.0.0.1:8713]收到Tcp服务器响应报文:ddd
[/127.0.0.1:8713]收到Tcp服务器响应报文:push-test-1689767677333
  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ThinkPet

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值