A Multi-Echo Chat Server Written In Go
Table of Contents
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:
- 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.
- 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:
}
}
- 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 thefor
loop, effectively ending operations. Remember that when this happens the TCPServer has ended already and therefore all client connections are closed. - 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.
- 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.