🦫 Самоучитель по Go для начинающих. Часть 17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net

В этой части самоучителя мы погрузимся в мир сетевого программирования, изучим его основные концепции и инструменты. Начнем с рассмотрения принципов работы компьютерных сетей и их архитектуры, познакомимся с протоколами TCP и IP, лежащими в основе стека TCP/IP. Затем детально изучим сокеты и их роль в сетевом взаимодействии. Особое внимание будет уделено пакету net, который предоставляет удобные инструменты для реализации сетевых приложений.

Основы сетевого взаимодействия

Перед написанием сетевых приложений разберемся с некоторыми базовыми понятиями. Во-первых, определимся: что же такое сеть? В этой статье под сетью будем понимать совокупность компьютеров, объединенных таким образом, что они способны взаимодействовать между собой посредством сетевых протоколов с целью обмена данными.

Данные передаются по сети в виде небольших сообщений, называемых пакетами. Каждый из них содержит заголовок с адресом назначения, который используют сетевые устройства (например, маршрутизаторы и коммутаторы) для последующего перенаправления пакета.

Что такое сетевой протокол?

Сетевой протокол – это свод правил и стандартов для описания взаимодействия двух и более устройств сети. Поддержка протоколов закладывается на аппаратном и/или программном уровнях.

Сетевые протоколы описывают взаимодействие на различных уровнях: физическом (радиочастоты, сигналы), сетевом (маршрутизация пакетов, логическая адресация) и других. Уровни объединяют в сетевые модели или стеки протоколов. В основе работы интернета лежит стек TCP/IP, описывающий взаимодействие устройств в глобальной сети. Он состоит из двух протоколов – TCP и IP, первый из которых работает поверх второго.

Что такое CP (Transmission Control Protocol)?

TCP (Transmission Control Protocol) — это протокол управления передачей, который обеспечивает доставку и сборку данных в заданном порядке. TCP гарантирует целостность и упорядоченность данных, однако из-за дополнительных проверок его скорость может быть ниже по сравнению с другими протоколами.

Работу TCP можно разделить на три этапа:

  1. Установка сеанса связи с помощью так называемого трехстороннего рукопожатия.
  2. Разбиение данных на сегменты, их нумерация и отправка получателю.
  3. Завершение соединения путем обмена флагами FIN и ACK.

Что такое IP (Internet Protocol)?

IP (Internet Protocol) — это межсетевой протокол, отвечающий за адресацию: доставку определенных данных до целевого компьютера. Именно IP логически объединил локальные компьютерные сети в глобальную, больше известную как Интернет.

Таким образом, протокол IP отвечает за передачу данных, а TCP – за гарантию их доставки.

Стек TCP/IP состоит из четырех уровней:

Название уровня Описание Примеры протоколов
Прикладной Обеспечивает работу сетевых приложений HTTP, DNS, FTP, SMTP
Транспортный Контролирует доставку сообщений и гарантирует корректную последовательность прихода данных TCP, UDP, RSVP
Межсетевой Отвечает за взаимодействие между отдельными сетями, соединяет локальные сети с глобальной IPv4, IPv6, ICMP
Канальный Отвечает за физический процесс передачи информации, её кодирование, разделение на пакеты и отправку по каналам Ethernet, MAC, WLAN, Wi-Fi

После прохождения транспортного уровня пакет попадает в управление операционной системы (ОС), которая подбирает подходящее сетевое приложение с помощью комбинации IP-адреса и сетевого порта.

Что такое IP-адрес?

IP-адрес – это уникальный адрес, идентифицирующий устройство в глобальной или локальной сети. Он представляется 32 или 128-битным числом для версий IPv4 и IPv6 соответственно.

Что такое сетевой порт?

Сетевой порт – это числовой идентификатор, который включается в заголовки транспортного уровня и используется для определения получателя пакета в пределах хоста. В заголовках протоколов TCP и UDP (о нем мы поговорим далее) порты хранятся в виде полей размером 16 бит и разделены на три диапазона: общеизвестные (0–1023), пользовательские (1024–49151) и частные (49152–65535).

Комбинация из IP-адреса и порта называется сокетом. Он представляет собой программный интерфейс для создания каналов взаимодействия между устройствами с целью обмена информацией. Сокеты используются во многих сетевых приложениях: веб-серверах, чатах, электронной почте и других.

Можно сказать, что сокет – это канал общения между программистом и ОС. Вместо того, чтобы разбираться в тонкостях работы протоколов, IP-адресов и портов, программисту достаточно создать в программе объект сокета для отправки и получения сообщений. При этом все задачи по передаче данных возлагаются на ОС.

В следующей части статьи изучим сетевое взаимодействие в Go и реализуем два вида сокетов: TCP и UDP. Второй из них основан на еще одном важном протоколе Интернета – UDP (User Datagram Protocol). Он обеспечивает передачу данных без необходимости установки сеансов связи и получения подтверждений, поэтому работает быстрее TCP, но не гарантирует целостность и упорядоченность информации. Из-за этой особенности UDP применяется там, где скорость работы важнее, чем потеря части пакетов, например, в стриминговых сервисах, онлайн-играх и голосовой связи.

🦫 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🦫🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🦫🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Сетевое взаимодействие в Go

Перейдем к реализации сетевого взаимодействия средствами языка Go. Мы будем работать с клиент-серверной моделью, состоящей из клиента и сервера и работающей по следующим принципам:

  1. Клиент отправляет запрос на определенный сетевой порт и получает от него ответ.
  2. Сервер привязывает сетевые службы к портам, ожидает запросы от клиента и отсылает ему ответы.

Основным пакетом для сетевого программирования в Go является net. Он предоставляет интерфейсы сетевого ввода / вывода, разрешения доменных имен, работы с сокетами, протоколами TCP, IP и UDP.

Пакет net предоставляет доступ к низкоуровневым сетевым инструментам, но для большинства задач хватает нескольких базовых примитивов: функций Dial, Listen, Accept и интерфейсов Conn и Listener, включая их вариации для протоколов TCP, UDP и IP.

Детально рассмотрим устройство интерфейсов Conn и Listener, так как они содержат часто используемые методы для работы с соединениями. В листингах приведено лишь короткое описание методов, более подробную информацию о них можно найти в документации пакета net.

Интерфейс Conn:

type Conn interface {
	// Read считывает данные из соединения.
	Read(b []byte) (n int, err error)

	// Write записывает данные в соединение.
	Write(b []byte) (n int, err error)

	// Close закрывает соединение.
	Close() error

	// LocalAddr возвращает адрес локальной сети, если он известен.
	LocalAddr() Addr

	// RemoteAddr возвращает адрес удаленной сети, если он известен.
	RemoteAddr() Addr

	// SetDeadline устанавливает таймаут для чтения и записи. Эквивалентен
	// вызову обоих методов SetReadDeadline и SetWriteDeadline.
	SetDeadline(t time.Time) error

	// SetReadDeadline устанавливает таймаут для будущих
	// и заблокированных на данный момент вызовов Read.
	SetReadDeadline(t time.Time) error

	// SetWriteDeadline устанавливает таймаут для будущих
	// и заблокированных на данный момент вызовов Write.
	SetWriteDeadline(t time.Time) error
}

Можно заметить, что интерфейс net.Conn реализует интерфейсы io.Reader и io.Writer, поэтому обладает совместимостью со многими объектами пакетов io и bufio. Эта особенность упрощает реализацию ввода-вывода в сетевых соединениях и открывает возможность настройки буферизации и десериализации данных. Для полноценного изучения пакетов io и bufio рекомендуем обратиться к статье: Самоучитель по Go для начинающих. Часть 14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os.

Интерфейс Listener:

type Listener interface {
	// Accept ожидает следующее соединение и возвращает его прослушивателю.
	Accept() (Conn, error)

	// Close закрывает прослушивателя.
	Close() error

	// Addr возвращает адрес сети прослушивателя.
	Addr() Addr
}

Для принятия входящих соединений используются функции Dial и Listen, возвращающие типы Conn и Listener соответственно:

// Dial подключается к адресу в указанной сети.
func Dial(network, address string) (Conn, error)

// Listen прослушивает подключения по адресу локальной сети.
// Возможные значения network: "tcp", "tcp4", "tcp6", "unix", "unixpacket".
func Listen(network, address string) (Listener, error)

Параметры network и address задействованы во многих объектах пакета net, поэтому разберем их детальнее:

  1. параметр network может принимать значения "tcp", "tcp4" (для IPv4), "tcp6" (для IPv6), аналогичные названия для UDP и IP, а также "unix", "unixgram" и "unixpacket".
  2. параметр address для протоколов TCP и UDP должен иметь вид "хост:порт", для IP – "хост". Если значение хоста пусто, например, имеет вид ":80" или "::", то будет использован локальный хост (localhost).

Самое время применить на практике теоретические знания об устройстве сетей и основных компонентов пакета net. Напишем несложный код для отправки HTTP-запроса по протоколу TCP с использованием функции Dial:

func main() {
	// Заголовок HTTP-запроса для получения корневой страницы сайта go.dev
	request := "GET / HTTP/1.1\\nHost: go.dev\\n\\n" 

	// Установка TCP-соединения по хосту go.dev и стандартному HTTP-порту 80
	conn, err := net.Dial("tcp", "go.dev:80")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close() // Отложенное закрытие соединения

	// Отправка HTTP-запроса в соединение и обработка ошибки
	if _, err = conn.Write([]byte(request)); err != nil {
		log.Fatal(err)
	}

	// Копирование данных в стандартный поток вывода из соединения
	io.Copy(os.Stdout, conn) 
}

Обратите внимание: после открытия соединения необходимо обязательно предусмотреть, как оно будет закрыто. Чаще всего для отложенного закрытия используется конструкция defer conn.Close().

В результате выполнения кода в консоль будет выведен стандартный HTTP-ответ от сервера, содержащий справочную информацию:

HTTP/1.1 302 Found
Location: <https://go.dev/>
X-Cloud-Trace-Context: eb1e804740ac2ae3dc5ef2058662f1e1
Date: Sun, 06 Oct 2024 13:15:59 GMT
Content-Type: text/html
Server: Google Frontend
Content-Length: 0

TCP-сервер

Рассмотрим реализацию TCP-сервера, который открывает сетевой порт на прослушивание, принимает соединения от клиента и обрабатывает его запросы в отдельной горутине. Чтобы сократить код и сконцентрироваться на структуре TCP-сервера, опустим обработку ошибок, оставив написание этого функционала в качестве полезного упражнения:

const (
	network = "tcp"
	port    = ":8080"
)

func main() {
	// Преобразование сети и порта в TCP-адрес
	tcpAddr, _ := net.ResolveTCPAddr(network, port)

	// Открытие сокета-прослушивателя
	listener, _ := net.ListenTCP(network, tcpAddr)
	defer listener.Close()

	log.Printf("Прослушивание порта %s...\\n", port)

	for {
		// Принятие TCP-соединения от клиента и создание нового соединения
		conn, err := listener.Accept()
		if err != nil {
			log.Fatal(err)
		}

		// Обработка запросов клиента в отдельной горутине
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()

	log.Printf("Подключен клиент %s\\n", conn.RemoteAddr().String())

	connReader := bufio.NewReader(conn)
	for {
		// Чтение данных из соединения
		data, err := connReader.ReadString('\\n')
		if err != nil {
			break
		}

		// Обработка сообщения от клиента
		message := strings.TrimSpace(string(data))
		log.Printf("Сообщение от %s: %s\\n", conn.RemoteAddr().String(), message)
		if message == "STOP" {
			break
		}

		// Отправка данных в соединение
		conn.Write([]byte(data))
	}
	log.Printf("Отключен клиент %s\\n", conn.RemoteAddr().String())
}

TCP-клиент

Теперь приведем код TCP-клиента, который считывает из стандартного потока ввода сообщение, отправляет его серверу и получает от него ответ:

const (
	network = "tcp"
	port    = ":8080"
)

func main() {
	// Преобразование сети и порта в TCP-адрес
	tcpAddr, _ := net.ResolveTCPAddr(network, port)

	// Создание соединения с сервером по TCP-адресу
	conn, _ := net.DialTCP(network, nil, tcpAddr)
	defer conn.Close()

	reader := bufio.NewReader(os.Stdin)
	connReader := bufio.NewReader(conn)
	for {
		fmt.Print("Сообщение серверу (или 'STOP' для завершения): ")

		// Считывание сообщения из стандартного потока ввода
		data, _ := reader.ReadString('\\n')
		if data == "STOP\\n" {
			break
		}

		// Отправка данных в соединение
		conn.Write([]byte(data))

		// Чтение данных из соединения
		message, _ := connReader.ReadString('\\n')
		fmt.Print("Ответ от сервера: " + message)
	}
}

Для тестирования кода TCP-клиента сначала следует в отдельном окне запустить TCP-сервер на открытом сетевом порту. Примерный вариант взаимодействия между клиентом и сервером может выглядеть так:

2024/10/08 22:43:44 Прослушивание порта :8080...
2024/10/08 22:43:45 Подключен клиент 127.0.0.1:37708
2024/10/08 22:43:49 Сообщение от 127.0.0.1:37708: message1
2024/10/08 22:43:53 Отключен клиент 127.0.0.1:37708

Вывод результата выполнения кода TCP-клиента:

Сообщение серверу (или 'STOP' для завершения): message1
Ответ от сервера: message1
Сообщение серверу (или 'STOP' для завершения): STOP

UDP-сервер

Основное отличие UDP-сервера от TCP-сервера заключается в том, что первый из них не устанавливает отдельного соединения с клиентом, а просто прослушивает определенный порт и обрабатывает входящие запросы по мере их поступления. Поэтому функция net.ListenUDP возвращает указатель на тип UDPConn, в то время как функция net.ListenTCP – указатель на тип TCPListener, что избавляет UDP-сервер от необходимости использовать метод Accept для принятия соединений:

const (
	network = "udp"
	port    = ":8080"
)

func main() {
	// Преобразование сети и порта в UDP-адрес
	addr, _ := net.ResolveUDPAddr(network, port)

	// Создание UDP-прослушивателя (открытие соединения)
	conn, _ := net.ListenUDP(network, addr)
	defer conn.Close()

	log.Printf("Прослушивание порта %s...\\n", port)

	buffer := make([]byte, 1024)
	for {
		// Чтение данных из UDP-соединения
		n, remoteAddr, _ := conn.ReadFromUDP(buffer)

		// Обработка сообщения от клиента
		message := strings.TrimSpace(string(buffer[:n]))
		log.Printf("Сообщение от %s: %s\\n", remoteAddr.String(), message)
		if message == "STOP" {
			break
		}

		// Отправка данных в соединение
		conn.WriteToUDP(buffer[:n], remoteAddr)
	}
}

UDP-клиент

Коды TCP- и UDP-клиентов практически не отличаются, за исключением того, что для чтения данных из UDP-соединения используется отдельный буфер и метод ReadFromUDP:

const (
	network = "udp"
	port    = ":8080"
)

func main() {
	// Преобразование сети и порта в UDP-адрес сервера
	udpAddr, _ := net.ResolveUDPAddr(network, port)

	// Создание UDP-соединения
	conn, _ := net.DialUDP(network, nil, udpAddr)
	defer conn.Close()

	reader := bufio.NewReader(os.Stdin)
	for {
		fmt.Print("Сообщение серверу (или 'STOP' для завершения): ")

		// Считывание сообщения из стандартного потока ввода
		data, _ := reader.ReadString('\\n')
		if data == "STOP\\n" {
			break
		}

		// Отправка данных в соединение
		conn.Write([]byte(data))

		// Чтение данных из соединения
		buffer := make([]byte, 1024)
		n, _, _ := conn.ReadFromUDP(buffer)
		fmt.Printf("Ответ от сервера: %s", string(buffer[:n]))
	}
}

Заключение

В этой части самоучителя мы познакомились с основами компьютерных сетей: изучили основные протоколы, на которых функционирует Интернет, а также узнали о сетевых моделях, стеках, портах и сокетах. Затем мы рассмотрели принципы сетевого программирования в Go и устройство пакета net, после чего закрепили знания на практических примерах.

В следующей части самоучителя познакомимся с еще одним важным протоколом Интернета – HTTP, который лежит в основе обмена данными. Детально рассмотрим пакет net/http и с помощью его компонентов реализуем клиент-серверное взаимодействие по протоколу HTTP.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
29 января 2017

Изучаем алгоритмы: полезные книги, веб-сайты, онлайн-курсы и видеоматериалы

В этой подборке представлен список книг, веб-сайтов и онлайн-курсов, дающих...