前言
去年在tomcat7自帶的例子中發(fā)現(xiàn)了兩個(gè)有趣的demo,貪食蛇游戲和畫板狠毯。很有意思的是打開的幾個(gè)窗口內(nèi)容都是一樣的策菜,而且還會(huì)同步更新,如果換做以往做web開發(fā)的套路來(lái)實(shí)現(xiàn)這個(gè)效果還是比較費(fèi)勁的国觉。于是心血來(lái)潮就去查了一些關(guān)于websocket的資料并做了這么一個(gè)文字聊天室吧恃。前段時(shí)間應(yīng)別人的需要又把它翻了出來(lái)加上了視頻和語(yǔ)音功能,瞬間高大上了很多麻诀。做完之后當(dāng)然得趁熱打鐵總結(jié)下痕寓,順便作為第一次寫文章的素材。(∩_∩)
簡(jiǎn)介
WebSocket 是 HTML5 一種新的協(xié)議蝇闭。它實(shí)現(xiàn)了瀏覽器與服務(wù)器全雙工通信呻率,能更好的節(jié)省服務(wù)器資源和帶寬并達(dá)到實(shí)時(shí)通訊,它建立在 TCP 之上呻引,同 HTTP 一樣通過(guò) TCP 來(lái)傳輸數(shù)據(jù)礼仗。
說(shuō)起實(shí)時(shí)通訊就不得不提一些“服務(wù)器推”技術(shù)。
-
輪詢
客戶端以一定的時(shí)間間隔發(fā)送Ajax請(qǐng)求,優(yōu)點(diǎn)實(shí)現(xiàn)起來(lái)比較簡(jiǎn)單逻悠、省事,不過(guò)缺點(diǎn)也很明顯元践,請(qǐng)求有很大一部分是無(wú)用的,而且需要頻繁建立和釋放TCP連接童谒,很消耗帶寬和服務(wù)器資源单旁。
-
長(zhǎng)輪詢
與普通輪詢不同的地方在于,服務(wù)端接收到請(qǐng)求后會(huì)保持住不立即返回響應(yīng)饥伊,等到有消息更新才返回響應(yīng)并關(guān)閉連接慎恒,客戶端處理完響應(yīng)再重新發(fā)起請(qǐng)求。較之普通輪詢沒有無(wú)用的請(qǐng)求撵渡,但服務(wù)器保持連接也是有消耗的融柬,如果服務(wù)端數(shù)據(jù)變化頻繁的話和普通輪詢并無(wú)兩樣。
-
長(zhǎng)連接
在頁(yè)面中嵌入一個(gè)隱藏的iframe,將其src設(shè)為一個(gè)長(zhǎng)連接的請(qǐng)求趋距,這樣服務(wù)端就能不斷向客戶端發(fā)送數(shù)據(jù)粒氧。優(yōu)缺點(diǎn)與長(zhǎng)輪詢相仿。
這些技術(shù)都明顯存在兩個(gè)相同的缺點(diǎn):
服務(wù)器需要很大的開銷
都做不到真正意義上的“主動(dòng)推送”节腐,服務(wù)端只能“被動(dòng)”地響應(yīng)外盯,于是就輪到正主出場(chǎng)了。
在websocket中翼雀,只需要做一個(gè)握手動(dòng)作就可以在客戶端和服務(wù)器之間建立連接饱苟,之后通過(guò)數(shù)據(jù)幀的形式在這個(gè)連接上進(jìn)行通訊,并且狼渊,由于連接是雙向的箱熬,在連接建立之后服務(wù)端隨時(shí)可以主動(dòng)向客戶端發(fā)送消息(前提是連接沒有斷開)类垦。
實(shí)現(xiàn)
以前一些websocket的例子都是基于某個(gè)特定的容器(如Tomcat,Jetty),在Oracle發(fā)布了JSR356規(guī)范之后城须,websocket的JavaAPI得到了統(tǒng)一,所以只要Web容器支持JSR356,那么我們寫websocket時(shí),代碼都是一樣的了.Tomcat從7.0.47開始支持JSR356.另外有一點(diǎn)要說(shuō)明的是JDK的要求是7及以上蚤认。
我本地的環(huán)境為 jdk1.7, nginx1.7.8 ( 反向代理 ), tomcat7.0.52( 需要在buildpath中還要添加tomcat7的library ),chrome糕伐。
廢話不多說(shuō)砰琢,先上代碼
// 消息結(jié)構(gòu)Message類
public class Message {
private int type;//消息類型
private String msg;//消息主題
private String host;// 發(fā)送者
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;// 發(fā)送給所有人
public final static int MsgToPoints = 4;// 發(fā)送給指定用戶
public final static int RequireLogin = 5;// 需要登錄
public final static int setName = 6;// 設(shè)置用戶名
}
public static class RoomInfo {
private String name;// 聊天室名稱
private String creater;//創(chuàng)建人
private String createTime;// 創(chuàng)建時(shí)間
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) {
//通過(guò)配置來(lái)獲取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連接建立后觸發(fā)
*
* @param session
* @param config
*/
@OnOpen
public void OnOpen(Session session, EndpointConfig config, @PathParam(value = "uid") String uid) {
//設(shè)置websocket連接的session
setSession(session);
// 獲取HttpSession
HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
// 從HttpSession中取得當(dāng)前登錄的用戶作為當(dāng)前連接的用戶
setLoginUser((LoginUser) httpSession.getAttribute("LoginUser"));
if (getLoginUser() == null) {
requireLogin();// 未登錄需要進(jìn)行登錄
return;
}
// 設(shè)置聊天室信息
if (getConnections().size() == 0) {// 如果當(dāng)前聊天室為空,建立新的信息
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連接斷開后觸發(fā)
*/
@OnClose
public void OnClose() {
//從連接集合中移除
getConnections().remove(this);
//廣播通知所有連接有用戶退出
broadcastToAll(new Message(getUserName(), MsgConstant.Close, getUsers()));
}
/**
* 接受到客戶端發(fā)送的字符串時(shí)觸發(fā)
*
* @param message
*/
@OnMessage(maxMessageSize = 1000)
public void OnMessage(String message) {
//消息內(nèi)容反序列化
Message msg = JSONObject.parseObject(message, Message.class);
msg.setHost(getUserName());
//對(duì)html代碼進(jìn)行轉(zhuǎn)義
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);
}
/**
* 發(fā)送給指定的用戶
*
* @param msg
*/
private static void broadcastToSpecia(Message msg) {
for (textController client : getConnections())
// 感覺用map進(jìn)行映射會(huì)更好點(diǎn)
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;
}
/**
* 設(shè)置聊天室信息
*/
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代碼轉(zhuǎn)義
*
* @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("\'", "'");
}
/**
* 字符串?dāng)?shù)組是否包含指定字符串
*
* @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;
}
}
服務(wù)端代碼就這么三個(gè)類良瞧,還是比較簡(jiǎn)單的(>_<|||還是比別人的例子復(fù)雜好多)陪汽。
- Message類是與客戶端統(tǒng)一的消息結(jié)構(gòu),消息序列化成json串進(jìn)行傳輸褥蚯,客戶端再反序列化為對(duì)象進(jìn)行操作挚冤,感覺還是比較方便的。
- wsConfigurator類繼承ServerEndpointConfig.Configurator并實(shí)現(xiàn)了modifyHandshake方法遵岩,將其作為ServerEndpoint的configurator參數(shù)值你辣,這里的用途是拿到HttpSession巡通,之后就可以取得HttpSession中的內(nèi)容(比如登錄用戶信息)尘执。
- textController類,上面兩個(gè)類都是可有可無(wú)的東西宴凉,這個(gè)就是websocket的關(guān)鍵誊锭,這里以注解的方式實(shí)現(xiàn),很方便弥锄。除此之外丧靡,另一種方式是繼承javax.websocket.Endpoint類,不過(guò)我沒試過(guò)就不廢話了籽暇。
@ServerEndpoint
用來(lái)標(biāo)記一個(gè)websocket服務(wù)器終端
- value : websocket連接的url温治,類似spring mvc 的 @RequestMapping,區(qū)別的話就是不用再補(bǔ)后綴了
- configurator : 這個(gè)參數(shù)沒有深究過(guò)戒悠,只用到過(guò)之前說(shuō)的提取HttpSession的作用
@ClientEndpoint
用來(lái)標(biāo)記一個(gè)websocket客戶器
@OnOpen
websocket連接建立后執(zhí)行熬荆,主要進(jìn)行一些初始化操作
- session : 此session非HttpSession,而是websocket通訊所使用的session,因此需要上述方法另外得到HttpSession
- EndpointConfig : 應(yīng)該包含一些這個(gè)Endpoint的配置信息之類的(瞎猜的)
- @PathParam(value = "uid") : 這個(gè)是自定義的參數(shù)绸狐,主要對(duì)應(yīng)ServerEndpoint注解的value值中的{uid}參數(shù)占位符卤恳,除此之外還可以通過(guò)session.getRequestParameterMap()來(lái)獲取url參數(shù)(如"/websocket/chat?uid=123")
@OnClose
websocket連接斷開后執(zhí)行,沒什么好說(shuō)的
@OnMessage
接收到客戶端發(fā)送端的消息后執(zhí)行寒矿,值得注意的是突琳,OnMessage注解的方法可以有多個(gè)重載,方法參數(shù)可以為String,ByteBuffer等類型符相,相應(yīng)的,session有這么幾個(gè)方法可以向客戶端發(fā)送消息:sendText拆融,sendBinary,sendObject等。另外冠息,上面代碼里向客戶端發(fā)消息用的是session.getBasicRemote().sendText方法挪凑,這是阻塞的方式,還有一種異步方式session.getAsyncRemote().sendText逛艰,雖說(shuō)是異步躏碳,不過(guò)高頻率發(fā)送并沒有出現(xiàn)錯(cuò)亂的情況,還有待研究散怖。
- maxMessageSize : 用來(lái)指定消息字節(jié)最大限制菇绵,超過(guò)限制就會(huì)關(guān)閉連接,文字聊天不設(shè)置基本沒什么問(wèn)題镇眷,默認(rèn)的大小夠用了咬最,傳圖片或者文件可能就會(huì)因?yàn)槌鱿拗贫鴮?dǎo)致連接“莫名其妙”被關(guān)閉,這個(gè)坑還是比較難發(fā)現(xiàn)的欠动。
@OnError
報(bào)錯(cuò)的時(shí)候會(huì)執(zhí)行永乌,不過(guò)試過(guò)各種異常下這個(gè)方法都沒有執(zhí)行,很奇怪
服務(wù)端功能比較簡(jiǎn)單具伍,主要實(shí)現(xiàn)了幾個(gè)注解的方法翅雏,對(duì)客戶端傳來(lái)的消息進(jìn)行廣播,并無(wú)其他額外操作人芽。再來(lái)看下前端的代碼:
(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 = ["文本", "視頻", "語(yǔ)音"],
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('錯(cuò)誤: 當(dāng)前瀏覽器不支持WebSocket望几,請(qǐng)更換其他瀏覽器', true);
alert('錯(cuò)誤: 當(dāng)前瀏覽器不支持WebSocket,請(qǐng)更換其他瀏覽器');
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) {// 向服務(wù)端發(fā)送給字符串
return isReady && this.socket.send(message);
};
this.sendBlob = function(blob) {// 向服務(wù)端發(fā)送二進(jìn)制數(shù)據(jù)
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);
這里的代碼我做了下粗劣的封裝萤厅,就不給出具體實(shí)現(xiàn)了橄抹,調(diào)用的時(shí)候?qū)崿F(xiàn)具體的邏輯即可。如下形式:
var textClient = new WSClient({
host: "ws://" + window.location.host + "/websocket/chat/123",// 注意這里不是http協(xié)議
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) {
}
});
和服務(wù)端要實(shí)現(xiàn)的幾個(gè)方法類似惕味,就不多說(shuō)了楼誓。其中,socket.onmessage中message.data有兩種類型:string和Blob,Blob表示二進(jìn)制數(shù)據(jù)名挥,比如圖片和聲音疟羹,文件就可以通過(guò)Blob對(duì)象來(lái)傳輸。另外躺同,服務(wù)端發(fā)送消息的send方法是有好幾種的阁猜,而這里WebSocket對(duì)象的send方法只有一個(gè),參數(shù)可以是Blob或string蹋艺。
最后附上websocket的nginx配置:
location /websocket/chat {
proxy_pass http://localhost:8080/websocket/chat;
include websocket.conf;
}
websocket.conf:
#避免nginx超時(shí)
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";
參考文章:
唉剃袍,第一次寫文章,加上基礎(chǔ)不扎實(shí)捎谨,磨磨蹭蹭寫了一晚上才結(jié)束戰(zhàn)斗民效,真是不容易憔维。水平有限,有講錯(cuò)的地方歡迎指出畏邢,之后會(huì)繼續(xù)關(guān)于視頻和音頻通訊方式的總結(jié)业扒。