用go实现聊天室(WebSocket方式)

前言

刚入门go语言和beego框架,通过一个简单聊天室的实现,来趁热练习。

详细代码见github

一、WebSocket协议

在实现之前,我们需要解决一个底层问题。

总所周知,HTTP协议是单向传输协议,只能由客户端主动向服务端发送信息,反之则不行。而在聊天室中,一个用户发送一条消息,服务器则需要将该条消息广播到聊天室中的所有用户,这想通过HTTP协议实现是不可能的。

除非,让每个用户每隔一段时间便请求一次服务器获取新消息。这种方式称为长轮询。但其缺点十分明显,非常消耗资源。

为了解决这个问题,WebSocket协议应运而生。

那什么是WebSocket协议呢?百度百科

WebSocket协议HTTP协议同属于应用层协议。不同的是,WebSocket是双向传输协议,弥补了这个缺点,在该协议下,服务端也能主动向客户端发送信息。同时,一旦连接,客户端会与服务端保持长时间的通讯。

WebSocket协议的标识符是ws,如:ws://localhost:8080/chatRoom/WS

二、go语言并发特性

go语言的一大特性,便是内置的并发功能(goroutine)。以及,在并发个体之间传递数据的“通道”(chan)。

具体细节不在此赘述。

三、beego框架

一个开源的轻量级web server框架,实现了典型的MVC模型,和先进的api接口模型(前后端分离模型)。

聊天室的实现,便基于其MVC模型。

四、实现步骤

1.需求分析

1)数据分析

聊天室中主要物体分为两种:用户和消息。

用户的主要属性为:姓名、客户端与服务端之间的WebSocket连接指针。

消息则分为三种:用户发消息、有用户加入、有用户离开。若将加入和离开也视为用户发出的消息内容,那消息的主要属性就有:消息类型、消息内容、发消息者。

2)功能分析

前端:

  • 实现与服务端的WebSocket连接。

后端:

  • 提供WebSocket连接接口。与实现HTTP连接接口一样,利用beego框架即可。

  • 当新用户建立连接时、用户断开连接时、收到连接中用户发来的新信息时,能将消息广播给所有连接用户。

客户端(即前端js)若要与服务端建立WebSocket连接,需要调用WebSocket连接API,详细内容见大神博客

服务端(即后端go)实现

2.数据结构

用户:

type Client struct {
    conn *websocket.Conn    // 用户websocket连接
    name string             // 用户名称
}

消息:

// 1.设置为公开属性(即首字母大写),是因为属性值私有时,外包的函数无法使用或访问该属性值(如:json.Marshal())
// 2.`json:"name"` 是为了在对该结构类型进行json编码时,自定义该属性的名称
type Message struct {
    EventType byte  `json:"type"`       // 0表示用户发布消息;1表示用户进入;2表示用户退出
    Name string     `json:"name"`       // 用户名称
    Message string  `json:"message"`    // 消息内容
}

用户组:

clients = make(map [Client] bool)      // 用户组映射

此处使用映射而不是数组,是为了方便判断某个用户是否已经加入或者已经退出了。

用于goroutine通道:

// 此处要设置有缓冲的通道。因为这是goroutine自己从通道中发送并接受数据。
// 若是无缓冲的通道,该goroutine发送数据到通道后就被锁定,需要数据被接受后才能解锁,而恰恰接受数据的又只能是它自己
join = make(chan Client, 10)        // 用户加入通道
leave = make(chan Client, 10)       // 用户退出通道
message = make(chan Message, 10)    // 消息通道

3.功能实现

1)前端WebSocket连接实现:
//====================webSocket连接======================
// 创建一个webSocket连接
var socket = new WebSocket('ws://'+window.location.host+'/chatRoom/WS?name=' + $('#name').text());

// 当webSocket连接成功的回调函数
socket.onopen = function () {
    console.log("webSocket open");
    connected = true;
};

// 断开webSocket连接的回调函数
socket.onclose = function () {
    console.log("webSocket close");
    connected = false;
};
//=======================接收消息并显示===========================
// 接受webSocket连接中,来自服务端的消息
socket.onmessage = function(event) {
    // 将服务端发送来的消息进行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表示有人发消息
    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;   // 让屏幕滚动
}
//========================发送消息==========================
// 通过webSocket发送消息到服务端
function sendMessage () {
    var inputMessage = $inputArea.val();  // 获取输入框的值

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

继承beego框架的Controller类型:

type ServerController struct {
    beego.Controller
}

编写ServerController类型中用于WebSocket连接的方法:

// 用于与用户间的websocket连接(chatRoom.html发送来的websocket请求)
func (c *ServerController) WS() {
    name := c.GetString("name")
    if len(name) == 0 {
        beego.Error("name is NULL")
        c.Redirect("/", 302)
        return
    }

    // 检验http头中upgrader属性,若为websocket,则将http协议升级为websocket协议
    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

    // 如果用户列表中没有该用户
    if !clients[client] {
        join <- client
        beego.Info("user:", client.name, "websocket connect success!")
    }

    // 当函数返回时,将该用户加入退出通道,并断开用户连接
    defer func() {
        leave <- client
        client.conn.Close()
    }()

    // 由于WebSocket一旦连接,便可以保持长时间通讯,则该接口函数可以一直运行下去,直到连接断开
    for {
        // 读取消息。如果连接断开,则会返回错误
        _, msgStr, err := client.conn.ReadMessage()

        // 如果返回错误,就退出循环
        if err != nil {
            break
        }

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

        // 如果没有错误,则把用户发送的信息放入message通道中
        var msg Message
        msg.Name = client.name
        msg.EventType = 0
        msg.Message = string(msgStr)
        message <- msg
    }
}
3)后端广播功能

将发消息、用户加入、用户退出三种情况都广播给所有用户。后两种情况经过处理,转换为第一种情况。真正发送信息给客户端的,只有第一种情况。

func broadcaster() {
    for {
        // 哪个case可以执行,则转入到该case。若都不可执行,则堵塞。
        select {
            // 消息通道中有消息则执行,否则堵塞
            case msg := <-message:
                str := fmt.Sprintf("broadcaster-----------%s send message: %s\n", msg.Name, msg.Message)
                beego.Info(str)
                // 将某个用户发出的消息发送给所有用户
                for client := range clients {
                    // 将数据编码成json形式,data是[]byte类型
                    // json.Marshal()只会编码结构体中公开的属性(即大写字母开头的属性)
                    data, err := json.Marshal(msg)
                    if err != nil {
                        beego.Error("Fail to marshal message:", err)
                        return
                    }
                    // fmt.Println("=======the json message is", string(data))  // 转换成字符串类型便于查看
                    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))

                // 此处要设置有缓冲的通道。因为这是goroutine自己从通道中发送并接受数据。
                // 若是无缓冲的通道,该goroutine发送数据到通道后就被锁定,需要数据被接受后才能解锁,而恰恰接受数据的又只能是它自己
                message <- msg

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

                // 如果该用户已经被删除
                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
        }
    }
}

在后端服务启动时,便开启广播功能:

func init() {
    go broadcaster()
}

此处需要利用goroutine并发模式,使得该函数能独立在额外的一个线程上运作。

五、参考文档

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

推荐阅读更多精彩内容