devlog/content/posts/buzzer-game.md

141 lines
5.2 KiB
Markdown
Raw Permalink Normal View History

2024-11-24 15:54:20 +00:00
+++
date = '2024-11-24T15:23:50+01:00'
draft = true
title = 'Classroom Buzzer App'
tags = ["education","golang","websockets","concurrency"]
+++
## I started working on a new project today.
The client is an educational services provider and wants me to develop a tool to facilitate in-person group activities. This tool should:
- provide a platform which students can log into from a mobile device, in a frictionless process that takes seconds
- allow a teacher to assign groups and pairs in
- allow the teacher to dynamically reassign groups without repeating combinations
- implement a simple score-keeping functionality
- be able to run a 'buzzer' game
- have a clean, appealing, and user-friendly UI
A quick read of this brief should make it clear that what is required here is a server capable of handling multiple, live, two-way connections. The server needs to be able to update the clients whenever the teacher wants to shuffle the groups, and the buzzer game requires that when a student 'buzzes', state is propagated via the server to all other clients. The solution to this problem is websockets, and a server capable of handling concurrency.
Now I'm relatively new to both of these topics, but my project [GoPaper](https://projects.ajstepien.xyz/andrzej/gopaper) made use of concurrent goroutines to implement a wallpaper daemon, so I'll be building off that knowledge and developing the server side of this project in Go.
## WebSockets in Go
It is possible to build a websocket framework entirely from scratch in Go, but seeing as I don't hate myself *quite that much*, I'll be using the framework [Gorilla](https://github.com/gorilla/websocket).
## Getting started: running concurrent websocket connections
```go
package main
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
type webSocketHandler struct {
upgrader websocket.Upgrader
}
// GLOBALS
var connections int = 0
var wg sync.WaitGroup
func (wsh webSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//upgrade http connection to websocket
connection, err := wsh.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Error while upgrading connection to websocket: %s", err)
return
}
connections++
log.Printf("New websocket connection. There are now %v\n", connections)
wg.Add(1)
go func() {
for {
//actually handle the websocket connection here
}
}()
}
func main() {
webSocketHandler := webSocketHandler{
upgrader: websocket.Upgrader{},
}
var port string = "8080"
http.Handle("/", webSocketHandler)
wg.Add(1)
log.Printf("listening on port %s...\n", port)
log.Fatal(http.ListenAndServe("localhost:8080", nil))
wg.Wait()
}
```
In this quick-and-dirty bit of code, we create a websocket handler that upgrades an http connection to a websocket connection, and then launches a goroutine. The goroutine doesn't do anything yet, but running ´websocat´ from multiple terminals shows us that we are now capable of handling multiple concurrent websocket connections 👍.
```bash
websocat ws://localhost:8080/
```
```
New websocket connection. There are now 1.
```
```
New websocket connection. There are now 2.
```
```
New websocket connection. There are now 3.
```
We know the connections have been made as either websocat or our Go app would error out otherwise. We should certainly do something to handle closed connections, as this code is a memory leak waiting to happen as things stand, but as a proof of concept, it works.
Now, time to actually do something with these connections! Let's add some logic to that goroutine.
```go
func (wsh webSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//upgrade http connection to websocket
connection, err := wsh.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Error while upgrading connection to websocket: %s", err)
return
}
connections++
log.Printf("New websocket connection. There are now %v\n", connections)
wg.Add(1)
go func() {
defer connection.Close()
for {
msgType, message, err := connection.ReadMessage()
if err != nil {
log.Printf("Error trying to read message from client: %s", err)
return
}
if msgType == websocket.BinaryMessage {
err = connection.WriteMessage(websocket.TextMessage, []byte("this server does not support binary messages"))
if err != nil {
log.Printf("Error trying to send message to client: %s", err)
}
return
}
log.Printf("received message from client: %s", message)
err = connection.WriteMessage(websocket.TextMessage, []byte("Message received!"))
}
}()
}
```
### Here's what we just added:
- The `defer` keyword closes the connection if/when the goroutine returns. In its current state, that would only happen in the case of an error.
- `connection.ReadMessage()` blocks until a message is received from the client
- If the client tries to send binary data, we bounce an error message back to the client, log an error on the server and return from the goroutine.
- If the message is text, we log it on the server and send a thank-you message back to the client. Testing this again with multiple instances of `websocat` shows that everything is working as it should. We have established two-way channels of communication between a server and multiple clients! 🥳
In my next post, I'll begin implementing the business logic.