In this assignment you will write a simple chat server and client that communicate via Remote Procedure Calls (RPCs). You will implement data serialization and networking from scratch.
The client and server will exist together in a single binary that will act in either role depending on the command-line arguments it is given:
./rpcchat 3410
(this launches the server and has it listen on port 3410). Or
./rpcchat krypton.cs.utahtech.edu:3410 alice
(this connects to krypton.cs.utahtech.edu:3410 and logs in as user “alice”). Or
./rpcchat :3410 bob
(this connects to localhost on port 3410 and logs in as user “bob”).
When you log in through the client you can type various commands:
tell <user> some message
: This sends “some message” to a
specific user.
say some other message
: This sends “some other message” to all
users.
list
: This lists all users currently logged in.
quit
: this logs you out.
help
: list all of the recognized commands.
shutdown
: this shuts down the server.
In addition to responding to your commands, the client will fetch messages being sent to you and display them on the terminal.
You can download and test a working binary in Linux:
curl -sO https://www.cs.utahtech.edu/cs/3410/rpcchat
chmod 755 rpcchat
The server sits and waits for incoming RPC requests. It keeps a queue of messages for each user currently logged in, delivering them to the client when the client requests them.
It provides the following functions over RPC to clients:
Register: This message is used when a new client logs in. The request includes a username, and the response is empty. The server should deliver a message that the user has logged in to the queue of everyone else currently logged in.
List: This message is used to gather a list of all users currently logged in. The request has no parameters, and the response is a list of strings, one per user.
CheckMessages: This message gathers all pending messages for a client. The request is a string (the username, as used to register earlier), and the result is a slice of strings. Each message is a string in the list.
Tell: This message sends a message to a specific user. The request is a type containing the following:
User: A string with the name of the user sending the message.
Target: A string with the name of the user to send the message to.
Message: A string.
The response is empty. If the target user does not exist, the
server delivers a message to the sender (adding it to his/her
queue) but should otherwise behave as though it succeeded. The
message should be formatted (by the server) as "<Sender> tells
you <Message>"
.
Say: This message sends a message to all users currently logged in. The request is a record:
User: A string with the name of the user sending the message.
Message: A string.
The response is empty. Note that the message should be delivered
to all users, including the sender. It should be formatted (by
the server) as "<Sender> says <Message>"
.
Logout: This message logs a user out. All queued messages are discarded, and the queue for that user is destroyed. The request is a username, the response is empty. The server should also deliver a message saying that the user logged out to the queue of every other user currently logged in.
Shutdown: This message shuts the server down.
The server maintains a queue of messages for every user currently logged in. A user queue is emptied after a CheckMessages transaction when all of the messages have been returned to that user.
The core server functionality can be implemented with the following:
const (
MsgRegister = iota
MsgList
MsgCheckMessages
MsgTell
MsgSay
MsgQuit
MsgShutdown
)
var mutex sync.Mutex
var messages map[string][]string
var shutdown chan struct{}
func server(listenAddress string) {
shutdown = make(chan struct{})
messages = make(map[string][]string)
// set up network listen and accept loop here
// to receive RPC requests and dispatch each
// in its own goroutine
// ...
// wait for a shutdown request
<-shutdown
time.Sleep(100 * time.Millisecond)
}
func dispatch(conn net.Conn) {
// handle a single incomming request:
// 1. Read the length (uint16)
// 2. Read the entire message into a []byte
// 3. From the message, parse the message type (uint16)
// 4. Call the appropriate server stub, giving it the
// remainder of the request []byte and collecting
// the response []byte
// 5. Write the message length (uint16)
// 6. Write the message []byte
// 7. Close the connection
//
// On any error, be sure to close the connection, log a
// message, and return (a request error should not kill
// the entire server)
}
func Register(user string) error {
if len(user) < 1 || len(user) > 20 {
return fmt.Errorf("Register: user must be between 1 and 20 letters")
}
for _, r := range user {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return fmt.Errorf("Register: user must only contain letters and digits")
}
}
mutex.Lock()
defer mutex.Unlock()
msg := fmt.Sprintf("*** %s has logged in", user)
log.Printf(msg)
for target, queue := range messages {
messages[target] = append(queue, msg)
}
messages[user] = nil
return nil
}
func List() []string {
mutex.Lock()
defer mutex.Unlock()
var users []string
for target := range messages {
users = append(users, target)
}
sort.Strings(users)
return users
}
func CheckMessages(user string) []string {
mutex.Lock()
defer mutex.Unlock()
if queue, present := messages[user]; present {
messages[user] = nil
return queue
} else {
return []string{"*** You are not logged in, " + user}
}
}
func Tell(user, target, message string) {
mutex.Lock()
defer mutex.Unlock()
msg := fmt.Sprintf("%s tells you %s", user, message)
if queue, present := messages[target]; present {
messages[target] = append(queue, msg)
} else if queue, present := messages[user]; present {
messages[user] = append(queue, "*** No such user: "+target)
}
}
func Say(user, message string) {
mutex.Lock()
defer mutex.Unlock()
msg := fmt.Sprintf("%s says %s", user, message)
for target, queue := range messages {
messages[target] = append(queue, msg)
}
}
func Quit(user string) {
mutex.Lock()
defer mutex.Unlock()
msg := fmt.Sprintf("*** %s has logged out", user)
log.Print(msg)
for target, queue := range messages {
messages[target] = append(queue, msg)
}
delete(messages, user)
}
func Shutdown() {
shutdown <- struct{}{}
}
The server functionality is exposed as a series of ordinary functions. Your job is to make them accessible via RPC and to write the client that connects to them.
The client connects to the server with a Register message. It then enters a loop:
Prompt the user and wait for them to type something. Use a
buffered reader to get input from the keyboard. See the bufio
package documenation for an example:
Parse the command. If it is a recognized command, form a request
and send it to the server. The strings
package has many useful
functions to help with parsing the input.
tell
and say
send their respective messages to the
server and do not print out any additional message to the
user.
list
prints out a nicely formatted list of users as
reported by the server.
quit
tells the user it is quitting, logs the user out, and
quits.
shutdown
shuts down the server and quits.
help
displays a list of recognized commands.
For an empty line, do not output anything in response to the command.
For an unrecognized command, print an error message and then print the help message.
In addition to the main loop that is handling keyboard input, launch another goroutine to call CheckMessages and display the result to the console.
CheckMessages on the server returns immediately. The client
calls it, displays the results, sleeps for a second or so
(using time.Sleep
), then repeats. This is called polling.
The command-line interface is straightforward to set up:
func main() {
log.SetFlags(log.Ltime)
var listenAddress string
var serverAddress string
var username string
switch len(os.Args) {
case 2:
listenAddress = net.JoinHostPort("", os.Args[1])
case 3:
serverAddress = os.Args[1]
if strings.HasPrefix(serverAddress, ":") {
serverAddress = "localhost" + serverAddress
}
username = strings.TrimSpace(os.Args[2])
if username == "" {
log.Fatal("empty user name")
}
default:
log.Fatalf("Usage: %s <port> OR %s <server> <user>",
os.Args[0], os.Args[0])
}
if len(listenAddress) > 0 {
server(listenAddress)
} else {
client(serverAddress, username)
}
}
It calls the function server
to kick off a server (and the whole
process shuts down when server
returns) or client
to run a
client instance.
The core of this assignment is to implement the RPC mechanism. Each server function needs the following:
A client stub. This is a function that the client code can call directly with almost the same parameters as the server function. It connects to the server over the network, serializes and transmits the arguments, receives and deserializes the return values, and returns to the client. For simplicity, you will establish a fresh connection for each RPC call and close it after the call.
Important note: every client stub returns an error value, even if the original server function does not (if it already does then nothing needs to change). Any RPC call can have an error related to the network, etc., even for an operation like “list” that will always succeed on its own.
A server listener. This is a loop running on the server that
receives incomming network connections (see Listen
and
Accept
in the net
package). For each connection is launches
a goroutine to handle the request. There is only a single
listener for all incoming RPC message types.
A request dispatcher. This part reads enough of an incoming request to figure out which RPC message it is and then dispatches it to the server stub for that function.
A server stub. Each RPC function has a server side that deserializes the arguments, calls the ordinary server function, serializes and transmits the return values, and then closes the connection.
Important note: this is where the error return value needs to be added if it is not already present for a given RPC. If there is an error reading the request or parsing it, the correct behavior is to send back a valid response with the error field containing a message about what went wrong.
The ordinary server function (provided above).
The server stub always transmits an error message at the end of each response. This is just a string and an empty string means no error occurred. If the server function already has an error return value then nothing changes, but if it does not then it should be added in both the server and client stubs. For server functions with no return value, this is especially useful as it gives the client stub a way to know that the server function has finished running successfully. Without it, the client only knows that it transmitted the request parameters. It does not know for sure if they were received and processed okay.
With this infrastructure in place, calling a server function over RPC is very similar to calling it a regular function on the client with a few important differences:
The client stub needs the server address as an extra parameter so it knows where to connect.
The client stub always returns an error value, even if the ordinary server function cannot return an error. RPCs can have errors introduced by the network, a failed server, etc. Clients should always think about the additional failures that can be introduced in a distributed environment.
The client and server cannot share memory, there is added latency in an RPC.
As an example, consider the CheckMessages function:
func CheckMessages(user string) []string { ... }
The client stub for this function should look like:
func CheckMessagesRPC(server string, user string) ([]string, error) { ... }
It connects to the server, writes the serialized request (see details below), waits for and deserializes the response, and returns. Note that the stub includes an error return value even though the original server function does not.
The server stub looks like:
func CheckMessagesServerStub(request []byte) []byte { ... }
The server dispatch
function, which receives each incoming
request, reads the request data from the network, parses the message
type, and calls the server stub with the remaining request data (the
message length and message type have already been parsed). It parses
the arguments and then calls the original server function,
serializes the results, and returns the serialized response.
We will use a straightforward scheme for serializing parameters and return values. You will need some primitives:
WriteUint16(buf []byte, n uint16) []byte
: writes a 16-bit
value as two bytes in network byte order to a []byte
,
returning the new []byte
. It should append the bytes to the
existing buffer and return the resulting slice. Note that a more
complete RPC mechanism would need the ability to write integers
of various sizes, but we will make do with a single integer size
for simplicity.
ReadUint16(buf []byte) (uint16, []byte, error)
: reads a 16-bit
value from a []byte
as two bytes in network byte order. It
returns the value, the remaining unparsed bytes, and an error
(if something went wrong while parsing, like the input was too
short).
WriteString(buf []byte, s string) []byte
: writes a string to a
[]byte
and returns the updated slice. It should write the
length of the string first as a uint16
value (using
WriteUint16
) and then the bytes of the string itself.
ReadString(buf []byte) (string, []byte, error)
: reads a string
from a []byte
as written by WriteString
. It returns the
string, the remaining slice bytes, and an error if anything went
wrong.
WriteStringSlice(buf []byte, s []string) []byte
: writes a
slice of strings. It first writes the number of elements in the
slice using WriteUint16
, then it uses WriteString
to write
each element in order.
ReadStringSlice(buf []byte) ([]string, []byte, error)
: reads a
slice of strings. It reads the length of the slice first
followed by the indicated number of strings.
Before writing any parameters, the client stub should first write the
message type using WriteUint16
. The message types are defined as
constants in the starter code given above.
Here is an example of the sequence of events for a single CheckMessages call. First the client side:
The client calls the CheckMessagesRPC
stub in a background
goroutine that fires every second or so.
CheckMessagesRPC
serializes the request, starting with the
message type as a uint16
and followed by the parameters (a
single string in this case) in order. Next it calls a helper
(how I recommend implementing it) called SendAndReceive
the
takes a server address and a message (a []byte
) and:
uint16
uint16
[]byte
This helper should return an error value as well in case anything goes wrong. There are lots of places where errors could occur, so this code should be pretty pedantic.
CheckMessagesRPC
deserializes the response including the error
value added by the server stub. It returns the []string
and
error
values like a normal function.
Note that every RPC has an error return value, regardless of whether or not the original server function can return an error. This means that every RPC call with have some return value (an empty error message at minimum).
On the server side:
The listen loop accepts an incoming connection. It launches a goroutine to handle it and calls the dispatcher function.
The dispatcher function is a convenient place to make sure every
connection is closed properly. It starts with defer conn.Close()
so the individual server stubs can just return when they are
finished and know that the connection will be closed properly.
The dispatcher reads the message length by first reading 2 bytes
from the network and then deserializing them using ReadUint16
.
Then it allocates a slice with the precise number of bytes to
hold the request message and reads it from the network.
io.ReadFull
is a nice helper for this.
The dispatcher then parses the message type from the request
message using ReadUint16
and calls the appropriate server
stub based on the message type. It gives the server stub the
remaining request message bytes and expects a slice of response
message bytes back.
The CheckMessagesServerStub
decodes the parameters. In this
case it reads the username using ReadString
.
It calls the original CheckMessages
function and captures the
return value (a slice of strings).
It serializes the result value by calling WriteStringSlice
.
It writes an empty error message to the response message using
WriteString
.
The server stub returns the response message bytes to the dispatched.
The dispatcher writes the length of the response message to the connection.
The dispatcher writes the response itself to the connection.
Note that an io.Writer
is required to write all bytes given in
the request (unlike an io.Reader
, where it is correct behavior
to write only some of the bytes) so there is no io.WriteFull
or anything like it.
The dispatcher closes the connection.
You must implement a similar flow for each of the server RPC functions. To check if your implementation is correct, try connecting your client to the example server and the example client to your server. They should be interchangable since they implement the same API.