Get started with Velocity
Join the Waitlist
Join Our Discord
Blogs

Build a Collaborative Document Editor with Golang Websockets

Jeff Vincent
Jeff Vincent
  
December 26, 2023

Build a Collaborative Document Editor with Golang Websockets

What are websockets?

Websockets are similar to standard HTTP requests, in that they utilize TCP as the underlying protocol, but unlike HTTP requests — which require a separate call to the server for every data transfer — websockets keep a connection with the server open during the life of the websocket, which means that data can flow much faster from the server to the browser and vice versa.

Common use cases for websockets are “real-time” applications, like chat, video streaming, collaborative document editing, and stock tickers, which of course require continually updated and accurate information.

What we'll be building

To demonstrate handling websocket connections in Go, we'll build a simple HTTP API with Gin, and we'll handle connection upgrades with the Gorilla websocket package. Our API will allow multiple connections to a single document that will be stored temporarily in memory, and each of those websocket connections will be able to edit the doc and view the edits of that same doc made in separate browsers in real time.

The full project, including the React frontend shown in the GIF above are available in GitHub.

Define our imports and the Document struct

As mentioned above, we'll need to import Gin and the Gorilla websocket package, as well the sync package — and we'll import log for good measure.

package main

import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"log"
"sync"
)

type Document struct {
sync.RWMutex
content string
clients map[*websocket.Conn]bool
}

What is  sync.RWMutex?

By adding the sync.RWMutex field to the Document struct, we get some pretty cool features that our simple application will need to avoid race conditions. Because multiple users can edit the same document, each time any one of them does so, there needs to be a “lock” put in place on that data, so the other users can't edit at the same time. Likewise, each time the document is updated and the new text is sent to each user, there needs to be a different lock put in place so that the document can't be updated until it has been sent to each user.

Specifically, (as shown in the “Handle text updates” section below) sync.RWMutex provides four methods on the Document type — Lock(), Unlock(), Rlock(), and RUnlock(). These methods provide the functionality described above — when an update to the document is being made, we first call Lock(), and when the updated document is being sent to each user, we first call Rlock() (or “Read Lock”). And then, of course, once the respective operations complete, we call the appropriate “Unlock” method to release the lock.

How does the clients field work?

The clients field in the Document type is a map of pointers to a websocket connection object and a boolean value that indicates whether the connection is active. So, (as shown in the “Handle new websocket connections” section below) each time a new websocket connection is made, the resulting object, along with a bool that defaults to true is added to the map of clients, so we can then iterate over the items in the map, and send the updated document content to each of the active connections.

Define the Main function

Next, we have a standard HTTP API defined with Gin, which has a single endpoint /ws exposed to handle new websocket connections.

func main() {
router := gin.Default()
document := NewDocument()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins
},
}

router.GET("/ws", handleWebSocket(document, &upgrader))

err := router.Run(":8000")
if err != nil {
log.Fatal("Error starting server:", err)
}
}

Create a new Document

And, of course, within the above Main function, before anyone can edit a Document, it first has to be created and stored in memory, which the NewDocument() function takes care of.

func NewDocument() *Document {
return &Document{
clients: make(map[*websocket.Conn]bool),
}

Handle new websocket connections

Next, in the following function definition, we handle an incoming HTTP request, and use the Gorilla websocket package to upgrade the connection to a websocket. Then, we call the document.Lock() method mentioned above, and add the new connection to the map of websocket clients described above.

Once that connection object has been added to the map of clients, we call handleIncomingMessages() which includes a for loop without any conditions, which equates to an infinite loop in Go, so once the connection is made, the loop runs indefinitely.

func handleWebSocket(document *Document, upgrader *websocket.Upgrader) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Println("Error upgrading connection to WebSocket:", err)
return
}

               // Register client
document.Lock()
document.clients[conn] = true
document.Unlock()

// Handle incoming messages
go handleIncomingMessages(document, conn)
}
}

Handle text updates

Finally, we'll define the handleIncomingMessages() function, which attempts to read the inciming message, locks the document, writes the new message to the document and then unlocks the document so other users can update it in turn.

Notice also that we check the length of the message and replace an an empty string with sample text, and then we call Rlock() as described above, and send the updated document content as we iterate over all of the connected users. And once the updated content has been sent to each user, we call RUnlock() to allow further edits.

func handleIncomingMessages(document *Document, conn *websocket.Conn) {
for {
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading WebSocket message:", err)

// Unregister client
document.Lock()
delete(document.clients, conn)
document.Unlock()

// Close the connection
conn.Close()
break
}

document.Lock()
if len(message) > 0 {
document.content = string(message)
} else {
// Replace empty message with sample text
document.content = "Start typing ..."
}
document.Unlock()

document.RLock()
for client := range document.clients {
err := client.WriteMessage(websocket.TextMessage, []byte(document.content))
if err != nil {
log.Println("Error sending message to client:", err)
client.Close()
delete(document.clients, client)
}
}
document.RUnlock()
}

Conclusion

Websockets are commonly used for “real-time” applications that require very fast request / response times from the front to the backend, and vice versa. Above, we looked at the basics of upgrading a HTTP request to a websocket connection with Go's Gorilla websocket package, and we also took a look at how we can use Go's sync package to avoid race conditions in applications where multiple users can interact with the same data at the “same” time.

Python class called ProcessVideo

Python class called ProcessVideo

Get started with Velocity