Sending and Receiving Data in Go

Reliable data transmission is a core challenge when building distributed systems or applications requiring inter-process communication. Go is renowned for its concise syntax and powerful concurrency model, while its standard library provides comprehensive networking capabilities through packages like net/http, io, and custom protocol implementations. Mastering how to efficiently send and receive data is not only foundational for writing HTTP services but also critical for implementing lower-level network applications such as TCP/UDP. This article delves into the core concepts and practical methods for handling streaming data transmission in Go.
In most modern network protocols (such as HTTP and TCP), communication does not rely on fixed message packet sizes but instead adopts the concept of a "stream." This means that the sender and receiver can continuously read and write data bytes until the connection is closed or a specific termination condition is met. Go abstracts this concept through the io.Reader and io.Writer interfaces, decoupling low-level transmission details from business logic.

This design brings two primary advantages:

  • Flexibility: The sender does not need to know the receiver's buffer size in advance; data can be written byte-by-byte, in chunks, or all at once.
  • Compatibility: Regardless of whether the upper-layer application processes text or binary streams, Go provides a unified read/write interface.

Understanding this model is crucial for debugging network errors. Many common "connection closed" (net: http: TLS handshake timeout) issues often stem from the sender failing to correctly handle stream termination signals.

Data Encapsulation in HTTP Requests and Responses

In practical development, the most common scenario involves building RESTful APIs. Go's http package automatically handles serialization and deserialization of data formats such as JSON and XML through structs, greatly simplifying the development process. Below is a typical Go function example demonstrating how to send a POST request with data and receive the response:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

func sendData() {
    // Define the struct for data to be sent
    type User struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    }

    user := &User{Name: "Alice", Age: 30}

    // Serialize data into a JSON byte stream
    jsonData, err := json.Marshal(user)
    if err != nil {
        fmt.Println("Serialization failed:", err)
        return
    }

    // Create the request body and set Content-Type
    bodyReader := bytes.NewReader(jsonData)
    
    req, err := http.NewRequest(http.MethodPost, "http://example.com/users", bodyReader)
    if err != nil {
        fmt.Println("Request creation failed:", err)
        return
    }

    // Critical step: Set Content-Type so the server knows it's receiving JSON
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    
    // Send the request and get the response
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("Network error:", err)
        return
    }
    defer resp.Body.Close()

    var result User
    // Receive the data returned by the server and deserialize it
    err = json.NewDecoder(resp.Body).Decode(&result)
    if err != nil {
        fmt.Println("Response parsing failed:", err)
        return
    }

    fmt.Printf("Successfully received data: %+v\n", result)
}

The above code illustrates the complete send-and-receive loop. Note the setting of the Content-Type header, which is a necessary condition for ensuring the server correctly parses JSON data; simultaneously, using json.NewDecoder(resp.Body) instead of directly reading the byte array allows automatic handling of multi-line data or chunked transmission within the streaming response.

Data Read/Write in Native TCP/UDP Communication

Beyond the HTTP protocol, Go provides low-level network packages (net, golang.org/x/net/context) for developers to write custom TCP servers and clients. In such scenarios, data sending and receiving rely entirely on direct manipulation of byte streams, requiring manual management of connection states and data sequences.

In TCP communication, data typically exists in the form of a "stream," so it is essential to explicitly determine when to stop writing and reading:

  • Closing the write end: Calling writer.Close() sends a FIN (Finish) flag to the peer, notifying them that no new data will be received.
  • Closing the read end: Calling reader.Close() can also trigger a FIN signal; if only one side closes without sending FIN, it may leave the connection in a half-open state until it times out and disconnects.

Below is a simplified TCP server example demonstrating how to listen for and process data streams from clients:

package main

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

func handleConn(conn net.Conn) {
    // Create read/write buffers to simulate network socket behavior
    reader := bufio.NewReader(conn)
    writer := bufio.NewWriterSize(conn, 4096)

    for {
        // Loop to read data until EOF (connection closed) or an error occurs
        data, err := io.ReadAll(reader)
        if err != nil && !io.EOF(err.Error()) {
            log.Println("Client sent error or disconnected:", err)
            break
        } else if len(data) == 0 {
            // Handle empty data to avoid infinite loops waiting for invalid input
            continue 
        }

        fmt.Printf("Received data: %s\n", string(data))

        // Construct a response and send it to the client
        response := []byte(fmt.Sprintf("Echo received: %s", data))
        _, err = writer.Write(response)
        if err != nil {
            log.Println("Send failed:", err)
            break
        }

        // Must flush the buffer to actually send data to the network
        err = writer.Flush()
    }
}

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    fmt.Println("TCP server started, listening on port 8080...")

    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        
        go handleConn(conn) // Execute handling each connection in a background thread
    }
}

This example demonstrates the typical pattern Go uses when handling streaming data: using bufio buffers to improve I/O efficiency, and perceiving the end of transmission by looping until EOF or an error is encountered. It is worth noting that in actual production environments, context should be combined with this approach to implement more flexible timeout control and graceful shutdown mechanisms.