From 40c95a46873be810f785d48469793a27513273ab Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Fri, 20 Oct 2017 16:51:30 -0700 Subject: [PATCH] Incomplete Example Implementation --- .gitignore | 2 + README.md | 9 ++ client.go | 169 +++++++++++++++++++++ client_test.go | 385 ++++++++++++++++++++++++++++++++++++++++++++++++ gui.go | 234 +++++++++++++++++++++++++++++ main.go | 184 +++++++++++++++++++++++ message.go | 189 ++++++++++++++++++++++++ message_test.go | 59 ++++++++ 8 files changed, 1231 insertions(+) create mode 100644 .gitignore create mode 100644 client.go create mode 100644 client_test.go create mode 100644 gui.go create mode 100644 main.go create mode 100644 message.go create mode 100644 message_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba2fcac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +beginning-go + diff --git a/README.md b/README.md index 15fa3bd..d3cc89c 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,18 @@ I promise that the expected result is correct. go test -v ``` +Removing the `-v` will cause only test failures to be displayed. + If your program does not compile it will show those errors and not run the tests. +## Editor Support + +I highly recommend taking a minute to install the Go plugin for your favorite +editor. Both Sublime Text +([gosublime]([200~https://github.com/DisposaBoy/GoSublime])) and VS Code (Just +search for the Go extension) have great plugins for editing Go code. + ## Fill in the Blanks Read through the code, run the unit tests, implement the missing parts. You diff --git a/client.go b/client.go new file mode 100644 index 0000000..0d18f50 --- /dev/null +++ b/client.go @@ -0,0 +1,169 @@ +package main + +import ( + "time" + + "github.com/clockworksoul/smudge" +) + +// NodeAddress is just an alias for strings, but increases clarity in the map +// keys of the ClientList. +type NodeAddress string + +// ClientList contains all clients which are currently connected to the cluster. +// +// Additionally, because this struct has methods defined on it which fulfill the +// requirements to be a smudge.StatusListener, it is used to handle +// notifications about added or removed clients. +// +// https://godoc.org/github.com/clockworksoul/smudge#StatusListener +type ClientList map[NodeAddress]ChatClient + +// OnChange is the only method defined on the smudge.StatusListener. By +// implementing this method on the ClientStatusListener struct, that struct will +// satisfy the interface and we can register it with smudge. +// +// When a client is added or removed from the gossip cluster, update our +// internal list of the membership. We can use this internally maintained +// membership list to display a friends list. +func (cl ClientList) OnChange(node *smudge.Node, status smudge.NodeStatus) { + if status == smudge.StatusAlive { + printDebug("Adding a new node: %s", node.Address()) + cl.AddClient(node) + } else { + printDebug("Removing a node: " + node.Address()) + cl.RemoveClient(node) + } + + printClientList(cl) +} + +// AddClient creates a ChatClient for the provided node and inserts it into the +// ClientList. If the node is ourselves, sets our username on the created +// ChatClient. +func (cl ClientList) AddClient(node *smudge.Node) { + // TODO: Implement this function + + // We need to create a new ChatClient to store in our ClientList. Learn + // about creating structs here: https://gobyexample.com/structs + + // If the node being added is us (the address matches localAddress) then we + // should add our username to the ChatClient object (localUsername). +} + +// RemoveClient deletes a ChatClient from the ClientList if it exists, based on +// the information from the provided node. +func (cl ClientList) RemoveClient(node *smudge.Node) { + // TODO: Implement this function + + // ClientList is a map from NodeAddress to a ChatClient. We can get the node + // address from the provided node by calling the "Address()" function on the + // Node, but that gives us a string. Casting to a NodeAddress is required + // before looking up in the map. + + // You can find information about checking for key existance and removing + // keys from maps here: https://blog.golang.org/go-maps-in-action +} + +// AddUsernames takes a map of NodeAddress->Username pairings and fills the +// ClientList with the usernames provided. It is possible that a node may change +// username, in which case the map should be updated. +func (cl ClientList) AddUsernames(usernames map[NodeAddress]string) error { + printDebug("Received username list containing: %+v", usernames) + + // TODO: Implement this function + // loop over the provided map of usernames, updating our client list with + // the username as we go. + // + // range is used to iterate over maps, slices, and arrays. + // More info: https://tour.golang.org/moretypes/16 + + // Tell the UI the client list has changed and should be redrawn + printClientList(cl) + return nil +} + +// getUsernameMap returns a map from node addresses to username, +// including only clients for which we know the username. Also include ourselves +// with the localAddress and localUsername. +func (cl ClientList) getUsernameMap() map[NodeAddress]string { + // TODO: Implement this function + + // Learn more about creating an empty map: https://gobyexample.com/maps + // Learn more about iterating maps: https://gobyexample.com/range + + // Don't forget to add ourselves! + + return make(map[NodeAddress]string) +} + +// BroadcastUsernames builds a map of the known usernames and broadcasts them +// to the chat cluster. +func (cl ClientList) BroadcastUsernames() error { + printDebug("Processing request to broadcast our known usernames...") + + usernames := cl.getUsernameMap() + msg := message{ + Type: messageTypeUsernames, + Usernames: usernames, + } + return smudge.BroadcastBytes(msg.Encode()) +} + +// FillMissingInfo looks for any connected clients for which we do not already +// know the username. If any missing usernames are found, request a username +// list from the first client found which does not have a username. +func (cl ClientList) FillMissingInfo() { + c := time.Tick(15 * time.Second) + for _ = range c { + printDebug("Checking for clients with a missing username...") + + if addrMissing, ok := cl.GetMissingUsername(); ok { + if err := cl.RequestUsernameList(addrMissing); err != nil { + printError("Error requesting missing usernames: %s", err) + } + } + } +} + +// GetMissingUsername iterates through the client list, looking for an connected +// clients for which we do not yet have the username. Returns the address of the +// first client encountered which is missing the username. +// If all usernames are known, an empty address and false are returned. +func (cl ClientList) GetMissingUsername() (NodeAddress, bool) { + // TODO: Implement this functiono + // More info about iterating maps: https://gobyexample.com/range + return NodeAddress(""), false +} + +// RequestUsernameList sends a broadcast to all nodes, requesting that the +// specified node respond with a list of all the usernames it is aware of. +// +// A broadcast is used because we have no way of directly connecting to this +// node. Other nodes will just have to ignore this message. +func (cl ClientList) RequestUsernameList(addrMissing NodeAddress) error { + printDebug("Sending username request to %s", addrMissing) + msg := message{ + Type: messageTypeUsernameReq, + Body: string(addrMissing), + } + + return smudge.BroadcastBytes(msg.Encode()) +} + +// ChatClient is a structure containing a reference to the smudge.Node +// represented and any additional information we know about this client, such as +// their username. +type ChatClient struct { + node *smudge.Node + + // username is a value we will query the client for when first discovered + username string +} + +// GetName returns the username of the connected client if the username is +// known, otherwise returns the address used by smudge to connect. +func (c *ChatClient) GetName() string { + // TODO: Implement this function + return "" +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..f166d7d --- /dev/null +++ b/client_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "fmt" + "net" + "os" + "reflect" + "testing" + + "github.com/clockworksoul/smudge" +) + +func TestMain(m *testing.M) { + unittestMode = true + os.Exit(m.Run()) +} + +func CheckNoError(t *testing.T, err error) { + if err != nil { + t.Fatalf("Expected nil error, received: %s", err) + } +} + +func TestGetName(t *testing.T) { + testNode, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9999) + CheckNoError(t, err) + + var cases = []struct { + client ChatClient + expectedResult string + }{ + { // When the username is set, it is returned + client: ChatClient{ + username: "testing", + node: testNode, + }, + expectedResult: "testing", + }, + { // When the username is not set, the node address is returned + client: ChatClient{ + node: testNode, + }, + expectedResult: "127.0.0.1:9999", + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + result := c.client.GetName() + if result != c.expectedResult { + t.Fatalf("Expected %q but got %q", c.expectedResult, result) + } + }) + } +} + +func TestRemoveClient(t *testing.T) { + testNode, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9999) + CheckNoError(t, err) + + testNode2, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9998) + CheckNoError(t, err) + + testNode3, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9997) + CheckNoError(t, err) + + var cases = []struct { + clientList *ClientList + expectedResult *ClientList + }{ + { // Test that a client is removed + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "testing", + node: testNode, + }, + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + expectedResult: &ClientList{ + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + }, + { // Test that clients that doesn't exist is not removed + clientList: &ClientList{ + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + NodeAddress("127.0.0.1:9997"): ChatClient{ + username: "testing", + node: testNode3, + }, + }, + expectedResult: &ClientList{ + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + NodeAddress("127.0.0.1:9997"): ChatClient{ + username: "testing", + node: testNode3, + }, + }, + }, + { // Test that it still works if there are no clients connected + clientList: &ClientList{}, + expectedResult: &ClientList{}, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + c.clientList.RemoveClient(testNode) + if !reflect.DeepEqual(*c.clientList, *c.expectedResult) { + t.Fatalf("Expected %v but got %v", *c.expectedResult, *c.clientList) + } + }) + } +} + +func TestAddClient(t *testing.T) { + localAddress = "192.168.0.101:8888" + localUsername = "unittest" + + testNode, err := smudge.CreateNodeByIP(net.ParseIP("192.168.0.10"), 9999) + CheckNoError(t, err) + testNode2, err := smudge.CreateNodeByIP(net.ParseIP("192.168.0.5"), 9998) + CheckNoError(t, err) + testNodeLocal, err := smudge.CreateNodeByIP(net.ParseIP("192.168.0.101"), 8888) + CheckNoError(t, err) + + var cases = []struct { + clientList *ClientList + expectedResult *ClientList + nodeToAdd *smudge.Node + }{ + { // Test that client is added + clientList: &ClientList{ + NodeAddress("192.168.0.5:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + expectedResult: &ClientList{ + NodeAddress("192.168.0.10:9999"): ChatClient{ + username: "", + node: testNode, + }, + NodeAddress("192.168.0.5:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + nodeToAdd: testNode, + }, + { // Test that if the client is us, the username is added + clientList: &ClientList{ + NodeAddress("192.168.0.10:9999"): ChatClient{ + username: "testing", + node: testNode, + }, + }, + expectedResult: &ClientList{ + NodeAddress(localAddress): ChatClient{ + username: localUsername, + node: testNodeLocal, + }, + NodeAddress("192.168.0.10:9999"): ChatClient{ + username: "testing", + node: testNode, + }, + }, + nodeToAdd: testNodeLocal, + }, + { // Test that it still works if the client list is empty + clientList: &ClientList{}, + expectedResult: &ClientList{ + NodeAddress("192.168.0.10:9999"): ChatClient{ + node: testNode, + }, + }, + nodeToAdd: testNode, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + c.clientList.AddClient(c.nodeToAdd) + if !reflect.DeepEqual(*c.clientList, *c.expectedResult) { + t.Fatalf("Expected %v but got %v", *c.expectedResult, *c.clientList) + } + }) + } +} + +func TestGetUsernameMap(t *testing.T) { + localAddress = "192.168.0.101:8888" + localUsername = "unittest" + + testNode, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9999) + CheckNoError(t, err) + testNode2, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.2"), 9998) + CheckNoError(t, err) + + var cases = []struct { + clientList *ClientList + expectedResult map[NodeAddress]string + }{ + { // Test that clients with usernames are included + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "testing", + node: testNode, + }, + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + expectedResult: map[NodeAddress]string{ + NodeAddress("127.0.0.1:9999"): "testing", + NodeAddress("127.0.0.2:9998"): "testing2", + NodeAddress(localAddress): localUsername, + }, + }, + { // Test that clients with no usernames are not included + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "", + node: testNode, + }, + }, + expectedResult: map[NodeAddress]string{ + NodeAddress(localAddress): localUsername, + }, + }, + { // Test that it still works if there are no clients connected + clientList: &ClientList{}, + expectedResult: map[NodeAddress]string{ + NodeAddress(localAddress): localUsername, + }, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + usernameMap := c.clientList.getUsernameMap() + if !reflect.DeepEqual(usernameMap, c.expectedResult) { + t.Fatalf("Expected %v but got %v", c.expectedResult, usernameMap) + } + }) + } +} +func TestGetMissingUsername(t *testing.T) { + testNode, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9999) + CheckNoError(t, err) + testNode2, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.2"), 9998) + CheckNoError(t, err) + + var cases = []struct { + clientList *ClientList + expectedResultBool bool + expectedResultAddr NodeAddress + }{ + { // Test that false is returned when all usernames are present + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "testing", + node: testNode, + }, + NodeAddress("127.0.0.2:9998"): ChatClient{ + username: "testing2", + node: testNode2, + }, + }, + expectedResultBool: false, + expectedResultAddr: NodeAddress(""), + }, + { // Test that a missing username is returned + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + node: testNode, + }, + }, + expectedResultBool: true, + expectedResultAddr: NodeAddress("127.0.0.1:9999"), + }, + { // Test that it still works if there are no clients connected + clientList: &ClientList{}, + expectedResultBool: false, + expectedResultAddr: NodeAddress(""), + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + resultAddr, resultBool := c.clientList.GetMissingUsername() + if resultAddr != c.expectedResultAddr { + t.Fatalf("Expected %v but got %v", c.expectedResultAddr, resultAddr) + } + if resultBool != c.expectedResultBool { + t.Fatalf("Expected %v but got %v", c.expectedResultBool, resultBool) + } + }) + } +} + +func TestAddUsernames(t *testing.T) { + testNode, err := smudge.CreateNodeByIP(net.ParseIP("127.0.0.1"), 9999) + CheckNoError(t, err) + + cases := []struct { + clientList *ClientList + usernames map[NodeAddress]string + expectedResult *ClientList + }{ + { // Simple case where the username has an entry we need + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + node: testNode, + }, + }, + usernames: map[NodeAddress]string{ + NodeAddress("127.0.0.1:9999"): "tester", + }, + expectedResult: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "tester", + node: testNode, + }, + }, + }, + { // Usernames have a new name for a client we know + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "tester", + node: testNode, + }, + }, + usernames: map[NodeAddress]string{ + NodeAddress("127.0.0.1:9999"): "new-tester", + }, + expectedResult: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "new-tester", + node: testNode, + }, + }, + }, + { // Usernames have an entry we don't need + clientList: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "tester", + node: testNode, + }, + }, + usernames: map[NodeAddress]string{ + NodeAddress("127.0.0.1:8888"): "tester", + }, + expectedResult: &ClientList{ + NodeAddress("127.0.0.1:9999"): ChatClient{ + username: "tester", + node: testNode, + }, + }, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + err := c.clientList.AddUsernames(c.usernames) + CheckNoError(t, err) + + if !reflect.DeepEqual(*c.clientList, *c.expectedResult) { + t.Fatalf("Expected %v but got %v", *c.expectedResult, *c.clientList) + } + }) + } +} diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..d1d6497 --- /dev/null +++ b/gui.go @@ -0,0 +1,234 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/jroimartin/gocui" +) + +// +// This file does not need to be edited! +// +// Please feel free to dig through this file if you are curious, however the +// contents are fully implemented, so no edits are required to arrive at a +// functional chat client. +// + +var ( + gui *gocui.Gui + + logsVisible = false +) + +func runGUI(cl ClientList) { + g, err := gocui.NewGui(gocui.OutputNormal) + if err != nil { + fmt.Println("Fatal GUI error: ", err) + os.Exit(1) + } + defer gui.Close() + + gui = g + + // Set GUI managers and key bindings + + gui.Cursor = true + gui.SetManagerFunc(layout) + + err = gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit) + if err != nil { + fmt.Println("Fatal GUI error: ", err) + os.Exit(1) + } + err = gui.SetKeybinding("", gocui.KeyCtrlL, gocui.ModNone, toggleLogs) + if err != nil { + fmt.Println("Fatal GUI error: ", err) + os.Exit(1) + } + err = gui.SetKeybinding("enter-text", gocui.KeyEnter, gocui.ModNone, readGuiMsg) + if err != nil { + fmt.Println("Fatal GUI error: ", err) + os.Exit(1) + } + + // We will update the client list after the GUI is initialized because we + // need to print the name of the initial client we connected to when + // creating Smudge. + // If this is skipped, we will not see the initial node connected until + // another node is added or removed. + printClientList(cl) + + if err := gui.MainLoop(); err != nil && err != gocui.ErrQuit { + fmt.Println("Fatal GUI error: ", err) + os.Exit(1) + } +} + +func layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + + helpY := maxY - 2 + chatX := 25 + if maxX < 25 { + // Support for small terminals + chatX = 15 + } + + chatMaxY := maxY - 6 + + if v, err := g.SetView("logs", 3, 2, maxX-3, chatMaxY-2); err != nil { + if err != gocui.ErrUnknownView { + return err + } + + v.Title = "Logs" + v.Autoscroll = true + v.Wrap = true + _, err = g.SetViewOnBottom("logs") + if err != nil { + return err + } + } + + if v, err := g.SetView("help", 0, helpY, maxX, maxY); err != nil { + if err != gocui.ErrUnknownView { + return err + } + + v.Frame = false + + fmt.Fprintf(v, "%s %s %s %s %s %s", + frameText("Ctrl-L"), "Toggle Logs", + frameText("Ctrl-C"), "Quit", + frameText("Enter"), "Send Message") + } + + if v, err := g.SetView("clients", 0, 0, chatX-1, helpY); err != nil { + if err != gocui.ErrUnknownView { + return err + } + + v.Title = "Clients" + } + + if v, err := g.SetView("messages", chatX, 0, maxX-1, chatMaxY); err != nil { + if err != gocui.ErrUnknownView { + return err + } + + v.Autoscroll = true + v.Wrap = true + v.Title = "Message-History" + } + + if v, err := g.SetView("enter-text", chatX, chatMaxY+1, maxX-1, helpY); err != nil { + if err != gocui.ErrUnknownView { + return err + } + if _, err := g.SetCurrentView("enter-text"); err != nil { + return err + } + + v.Title = "Send:" + v.Editable = true + v.Wrap = true + } + return nil +} + +func readGuiMsg(g *gocui.Gui, v *gocui.View) error { + msgText := v.Buffer() + v.Clear() + + if err := v.SetCursor(0, 0); err != nil { + return err + } + + return SendMessage(msgText) +} + +func printChatMessage(msg, sender string) { + gui.Update(func(g *gocui.Gui) error { + v, err := g.View("messages") + if err != nil { + return err + } + + fmt.Fprintf(v, "%s: %s\n", sender, msg) + return nil + }) +} + +// printClientList takes a ClientList and prints the username or NodeAddress for +// each entry into the clients section of the UI. +func printClientList(cl ClientList) { + if gui == nil { + return + } + + gui.Update(func(g *gocui.Gui) error { + v, err := g.View("clients") + if err != nil { + return err + } + + v.Clear() + err = v.SetCursor(0, 0) + if err != nil { + return err + } + + for _, client := range cl { + fmt.Fprintln(v, client.GetName()) + } + return nil + }) +} + +func printLogs(msg string) { + if gui == nil { + fmt.Println(msg) + } else { + gui.Update(func(g *gocui.Gui) error { + v, err := g.View("logs") + if err != nil { + return err + } + + fmt.Fprintln(v, msg) + return nil + }) + } +} + +func toggleLogs(g *gocui.Gui, v *gocui.View) error { + if logsVisible { + _, err := g.SetViewOnBottom("logs") + if err != nil { + return err + } + } else { + _, err := g.SetViewOnTop("logs") + if err != nil { + return err + } + } + + logsVisible = !logsVisible + return nil +} + +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + +func stringFormatBoth(fg, bg int, str string, args []string) string { + return fmt.Sprintf("\x1b[48;5;%dm\x1b[38;5;%d;%sm%s\x1b[0m", bg, fg, strings.Join(args, ";"), str) +} + +// Frame text with colors +func frameText(text string) string { + return stringFormatBoth(15, 0, text, []string{"1"}) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cb0da9c --- /dev/null +++ b/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/clockworksoul/smudge" +) + +const ( + // variables declared within "const" are constants in Go. The type is + // determined by the compiler. + // More info: https://blog.golang.org/constants + // https://gobyexample.com/constants + + // heartbeatMillis is used to configure how frequently the gossip protocol + // announces that it is still connected. No need to change this value. + heartbeatMillis = 500 +) + +var ( + // variables declared within "var" are mutable in Go. They can be explicitly + // initialized to a value, or if not set explicitly, default to the "empty + // value" for their type. + // More info: https://golang.org/doc/effective_go.html#variables + + // otherClient specifies the address of one running instance of the client. + // If omitted, this client will not initiate a connection to any existing + // client (i.e. this is the first client in a cluster) + otherClient string + + // listenPort is where this client will listen for other clients connecting. + // Must not be left empty. + listenPort int + + // localUsername is the friendly name we will present to other clients + // instead of our address. Must not be left empty. + localUsername string + + // localAddress is the NodeAddress which other Clients will use to reach us. + localAddress NodeAddress + + unittestMode = false +) + +// printDebug outputs a log message with the "DEBUG:" prefix. This function can +// be edited to easily enable and disable debugging logs without removing all +// the log lines in the codebase. +func printDebug(msg string, args ...interface{}) { + if !unittestMode { + printLogs(fmt.Sprintf("DEBUG: "+msg, args...)) + } +} + +// printInfo outputs a log message with the "INFO:" prefix. This function can +// be edited to easily enable and disable debugging logs without removing all +// the log lines in the codebase. +func printInfo(msg string, args ...interface{}) { + if !unittestMode { + printLogs(fmt.Sprintf("INFO: "+msg, args...)) + } +} + +// printError outputs a log message with the "ERROR:" prefix. This function can +// be edited to easily enable and disable error logs without removing all +// the log lines in the codebase. +func printError(msg string, args ...interface{}) { + if !unittestMode { + printLogs(fmt.Sprintf("ERROR: "+msg, args...)) + } +} + +// cacheLocalIP populates the value of the localAddress global variable. +// localAddress is used to determine if a broadcast was directed to us +// specifically, as it is the address which other clients use to communicate +// with us. +func cacheLocalIP() { + // this pattern of returning a result and an error is extremely prevalent in + // Go. Unlike many languages, exceptions (or in Go, Panics) are very rarely + // used. When a function returns an error, it Must be handled and the result + // disregarded. + // More info: https://blog.golang.org/error-handling-and-go + ip, err := smudge.GetLocalIP() + if err != nil { + fmt.Println("Unable to retrieve local IP", err) + os.Exit(1) + } + + localIP := ip.String() + + // listenPort, defined above, is a pointer to a number. Take a look at the + // return type of https://golang.org/pkg/flag/#Int + // Prepending our use of listenPort with a * will dereference the pointer, + // giving us a normal int. + localAddress = NodeAddress(fmt.Sprintf("%s:%d", localIP, listenPort)) +} + +// main is the entry point to the application. +func main() { + // Populate the flag variables at the top of this file with input from the + // user. Afterwards, determine if any required values were omitted. + flag.StringVar(&localUsername, "username", "", + "Friendly name for this client") + flag.IntVar(&listenPort, "listenport", 0, + "Port on which client listens for connections to other clients") + flag.StringVar(&otherClient, "client", "", + "Address of an existing client, if empty do not attempt to connect") + flag.Parse() + + if listenPort == 0 { + printError("Listen port is required") + flag.Usage() + os.Exit(1) + } else if localUsername == "" { + printError("Username is required") + flag.Usage() + os.Exit(1) + } + + // Now the user input is parsed, lets start configuring the gossip + // communication with other clients. These options were all grabbed from the + // example on the project homepage: https://github.com/clockworksoul/smudge#everything-in-one-place + + // Set configuration options + smudge.SetListenPort(listenPort) + smudge.SetHeartbeatMillis(heartbeatMillis) + + // Add the status listener + clientList := ClientList(make(map[NodeAddress]ChatClient)) + smudge.AddStatusListener(clientList) + + // Add the broadcast listener + messenger := Messenger{clients: clientList} + smudge.AddBroadcastListener(&messenger) + + // Only attempt to connect to another client if the address for one was + // provided. If not, the client will sit and wait until a client connects. + if otherClient != "" { + // Add a new remote node. To join an existing cluster you must + // add at least one of its healthy member nodes. + if node, err := smudge.CreateNodeByAddress(otherClient); err != nil { + printError("Failed to create a new node from addr: ", err) + os.Exit(1) + } else { + _, err = smudge.AddNode(node) + if err != nil { + printError("Failed to add a node to Smudge: ", err) + os.Exit(1) + } + } + } + + // The default logs from smudge just print to stdout and look messy in our + // fancy UI. + smudge.SetLogThreshold(smudge.LogOff) + + // Start the server! + // We will run the smudge server in a background go routine. This is similar + // to a new thread, however it is scheduled on real OS threads by the Go + // runtime. + // + // For the scope of this class, you can assume this function is + // running in the background. I encourage reading more about these later + // from a resource such as this: https://gobyexample.com/goroutines + printDebug("Starting Smudge...\n") + go smudge.Begin() + + cacheLocalIP() + + // Start the username watcher! + // Another go routine, both will be scheduled by the runtime and run as + // frequently as possible, depending on the number of threads given to the + // process. + go clientList.FillMissingInfo() + + // Start the gui! + // Notice that here we are not starting in a go routine. If we did then this + // thread (the main one) would reach the end of the main function, exit, and + // kill all the other go routines. We will hand-off control of the program + // to the UI which will listen for input from the user from here out. + printDebug("Starting the GUI...\n") + runGUI(clientList) +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..c919b7a --- /dev/null +++ b/message.go @@ -0,0 +1,189 @@ +package main + +import ( + "bytes" + "compress/zlib" + "encoding/json" + "fmt" + "strings" + + "github.com/clockworksoul/smudge" +) + +// messageType is an alias for int8. Whenever you see "messageType" in the code, +// think int8. Using this alias however allows us to increase code clarity and +// safety beyond simply passing meaningless numbers around. +type messageType int8 + +const ( + // Go does not have any native support for enum types, however there is an + // idiomatic way to accomplish the same goal. + // https://golang.org/ref/spec#Iota + + messageTypeChat messageType = iota + 1 + messageTypeUsernames + messageTypeUsernameReq +) + +// message represents the structure of the contents in a smudge.Broadcast. We +// can use the Type to determine what the Body will contain. +type message struct { + // The text in backticks after each field here is called a Struct Tag. + // The standard JSON marshaller uses the "json" struct tag to name fields. + // + // https://golang.org/pkg/reflect/#StructTag + // https://golang.org/pkg/encoding/json/#Marshal + // + // The JSON marshaller can only interact with "exported fields", therefore + // these field names must start with an uppercase letter. + + // Type informs us what to expect in the body of this message, and what + // action to take on it. + Type messageType `json:"type"` + + // Body contains the bulk of the message + Body string `json:"body"` + + // Usernames is filled only in a messageTypeUsernames. It contains a map + // of the address->username pairings know by the sending client. + Usernames map[NodeAddress]string `json:"usernames"` +} + +// Encode converts the message into a form which can be sent to other clients +// through Smudge (a []byte, pronounced byte slice). +// +// Notice how this func has an extra set of parens before the name? This is how +// methods are defined in Go. Unlike a function, methods are not in the global +// namespace and must be called on an instance of an object. That object being +// called on is in the first set of parens, and is called the receiver. This of +// this as "self" in python, or "this" in many other languages. +// More info: https://tour.golang.org/methods/1 +func (m *message) Encode() []byte { + // There is a lot happening here in a pretty small space. We first create an + // empty buffer in which we can temporarily store some bytes. This buffer + // implements the io.Writer interface, but we want to write compressed + // bytes, so we wrap that writer in the zlib writer which also implements + // the io.Writer interface. Finally we create a json encoder which will + // output the json format of m into the zlib writer. + var b bytes.Buffer + w := zlib.NewWriter(&b) + err := json.NewEncoder(w).Encode(m) + if err != nil { + printError("Failed to marshal a chat message to send: %s", err) + } + err = w.Close() // The bytes might not actually be written until closed (or flushed) + if err != nil { + printError("Failed to close the encoding writer: %s", err) + } + + // read out the contents from our temporary buffer, and return them + return b.Bytes() +} + +// Messenger contains all the messages which we know have been sent in the past. +// Additionally, it provides the interface for sending and receiving new +// messages. +// +// To send and receive messages we must implement the Broadcast functionality in +// the smudge library. +// +// https://godoc.org/github.com/clockworksoul/smudge#BroadcastListener +// https://godoc.org/github.com/clockworksoul/smudge#BroadcastString +type Messenger struct { + // clients is the list of all known and alive clients. Maintaining a + // reference here will allow us to update status based on broadcasts. + clients ClientList +} + +// Decode converts the byte slice received from a broadcast into a usable +// message. This is the reverse of the Encode() operation. +// +// Just like how the encode method above uses the json and zlib packages to +// json marshal and then compress a message, here we are doing the reverse. +func (m *message) Decode(data []byte) error { + bb := bytes.NewReader(data) + r, err := zlib.NewReader(bb) + if err != nil { + return fmt.Errorf("Failed to decompress message: %s", err) + } + + // msg is what the decompressed bytes will be un-json-marshalled into + err = json.NewDecoder(r).Decode(m) + if err != nil { + return fmt.Errorf("Failed to decode message: %s", err) + } + + return nil +} + +// OnBroadcast is the only method defined on the smudge.BroadcastListener +// interface. By implementing this method on the Messenger struct, that struct +// will satisfy the interface and we can register it with smudge. +// +// When another node in the gossip cluster sends a broadcast message, this +// function will be called. +func (m *Messenger) OnBroadcast(b *smudge.Broadcast) { + senderAddr := NodeAddress(b.Origin().Address()) + + printDebug("Received %d bytes", len(b.Bytes())) + var msg message + err := msg.Decode(b.Bytes()) + if err != nil { + printError("Failed to receive message from %s: %s", senderAddr, err) + return + } + + switch msg.Type { + case messageTypeUsernames: + printDebug("Received a broadcast containing usernames") + + if msg.Usernames == nil || len(msg.Usernames) == 0 { + printError("Received an empty username list") + return + } + + err := m.clients.AddUsernames(msg.Usernames) + if err != nil { + printError("Failed to process received usernames: %s", err) + } + case messageTypeUsernameReq: + printDebug("Received a broadcast requesting %s send usernames, my localAddress is %s", msg.Body, localAddress) + + if msg.Body == string(localAddress) { + // The request targeted us... + // Let's send all the usernames we know about to minimize requests + // for a new client. + err := m.clients.BroadcastUsernames() + if err == nil { + printInfo("Successfully broadcast usernames to the group") + } else { + printError("Tried to broadcast usernames but failed: %s", err) + } + } + case messageTypeChat: + // Received a chat message + + sender := m.clients[senderAddr] + printChatMessage(msg.Body, sender.GetName()) + } +} + +// SendMessage takes a chat message to be sent and broadcasts it to the cluster +// and posts to the local chat view. +func SendMessage(text string) error { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + + // First let's make the message show up in our own chat history + printChatMessage(text, localUsername) + + // Now we can send it on to others + msg := message{ + Type: messageTypeChat, + Body: text, + } + + return smudge.BroadcastBytes(msg.Encode()) +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..b2185ca --- /dev/null +++ b/message_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "reflect" + "testing" +) + +func TestMessageEncodeDecode(t *testing.T) { + var cases = []struct { + message message + expectedResult []byte + }{ + { // When all the fields are empty + message: message{}, + expectedResult: []byte{0x78, 0x9c, 0xaa, 0x56, 0x2a, 0xa9, 0x2c, 0x48, 0x55, 0xb2, 0x32, 0xd0, 0x51, 0x4a, 0xca, 0x4f, 0xa9, 0x54, 0xb2, 0x52, 0x52, 0xd2, 0x51, 0x2a, 0x2d, 0x4e, 0x2d, 0xca, 0x4b, 0xcc, 0x4d, 0x2d, 0x56, 0xb2, 0xca, 0x2b, 0xcd, 0xc9, 0xa9, 0xe5, 0x2, 0x4, 0x0, 0x0, 0xff, 0xff, 0xe8, 0xed, 0xc, 0x47}, + }, + { // When the message is a chat + message: message{ + Type: messageTypeChat, + Body: "Hello World", + }, + expectedResult: []byte{0x78, 0x9c, 0xaa, 0x56, 0x2a, 0xa9, 0x2c, 0x48, 0x55, 0xb2, 0x32, 0xd4, 0x51, 0x4a, 0xca, 0x4f, 0xa9, 0x54, 0xb2, 0x52, 0xf2, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x8, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0xd2, 0x51, 0x2a, 0x2d, 0x4e, 0x2d, 0xca, 0x4b, 0xcc, 0x4d, 0x2d, 0x56, 0xb2, 0xca, 0x2b, 0xcd, 0xc9, 0xa9, 0xe5, 0x2, 0x4, 0x0, 0x0, 0xff, 0xff, 0x8e, 0xb7, 0x10, 0x64}, + }, + { // When the message is a username update + message: message{ + Type: messageTypeUsernames, + Usernames: map[NodeAddress]string{ + NodeAddress("192.168.0.10:9999"): "Server-1", + NodeAddress("172.16.0.17:9999"): "Server-2", + }, + }, + expectedResult: []byte{0x78, 0x9c, 0x5c, 0xc9, 0x3d, 0xe, 0x85, 0x20, 0xc, 0x7, 0xf0, 0xfd, 0x1d, 0xe3, 0x3f, 0xf3, 0x8, 0x65, 0x10, 0xdb, 0x6b, 0x78, 0x2, 0x8d, 0x1d, 0xfd, 0x48, 0x51, 0x13, 0x42, 0xb8, 0xbb, 0x61, 0x75, 0xfe, 0x55, 0x5c, 0xe5, 0x54, 0x48, 0x74, 0x58, 0x8e, 0xb5, 0x40, 0x0, 0x87, 0x3b, 0xab, 0xed, 0xf3, 0xa6, 0x19, 0x52, 0x41, 0x29, 0x7a, 0x1a, 0x7c, 0xf0, 0x94, 0x84, 0x99, 0x19, 0x82, 0x49, 0xed, 0x51, 0xfb, 0x47, 0x38, 0x10, 0x77, 0x1d, 0x3b, 0x87, 0xf, 0x13, 0x5a, 0xfb, 0xbd, 0x1, 0x0, 0x0, 0xff, 0xff, 0x9, 0x36, 0x19, 0x96}, + }, + { // When the message is a username request + message: message{ + Type: messageTypeUsernameReq, + Body: "192.168.0.32:9876", + }, + expectedResult: []byte{0x78, 0x9c, 0xaa, 0x56, 0x2a, 0xa9, 0x2c, 0x48, 0x55, 0xb2, 0x32, 0xd6, 0x51, 0x4a, 0xca, 0x4f, 0xa9, 0x54, 0xb2, 0x52, 0x32, 0xb4, 0x34, 0xd2, 0x33, 0x34, 0xb3, 0xd0, 0x33, 0xd0, 0x33, 0x36, 0xb2, 0xb2, 0xb4, 0x30, 0x37, 0x53, 0xd2, 0x51, 0x2a, 0x2d, 0x4e, 0x2d, 0xca, 0x4b, 0xcc, 0x4d, 0x2d, 0x56, 0xb2, 0xca, 0x2b, 0xcd, 0xc9, 0xa9, 0xe5, 0x2, 0x4, 0x0, 0x0, 0xff, 0xff, 0xa8, 0xb6, 0xf, 0xbc}, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("Test case %d", i), func(t *testing.T) { + result := c.message.Encode() + if !reflect.DeepEqual(result, c.expectedResult) { + t.Fatalf("Encode - Expected %#v but got %#v", c.expectedResult, result) + } + + var newMsg message + err := newMsg.Decode(result) + CheckNoError(t, err) + if !reflect.DeepEqual(newMsg, c.message) { + t.Fatalf("Decode - Expected %#v but got %#v", c.message, newMsg) + } + }) + } +}