(Image credit to quasilyte on DeviantArt)
Disclaimer: The code you are about to read is not safe for work. Because it is terrible. It is here if you dare. I've saved off the results in the24_hours
branch. To save some sort of professional dignity, I've made some touchups to themain
branch.
At my current job, I do a lot less programming that I’d like to. It is no fault of my employer. As an SRE, I am tasked with what is required to get the product moving. At the moment, that is less programming and more of infrastructural work. A lot of my job recently has been configuration, writing YAML, troubleshooting CI/CD pipelines, and interfacing with external teams. As a result, I can feel a few of my programming muscles atrophying.
Becoming proficient with Go is something that I’ve had on my to-do list forever. I write in Go infrequently at work and have used it in previous projects, but it is not something in which I am fully comfortable yet. I have encountered a lot of Go fanboys that are insistent on the benefits and features of Go, to the point where usually they will tout benefits that are in no way relevant to the task they’re choosing. For example, I’ve witnessed developers spending a bunch of time writing an entire Go package for a fully extensible and robust HTTP tool that can do a lot of cool things. Unfortunately, the only thing it’s been used for in the past year is sending a GET request to a specified endpoint with a exponential backoff, something that can be done with a bash one-liner:
$ curl -X GET --retry 5 my-bad-url.com
Warning: Transient problem: timeout Will retry in 1 seconds. 5 retries left.
Warning: Transient problem: timeout Will retry in 2 seconds. 4 retries left.
Warning: Transient problem: timeout Will retry in 4 seconds. 3 retries left.
Warning: Transient problem: timeout Will retry in 8 seconds. 2 retries left.
Warning: Transient problem: timeout Will retry in 16 seconds. 1 retries left.
curl: (6) Could not resolve host: my-bad-url.com
Despite some of the overzealousness when it comes to Go, there are some real and inarguable benefits to the language. It has a lot of the features of a low-level language like C (pointers, strong typing, an efficient compiler) without one of the gigantic headaches: manual memory management. There are folks that will argue til they die that memory management is best left up to the developers, but in my experience, a lot of those arguments boil down to pissing contents about how segfaults and double-frees are really just faults of developers who “aren’t good enough”. C is a bedrock language that most of the modern world was built on, and continues to be an absolutely integral part of software development. It is the right choice for a lot of applications. But not all! For programmers that want a feel for that low-level control without the manual memory management, Go is a good choice. As of this writing, it still hasn’t cracked the top ten languages on Github:
But it is one of the most popular languages that developers want to learn and start using:
I’m no different! I use Go somewhat regularly, but it looks to be wave of the future, especially as a huge amount of my favorite open source tools are written in Go. So what does it take to become proficient? Probably a heck of a lot more that I did, but its a start. I spent 24 hours trying to write a chat service and this is about how far I got. I tried to implement a hackathon mentality in my brain to figure out what the quickest thing I could come up with a language that I’m not the best at, and this is the result.
The Project – Chat Service
I figured that if I worked for the evening and morning of single day, as a halfways competent developer, I should be able to get something resembling a working a person-to-person chat service going. My thought for design was stupidly simple. I would have an HTTP server that would listen on a few routes:
- Create a User
- Send a Message
- Get Chats between Users
I wasn’t going to implement any sort of validation that users could only see their own chats, users were going to be assigned UUIDs for simplicity, chats could be anything (including SQL injections!), etc. The database is purely in memory and has NO consistency guarantees. No rules! If you know someone elses ID, you can see their chats. Obviously not something you’d put on the web with your name on it, but a good enough exercise in programming to try and figure out if I can get an intermediate-level challenge under my belt.
Custom Data Types
By defining some arbitrary data, we can sort of approximate the things you need to have a chat service.
package main
import (
"time"
"github.com/google/uuid"
)
type User struct {
Id uuid.UUID
// other fields as necessary
}
type Message struct {
Msg string `json:"Msg"`
To uuid.UUID `json:"To"`
From uuid.UUID `json:"From"`
timestamp time.Time
}
type GetChat struct {
To uuid.UUID `json:"To"`
From uuid.UUID `json:"From"`
}
type Chat struct {
UserOneId uuid.UUID
UserTwoId uuid.UUID
Messages []Message
}
I modeled a User as a struct (although in retrospect, this could’ve just been the UUID since I didn’t add any other data points to the struct), a Message to approximate a JSON object sent over the wire, a Chat to represent who’s in the chat and the messages sent. In addition, I represent the message sent to the server to grab the chats between users.
“Database”
I knew that I would need some way to retrieve data. Although I didn’t want to actually have a persistent on-disk database, I knew that I’d have to have a data structure that would actually allow me to somewhat efficiently access the chats that users sent to each other. Essentially I needed a way to uniquely identify users, a way to map users to other users that have been in contact with each other, and a way to keep track of the messages that they’ve written.
package main
import (
"log"
"sync"
"github.com/google/uuid"
)
type UserDb struct {
lock sync.Mutex
Users map[uuid.UUID]bool
UsersToUsers map[uuid.UUID]uuid.UUID // user one -> user two, or "FROM" -> "TO"
UserOneToChat map[uuid.UUID]*Chat // user one = from, user two = to
}
func (u *UserDb) getUser(usr uuid.UUID) bool {
return u.Users[usr]
}
func (u *UserDb) addUser(usr uuid.UUID) bool {
if u.Users[usr] {
log.Printf("User %v already exists", usr)
return false
}
u.Users[usr] = true
return true
}
func (u *UserDb) createChat(initMsg *Message) *Chat {
u.lock.Lock()
msgCopy := initMsg
newChat := Chat{
UserOneId: initMsg.From,
UserTwoId: initMsg.To,
Messages: []Message{*msgCopy},
}
userDatabase.UserOneToChat[initMsg.From] = &newChat
userDatabase.UsersToUsers[initMsg.From] = initMsg.To
log.Printf("new Chat created")
u.lock.Unlock()
return &newChat
}
I picked UUID as a way to uniquely identify users, a mutex to ensure concurrent access wouldn’t cause data integrity problems, and really basic mapping to point users to users and users to chats. From there, adding users and creating chats was simply adding really basic methods to the struct.
Another File for Some Reason: Users.go
package main
import (
"log"
"github.com/google/uuid"
)
func CreateNewUserAddToDb(d *UserDb) User {
u := uuid.New()
newUser := User{
Id: u,
}
userDatabase.Users[u] = true
log.Printf("Created a new user %v with no chats at %v", u, getTime())
log.Printf("Users: %v", userDatabase)
return newUser
}
Adding Users to the DB is done through a simple function that is a wrapper around the DB method. It’s pretty much only used to generate a UUID. In retrospect, this is pretty useless. In addition, it uses a global variable userDatabase
that is probably not the best way to do this. Of course, if we were using a real database, this would just be an interface anyway.
Handlers
In order for the server to respond to any requests, I implemented a few handlers that can deserialize messages and handle data.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func handleSendMessage(w http.ResponseWriter, r *http.Request) {
var m Message
err := json.NewDecoder(r.Body).Decode(&m)
if err != nil {
http.Error(w, fmt.Sprintf("Required json fields missing, or smthn lol: %v", err.Error()), http.StatusBadRequest)
return
}
err = validateSendMessage(&m)
if err != nil {
http.Error(w, fmt.Sprintf("Required json fields missing, or smthn lol: %v", err.Error()), http.StatusBadRequest)
return
}
m.timestamp = getTime()
c := findExistingOrCreateNewChat(&m)
fmt.Printf("chat: %v", c)
}
func handleNewUser(w http.ResponseWriter, r *http.Request) {
if !matchPathAndMethod(w, r, "/newUser", "POST") {
return
}
newUser := CreateNewUserAddToDb(&userDatabase)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(newUser)
}
func handleEmptyGetReq(w http.ResponseWriter, r *http.Request) {
if !matchPathAndMethod(w, r, "/", "GET") {
return
}
log.Printf("Got a GET request to the root at %v", getTime())
fmt.Fprintf(w, "Hello\n")
}
func handleGetChats(w http.ResponseWriter, r *http.Request) {
if !matchPathAndMethod(w, r, "/getChats", "GET") {
return
}
var getChatMsg GetChat
err := json.NewDecoder(r.Body).Decode(&getChatMsg)
if err != nil {
http.Error(w, fmt.Sprintf("Required json fields missing, or smthn lol: %v", err.Error()), http.StatusBadRequest)
return
}
chatBetweenUsers := getChatBetweenUsers(getChatMsg.To, getChatMsg.From)
if chatBetweenUsers != nil {
for _, m := range chatBetweenUsers.Messages {
fmt.Printf("[%v]\n- To: %v\n- From: %v\nMessage: %v", m.timestamp, m.To, m.From, m.Msg)
}
}
}
These handlers are fairly self descriptive. They handle different types of requests and routes.
In Action
Like I’ve said, this chat server isn’t overly impressive. It simply takes in some data in the form of JSON POST requests, and opens up a chat-like interface between different UUIDs. If you were to deploy this with a persistent datastore to some sort of DNS-resolved VM, it might kinda halfway work to function as a chat application (with a proper client). Here’s what it looks like with Postman.
As you can see, there are some interesting design decisions. For example, sending a GET request to the /getChats
endpoint with two user IDs specified causes the server to log the chats and returns… nothing to the user. What good is that?
Lessons Learned
Go Test is your Friend
Go’s native testing interface is something that can easily be integrated into any project and absolutely should. When touching up some of the chat service, having a single unit test on the database saved me a bunch of time in refactors.
Makefiles are Awesome
Go’s build toolchain is great, and augmenting it with a really simple Makefile to wrap some common tasks (go test -v && go build
) really saves some time.
Make it Work, Make it Good
When taking a hackathon mentality, the first thing to do is to make the thing work. I tend to spend a lot of time overengineering solutions and this time was no different. I added a bunch of validation to HTTP calls that weren’t ever going to be exposed to erroneous data. I could’ve spent more time trying to add some of the functionality that would be cool rather than trying to make my tiny project for no one to be bulletproof. Live and learn!