使用golang構建實時白板

源碼

https://github.com/chapin666/simple-drawing-backendhtml

首先,咱們須要建立一個用於與用戶交互消息的橋樑(Hub)。這個思路相似於Gorilla's 的 chat 例子。前端

Client struct

建立一個 client.go 文件git

package main

import (
  "github.com/gorilla/websocket"
  uuid "github.com/satori/go.uuid"
)

type Client struct {
  id       string
  hub      *Hub
  color    string
  socket   *websocket.Conn
  outbound chan []byte
}

爲client編寫一個構造方法,這裏使用了UUID和隨機顏色庫github

func newClient(hub *Hub, socket *websocket.Conn) *Client {
  return &Client{
    id:       uuid.NewV4().String(),
    color:    generateColor(),
    hub:      hub,
    socket:   socket,
    outbound: make(chan []byte),
  }
}

新建 utilities.go 文件來編程 generateColor 方法web

package main

import (
  "math/rand"
  "time"
  colorful "github.com/lucasb-eyer/go-colorful"
)

func init() {
  rand.Seed(time.Now().UnixNano())
}

func generateColor() string {
  c := colorful.Hsv(rand.Float64()*360.0, 0.8, 0.8)
  return c.Hex()
}

寫一個用於到Hub讀取消息 read 的方法,若是有錯誤發生,將會通知unregistered通道編程

func (client *Client) read() {
  defer func() {
    client.hub.unregister <- client
  }()
  for {
    _, data, err := client.socket.ReadMessage()
    if err != nil {
      break
    }
    client.hub.onMessage(data, client)
  }
}

write方法從outbound通道獲取消息併發送給用戶。這樣,服務器將可以發送消息到客戶端。json

func (client *Client) write() {
  for {
    select {
    case data, ok := <-client.outbound:
      if !ok {
        client.socket.WriteMessage(websocket.CloseMessage, []byte{})
        return
      }
      client.socket.WriteMessage(websocket.TextMessage, data)
    }
  }
}

在 client struct中添加 啓動 和 結束 進程的方法,而且在啓動方法中使用 goroutine 運行 read 和 write 方法canvas

func (client Client) run() {
  go client.read()
  go client.write()
}

func (client Client) close() {
  client.socket.Close()
  close(client.outbound)
}

Hub struct

新建 hub.go 文件並聲明 Hub structapi

package main

import (
  "encoding/json"
  "log"
  "net/http"

  "github.com/gorilla/websocket"
  "github.com/tidwall/gjson"
)

type Hub struct {
  clients    []*Client
  register   chan *Client
  unregister chan *Client
}

添加構造方法數組

func newHub() *Hub {
  return &Hub{
    clients:    make([]*Client, 0),
    register:   make(chan *Client),
    unregister: make(chan *Client),
  }
}

添加 run 方法

func (hub *Hub) run() {
  for {
    select {
    case client := <-hub.register:
      hub.onConnect(client)
    case client := <-hub.unregister:
      hub.onDisconnect(client)
    }
  }
}

編寫一個將http升級到WebSockets請求的方法。 若是升級成功,客戶端將被添加到 clients 中。

var upgrader = websocket.Upgrader{
  // Allow all origins
  CheckOrigin: func(r *http.Request) bool { return true },
}

func (hub *Hub) handleWebSocket(w http.ResponseWriter, r *http.Request) {
  socket, err := upgrader.Upgrade(w, r, nil)
  if err != nil {
    log.Println(err)
    http.Error(w, "could not upgrade", http.StatusInternalServerError)
    return
  }
  client := newClient(hub, socket)
  hub.clients = append(hub.clients, client)
  hub.register <- client
  client.run()
}

編寫一個發送消息到客戶端的方法

func (hub *Hub) send(message interface{}, client *Client) {
  data, _ := json.Marshal(message)
  client.outbound <- data
}

編寫一個廣播(broadcast)消息到全部客戶端的方法(排除本身)

func (hub *Hub) broadcast(message interface{}, ignore *Client) {
  data, _ := json.Marshal(message)
  for _, c := range hub.clients {
    if c != ignore {
      c.outbound <- data
    }
  }
}

Messages

Messages 將使用JSON做爲交互格式。 每條消息將攜帶一個「kind」字段,以區分消息

新建messages.go文件並建立 message 包

聲明全部消息「kinds」的枚舉

package message

const (
  // KindConnected is sent when user connects
  KindConnected = iota + 1
  // KindUserJoined is sent when someone else joins
  KindUserJoined
  // KindUserLeft is sent when someone leaves
  KindUserLeft
  // KindStroke message specifies a drawn stroke by a user
  KindStroke
  // KindClear message is sent when a user clears the screen
  KindClear
)

聲明一些簡單的數據結構

type Point struct {
  X int `json:"x"`
  Y int `json:"y"`
}

type User struct {
  ID    string `json:"id"`
  Color string `json:"color"`
}

聲明全部的消息類型結構體並編寫 構造函數。kind 字段在構造函數中設置

type Connected struct {
  Kind  int    `json:"kind"`
  Color string `json:"color"`
  Users []User `json:"users"`
}

func NewConnected(color string, users []User) *Connected {
  return &Connected{
    Kind:  KindConnected,
    Color: color,
    Users: users,
  }
}

type UserJoined struct {
  Kind int  `json:"kind"`
  User User `json:"user"`
}

func NewUserJoined(userID string, color string) *UserJoined {
  return &UserJoined{
    Kind: KindUserJoined,
    User: User{ID: userID, Color: color},
  }
}

type UserLeft struct {
  Kind   int    `json:"kind"`
  UserID string `json:"userId"`
}

func NewUserLeft(userID string) *UserLeft {
  return &UserLeft{
    Kind:   KindUserLeft,
    UserID: userID,
  }
}

type Stroke struct {
  Kind   int     `json:"kind"`
  UserID string  `json:"userId"`
  Points []Point `json:"points"`
  Finish bool    `json:"finish"`
}

type Clear struct {
  Kind   int    `json:"kind"`
  UserID string `json:"userId"`
}

Handling message flow

返回hub.go文件,添加全部缺乏的功能。

onConnect函數表示客戶端鏈接,在 run方法中調用。 它將用戶的畫筆顏色和其餘用戶的信息發送給客戶端。 它還將當前鏈接的用戶信息通知給其餘在線用戶。

func (hub *Hub) onConnect(client *Client) {
  log.Println("client connected: ", client.socket.RemoteAddr())
  // Make list of all users
  users := []message.User{}
  for _, c := range hub.clients {
    users = append(users, message.User{ID: c.id, Color: c.color})
  }
  // Notify user joined
  hub.send(message.NewConnected(client.color, users), client)
  hub.broadcast(message.NewUserJoined(client.id, client.color), client)
}

onDisconnect函數從 clients 中刪除斷開鏈接的客戶端,並通知其餘人有人離開。

func (hub *Hub) onDisconnect(client *Client) {
  log.Println("client disconnected: ", client.socket.RemoteAddr())
  client.close()
  // Find index of client
  i := -1
  for j, c := range hub.clients {
    if c.id == client.id {
      i = j
      break
    }
  }
  // Delete client from list
  copy(hub.clients[i:], hub.clients[i+1:])
  hub.clients[len(hub.clients)-1] = nil
  hub.clients = hub.clients[:len(hub.clients)-1]
  // Notify user left
  hub.broadcast(message.NewUserLeft(client.id), nil)
}

每當從客戶端收到消息時,都會調用onMessage函數。 首先經過使用tidwall/gjson包來讀取它是什麼樣的消息,而後分別處理每一個狀況。

在這個例子中,狀況都是類似的。 每一個消息得到用戶的ID,而後轉發給其餘客戶端。

func (hub *Hub) onMessage(data []byte, client *Client) {
  kind := gjson.GetBytes(data, "kind").Int()
  if kind == message.KindStroke {
    var msg message.Stroke
    if json.Unmarshal(data, &msg) != nil {
      return
    }
    msg.UserID = client.id
    hub.broadcast(msg, client)
  } else if kind == message.KindClear {
    var msg message.Clear
    if json.Unmarshal(data, &msg) != nil {
      return
    }
    msg.UserID = client.id
    hub.broadcast(msg, client)
  }
}

最後,編寫main.go文件

package main

import (
  "log"
  "net/http"
)

func main() {
  hub := newHub()
  go hub.run()
  http.HandleFunc("/ws", hub.handleWebSocket)
  err := http.ListenAndServe(":3000", nil)
  if err != nil {
    log.Fatal(err)
  }
}

Front-end app

前端應用程序將用純JavaScript編寫。 在client目錄建立index.html文件

<!DOCTYPE html>
<html>
<head>
  <title>Collaborative Drawing App</title>
  <style>
    #canvas {
      border: 1px solid #000;
    }
  </style>
</head>
<body>
  <canvas id="canvas"
          width="480"
          height="360">
  </canvas>
  <div>
    <button id="clearButton">Clear</button>
  </div>
  <script>
    MESSAGE_CONNECTED = 1;
    MESSAGE_USER_JOINED = 2;
    MESSAGE_USER_LEFT = 3;
    MESSAGE_STROKE = 4;
    MESSAGE_CLEAR = 5;

    window.onload = function () {}
  </script>
</body>
</html>

上面的代碼會建立一個畫布和一個清除按鈕。 如下全部JavaScript代碼都編寫在window.onload事件處理程序中。

Drawing on canvas

聲明一些變量

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext("2d");
var isDrawing = false;
var strokeColor = '';
var strokes = [];

編寫canvas處理事件

canvas.onmousedown = function (event) {
  isDrawing = true;
  addPoint(event.pageX - this.offsetLeft, event.pageY - this.offsetTop, true);
};

canvas.onmousemove = function (event) {
  if (isDrawing) {
    addPoint(event.pageX - this.offsetLeft, event.pageY - this.offsetTop);
  }
};

canvas.onmouseup = function () {
  isDrawing = false;
};

canvas.onmouseleave = function () {
  isDrawing = false;
};

編寫addPoint方法,strokes是一個畫筆數組,存儲全部的點。

function addPoint(x, y, newStroke) {
  var p = { x: x, y: y };
  if (newStroke) {
    strokes.push([p]);
  } else {
    strokes[strokes.length - 1].push(p);
  }
  update();
}

update 方法重繪

function update() {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.lineJoin = 'round';
  ctx.lineWidth = 4;

  ctx.strokeStyle = strokeColor;
  drawStrokes(strokes);
}

drawStrokes 繪製多個路徑

function drawStrokes(strokes) {
  for (var i = 0; i < strokes.length; i++) {
    ctx.beginPath();
    for (var j = 1; j < strokes[i].length; j++) {
      var prev = strokes[i][j - 1];
      var current = strokes[i][j];
      ctx.moveTo(prev.x, prev.y);
      ctx.lineTo(current.x, current.y);
    }
    ctx.closePath();
    ctx.stroke();
  }
}

清除 點擊事件

document.getElementById('clearButton').onclick = function () {
  strokes = [];
  update();
};

Server communication

要與服務器通訊,請首先聲明一些額外的變量。

var socket = new WebSocket("ws://localhost:3000/ws");
var otherColors = {};
var otherStrokes = {};

otherColors對象將保存其餘客戶端的顏色,其中key將是用戶ID。 otherStrokes將保存繪圖數據。

在addPoint函數中增長髮送消息。 對於這個例子,points數組只有一個點。 理想狀況下,分數將根據一些標準分批發送。

function addPoint(x, y, newStroke) {
  var p = { x: x, y: y };
  if (newStroke) {
    strokes.push([p]);
  } else {
    strokes[strokes.length - 1].push(p);
  }
  socket.send(JSON.stringify({ kind: MESSAGE_STROKE, points: [p], finish: newStroke }));
  update();
}

處理髮送 "clear" 消息

document.getElementById('clearButton').onclick = function () {
  strokes = [];
  socket.send(JSON.stringify({ kind: MESSAGE_CLEAR }));
  update();
};

onmessage 處理函數

socket.onmessage = function (event) {
  var messages = event.data.split('\n');
  for (var i = 0; i < messages.length; i++) {
    var message = JSON.parse(messages[i]);
    onMessage(message);
  }
};

function onMessage(message) {
  switch (message.kind) {
    case MESSAGE_CONNECTED:
      break;
    case MESSAGE_USER_JOINED:
      break;
    case MESSAGE_USER_LEFT:
      break;
    case MESSAGE_STROKE:
      break;
    case MESSAGE_CLEAR:
      break;
  }
}

對於 MESSAGE_CONNECTED 狀況,設置用戶的畫筆顏色並用給定的信息填充「other」對象。

strokeColor = message.color;
for (var i = 0; i < message.users.length; i++) {
  var user = message.users[i];
  otherColors[user.id] = user.color;
  otherStrokes[user.id] = [];
}

對於MESSAGE_USER_JOINED的狀況,設置用戶的顏色並準備一個空的筆劃數組。

otherColors[message.user.id] = message.user.color;
otherStrokes[message.user.id] = [];

在MESSAGE_USER_LEFT的狀況下,若是有人離開,須要刪除他的數據,並從畫布上清除他的繪畫。

delete otherColors[message.userId];
delete otherStrokes[message.userId];
update();

在MESSAGE_STROKE的狀況下,更新用戶的筆畫數組。

if (message.finish) {
  otherStrokes[message.userId].push(message.points);
} else {
  var strokes = otherStrokes[message.userId];
  strokes[strokes.length - 1] = strokes[strokes.length - 1].concat(message.points);
}
update();

對於MESSAGE_CLEAR狀況,只需清除用戶的筆劃數組。

otherStrokes[message.userId] = [];
update();

更新update方法以顯示他人的圖紙。

function update() {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.lineJoin = 'round';
  ctx.lineWidth = 4;
  // Draw mine
  ctx.strokeStyle = strokeColor;
  drawStrokes(strokes);
  // Draw others'
  var userIds = Object.keys(otherColors);
  for (var i = 0; i < userIds.length; i++) {
    var userId = userIds[i];
    ctx.strokeStyle = otherColors[userId];
    drawStrokes(otherStrokes[userId]);
  }
}

效果圖

相關文章
相關標籤/搜索