Go-Chat, adding chat history
Background
A few months ago, I decided to pick up the Go Programming Language. After a couple of weeks of going through the Go Tour and Go by Example to understand the syntax, I was looking for a "real" project to really start understanding the language and it's surrounding hype.
From this post by ThePrimeagen, a WebSocket based chat server and client seemed like a fun idea. After all, I'd just come across the Bubbletea set of libraries by Charm around this time too, so why not go all in and make it a Terminal User Interface / TUI. I had an 8 hour flight coming up that weekend and - loaded with a couple offline docs, all of the Charm + relevant libraries go get'd, and multiple Spotify playlists downloaded - I planned to make a proof-of-concept client and server within that flight.
Honestly, that flight was a blast. Like a hackathon but without the chaos and distractions at an event. Just solid focus, especially thanks to a lovely lady from Wales in the same aisle, who also spent the whole overnight flight awake reading and chatting.
The initial release from this flight is v0.1.0, and I'm certainly proud of it, including minor changes made since then. But there's also a couple of major flaws at the moment. The first is that resizing the client on Windows causes the entire view of the application to break, though curiously not on WSL. Thanks to charmbracelet/bubbletea#878 (credit to @erikgeiser ❤️), updating my go.mod is all it took to solve this. The second is that chat logs for each room are currently only stored in memory, and are lost if the server ever stops for any reason, such as a crash or the server virtual machine restarting. This post serves to document fixing this oversight.
Enter SQLite, or All your messages are belong to us
Adding a SQL database was immediately the easiest option, there are various great guides on using SQL in Go, and I had previously used a database to store long-lived application state in nanobot. For such a simple and lightweight program, spinning up a PostgreSQL container would be massively overkill. Enter SQLite: "a small, fast, self-contained, high-reliability, full-featured, SQL database engine". In other words, an easy way for the server binary itself to read/write to a database file on-disk with little overhead.
Of course, when trying any new thing out, starting out small is probably a good idea. The test program ended up as follows:
[!INFO] The
try()function is used here to avoid a load ofif err != nilchecks and keep the example short, while still checking for errors and logging + crashing if one occurs. In a real program, it's better to properly handle recoverable errors. In cases where a function only returns an error,try(0, ...)allows the same function to be reused, and the0is discarded.
package main
import (
_ "database/sql"
"fmt"
"log"
"os"
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
type LMsg struct {
Tim time.Time
Id string
Msg string
}
func main() {
f := "log.db"
defer os.Remove(f)
db := try(sqlx.Open("sqlite", f))
try(db.Exec(`drop table if exists msgs`))
try(db.Exec(`create table msgs(tim timestamp, id text, msg text)`))
msgsIn := []LMsg{
{Tim: time.Now().Add(-2 * time.Hour), Id: "8bit", Msg: "hello :D"},
{Tim: time.Now().Add(-2 * time.Minute), Id: "8bit", Msg: "testing"},
{Tim: time.Now(), Id: "adam", Msg: "boop"},
}
try(db.NamedExec(`insert into msgs (tim, id, msg) values (:tim, :id, :msg)`, msgsIn))
msgs := []LMsg{}
try(0, db.Select(&msgs, `select * from msgs order by tim asc`))
fmt.Printf("%v\n", msgs)
try(0, db.Close())
}
func try[T any](res T, err error) T {
if err != nil {
log.Fatalln(err)
}
return res
}
func (m LMsg) String() string {
return fmt.Sprintf("%s %s: %q\n", m.Tim.Format(time.TimeOnly), m.Id, m.Msg)
}
With this output from the fmt.Printf call:
PS sqltest> go run .
[19:14:44 8bit: "hello :D"
21:12:44 8bit: "testing"
21:14:44 adam: "boop"
]
As a bonus, it seems like (de-)serialising between SQLite's timestamp and time.Time works, so no manual parsing needed on my side!
The Main Event™
[!INFO] You can check out the changes discussed here on GitHub using this
comparelink
The biggest changes to the server are the addition of a SQLite database, including a new command line parameter for the database file-path. This database will be loaded and initialised on start-up, as well as being written to as events occur such as messages being sent, or rooms being created.
To make this easier, the server struct was extended to store a database connection as well as a send-only channel for message logs to be sent into. Keeping this channel send-only ensures the compiler can spot mistakes like reading from the channel anywhere other than the logging Goroutine. The last change to the struct is replacing the rooms set (a map from string to struct{}) with an actual map from a room name to the respective SQL query for saving a message. This was done since the query string doesn't change per-room, but query templating doesn't include binding the table name, and I had decided each room would have a table to more closely match the internal representation of room message histories. Thankfully, this wouldn't break any existing uses of the rooms set, as I had been using the 2-variable read from Go maps to check for a key's existence, which is unaffected by the return type of the map.
While I do miss type and completion feedback from more advanced language servers like Rust-Analyzer, the incredibly fast compile times of Go mean you still have quick iteration cycles and I think I'm starting to feel as productive in Go. In total, these changes probably took on the order of a couple of weekends, with the majority of time spent working out database layout and what features to implement.
Since completing the message persistence additions to the server, I've also fixed a couple of issues in the client, such as the inability to resize and flickering on Windows. If you're feeling it, please come and say hi, or leave a message in the #guestbook channel! I'll be sure to check them out even if the server restarts :D The latest client binaries are available on the GitHub Release.
Two Wishes
I plan to keep working with Go for personal projects, because I've found many awesome libraries and it makes a lot of things much easier, being the first runtime-based language I've spent any serious amount of time with. Considering that, there are a couple of things I hope come to Go, to make writing Go even less boilerplate-y:
- Tagged Unions (or Discriminated Unions)
- There have been quite a few times where I really wished I had a type-safe
enum, and this would fit the bill. Something where I can pass a value into aswitchstatement and have the Go compiler warn me when my code doesn't exhaustively check every possible case or provide adefaultcase.
- There have been quite a few times where I really wished I had a type-safe
- A shortcut for error handling (akin to
tryin Zig or?in Rust)- This probably relies on Tagged Unions, but it would be awesome to not have 3 extra lines of
if err != nil {...}when all I want to do is bubble the error up a level. It's a blessing that Go has errors as values and I think this would be a wonderful, optional, addition.
- This probably relies on Tagged Unions, but it would be awesome to not have 3 extra lines of