WebSocket簡介
WebSocket協議是完全重新設計的協議,旨在爲Web上的雙向數據傳輸問題提供一個切實可行的解決方案,使得客戶端和服務器之間可以在任意時刻傳輸消息,因此,這也就要求它們異步地處理消息回執
WebSocket特點:
- HTML5 中的協議,實現與客戶端與服務器雙向,基於消息的文本或二進制數據通信
- 適合於對數據的實時性要求比較強的場景,如通信、直播、共享桌面,特別適合於客戶端與服務端頻繁交互的情況下,如實時共享、多人協作等平臺
- 採用新的協議,後端需要單獨實現
- 客戶端並不是所有瀏覽器都支持
WebSocket通信握手
在從標準的 HTTP 或者 HTTPS協議切換到WebSocket時,將會使用一種稱爲握手的機制 ,因此,使用WebSocket的應用程序將始終以HTTP/S作爲開始,然後再執行升級。這個升級動作發生的確切時刻特定於應用程序;它可能會發生在啓動時,也可能會發生在請求了某個特定的URL之後
下面是WebSocket請求和響應的標識信息:
客戶端的請求:
- Connection屬性中標識Upgrade,表示客戶端希望連接升級
- Upgrade屬性中標識爲Websocket,表示希望升級成 Websocket 協議
- Sec-WebSocket-Key屬性,表示隨機字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。
- Sec-WebSocket-Version屬性,表示支持的 Websocket 版本,RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用
服務器端響應:
- Upgrade屬性中標識爲websocket
- Connection告訴客戶端即將升級的是 Websocket 協議
- 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">
<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技術的一種重要進展,可以擴寬我們的視野,在一些特定的工作場景中,可以幫助我們解決一些問題