Golang实战(一):x-spreadsheet+Gorilla+Nginx搭建多人在线共享Excel

in PHPGo with 0 comment

前言

由于工作需要,我们时常需要使用Excel同步一些信息,目前使用最广的就是腾讯在线文档了,主要完成了:

极大地提高了信息共享的效率,但是公司领导担心公司机密数据可能会被腾讯窃取(默默嘀咕,腾讯真的在乎吗),于是想让我们搭建一个在线Excel编辑共享的工具。
x-spreadsheet刚好满足前端Excel的样式,由于这种需求要求数据同步实时性高,于是想到使用Gorilla做WebSocket层,对每一个人编辑Excel做监听和广播。

Demo演示地址

设计方案

其实设计起来没有想象的复杂,由于都是使用的开源组件,只需要设置基本逻辑即可:

  1. 用户打开Excel首页,前端js与服务端websocket服务建立连接
  2. 用户每次操作Excel,触发send事件,向websocket连接丢数据
  3. 服务端接收到Excel数据变更事件后,通知所有已连接的Client变更数据
  4. 前端js触发onmessage事件,更改前端数据

Golang实现

首先安装开源组件Gorilla。

go get github.com/gorilla/websocket

启动一个Web服务器,这里简单讲下为什么需要启动一个协程,可以理解为hub.run()是掌管了整个Websocket连接池的代码逻辑,这个连接池在广播的时候至关重要。

// 文件位置:main.go
package main

import (
    "fmt"
    "net/http"

    "tool.gavinys.com/controller/excel"
    "tool.gavinys.com/model/mexcel"
)

func main() {
    http.HandleFunc("/excel", excel.Home)

    hub := mexcel.NewHub()
    go hub.Run()
    http.HandleFunc("/excel/ws", func(w http.ResponseWriter, r *http.Request) {
        excel.WebsocketServer(w, r, hub)
    })

    err := http.ListenAndServe("127.0.0.1:8002", nil)
    if err != nil {
        fmt.Println(err)
    }
}

这里的hub.go主要通过select监听Hub结构体的Broadcast是否有信息写入,如果有信息写入,就代表需要向所有在线的Client广播,于是触发Client.WriteJSON(m)方法来达到广播的目的。

// 文件位置 model/mexcel/hub.go
package mexcel

import (
    "github.com/gorilla/websocket"
)

type Hub struct {
    Clients []*websocket.Conn

    Broadcast chan WsMsg
}

func NewHub() *Hub {
    return &Hub{
        Broadcast: make(chan WsMsg),
        Clients:   make([]*websocket.Conn, 0),
    }
}

func (h *Hub) Run() {
    for {
        select {

        case message := <-h.Broadcast:

            for _, client := range h.Clients {
                client.WriteJSON(message)
            }
        }
    }
}

下面看一下我们如何实现一个Websocket服务,当有一个新的websocket连接被打开时,会进入到WebsocketServer()方法中,我们将这个Client加载到hub的连接池中保存起来,并将Client通过协程listenEvent()方式实现长连接。

//文件位置: controller/excel/excel.go
package excel

import (
    "encoding/json"
    "fmt"
    "html/template"
    "net/http"

    "github.com/gorilla/websocket"
    "tool.gavinys.com/model/mexcel"
)

func Home(w http.ResponseWriter, r *http.Request) {
    var display map[string]string
    template, err := template.ParseFiles(
        "template/excel/index.html",
    )
    if err != nil {
        fmt.Println("template load fails:", err)
    }
    template.Execute(w, display)
}

func WebsocketServer(w http.ResponseWriter, r *http.Request, hub *mexcel.Hub) {
    conn, err := websocket.Upgrade(w, r, w.Header(), 1024, 1024)
    if err != nil {
        http.Error(w, "Could not open websocket connetction", http.StatusBadRequest)
    }

    // 生成redis连接串
    // rds, err := redis.Dial("tcp", "127.0.0.1:6379")
    // rds.Do("AUTH", "gvredis")
    // // 生成一个token 与conn 映射
    token := mexcel.MD5(mexcel.GetRandomSalt())
    // rds.Do("SADD", "excel_token", token)

    hub.Clients = append(hub.Clients, conn)
    conn.WriteJSON(mexcel.WsMsg{Event: "open", Data: token, Token: token})

    go listenEvent(conn, hub)
}

func listenEvent(conn *websocket.Conn, hub *mexcel.Hub) {
    defer func() {
        conn.Close()
    }()
    for {
        m := mexcel.WsMsg{}

        msgType, msg, err := conn.ReadMessage()
        if msgType == -1 {
            // close websocket
            conn.Close()
            return
        }
        if err != nil {
            // err is not nil
            fmt.Println("error read", err)
        }

        // msg json fomat
        err = json.Unmarshal(msg, &m)
        if err != nil {
            fmt.Println("can not format json", err)
        }
        fmt.Printf("Got message: %#v\n", m)

        hub.Broadcast <- m

    }
}

我将每一次的Websocket通信定义成了Event,Data,Token三种数据格式组成的数据结构,这样有利于后续扩展

//文件位置: model/mexcel/mexcel.go
package mexcel

import (
    "crypto/md5"
    "encoding/hex"
    "math/rand"
    "time"
)

type WsMsg struct {
    Event string
    Data  string
    Token string
}

// 生成32位MD5
func MD5(text string) string {
    ctx := md5.New()
    ctx.Write([]byte(text))
    return hex.EncodeToString(ctx.Sum(nil))
}

// return len=8  salt
func GetRandomSalt() string {
    return GetRandomString(16)
}

//生成随机字符串
func GetRandomString(length int) string {
    str := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    bytes := []byte(str)
    result := []byte{}
    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    for i := 0; i < length; i++ {
        result = append(result, bytes[r.Intn(len(bytes))])
    }
    return string(result)
}

前端实现

前端实现起来就很方便了,主要是打开页面后的自动连接websocket,连接后对message事件的监听,以及修改数据后的监听。

<!DOCTYPE html>
<html>
<head>
    <title>GiY. 共享Excel</title>
    <link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.0.13/dist/xspreadsheet.css">
</head>
<body id="xspreadsheet">

<script src="https://cdn.bootcss.com/jquery/2.2.4/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
<script src="https://unpkg.com/x-data-spreadsheet@1.0.13/dist/xspreadsheet.js"></script>
<script>
   var conn;
   var excel = x.spreadsheet('#xspreadsheet');

   function initConn()
   {
           var conn;
      conn = new WebSocket("wss://" + document.location.host + "/excel/ws");

      conn.onopen = function(){
        console.log("socket opening")
      }

      conn.onclose = function(){
        console.log("socket closing")
      }

      conn.onmessage = function(e){
        console.log("socket recieve message ", e.data)
      }
         return conn
   }

   function initToken(token)
   {
      return $.cookie("excel_token", token);
   }

   function sendMsg(json_data)
   {
      token = $.cookie("excel_token");
      json_data.Token = token

      conn.send(JSON.stringify(json_data))
   }

   
   conn = initConn();

   conn.addEventListener('close', function(){
    console.log("socket close")
    conn.send(JSON.stringify({Event: "close", Data: "Close"}));
   });

   conn.addEventListener('message', function(e){
      var json_data = JSON.parse(e.data)
      if(json_data.Event == 'open') {
        console.log("init token:", e, json_data)
        return initToken(json_data.Token)
      }

      if(json_data.Event == 'change_excel') {
        var data = JSON.parse(json_data.Data);
        console.log("accept change excel", data)
        excel.loadData(data)
      }

      console.log(e)
   });

   excel.change(data => {
    var json_data = {Event: "change_excel", Data: JSON.stringify(data)}
    sendMsg(json_data)
   });
   
</script>
</body>
</html>

后记

Websocket跟Web最大的不同在于:我们需要实时维护着连接池,且要有一套心跳检测机制,避免过多无效连接占用资源,且在隐秘性较高的情况下,每次操作时需要往数据中增加鉴权token,程序进行鉴权判断。

Responses