Skip to main content

A Multi-Echo Chat Server Written In Go

·12 mins

I wrote a chat service program as part of my Golearn project. The program implements a console-based chat service where any lines written by a client are echoed to all other clients. It uses a TCPServer library that I wrote from scratch to manage the connections. Source code is available from my chat service Gitlab repository.

This post documents the chat service. For a description of the TCPServer library see my post A Library to Manage TCP Connections Written In Go.

The Chat Service #

The binary for the chat service is chat. To start the chat service use the following command:

bin/chat -p 9090

The -p option specifies the TCP port the chat service must listen on for client connections. The program selects a random port if none is specified.

Stopping the Chat Service #

The chat service runs until you enter Ctrl-C on the keyboard. More generally, the chat service runs until it receives one of the os.Interrupt, os.Kill, or syscall.SIGINT system signals.

Before closing, the chat service displays a table reporting for each client the number of messages echoed, the number of messages sent while muted (see below), and whether the client used the /quit command (see below) to exit or not (the latter happens when the chat service closes a client’s connection while exiting). Here is a sample summary table:

                Messages        Messages
Client ID       Echoed          Muted           Used /quit
127.0.0.1:51012 5               0               true
127.0.0.1:51038 6               0               false
127.0.0.1:51046 2               3               false

The Log File #

The chat service logs messages to the file chat.log in the local directory where the chat service starts. The logging facility appends new messages if the log file exists already. Here is a sample log file:

2018/08/15 12:55:33 Chat v1.0
2018/08/15 12:55:33 tcpserver.go:52: tcp server listening on address 0.0.0.0:9090
2018/08/15 12:55:37 tcpserver.go:108: new connection 127.0.0.1:56144
2018/08/15 12:55:41 tcpserver.go:108: new connection 127.0.0.1:56154
2018/08/15 12:55:42 tcpserver.go:108: new connection 127.0.0.1:56160
2018/08/15 13:00:17 connmgr.go:65: closing connection: 127.0.0.1:56144
2018/08/15 13:00:19 tcpserver.go:108: new connection 127.0.0.1:56762
2018/08/15 13:00:31 connmgr.go:65: closing connection: 127.0.0.1:56762
2018/08/15 13:08:07 chat.go:67: interrupt signal received
2018/08/15 13:08:07 tcpserver.go:147: tcp server is shutting down
2018/08/15 13:08:07 connmgr.go:65: closing connection: 127.0.0.1:56154
2018/08/15 13:08:07 connmgr.go:65: closing connection: 127.0.0.1:56160
2018/08/15 13:08:07 multiecho.go:93: multiecho server is shutting down

The Clients #

I tested the chat service using the telnet command as a client. Note that telnet uses the two-character sequence CR-LF by default to mark the end-of-line. The chat service uses this fact to avoid echoing empty lines.

Echoing Messages #

The following is a sample three-client session:

$ telnet localhost 9090
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
multiecho server v1.0
client id: 127.0.0.1:53362
one
(127.0.0.1:53354) two
(127.0.0.1:53340) three

───────────────────────────────────────────────────
$ telnet localhost 9090
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
multiecho server v1.0
client id: 127.0.0.1:53354
(127.0.0.1:53362) one
two
(127.0.0.1:53340) three

───────────────────────────────────────────────────
$ telnet localhost 9090
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
multiecho server v1.0
client id: 127.0.0.1:53340
(127.0.0.1:53362) one
(127.0.0.1:53354) two
three

In this example, the first client, with id 127.0.0.1:53362, sends the message one, then client 127.0.0.1:53354 sends the message two, and finally client 127.0.0.1:53340 sends the message three. The chat service echoes these messages prefixed with the id of the source client in parenthesis.

Muting and Unmutting a Client #

Clients can instruct the chat service to not echo their messages by sending the /mute command. They can send the /unmute command to resume normal echoing. When a client mutes its channel, the messages it writes are not sent to other clients. The chat service discards those messages but keeps track of their number for accounting purposes.

The following is a sample session where the second client, 127.0.0.1:56154, mutes its session before sending the I’m mute message to the server.

multiecho server v1.0
client id: 127.0.0.1:56160
one
(127.0.0.1:56154) two
(127.0.0.1:56144) three

───────────────────────────────────────────────────
multiecho server v1.0
client id: 127.0.0.1:56154
(127.0.0.1:56160) one
/mute
I'm mute
/unmute
two
(127.0.0.1:56144) three

───────────────────────────────────────────────────
multiecho server v1.0
client id: 127.0.0.1:56144
(127.0.0.1:56160) one
(127.0.0.1:56154) two
three

Closing a Session #

Clients can close their own sessions by sending the /quit command to the chat service, as illustrated in the following example:

multiecho server v1.0
client id: 127.0.0.1:56762
one
/quit
Connection closed by foreign host.

The Chat Server Program #

The chat program is structured into two source files, chat.go that implements the main thread, and multiecho.go that implements the message echoing functionality and runs as a Go routine.

The Main Thread #

The following code shows the structure of chat.go, which I describe in the following sections.

import{"context" ...}

type jobType struct {
	address string
}

var job jobType

// init() parses the command line and sets up the global `job` variable with the
// captured details.
func init() {...}

// SIGTERM signal handler
func sigHandler(signals chan os.Signal, cancel context.CancelFunc) {
	<-signals
	log.Print("interrupt signal received")
	cancel()
}

// confLogging configures the chat logging service.
// Log entries are recorded on the local file `chat.log`
func confLogging() *os.File {...}

func main() {
	// configure logging
	defer confLogging().Close()

    // initialize server contexts
    
    // signal handler
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, os.Interrupt, os.Kill, syscall.SIGINT)
	go sigHandler(sigs, commCancel)

    // create and start servers

    // wait for servers to finish
}

The program defines a job global variable to describe the chat server, in this version it holds only the TCP listening port number. The init() function, which executes before the main code, captures the port number from the command line and exits the program if an invalid number or unknown parameters are found.

The signal handler is next. It listens for a set of signals to happen and then invokes the cancel() function which the handler receives as a parameter. As explained below, this cancel() function happens to be a function that the TCPServer uses to initiate its shutdown.

The confLogging() function that follows configures logging to write to the chat.log file. The function returns the log’s file handle that is used to close the log file when the program ends. This is done by the defer statement in the main() function.

The application then creates two servers which run as go routines. The multiEcho server handles the application-level message exchanges, that is, the communications among the clients, and the TCPServer manages the lower, socket-level, communications.

The main thread starts with the logging defer statement, then creates server context variables, initializes and launches the two servers, and finally waits for the servers to exit.

Server Contexts #

The two servers take a Go Context object as part of their initialization parameters. Go contexts are hierarchical objects that the programmer can enrich with relevant information as the contexts move deeper across API boundaries and between processes.

The main thread provides a context to each server with the following information:

  1. A Go Context Cancel channel which the main thread uses to signal the server that it has to start closing procedures. This is the channel that the main thread also passes as a parameter to the signal handler described above.
  2. A Go Wait Group which the server uses to signal the main thread when the server finishes its work.

The context hierarchy to support these two elements is the following:

context.Background() --> context.WithValue() --> context.withCancel()

contex.Bacground() is a Go context primitive used as the root of the hierarchy. I use context.WithValue() to create a context that holds the wait group variable (item 2 above) as a key/value pair, the key being the “wg” string. Finally, I use context.WithCancel() to add the Done channel (item 1 above). The order of the last two contexts does not matter, it could be reversed.

In the following code from the main thread, line 78 creates the “wg” key (I use it in both contexts, for the multiEcho and TCPServer servers). Lines 79 and 80 create the context for the multiEcho server, and lines 81 and 82 do the same for the TCPServer server.

77// app and comm contexts
78key := tcpserver.ServerCtx("wg")
79ctx0 := context.WithValue(context.Background(), key, &wg)
80ctx, cancel := context.WithCancel(ctx0)
81commCtx0 := context.WithValue(context.Background(), key, &commWG)
82commCtx, commCancel := context.WithCancel(commCtx0)

As soon as the servers are created, they look at their context, extract the wait group and the Done channel, and then get to work.

The Signal Handler #

The main thread next launches the signal handler as a Go routine:

84// signal handler
85sigs := make(chan os.Signal, 1)
86signal.Notify(sigs, os.Interrupt, os.Kill, syscall.SIGINT)
87go sigHandler(sigs, commCancel)

It creates the signal channel at line 85, associates the system signals to capture at line 86, and launches the Go routine at line 87. When launching the signal handler routine, the main thread passes the TCPServer commCancel channel as a parameter. In this way, when the system triggers any of the selected signals, such as when pressing Ctrl-C on the keyboard, the signal handler can initiate the shutdown by signaling the TCPServer as explained before.

Starting the Servers #

The main thread creates and initializes the servers using the following code:

 89server := new(tcpserver.TCPServer)
 90server.Address = job.address
 91channels, err := server.Init()
 92if err != nil {
 93	log.Fatal(err)
 94}
 95
 96// the multiecho app
 97wg.Add(1)
 98go multiEcho(ctx, channels)
 99
100// the tcp server.
101// The signal handler triggers the server's shutdown sequence
102commWG.Add(1)
103go server.Start(commCtx)
104commWG.Wait() // tcp server is finished

It creates the TCPServer server in lines 89 to 94. An important element is the channels object that the TCPServer returns when initialized. As explained later, channels is a structure that tells the application code which channels to use for listening and sending messages, and which channels to use for control. Note that these channels become active only after you invoke the start() method on the TCPServer.

Lines 97 and 98 deal with the multiEcho server. Line 97 increments the corresponding waiting group and line 98 launches the server. The server takes its context and the channels structure as initialization parameters.

Lines 102 to 104 deal with the TCPServer server created before. Line 102 increments the corresponding waiting group and line 98 starts the server. Upon return from this line, the server is ready listening for incoming and outgoing messages. The main thread then waits at line 104 for the server to finish.

Once the TCPServer exits, the main thread executes its final lines.

106// close the multiecho app now
107	cancel()
108	wg.Wait()

At line 107 it notifies the multiEcho server that it is time to close by signaling on the server’s close channel. It then waits at line 108 for the server to finish.

The MultiEcho Server #

The multiEcho server is implemented in the file multiecho.go. It runs as a Go routine which the main thread starts at line 98. At its core there is a forever for loop waiting for activity in one of three different channels:

L:
	for {
	    select {
	    // app is shutting down
	    case <-ctx.Done():
		    log.Print("multiecho server is shutting down")
		    printStats(clients)
		    break L

        // incoming ctrl message from tcp server
	    case ctrl := <-channels.CtrlIn:

        // incoming data from client
	    case msg := <-channels.DataIn:
	    }
    }
  1. The Go context Done channel. Activity on this channel means that the main thread is requesting the multiEcho server to end operations. The server then prints some statistics and breaks the for loop, effectively ending operations. Remember that when this happens the TCPServer has ended already and therefore all client connections are closed.
  2. The TCPServer incoming control channel. A message in this channel indicates that a new client is available (someone initiated a new Telnet connection) or that an existing client closed its connection. The server maintains a list of clients which it updates accordingly.
  3. The TCPServer incoming data channel. Messages in this channel come from clients. These messages may be chat control messages, if they start with the / character, or messages to be echoed (provided that the source client is not muted). The server analyzes the incoming messages and acts upon them accordingly, either processing chat-level commands, or echoing them as required.

The multiEcho server also uses two outgoing channels to communicate with the TCPServer, one to send a control message requesting the TCPServer to close a client connection (in response to the client sending a chat /quit message), and another to request delivery of a message to a client (used to send a welcome message to new clients and when echoing messages). The following code shows the usage of these two outgoing channels in the forever for loop:

117// incoming data from client
118case msg := <-channels.DataIn:
119	switch getCommand(msg) {
120	case "quit":
121		ctrl := new(tcpserver.ClientCtrl)
122		ctrl.ID = msg.ID
123		ctrl.Enable = false
124		channels.CtrlOut <- ctrl
125	case "mute":
126		clients[msg.ID].mute = true
127	case "unmute":
128		clients[msg.ID].mute = false
129	default:
130		if clients[msg.ID].mute {
131			clients[msg.ID].msgMuted++
132		} else if len(msg.Data) > 2 {
133			echoMsg(msg)
134		}

The server sends a control message in line 124 when processing the chat’s /quit command. The message it sends is a structure defined in the TCPServer; it has an Enable field which when set to false tells the TCPServer to close the connection.

The server echoes messages in line 133 using the echoMsg() function which is turn makes use of the following function:

79sendMsg := func(id string, data []byte) {
80	msgOut := new(tcpserver.ClientData)
81	msgOut.ID = id
82	msgOut.Data = data
83	channels.DataOut <- msgOut
84}

The chat message to be sent is part of a structure defined in the TCPServer which includes the identity of the target client. The TCPServer identifies clients using their IP address and TCP port number.

The TCP Layer #

The details of the TCP layer are of no concern to the application code. The multiEcho server only needs to know about the interface that the TCPServer provides, which is captured in the channels structure. This structure provides two control channels and two data channels, as follows:

  • The TCP layer uses the ctrlIn control channel to notify the multiEcho server when new clients connect and when existing clients close their connections.

  • The multiEcho server uses the ctrlOut control channel to request the closing of a client connection. This is done in response to the client sending the /quit command.

  • The TCP layer uses the dataIn data channel to push incoming client messages to the multiEcho server. Each message on this channel uniquely identifies the source client by its IP:port address.

  • The multiEcho server uses the dataOut data channel to send messages to the clients. Each message on this channel uniquely identifies the target client by its IP:port address.

The TCPServer does not have any knowledge of the high-level application, the multi-echo server in this case. The data and control communication channels are generic enough to enable the implementation of any application-level logic.

Summary #

In this post I discuss my work building an echo server from scratch. The first section addresses the usage of the server from the end user point of view. Then I describe the Go program which includes a main thread coded in the file chat.go and the multi-echo service which is coded in the file multiecho.go and runs as a Go routine.

The multi-echo routine uses Go channels provided by the TCPServer to send and receive client messages and to exchange control commands. By using these channels the multi-echo logic is isolated from the low-level details of the TCP socket connections.