使用SpringBoot和Netty實現一對一(互相)簡單聊天

首先看一下效果圖:
在這裏插入圖片描述

依賴前端代碼詳情請移步: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框架,很不錯喔!
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章