看完這篇包你進大廠,實戰即時聊天,一文說明白:聊天伺服器+聊天客戶端+Web管理控制檯。

失足成萬古風流人物發表於2021-05-17

一、前言

  說實話,寫這個玩意兒是我上週剛剛產生的想法,本想寫完後把程式碼掛上來賺點積分也不錯。寫完後發現這東西值得寫一篇文章,授人予魚不如授人以漁嘛(這句話是這麼說的吧),順便賺點應屆學生MM的膜拜那就更妙了。然後再掛一個收款二維碼,一個人1塊錢,一天10000個人付款,一個月30萬,一年360萬。。。可了不得了,離一個億的小目標就差幾十年了。

  不知道部落格園對夢話有沒有限制,有的話請告知,我會盡快刪除上述文字。

  那麼現在回到現實中,這篇博文如果能有>2個評論,我後續會再出一個Netty相關的專欄。否則,就不出了。有人會好奇,為什麼把閾值定義成>2呢?不為什麼,因為我肯定會先用我媳婦兒的號留個言,然後用自己的號留個言。

  好了,廢話不多說了,後面還有好多事兒呢,洗菜、做飯、刷碗、跪搓衣。。。好了,言歸正傳吧。

二、最終效果

  為什麼先看最終效果?因為此刻程式碼已經擼完了。更重要的是我們帶著感官的目標去進行後續的分析,可以更好地理解。標題中提到了,整個工程包含三個部分:

1、聊天伺服器

  聊天伺服器的職責一句話解釋:負責接收所有使用者傳送的訊息,並將訊息轉發給目標使用者。

  聊天伺服器沒有任何介面,但是卻是IM中最重要的角色,為表達敬意,必須要給它放個效果圖:

 

2021-05-11 10:41:40.037  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700900029,"messageType":"99"}
2021-05-11 10:41:50.049  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.n.handler.BussMessageHandler     : 收到訊息:{"time":1620700910045,"messageType":"14","sendUserName":"guodegang","recvUserName":"yuqian","sendMessage":"於老師你好"}
2021-05-11 10:41:50.055  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.executor.SendMsgExecutor   : 訊息轉發成功:{"time":1620700910052,"messageType":"14","sendUserName":"guodegang","recvUserName":"yuqian","sendMessage":"於老師你好"}
2021-05-11 10:41:54.068  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700914064,"messageType":"99"}
2021-05-11 10:41:57.302  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.n.handler.BussMessageHandler     : 收到訊息:{"time":1620700917301,"messageType":"14","sendUserName":"yuqian","recvUserName":"guodegang","sendMessage":"郭老師你好"}
2021-05-11 10:41:57.304  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.executor.SendMsgExecutor   : 訊息轉發成功:{"time":1620700917303,"messageType":"14","sendUserName":"yuqian","recvUserName":"guodegang","sendMessage":"郭老師你好"}
2021-05-11 10:42:05.050  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700925049,"messageType":"99"}
2021-05-11 10:42:12.309  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700932304,"messageType":"99"}
2021-05-11 10:42:20.066  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700940050,"messageType":"99"}
2021-05-11 10:42:27.311  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700947309,"messageType":"99"}
2021-05-11 10:42:35.070  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700955068,"messageType":"99"}
2021-05-11 10:42:42.316  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700962312,"messageType":"99"}
2021-05-11 10:42:50.072  INFO 9392 --- [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700970071,"messageType":"99"}
2021-05-11 10:42:57.316  INFO 9392 --- [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler   : server收到心跳包:{"time":1620700977315,"messageType":"99"}

  從效果圖我們看到了一些內容:收到心跳包、收到訊息,轉發訊息,這些內容後面會詳細講解。

2、聊天客戶端

  聊天客戶端的職責一句話解釋:登陸,給別人發聊天內容,收其它人發給自己的聊天內容。

  下面為方便演示,我會開啟兩個客戶端,用兩個不同使用者登陸,然後發訊息。

 

3、Web管理控制檯

  目前只做了一個賬戶管理,具體看圖吧:

三、需求分析

  無(見第二章節)。

四、概要設計

1、技術選型

1)聊天服務端

  聊天伺服器與客戶端通過TCP協議進行通訊,使用長連線、全雙工通訊模式,基於經典通訊框架Netty實現。

  那麼什麼是長連線?顧名思義,客戶端和伺服器連上後,會在這條連線上面反覆收發訊息,連線不會斷開。與長連線對應的當然就是短連線了,短連線每次發訊息之前都需要先建立連線,然後發訊息,最後斷開連線。顯然,即時聊天適合使用長連線。

  那麼什麼又是全雙工?當長連線建立起來後,在這條連線上既有上行的資料,又有下行的資料,這就叫全雙工。那麼對應的半雙工、單工,大家自行百度吧。

2)Web管理控制檯

  Web管理端使用SpringBoot腳手架,前端使用Layuimini(一個基於Layui前端框架封裝的前端框架),後端使用SpringMVC+Jpa+Shiro。

3)聊天客戶端

  使用SpringBoot+JavaFX,做了一個極其簡陋的客戶端,JavaFX是一個開發Java桌面程式的框架,本人也是第一次使用,程式碼中的寫法都是網上查的,這並不是本文的重點,有興趣的仔細百度吧。

4)SpringBoot

  以上三個元件,全部以SpringBoot做為腳手架開發。

5)程式碼構建

  Maven。

2、資料庫設計

  我們只簡單用到一張使用者表,比較簡單直接貼指令碼:

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `user_name` varchar(64) DEFAULT NULL COMMENT '使用者名稱:登陸賬號',
  `pass_word` varchar(128) DEFAULT NULL COMMENT '密碼',
  `name` varchar(16) DEFAULT NULL COMMENT '暱稱',
  `sex` char(1) DEFAULT NULL COMMENT '性別:1-男,2女',
  `status` bit(1) DEFAULT NULL COMMENT '使用者狀態:1-有效,0-無效',
  `online` bit(1) DEFAULT NULL COMMENT '線上狀態:1-線上,0-離線',
  `salt` varchar(128) DEFAULT NULL COMMENT '密碼鹽值',
  `admin` bit(1) DEFAULT NULL COMMENT '是否管理員(只有管理員才能登入Web端):1-是,0-否',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

  這張表都在什麼時候用到?

  1)Web管理端登陸的時候;2)聊天客戶端將登陸請求傳送到聊天服務端時,聊天服務端進行使用者認證;3)聊天客戶端的好友列表載入。

3、通訊設計

  本節將會是本文的核心內容之一,主要描述通訊報文協議格式、以及通訊報文的互動場景。

1)報文協議格式

  下面這張圖應該能說明99%了:

  剩下的1%在這裡說:

  a)粘包問題,TCP長連線中,粘包是第一個需要解決的問題。通俗的講,粘包的意思是訊息接收方往往收到的不是“整個”報文,有時候比“整個”多一點,有時候比“整個”少一點,這樣就導致接收方無法解析這個報文。那麼上圖中的頭8個位元組就為了解決這個問題,接收方根據頭8個位元組標識的長度來獲取到“整個”報文,從而進行正常的業務處理;

  b)2位元組報文型別,為了方便解析報文而設計。根據這兩個位元組將後面的json轉成相應的實體以便進行後續處理;

  c)變長報文體實際上就是json格式的串,當然,你可以自己設計報文格式,我這裡為了方便處理就直接放json了;

  d)當然,你可以把報文設計的更復雜、更專業,比如加密、加簽名等。

2)報文互動場景

  a)登陸

  b)傳送訊息-成功

  c)傳送訊息-目標客戶端不線上

  d)傳送訊息-目標客戶端線上,但訊息轉發失敗

五、編碼實現

  前面說了那麼多,現在總得說點有用的。

1、先說說Netty

  Netty是一個相當優秀的通訊框架,大多數的頂級開源框架中都有Netty的身影。具體它有多麼優秀,建議大家自行百度,我不如百度說的好。我只從應用方面說說Netty。應用過程中,它最核心的東西叫handler,我們可以簡單理解它為訊息處理器。收到的訊息和出去的訊息都會經過一系列的handler加工處理。收到的訊息我們叫它入站訊息,發出去的訊息我們叫它出站訊息,因此handler又分為出站handler和入站handler。收到的訊息只會被入站handler處理,發出去的訊息只會被出站handler處理。

  舉個例子,我們從網路上收到的訊息是二進位制的位元組碼,我們的目標是將訊息轉換成java bean,這樣方便我們程式處理,針對這個場景我設計這麼幾個入站handler:

  1)將位元組轉換成String的handler;

  2)將String轉成java bean的handler;

  3)對java bean進行業務處理的handler。

  發出去的訊息呢,我設計這麼幾個出站handler:

  1)java bean 轉成String的handler;

  2)String轉成byte的handler。

  以上是關於handler的說明。

  接下來再說一下Netty的非同步。非同步的意思是當你做完一個操作後,不會立馬得到操作結果,而是有結果後Netty會通知你。通過下面的一段程式碼來說明:

channel.writeAndFlush(sendMsgRequest).addListener(new GenericFutureListener<Future<? super Void>>() {
                @Override
                public void operationComplete(Future<? super Void> future) throws Exception {
                    if (future.isSuccess()){
                        logger.info("訊息傳送成功:{}",sendMsgRequest);
                    }else {
                        logger.info("訊息傳送失敗:{}",sendMsgRequest);
                    }
                }
            });

  上面的writeAndFlush操作無法立即返回結果,如果你關注結果,那麼為他新增一個listener,有結果後會在listener中響應。

  到這裡,百度上搜到的Netty相關的程式碼你基本就能看懂了。

2、聊天服務端

  首先看主入口的程式碼

public void start(){
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap.group(boss, worker)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 1024)
                .handler(new LoggingHandler(LogLevel.INFO))
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        //心跳
                        ch.pipeline().addLast(new IdleStateHandler(25, 20, 0, TimeUnit.SECONDS));
                        //收整包
                        ch.pipeline().addLast(new StringLengthFieldDecoder());
                        //轉字串
                        ch.pipeline().addLast(new StringDecoder(Charset.forName("UTF-8")));
                        //json轉物件
                        ch.pipeline().addLast(new JsonDecoder());
                        //心跳
                        ch.pipeline().addLast(new HeartBeatHandler());
                        //實體轉json
                        ch.pipeline().addLast(new JsonEncoder());
                        //訊息處理
                        ch.pipeline().addLast(bussMessageHandler);
                    }
                });
        try {
            ChannelFuture f = serverBootstrap.bind(port).sync();
            f.channel().closeFuture().sync();
        }catch (InterruptedException e) {
            logger.error("服務啟動失敗:{}", ExceptionUtils.getStackTrace(e));
        }finally {
            worker.shutdownGracefully();
            boss.shutdownGracefully();
        }
    }

  程式碼中除了initChannel方法中的程式碼,其他程式碼都是固定寫法。那麼什麼叫固定寫法呢?通俗來講就是可以Ctrl+c、Ctrl+v。

  下面我們著重看initChannel方法裡面的程式碼。這裡面就是上面講到的各種handler,我們下面挨個講這些handler都是幹啥的。

  1)IdleStateHandler。這個是Netty內建的一個handler,既是出站handler又是入站handler。它的作用一般是用來實現心跳監測。所謂心跳,就是客戶端和服務端建立連線後,服務端要實時監控客戶端的健康狀態,如果客戶端掛了或者hung住了,服務端及時釋放相應的資源,以及做出其他處理比如通知運維。所以在我們的場景中,客戶端需要定時上報自己的心跳,如果服務端檢測到一段時間內沒收到客戶端上報的心跳,那麼及時做出處理,我們這裡就是簡單的將其連線斷開,並修改資料庫中相應賬戶的線上狀態。

  現在開始說IdleStateHandler,第一個引數叫讀超時時間,第二個引數叫寫超時時間,第三個引數叫讀寫超時時間,第四個引數時時間單位秒。這個handler表達的意思是當25秒內沒讀到客戶端的訊息,或者20秒內沒往客戶端發訊息,就會產生一個超時事件。那麼這個超時事件我們該對他做什麼處理呢,請看下一條。

  2)HeartBeatHandler。結合a)一起看,當發生超時事件時,HeartBeatHandler會收到這個事件,並對它做出處理:第一將連結斷開;第二講資料庫中相應的賬戶更新為不線上狀態。

public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
    private static Logger logger = LoggerFactory.getLogger(HeartBeatHandler.class);

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent){
            IdleStateEvent event = (IdleStateEvent)evt;
            if (event.state() == IdleState.READER_IDLE) {
                //讀超時,應將連線斷掉
                InetSocketAddress socketAddress = (InetSocketAddress)ctx.channel().remoteAddress();
                String ip = socketAddress.getAddress().getHostAddress();
                ctx.channel().disconnect();
                logger.info("【{}】連線超時,斷開",ip);
                String userName = SessionManager.removeSession(ctx.channel());
                SpringContextUtil.getBean(UserService.class).updateOnlineStatus(userName,Boolean.FALSE);
            }else {
                super.userEventTriggered(ctx, evt);
            }
        }else {
            super.userEventTriggered(ctx, evt);
        }

    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof HeartBeat){
            //收到心跳包,不處理
            logger.info("server收到心跳包:{}",msg);
            return;
        }
        super.channelRead(ctx, msg);
    }
}
 

  3)StringLengthFieldDecoder。這是個入站handler,他的作用就是解決上面提到的粘包問題:

public class StringLengthFieldDecoder extends LengthFieldBasedFrameDecoder {
    public StringLengthFieldDecoder() {
        super(10*1024*1024,0,8,0,8);
    }


    @Override
    protected long getUnadjustedFrameLength(ByteBuf buf, int offset, int length, ByteOrder order) {
        buf = buf.order(order);
        byte[] lenByte = new byte[length];
        buf.getBytes(offset, lenByte);
        String lenStr = new String(lenByte);
        Long len =  Long.valueOf(lenStr);
        return len;
    }
}

  只需要整合Netty提供的LengthFieldBasedFrameDecoder 類,並重寫getUnadjustedFrameLength方法即可。

  首先看構造方法中的5個引數。第一個表示能處理的包的最大長度;第二三個引數應該結合起來理解,表示長度欄位從第幾位開始,長度的長度是多少,也就是上面報文格式協議中的頭8個位元組;第四個參數列示長度是否需要校正,舉例理解,比如頭8個位元組解析出來的長度=包體長度+頭8個位元組的長度,那麼這裡就需要校正8個位元組,我們的協議中長度只包含報文體,因此這個引數填0;最後一個引數,表示接收到的報文是否要跳過一些位元組,本例中設定為8,表示跳過頭8個位元組,因此經過這個handler後,我們收到的資料就只有報文字身了,不再包含8個長度位元組了。

  再看getUnadjustedFrameLength方法,其實就是將頭8個字串型的長度為轉換成long型。重寫完這個方法後,Netty就知道如何收一個“完整”的資料包了。

  4)StringDecoder。這個是Netty自帶的入站handler,會將位元組流以指定的編碼解析成String。

  5)JsonDecoder。是我們自定義的一個入站handler,目的是將json String轉換成java bean,以方便後續處理:

public class JsonDecoder extends MessageToMessageDecoder<String> {
    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, String o, List<Object> list) throws Exception {
        Message msg = MessageEnDeCoder.decode(o);
        list.add(msg);
    }

}

  這裡會呼叫我們自定義的一個編解碼幫助類進行轉換:

public static Message decode(String message){
        if (StringUtils.isEmpty(message) || message.length() < 2){
            return null;
        }
        String type = message.substring(0,2);
        message = message.substring(2);
        if (type.equals(LoginRequest)){
            return JsonUtil.jsonToObject(message,LoginRequest.class);
        }else if (type.equals(LoginResponse)){
            return JsonUtil.jsonToObject(message,LoginResponse.class);
        }else if (type.equals(LogoutRequest)){
            return JsonUtil.jsonToObject(message,LogoutRequest.class);
        }else if (type.equals(LogoutResponse)){
            return JsonUtil.jsonToObject(message,LogoutResponse.class);
        }else if (type.equals(SendMsgRequest)){
            return JsonUtil.jsonToObject(message,SendMsgRequest.class);
        }else if (type.equals(SendMsgResponse)){
            return JsonUtil.jsonToObject(message,SendMsgResponse.class);
        }else if (type.equals(HeartBeat)){
            return JsonUtil.jsonToObject(message,HeartBeat.class);
        }
        return null;
    }

  6)BussMessageHandler。先看這個入站handler,是我們的一個業務處理主入口,他的主要工作就是將訊息分發給執行緒池去處理,另外還負載一個小場景,當客戶端主動斷開時,需要將相應的賬戶資料庫中狀態更新為不線上。

public class BussMessageHandler extends ChannelInboundHandlerAdapter {
    private static Logger logger = LoggerFactory.getLogger(BussMessageHandler.class);

    @Autowired
    private TaskDispatcher taskDispatcher;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        logger.info("收到訊息:{}",msg);
        if (msg instanceof Message){
            taskDispatcher.submit(ctx.channel(),(Message)msg);
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        //客戶端連線斷開
        InetSocketAddress socketAddress = (InetSocketAddress)ctx.channel().remoteAddress();
        String ip = socketAddress.getAddress().getHostAddress();
        logger.info("客戶端斷開:{}",ip);
        String userName = SessionManager.removeSession(ctx.channel());
        SpringContextUtil.getBean(UserService.class).updateOnlineStatus(userName,Boolean.FALSE);
        super.channelInactive(ctx);
    }
}

  接下來還差執行緒池的處理邏輯,也非常簡單,就是將任務封裝成executor然後交給執行緒池處理:

public class TaskDispatcher {
    private ThreadPoolExecutor threadPool;

    public TaskDispatcher(){
        int corePoolSize = 15;
        int maxPoolSize = 50;
        int keepAliveSeconds = 30;
        int queueCapacity = 1024;
        BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(queueCapacity);
        this.threadPool = new ThreadPoolExecutor(
                corePoolSize, maxPoolSize, keepAliveSeconds, TimeUnit.SECONDS,
                queue);
    }

    public void submit(Channel channel, Message msg){
        ExecutorBase executor = null;
        String messageType = msg.getMessageType();
        if (messageType.equals(MessageEnDeCoder.LoginRequest)){
            executor = new LoginExecutor(channel,msg);
        }
        if (messageType.equalsIgnoreCase(MessageEnDeCoder.SendMsgRequest)){
            executor = new SendMsgExecutor(channel,msg);
        }
        if (executor != null){
            this.threadPool.submit(executor);
        }
    }
}
 

  接下來看一下訊息轉發executor是怎麼做的:

public class SendMsgExecutor extends ExecutorBase {
    private static Logger logger = LoggerFactory.getLogger(SendMsgExecutor.class);

    public SendMsgExecutor(Channel channel, Message message) {
        super(channel, message);
    }

    @Override
    public void run() {
        SendMsgResponse response = new SendMsgResponse();
        response.setMessageType(MessageEnDeCoder.SendMsgResponse);
        response.setTime(new Date());
        SendMsgRequest request = (SendMsgRequest)message;
        String recvUserName = request.getRecvUserName();
        String sendContent = request.getSendMessage();
        Channel recvChannel = SessionManager.getSession(recvUserName);
        if (recvChannel != null){
            SendMsgRequest sendMsgRequest = new SendMsgRequest();
            sendMsgRequest.setTime(new Date());
            sendMsgRequest.setMessageType(MessageEnDeCoder.SendMsgRequest);
            sendMsgRequest.setRecvUserName(recvUserName);
            sendMsgRequest.setSendMessage(sendContent);
            sendMsgRequest.setSendUserName(request.getSendUserName());
            recvChannel.writeAndFlush(sendMsgRequest).addListener(new GenericFutureListener<Future<? super Void>>() {
                @Override
                public void operationComplete(Future<? super Void> future) throws Exception {
                    if (future.isSuccess()){
                        logger.info("訊息轉發成功:{}",sendMsgRequest);
                        response.setResultCode("0000");
                        response.setResultMessage(String.format("發給使用者[%s]訊息成功",recvUserName));
                        channel.writeAndFlush(response);
                    }else {
                        logger.error(ExceptionUtils.getStackTrace(future.cause()));
                        logger.info("訊息轉發失敗:{}",sendMsgRequest);
                        response.setResultCode("9999");
                        response.setResultMessage(String.format("發給使用者[%s]訊息失敗",recvUserName));
                        channel.writeAndFlush(response);
                    }
                }
            });
        }else {
            logger.info("使用者{}不線上,訊息轉發失敗",recvUserName);
            response.setResultCode("9999");
            response.setResultMessage(String.format("使用者[%s]不線上",recvUserName));
            channel.writeAndFlush(response);
        }
    }
}

  整體邏輯:一獲取要把訊息發給那個賬號;二獲取該賬號對應的連線;三在此連線上傳送訊息;四獲取訊息傳送結果,將結果發給訊息“發起者”。

  下面是登陸處理的executor:

public class LoginExecutor extends ExecutorBase {
    private static Logger logger = LoggerFactory.getLogger(LoginExecutor.class);

    public LoginExecutor(Channel channel, Message message) {
        super(channel, message);
    }
    @Override
    public void run() {
        LoginRequest request = (LoginRequest)message;
        String userName = request.getUserName();
        String password = request.getPassword();
        UserService userService = SpringContextUtil.getBean(UserService.class);
        boolean check = userService.checkLogin(userName,password);
        LoginResponse response = new LoginResponse();
        response.setUserName(userName);
        response.setMessageType(MessageEnDeCoder.LoginResponse);
        response.setTime(new Date());
        response.setResultCode(check?"0000":"9999");
        response.setResultMessage(check?"登陸成功":"登陸失敗,使用者名稱或密碼錯");
        if (check){
            userService.updateOnlineStatus(userName,Boolean.TRUE);
            SessionManager.addSession(userName,channel);
        }
        channel.writeAndFlush(response).addListener(new GenericFutureListener<Future<? super Void>>() {
            @Override
            public void operationComplete(Future<? super Void> future) throws Exception {
                //登陸失敗,斷開連線
                if (!check){
                    logger.info("使用者{}登陸失敗,斷開連線",((LoginRequest) message).getUserName());
                    channel.disconnect();
                }
            }
        });
    }
}

  登陸邏輯也不復雜,登陸成功則更新使用者線上狀態,並且無論登陸成功還是失敗,都會返一個登陸應答。同時,如果登陸校驗失敗,在返回應答成功後,需要將連結斷開。

  7)JsonEncoder。最後看這個唯一的出站handler,服務端發出去的訊息都會被出站handler處理,他的職責就是將java bean轉成我們之前定義的報文協議格式:

public class JsonEncoder extends MessageToByteEncoder<Message> {
    @Override
    protected void encode(ChannelHandlerContext channelHandlerContext, Message message, ByteBuf byteBuf) throws Exception {
        String msgStr = MessageEnDeCoder.encode(message);
        int length = msgStr.getBytes(Charset.forName("UTF-8")).length;
        String str = String.valueOf(length);
        String lenStr = StringUtils.leftPad(str,8,'0');
        msgStr = lenStr + msgStr;
        byteBuf.writeBytes(msgStr.getBytes("UTF-8"));
    }
}

  8)SessionManager。剩下最後一個東西沒說,這個是用來儲存每個登陸成功賬戶的連結的,底層是個map,key為使用者賬戶,value為連結:

public class SessionManager {
    private static ConcurrentHashMap<String,Channel> sessionMap = new ConcurrentHashMap<>();

    public static void addSession(String userName,Channel channel){
        sessionMap.put(userName,channel);
    }

    public static String removeSession(String userName){
        sessionMap.remove(userName);
        return userName;
    }

    public static String removeSession(Channel channel){
        for (String key:sessionMap.keySet()){
            if (channel.id().asLongText().equalsIgnoreCase(sessionMap.get(key).id().asLongText())){
                sessionMap.remove(key);
                return key;
            }
        }
        return null;
    }

    public static Channel getSession(String userName){
        return sessionMap.get(userName);
    }
}

  到這裡,整個服務端的邏輯就走完了,是不是,很簡單呢!

3、聊天客戶端

  客戶端中介面相關的東西是基於JavaFX框架做的,這個我是第一次用,所以不打算講這塊,怕誤導大家。主要還是講Netty作為客戶端是如何跟服務端通訊的。

  按照慣例,還是先貼出主入口:

public void login(String userName,String password) throws Exception {
        Bootstrap clientBootstrap = new Bootstrap();
        EventLoopGroup clientGroup = new NioEventLoopGroup();
        try {
            clientBootstrap.group(clientGroup)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS,10000);
            clientBootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new IdleStateHandler(20, 15, 0, TimeUnit.SECONDS));
                    ch.pipeline().addLast(new StringLengthFieldDecoder());
                    ch.pipeline().addLast(new StringDecoder(Charset.forName("UTF-8")));
                    ch.pipeline().addLast(new JsonDecoder());
                    ch.pipeline().addLast(new JsonEncoder());
                    ch.pipeline().addLast(bussMessageHandler);
                    ch.pipeline().addLast(new HeartBeatHandler());
                }
            });
            ChannelFuture future = clientBootstrap.connect(server,port).sync();
            if (future.isSuccess()){
                channel = (SocketChannel)future.channel();
                LoginRequest request = new LoginRequest();
                request.setTime(new Date());
                request.setUserName(userName);
                request.setPassword(password);
                request.setMessageType(MessageEnDeCoder.LoginRequest);
                channel.writeAndFlush(request).addListener(new GenericFutureListener<Future<? super Void>>() {
                    @Override
                    public void operationComplete(Future<? super Void> future) throws Exception {
                        if (future.isSuccess()){
                            logger.info("登陸訊息傳送成功");
                        }else {
                            logger.info("登陸訊息傳送失敗:{}", ExceptionUtils.getStackTrace(future.cause()));
                            Platform.runLater(new Runnable() {
                                @Override
                                public void run() {
                                    LoginController.setLoginResult("網路錯誤,登陸訊息傳送失敗");
                                }
                            });
                        }
                    }
                });
            }else {
                clientGroup.shutdownGracefully();
                throw new RuntimeException("網路錯誤");
            }
        }catch (Exception e){
            clientGroup.shutdownGracefully();
            throw new RuntimeException("網路錯誤");
        }
    }

  對這段程式碼,我們主要關注這幾點:一所有handler的初始化;二connect服務端。

  所有handler中,除了bussMessageHandler是客戶端特有的外,其他的handler在服務端章節已經講過了,不再贅述。

  1)先看連線服務端的操作。首先發起連線,連線成功後傳送登陸報文。發起連線需要對成功和失敗進行處理。傳送登陸報文也需要對成功和失敗進行處理。注意,這裡的成功失敗只是代表當前操作的網路層面的成功失敗,這時候並不能獲取服務端返回的應答中的業務層面的成功失敗,如果不理解這句話,可以翻看前面講過的“非同步”相關內容。

  2)BussMessageHandler。整體流程還是跟服務端一樣,將受到的訊息扔給執行緒池處理,我們直接看處理訊息的各個executor。

  先看客戶端發出登陸請求後,收到登陸應答訊息後是怎麼處理的(這段程式碼可以結合1)的內容一起理解):

public class LoginRespExecutor extends ExecutorBase {
    private static Logger logger = LoggerFactory.getLogger(LoginRespExecutor.class);

    public LoginRespExecutor(Channel channel, Message message) {
        super(channel, message);
    }

    @Override
    public void run() {
        LoginResponse response = (LoginResponse)message;
        logger.info("登陸結果:{}->{}",response.getResultCode(),response.getResultMessage());
        if (!response.getResultCode().equals("0000")){
            Platform.runLater(new Runnable() {
                @Override
                public void run() {
                    LoginController.setLoginResult("登陸失敗,使用者名稱或密碼錯誤");
                }
            });
        }else {
            LoginController.setCurUserName(response.getUserName());
            ClientApplication.getScene().setRoot(SpringContextUtil.getBean(MainView.class).getView());
        }
    }
}

  接下來看客戶端是怎麼發聊天資訊的:

public void sendMessage(Message message) {
        channel.writeAndFlush(message).addListener(new GenericFutureListener<Future<? super Void>>() {
            @Override
            public void operationComplete(Future<? super Void> future) throws Exception {
                SendMsgRequest send = (SendMsgRequest)message;
                if (future.isSuccess()){
                    Platform.runLater(new Runnable() {
                        @Override
                        public void run() {
                            MainController.setMessageHistory(String.format("[我]在[%s]發給[%s]的訊息[%s],傳送成功",
                                    DateFormatUtils.format(send.getTime(),"yyyy-MM-dd HH:mm:ss"),send.getRecvUserName(),send.getSendMessage()));
                        }
                    });
                }else {
                    Platform.runLater(new Runnable() {
                        @Override
                        public void run() {
                            MainController.setMessageHistory(String.format("[我]在[%s]發給[%s]的訊息[%s],傳送失敗",
                                    DateFormatUtils.format(send.getTime(),"yyyy-MM-dd HH:mm:ss"),send.getRecvUserName(),send.getSendMessage()));
                        }
                    });
                }
            }
        });
    }

  實際上,到這裡通訊相關的程式碼已經貼完了。剩下的都是介面處理相關的程式碼,不再貼了。

  客戶端,是不是,非常簡單!

4、Web管理端

  Web管理端可以說是更沒任何技術含量,就是Shiro登陸認證、列表增刪改查。增刪改沒什麼好說的,下面重點說一下Shiro登陸和列表查詢。

  1)Shiro登陸

  首先定義一個Realm,至於這是什麼概念,自行百度吧,這裡並不是本文重點:

public class UserDbRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        
        UsernamePasswordToken upToken = (UsernamePasswordToken) authenticationToken;
        String username = upToken.getUsername();
        String password = "";
        if (upToken.getPassword() != null)
        {
            password = new String(upToken.getPassword());
        }
        // TODO: 2021/5/13 校驗使用者名稱密碼,不通過則拋認證異常即可 
        ShiroUser user = new ShiroUser();
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());
        return info;
    }
}

  接下來把這個Realm註冊成Spring Bean,同時定義過濾鏈:

    @Bean
    public Realm realm() {
        UserDbRealm realm = new UserDbRealm();
        realm.setAuthorizationCachingEnabled(true);
        realm.setCacheManager(cacheManager());
        return realm;
    }
    
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/css/**", "anon");
        chainDefinition.addPathDefinition("/img/**", "anon");
        chainDefinition.addPathDefinition("/js/**", "anon");
        chainDefinition.addPathDefinition("/logout", "logout");
        chainDefinition.addPathDefinition("/login", "anon");
        chainDefinition.addPathDefinition("/captchaImage", "anon");
        chainDefinition.addPathDefinition("/**", "authc");
        return chainDefinition;
    }

  到現在為止,Shiro配置好了,下面看如何調起登陸:

    @PostMapping("/login")
    @ResponseBody
    public Result<String> login(String username, String password, Boolean rememberMe)
    {
        Result<String> ret = new Result<>();
        UsernamePasswordToken token = new UsernamePasswordToken(username, password);
        Subject subject = SecurityUtils.getSubject();
        try
        {
            subject.login(token);
            return ret;
        }
        catch (AuthenticationException e)
        {
            String msg = "使用者或密碼錯誤";
            if (StringUtils.isNotEmpty(e.getMessage()))
            {
                msg = e.getMessage();
            }
            ret.setCode(Result.FAIL);
            ret.setMessage(msg);
            return ret;
        }
    }

  登陸程式碼就這麼愉快的完成了。

  2)列表查詢

  查是個很簡單的操作,但是卻是所有web系統中使用最頻繁的操作。因此,做一個通用性的封裝,非常有必要。以下程式碼不做過多講解,初級工程師到高階工程師,就差這段程式碼了(手動捂臉):

  a)Controller

    @RequestMapping("/query")
    @ResponseBody
    public Result<Page<User>> query(@RequestParam Map<String,Object> params, String sort, String order, Integer pageIndex, Integer pageSize){
        Page<User> page = userService.query(params,sort,order,pageIndex,pageSize);
        Result<Page<User>> ret = new Result<>();
        ret.setData(page);
        return ret;
    }

  b)Service

    @Autowired
    private UserDao userDao;
    @Autowired
    private QueryService queryService;

    public Page<User> query(Map<String,Object> params, String sort, String order, Integer pageIndex, Integer pageSize){
        return queryService.query(userDao,params,sort,order,pageIndex,pageSize);
    }
public class QueryService {
    public <T> com.easy.okim.common.model.Page<T> query(JpaSpecificationExecutor<T> dao, Map<String,Object> filters, String sort, String order, Integer pageIndex, Integer pageSize){
        com.easy.okim.common.model.Page<T> ret = new com.easy.okim.common.model.Page<T>();
        Map<String,Object> params = new HashMap<>();
        if (filters != null){
            filters.remove("sort");
            filters.remove("order");
            filters.remove("pageIndex");
            filters.remove("pageSize");
            for (String key:filters.keySet()){
                Object value = filters.get(key);
                if (value != null && StringUtils.isNotEmpty(value.toString())){
                    params.put(key,value);
                }
            }
        }
        Pageable pageable = null;
        pageIndex = pageIndex - 1;
        if (StringUtils.isEmpty(sort)){
            pageable = PageRequest.of(pageIndex,pageSize);
        }else {
            Sort s = Sort.by(Sort.Direction.ASC,sort);
            if (StringUtils.isNotEmpty(order) && order.equalsIgnoreCase("desc")){
                s = Sort.by(Sort.Direction.DESC,sort);
            }
            pageable = PageRequest.of(pageIndex,pageSize,s);
        }
        Page<T> page = null;
        if (params.size() ==0){
            page = dao.findAll(null,pageable);
        }else {
            Specification<T> specification = new Specification<T>() {
                @Override
                public Predicate toPredicate(Root<T> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder builder) {
                    List<Predicate> predicates = new ArrayList<>();
                    for (String filter : params.keySet()) {
                        Object value = params.get(filter);
                        if (value == null || StringUtils.isEmpty(value.toString())) {
                            continue;
                        }
                        String field = filter;
                        String operator = "=";
                        String[] arr = filter.split("\\|");
                        if (arr.length == 2) {
                            field = arr[0];
                            operator = arr[1];
                        }
                        if (arr.length == 3) {
                            field = arr[0];
                            operator = arr[1];
                            String type = arr[2];
                            if (type.equalsIgnoreCase("boolean")){
                                value = Boolean.parseBoolean(value.toString());
                            }else if (type.equalsIgnoreCase("integer")){
                                value = Integer.parseInt(value.toString());
                            }else if (type.equalsIgnoreCase("long")){
                                value = Long.parseLong(value.toString());
                            }
                        }
                        String[] names = StringUtils.split(field, ".");
                        Path expression = root.get(names[0]);
                        for (int i = 1; i < names.length; i++) {
                            expression = expression.get(names[i]);
                        }
                        // logic operator
                        switch (operator) {
                            case "=":
                                predicates.add(builder.equal(expression, value));
                                break;
                            case "!=":
                                predicates.add(builder.notEqual(expression, value));
                                break;
                            case "like":
                                predicates.add(builder.like(expression, "%" + value + "%"));
                                break;
                            case ">":
                                predicates.add(builder.greaterThan(expression, (Comparable) value));
                                break;
                            case "<":
                                predicates.add(builder.lessThan(expression, (Comparable) value));
                                break;
                            case ">=":
                                predicates.add(builder.greaterThanOrEqualTo(expression, (Comparable) value));
                                break;
                            case "<=":
                                predicates.add(builder.lessThanOrEqualTo(expression, (Comparable) value));
                                break;
                            case "isnull":
                                predicates.add(builder.isNull(expression));
                                break;
                            case "isnotnull":
                                predicates.add(builder.isNotNull(expression));
                                break;
                            case "in":
                                CriteriaBuilder.In in = builder.in(expression);
                                String[] arr1 = StringUtils.split(filter.toString(), ",");
                                for (String e : arr1) {
                                    in.value(e);
                                }
                                predicates.add(in);
                                break;
                        }
                    }

                    // 將所有條件用 and 聯合起來
                    if (!predicates.isEmpty()) {
                        return builder.and(predicates.toArray(new Predicate[predicates.size()]));
                    }
                    return builder.conjunction();
                }
            };
            page = dao.findAll(specification,pageable);
        }
        ret.setTotal(page.getTotalElements());
        ret.setRows(page.getContent());
        return ret;
    }
}

  c)Dao

public interface UserDao extends JpaRepository<User,Long>,JpaSpecificationExecutor<User> {
    //啥都不用寫,繼承Spring Data Jpa提供的類就行了
}

五、結語

  雖然標題起的有些譁眾取寵了,但內容也確實都是實實在在的乾貨,希望本文能對大家有一些幫助,原始碼工程不打算貼了,希望你能跟著文章自己手敲一遍。

  開頭說的收款二維碼,只是說笑,如果你真想付款,請私信我索取收款二維碼,金額不設上限的哈哈。

  歡迎閱讀,歡迎轉載,轉載請註明出處,求你了。

相關文章