基于WebSocket的在线聊天室(一)

效果预览

前言


去年在tomcat7自带的例子中发现了两个有趣的demo,贪食蛇游戏和画板。很有意思的是打开的几个窗口内容都是一样的,而且还会同步更新,如果换做以往做web开发的套路来实现这个效果还是比较费劲的。于是心血来潮就去查了一些关于websocket的资料并做了这么一个文字聊天室。前段时间应别人的需要又把它翻了出来加上了视频和语音功能,瞬间高大上了很多。做完之后当然得趁热打铁总结下,顺便作为第一次写文章的素材。(∩_∩)

简介


WebSocket 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯,它建立在 TCP 之上,同 HTTP 一样通过 TCP 来传输数据。

说起实时通讯就不得不提一些“服务器推”技术。

  • 轮询

    客户端以一定的时间间隔发送Ajax请求,优点实现起来比较简单、省事,不过缺点也很明显,请求有很大一部分是无用的,而且需要频繁建立和释放TCP连接,很消耗带宽和服务器资源。

  • 长轮询

    与普通轮询不同的地方在于,服务端接收到请求后会保持住不立即返回响应,等到有消息更新才返回响应并关闭连接,客户端处理完响应再重新发起请求。较之普通轮询没有无用的请求,但服务器保持连接也是有消耗的,如果服务端数据变化频繁的话和普通轮询并无两样。

  • 长连接

    在页面中嵌入一个隐藏的iframe,将其src设为一个长连接的请求,这样服务端就能不断向客户端发送数据。优缺点与长轮询相仿。

这些技术都明显存在两个相同的缺点:

  1. 服务器需要很大的开销

  2. 都做不到真正意义上的“主动推送”,服务端只能“被动”地响应,于是就轮到正主出场了。

在websocket中,只需要做一个握手动作就可以在客户端和服务器之间建立连接,之后通过数据帧的形式在这个连接上进行通讯,并且,由于连接是双向的,在连接建立之后服务端随时可以主动向客户端发送消息(前提是连接没有断开)。

实现


以前一些websocket的例子都是基于某个特定的容器(如Tomcat,Jetty),在Oracle发布了JSR356规范之后,websocket的JavaAPI得到了统一,所以只要Web容器支持JSR356,那么我们写websocket时,代码都是一样的了.Tomcat从7.0.47开始支持JSR356.另外有一点要说明的是JDK的要求是7及以上。
我本地的环境为 jdk1.7, nginx1.7.8 ( 反向代理 ), tomcat7.0.52( 需要在buildpath中还要添加tomcat7的library ),chrome

废话不多说,先上代码

// 消息结构Message类
public class Message {
    
        private int type;//消息类型

        private String msg;//消息主题

        private String host;// 发送者

        private String[] dests;// 接受者

        private RoomInfo roomInfo;//聊天室信息

        public class MsgConstant {

            public final static int Open = 1;// 新连接

            public final static int Close = 2;// 连接断开

            public final static int MsgToAll = 3;// 发送给所有人

            public final static int MsgToPoints = 4;// 发送给指定用户

            public final static int RequireLogin = 5;// 需要登录

            public final static int setName = 6;// 设置用户名
        }

        public static class RoomInfo {
    
            private String name;// 聊天室名称

            private String creater;//创建人

            private String createTime;// 创建时间

            public RoomInfo(String creater, String createTime) {
                this.creater = creater;
                this.createTime = createTime;
            }
    
            public RoomInfo(String name) {
                this.name = name;
            }
            // 省略set get
        }

        public Message() {
            setType(MsgConstant.MsgToAll);
        }
    
        public Message(String host, int type) {
            setHost(host);
            setType(type);
        }
    
        public Message(String host, int type, String msg) {
            this(host, type);
            setMsg(msg);
        }

        public Message(String host, int type, String[] dests) {
            this(host, type);
            setDests(dests);
        }

        @Override
        public String toString() {
            // 序列化成json串
            return JSONObject.toJSONString(this);
        }
    }

public class wsConfigurator extends ServerEndpointConfig.Configurator {
    @Override
    public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
        //通过配置来获取httpsession
        HttpSession httpSession = (HttpSession) request.getHttpSession();
        config.getUserProperties().put(HttpSession.class.getName(), httpSession);
    }
}

@ServerEndpoint(value = "/websocket/chat/{uid}", configurator = wsConfigurator.class)
public class textController {

    private Session session;

    private LoginUser loginUser;

    private static RoomInfo roomInfo;

    //连接集合
    private static final Set<textController> connections = new CopyOnWriteArraySet<textController>();

    /**
     * websocket连接建立后触发
     * 
     * @param session
     * @param config
     */
    @OnOpen
    public void OnOpen(Session session, EndpointConfig config, @PathParam(value = "uid") String uid) {
        //设置websocket连接的session
        setSession(session);
        // 获取HttpSession
        HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
        // 从HttpSession中取得当前登录的用户作为当前连接的用户
        setLoginUser((LoginUser) httpSession.getAttribute("LoginUser"));
        if (getLoginUser() == null) {
            requireLogin();// 未登录需要进行登录
            return;
        }
        // 设置聊天室信息
        if (getConnections().size() == 0) {// 如果当前聊天室为空,建立新的信息
            setRoomInfo(new RoomInfo(getUserName(), (new SimpleDateFormat("yyyy-MM-dd hh:mm:ss")).format(new Date())));
        }
        //加入连接集合
        getConnections().add(this);
        //广播通知所有连接有新用户加入
        broadcastToAll(new Message(getUserName(), MsgConstant.Open, getUsers()));
    }

    /**
     * websocket连接断开后触发
     */
    @OnClose
    public void OnClose() {
        //从连接集合中移除
        getConnections().remove(this);
        //广播通知所有连接有用户退出
        broadcastToAll(new Message(getUserName(), MsgConstant.Close, getUsers()));
    }

    /**
     * 接受到客户端发送的字符串时触发
     * 
     * @param message
     */
    @OnMessage(maxMessageSize = 1000)
    public void OnMessage(String message) {
        //消息内容反序列化
        Message msg = JSONObject.parseObject(message, Message.class);
        msg.setHost(getUserName());
        //对html代码进行转义
        msg.setMsg(txt2htm(msg.getMsg()));
        if (msg.getDests() == null)
            broadcastToAll(msg);
        else
            broadcastToSpecia(msg);
    }

    @OnError
    public void onError(Throwable t) throws Throwable {
        System.err.println("Chat Error: " + t.toString());
    }

    /**
     * 广播给所有用户
     * 
     * @param msg
     */
    private static void broadcastToAll(Message msg) {
        for (textController client : getConnections())
            client.call(msg);
    }

    /**
     * 发送给指定的用户
     * 
     * @param msg
     */
    private static void broadcastToSpecia(Message msg) {
        for (textController client : getConnections())
            // 感觉用map进行映射会更好点
            if (Contains(msg.getDests(), client.getUserName()))
                client.call(msg);
    }

    private void call(Message msg) {
        try {
            synchronized (this) {
                if (getUserName().equals(msg.getHost()) && msg.getType() == MsgConstant.Open)
                    msg.setRoomInfo(getRoomInfo());
                this.getSession().getBasicRemote().sendText(msg.toString());
            }
        } catch (IOException e) {
            try {
                //断开连接
                this.getSession().close();
            } catch (IOException e1) {
            }
            OnClose();
        }
    }

    private void requireLogin() {
        Message msg = new Message();
        msg.setType(MsgConstant.RequireLogin);
        call(msg);
    }

    public void setSession(Session session) {
        this.session = session;
    }

    public Session getSession() {
        return this.session;
    }

    public LoginUser getLoginUser() {
        return loginUser;
    }

    public void setLoginUser(LoginUser loginUser) {
        this.loginUser = loginUser;
    }

    /**
     * 设置聊天室信息
     */
    public static void setRoomInfo(RoomInfo info) {
        roomInfo = info;
    }

    public static RoomInfo getRoomInfo() {
        return roomInfo;
    }

    private String getUserName() {
        if (getLoginUser() == null)
            return "";
        return getLoginUser().getUserName();
    }

    public static Set<textController> getConnections() {
        return connections;
    }

    private String[] getUsers() {
        int i = 0;
        String[] destArrary = new String[getConnections().size()];
        for (textController client : getConnections())
            destArrary[i++] = client.getUserName();
        return destArrary;
    }

    /**
     * html代码转义
     * 
     * @param txt
     * @return
     */
    public static String txt2htm(String txt) {
        if (StringUtils.isBlank(txt)) {
            return txt;
        }
        return txt.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll(" ", " ").replaceAll("\n", "<br/>").replaceAll("\'", "'");
    }

    /**
     * 字符串数组是否包含指定字符串
     * 
     * @param strs
     * @param str
     * @return
     */
    public static boolean Contains(String[] strs, String str) {
        if (StringUtils.isBlank(str) || strs.length == 0)
            return false;
        for (String s : strs)
            if (s.equals(str))
                return true;
        return false;
    }
}

服务端代码就这么三个类,还是比较简单的(>_<|||还是比别人的例子复杂好多)。

  • Message类是与客户端统一的消息结构,消息序列化成json串进行传输,客户端再反序列化为对象进行操作,感觉还是比较方便的。
  • wsConfigurator类继承ServerEndpointConfig.Configurator并实现了modifyHandshake方法,将其作为ServerEndpoint的configurator参数值,这里的用途是拿到HttpSession,之后就可以取得HttpSession中的内容(比如登录用户信息)。
  • textController类,上面两个类都是可有可无的东西,这个就是websocket的关键,这里以注解的方式实现,很方便。除此之外,另一种方式是继承javax.websocket.Endpoint类,不过我没试过就不废话了。

@ServerEndpoint

用来标记一个websocket服务器终端

  • value : websocket连接的url,类似spring mvc 的 @RequestMapping,区别的话就是不用再补后缀了
  • configurator : 这个参数没有深究过,只用到过之前说的提取HttpSession的作用

@ClientEndpoint

用来标记一个websocket客户器

@OnOpen

websocket连接建立后执行,主要进行一些初始化操作

  • session : 此session非HttpSession,而是websocket通讯所使用的session,因此需要上述方法另外得到HttpSession
  • EndpointConfig : 应该包含一些这个Endpoint的配置信息之类的(瞎猜的)
  • @PathParam(value = "uid") : 这个是自定义的参数,主要对应ServerEndpoint注解的value值中的{uid}参数占位符,除此之外还可以通过session.getRequestParameterMap()来获取url参数(如"/websocket/chat?uid=123")

@OnClose

websocket连接断开后执行,没什么好说的

@OnMessage

接收到客户端发送端的消息后执行,值得注意的是,OnMessage注解的方法可以有多个重载,方法参数可以为String,ByteBuffer等类型,相应的,session有这么几个方法可以向客户端发送消息:sendText,sendBinary,sendObject等。另外,上面代码里向客户端发消息用的是session.getBasicRemote().sendText方法,这是阻塞的方式,还有一种异步方式session.getAsyncRemote().sendText,虽说是异步,不过高频率发送并没有出现错乱的情况,还有待研究。

  • maxMessageSize : 用来指定消息字节最大限制,超过限制就会关闭连接,文字聊天不设置基本没什么问题,默认的大小够用了,传图片或者文件可能就会因为超出限制而导致连接“莫名其妙”被关闭,这个坑还是比较难发现的。

@OnError

报错的时候会执行,不过试过各种异常下这个方法都没有执行,很奇怪

服务端功能比较简单,主要实现了几个注解的方法,对客户端传来的消息进行广播,并无其他额外操作。再来看下前端的代码:

(function(window) {
    Blob.prototype.appendAtFirst = function(blob) {
        return new Blob([blob, this]);
    };
    var WS_Open = 1,
        WS_Close = 2,
        WS_MsgToAll = 3,
        WS_MsgToPoints = 4,
        WS_RequireLogin = 5,
        WS_setName = 6,
        types = ["文本", "视频", "语音"],
        getWebSocket = function(host) {
            var socket;
            if ('WebSocket' in window) {
                socket = new WebSocket(host);
            } else if ('MozWebSocket' in window) {
                socket = new MozWebSocket(host);
            }
            return socket;
        },
        WSClient = function(option) {
            var isReady = false,
                init = function(client, option) {
                    client.socket = null;
                    client.online = false;
                    client.isUserClose = false;
                    client.option = option || {};
                };

            this.connect = function(host) {
                var client = this,
                    socket = getWebSocket(host);

                if (socket == null) {
                    console.log('错误: 当前浏览器不支持WebSocket,请更换其他浏览器', true);
                    alert('错误: 当前浏览器不支持WebSocket,请更换其他浏览器');
                    return;
                }

                socket.onopen = function() {
                    var onopen = client.option.onopen,
                        type = types[client.option.type];
                    console.log('WebSocket已连接.');
                    console.log("%c类型:" + type, "color:rgb(228, 186, 20)");
                    onopen && onopen();
                };

                socket.onclose = function() {
                    var onclose = client.option.onclose,
                        type = types[client.option.type];
                    client.online = false;
                    console.error('WebSocket已断开.');
                    console.error("%c类型:" + type, "color:rgb(228, 186, 20)");
                    onclose && onclose();
                    if (!client.isUserClose) {
                        client.initialize();
                    }
                };

                socket.onmessage = function(message) {
                    var option = client.option;
                    if (typeof(message.data) == "string") {
                        var msg = JSON.parse(message.data);
                        switch (msg.type) {
                        case WS_Open:
                            option.wsonopen && option.wsonopen(msg);
                            break;
                        case WS_Close:
                            option.wsonclose && option.wsonclose(msg);
                            break;
                        case WS_MsgToAll:
                        case WS_MsgToPoints:
                            option.wsonmessage && option.wsonmessage(msg);
                            break;
                        case WS_RequireLogin:
                            option.wsrequirelogin && option.wsrequirelogin();
                            break;
                        case WS_setName:
                            option.userName = msg.host;
                            option.wssetname && option.wssetname(msg);
                            break;
                        }
                    } else if (message.data instanceof Blob) {
                        option.wsonblob && option.wsonblob(message);
                    }

                };

                isReady = true;
                this.socket = socket;
                return this;
            };

            this.initialize = function(param) {
                return this.connect(this.option.host + (param ? "?" + param : ""));
            };

            this.sendString = function(message) {// 向服务端发送给字符串
                return isReady && this.socket.send(message);
            };

            this.sendBlob = function(blob) {// 向服务端发送二进制数据
                return isReady && this.socket.send(blob.appendAtFirst(this.option.userName));
            };

            this.close = function() {
                this.isReady = false;
                this.online = false;
                this.isUserClose = true;
                this.socket.close();
                return true;
            };

            this.isMe = function(name) {
                return this.option.userName == name;
            }

            init(this, option);
        };

    window.WSClient = WSClient;

})(window);

这里的代码我做了下粗劣的封装,就不给出具体实现了,调用的时候实现具体的逻辑即可。如下形式:

var textClient = new WSClient({
    host: "ws://" + window.location.host + "/websocket/chat/123",// 注意这里不是http协议
    type: MODE_TEXT,
    onopen: function() {
        console.log('WebSocket已连接.');
    },
    onclose: function() {
        console.log('Info: WebSocket已断开.');
    },
    wsonopen: function(msg) {
        console.log("***加入聊天室");
    },
    wsonclose: function(msg) {
        console.log("***退出了聊天室");
    },
    wsonmessage: function(msg) {
        console.log(“收到消息:” + msg.msg);
    },
    wsrequirelogin: function(msg) {
        document.location.href = "http://" + window.location.host + "/login.htm?to_url=" + document.location.href;
    },
    wssetname: function(msg) {
    }
});

和服务端要实现的几个方法类似,就不多说了。其中,socket.onmessage中message.data有两种类型:string和Blob,Blob表示二进制数据,比如图片和声音,文件就可以通过Blob对象来传输。另外,服务端发送消息的send方法是有好几种的,而这里WebSocket对象的send方法只有一个,参数可以是Blob或string。

最后附上websocket的nginx配置:

location /websocket/chat { 
    proxy_pass http://localhost:8080/websocket/chat; 
    include websocket.conf;
}

websocket.conf:

#避免nginx超时
proxy_read_timeout 86400;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

参考文章:


唉,第一次写文章,加上基础不扎实,磨磨蹭蹭写了一晚上才结束战斗,真是不容易。水平有限,有讲错的地方欢迎指出,之后会继续关于视频和音频通讯方式的总结。


转载请注明出处:https://www.jianshu.com/p/62790429acef

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容