一、前言
說實話,寫這個玩意兒是我上週剛剛產生的想法,本想寫完後把程式碼掛上來賺點積分也不錯。寫完後發現這東西值得寫一篇文章,授人予魚不如授人以漁嘛(這句話是這麼說的吧),順便賺點應屆學生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提供的類就行了 }
五、結語
雖然標題起的有些譁眾取寵了,但內容也確實都是實實在在的乾貨,希望本文能對大家有一些幫助,原始碼工程不打算貼了,希望你能跟著文章自己手敲一遍。
開頭說的收款二維碼,只是說笑,如果你真想付款,請私信我索取收款二維碼,金額不設上限的哈哈。
歡迎閱讀,歡迎轉載,轉載請註明出處,求你了。