Go language TCP/IP network programming

At first glance, connecting two processes over the TCP/IP layer feels scary, but it’s easier than you might think in Go.

Application scenarios of sending data at the TCP/IP layer

In many, but not most, cases, it is undoubtedly better to use higher-level network protocols because of the use of fancy apis that hide technical details. There are now many options for different needs, such as message queuing protocols, gRPC, Protobuf, FlatBuffers, RESTful web apis, Websocket, and so on.

However, in some special scenarios, especially small projects, any other option can feel too bloated, not to mention the additional dependency packages you need to introduce.

Fortunately, using the standard library’s NET package to create simple network communications is no more difficult than you see.

Because there are two simplifications in Go.

Simplification 1: A connection is an IO stream

The Conn interface implements IO.Reader, IO.Writer, and IO.Closer interfaces. TCP connections can therefore be treated like any other IO stream.

You might think, “Ok, I can send strings or byte fragments in TCP, that’s great, but what about complex data structures? Let’s say we have structure-type data?”

Simplification 2: Does Go know how to decode complex types efficiently

When you think of sending encoded structured data over the network, JSON comes to mind first. But wait a minute – the Go library Encoding/GOB package provides a way to serialize and emit sequence Go data types without attaching string tags to constructs, Go incompatible JSON, or waiting for the laborious parsing of text into binary data using Json. Unmarshal.

Gob codecs can operate directly on IO streams, which perfectly matches the first simplification.

Let’s implement a simple App together with these two simplified rules.

The goal of this simple APP

This app should do two things:

  • Send and receive simple string messages.
  • Structures are sent and received via GOB.

The first part, sending simple strings, demonstrates how easy it is to send data over a TCP/IP network without resorting to advanced protocols. The second part, going a little further, sends complete structures over the network that use strings, fragments, maps, and even recursive Pointers to themselves.

Thanks to the GOB pack, it’s a no-brainer.

Client server sent structure after decoding structure testStruct structure testStruct structure | | ^ V gob code -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- > gob decoding | | ^ V ============ Network ================= ReceivedCopy the code

The basics of sending string data over TCP

On the sending end

Sending a string requires three simple steps:

  • Open the connection for the receiving process.
  • Write strings.
  • Close the connection.

The NET package provides a pair of methods to do this.

  • ResolveTCPAddr(): This function returns the TCP terminal address.
  • DialTCP(): Similar to the TCP network.

Both methods are defined in the SRC /net/tcpsock.go file of the GO source code.

func ResolveTCPAddr(network, address string) (*TCPAddr, error) { switch network { case "tcp", "tcp4", "tcp6": case "": // a hint wildcard for Go 1.0 undocumented behavior network = "TCP" default: return nil, UnknownNetworkError(network) } addrs, err := DefaultResolver.internetAddrList(context.Background(), network, address) if err ! = nil { return nil, err } return addrs.forResolve(network, address).(*TCPAddr), nil }Copy the code

ResolveTCPAddr() takes two string arguments.

  • Network: indicates the TCP network name, such as TCP, tcp4, and tcp6.
  • Address: TCP address A character string. If it is not a literal IP address or a port number is not a literal port number, the ResolveTCPAddr resolves the incoming address to the ADDRESS of the TCP terminal. Otherwise, a pair of literal IP addresses and port numbers are passed as addresses. The address parameter can use the host name, but it is not recommended because it returns at most an IP address of the host name.

ResolveTCPAddr() receives a string representing the TCP address (such as localhost:80, 127.0.0.1:80, or [::1]:80, both representing port 80 of the host), and returns (net.tcpaddr, Nil)(returns (nil, error) if the string cannot be resolved to a valid TCP address).

func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) { switch network { case "tcp", "tcp4", "tcp6": default: return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: UnknownNetworkError(network)} } if raddr == nil { return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: nil, Err: errMissingAddress} } c, err := dialTCP(context.Background(), network, laddr, raddr) if err ! = nil { return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: err} } return c, nil }Copy the code

The DialTCP() function accepts three arguments:

  • Network: This parameter must be a TCP network name, just like the network parameter in ResolveTCPAddr.
  • Laddr: indicates the pointer of the TCPAddr type, representing the local TCP address.
  • Raddr: indicates the pointer of type TCPAddr, representing the remote TCP address.

It connects the dial-up two TCP addresses and returns this connection as a net.tcpconn object (error on connection failure). If we don’t need too much control over Dial Settings, we can use Dial() instead.

func Dial(network, address string) (Conn, error) {
    var d Dialer
    return d.Dial(network, address)
}Copy the code

The Dial() function receives a TCP address and returns a generic net.conn. That’s enough for our test case. However, if you need functionality that is only available over TCP connections, you can use TCP variants (DialTCP, TCPConn, TCPAddr, etc.).

After a successful dialing, we can treat the new connection the same as any other input/output stream, as described above. We can even wrap the connection in bufio.readwriter, using various ReadWriter methods such as ReadString(), ReadBytes, WriteString, and so on.

func Open(addr string) (*bufio.ReadWriter, error) { conn, err := net.Dial("tcp", addr) if err ! = nil { return nil, errors.Wrap(err, While bufio.newReader (Conn), while bufio.newReader (Conn), bufio.NewWriter(conn)), nil }Copy the code

Remember that buffer Writer needs to call Flush() after writing so that all data is flushed to the underlying network connection.

Finally, each connection object has a Close() method to terminate the communication.

Fine-tuning (fine tuning)

The Dialer structure is defined as follows:

type Dialer struct {
    Timeout time.Duration
    Deadline time.Time
    LocalAddr Addr
    DualStack bool
    FallbackDelay time.Duration
    KeepAlive time.Duration
    Resolver *Resolver
    Cancel <-chan struct{}
}Copy the code
  • Timeout: indicates the maximum time for dialing to wait for the connection to end. If the Deadline is set at the same time, you can fail earlier. There is no timeout by default. When using TCP and dialing host names with multiple IP addresses, timeouts are split between them. With or without timeouts, the operating system can force earlier timeouts. For example, the TCP timeout is usually about 3 minutes.
  • Deadline: indicates the absolute time when dialing is about to fail. If Timeout is set, it may fail earlier. A value of 0 indicates that there is no deadline, or that you rely on the operating system or use the Timeout option.
  • LocalAddr: is the local address used when dialing an address. This address must be of a fully compatible type with the network address to dial. If it is nil, a local address is automatically selected.
  • DualStack: This property enables RFC 6555 compatible “Happy Eyeballs” dialing. When network is TCP, the host in the address parameter can be resolved to IPv4 and IPv6 addresses. This allows the client to tolerate the network rules of an address family a little bit.
  • FallbackDelay: When DualStack is enabled, specifies the time to wait before a fallback connection occurs. If this parameter is set to 0, 300ms is used by default.
  • KeepAlive: Specifies a period of time for an active network connection to remain active. If set to 0, keep-alive is not enabled. Network protocols that do not support keep-alive ignore this field.
  • Resolver: Specifies an alternative Resolver to be used.
  • Cancel: Optional channel whose closure indicates that the dial should be canceled. Not all dialing types support dialing cancel. Invalid. Use DialContext instead.

There are two options available for fine tuning.

Therefore, the Dialer interface provides two options that can be fine-tuned:

  • DeadLine and Timeout options: Timeout Settings for unsuccessful dialing.
  • KeepAlive option: Manages the life span of a connection.
type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    LocalAddr() Addr
    RemoteAddr() Addr
    SetDeadline(t time.Time) error
    SetReadDeadline(t time.Time) error
    SetWriteDeadline(t time.Time) error
}Copy the code

The NET.CONN interface is a generic streaming – oriented network connection. It has the following interface methods:

  • Read(): Reads data from the connection.
  • Write(): Writes data to the connection.
  • Close(): closes the connection.
  • LocalAddr(): Returns the local network address.
  • RemoteAddr(): Returns the remote network address.
  • SetDeadline(): Sets the read and write deadline associated with the connection. This is equivalent to calling both SetReadDeadline() and SetWriteDeadline().
  • SetReadDeadline(): Sets the timeout deadline for future read calls and currently blocked read calls.
  • SetWriteDeadline(): Sets a timeout deadline for future write calls and currently blocked write calls.

The Conn interface also has deadline Settings; There are both for the entire connection (SetDeadLine()) and for specific read and write calls (SetReadDeadLine() and SetWriteDeadLine()).

Note that the deadline is a wallclock. Unlike timeouts, they do not reset after a new activity. Therefore, a new deadline must be set for each activity on the connection.

The sample code below does not use deadline because it is simple enough that we can easily see when we are stuck. Ctrl-c is our tool to trigger deadline manually.

On the receiving end

The steps at the receiving end are as follows:

  • Open listening on local ports.
  • When a request comes in, spawn goroutine is generated to process the request.
  • In Goroutine, the data is read. You can also selectively send responses.
  • Close the connection.

Listening You need to specify the local listening port number. Generally speaking, a listening application (also called a server) announces the port number to listen on and, if a standard service is provided, uses the port corresponding to that service. For example, web services typically listen for 80 to server HTTP, and port 443 to server HTTPS requests. The SSH daemon listens on port 22 by default, and the WHOIS service uses port 43.

type Listener interface { // Accept waits for and returns the next connection to the listener. Accept() (Conn, error) // Close closes the listener. // Any blocked Accept operations will be unblocked and return errors. Close() error  // Addr returns the listener's network address. Addr() Addr }Copy the code
func Listen(network, address string) (Listener, error) { addrs, err := DefaultResolver.resolveAddrList(context.Background(), "listen", network, address, nil) if err ! = nil { return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: nil, Err: err} } var l Listener switch la := addrs.first(isIPv4).(type) { case *TCPAddr: l, err = ListenTCP(network, la) case *UnixAddr: l, err = ListenUnix(network, la) default: return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: la, Err: &AddrError{Err: "unexpected address type", Addr: address}} } if err ! = nil { return nil, err // l is non-nil interface containing nil pointer } return l, nil }Copy the code

The core parts of net package implementation server side are:

Net.listen () creates a new listener at the given local network address. If only a port number is passed to it, such as “:61000”, then the listener listens for all available network interfaces. This is quite convenient because computers usually provide at least two active interfaces, a loopback interface and at least one real network card. This function returns the Listener on success.

The Listener interface has an Accept() method to wait for requests to come in. It then accepts the request and returns the new connection to the caller. Accept() is typically called in a loop and can serve multiple connections simultaneously. Each connection can be handled by a separate Goroutine, as shown in the code below.

Code section

Rather than having the code push a few bytes back and forth, I want it to demonstrate something more useful. I want it to be able to send different commands to the server with different data carriers. The server should be able to identify each command and decode the command data.

In our code the client sends two types of commands: “STRING” and “GOB”. They all end with a newline character.

The “STRING” command contains a row of STRING data that can be handled by simple reads and writes in Bufio.

The “GOB” command consists of a structure that contains fields, a shard and map, and even Pointers to itself. As you can see, when running this code, the GOB package can move this data over our network connection without fuss.

What we have here are basically ad-hoc protocols (AD hoc, purpose-specific, ad-hoc, AD hoc) that both the client and the server follow, followed by line breaks, followed by data. For each command, the server must know the exact format of the data and know what to do with it.

To achieve this, the server-side code takes a two-step approach.

  • Step 1: When the Listen() function receives a new connection, it generates a new Goroutine to call handleMessage(). This function reads the command name from the connection, looks up the appropriate processor function from the map, and then invokes it.
  • Step 2: The selected processor function reads and processes the command line data.
package main

import (
    "bufio"
    "encoding/gob"
    "flag"
    "github.com/pkg/errors"
    "io"
    "log"
    "net"
    "strconv"
    "strings"
    "sync"
)

type complexData struct {
    N int
    S string
    M map[string]int
    P []byte
    C *complexData
}

const (
    Port = ":61000"
)Copy the code

Outcoing Connections

Using a transmitting connection is a snapshot. Net.conn supports the IO.Reader and IO.Writer interfaces, so we can treat TCP connections just like any other Reader and Writer.

func Open(addr string) (*bufio.ReadWriter, error) { log.Println("Dial " + addr) conn, err := net.Dial("tcp", addr) if err ! = nil { return nil, errors.Wrap(err, "Dialing " + addr + " failed") } return bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil }Copy the code

Example Open a TCP connection. It returns a TCP connection with a timeout and wraps it into a buffered ReadWriter. Dial remote process. Note that local ports are allocated on the fly. If you must specify a local port number, use the DialTCP() method.

Enter the connection

This section deals a bit with the preparation of the entry data. According to the ad-Hoc protocol we introduced earlier, command name + newline + data + newline. Natural data is relevant to specific commands. To handle this situation, we create an Endpoint object with the following properties:

  • It allows you to register one or more processor functions, each of which can handle a particular command.
  • It schedules specific commands to the relevant processor functions based on the command name.

First we declare a HandleFunc type, which is the type of function that receives a bufio.ReadWriter pointer value, which is the handler function we will register for each of the different commands later. The argument it receives is a NET.conn connection wrapped with the ReadWriter interface.

type HandleFunc func(*bufio.ReadWriter)Copy the code

We then declare an Endpoint structure type that has three properties:

  • Listener: The listener object returned by net.listen ().
  • Handler: Used to hold mappings of registered processor functions.
  • M: A mutex to solve the problem of map’s multiple goroutine being unsafe.
Type Endpoint struct {listener net. listener Handler map[string]HandleFunc m sync.RWMutex // Maps are not thread-safe, so mutex is required to control access. } func NewEndpoint() *Endpoint { return &Endpoint{ handler: map[string]HandleFunc{}, } } func (e *Endpoint) AddHandleFunc(name string, f HandleFunc) { e.m.Lock() e.handler[name] = f e.m.Unlock() } func (e *Endpoint) Listen() error { var err error e.listener, err = net.Listen("tcp", Port) if err ! = nil { return errors.Wrap(err, "Unable to listen on "+e.listener.Addr().String()+"\n") } log.Println("Listen on", e.listener.Addr().String()) for { log.Println("Accept a connection request.") conn, err := e.listener.Accept() if err ! = nil { log.Println("Failed accepting a connection request:", Err) continue} log.Println("Handle Incoming messages.") go e.handler messages (conn)}} // handleMessages reads connections to the first newline character. Based on this string, it calls the appropriate HandleFunc. Func (e *Endpoint) handleMessages(conn net.conn) {// Wrap the connection to the buffer reader for reading rw := Bufio.newreadwrite (bufio.newreader (conn), bufio.newwriter (conn)) defer conn.close () // Read from connection until EOF is encountered. Expect the next input to be a command name. Invokes the handler registered for the command. for { log.Print("Receive command '") cmd, err := rw.ReadString('\n') switch { case err == io.EOF: log.Println("Reached EOF - close this connection.\n ---") return case err ! Println("\nError Reading Command. Got: '" + CMD + "'\n", err)} // Trim extra carriage returns and Spaces in the request string - ReadString does not remove any newlines. CMD = strings.trim (CMD, "\n ") log.println (CMD + "'") // Get the appropriate processor function from the handler map and call it. e.m.Lock() handleCommand, ok := e.handler[cmd] e.m.Unlock() if ! ok { log.Println("Command '" + cmd + "' is not registered.") return } handleCommand(rw) } }Copy the code

The NewEndpoint() function is the factory function for the Endpoint. It initializes only the Handler map. To simplify matters, assume that the port on which our terminal listens is fixed.

The Endpoint type declares several methods:

  • AddHandleFunc(): Safely adds handler functions that handle certain types of commands to handler attributes using mutex.
  • Listen(): starts listening on all interfaces of the terminal port. Before calling Listen, at least one handler function must be registered with AddHandleFunc().
  • HandleMessages(): Wrap the connection in Bufio and read it in two steps, first reading the command and then wrapping the line, we get the command name. The handler function that corresponds to the registered command is then retrieved by handler, which is then scheduled to perform data reading and parsing.

Notice how dynamic functions are used above. HandleCommand looks for a function based on the command name, and then assigns the function to handleCommand, which is actually of type HandleFunc, the handler function type declared earlier.

At least one handler function must be registered before the Endpoint’s Listen method is called. So let’s define two types of processor functions: handleStrings and handleGob.

The handleStrings() function receives and processes the processor function that sends string data only in our real-time protocol. The handleGob() function is a complex structure that receives and processes the sent GOB data. HandleGob is a little more complicated. In addition to reading the data, we need to decode the data.

We can see that we use rw.readString (‘n’) twice in a row, reading the string, and saving what we read to the string when we encounter a newline stop. Note that the string contains a trailing newline.

In addition, for ordinary string data, we directly use Bufio wrapped ReadString to read. For complex GOB structures, we use GOB to decode the data.

func handleStrings(rw *bufio.ReadWriter) { log.Print("Receive STRING message:") s, err := rw.ReadString('\n') if err ! = nil { log.Println("Cannot read from connection.\n", err) } s = strings.Trim(s, "\n ") log.Println(s) -, err = rw.WriteString("Thank you.\n") if err ! = nil { log.Println("Cannot write to connection.\n", err) } err = rw.Flush() if err ! = nil { log.Println("Flush failed.", err) } } func handleGob(rw *bufio.ReadWriter) { log.Print("Receive GOB data:") var data complexData dec := gob.NewDecoder(rw) err := dec.Decode(&data) if err ! = nil { log.Println("Error decoding GOB data:", err) return } log.Printf("Outer complexData struct: \n%#v\n", data) log.Printf("Inner complexData struct: \n%#v\n", data.C) }Copy the code

Client and server functions

With everything in place, we can prepare our client and server functions.

  • Client functions connect to the server and send STRING and GOB requests.
  • The server starts listening for the request and triggers the appropriate handler.
Func client(IP string) error {testStruct := complexData{N: 23, S: "string data", M: map[string]int{"one": 1, "two": 2, "three": 3}, P: []byte("abc"), C: &complexData{ N: 256, S: "Recursive structs? Piece of cake!" , M: Map[string]int{"01": "10": 2, "11": 3}, }, } rw, err := Open(ip + Port) if err ! = nil { return errors.Wrap(err, "Client: Failed to open connection to " + ip + Port) } log.Println("Send the string request.") n, err := rw.WriteString("STRING\n") if err ! = nil { return errors.Wrap(err, "Could not send the STRING request (" + strconv.itoa (n) + "bytes written)")} // Send the STRING request. Send the request name and send the data. log.Println("Send the string request.") n, err = rw.WriteString("Additional data.\n") if err ! = nil { return errors.Wrap(err, "Could not send additional STRING data (" + strconv.Itoa(n) + " bytes written)") } log.Println("Flush the buffer.") err = rw.Flush() if err ! = nil {return errors.Wrap(err, "Flush failed.")} // Read log.println ("Read the reply.") response, err := rw.ReadString('\n') if err ! = nil { return errors.Wrap(err, "Client: Failed to read the reply: '" + response + "'") } log.Println("STRING request: Got a response:", response) // Send a GOB request. Create an encoder and convert it directly to the request name of rw.Send. Println("Send a struct as GOB:") log.Printf("Outer complexData struct: \n%#v\n", testStruct) log.Printf("Inner complexData struct: \n%#v\n", testStruct.C) enc := gob.NewDecoder(rw) n, err = rw.WriteString("GOB\n") if err ! = nil { return errors.Wrap(err, "Could not write GOB data (" + strconv.Itoa(n) + " bytes written)") } err = enc.Encode(testStruct) if err ! = nil { return errors.Wrap(err, "Encode failed for struct: %#v", testStruct) } err = rw.Flush() if err ! = nil { return errors.Wrap(err, "Flush failed.") } return nil }Copy the code

The client function is executed when the CONNECT flag is specified during application execution, as you will see later in the code.

The following is the server program. The server listens for incoming requests and dispatches them to the registered specific handler based on the request command name.

Func server() error {endpoint := NewEndpoint() // Add handler function endpoint.addHandlefunc ("STRING", HandleStrings) endpoint.addHandlefunc ("GOB", handleGOB) // Start listening return endpoint.listen ()}Copy the code

The main function

The main function below can start either the client or the server, depending on whether the connect flag is set. If this flag is not present, the process is started with the server and listens for incoming requests. If there is a flag, start as a client and connect to the host specified by this flag.

You can run both processes on the same machine using localhost or 127.0.0.1.

func main() { connect := flag.String("connect", "", "IP address of process to join. If empty, ") flag.parse () // If the connect flag is set, enter client mode if *connect! = '' { err := client(*connect) if err ! = nil { log.Println("Error:", Errors. WithStack(err))} log.Println("Client done.") return} err := server() if err! = nil { log.Println("Error:", Errors.withstack (err))} log.println ("Server done.")} func init() {log.setFlags (log.lshortfile)}Copy the code

How do I get the code and run it

Step 1: Get the code. Note that the -d flag automatically installs binary into the $GOPATH/bin directory.

go get -d github.com/appliedgo/networkingCopy the code

Step 2: CD to the source directory. cd $GOPATH/src/github.com/appliedgo/networking

Step 3: Run the server. go run networking.go

Step 4: Open another shell, again go to the source directory (step 2), and run the client. go run networking.go -connect localhost

Tips

If you want to modify the source code slightly, here are some suggestions:

  • Run the client and server on different machines (on the same LAN).
  • Beef up complexData with more maps and Pointers, and see how GOB cope with it.
  • Start multiple clients at the same time to see if the server can handle them.

2017-02-09: Map is not thread-safe, so if you use the same map in different goroutines, you should use mutex to control access to the map.


In the above code, the map was added before the Goroutine started, so you can safely modify the code to call AddHandleFunc() when the handleMessages Goroutine is already running.

Summary of knowledge points learned in this chapter

— — — — — — 2018-05-04

  • Bufio applications.
  • Application of GOB.
  • Maps are not secure when shared between multiple Goroutines.

Refer to the link

  • TCP/IP network
  • Simple TCP server and client
  • Gob data