使用golang和vue构建一个简单的WebSockets聊天应用程序

第一章

一、创建WebSocket服务端程序

1、创建目录chat并初始化

cd chat
go mod init chat

2、增加文件client.go,该文件处理WebSocket客户端程序。其中serveWebsocket()函数处理客户端连接。

package main

import (
    "bytes"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

const (
    // Time allowed to write a message to the peer.
    writeWait = 10 * time.Second

    // Time allowed to read the next pong message from the peer.
    pongWait = 60 * time.Second

    // Send pings to peer with this period. Must be less than pongWait.
    pingPeriod = (pongWait * 9) / 10

    // Maximum message size allowed from peer.
    maxMessageSize = 512
)

var (
    newline = []byte{'\n'}
    space   = []byte{' '}
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

// Client is a middleman between the websocket connection and the hub.
type Client struct {
    hub *Hub

    // The websocket connection.
    conn *websocket.Conn

    // Buffered channel of outbound messages.
    send chan []byte
}

// readPump pumps messages from the websocket connection to the hub.
//
// The application runs readPump in a per-connection goroutine. The application
// ensures that there is at most one reader on a connection by executing all
// reads from this goroutine.
func (c *Client) readPump() {
    defer func() {
        c.hub.unregister <- c
        c.conn.Close()
    }()
    c.conn.SetReadLimit(maxMessageSize)
    c.conn.SetReadDeadline(time.Now().Add(pongWait))
    c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
    for {
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
                log.Printf("error: %v", err)
            }
            break
        }
        message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
        c.hub.broadcast <- message
    }
}

// writePump pumps messages from the hub to the websocket connection.
//
// A goroutine running writePump is started for each connection. The
// application ensures that there is at most one writer to a connection by
// executing all writes from this goroutine.
func (c *Client) writePump() {
    ticker := time.NewTicker(pingPeriod)
    defer func() {
        ticker.Stop()
        c.conn.Close()
    }()
    for {
        select {
        case message, ok := <-c.send:
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if !ok {
                // The hub closed the channel.
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }

            w, err := c.conn.NextWriter(websocket.TextMessage)
            if err != nil {
                return
            }
            w.Write(message)

            // Add queued chat messages to the current websocket message.
            n := len(c.send)
            for i := 0; i < n; i++ {
                w.Write(newline)
                w.Write(<-c.send)
            }

            if err := w.Close(); err != nil {
                return
            }
        case <-ticker.C:
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

// serveWebsocket handles websocket requests from the peer.
func serveWebsocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }
    client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
    client.hub.register <- client

    // Allow collection of memory referenced by the caller by doing all work in
    // new goroutines.
    go client.writePump()
    go client.readPump()
}

3、新建main.go文件,该文件为主启动程序,负责建立HTTP服务以响应客户端到serveWebsocket请求响应。

package main

import (
    "flag"
    "log"
    "net/http"
)

var addr = flag.String("addr", ":3000", "http service address")

func serveHome(w http.ResponseWriter, r *http.Request) {
    log.Println(r.URL)
    if r.URL.Path != "/" {
        http.Error(w, "Not found", http.StatusNotFound)
        return
    }
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    http.ServeFile(w, r, "public/index.html")
}

func main() {
    flag.Parse()
    hub := NewWebsocketServer()
    go hub.Run()
    http.HandleFunc("/", serveHome)
    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        serveWebsocket(hub, w, r)
    })
    http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("public/assets"))))

    err := http.ListenAndServe(*addr, nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}
二、创建WebSocket页面端程序

1、在chat目录下创建public目录,在该目录下创建index.html文件并引入三方包

<!-- public/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Chat</title>
    <!-- Load required Bootstrap and BootstrapVue CSS -->
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.css" />

    <!-- Load polyfills to support older browsers -->
    <script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CIntersectionObserver" crossorigin="anonymous"></script>

    <!-- Load Vue followed by BootstrapVue -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.js"></script>

    <!-- Load the following for BootstrapVueIcons support -->
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue-icons.min.js"></script> 
  </head>

  <body>
    <div id="app">
      <div class="container-fluid h-100">
        <div class="row justify-content-center h-100">
          <div class="col-md-8 col-xl-6 chat">
            <div class="card">
              <div class="card-header msg_head">
                <div class="d-flex bd-highlight justify-content-center">
                  Chat
                </div>
              </div>
              <div class="card-body msg_card_body">
                <div
                     v-for="(message, key) in messages"
                     :key="key"
                     class="d-flex justify-content-start mb-4"
                     >
                  <div class="msg_cotainer">
                    {{message.message}}
                    <span class="msg_time"></span>
                  </div>
                </div>
              </div>
              <div class="card-footer">
                <div class="input-group">
                  <textarea
                            v-model="newMessage"
                            name=""
                            class="form-control type_msg"
                            placeholder="Type your message..."
                            @keyup.enter.exact="sendMessage"
                            ></textarea>
                  <div class="input-group-append">
                    <span class="input-group-text send_btn" @click="sendMessage"
                          >></span>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
     </div>
    </div>
  </body>
  <script src="assets/app.js"></script>
</html>

2、在public下创建assets目录并在该目录下创九点过 app.js

// public/assets/app.js
var app = new Vue({
    el: '#app',
    data: {
      ws: null,
      serverUrl: "ws://localhost:3000/ws",
      messages: [],
      newMessage: ""
    },
    mounted: function() {
      this.connectToWebsocket()
    },
    methods: {
      connectToWebsocket() {
        this.ws = new WebSocket( this.serverUrl );
        this.ws.addEventListener('open', (event) => { this.onWebsocketOpen(event) });
        this.ws.addEventListener('message', (event) => { this.handleNewMessage(event) });
      },
      onWebsocketOpen() {
        console.log("connected to WS!");
      },
      handleNewMessage(event) {
        let data = event.data;
        data = data.split(/\r?\n/);
        for (let i = 0; i < data.length; i++) {
          let msg = JSON.parse(data[i]);
          this.messages.push(msg);
        }   
      },
      sendMessage() {
        if(this.newMessage !== "") {
          this.ws.send(JSON.stringify({message: this.newMessage}));
          this.newMessage = "";
        }
      }
    }
  })
三、发送和接收消息

创建新文件chatServer.go,该文件包含一个Hub结构体类型中的Clients注册客户,使用两个channel管道实现注册和解除注册请求。

package main

type Hub struct {
    clients    map[*Client]bool
    register   chan *Client
    unregister chan *Client
    broadcast  chan []byte
}

// NewWebsocketServer creates a new Hub type
func NewWebsocketServer() *Hub {
    return &Hub{
        clients:    make(map[*Client]bool),
        register:   make(chan *Client),
        unregister: make(chan *Client),
        broadcast:  make(chan []byte),
    }
}

// Run our websocket server, accepting various requests
func (hub *Hub) Run() {
    for {
        select {
        case client := <-hub.register:
            hub.registerClient(client)
        case client := <-hub.unregister:
            hub.unregisterClient(client)
        case message := <-hub.broadcast:
            hub.broadcastToClients(message)
        }
    }
}

func (hub *Hub) registerClient(client *Client) {
    hub.clients[client] = true
}

func (hub *Hub) unregisterClient(client *Client) {
    if _, ok := hub.clients[client]; ok {
        delete(hub.clients, client)
    }
}

func (hub *Hub) broadcastToClients(message []byte) {
    for client := range hub.clients {
        client.send <- message
    }
}

Run()函数持续侦听管道,该函数是处理请求专用函数,现在只提供增加和删除客户连接map功能。

运行程序

go run .

目录结构如下:


image.png

运行结果如下:


image.png

第二章

引入CommunicationChannel通信频道

创建communication_channel.go文件,建立CommunicationChannel结构体,每一个频道中能够注册客户端,解除注册,广播。

在Hub结构体中增加CommunicationChannel
// chatServer.go
package main

type Hub struct {
    ...
    communicationChannels map[*CommunicationChannel]bool
}

func NewWebsocketServer() *Hub {
    return &Hub{
        ...
        communicationChannels: make(map[*CommunicationChannel]bool),
    }
}

通过maps和channels,可以获取到客户端在线状况。
chatServer.go中增加方法查找存在频道和创建新频道:

// chatServer.go
func (hub *Hub) findCommunicationChannelByName(name string) *CommunicationChannel {
    var foundCommunicationChannel *CommunicationChannel
    for comchan := range hub.communicationChannels {
        if comchan.GetName() == name {
            foundCommunicationChannel = comchan
            break
        }
    }

    return foundCommunicationChannel
}

func (hub *Hub) createCommunicationChannel(name string) *CommunicationChannel {
    comchan := NewCommunicationChannel(name)
    go comchan.RunCommunicationChannel()
    hub.communicationChannels[comchan] = true

    return comchan
}
增加消息处理

处理不同类型的类型,例如加入频道或发送消息,引入Message类型,包括:
Action: 活动状态(发送消息,加入或离开频道)
Message: 消息内容。
Target: 消息目标。
Sender:消息发送人
创建message.go

// message.go
package main

import (
    "encoding/json"
    "log"
)

const SendMessageAction = "send-message"
const JoinCommunicationChannelAction = "join-communication-channel"
const LeaveCommunicationChannelAction = "leave-communication-channel"

type Message struct {
    Action  string  `json:"action"`
    Message string  `json:"message"`
    Target  string  `json:"target"`
    Sender  *Client `json:"sender"`
}

func (message *Message) encode() []byte {
    json, err := json.Marshal(message)
    if err != nil {
        log.Println(err)
    }
    return json
}
与频道进行交互

开始进行客户端在频道中的加入、离开、广播。首先增加一个频道Map对加入和离开就行追踪。修改client.go中的disconnect()方法对频道客户进行解除注册。

type Client struct {
    ...
    ID                    uuid.UUID `json:"id"`
    Name                  string    `json:"name"`
    communicationChannels map[*CommunicationChannel]bool
}
func newClient(conn *websocket.Conn, hub *Hub, name string) *Client {
    return &Client{
        ID:                    uuid.New(),
        Name:                  name,
        conn:                  conn,
        hub:                   hub,
        send:                  make(chan []byte, 256),
        communicationChannels: make(map[*CommunicationChannel]bool),
    }
}
func (client *Client) disconnect() {
    client.hub.unregister <- client
    for communicationChannel := range client.communicationChannels {
        communicationChannel.unregister <- client
    }
    close(client.send)
    client.conn.Close()
}
信息处理

现在客户端已能够加入频道。通过不同的action处理不同的消息类型。
首先,修改client增加一个新方法解析JSON消息传递给指定人:

func (client *Client) handleNewMessage(jsonMessage []byte) {

    var message Message
    if err := json.Unmarshal(jsonMessage, &message); err != nil {
        log.Printf("Error on unmarshal JSON message %s", err)
        return
    }

    message.Sender = client

    switch message.Action {
    case SendMessageAction:
        // The send-message action, this will send messages to a specific channel now.
        // Which channel wil depend on the message Target

        communicationChannelName := message.Target

        if communicationChannel := client.hub.findCommunicationChannelByName(communicationChannelName); communicationChannel != nil {
            communicationChannel.broadcast <- &message
        }

    case JoinCommunicationChannelAction:
        client.handleJoinCommunicationChannelMessage(message)

    case LeaveCommunicationChannelAction:
        client.handleLeaveCommunicationChannelMessage(message)
  }
}

上面代码中的方法,我们直送信息到频道,使用Message取代[]byte来发送信息,需调整communication_channel.go

// RunCommunicationChannel runs our comchan, accepting various requests
func (comchan *CommunicationChannel) RunCommunicationChannel() {
    for {
        select {
        ...
        case message := <-comchan.broadcast:
            comchan.broadcastToClientsInCommunicationChannel(message.encode())
        }
    }
}

当频道不存在时使用程序重新创建一个:

// client.go
func (client *Client) handleJoinCommunicationChannelMessage(message Message) {
    communicationChannelName := message.Message

    communicationChannel := client.hub.findCommunicationChannelByName(communicationChannelName)
    if communicationChannel == nil {
        communicationChannel = client.hub.createCommunicationChannel(communicationChannelName)
    }

    client.communicationChannels[communicationChannel] = true

    communicationChannel.register <- client
}

修改readPump()函数,在收到新消息时使用handleNewMessage方法进行处理:

func (client *Client) readPump() {
    defer func() {
        client.disconnect()
    }()

    client.conn.SetReadLimit(maxMessageSize)
    client.conn.SetReadDeadline(time.Now().Add(pongWait))
    client.conn.SetPongHandler(func(string) error { client.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })

    // Start endless read loop, waiting for messages from client
    for {
        _, jsonMessage, err := client.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
                log.Printf("unexpected close error: %v", err)
            }
            break
        }
        client.handleNewMessage(jsonMessage)
    }

}

命名登录客户

// client.go
type Client struct {
    ... 
    Name     string `json:"name"`   
}
func newClient(conn *websocket.Conn, wsServer *WsServer, name string) *Client {
    return &Client{
        Name:     name,
        ...
    }
}

func (client *Client) GetName() string {
    return client.Name
}

修改serveWebsocket函数,在URL中增加用户名:

// client.go
func serveWebsocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
    name, ok := r.URL.Query()["name"]

    if !ok || len(name[0]) < 1 {
        log.Println("Url Param 'name' is missing")
        return
    }
    ...
    client := newClient(conn, hub, name[0])
    ...
}
欢迎消息

当用户加入频道时其他用户能够看到
首先,在communication_channel.go增加一个新方法

// communication_channel.go
const welcomeMessage = "%s joined the channel"

func (comchan *CommunicationChannel) notifyClientJoined(client *Client) {
    message := &Message{
        Action:  SendMessageAction,
        Target:  comchan.name,
        Message: fmt.Sprintf(welcomeMessage, client.GetName()),
    }

    comchan.broadcastToClientsInCommunicationChannel(message.encode())
}

当用户注册时调用下述方法:

func (comchan *CommunicationChannel) registerClientInCommunicationChannel(client *Client) {
    comchan.notifyClientJoined(client)
    comchan.clients[client] = true
}

服务端代码完成,开始修改前端代码

频道接口

// public/assets/app.js
var app = new Vue({
    el: '#app',
    data: {
      ws: null,
      serverUrl: "ws://localhost:3000/ws",
      channelInput: null,
      channels: [],
      user: {
        name: ""
      }
    },
...
})

channelInput为新创建频道。
channels对所有加入频道列表。
user为用户数据。
方法修改如下:

  methods: {
    connect() {
      this.connectToWebsocket();
    },
    connectToWebsocket() {
      // Pass the name paramter when connecting.
      this.ws = new WebSocket(this.serverUrl + "?name=" + this.user.name);
      this.ws.addEventListener('open', (event) => { this.onWebsocketOpen(event) });
      this.ws.addEventListener('message', (event) => { this.handleNewMessage(event) });
    },
    onWebsocketOpen() {
      console.log("connected to WS!");
    },

    handleNewMessage(event) {
      let data = event.data;
      data = data.split(/\r?\n/);

      for (let i = 0; i < data.length; i++) {
        let msg = JSON.parse(data[i]);
        // display the message in the correct channel.
        const channel = this.findChannel(msg.target);
        if (typeof channel !== "undefined") {
          channel.messages.push(msg);
        }
      }
    },
    sendMessage(channel) {
      // send message to correct channel.
      if (channel.newMessage !== "") {
        this.ws.send(JSON.stringify({
          action: 'send-message',
          message: channel.newMessage,
          target: channel.name
        }));
        channel.newMessage = "";
      }
    },
    findChannel(channelName) {
      for (let i = 0; i < this.channels.length; i++) {
        if (this.channels[i].name === channelName) {
          return this.channels[i];
        }
      }
    },
    joinChannel() {
      this.ws.send(JSON.stringify({ action: 'join-channel', message: this.channelInput }));
      this.messages = [];
      this.channels.push({ "name": this.channelInput, "messages": [] });
      this.channelInput = "";
    },
    leaveChannel(channel) {
      this.ws.send(JSON.stringify({ action: 'leave-channel', message: channel.name }));

      for (let i = 0; i < this.channels.length; i++) {
        if (this.channels[i].name === channel.name) {
          this.channels.splice(i, 1);
          break;
        }
      }
    }
  }

引进了三个方法分别做查找渠道、加入渠道、离开渠道。
HTML修改如下:


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

推荐阅读更多精彩内容