1
0

Incomplete Example Implementation

This commit is contained in:
Tony Grosinger 2017-10-20 16:51:30 -07:00
parent 20c5cef1cc
commit 40c95a4687
8 changed files with 1231 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
beginning-go

View File

@ -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

169
client.go Normal file
View File

@ -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 ""
}

385
client_test.go Normal file
View File

@ -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)
}
})
}
}

234
gui.go Normal file
View File

@ -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"})
}

184
main.go Normal file
View File

@ -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)
}

189
message.go Normal file
View File

@ -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())
}

59
message_test.go Normal file
View File

@ -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)
}
})
}
}