使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

点击关注公众号,利用碎片时间学习

WebSocket简介

WebSocket协议是完全重新设计的协议,旨在为Web上的双向数据传输问题提供一个切实可行的解决方案,使得客户端和服务器之间可以在任意时刻传输消息,因此,这也就要求它们异步地处理消息回执

WebSocket特点:

  • HTML5 中的协议,实现与客户端与服务器双向,基于消息的文本或二进制数据通信
  • 适合于对数据的实时性要求比较强的场景,如通信、直播、共享桌面,特别适合于客户端与服务端频繁交互的情况下,如实时共享、多人协作等平台
  • 采用新的协议,后端需要单独实现
  • 客户端并不是所有浏览器都支持

WebSocket通信握手

在从标准的 HTTP 或者 HTTPS协议切换到WebSocket时,将会使用一种称为握手的机制 ,因此,使用WebSocket的应用程序将始终以HTTP/S作为开始,然后再执行升级。这个升级动作发生的确切时刻特定于应用程序;它可能会发生在启动时,也可能会发生在请求了某个特定的URL之后

下面是WebSocket请求和响应的标识信息:

使用 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实现

使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)

实战

首先,定义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-size12px;
}

body ,div ,dl ,dt ,dd ,ol ,li ,h1 ,h2 ,h3 ,h4 ,h5 ,h6 ,pre ,form ,fieldset ,input ,P ,blockquote ,th ,td ,img,
INS {
 margin0px;
 padding0px;
 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{
 height480px;
 width335px;
 margin:0 auto;
 border-left-width1px;
 border-left-style: solid;
 border-left-color#444;
}
.talk_title{
 width100%;
 height:40px;
 line-height:40px;
 text-indent12px;
 font-size16px;
 font-weight: bold;
 color#afafaf;
 background:#212121;
 border-bottom-width1px;
 border-bottom-style: solid;
 border-bottom-color#434343;
 font-family"微软雅黑";
}
.talk_title span{float:left}
.talk_title_c {
 width100%;
 height:30px;
 line-height:30px;
}
.talk_record{
 width100%;
 height:398px;
 overflow: hidden;
 border-bottom-width1px;
 border-bottom-style: solid;
 border-bottom-color#434343;
 margin0px
}
.talk_word {
 line-height40px;
 height40px;
 width100%;
 background:#212121;
}
.messages {
 height24px;
 width240px;
 text-indent:5px;
 overflow: hidden;
 font-size12px;
 line-height24px;
 color#666
 background-color#ccc;
 border-radius3px;
 -moz-border-radius3px;
 -webkit-border-radius3px;
}
.messages:hover{background-color#fff;}
.talk_send{
 width:50px;
 height:24px;
 line-height24px;
 font-size:12px;
 border:0px;
 margin-left2px;
 color#fff;
 background-repeat: no-repeat;
 background-position0px 0px;
 background-color: transparent;
 font-family"微软雅黑";
}
.talk_send:hover {
 background-position0px -24px;
}
.talk_record ulpadding-left:5px;}
.talk_record li {
 line-height25px;
}
.talk_word .controlbtn a{
 margin12px;
}
.talk .talk_word .order {
 float:left;
 display: block;
 height14px;
 width16px;   
 background-repeat: no-repeat;
 background-position0px 0px;
}

.talk .talk_word .loop {
 float:left;
 display: block;
 height14px;
 width16px;
 background-repeat: no-repeat;
 background-position: -30px 0px;
}
.talk .talk_word .single {
 float:left;
 display: block;
 height14px;
 width16px;
 background-repeat: no-repeat;
 background-position: -60px 0px;
}
.talk .talk_word .order:hover,.talk .talk_word .active{
 background-position0px -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-top5px;
 padding-right10px;
 padding-left10px;
 padding-bottom0px;
}

.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;
 padding5px;
 float:left;
 color:#333333;
}
.jp-container .talk_recordbox h3{
 font-size:14px;
 padding:2px 0 5px 0;
 text-transform:uppercase;
 font-weight100;
 
}
.jp-container .talk_recordbox .user {
 float:left;
 display:inline;
 height45px;
 width45px;
 margin-top0px;
 margin-right5px;
 margin-bottom0px;
 margin-left0px;
 font-size12px;
 line-height20px;
 text-align: center;
}
/*自己发言样式*/
.jp-container .talk_recordboxme{
 display:block;
 min-height:80px;
 color#afafaf
 padding-top5px;
 padding-right10px;
 padding-left10px;
 padding-bottom0px;
}
.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;
 padding5px;
 color:#666;
 font-size:12px;
 float:right;
 
}
.jp-container .talk_recordboxme h3{
 font-size:14px;
 padding:2px 0 5px 0;
 text-transform:uppercase;
 font-weight100;
 color:#333333;
 
}
.jp-container .talk_recordboxme .user{
 float:right;
 height45px;
 width45px;
 margin-top0px;
 margin-right10px;
 margin-bottom0px;
 margin-left5px;
 font-size12px;
 line-height20px;
 text-align: center;
 display:inline;
}
.talk_time{
 color#666;
 text-align: right;
 width240px;
 display: block;
}

测试

首先,启动三个窗口

使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)群聊

使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)单聊

使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)

总结

本文,基于Netty实战了一个WebSocket协议实现的网页版聊天室服务器,从代码上可以看出,基于Netty的WebSocket的实现还是非常简单、容易实现的。

但是WebSocket协议使用上还是存在局限的,比如需要浏览器的支持。但是毕竟WebSocket代表了Web技术的一种重要进展,可以扩宽我们的视野,在一些特定的工作场景中,可以帮助我们解决一些问题

来源:blog.csdn.net/wzljiayou/article/details/110506164

推荐:

最全的java面试题库

使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)
PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。“在看”支持我们吧!

原文始发于微信公众号(Java笔记虾):使用 WebSocket 实现一个网页版的聊天室(摸鱼更隐蔽)