So the task was to first create a custom TCP protocol (namely ebl0) with certain parameters in its header and a number of methods to use with it (e.g. Login, Create, Join etc.). I'm not listing it here because it was relatively easy. The next step was building a server using this protocol that complies to the following:
Has a 'heartbeat' functionality (detects a dead client: if a client is dead for 8 seconds, ping it, if no response is received within 8 more seconds, disconnect).
Can handle multiple clients/connections.
Works within the scope of ebl0 protocol and supports the following commands from ebl0 protocol : ping, pong, login, login acknowledge, list (list all current rooms for the user), create, create acknowledge, delete, delete acknowledge, join, join acknowledge, leave, leave acknowledge, message. Those are pretty self-explanatory.
Smaller features I've tried to include in the comments as I was working on this.
At this point I'd like to know if:
Something can be done better/faster.
I'm using mutexes correctly.
There's a way to hide implementation details (making the room and user maps) from the Server constructor.
There's generally a more idiomatic architecture (structs/methods).
Thank you for your time!
package main
import (
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hex3r/go-learning/ebl0"
)
// Server contains server parameters
type Server struct {
port string
rooms map[string]*Room
users map[string]*User
}
// Conn contains connection parameters
type Conn struct {
encoder *ebl0.Encoder
decoder *ebl0.Decoder
timeout time.Duration
isClosed chan bool
timeValue atomic.Value
}
// Room contains room parameters
type Room struct {
users []string
}
// User contains user parameters
type User struct {
rooms []string
address net.Conn
}
func getSliceElementIndex(slice []string, element string) int {
for i, v := range slice {
if v == element {
return i
}
}
return -1
}
func (conn *Conn) getLastReadTime() time.Time {
return conn.timeValue.Load().(time.Time)
}
func (conn *Conn) setLastReadTime() {
conn.timeValue.Store(time.Now())
}
func (conn *Conn) timeoutTimer() {
i := 1
for {
// store last read time
lastReadTime := conn.getLastReadTime()
select {
// handleConnection has quit, return
case <-conn.isClosed:
return
// timeout reached
case <-time.After((lastReadTime.Add(time.Duration(i) * conn.timeout)).Sub(time.Now())):
// if last read time remains the same (client didn't send anything),
// send PING to the client
if lastReadTime == conn.getLastReadTime() && i == 1 {
if err := conn.encoder.Ping(); err != nil {
fmt.Println(err)
return
}
i++
// if this isn't the first attempt, reset the counter
} else {
i = 1
}
}
}
}
func (server *Server) handleConnection(c net.Conn) {
// initialize variables
var err error
var msg ebl0.Message
var name string // username
isLoggedIn := false // user login status
var mutex = &sync.Mutex{} // syncing access to maps
conn := Conn{
encoder: ebl0.NewEncoder(c),
decoder: ebl0.NewDecoder(c),
timeout: time.Duration(8) * time.Second,
isClosed: make(chan bool),
}
defer func() {
// close sync channel
conn.isClosed <- true
close(conn.isClosed)
// close connection
c.Close()
mutex.Lock()
// remove user from userlist on server
delete(server.users, name)
// iterate over all server rooms
for roomName := range server.rooms {
// check if disconnecting user is present in any of them
if i := getSliceElementIndex(server.rooms[roomName].users, name); i != -1 {
// remove user from the userlist of room
server.rooms[roomName].users = append(server.rooms[roomName].users[:i], server.rooms[roomName].users[i+1:]...)
fmt.Println(name, "left", roomName)
}
}
mutex.Unlock()
// custom error message for normal disconnect (EOF)
if err == io.EOF {
err = errors.New("quit")
}
fmt.Println(name, "disconnected. (", err, ")")
}()
// start timeout counter goroutine
conn.setLastReadTime()
go conn.timeoutTimer()
for {
// set read deadline to twice the timeout as per protocol rules
c.SetReadDeadline(time.Now().Add(conn.timeout * 2))
// decode the message
msg, err = conn.decoder.Decode()
if err != nil {
return
}
// set most recent read time to current time and reset timeout
conn.setLastReadTime()
c.SetReadDeadline(time.Time{})
// get command number of the message
cmdNum := msg.Command()
// if user attempts actions without logging in, disconnect
if !isLoggedIn && cmdNum != 3 {
name = "An anonymous user"
return
}
switch cmdNum {
case 1: // client sends PING, reply with PONG
fmt.Println(name, "pinged the server.")
if err = conn.encoder.Pong(); err != nil {
return
}
case 3: // client sends LOGIN, acknowledge
if !isLoggedIn {
// get name
name = string(msg.Payload())
mutex.Lock()
// if such user doesn't exist, acknowledge it
if _, ok := server.users[name]; !ok {
server.users[name] = &User{
rooms: make([]string, 0),
address: c,
}
isLoggedIn = true
fmt.Println(name, "logged in.")
if err = conn.encoder.LoginAck(1, ""); err != nil {
return
}
// if user exists
} else {
if err = conn.encoder.LoginAck(0, "User under such name has already logged in."); err != nil {
return
}
}
mutex.Unlock()
// if user has previously logged in
} else {
if err = conn.encoder.LoginAck(0, "You have already logged in."); err != nil {
return
}
}
case 5: // client sends LIST, provide it with list of rooms
mutex.Lock()
// form list of rooms
roomNames := make([]string, len(server.rooms))
i := 0
for roomName := range server.rooms {
roomNames[i] = roomName
i++
}
mutex.Unlock()
// send list of rooms
if err = conn.encoder.ListRooms(roomNames); err != nil {
return
}
case 6: // client sends CREATE, try to create room and acknowledge
// get room name
roomName := string(msg.Payload())
mutex.Lock()
// if such room doesn't exist, create it
if _, ok := server.rooms[roomName]; !ok {
server.rooms[roomName] = &Room{make([]string, 0)}
if err = conn.encoder.CreateAck(1, ""); err != nil {
return
}
fmt.Println(name, "created", roomName)
// if it exists, send back error
} else {
if err = conn.encoder.CreateAck(0, "Room with such name already exists."); err != nil {
return
}
}
mutex.Unlock()
case 8: // client sends DELETE, try to delete room and acknowledge
// get room name
roomName := string(msg.Payload())
mutex.Lock()
// if such room exists
if _, ok := server.rooms[roomName]; ok {
// if player is not present in it
if getSliceElementIndex(server.users[name].rooms, roomName) == -1 {
// iterate over all server users
for user := range server.users {
// check if room is present in any of their roomlists
if i := getSliceElementIndex(server.users[user].rooms, roomName); i != -1 {
// there are users in room, send error
if err = conn.encoder.DeleteAck(0, "There are users present in the room. They must leave to delete the room."); err != nil {
return
}
}
}
// delete room
delete(server.rooms, roomName)
// acknowledge
if err = conn.encoder.DeleteAck(1, ""); err != nil {
return
}
fmt.Println(name, "deleted", roomName)
// if player is in the room, send error
} else {
if err = conn.encoder.DeleteAck(0, "You must leave the room first."); err != nil {
return
}
}
// if room doesn't exist, send error
} else {
if err = conn.encoder.DeleteAck(0, "Room with such name doesn't exist."); err != nil {
return
}
}
mutex.Unlock()
case 10: // client sends JOIN, try to join and acknowledge
// get room name
roomName := string(msg.Payload())
mutex.Lock()
// if such room exists
if _, ok := server.rooms[roomName]; ok {
// and if user is not in it already, join room
if getSliceElementIndex(server.users[name].rooms, roomName) == -1 {
// add user to userlist of joined room
server.rooms[roomName].users = append(server.rooms[roomName].users, name)
// add joined room to roomlist of the user
server.users[name].rooms = append(server.users[name].rooms, roomName)
// acknowledge joining room
if err = conn.encoder.JoinAck(1, ""); err != nil {
return
}
fmt.Println(name, "joined", roomName)
// if user is already in the room, send error
} else {
if err = conn.encoder.JoinAck(0, "You are already in this room."); err != nil {
return
}
}
// if room doesn't exist, send error
} else {
if err = conn.encoder.JoinAck(0, "Room with such name doesn't exist."); err != nil {
return
}
}
mutex.Unlock()
case 12: // client sends LEAVE, try to leave and acknowledge
// get room name
roomName := string(msg.Payload())
mutex.Lock()
// if such room exists
if _, ok := server.rooms[roomName]; ok {
// and if user is present in it, leave room
if getSliceElementIndex(server.users[name].rooms, roomName) != -1 {
// find user in userlist of room
i := getSliceElementIndex(server.rooms[roomName].users, name)
// remove user from the userlist of room
server.rooms[roomName].users = append(server.rooms[roomName].users[:i], server.rooms[roomName].users[i+1:]...)
// find room in the roomlist of user
j := getSliceElementIndex(server.users[name].rooms, roomName)
// remove room from the roomlist of user
server.users[name].rooms = append(server.users[name].rooms[:j], server.users[name].rooms[j+1:]...)
// acknowledge leaving
if err = conn.encoder.LeaveAck(1, ""); err != nil {
return
}
fmt.Println(name, "left", roomName)
// if user is not in the room, send error
} else {
if err = conn.encoder.LeaveAck(0, "You are not present in that room you want to leave."); err != nil {
return
}
}
// if room doesn't exist, send error
} else {
if err = conn.encoder.LeaveAck(0, "Room with such name doesn't exist."); err != nil {
return
}
}
mutex.Unlock()
case 14: // client sends MESSAGE, receive and send to everyone in the room
// get room name and message
payload := string(msg.Payload())
payloadSlice := strings.Split(payload, "\n")
roomName := payloadSlice[0]
message := payloadSlice[1]
fmt.Println("#", roomName, "@", name+":", message)
mutex.Lock()
// if room exists
if _, ok := server.rooms[roomName]; ok {
// if user is present in it
if getSliceElementIndex(server.users[name].rooms, roomName) != -1 {
// iterate over all server users
for i := range server.users {
// if user is present in the room
if getSliceElementIndex(server.users[i].rooms, roomName) != -1 {
// send message to user
if err = ebl0.NewEncoder(server.users[i].address).RoomMessage(roomName, name, message); err != nil {
return
}
}
}
}
}
mutex.Unlock()
}
}
}
func (server *Server) serve() {
// listen on a port
ln, err := net.Listen("tcp", server.port)
if err != nil {
fmt.Println(err)
return
}
for {
// accept connection
c, err := ln.Accept()
if err != nil {
fmt.Println(err)
continue
}
// handle connection
go server.handleConnection(c)
}
}
func main() {
server := Server{
port: ":9999",
rooms: make(map[string]*Room),
users: make(map[string]*User),
}
server.serve()
}