Netty框架實戰篇 - 基於WebSocket實現網頁版的聊天室服務器

WebSocket簡介

WebSocket協議是完全重新設計的協議,旨在爲Web上的雙向數據傳輸問題提供一個切實可行的解決方案,使得客戶端和服務器之間可以在任意時刻傳輸消息,因此,這也就要求它們異步地處理消息回執

WebSocket特點:

  1. HTML5 中的協議,實現與客戶端與服務器雙向,基於消息的文本或二進制數據通信
  2. 適合於對數據的實時性要求比較強的場景,如通信、直播、共享桌面,特別適合於客戶端與服務端頻繁交互的情況下,如實時共享、多人協作等平臺
  3. 採用新的協議,後端需要單獨實現
  4. 客戶端並不是所有瀏覽器都支持

WebSocket通信握手

在從標準的 HTTP 或者 HTTPS協議切換到WebSocket時,將會使用一種稱爲握手的機制 ,因此,使用WebSocket的應用程序將始終以HTTP/S作爲開始,然後再執行升級。這個升級動作發生的確切時刻特定於應用程序;它可能會發生在啓動時,也可能會發生在請求了某個特定的URL之後

下面是WebSocket請求和響應的標識信息:
在這裏插入圖片描述

客戶端的請求:

  1. Connection屬性中標識Upgrade,表示客戶端希望連接升級
  2. Upgrade屬性中標識爲Websocket,表示希望升級成 Websocket 協議
  3. Sec-WebSocket-Key屬性,表示隨機字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。
  4. Sec-WebSocket-Version屬性,表示支持的 Websocket 版本,RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用

服務器端響應:

  1. Upgrade屬性中標識爲websocket
  2. Connection告訴客戶端即將升級的是 Websocket 協議
  3. Sec-WebSocket-Accept這個則是經過服務器確認,並且加密過後的Sec-WebSocket-Key

Netty爲WebSocket數據幀提供的支持

由 IETF 發佈的WebSocket RFC,定義了6種幀,Netty爲它們每種都提供了一個POJO實現

幀類型 描述
BinaryWebSocketFrame 包含了二進制數據
TextWebSocketFrame 包含了文本數據
ContinuationWebSocketFrame 包含屬於上一個BinaryWebSocketFrame 或TextWebSocketFrame 的文本或者二進制數據
CloseWebSocketFrame 標識一個CLOSE請求,包含一個關閉的狀態碼
PingWebSocketFrame 請求傳輸一個PongWebSocketFrame
PongWebSocketFrame 作爲一個對於PingWebSocketFrame的響應被髮送

實戰

首先,定義WebSocket服務端,其中創建了一個Netty提供ChannelGroup變量用來記錄所有已經連接的客戶端channel,而這個ChannelGroup就是用來完成羣發和單聊功能的

//定義websocket服務端
public class WebSocketServer {
   
   

	private static EventLoopGroup bossGroup = new NioEventLoopGroup(1);
	private static EventLoopGroup workerGroup = new NioEventLoopGroup();
    private static ServerBootstrap bootstrap = new ServerBootstrap();
	
	private static final int PORT =8761;

	//創建 DefaultChannelGroup,用來保存所有已經連接的 WebSocket Channel,羣發和一對一功能可以用上
	private final static ChannelGroup channelGroup =
            new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);
	
	public static void startServer(){
   
   
		try {
   
   
			bootstrap.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new WebSocketServerInitializer(channelGroup));
            Channel ch = bootstrap.bind(PORT).sync().channel();
            System.out.println("打開瀏覽器訪問: http://127.0.0.1:" + PORT + '/');
            ch.closeFuture().sync();
		} catch (Exception e) {
   
   
			e.printStackTrace();
		}finally{
   
   
			bossGroup.shutdownGracefully();
	        workerGroup.shutdownGracefully();
		}
	}
	public static void main(String[] args) {
   
   
		startServer();
	}
}

接下來,初始化Pipeline,向當前Pipeline中註冊所有必需的ChannelHandler,主要包括:用於處理HTTP請求編解碼的HttpServerCodec、自定義的處理HTTP請求的HttpRequestHandler、用於處理WebSocket幀數據以及升級握手的WebSocketServerProtocolHandler以及自定義的處理TextWebSocketFrame數據幀和握手完成事件的WebSocketServerHanlder

public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel>{
   
   

	/*websocket訪問路徑*/
    private static final String WEBSOCKET_PATH = "/ws";
	
    private ChannelGroup channelGroup;
	
	public WebSocketServerInitializer(ChannelGroup channelGroup){
   
   
		this.channelGroup=channelGroup;
	} 
    
	@Override
	protected void initChannel(SocketChannel ch) throws Exception {
   
   
		//用於HTTP請求的編解碼
		ch.pipeline().addLast(new HttpServerCodec());
		//用於寫入一個文件的內容
		ch.pipeline().addLast(new ChunkedWriteHandler());
		//用於http請求的聚合
		ch.pipeline().addLast(new HttpObjectAggregator(64*1024));
		//用於WebSocket應答數據壓縮傳輸
		ch.pipeline().addLast(new WebSocketServerCompressionHandler());
		//處理http請求,對非websocket請求的處理
		ch.pipeline().addLast(new HttpRequestHandler(WEBSOCKET_PATH));
		//根據websocket規範,處理升級握手以及各種websocket數據幀
		ch.pipeline().addLast(new WebSocketServerProtocolHandler(WEBSOCKET_PATH, "", true));
		//對websocket的數據進行處理,主要處理TextWebSocketFrame數據幀和握手完成事件
		ch.pipeline().addLast(new WebSocketServerHanlder(channelGroup));
	}
}

HttpRequestHandler用來處理HTTP請求,首先會先確認當前的HTTP請求是否指向了WebSocket的URI,如果是那麼HttpRequestHandler將調用FullHttpRequest對象上的retain方法,並通過調用fireChannelRead(msg)方法將它轉發給下一個ChannelInboundHandler(之所以調用retain方法,是因爲調用channelRead0方法完成之後,會進行資源釋放)

接下來,讀取磁盤上指定路徑的index.html文件內容,將內容封裝成ByteBuf對象,之後,構造一個FullHttpResponse響應對象,將ByteBuf添加進去,並設置請求頭信息。最後,調用writeAndFlush方法沖刷所有寫入的消息

public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest>{
   
   

	private static final File INDEX = new File("D:/學習/index.html");
	
	private String websocketUrl;
	
	public HttpRequestHandler(String websocketUrl)
	{
   
   
		this.websocketUrl = websocketUrl;
	}
	
	@Override
	protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
   
   
		if(websocketUrl.equalsIgnoreCase(msg.getUri())){
   
   
			//如果該HTTP請求指向了websocketUrl的URL,那麼直接交給下一個ChannelInboundHandler進行處理
			ctx.fireChannelRead(msg.retain());
		}else{
   
   
			//生成index頁面的具體內容,並送往瀏覽器
			ByteBuf content = loadIndexHtml(); 
			FullHttpResponse res = new DefaultFullHttpResponse(
		                    HTTP_1_1, OK, content);
            
		    res.headers().set(HttpHeaderNames.CONTENT_TYPE,
		                    "text/html; charset=UTF-8");
		    HttpUtil.setContentLength(res, content.readableBytes());
		    sendHttpResponse(ctx, msg, res);
		}
	}
	
	public static ByteBuf loadIndexHtml(){
   
   
		FileInputStream fis = null;
		InputStreamReader isr = null;
		BufferedReader  raf = null;
		StringBuffer content = new StringBuffer();
		try {
   
   
			  fis = new FileInputStream(INDEX);
			  isr = new InputStreamReader(fis);
			  raf = new BufferedReader(isr);
			  String s = null;
			  // 讀取文件內容,並將其打印
			  while((s = raf.readLine()) != null) {
   
   
				 content.append(s);
			  }
		 } catch (Exception e) {
   
   
			// TODO Auto-generated catch block
			e.printStackTrace();
		} finally {
   
   
			try {
   
   
				fis.close();
				isr.close();
				raf.close();
			} catch (IOException e) {
   
   
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
		return Unpooled.copiedBuffer(content.toString().getBytes());
	}
	 /*發送應答*/
    private static void sendHttpResponse(ChannelHandlerContext ctx,
                                         FullHttpRequest req,
                                         FullHttpResponse res) {
   
   
        // 錯誤的請求進行處理 (code<>200).
        if (res.status().code() != 200) {
   
   
            ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(),
                    CharsetUtil.UTF_8);
            res.content().writeBytes(buf);
            buf.release();
            HttpUtil.setContentLength(res, res.content().readableBytes());
        }

        // 發送應答.
        ChannelFuture f = ctx.channel().writeAndFlush(res);
        //對於不是長連接或者錯誤的請求直接關閉連接
        if (!HttpUtil.isKeepAlive(req) || res.status().code() != 200) {
   
   
            f.addListener(ChannelFutureListener.CLOSE);
        }
    }
}

前面的HttpRequestHandler處理器只是用來管理HTTP請求和響應的,而實際對傳輸的WebSocket數據幀的處理是交由WebSocketServerHanlder 進行(其中只對TextWebSocketFrame類型的數據幀進行處理)。

WebSocketServerHanlder 處理時通過重寫userEventTriggered方法,並監聽握手成功的事件,當新客戶端的WebSocket握手成功之後,它將通過把通知消息寫到ChannelGroup中的所有channel來通知所有已經連接的客戶端,然後它將這個新的channel加入到該ChannelGroup中,並且還爲每個channel隨機生成了一個用戶

之後,如果接收到了TextWebSocketFrame消息時,會先根據當前channel拿到用戶,並解析發送的文本幀信息,確認是羣聊還是單聊,最後,構造TextWebSocketFrame響應內容,通過writeAndFlush進行沖刷

/**
 * 對websocket的文本數據幀進行處理
 *
 */
public class WebSocketServerHanlder extends SimpleChannelInboundHandler<TextWebSocketFrame>{
   
   

	
	private ChannelGroup channelGroup;
	
	public WebSocketServerHanlder(ChannelGroup channelGroup){
   
   
		this.channelGroup=channelGroup;
	}
	
	@Override
	protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
   
   
		//獲取當前channel用戶名
		String userName=UserMap.getUser(ctx.channel().id().asLongText());
	    //文本幀
		String content= msg.text();
		System.out.println("Client: "+ userName+" received [ "+content+" ]");
		String toName = null;
		//判斷是單聊還是羣發(單聊會通過  user@ msg 這種格式進行傳輸文本幀)
		if(content.contains("@")){
   
   
			String[] str= content.split("@");
			content=str[1];
			//獲取單聊的用戶
			toName = str[0];
		}
		if(null!=toName){
   
   
			Iterator<Channel> it=channelGroup.iterator();
			while(it.hasNext()){
   
   
				Channel channel=it.next();
				//找到指定的用戶
				if(UserMap.getUser(channel.id().asLongText()).equals(toName)){
   
   
					//單聊
					channel.writeAndFlush(new TextWebSocketFrame(userName+"@"+content));
				}
			}
		}else{
   
   
			channelGroup.remove(ctx.channel());
			//羣發實現
			channelGroup.writeAndFlush(new TextWebSocketFrame(userName+"@"+content));
			channelGroup.add(ctx.channel());
		}
	}
	@Override
	public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
   
   
		//檢測事件,如果是握手成功事件,做點業務處理
		if(evt==WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE){
   
   
			String channelId = ctx.channel().id().asLongText();
			//隨機爲當前channel指定一個用戶名
			UserMap.setUser(channelId);
			System.out.println("新的客戶端連接:"+UserMap.getUser(channelId));
			//通知所有已經連接的 WebSocket 客戶端新的客戶端已經連接上了
			channelGroup.writeAndFlush(new TextWebSocketFrame(UserMap.getUser(channelId)+"加入羣聊"));
			//將新的 WebSocket Channel 添加到 ChannelGroup 中
			channelGroup.add(ctx.channel());
		}else{
   
   
			super.userEventTriggered(ctx, evt);
		}
	}
}

index.html內容

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>基於WebSocket實現網頁版羣聊</title>
</head>
<body>
<script type="text/javascript">   
           var userName= null;        
           var socket;        
           var myDate = new Date();
           if (!window.WebSocket) {
   
   
                window.WebSocket = window.MozWebSocket;
            }
            if (window.WebSocket) {
   
   
                socket = new WebSocket("ws://127.0.0.1:8761/ws");
                socket.onmessage = function(event) {
   
    
                   var info = document.getElementById("jp-container");
                   var dataObj=event.data;
                   if(dataObj.indexOf("@")!=-1){
   
   
                        var arr = dataObj.split('@');
                        var sendUser;
                        var acceptMsg;
                        for(var i=0;i<arr.length;i++){
   
   
                            if(i==0){
   
   
                                 sendUser = arr[i];
                            }else{
   
   
                                 acceptMsg =arr[i];
                            }
                        }
                      if(userName==sendUser){
   
   
                             return;
                      }        
                      var talk= document.createElement("div");
                      talk.setAttribute("class", "talk_recordboxme");
                      talk.innerHTML = sendUser+':';
                      var recordtext= document.createElement("div");
                      recordtext.setAttribute("class", "talk_recordtextbg");
                      talk.appendChild(recordtext);
                      var talk_recordtext=document.createElement("div");
                      talk_recordtext.setAttribute("class", " talk_recordtext");
                       var h3=document.createElement("h3");
                       h3.innerHTML =acceptMsg;
                       talk_recordtext.appendChild(h3);
                       var span=document.createElement("span");
                       span.innerHTML =myDate.toLocaleTimeString();
                       span.setAttribute("class", "talk_time");
                       talk_recordtext.appendChild(span);
                       talk.appendChild(talk_recordtext);
                   }else{
   
   
                       var talk= document.createElement("div");
                       talk.style.textAlign="center";
                       var font = document.createElement("font");
                       font.color='#212121';
                       font.innerHTML = dataObj+': '+myDate.toLocaleString( ); 
                       talk.appendChild(font);
                   }
                   info.appendChild(talk);
                };
                socket.onopen = function(event) {
   
   
                      console.log("Socket 已打開");
                };
                socket.onclose = function(event) {
   
   
                     console.log("Socket已關閉");
                  };
            } else {
   
   
                  alert("Your browser does not support Web Socket.");
            }
                function send(message) {
   
   
                    if (!window.WebSocket) {
   
    return; }
                       if (socket.readyState == WebSocket.OPEN) {
   
   
                         var info = document.getElementById("jp-container");

                   var talk= document.createElement("div");
                   talk.setAttribute("class", "talk_recordbox");

                    var user = document.createElement("div");
                    user.setAttribute("class", "user");
                    talk.appendChild(user);
                     var recordtext= document.createElement("div");
     
                   recordtext.setAttribute("class", "talk_recordtextbg");
                    talk.appendChild(recordtext);

                      var talk_recordtext=document.createElement("div");
                      talk_recordtext.setAttribute("class", " talk_recordtext");

                       var h3=document.createElement("h3");
                      h3.innerHTML =message;
                      talk_recordtext.appendChild(h3);
                     var span=document.createElement("span");
                      span.innerHTML =myDate.toLocaleTimeString();
                     span.setAttribute("class", "talk_time");
                      talk_recordtext.appendChild(span);
                     talk.appendChild(talk_recordtext);
                     info.appendChild(talk );
                          socket.send(message);
                     } else {
   
   
                           alert("The socket is not open.");
                      }
                }
</script>

<br>
<br>
<div class="talk">
	<div class="talk_title"><span>羣聊</span></div>
	<div class="talk_record" style="background: #EEEEF4;">
		<div id="jp-container" class="jp-container">
		</div>
	
	</div>
	 <form onsubmit="return false;">
	      <div class="talk_word">
		&nbsp;
		<input class="add_face" id="facial" type="button" title="添加表情" value="" />
		<input class="messages emotion" autocomplete="off" name="message" value="在這裏輸入文字" onFocus="if(this.value=='在這裏輸入文字'){this.value='';}"  onblur="if(this.value==''){this.value='在這裏輸入文字';}"  />
		<input class="talk_send" type="button" title="發送" value="發送"  onclick="send(this.form.message.value)" />
	      </div>
               </form> 
</div>

樣式

body{
   
   
	font-family:verdana, Arial, Helvetica, "宋體", sans-serif;
	font-size: 12px;
}

body ,div ,dl ,dt ,dd ,ol ,li ,h1 ,h2 ,h3 ,h4 ,h5 ,h6 ,pre ,form ,fieldset ,input ,P ,blockquote ,th ,td ,img,
INS {
   
   
	margin: 0px;
	padding: 0px;
	border:0;
}
ol{
   
   
	list-style-type: none;
}
img,input{
   
   
	border:none;
}

a{
   
   
	color:#198DD0;
	text-decoration:none;
}
a:hover{
   
   
	color:#ba2636;
	text-decoration:underline;
}
a{
   
   blr:expression(this.onFocus=this.blur())}/*去掉a標籤的虛線框,避免出現奇怪的選中區域*/
:focus{
   
   outline:0;}


.talk{
   
   
	height: 480px;
	width: 335px;
	margin:0 auto;
	border-left-width: 1px;
	border-left-style: solid;
	border-left-color: #444;
}
.talk_title{
   
   
	width: 100%;
	height:40px;
	line-height:40px;
	text-indent: 12px;
	font-size: 16px;
	font-weight: bold;
	color: #afafaf;
	background:#212121;
	border-bottom-width: 1px;
	border-bottom-style: solid;
	border-bottom-color: #434343;
	font-family: "微軟雅黑";
}
.talk_title span{
   
   float:left}
.talk_title_c {
   
   
	width: 100%;
	height:30px;
	line-height:30px;
}
.talk_record{
   
   
	width: 100%;
	height:398px;
	overflow: hidden;
	border-bottom-width: 1px;
	border-bottom-style: solid;
	border-bottom-color: #434343;
	margin: 0px;	
}
.talk_word {
   
   
	line-height: 40px;
	height: 40px;
	width: 100%;
	background:#212121;
}
.messages {
   
   
	height: 24px;
	width: 240px;
	text-indent:5px;
	overflow: hidden;
	font-size: 12px;
	line-height: 24px;
	color: #666;	
	background-color: #ccc;
	border-radius: 3px;
	-moz-border-radius: 3px;
	-webkit-border-radius: 3px;
}
.messages:hover{
   
   background-color: #fff;}
.talk_send{
   
   
	width:50px;
	height:24px;
	line-height: 24px;
	font-size:12px;
	border:0px;
	margin-left: 2px;
	color: #fff;
	background-repeat: no-repeat;
	background-position: 0px 0px;
	background-color: transparent;
	font-family: "微軟雅黑";
}
.talk_send:hover {
   
   
	background-position: 0px -24px;
}
.talk_record ul{
   
    padding-left:5px;}
.talk_record li {
   
   
	line-height: 25px;
}
.talk_word .controlbtn a{
   
   
	margin: 12px;
}
.talk .talk_word .order {
   
   
	float:left;
	display: block;
	height: 14px;
	width: 16px;	  
	background-repeat: no-repeat;
	background-position: 0px 0px;
}

.talk .talk_word .loop {
   
   
	float:left;
	display: block;
	height: 14px;
	width: 16px;
	background-repeat: no-repeat;
	background-position: -30px 0px;
}
.talk .talk_word .single {
   
   
	float:left;
	display: block;
	height: 14px;
	width: 16px;
	background-repeat: no-repeat;
	background-position: -60px 0px;
}
.talk .talk_word .order:hover,.talk .talk_word .active{
   
   
	background-position: 0px -20px;
	text-decoration: none;
}
.talk .talk_word .loop:hover{
   
   
	background-position: -30px -20px;
	text-decoration: none;
}
.talk .talk_word .single:hover{
   
   
	background-position: -60px -20px;
	text-decoration: none;
}


/*討論區*/
.jp-container .talk_recordbox{
   
   
	min-height:80px;
	color: #afafaf;
	padding-top: 5px;
	padding-right: 10px;
	padding-left: 10px;
	padding-bottom: 0px;
}

.jp-container .talk_recordbox:first-child{
   
   border-top:none;}
.jp-container .talk_recordbox:last-child{
   
   border-bottom:none;}
.jp-container .talk_recordbox .talk_recordtextbg{
   
   
	float:left;
	width:10px;
	height:30px;
	display:block;
	background-repeat: no-repeat;
	background-position: left top;}
.jp-container .talk_recordbox .talk_recordtext{
   
   
	-moz-border-radius:5px;
	-webkit-border-radius:5px;
	border-radius:5px;
	background-color:#b8d45c;
	width:240px;
	height:auto;
	display:block;
	padding: 5px;
	float:left;
	color:#333333;
}
.jp-container .talk_recordbox h3{
   
   
	font-size:14px;
	padding:2px 0 5px 0;
	text-transform:uppercase;
	font-weight: 100;
	
}
.jp-container .talk_recordbox .user {
   
   
	float:left;
	display:inline;
	height: 45px;
	width: 45px;
	margin-top: 0px;
	margin-right: 5px;
	margin-bottom: 0px;
	margin-left: 0px;
	font-size: 12px;
	line-height: 20px;
	text-align: center;
}
/*自己發言樣式*/
.jp-container .talk_recordboxme{
   
   
	display:block;
	min-height:80px;
	color: #afafaf;	
	padding-top: 5px;
	padding-right: 10px;
	padding-left: 10px;
	padding-bottom: 0px;
}
.jp-container .talk_recordboxme .talk_recordtextbg{
   
   
	float:right;
	width:10px;
	height:30px;
	display:block;
	background-repeat: no-repeat;
	background-position: left top;}

.jp-container .talk_recordboxme .talk_recordtext{
   
   
	-moz-border-radius:5px;
	-webkit-border-radius:5px;
	border-radius:5px;
	background-color:#fcfcfc;
	width:240px;
	height:auto;
	padding: 5px;
	color:#666;
	font-size:12px;
	float:right;
	
}
.jp-container .talk_recordboxme h3{
   
   
	font-size:14px;
	padding:2px 0 5px 0;
	text-transform:uppercase;
	font-weight: 100;
	color:#333333;
	
}
.jp-container .talk_recordboxme .user{
   
   
	float:right;
	height: 45px;
	width: 45px;
	margin-top: 0px;
	margin-right: 10px;
	margin-bottom: 0px;
	margin-left: 5px;
	font-size: 12px;
	line-height: 20px;
	text-align: center;
	display:inline;
}
.talk_time{
   
   
	color: #666;
	text-align: right;
	width: 240px;
	display: block;
}

測試

首先,啓動三個窗口
在這裏插入圖片描述

羣聊
在這裏插入圖片描述

單聊
在這裏插入圖片描述

總結

本文,基於Netty實戰了一個WebSocket協議實現的網頁版聊天室服務器,從代碼上可以看出,基於Netty的WebSocket的實現還是非常簡單、容易實現的。但是WebSocket協議使用上還是存在侷限的,比如需要瀏覽器的支持。但是畢竟WebSocket代表了Web技術的一種重要進展,可以擴寬我們的視野,在一些特定的工作場景中,可以幫助我們解決一些問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章