Coding simple tcp chat on GO

04/01/2020 tcp server chat


Let's start server itself:

l, err := net.Listen("tcp", "localhost:9090")
if err != nil {
	return
}

defer l.Close()

Server listens 9090 tcp port on localhost. It is normal practice to launch services binded to localhost.

Next, let's start to accept connections:

conn, err := l.Accept()
if err != nil {
	return
}

No error handling yet. I'll focus on it later.

In chat we need to read messages sent by clients.

Number of clients is generally more than one, and reading is blocking operation. Having single program thread is not available to us to read from all connections. Let's fix this issue by adding goroutine for each connected client:

l, err := net.Listen("tcp", "localhost:9090")
if err != nil {
	return
}

defer l.Close()

for {
	conn, err := l.Accept()
	if err != nil {
		return
	}

	go handleUserConnection(conn)
}
func handleUserConnection(c net.Conn) {
	defer c.Close()

	for {
		userInput, err := bufio.NewReader(c).ReadString('\n')
		if err != nil {
			return
		}
	}
}

Here we are returning from handling function on read error, which will happen on client disconnect. After returning from function, we also close the connection. The defer helps here — no matter how function is finished — defer is called after return.

We don't need to handle connection closing error, because error here doesn't change the program flow.

Now we have working server, but there is no logic besides reading data from clients. Let's add chat logic. The simpliest chat will send any message to all clients. We may call this "fan out" pattern and it is drawn below:

To implement "fan out" we have to run over all client connections and "write" message to them.

In order to do that we need to store connections some way, we can iterate them. I choose sync.Map here, because it solves all concurrent access issues.

For our task slice is enough. But in our concurrent program we would have to add / remove data with that slice, and it would be impossible without sync.Lock. And we are coding simple tcp chat, so let's just use predefined type that solves concurrency issues.

// Using sync.Map to not deal with concurrency slice/map issues
var connMap = &sync.Map{}

It is important to work with pointer to sync.Map, not with value.

sync.Map is a structure that contains a sync.Lock. There is a known issue called locks passed by value.

For our map we need keys. I used UUID here, because it guarantees that the keys will be different. (Extremely low probability that UUID generates 2 equal values).

On client disconnect, we have to remove such client from map. Let's pass client's id to handling function.

id := uuid.New().String()
connMap.Store(id, conn)

Finally, let's implement messages fan out pattern:

for {
	userInput, err := bufio.NewReader(c).ReadString('\n')
	if err != nil {
		return
	}

	connMap.Range(func(key, value interface{}) bool {
		if conn, ok := value.(net.Conn); ok {
			conn.Write([]byte(userInput))
		}

		return true
	})
}

We can stop iterating the map if false is returned from range function if we want.

Finally, i added errors handling and use of zap logger. The following code is the task solution. Full source code is also available on github.

package main

import (
	"bufio"
	"net"
	"sync"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

func main() {
	var loggerConfig = zap.NewProductionConfig()
	loggerConfig.Level.SetLevel(zap.DebugLevel)

	logger, err := loggerConfig.Build()
	if err != nil {
		panic(err)
	}

	l, err := net.Listen("tcp", "localhost:9090")
	if err != nil {
		return
	}

	defer l.Close()

	// Using sync.Map to not deal with concurrency slice/map issues
	var connMap = &sync.Map{}

	for {
		conn, err := l.Accept()
		if err != nil {
			logger.Error("error accepting connection", zap.Error(err))
			return
		}

		id := uuid.New().String()
		connMap.Store(id, conn)

		go handleUserConnection(id, conn, connMap, logger)
	}
}

func handleUserConnection(id string, c net.Conn, connMap *sync.Map, logger *zap.Logger) {
	defer func(){
		c.Close()
		connMap.Delete(id)
	}()

	for {
		userInput, err := bufio.NewReader(c).ReadString('\n')
		if err != nil {
			logger.Error("error reading from client", zap.Error(err))
			return
		}

		connMap.Range(func(key, value interface{}) bool {
			if conn, ok := value.(net.Conn); ok {
				if _, err := conn.Write([]byte(userInput)); err != nil {
					logger.Error("error on writing to connection", zap.Error(err))
				}
			}

			return true
		})
	}
}

Related articles