Communicating with sockets

11 minutes read

Communication in the digital world often happens through protocols and predefined rules. One of the foundational elements that facilitate this communication is the socket. In this topic, you'll learn about sockets and how to work with them using Go's net package to build a simple client-server communication system.

What are sockets?

As you may know, every computer or device on the Internet possesses a unique IP address, enabling other systems to recognize and communicate with it. For network applications like instant messengers or online games, users interact using sockets. Think of a socket as a two-way communication channel determined by a specific IP address (e.g., 127.0.0.1) and port (e.g., 8080) on a computer. So, a socket address might look like: 127.0.0.1:8080.

Below are some commonly used sockets tailored for different communication patterns:

TCP sockets: Connection-oriented sockets that ensure reliable and ordered data transmission. Commonly used for web traffic and file transfers, they establish a stable link between the sender and receiver, retransmitting lost packets and ensuring data integrity.

UDP sockets: These sockets operate in a connectionless manner and are designed for speed, making them ideal for video streaming and online gaming. While they send data quickly, they don't guarantee the order or delivery of the packets.

UNIX domain sockets: Used for communication between processes on the same device, UNIX domain sockets offer efficient local inter-process communication by bypassing the networking layer. Many system daemons and services prefer them for local communications.

Having learned about commonly used sockets, let's take a look at how to create a listener TCP socket in Go:

listener, err := net.Listen("tcp", "127.0.0.1:8080")
if err != nil {
    // handle error
}

In the above example, the listener socket waits for incoming connection requests. The "tcp" argument denotes the network protocol used, and "127.0.0.1:8080" specifies the address+port number on which the socket "listens" for incoming connections.

The Client-Server model

At the core of many network applications lies the Client-Server model, where the server continuously listens for a client's connection request. This model isn't limited to vast networks; it can also represent communication between two processes on the same machine, effectively turning a single system into a mini-network. Such communication generally passes through the layers of the TCP/IP protocol stack.

The net package provides essential networking functions to support the Client-Server model in Go:

  • net.Dial(): Facilitates the client's role by attempting to establish a connection to a server listening on a particular address and port.

  • net.Listen(): Enables the server to listen on a specified address and port, waiting for a client to initiate a connection.

  • listener.Accept(): Used by the server, this function waits for a client's connection request and, when received, completes the connection.

It's crucial to understand that real-world applications often require servers to handle multiple simultaneous client connections. In Go, you can implement a server socket that manages multiple client connections concurrently leveraging goroutines and the above functions from the net package.

As a quick recap, the initial code snippet used the net.Listen() function to create a listener socket, which is the first step in setting up a server. In the following sections, you'll learn in detail how to create client and server sockets and finally get them to communicate.

Creating the server socket

The server socket is a diligent listener; it binds to a specific port and waits for incoming client connections. When a client initiates a connection, the server accepts it and creates a new, dedicated socket for communication with that client; this approach allows the server to maintain simultaneous interactions with multiple clients, each through its unique socket. In short, the general steps the server socket follows are:

  1. Initiate a listener socket with a specific protocol on a designated address.

  2. Continuously listen for incoming connection attempts on that address.

  3. Upon detecting a connection request, accept the connection.

  4. Engage in a communication exchange following a predetermined protocol.

  5. Close the connection after communication with the client concludes.

  6. Remain in continuous listening mode, ready for the next client connection.

Now, let's create a new file named server.go and, within it, write the code to build a listener TCP server socket:

// server.go

package main

import (
    "fmt"
    "log"
    "net"
)

const (
    protocol = "tcp"
    address  = "127.0.0.1"
    port     = ":8080"
)

func handleConnection(connection net.Conn) error {
    buffer := make([]byte, 1024)
    clientMsgLen, err := connection.Read(buffer)
    if err != nil {
        return err
    }
    log.Printf("Received from client: %s\n", string(buffer[:clientMsgLen]))

    err = sendResponse(connection, "Server socket says hello!")
    if err != nil {
        return err
    }
    return connection.Close()
}

func sendResponse(connection net.Conn, message string) error {
    _, err := connection.Write([]byte(message))
    if err != nil {
        return fmt.Errorf("cannot write server data to connection: %w", err)
    }
    return nil
}

func main() {
    listener, err := net.Listen(protocol, address+port)
    if err != nil {
        log.Fatal("cannot open server socket", err)
    }
    defer listener.Close()
    log.Printf("Server socket is listening on %s%s\n", address, port)

    for {
        connection, err := listener.Accept()
        if err != nil {
            log.Println("cannot accept client connection", err)
            continue
        }

        go func(connection net.Conn) {
            err = handleConnection(connection)
            if err != nil {
                log.Println(err)
            }
        }(connection)
    }
}

In the above code, the first step is to initiate a TCP server socket on the address 127.0.0.1:8080 via the net.Listen() function; this server continuously listens for incoming client connections using the listener.Accept() method.

Upon detecting a client connection, the handleConnection() helper gets invoked in a new goroutine, allowing the server to manage multiple connections concurrently. This helper reads the incoming data into a buffer using the connection.Read() method and prints the data received from the client to the server's console; then, the sendResponse() function sends a response message back to the client via the connection.Write() method.

Finally, after the communication with a client concludes, the connection is closed via the connection.Close() method. Additionally, at the end of the server's lifecycle, the defer listener.Close() statement shuts down the listener socket, preventing the accumulation of unused network connections and unreleased memory.

Creating the client socket

The client socket actively seeks out the server, attempting to form a connection; this connection gets initiated through a predetermined port and protocol, after which communication can occur. The sequence of steps the client socket typically follows are:

  1. Specify the server's protocol and address.

  2. Initiate a connection to the specified server.

  3. Send and/or receive data.

  4. Close the connection after communication finishes.

Now, let's go ahead and create a new file named client.go and, within it, write the code to build the client socket:

// client.go

package main

import (
    "fmt"
    "log"
    "net"
)

const (
    serverProtocol = "tcp"
    serverAddress  = "127.0.0.1"
    serverPort     = ":8080"
)

func sendMessage(connection net.Conn, message string) error {
    _, err := connection.Write([]byte(message))
    if err != nil {
        return fmt.Errorf("cannot write client data to connection: %w", err)
    }
    return nil
}

func readResponse(connection net.Conn) (string, error) {
    buffer := make([]byte, 1024)
    serverMsgLen, err := connection.Read(buffer)
    if err != nil {
        return "", fmt.Errorf("cannot read server response: %w", err)
    }
    return string(buffer[:serverMsgLen]), nil
}

func main() {
    connection, err := net.Dial(serverProtocol, serverAddress+serverPort)
    if err != nil {
        log.Fatal("cannot establish a connection to server", err)
    }
    defer connection.Close()

    err = sendMessage(connection, "Hello server socket, I'm the client socket!")
    if err != nil {
        log.Println(err)
        return
    }

    serverResponse, err := readResponse(connection)
    if err != nil {
        log.Println(err)
        return
    }

    fmt.Printf("Received from server: %s\n", serverResponse)
}

In the above code, the client socket initiates a connection to the server socket using the net.Dial() function. With the connection set up, the sendMessage() helper transmits a friendly greeting message to the server socket via the connection.Write() method.

After sending the greeting message, the readResponse() function captures the server's response message, reads the data into the buffer via the connection.Read() method and prints it to the client's console. Finally, after communication finishes, the connection is closed.

Socket communication operates at a low level; this means that methods like connection.Write() and connection.Read() write to and read from slices of bytes. This byte-level interaction emphasizes the nature of socket communication, where data gets transferred in its most primitive form without any protocol-specific filters or modifications.

Client-Server communication

Now that you've learned the individual roles and implementations of the client and server sockets, it's time to see the communication process in action. First, you'll run the server as it listens for incoming connections. If you try to start the client without the server running, the connection attempt will fail since there's no server available to listen to handle the client's request.

Let's open up a terminal, move into the project workspace, and execute the command:

go run server.go

This command starts the server socket, and you should see a message indicating that the server is listening for connections at 127.0.0.1:8080.

Next, to simulate multiple clients connecting to the server simultaneously, you'll need to compile the client binary and then run multiple instances of the client; to achieve this, open up a second terminal, move into the project workspace again, and execute the following commands:

go build client.go
./client & ./client & ./client &

After executing the above commands, 3 client processes will start almost simultaneously; these clients immediately send a greeting "Hello server socket, I'm the client socket!" that you can check on the server's terminal:

# server terminal output:
2023/10/28 12:05:54 Server socket is listening on 127.0.0.1:8080...
2023/10/28 12:06:05 Received from client: Hello server socket, I'm the client socket!
2023/10/28 12:06:05 Received from client: Hello server socket, I'm the client socket!
2023/10/28 12:06:06 Received from client: Hello server socket, I'm the client socket!

Going back to the terminal where you ran the clients, as each interaction completes, you'll be able to see 3 messages with the server's response "Server socket says hello!":

# client terminal output:
Received from server: Server socket says hello!
Received from server: Server socket says hello!
Received from server: Server socket says hello!

If you run into any errors when running the server and the client, it might be because another application on your computer is already using port 8080. You can try modifying the port and serverPort constants in both the server.go and client.go files to another like ":8081" or :"9090"

Even though the above interaction might seem very simple, remember that socket communication is the foundation of many network-based applications, from simple chat applications to complex web servers.

Conclusion

In this topic, you learned about sockets and their crucial role in network communication. You also learned how to use Go's net package's functions to establish a basic client-server communication system, emphasizing direct interaction with TCP sockets.

With this knowledge, you can explore more intricate networking applications in Go. Now, it's time to put this knowledge to the test with a few theory and coding tasks. Let's go!

2 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo