Building a Real-time Collaborative Drawing App in Go

Tin Rabzelj
22 November, 2017

cover

This article shows how to build a simple drawing app using Go for the back-end. All communication will be established with WebSockets, so users will be able to see each others' drawings in real-time.

Source code is available on GitHub.

Getting started

Install necessary Go packages.

go get -u github.com/gorilla/websocket \
  github.com/lucasb-eyer/go-colorful \
  github.com/tidwall/gjson \
  github.com/satori/go.uuid

Create a directory for your project inside $GOPATH.

Server

First, you'll create the server. Server consists of a Hub which handles clients' lifecycle and manages received messages. This solution is similar to Gorilla's chat example.

Client struct

Create client.go file.

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
}

Write a constructor function for the client. It assigns an UUID and a random color.

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

Write the generateColor utility function inside utilities.go file.

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()
}

Write the read function, which reads messages sent from clients and forwards them to the hub. The client will be sent to the unregistered channel, if there's an error or if it disconnects.

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)
  }
}

The write function takes messages from the outbound channel and sends them to the client. This way, the server will be able to send messages to a client.

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)
    }
  }
}

Write functions that start and end processing of the client. Run both read and write functions as goroutines.

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

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

Hub struct

Declare Hub struct inside hub.go.

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
}

Write the constructor function.

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

Write run function.

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

Write a HTTP handler to upgrade the request to a WebSockets request. If upgrade succeeds, the client is added to the list of 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()
}

Write a function which sends a message to a client.

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

Write a function which broadcasts a message to all clients, except one. This is useful for forwarding received messages to other clients, while excluding the sender.

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 will use JSON as the wire format. Each message will carry a "kind" field, to distinguish messages from one another.

Create a message subpackage and messages.go file inside it.

Declare an enum of all 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
)

Declare some plain data structs.

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

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

Declare message struct and write helper constructor functions where needed. The kind fields are set inside constructors.

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

Back in the hub.go file, add all the missing functions.

The onConnect function is called from run when a client connects. It sends user's color and information about other users to the client. It also notifies others that someone joined.

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)
}

The onDisconnect function removes disconnected client from the list of all clients and notifies others that somebody left.

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)
}

The onMessage function is called whenever a message is received from a client. First it reads what kind of a message it is, by using tidwall/gjson package, then handles each case separately.

In this example, both cases work similarly. Each message gets ID of the user, and then is forwarded to other clients.

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)
  }
}

To finish off the server-side code, run the server inside main.go file.

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

The front-end app will be written in pure JavaScript. Create client directory and index.html file inside it.

<!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>

This creates a canvas and a button to clear it. All of the following JavaScript code resides inside window.onload event handler.

Drawing on canvas

Declare some variables.

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

Write event handlers for the 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;
};

Write addPoint function. strokes is an array of strokes, each being an array of points.

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

The update function redraws everything on the canvas.

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

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

Write drawStrokes function which draws multiple strokes.

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();
  }
}

Write an onclick handler for the clear button.

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

Server communication

To communicate with the server, first declare some additional variables.

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

The otherColors object will hold other clients' colors, where keys will be their IDs. And the otherStrokes will hold drawing data.

Update addPoint function to send a message. For this example, points array has only one point. Ideally, points would be sent in batches based on some criteria.

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();
}

Send a "clear" message in the clear handler.

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

Now write onmessage handler to handle received messages.

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;
  }
}

For MESSAGE_CONNECTED case, set the user's stroke color and populate "other" objects with given information.

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] = [];
}

For MESSAGE_USER_JOINED case, set the user's color and prepare an empty stroke array.

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

If somebody leaves, delete his data in the MESSAGE_USER_LEFT case and clear his drawings from the canvas.

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

Inside MESSAGE_STROKE case, update the strokes array for a user.

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();

For MESSAGE_CLEAR case, simply clear the user's stroke array.

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

Update the update function to display others' drawings.

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]);
  }
}

Wrapping up

To run your app, buld and run the server.

$ go build -o server && ./server

Then open client/index.html file in your browser.

Demo

Source code is available on GitHub.

Newsletter

Get awesome articles delivered right to your doorstep

Protected by reCAPTCHA - Privacy - Terms

Comments

Sign in to post a comment