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 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 If your program does not compile it will show those errors and not run the
tests. 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 ## Fill in the Blanks
Read through the code, run the unit tests, implement the missing parts. You 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)
}
})
}
}