2023-12-03
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/series/article/3609449697

在前面 8 篇文章中,大明哥详细阐述了 Netty 核心组件的基本原理及核心 API 的使用,但是那些只是 API 的使用,且只有一个简单的 hello world 的 demo,没有一个 demo 来完整演示客户端与服务端之间的交互。这篇文章就完整演示客户端与服务端的交互,场景:智障客户(zzkf)。如果有小伙伴对 Netty 的核心组件还不是很清晰的话,可以移步相关文章再复习下:

前言

客户端连接服务端后,服务端给客户端发送一段如下的消息:

Xxx,您好,我是智能客服小磕。请告诉你想咨询的服务
1. 死磕 Java 目前哪些系列
2. 死磕 Java 的网址哪个
3. 投诉与建议
0. 人工

客户端输入相关数字,服务端返回相应内容,这样就可以完成客户端与服务端的交互。类似这样的:

实现流程

我们实现的效果是一个基于 console 控制台的效果,从效果来看,这里有三个角色:

  • 智能客服:服务端
  • 用户:客户端

这两个角色承担的责任如下:

  1. 服务端
    1. 维护客户端。维护客户映射表,他需要将用户的 Channel 保存下来。(没有登录,且为后面人工坐席做准备)
    2. 根据客户输入的问题编码返回对应的答案
  • 用户
    • 向服务端注册自己
    • 输入问题编码,接收答案

最后,我们还要有一个编解码器,因为这篇文章是入门级别,且大明哥还没有讲述自定义编解码器,所以这里统一使用 StringDecoder 和 StringEncoder。不了解编解码器的小伙伴可以先去简单了解下。

功能实现

基础代码

// 服务端基础代码
public class ZzkfServer {
    public static void main(String[] args) {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(new NioEventLoopGroup(),new NioEventLoopGroup());
        bootstrap.channel(NioServerSocketChannel.class);
        bootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
            @Override
            protected void initChannel(NioSocketChannel ch) throws Exception {
                //...
            }
        });
        // 绑定 8081 端口
        bootstrap.bind(8081);
    }
}

// 客户端基础代码
public class UserClient {
    public static void main(String[] args) throws Exception {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(new NioEventLoopGroup());
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.handler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                //...
            }
        });
        bootstrap.connect();

        bootstrap.connect("127.0.0.1",8081).sync().channel();
    }
}

服务端 handler

服务端有两个 handler:

  • 一个 handler 用于客户端注册维护用户映射关系。

  • 一个 handler 用于回复客户消息。

  • ClientRegisterHandler,维护用户映射关系。

public class ClientRegisterHandler extends SimpleChannelInboundHandler {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        String[] messages = ((String) msg).split("&");

        // 消息体有两部分构成  key&value
        // key 用来区分身份,value 是真正的消息
        String key = messages[0];
        String value = messages[1];

        Channel channel = ctx.channel();

        if (MessageTypeEnum.USER_REGISTER.getType().equals(key)) {
            // 用户
            ChannelSessionMemory.putClientChannel(channel.id().toString(),channel);

           // 用户注册成功后,立刻向用户发送问题列表
            sendInitMessage(channel);
        } else {
            ctx.fireChannelRead(value);
        }
    }

    /**
     * 发送消息
     * @param channel
     */
    private void sendInitMessage(Channel channel) {
        StringBuilder message = new StringBuilder(channel.id().toString() + ",您好,我是智能客服小磕。请告诉你想咨询的服务").append("\r\n")
                .append("1. 死磕 Java 目前哪些系列").append("\r\n")
                .append("2. 死磕 Java 的网址哪个").append("\r\n")
                .append("3. 投诉与建议").append("\r\n")
                .append("0. 人工").append("\r\n")
                .append("==================请输入咨询问题的编号==================");
        channel.writeAndFlush(message);
    }
}

这个 handler 需要区分消息类型,因为我们目前采用的还不是自定义类型的 ChannelHandler,所以所有的 InboundHandler 都需要过滤一遍消息,判断该消息是否是自己处理的。如果不是则流转到下一个 handler。

当完成客户端注册后,就立刻将问题清单发送给用户。

  • 智能回复 handler,该 handler 则是根据用户输入的问题编码将答案发送给用户。
public class AiAnswerHandler extends SimpleChannelInboundHandler {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        String key = (String) msg;

        if ("0".equals(key)) {
            ctx.channel().writeAndFlush("暂无人工坐席");
        } else {
            String message = ServiceAnswerSet.getAnswer(key);
            ctx.channel().writeAndFlush(message);
        }
    }
}

这个 handler 没有实现人工,人工坐席相对复杂 下,这部分我们下篇文章实现。

这里注意下这个代码 String key = (String) msg; 为什么这不需要使用 & 来进行分割了?因为我们在它的前置 handler ClientRegisterHandler 已经将消息 msg 进行了处理,传递给它的是一个真正的没有带前缀的消息提 value ,所以这里就不用分割了。在下篇文章(人工坐席)中,你会发现传递的参数又会不一样。

  • 将 handler 添加到 ChannelPipeline 中
ch.pipeline().addLast(new StringDecoder());     // 解码
ch.pipeline().addLast(new StringEncoder());     // 编码

ch.pipeline().addLast(new ClientRegisterHandler());
ch.pipeline().addLast(new AiAnswerHandler());

这里一定要注意添加 ChannelHandler 的顺序,如果要小伙伴还不清楚 ChannelPipeline 中 ChannelHandler 的执行顺序,先看看这篇文章(Netty 入门 — ChannelPipeline,Netty 的核心编排组件)的事件传播机制。

这里有两个编解码器,StringDecoder 解码器,StringEncoder 编码器,我们传递的消息格式很简单,所以这两个编解码器够用了,后面进阶部分大明哥会分享自定义编解码器。还有,这两个编解码器为什么要放在最上面,大明哥就不解释了(其实他们的顺序还可以调整下,我想熟悉 ChannelPipeline 事件传播机制的小伙伴应该知道怎么调整了)。

客户端Handler

客户端的 handler 比较简单,它做两件事:

  1. 连接服务端成功后,立刻向服务端注册。
  2. 接收消息,并做相对应的响应,输入相对应的问题编号。
public class ConsultHandler extends ChannelInboundHandlerAdapter {
    
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ctx.channel().writeAndFlush(MessageTypeEnum.USER_REGISTER.getType() + "&我是用户:" + ctx.channel().id().toString());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        String message = (String) msg;
        System.out.println(message);

        Scanner scanner = new Scanner(System.in);
        String key = scanner.nextLine();
        ctx.channel().writeAndFlush(MessageTypeEnum.USER_QUESTION.getType() + "&" + key);
    }
}

将 Handler 添加到 ChannelPipeline 中。

ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());

ch.pipeline().addLast(new ConsultHandler());

运行结果

运行结果完美展示了智能客服(ZZKF)的强大之处,但是有一个地方没有实现,那就是人工坐席。为什么大明哥不在这篇文章里面再加呢?因为我觉得你还需要吸收里面的内容,前面 8 篇文章都是理论已经 API 的使用,这篇文章算是一个完整的 demo 程序了,它完全表达了客户端与服务端交互的流程,以及 ChannelHandler 的处理逻辑,所以小伙伴先吸收下吧,你要能做到不看这个 demo 就能完整敲出来才算真正掌握了这篇文章。

因为不是核心逻辑,所以有部分代码没有贴出来,各位小伙去源码里面捞吧。

源码:http://suo.nz/1gaXge

阅读全文