用go實(shí)現(xiàn)聊天室(WebSocket方式)

前言

剛?cè)腴T(mén)go語(yǔ)言和beego框架,通過(guò)一個(gè)簡(jiǎn)單聊天室的實(shí)現(xiàn),來(lái)趁熱練習(xí)。

詳細(xì)代碼見(jiàn)github

一、WebSocket協(xié)議

在實(shí)現(xiàn)之前,我們需要解決一個(gè)底層問(wèn)題。

總所周知,HTTP協(xié)議是單向傳輸協(xié)議,只能由客戶端主動(dòng)向服務(wù)端發(fā)送信息,反之則不行。而在聊天室中,一個(gè)用戶發(fā)送一條消息,服務(wù)器則需要將該條消息廣播到聊天室中的所有用戶,這想通過(guò)HTTP協(xié)議實(shí)現(xiàn)是不可能的。

除非,讓每個(gè)用戶每隔一段時(shí)間便請(qǐng)求一次服務(wù)器獲取新消息。這種方式稱為長(zhǎng)輪詢。但其缺點(diǎn)十分明顯,非常消耗資源。

為了解決這個(gè)問(wèn)題,WebSocket協(xié)議應(yīng)運(yùn)而生。

那什么是WebSocket協(xié)議呢?百度百科

WebSocket協(xié)議HTTP協(xié)議同屬于應(yīng)用層協(xié)議。不同的是,WebSocket是雙向傳輸協(xié)議,彌補(bǔ)了這個(gè)缺點(diǎn),在該協(xié)議下,服務(wù)端也能主動(dòng)向客戶端發(fā)送信息。同時(shí),一旦連接,客戶端會(huì)與服務(wù)端保持長(zhǎng)時(shí)間的通訊。

WebSocket協(xié)議的標(biāo)識(shí)符是ws,如:ws://localhost:8080/chatRoom/WS

二、go語(yǔ)言并發(fā)特性

go語(yǔ)言的一大特性,便是內(nèi)置的并發(fā)功能(goroutine)。以及,在并發(fā)個(gè)體之間傳遞數(shù)據(jù)的“通道”(chan)。

具體細(xì)節(jié)不在此贅述。

三、beego框架

一個(gè)開(kāi)源的輕量級(jí)web server框架,實(shí)現(xiàn)了典型的MVC模型,和先進(jìn)的api接口模型(前后端分離模型)。

聊天室的實(shí)現(xiàn),便基于其MVC模型。

四、實(shí)現(xiàn)步驟

1.需求分析

1)數(shù)據(jù)分析

聊天室中主要物體分為兩種:用戶和消息。

用戶的主要屬性為:姓名、客戶端與服務(wù)端之間的WebSocket連接指針。

消息則分為三種:用戶發(fā)消息、有用戶加入、有用戶離開(kāi)。若將加入和離開(kāi)也視為用戶發(fā)出的消息內(nèi)容,那消息的主要屬性就有:消息類型、消息內(nèi)容、發(fā)消息者。

2)功能分析

前端:

  • 實(shí)現(xiàn)與服務(wù)端的WebSocket連接。

后端:

  • 提供WebSocket連接接口。與實(shí)現(xiàn)HTTP連接接口一樣,利用beego框架即可。

  • 當(dāng)新用戶建立連接時(shí)、用戶斷開(kāi)連接時(shí)、收到連接中用戶發(fā)來(lái)的新信息時(shí),能將消息廣播給所有連接用戶。

客戶端(即前端js)若要與服務(wù)端建立WebSocket連接,需要調(diào)用WebSocket連接API,詳細(xì)內(nèi)容見(jiàn)大神博客。

服務(wù)端(即后端go)實(shí)現(xiàn)

2.數(shù)據(jù)結(jié)構(gòu)

用戶:

type Client struct {
    conn *websocket.Conn    // 用戶websocket連接
    name string             // 用戶名稱
}

消息:

// 1.設(shè)置為公開(kāi)屬性(即首字母大寫(xiě)),是因?yàn)閷傩灾邓接袝r(shí),外包的函數(shù)無(wú)法使用或訪問(wèn)該屬性值(如:json.Marshal())
// 2.`json:"name"` 是為了在對(duì)該結(jié)構(gòu)類型進(jìn)行json編碼時(shí),自定義該屬性的名稱
type Message struct {
    EventType byte  `json:"type"`       // 0表示用戶發(fā)布消息;1表示用戶進(jìn)入;2表示用戶退出
    Name string     `json:"name"`       // 用戶名稱
    Message string  `json:"message"`    // 消息內(nèi)容
}

用戶組:

clients = make(map [Client] bool)      // 用戶組映射

此處使用映射而不是數(shù)組,是為了方便判斷某個(gè)用戶是否已經(jīng)加入或者已經(jīng)退出了。

用于goroutine通道:

// 此處要設(shè)置有緩沖的通道。因?yàn)檫@是goroutine自己從通道中發(fā)送并接受數(shù)據(jù)。
// 若是無(wú)緩沖的通道,該goroutine發(fā)送數(shù)據(jù)到通道后就被鎖定,需要數(shù)據(jù)被接受后才能解鎖,而恰恰接受數(shù)據(jù)的又只能是它自己
join = make(chan Client, 10)        // 用戶加入通道
leave = make(chan Client, 10)       // 用戶退出通道
message = make(chan Message, 10)    // 消息通道

3.功能實(shí)現(xiàn)

1)前端WebSocket連接實(shí)現(xiàn):
//====================webSocket連接======================
// 創(chuàng)建一個(gè)webSocket連接
var socket = new WebSocket('ws://'+window.location.host+'/chatRoom/WS?name=' + $('#name').text());

// 當(dāng)webSocket連接成功的回調(diào)函數(shù)
socket.onopen = function () {
    console.log("webSocket open");
    connected = true;
};

// 斷開(kāi)webSocket連接的回調(diào)函數(shù)
socket.onclose = function () {
    console.log("webSocket close");
    connected = false;
};
//=======================接收消息并顯示===========================
// 接受webSocket連接中,來(lái)自服務(wù)端的消息
socket.onmessage = function(event) {
    // 將服務(wù)端發(fā)送來(lái)的消息進(jìn)行json解析
    var data = JSON.parse(event.data);
    console.log("revice:" , data);

    var name = data.name;
    var type = data.type;
    var msg = data.message;

    // type為0表示有人發(fā)消息
    var $messageDiv;
    if (type == 0) {
        var $usernameDiv = $('<span style="margin-right: 15px;font-weight: 700;overflow: hidden;text-align: right;"/>')
                .text(name);
        if (name == $("#name").text()) {
            $usernameDiv.css('color', nameColor);
        } else {
            $usernameDiv.css('color', getUsernameColor(name));
        }
        var $messageBodyDiv = $('<span style="color: gray;"/>')
                .text(msg);
        $messageDiv = $('<li style="list-style-type:none;font-size:25px;"/>')
                .data('username', name)
                .append($usernameDiv, $messageBodyDiv);
    }
    // type為1或2表示有人加入或退出
    else {
        var $messageBodyDiv = $('<span style="color:#999999;">')
                .text(msg);
        $messageDiv = $('<li style="list-style-type:none;font-size:15px;text-align:center;"/>')
                .append($messageBodyDiv);
    }

    $messageArea.append($messageDiv);
    $messageArea[0].scrollTop = $messageArea[0].scrollHeight;   // 讓屏幕滾動(dòng)
}
//========================發(fā)送消息==========================
// 通過(guò)webSocket發(fā)送消息到服務(wù)端
function sendMessage () {
    var inputMessage = $inputArea.val();  // 獲取輸入框的值

    if (inputMessage && connected) {
        $inputArea.val('');      // 清空輸入框的值
        socket.send(inputMessage);  // 基于WebSocket連接發(fā)送消息
        console.log("send message:" + inputMessage);
    }
}
2)后端WebSocket連接接口

繼承beego框架的Controller類型:

type ServerController struct {
    beego.Controller
}

編寫(xiě)ServerController類型中用于WebSocket連接的方法:

// 用于與用戶間的websocket連接(chatRoom.html發(fā)送來(lái)的websocket請(qǐng)求)
func (c *ServerController) WS() {
    name := c.GetString("name")
    if len(name) == 0 {
        beego.Error("name is NULL")
        c.Redirect("/", 302)
        return
    }

    // 檢驗(yàn)http頭中upgrader屬性,若為websocket,則將http協(xié)議升級(jí)為websocket協(xié)議
    conn, err := (&websocket.Upgrader{}).Upgrade(c.Ctx.ResponseWriter, c.Ctx.Request, nil)

    if _, ok := err.(websocket.HandshakeError); ok {
        beego.Error("Not a websocket connection")
        http.Error(c.Ctx.ResponseWriter, "Not a websocket handshake", 400)
        return
    } else if err != nil {
        beego.Error("Cannot setup WebSocket connection:", err)
        return
    }

    var client Client
    client.name = name
    client.conn = conn

    // 如果用戶列表中沒(méi)有該用戶
    if !clients[client] {
        join <- client
        beego.Info("user:", client.name, "websocket connect success!")
    }

    // 當(dāng)函數(shù)返回時(shí),將該用戶加入退出通道,并斷開(kāi)用戶連接
    defer func() {
        leave <- client
        client.conn.Close()
    }()

    // 由于WebSocket一旦連接,便可以保持長(zhǎng)時(shí)間通訊,則該接口函數(shù)可以一直運(yùn)行下去,直到連接斷開(kāi)
    for {
        // 讀取消息。如果連接斷開(kāi),則會(huì)返回錯(cuò)誤
        _, msgStr, err := client.conn.ReadMessage()

        // 如果返回錯(cuò)誤,就退出循環(huán)
        if err != nil {
            break
        }

        beego.Info("WS-----------receive: "+string(msgStr))

        // 如果沒(méi)有錯(cuò)誤,則把用戶發(fā)送的信息放入message通道中
        var msg Message
        msg.Name = client.name
        msg.EventType = 0
        msg.Message = string(msgStr)
        message <- msg
    }
}
3)后端廣播功能

將發(fā)消息、用戶加入、用戶退出三種情況都廣播給所有用戶。后兩種情況經(jīng)過(guò)處理,轉(zhuǎn)換為第一種情況。真正發(fā)送信息給客戶端的,只有第一種情況。

func broadcaster() {
    for {
        // 哪個(gè)case可以執(zhí)行,則轉(zhuǎn)入到該case。若都不可執(zhí)行,則堵塞。
        select {
            // 消息通道中有消息則執(zhí)行,否則堵塞
            case msg := <-message:
                str := fmt.Sprintf("broadcaster-----------%s send message: %s\n", msg.Name, msg.Message)
                beego.Info(str)
                // 將某個(gè)用戶發(fā)出的消息發(fā)送給所有用戶
                for client := range clients {
                    // 將數(shù)據(jù)編碼成json形式,data是[]byte類型
                    // json.Marshal()只會(huì)編碼結(jié)構(gòu)體中公開(kāi)的屬性(即大寫(xiě)字母開(kāi)頭的屬性)
                    data, err := json.Marshal(msg)
                    if err != nil {
                        beego.Error("Fail to marshal message:", err)
                        return
                    }
                    // fmt.Println("=======the json message is", string(data))  // 轉(zhuǎn)換成字符串類型便于查看
                    if client.conn.WriteMessage(websocket.TextMessage, data) != nil {
                        beego.Error("Fail to write message")
                    }
                }

            // 有用戶加入
            case client := <-join:
                str := fmt.Sprintf("broadcaster-----------%s join in the chat room\n", client.name)
                beego.Info(str)

                clients[client] = true  // 將用戶加入映射

                // 將用戶加入消息放入消息通道
                var msg Message
                msg.Name = client.name
                msg.EventType = 1
                msg.Message = fmt.Sprintf("%s join in, there are %d preson in room", client.name, len(clients))

                // 此處要設(shè)置有緩沖的通道。因?yàn)檫@是goroutine自己從通道中發(fā)送并接受數(shù)據(jù)。
                // 若是無(wú)緩沖的通道,該goroutine發(fā)送數(shù)據(jù)到通道后就被鎖定,需要數(shù)據(jù)被接受后才能解鎖,而恰恰接受數(shù)據(jù)的又只能是它自己
                message <- msg

            // 有用戶退出
            case client := <-leave:
                str := fmt.Sprintf("broadcaster-----------%s leave the chat room\n", client.name)
                beego.Info(str)

                // 如果該用戶已經(jīng)被刪除
                if !clients[client] {
                    beego.Info("the client had leaved, client's name:"+client.name)
                    break
                }

                delete(clients, client) // 將用戶從映射中刪除

                // 將用戶退出消息放入消息通道
                var msg Message
                msg.Name = client.name
                msg.EventType = 2
                msg.Message = fmt.Sprintf("%s leave, there are %d preson in room", client.name, len(clients))
                message <- msg
        }
    }
}

在后端服務(wù)啟動(dòng)時(shí),便開(kāi)啟廣播功能:

func init() {
    go broadcaster()
}

此處需要利用goroutine并發(fā)模式,使得該函數(shù)能獨(dú)立在額外的一個(gè)線程上運(yùn)作。

五、參考文檔

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容