首先看一下效果圖:
依賴前端代碼詳情請移步:https://github.com/coffcer/vue-chat
本樣例前端採用JQuery與Vue + Webpack
爲了項目儘可能簡單,我們一切從簡,具體如下:
-
不涉及複雜的業務邏輯
-
測試樣例從簡(Lucy,Jack,Mike),MYSQL表數據如下:
-
項目存在兩個服務器:tomcat服務器,Netty構建的webSocket服務器
-
項目結構如下:
額,DeleteUselessRepository與本項目無關,只是用來清理maven倉庫的無效文件夾。
需要指出的是前端模板本身是很優異的,使用了Vue,也就是我們只需要修改Vue的data的數據即可,然後在關鍵的部位添加我們自己的方法即可。
另外此tomcat服務於netty服務無交集,即不共享session,此爲缺陷之一。
下面講解代碼部分:
CacheLoader.java
public class CacheLoader {
public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
public static Map<Double,Channel> channelMap = new ConcurrentHashMap<>();
}
channelGroup和channelMap都是存儲客戶端的SocketChannel對象的
channelGroup使用原生對象,使用全局事件處理器,主要用來廣播消息
channelMap爲自己實現,主要是爲了完成一對一通信,所以每一個SocketChannel都與其用戶id綁定
HttpRequestHandler.java
public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
// webSocket標識
private final String wsUri;
public HttpRequestHandler(String wsUri) {
this.wsUri = wsUri;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest fullHttpRequest) throws Exception {
// 如果是webSocket請求,請求地址uri等於wsUri
if (wsUri.equalsIgnoreCase(fullHttpRequest.uri())) {
// 將消息發送到下一個channelHandler
ctx.fireChannelRead(fullHttpRequest.retain());
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
因爲此處不需要構建http服務(全權交給tomcat處理),故HttpRequestHandler實現就一個功能,判斷請求類型,如果是websocket請求則向下轉發。
TextWebSocketFrameHandler.java
public class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private final ChannelGroup channelGroup;
private ChatService chatService = new ChatServiceImpl();
public TextWebSocketFrameHandler(ChannelGroup channelGroup) {
this.channelGroup = channelGroup;
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 如果ws握手完成
if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
// 刪除pipeLine中處理http請求的handler
ctx.pipeline().remove(HttpRequestHandler.class);
// 寫一個消息廣播到所有的客戶端channel
//channelGroup.writeAndFlush(new TextWebSocketFrame("Client " + ctx.channel() + " joined!"));
// 將當前客戶端channel添加進group
channelGroup.add(ctx.channel());
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
// 將接收的消息通過ChannelGroup轉發到所有連接的客戶端
//channelGroup.writeAndFlush(textWebSocketFrame.retain());
// 前端組裝的消息格式是 {"message":{"text":"項目地址","date":"2018-11-28T02:13:52.437Z"},"to":2,"from":1}
Map<String,Object> msg = GsonUtils.fromJson(textWebSocketFrame.text().toString(),new TypeToken<Map<String,Object>>(){});
String type = (String) msg.get("type");
switch (type) {
case "REGISTER":
chatService.register(channelHandlerContext,msg);
break;
case "SINGLE_SENDING":
chatService.singleSend(channelHandlerContext,msg);
break;
}
}
}
userEventTriggered是客戶端連接請求觸發函數
channelRead0中則是我們的核心業務邏輯
- 前端傳來的數據是Json字符串
- Json字符串中存在type字段用來標識消息類型,from字段標識消息來源,to字段標識消息去向,message則爲消息體。
- 因爲前端模板需要,message中應該存在兩個字段:date與text。二者顧名思義。
ChatServiceImpl.java
public class ChatServiceImpl implements ChatService{
@Override
public void register(ChannelHandlerContext channelHandlerContext, Map<String, Object> msg) {
CacheLoader.channelMap.put(Double.parseDouble(msg.get("userId").toString()),channelHandlerContext.channel());
}
@Override
public void singleSend(ChannelHandlerContext channelHandlerContext, Map<String, Object> msg) {
Double to = Double.parseDouble(msg.get("to").toString());
msg.remove("to");
msg.remove("type");
CacheLoader.channelMap.get(to).writeAndFlush(new TextWebSocketFrame(GsonUtils.toJson(msg)));
}
}
以上兩個邏輯函數比較簡單,不要吐槽爲什麼channelMap的id是Double類型,因爲我懶QAQ!
單對單聊天中msg需要傳到前臺的只有from和message兩個字段。
ChatServerInitializer.java
public class ChatServerInitializer extends ChannelInitializer<SocketChannel> {
private final ChannelGroup channelGroup;
public ChatServerInitializer(ChannelGroup channelGroup) {
this.channelGroup = channelGroup;
}
@Override
protected void initChannel(SocketChannel channel) throws Exception {
channel.pipeline()
// 編解碼http請求
.addLast(new HttpServerCodec())
//聚合解碼HttpRequest/HttpContent/LastHttpContent到FullHttpRequest
//保證接收的Http請求的完整性
.addLast(new HttpObjectAggregator(64 * 1024))
// 處理FullHttpRequest
.addLast(new HttpRequestHandler("/ws"))
// 處理其他的WebSocketFrame
.addLast(new WebSocketServerProtocolHandler("/ws"))
// 處理TextWebSocketFrame
.addLast(new TextWebSocketFrameHandler(channelGroup));
}
}
netty支持六種WebSocket框架
我們只需要處理TextWebSocketFrameHandler,其他交給netty的WebSocketServerProtocolHandler處理
下面是對前端js的改造和新增
- 改造發送邏輯,新增send方法
- 改造加載邏輯,新增fetch方法
- 改造在原末班的main.js進行,新增handle.js
handle.js
var R$_globalVM;
// 加載初始化信息
R$_fetch = function () {
// 下面的ajax方法爲同步
var result = null;
$.ajaxSettings.async = false
$.get("/user/getInitialData",null,function (data) {
result = data;
});
return result;
$.ajaxSettings.async = true
}
var socket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
socket = new WebSocket("ws://localhost:2048/ws");
socket.onmessage = function (event) {
// 後端發送過來的數據中存在userId,message
var source = JSON.parse(event.data);
console.log(source);
var vm_data = R$_globalVM.$data.sessionList;
for(var i = 0;i<vm_data.length;i++){
if (vm_data[i].userId == source.from){
vm_data[i].messages.push(source.message);
break;
}
}
};
// 連接成功1秒後,將用戶信息註冊到服務器在線用戶表
socket.onopen = setTimeout(function(event){
console.log("連接開啓!");
var data = {
"userId" : R$_globalVM.$data.user.id,
"type" : "REGISTER"
};
send(JSON.stringify(data));
}, 1000);
socket.onclose = function (event) {
console.log("連接被關閉");
};
} else {
alert("你的瀏覽器不支持 WebSocket!");
}
function send(value) {
if (!window.WebSocket) {
return;
}
if (socket.readyState == WebSocket.OPEN) {
socket.send(value);
} else {
alert("連接沒有開啓.");
}
}
R$_send = function (value) {
send(JSON.stringify(value));
return true;
}
socket.onopen中完成用戶註冊請求邏輯
socket.onmessage接收到後臺發送的消息,判斷來源,將message對應加入到sessionList中
/user/getInitialData對應的控制器邏輯:
@RequestMapping(value = "getInitialData",method = RequestMethod.GET)
@ResponseBody
public Map<String,Object> getInitialData(HttpSession session){
User user = (User) session.getAttribute("user");
Map<String,Object> result = new HashMap<>();
// user
result.put("user",userService.userToMap(user));
// userList,sessionList
List<Map<String,Object>> userList = new ArrayList<>();
List<Map<String,Object>> sessionList = new ArrayList<>();
String[] friends = user.getFriends().split(",");
for (String friend : friends){
userList.add(userService.userToMap(userService.get(Integer.parseInt(friend))));
Map<String,Object> tmp_map = new HashMap<>();
tmp_map.put("messages",new ArrayList<>());
tmp_map.put("userId",Integer.parseInt(friend));
sessionList.add(tmp_map);
}
result.put("userList",userList);
result.put("sessionList",sessionList);
return result;
}
需要注意的是,在main.js中需要:
R$_globalVM = new Vue(o["default"])
改造發送邏輯
<div class=m-text><textarea placeholder="按 Ctrl + Enter 發送" v-model=text @keyup=inputing></textarea></div>
找到inputing方法,改造前後對比:
改造前:
inputing: function (e) {
e.ctrlKey && 13 === e.keyCode && this.text.length && (this.session.messages.push({
text: this.text,
date: new Date,
self: !0 // !0表示是自己發送的消息(其實就是true),0表示是對方發送的消息
}), this.text = "")
}
改造後:
inputing: function (e) {
e.ctrlKey && 13 === e.keyCode && this.text.length && (this.session.messages.push({
text: this.text,
date: new Date,
self: !0 // !0表示是自己發送的消息(其實就是true),0表示是對方發送的消息
}),R$_send({"message":{
text: this.text,
date: new Date},
// 發給誰的數據
"to": this.session.userId,
"from":R$_globalVM.$data.user.id,
"type":"SINGLE_SENDING"
}), this.text = "")
}
改造加載邏輯
改造前:
var i = s(14), o = r(i), n = "VUE-CHAT-v3";
if (!localStorage.getItem(n)) {
var a = new Date, l = {
user: {id: 1, name: "Coffce", img: "dist/images/1.jpg"},
userList: [{id: 2, name: "示例介紹", img: "dist/images/2.png"}, {
id: 3,
name: "webpack",
img: "dist/images/3.jpg"
}],
sessionList: [{
userId: 2,
messages: [{
text: "Hello,這是一個基於Vue + Webpack構建的簡單chat示例,聊天記錄保存在localStorge。簡單演示了Vue的基礎特性和webpack配置。",
date: a
}, {text: "項目地址: https://github.com/coffcer/vue-chat", date: a}]
}, {userId: 3, messages: []}]
};
localStorage.setItem(n, (0, o["default"])(l))
}
t["default"] = {
fetch: function () {
return JSON.parse(localStorage.getItem(n))
}, save: function (e) {
localStorage.setItem(n, (0, o["default"])(e))
}
}
改造後:
t["default"] = {
fetch: function () {
// 加載歷史聊天記錄
return R$_fetch();
}, save: function (e) {
// 暫時不做
//localStorage.setItem(n, (0, o["default"])(e))
//R$_save((0, o["default"])(e));
}
}
ok,至此,大功告成!!至於tomcat服務,對不起,那完全是無關緊要的,不在此處講解。
看看成果?
hah,Jack,Lucy,Mike三人可以愉快地聊天了。
此外,websocket通信也推薦nodejs的socket.io框架,很不錯喔!