golang

Кратко про сетевые протоколы в Golang: TCP, QUIC и UDP

  • воскресенье, 28 июля 2024 г. в 00:00:07
https://habr.com/ru/companies/otus/articles/830096/

Привет, Хабр!

Сегодня мы кратко рассмотрим то, как реализовать такие протколы, как TCP, UDP и QUIC в Golang.

Начнем с TCP.

TCP

TCP — это очень надежный, ориентированный на соединение протокол. Он обеспечивает упорядоченную передачу данных, автоматом исправляя ошибки.

Основные черты TCP:

  • Надежность: подтверждения и повторная отправка потерянных пакетов.

  • Упорядоченность: передача данных в том порядке, в котором они были отправлены.

  • Контроль перегрузки: предотвращение коллапса сети за счет контроля скорости передачи данных.

Go имеет пакет net для создания серверов и клиентов TCP. В этом пакете есть несколько функций, которые позволяют управлять сетевыми соединениями.

Для инициализация слушающего сокета используется функция net.Listen, которая принимает тип сети и адрес. Пример вызова: listener, err := net.Listen("tcp", "localhost:8080"). Функция возвращает объект Listener, который будет слушать входящие соединения на указанном порту.

После создания слушателя, можно принимать входящие соединения в цикле, используя listener.Accept(). Метод блокируется до тех пор, пока не поступит новое входящее соединение. Каждое новое соединение можно обрабатывать в отдельной горутине для асинхронной обработки.

С помощью полученного объекта Conn, можно читать данные через conn.Read() и отправлять данные через conn.Write().

Для создания TCP клиента используется функция net.Dial, которая устанавливает соединение с сервером. Пример: conn, err := net.Dial("tcp", "localhost:8080").

Аналогично серверу, через объект Conn можно отправлять и получать данные.

Пример

Реализуем простую систему обмена сообщениями между сервером и клиентом.

Сервер будет слушать входящие TCP подключения, принимать сообщения от клиентов, и отправлять простое подтверждение о получении сообщения:

package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
)

func main() {
    // определяем порт для прослушивания
    PORT := ":9090"
    listener, err := net.Listen("tcp", PORT)
    if err != nil {
        fmt.Println("Error listening:", err.Error())
        os.Exit(1)
    }
    // закрываем listener при завершении программы
    defer listener.Close()
    fmt.Println("Server is listening on " + PORT)

    for {
        // принимаем входящее подключение
        conn, err := listener.Accept()
        if err != nil {
            fmt.Println("Error accepting:", err.Error())
            os.Exit(1)
        }
        fmt.Println("Connected with", conn.RemoteAddr().String())
        // обрабатываем подключение в отдельной горутине
        go handleRequest(conn)
    }
}

func handleRequest(conn net.Conn) {
    defer conn.Close()
    // читаем данные от клиента
    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        clientMessage := scanner.Text()
        fmt.Printf("Received from client: %s\n", clientMessage)
        // отправляем ответ клиенту
        conn.Write([]byte("Message received.\n"))
    }

    if err := scanner.Err(); err != nil {
        fmt.Println("Error reading:", err.Error())
    }
}

Клиент будет подключаться к серверу, отправлять сообщения и получать ответы от сервера:

package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
)

func main() {
    // соединяемся с сервером
    conn, err := net.Dial("tcp", "localhost:9090")
    if err != nil {
        fmt.Println("Error connecting:", err.Error())
        os.Exit(1)
    }
    defer conn.Close()

    // читаем сообщения с консоли и отправляем их серверу
    consoleScanner := bufio.NewScanner(os.Stdin)
    fmt.Println("Enter text to send:")
    for consoleScanner.Scan() {
        text := consoleScanner.Text()
        conn.Write([]byte(text + "\n"))

        // получаем ответ от сервера
        response, err := bufio.NewReader(conn).ReadString('\n')
        if err != nil {
            fmt.Println("Error reading:", err.Error())
            os.Exit(1)
        }
        fmt.Print("Server says: " + response)
        fmt.Println("Enter more text to send:")
    }

    if err := consoleScanner.Err(); err != nil {
        fmt.Println("Error reading from console:", err.Error())
    }
}

UDP

UDP — это простой протокол без установления соединения, который не гарантирует доставку, порядок или интегральность данных. Но зато, он дает минимум задержки.

Основные черты UDP:

  • Отсутствие процесса установления соединения уменьшает задержку.

  • Меньше накладных расходов, больше производительности.

Для создания UDP сервера используется функция net.ListenPacket() или net.ListenUDP(). Они позволяют привязать сервер к определенному адресу и порту. Сервер будет слушать входящие UDP пакеты и может отвечать на них без установления постоянного соединения, что характерно для UDP.

Пример

Пример сервера:

package main

import (
    "fmt"
    "net"
)

func main() {
    conn, err := net.ListenPacket("udp", ":8080")
    if err != nil {
        fmt.Println("Error creating socket:", err)
        return
    }
    defer conn.Close()

    fmt.Println("Listening on :8080...")

    buf := make([]byte, 1024)

    for {
        n, addr, err := conn.ReadFrom(buf)
        if err != nil {
            fmt.Println("Error reading datagram:", err)
            continue
        }

        if _, err := conn.WriteTo(buf[:n], addr); err != nil {
            fmt.Println("Error writing datagram:", err)
        }
    }
}

Клиент UDP в Go создается с использованием функции net.DialUDP() или net.Dial("udp", address), которая возвращает объект net.Connс методамиRead и Write для отправки и получения данных.

Пример клиента:

package main

import (
    "fmt"
    "net"
)

func main() {
    addr, err := net.ResolveUDPAddr("udp", "localhost:8080")
    if err != nil {
        fmt.Println("Error resolving address:", err)
        return
    }

    conn, err := net.DialUDP("udp", nil, addr)
    if err != nil {
        fmt.Println("Error creating socket:", err)
        return
    }
    defer conn.Close()

    data := "Hello, server!"
    if _, err := conn.Write([]byte(data)); err != nil {
        fmt.Println("Error sending datagram:", err)
        return
    }

    buf := make([]byte, len(data))
    if _, err := conn.Read(buf); err != nil {
        fmt.Println("Error reading datagram:", err)
        return
    }

    fmt.Println("Received from server:", string(buf))
}

UDP не использует установление соединения, что делает его быстрее TCP для проектов, где допустима потеря данных.

Функции ReadFrom() и WriteTo() используются для обмена данными без необходимости установления постоянного соединения.

Нет необходимости в слушающем объекте типа Listener, как это требуется в TCP, поскольку UDP оперирует на основе датаграмм, а не потоков данных.

QUIC

QUIC — это уже современный протокол, разработанный Google и стандартизированный IETF, который стремится улучшить производительность соединений, предоставляемых TCP, с добавлением функций безопасности, аналогичных TLS/SSL. QUIC работает поверх UDP и предназначен для снижения задержек соединения, поддерживает мультиплексирование потоков без взаимного блокирования и управляет потерей пакетов более лучше, чем TCP.

Основные черты QUIC:

  • Уменьшение задержек:уменьшает задержку соединения за счет использования 0-RTT и 1-RTT рукопожатий.

  • Безопасность: включает встроенное шифрование на уровне соединений.

  • Мультиплексирование: позволяет нескольким потокам данных обмениваться данными в рамках одного соединения без взаимной блокировки.

Работа с протоколом QUIC в Go проходит с помощью библиотеки quic-go, которая представляет собой полноценную реализацию QUIC. Эта библиотека поддерживает множество стандартов, включая HTTP/3.

В quic-go можно инициализировать транспортное соединение с помощью quic.Transport, которое позволяет мультиплексировать несколько соединений с одного UDP-сокета.

Для установки соединения можно использовать функции quic.Dial или quic.DialAddr, которые не требуют предварительной инициализации quic.Transport. Эти функции позволяют быстро подключиться к серверу с заданными конфигурациями TLS и QUIC.

Чтобы создать пример сервера и клиента на QUIC в Go, можно использовать библиотеку quic-go, которая предоставляет полную реализацию протокола QUIC. Вот как вы можете создать базовый QUIC сервер и клиент с использованием этой библиотеки.

Пример

Сервер будет слушать на определённом порту и отвечать на входящие сообщения от клиента:

package main

import (
    "context"
    "crypto/tls"
    "fmt"
    "io"
    "log"

    "github.com/lucas-clemente/quic-go"
)

func main() {
    listener, err := quic.ListenAddr("localhost:4242", generateTLSConfig(), nil)
    if err != nil {
        log.Fatal("Failed to listen:", err)
    }

    for {
        sess, err := listener.Accept(context.Background())
        if err != nil {
            log.Fatal("Failed to accept session:", err)
        }

        go func() {
            for {
                stream, err := sess.AcceptStream(context.Background())
                if err != nil {
                    log.Fatal("Failed to accept stream:", err)
                }

                // эхо полученных данных обратно клиенту
                _, err = io.Copy(stream, stream)
                if err != nil {
                    log.Fatal("Failed to echo data:", err)
                }
            }
        }()
    }
}

func generateTLSConfig() *tls.Config {
    key, cert := generateKeys() // Допустим, что функция generateKeys генерирует TLS ключ и сертификат
    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        NextProtos:   []string{"quic-echo-example"},
    }
}

Клиент будет подключаться к серверу, отправлять сообщения и получать ответы:

package main

import (
    "context"
    "crypto/tls"
    "fmt"
    "io"
    "log"
    "os"

    "github.com/lucas-clemente/quic-go"
)

func main() {
    session, err := quic.DialAddr("localhost:4242", &tls.Config{InsecureSkipVerify: true}, nil)
    if err != nil {
        log.Fatal("Failed to dial:", err)
    }

    stream, err := session.OpenStreamSync(context.Background())
    if err != nil {
        log.Fatal("Failed to open stream:", err)
    }

    fmt.Fprintf(stream, "Hello, QUIC Server!\n")
    buf := make([]byte, 1024)
    n, err := io.ReadFull(stream, buf)
    if err != nil {
        log.Fatal("Failed to read from stream:", err)
    }

    fmt.Printf("Server says: %s", string(buf[:n]))
}

Материал подготовлен в рамках старта онлайн-курса "Go (Golang) Developer Basic".