Incomplete Example Implementation
This commit is contained in:
parent
20c5cef1cc
commit
40c95a4687
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
beginning-go
|
||||||
|
|
@ -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
169
client.go
Normal 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
385
client_test.go
Normal 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
234
gui.go
Normal 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
184
main.go
Normal 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
189
message.go
Normal 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
59
message_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user