一篇文章,大明哥教会你如何来写一个智障客服

大家好,我是大明哥,一个专注「死磕 Java」系列文章创作的程序员。 本文已收录到我的小站:skjava.com


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

智能客服

前言

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

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

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

实现流程

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

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

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

  1. 服务端

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

    • 向服务端注册自己
    • 输入问题编码,接收答案

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

功能实现

基础代码

java 复制代码
// 服务端基础代码
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,维护用户映射关系。
scss 复制代码
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 则是根据用户输入的问题编码将答案发送给用户。
scala 复制代码
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 中
scss 复制代码
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. 接收消息,并做相对应的响应,输入相对应的问题编号。
java 复制代码
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 中。

scss 复制代码
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());

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

运行结果

运行结果完美展示了智能客服(ZZKF)的强大之处。

人工坐席

上面部分,大明哥实现了智能客服,它能够很好的解决用户的一些常见的问题,但是有些问题依然还需要人工客服来完成,下面就来看看如何实现人工坐席。

上篇文章(Netty 入门 --- 第一个完整的 demo 之智能客服)大明哥演示了强大的智能客服,它能够很好的解决用户的一些常见的问题,但是有些问题依然还需要人工客服来完成,所以这篇文章我们来实现人工坐席。

功能实现

当用户输入 0 的时候,智能客户需要将这个用户转发给某一个人工客服,如果没有人工客户在线需要提示用户是否继续等待。在等待期间若有人工客服上线则将其转接到该用户。

那么服务端需要做如下几件事情:

  1. 维护用户与人工坐席之间的关系,发的消息不能串。
  2. 将人工坐席分配给用户。若没有人工坐席,则在用户等待期间,人工坐席上线则将其转接给该用户。
  3. 消息的转发。

至于用户和人工坐席,则相对来说就比较简单了,接收服务端的消息,并发送消息。

代码实现

代码逻辑相比上篇文章智能 AI 来说有点儿复杂代码,所以大明哥将其分为两部分实现:

  1. 无人工坐席
  2. 有人工坐席

无人工坐席

无人工坐席的时候,用户就是与智能客服交互,在用户输入 0 的时候,智能客服需要去获取已经注册在线的人工坐席,如果没有需要提示用户是否继续等待。

服务端

  • 服务端基础代码
Java 复制代码
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 {
                ch.pipeline().addLast(new StringDecoder());     // 解码
                ch.pipeline().addLast(new StringEncoder());     // 编码
            }
        });
        // 绑定 8081 端口
        bootstrap.bind(8081);
    }
}
  • 用户注册 handler

因为这个时候已经有用户注册、人工坐席注册了,所以为了遵循单一职责原则,我们需要将用户注册拆分出来。

Java 复制代码
public class UserRegisterHandler 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];

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

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

    /**
     * 发送消息
     * @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 比较简单,它只做用户注册工作,注册成功后,给用户发送初始化消息,告诉用户它能做哪些事。

  • 智能客服回答 handler
Java 复制代码
public class AiAnswerHandler extends SimpleChannelInboundHandler {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        String[] messages = ((String) msg).split("&");
        String key = messages[0];

        Channel channel = ctx.channel();
        String userChannelId = channel.id().toString();

        if (MessageTypeEnum.USER_QUESTION.getType().equals(key)) {
            String value = messages[1];

            if ("0".equals(value)) {
                // 转交给人工 handler 处理
                String rgChannelId = ChannelSessionMemory.getRgChannelId();
                if (rgChannelId == null) {
                    // 如果没有,先将用户添加到等待队列,然后返回给用户目前暂无人工坐席
                    ChannelSessionMemory.putWaitingUser(userChannelId);
                    channel.writeAndFlush("当前暂无人工坐席,继续等待请输入 0 ,退出请输入 9");
                } else {
                    Channel rgChannel = ChannelSessionMemory.getClientChannel(rgChannelId);
                    rgChannel.writeAndFlush(MessageTypeEnum.USER_HELLO.getType() + "&" + userChannelId + "|有用户咨询");
                }
            } else if ("9".equals(value)) {
                // 用户退出
                ChannelSessionMemory.deleteWaitingUser(userChannelId);
                channel.writeAndFlush("对不起,无法解决您的问题,深感抱歉");
            } else {
                String answer = ServiceAnswerSet.getAnswer(value);
                channel.writeAndFlush(answer);
            }
        }
    }
}

回答问题 handler 就需要区分用户输入的指令了。

  • 0:判断当前是否有在线的人工坐席,如果没有,将用户加入等待队列,并通知用户暂无人工坐席

  • 9:将用户从等待队列中删除

  • 其他:从答案库里面找到问题集,返回给用户

  • 添加到 ChannelPipeline 中

Java 复制代码
ch.pipeline().addLast(new UserRegisterHandler());
ch.pipeline().addLast(new AiAnswerHandler());

用户端

  • 用户基础代码
Java 复制代码
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 {
                ch.pipeline().addLast(new StringDecoder());
                ch.pipeline().addLast(new StringEncoder());
                ch.pipeline().addLast(new UserHandler());
            }
        });

        bootstrap.connect("127.0.0.1",8081).sync().channel();
    }
}
  • 用户处理 handler
Java 复制代码
public class UserHandler 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);
    }
}

用户部分逻辑还是非常简单的,这里就阐述了。

运行结果

输入 0 的时候,需要等待 30 秒。

我相信这个截图完美呈现了无人工坐席的场景。

有人工坐席

这里我们就需要再增加一个人工坐席客户端了。

服务端

服务端逻辑比较复杂了,因为它不仅仅只是智能客服了,他需要维护人工坐席,用户与人工坐席的对话。部分已经将没有人工坐席的部分写好,现在我们来梳理人工坐席的逻辑。

  • 人工坐席注册

当人工坐席上线后,他需要注册到服务端,告诉服务端它上线了。

Java 复制代码
public class RgRegisterHandler extends SimpleChannelInboundHandler {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        String[] messages = ((String) msg).split("&");
        String key = messages[0];

        if (MessageTypeEnum.RG_REGISTER.getType().equals(key)) {
            Channel channel = ctx.channel();
            String channelId = channel.id().toString();
            // 注册
            ChannelSessionMemory.putClientChannel(channelId,channel);

            String waitingUser = ChannelSessionMemory.getWaitingUser();
            if (waitingUser == null || "".equals(waitingUser)) {
                // 无用户等待,则直接加入
                ChannelSessionMemory.putRg(channelId);
            } else {
                // 这里的消息体 messageType&channelId|message
                // -1 消息体表示用户刚刚进来,需要人工去打招呼
                channel.writeAndFlush(MessageTypeEnum.USER_HELLO.getType() + "&" + waitingUser + "|-1");
            }
        } else {
            ctx.fireChannelRead(msg);
        }
    }
}

对于人工坐席注册,它除了注册之外还需要做两件事:

  1. 判断这个时候是否有用户在等待,如果有客户在等待的,则服务端需要通知人工坐席与该用户打招呼。
  2. 如果没有用户,则他就加入到等待池中,等下一个用户来咨询的时候可以直接联系。大明哥在智能客服回答 handler:AiAnswerHandler中已经有这部分逻辑了,当用户输入 0 的时候就判断当前是否有人工坐席在线。
  • 用户-人工坐席对话

当用户与人工坐席建立联系后,他们就可以对话了。

Java 复制代码
public class UserRgChatHandler extends SimpleChannelInboundHandler {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        String[] messages = ((String)msg).split("&");
        String messageType = messages[0];
        if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
            String[] values = messages[1].split("\\|");

            // 转发的内容
            String content = values[1];

            // 发给谁
            String toChannelId = values[0];
            Channel toChannel = ChannelSessionMemory.getClientChannel(toChannelId);

            // 从哪里来
            String fromChannelId = ctx.channel().id().toString();

            // message
            content = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + fromChannelId + "|" + content;

            // 发送内容
            toChannel.writeAndFlush(content);
        } else {
            ctx.fireChannelRead(msg);
        }
    }
}

这个 handler 一定要注意发送的消息体,大明哥采用的消息体为 messageType&toChannelId|content 。为什么要这么设计呢?因为用户与人工坐席是绑定的关系,他们不能串,他需要告诉服务端他是与谁进行交互。

  • 对于用户来的消息:fromChannelId 则为用户 channelId,toChannelId 则为绑定的人工做些 channelId,同时服务端要使用 toChannel 来发送,因为要发给人工坐席。

  • 对于人工坐席的消息:与上面一致。

  • 添加到 ChannelPipeline 中

Java 复制代码
ch.pipeline().addLast(new UserRegisterHandler());
ch.pipeline().addLast(new RgRegisterHandler());
ch.pipeline().addLast(new AiAnswerHandler());
ch.pipeline().addLast(new UserRgChatHandler());

还是那句话,注意顺序。

人工坐席

对于人工坐席就稍微简单了,他和用户端一样,做两件事,1 是连接成功后,注册到服务端,2 是与用户端交互。

Java 复制代码
public class RgHandler extends ChannelInboundHandlerAdapter {
    
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ctx.channel().writeAndFlush(MessageTypeEnum.RG_REGISTER.getType() + "&我是人工坐席:" + ctx.channel().id().toString());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        String[] message = ((String) msg).split("&");
        String messageType = message[0];
        String[] values = message[1].split("\\|");
        String userChannelId = values[0];

        String rgChannelId = ctx.channel().id().toString();

        if (MessageTypeEnum.USER_HELLO.getType().equals(messageType)) {
            System.out.println("有用户[" + userChannelId + "]在等待人工坐席,请尽快接入");

            ctx.channel().writeAndFlush(MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + userChannelId + "|您好,我是工号[" + rgChannelId + "],请问您有什么问题需要咨询吗?");
        } else if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
            String content = values[1];

            System.out.println("用户说:" + content);
            System.out.println();

            Scanner scanner = new Scanner(System.in);
            String rgContent = scanner.nextLine();

            // 人工坐席 Channel
            String toChannelId = values[0];
            rgContent = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + toChannelId + "|" + rgContent;

            ctx.channel().writeAndFlush(rgContent);
        }

    }
}
  • 添加到 ChannelPipeline 中
Java 复制代码
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());

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

用户端

用户端的 handler 就需要改造下了,他需要区分是智能客服来的还是人工坐席的。两者的消息提不一样。当然这里也是可以拆分为两个 handler,这是没有必要了。

Java 复制代码
public class UserHandler 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[] messages = ((String) msg).split("&");
        String messageType = messages[0];
        if (MessageTypeEnum.USER_QUESTION.getType().equals(messageType)) {
            // 如果是智能客服
            System.out.println(messages[1]);
            System.out.println();

            Scanner scanner = new Scanner(System.in);
            String key = scanner.nextLine();

            ctx.channel().writeAndFlush(MessageTypeEnum.USER_QUESTION.getType() + "&" + key);
        } else if (MessageTypeEnum.RG_USER_MESSAGE.getType().equals(messageType)) {
            // 人工坐席
            String[] values = messages[1].split("\\|");
            String content = values[1];
            System.out.println("人工坐席说:" + content);
            System.out.println();

            Scanner scanner = new Scanner(System.in);
            String userContent = scanner.nextLine();


            // 人工坐席 Channel
            String toChannelId = values[0];
            userContent = MessageTypeEnum.RG_USER_MESSAGE.getType() + "&" + toChannelId + "|" + userContent;

            ctx.channel().writeAndFlush(userContent);
        }
    }
}

运行结果

运行结果有两种结果,1 是用户先输入 0 ,然后等待人工坐席,2 是用户登录的时候有人工坐席。

  • 用户输入 0 ,等待人工坐席

用户端日志

输入 0 后,其实用户一直在这里等待,小伙伴去看下 ChannelSessionMemory.getRgChannelId() 这个获取人工做些的代码,其实大明哥这里使用的是阻塞队列 ArrayBlockingQueue,在获取的时候会等待 30 秒。

当人工坐席注册后,日志如下:

人工坐席一注册,智能客服就告诉他有用户在等待,需要他尽快接入,这个时候,他会给用户发送一条消息:您好,我是工号[xxx],请问您有什么问题需要咨询吗?,用户收到这个消息后就知道有人工坐席了,这个时候他们就可以对话了。

  • 用户输入 0 时,有人工坐席在线

如果用户输入 0 的时候,有人工坐席在线的话,会立刻收到 您好,我是工号[xxx],请问您有什么问题需要咨询吗?

对于人工坐席而言,他不会收到有 xxx 用户在等待,而是直接收到用户发过来的消息。

到这里整个人工坐席就已经完成了,是不是超级牛逼(LJ),超级智能(ZZ)。当然里面还有很多问题没有解决,比如用户突然下线怎么处理?还有并发情况等等一系列的情况,但是这个毕竟是一个入门级别的 demo,我们的目的是掌握 Netty 核心组件的基本概念,以及使用方法。

文章代码只是部分,完整的代码见源码部分。

源码:suo.nz/1ZjowB

相关推荐
吾日三省吾码3 小时前
JVM 性能调优
java
Estar.Lee3 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi
弗拉唐4 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi775 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
2401_857610035 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
少说多做3435 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀5 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20205 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深5 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++