DEV Community

Cover image for Golang CLI-based Socket MultiChat Tutorial
SeongKuk Han
SeongKuk Han

Posted on • Updated on

Golang CLI-based Socket MultiChat Tutorial

program output

At the start of the program, you can choose whether it operates as a server or client. When a client provides their nickname, they can have conversations with other clients.


What is Socket Programming?

Socket programming is a way of connecting two nodes on a network to communicate with each other. One socket(node) listens on a particular port at an IP, while the other socket reaches out to the other to form a connection. - geeksforgeeks.org


Source Code

Structure

Structure Diagram
-excalidraw

I have included this diagram to help you get a better sense of how the project is organized.
Let's now explore the code for each module step by step.


Config

config.go

package config

const (
    SERVER_HOST = "0.0.0.0"
    SERVER_PORT = "3000"
    SERVER_NETWORK = "tcp"
)
Enter fullscreen mode Exit fullscreen mode

The constants are utilized for both the server and the client.


Data

data.go

package data

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "os"
)

type Data struct {
    MessageType byte
    Message string
}

const (
    MAX_BUFFER = 1024
)

const (
    MESSAGE_TYPE_NICKNAME = 1
    MESSAGE_TYPE_MESSAGE = 2
)

func GenerateNicknameData(nickname string) []byte {
    return dataToBytes(MESSAGE_TYPE_NICKNAME, nickname)
}

func GenerateMessageData(message string) []byte {
    return dataToBytes(MESSAGE_TYPE_MESSAGE, message)
}

func ConvertBytesToData(b []byte) Data {
    data := Data{}
    decoder := gob.NewDecoder(bytes.NewBuffer(b))
    err := decoder.Decode(&data)

    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    return data
}

func dataToBytes(messageType byte, message string) []byte {
    d := Data{}
    d.MessageType = messageType
    d.Message = message

    buffer := new(bytes.Buffer)
    encoder := gob.NewEncoder(buffer)
    err := encoder.Encode(d)

    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    return buffer.Bytes()
}
Enter fullscreen mode Exit fullscreen mode

type Data struct {
    MessageType byte
    Message string
}

const (
    MAX_BUFFER = 1024
)

const (
    MESSAGE_TYPE_NICKNAME = 1
    MESSAGE_TYPE_MESSAGE = 2
)
Enter fullscreen mode Exit fullscreen mode

The Data structure consists of two fields MessageType and Message.
The MessageType can take one of two values, MESSAGE_TYPE_NICKNAME or MESSAGE_TYPE_MESSAGE.

When MessageType is set to MESSAGE_TYPE_NICKNAME, it indicates the user's intention to change their nickname. On the other hand, when set to MESSAGE_TYPE_MESSAGE, the message is sent to all connected clients.

The MAX_BUFFER specifies the maximum data size in bytes. As there is no size limit imposed in this app, issues may arise if the data exceeds 1024 bytes. it is going to be a problem. To prevent such problems, you have the option to create a custom protocol.

For instance, you can design the protocol as below.

Header(1 byte) | MessageSize(2 byte) | Message(X bytes)
Enter fullscreen mode Exit fullscreen mode

The Header serves the purpose of marking the start of the data.

Subsequently, two bytes are used to represent the size of the data, allowing you to determine the data's length.

However, in the context of this example, we won't delve further into this particular scenario.

func dataToBytes(messageType byte, message string) []byte {
    d := Data{}
    d.MessageType = messageType
    d.Message = message

    buffer := new(bytes.Buffer)
    encoder := gob.NewEncoder(buffer)
    err := encoder.Encode(d)

    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    return buffer.Bytes()
}
Enter fullscreen mode Exit fullscreen mode

the dataToBytes function converts the Data struct into a []byte.

func ConvertBytesToData(b []byte) Data {
    data := Data{}
    decoder := gob.NewDecoder(bytes.NewBuffer(b))
    err := decoder.Decode(&data)

    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    return data
}
Enter fullscreen mode Exit fullscreen mode

the ConvertBytesToData function converts []byte into the Data struct.


Server

main.go

package server

func Main() {
    Run()
}
Enter fullscreen mode Exit fullscreen mode

This function is an entry point and is called from the main function.

server.go

package server

import (
    "fmt"
    "net"
    "os"

    "github.com/hsk-kr/golang-console-chat-example/config"
)

func Run() {
    endpoint := fmt.Sprintf("%s:%s", config.SERVER_HOST, config.SERVER_PORT) 
    server, err := net.Listen(config.SERVER_NETWORK, endpoint)

    if err != nil {
        fmt.Printf("net.Listen Error: %s\n", err)
        os.Exit(1)
    }
    defer server.Close()

    cs := new(chatServer)
    cs.server = &server

    fmt.Printf("Server has listened[%s:%s]\n", config.SERVER_HOST, config.SERVER_PORT)
    for {
        conn, err := server.Accept()

        if err != nil {
            fmt.Printf("server.Accept Error: %s\n", err)
            break
        }

        cs.connect(&conn)
    }
}
Enter fullscreen mode Exit fullscreen mode

endpoint := fmt.Sprintf("%s:%s", config.SERVER_HOST, config.SERVER_PORT) 
server, err := net.Listen(config.SERVER_NETWORK, endpoint)
Enter fullscreen mode Exit fullscreen mode

The net.Listen function opens the server and accepts incoming connections.

for {
    conn, err := server.Accept()

    if err != nil {
        fmt.Printf("server.Accept Error: %s\n", err)
        break
    }

    cs.connect(&conn)
}
Enter fullscreen mode Exit fullscreen mode

In the main loop, it accepts the incoming connections and passes them to the chatServer.connect function. The core server logic is implemented within the chatServer.

chat_server.go

package server

import (
    "fmt"
    "net"
    "sync"

    "github.com/hsk-kr/golang-console-chat-example/data"
)

type chatServer struct {
    server *net.Listener
    clients []*client
    muClients sync.Mutex
}

func (cs *chatServer) connect(conn *net.Conn) {
    newClient := client{}
    newClient.conn = conn
    newClient.cs = cs

    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    cs.clients = append(cs.clients, &newClient)
    go newClient.run()
    fmt.Printf("client %s connected.\n", newClient.getIP())
}

func (cs *chatServer) disconnect(c *client) {
    idx := -1

    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    for i, client := range cs.clients {
        if client == c {
            idx = i
            break
        }
    }

    if idx == -1 {
        fmt.Printf("client can't not be found. (%s)", c.getIP())
        return
    }

    targetClient := cs.clients[idx]
    fmt.Printf("client %s disconnected.\n", targetClient.getIP())
    (*targetClient.conn).Close()
    cs.clients = append(cs.clients[:idx], cs.clients[idx + 1:]...)
}

func (cs *chatServer) broadMessage(c *client, message string) {
    if c != nil {
        message = fmt.Sprintf("%s:%s", c.getNickname(), message)
    }

    data := data.GenerateMessageData(message)
    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    for _, client := range cs.clients {
        client.sendMessage(data)
    }
}
Enter fullscreen mode Exit fullscreen mode

func (cs *chatServer) connect(conn *net.Conn) {
    newClient := client{}
    newClient.conn = conn
    newClient.cs = cs

    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    cs.clients = append(cs.clients, &newClient)
    go newClient.run()
    fmt.Printf("client %s connected.\n", newClient.getIP())
}
Enter fullscreen mode Exit fullscreen mode

The connect function creates a client and adds it to the slice.

Since the clients slice can be accessed by other goroutines, I use a Mutex to ensure synchronization.

func (cs *chatServer) disconnect(c *client) {
    idx := -1

    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    for i, client := range cs.clients {
        if client == c {
            idx = i
            break
        }
    }

    if idx == -1 {
        fmt.Printf("client can't not be found. (%s)", c.getIP())
        return
    }

    targetClient := cs.clients[idx]
    fmt.Printf("client %s disconnected.\n", targetClient.getIP())
    (*targetClient.conn).Close()
    cs.clients = append(cs.clients[:idx], cs.clients[idx + 1:]...)
}
Enter fullscreen mode Exit fullscreen mode

The disconnect function takes the client as input. It proceeds to close the connection and remove the client from the slice.

func (cs *chatServer) broadMessage(c *client, message string) {
    if c != nil {
        message = fmt.Sprintf("%s:%s", c.getNickname(), message)
    }

    data := data.GenerateMessageData(message)
    cs.muClients.Lock()
    defer cs.muClients.Unlock()
    for _, client := range cs.clients {
        client.sendMessage(data)
    }
}
Enter fullscreen mode Exit fullscreen mode

The broadMessage function is responsible for transmitting messages to all clients. Within this function, the client's nickname is prefixed to the message when the client is not nil.

client.go

package server

import (
    "fmt"
    "io"
    "net"

    "github.com/hsk-kr/golang-console-chat-example/data"
)

type client struct {
    conn *net.Conn
    cs *chatServer
    nickname string
}

func (c *client) run() {
    byteData := make([]byte, data.MAX_BUFFER)

    for {
        len, err := (*c.conn).Read(byteData)

        if err != nil {
            fmt.Printf("Client Read Error: %s(%d)\n", err, len)
            if err == io.EOF {
                (*c.cs).disconnect(c)
                break
            }
        }

        d := data.ConvertBytesToData(byteData)

        switch d.MessageType {
            case data.MESSAGE_TYPE_NICKNAME:
                c.setNickname(d.Message)
            case data.MESSAGE_TYPE_MESSAGE:
                c.cs.broadMessage(c, d.Message)
        }
    }
}

func (c *client) getIP() string {
    addr, err := net.ResolveTCPAddr("tcp", (*c.conn).RemoteAddr().String())
    if err != nil {
        fmt.Printf("Client ResolveTCPAddr Error: %s\n", err)
        return ""
    }

    return addr.IP.String()
}

func (c *client) setNickname(nickname string) {
    c.nickname = nickname
}

func (c *client) getNickname() string {
    if c.nickname == "" {
        return "unknown"
    }

    return c.nickname
}

func (c *client) sendMessage(data []byte) {
    (*c.conn).Write(data)
}
Enter fullscreen mode Exit fullscreen mode

func (c *client) run() {
    byteData := make([]byte, data.MAX_BUFFER)

    for {
        len, err := (*c.conn).Read(byteData)

        if err != nil {
            fmt.Printf("Client Read Error: %s(%d)\n", err, len)
            if err == io.EOF {
                (*c.cs).disconnect(c)
                break
            }
        }

        d := data.ConvertBytesToData(byteData)

        switch d.MessageType {
            case data.MESSAGE_TYPE_NICKNAME:
                c.setNickname(d.Message)
            case data.MESSAGE_TYPE_MESSAGE:
                c.cs.broadMessage(c, d.Message)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The run function accepts data and checks the message type.

If the message type is MESSAGE_TYPE_NICKNAME, it updates the nickname.

If the message type is MESSAGE_TYPE_MESSAGE, it forwards the message to the broadMessage function within the chatServer.


Client

main.go

package client

import (
    "bufio"
    "fmt"
    "io"
    "net"
    "os"
    "strings"

    "github.com/hsk-kr/golang-console-chat-example/config"
    d "github.com/hsk-kr/golang-console-chat-example/data"
)

func Main() {
    fmt.Printf("Enter your nickname:")
    nickname := readLine()

    address := fmt.Sprintf("%s:%s", config.SERVER_HOST, config.SERVER_PORT)
    conn, err := net.Dial(config.SERVER_NETWORK, address)

    if err != nil {
        fmt.Printf("net.Dial Error: %s\n", err)
        os.Exit(1)
    }

    sendNicknameChangeRequest(&conn, nickname)
    go runReceiveData(&conn)

    for {
        message := readLine()
        sendMessage(&conn, message)
    }
}

func readLine() string {
    reader := bufio.NewReader(os.Stdin)
    str, _ := reader.ReadString('\n')
    str = strings.Replace(str, "\n", "", -1);
    return str
}

func sendNicknameChangeRequest(conn *net.Conn, nickname string) {
    d := d.GenerateNicknameData(nickname)
    (*conn).Write(d)
}

func sendMessage(conn *net.Conn, message string) {
    d := d.GenerateMessageData(message)
    (*conn).Write(d)
}

func runReceiveData(conn *net.Conn) {
    defer (*conn).Close()

    byteData := make([]byte, d.MAX_BUFFER)
    for {
        len, err := (*conn).Read(byteData)

        if err != nil {
            fmt.Printf("Client Read Error: %s(%d)\n", err, len)
            if err == io.EOF {
                os.Exit(1)
            }
        }

        data := d.ConvertBytesToData(byteData)
        fmt.Printf("%s\n", data.Message)
    }
}
Enter fullscreen mode Exit fullscreen mode

func Main() {
    fmt.Printf("Enter your nickname:")
    nickname := readLine()

    address := fmt.Sprintf("%s:%s", config.SERVER_HOST, config.SERVER_PORT)
    conn, err := net.Dial(config.SERVER_NETWORK, address)

    if err != nil {
        fmt.Printf("net.Dial Error: %s\n", err)
        os.Exit(1)
    }

    sendNicknameChangeRequest(&conn, nickname)
    go runReceiveData(&conn)

    for {
        message := readLine()
        sendMessage(&conn, message)
    }
}
Enter fullscreen mode Exit fullscreen mode

At the program's start, it gets an input of the user's nickname and sends it to the server after establishing a connection.

Then, whenever the user inputs a message, it sends it to the server.

func runReceiveData(conn *net.Conn) {
    defer (*conn).Close()

    byteData := make([]byte, d.MAX_BUFFER)
    for {
        len, err := (*conn).Read(byteData)

        if err != nil {
            fmt.Printf("Client Read Error: %s(%d)\n", err, len)
            if err == io.EOF {
                os.Exit(1)
            }
        }

        data := d.ConvertBytesToData(byteData)
        fmt.Printf("%s\n", data.Message)
    }
}
Enter fullscreen mode Exit fullscreen mode

The runReceiveData function receives the data from the server and displays the message on the screen.

To allow the user to send and receive messages simultaneously, this function runs as a goroutine.

func readLine() string {
    reader := bufio.NewReader(os.Stdin)
    str, _ := reader.ReadString('\n')
    str = strings.Replace(str, "\n", "", -1);
    return str
}
Enter fullscreen mode Exit fullscreen mode

The readLine function inputs a string from the command line and returns it.

As the input data includes the new line character, it gets rid of the new line character using strings.Replace.


Main

main.go

package main

import (
    "fmt"

    "github.com/hsk-kr/golang-console-chat-example/client"
    "github.com/hsk-kr/golang-console-chat-example/server"
)

func main() {
    var t string

    for t != "c" && t != "s"{
        fmt.Print("Which one do you want to execute as server(s) or client(c):")
        fmt.Scanf("%s", &t);
    }

    if t == "s" {
        server.Main()
    } else {
        client.Main()
    }
}
Enter fullscreen mode Exit fullscreen mode

The program requests the user to choose whether they want to run it as a server or a client. It then calls the respective Main function accordingly.


Conclusion

This is my very first Go project, and it reminds me of the time I studied socket programming a little bit with C/C++ about 10 years ago. Actually, I made the code in a similar way, I think I haven't fully used Go's strengths.

These days, starting a coding project is kind of daunting. The pressure to create something big or impressive makes it hard to begin, especially when I see amazing projects from others, I feel kind of overwhelmed.

But now, I have made a decision to keep coding without overthinking. I will work on small pieces of code, one at a time, and write about them here. I believe that these small steps will add up and be worth it!

I hope you guys found it useful.

Happy Coding!

Top comments (0)