Сетевое программирование в Go

Введение

Погрузимся в увлекательный мир сетевого программирования! Если вы еще не сталкивались с сетевым программированием, то речь идет о создании программ, которые взаимодействуют посредством компьютерной сети. Это могут быть любые программы  —  от небольшой, получающей данные с веб-страницы, до сложной, управляющей тысячами соединений. Для написания таких программ идеально подходит Go, обладающий простотой и мощной стандартной библиотекой.

Напишем Go-программу для создания сервера, способного обрабатывать множество соединений и следить за ними. Задача кажется сложной, но не волнуйтесь: чтобы было понятно, разберем все построчно.

Как работает Go-сервер

Go-сервер похож на офисного секретаря, который принимает звонки (сетевые соединения), ведет учет звонков (логирование данных) и может общаться с несколькими людьми одновременно (параллельная обработка нескольких соединений). Приступим к делу и посмотрим, что заставляет его работать!

Отправная точка: импорт пакетов

Начнем с импорта необходимых пакетов:

import (
"context"
"log"
"net"
"os"
"time"

"golang.org/x/sync/semaphore"
)

Эти пакеты предоставляют инструменты для работы с сетями, логирования, управления контекстами и использования семафоров. “Семафоры”  —  это образное выражение, обозначающее контроль над количеством параллельных процессов.

Пакет Semaphore

В Go пакет semaphore используется для ограничения количества определенных операций, выполняемых одновременно.

Представьте его в виде светофора. Он управляет потоком машин (комплексом программных задач), чтобы предотвратить пробки (чрезмерное использование ресурсов).

Что касается кода, то здесь semaphore ограничивает количество соединений, которые сервер обрабатывает одновременно. 

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

Создание главной функции

Главная функция  —  это то место, где начинается магия. Именно она настраивает логирование и создает сетевого слушателя. Этот слушатель ожидает входящих соединений, подобно секретарю, ожидающему телефонного звонка.

func main() {
// Создаем семафор с максимальным весом maxConcurrentConnections.
sem = semaphore.NewWeighted(maxConcurrentConnections)

// Создаем файл лога.
logFile, err := os.OpenFile("network.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("Error opening log file: %v", err)
}
defer logFile.Close()

// Устанавливаем вывод логера по умолчанию в файл лога.
log.SetOutput(logFile)

// Прослушиваем входящие соединения.
l, err := net.Listen("tcp", ":2000")
if err != nil {
log.Fatalf("Error listening: %v", err)
}

// Закрываем слушателя при закрытии приложения.
defer func() {
if err = l.Close(); err != nil {
log.Printf("Error closing listener: %v", err)
}
}()
log.Println("Listening on localhost:2000")
// ...
}

Здесь мы устанавливаем “семафор” (что-то вроде контроля за пропускной способностью офиса). Он предупредит перегрузку от слишком большого количества соединений, обрабатываемых одновременно. Создаем также лог-файл, чтобы отслеживать происходящее. Затем начинаем прослушивать соединения на определенном порту (в данном случае  —  2000).

Принятие и обработка соединений

Помимо прослушивания соединений, нам нужно принимать и обрабатывать их. Именно для этого предназначена следующая часть кода:

for {
// Прослушиваем входящее соединение.
conn, err := l.Accept()
if err != nil {
log.Printf("Error accepting: %v", err)
continue
}
// Устанавливаем KeepAlive для соединения.
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(5 * time.Minute)
}

// Пытаемся получить семафор. Если горутины maxConcurrentConnections уже запущены, это заблокирует их до тех пор,
// пока одна из них не завершится и не выдаст семафор.
if err := sem.Acquire(context.Background(), 1); err != nil {
log.Printf("Failed to acquire semaphore: %v", err)
continue
}

// Обрабатываем соединения в новой горутине.
go func() {
// Выпускаем семафор при завершении горутины.
defer sem.Release(1)
handleRequest(conn)
}()
}

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

Завершение: обработка запросов

Наконец, переходим к функции, в которой обрабатываем каждое соединение:

func handleRequest(conn net.Conn) {
// Создаем буфер для хранения входящих данных.
buf := make([]byte, 1024)
for {
// Записываем входящее соединение в буфер.
len, err := conn.Read(buf)
if err != nil {
log.Printf("Error reading: %v", err)
break
}
// Занесим в лог в IP-адрес и порт удаленного клиента.
log.Printf("Received connection from: %s", conn.RemoteAddr().String())

// Заносим в лог время соединения
log.Printf("Time of connection: %s", time.Now().Format(time.RFC3339))

// Добавляем к общему количеству полученных байтов и заносим новую сумму в лог
totalBytes += len
log.Printf("Received %d bytes from the client. Total bytes received: %d", len, totalBytes)

// Заносим в лог входящее сообщение.
log.Printf("Received message: %s", string(buf[:len]))
}

// Закрываем соединение после его обработки.
defer func() {
if err := conn.Close(); err != nil {
log.Printf("Error closing connection: %v", err)
}
}()
}

Здесь записываем все детали звонка, например от кого он был, когда он был сделан и что было сказано. Как только закончим, закроем соединение и приготовимся к обработке следующего вызова.

Испытаем сервер на практике

Теперь, когда сервер создан, осталось убедиться в том, что он работает корректно. Вот тут-то и приходит на помощь тестирование!

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

Создание тестового клиента

Прежде всего нам понадобится клиент для подключения к серверу. Этот клиент будет открывать соединение, отправлять сообщение, а затем закрывать соединение. Вот простая Go-программа, которая делает именно это:

package main

import (
"log"
"net"
"time"
)

func main() {
conn, err := net.Dial("tcp", "localhost:2000")
if err != nil {
log.Fatalf("Failed to connect to server: %v", err)
}

message := "Hello, Server!"
_, err = conn.Write([]byte(message))
if err != nil {
log.Fatalf("Failed to send message: %v", err)
}

// Ждем некоторое время, чтобы убедиться в том, что сообщение отправлено.
time.Sleep(1 * time.Second)
conn.Close()
}

Запуск теста

Чтобы протестировать сервер, нужно запустить и сервер, и клиента. Вот как это сделать.

  1. Запуск сервера. Откройте терминал, перейдите в каталог, содержащий код сервера, и выполните команду go run server.go (замените server.go на имя файла кода вашего сервера).
  2. Запуск клиента. Откройте новый терминал, перейдите в каталог, содержащий код клиента, и выполните команду go run client.go (замените client.go именем файла кода вашего клиента).

Проверка результатов

После того как клиент отправил свое сообщение, проверьте лог-файл сервера (network.log). Вы должны увидеть запись о том, что сервер получил соединение, а также сообщение от клиента и общее количество полученных байтов. Если вы видите это, поздравляем! Ваш сервер работает так, как нужно.

Помните, что это простой тест для проверки корректной работы сервера. Реальные приложения требуют более тщательного тестирования, включая обработку побочных случаев и потенциальных ошибок. Но пока можете похвалить себя за то, что так далеко продвинулись!

Образец лога

2023/05/17 22:41:56 Listening on localhost:2000
2023/05/17 22:42:01 Received connection from: [::1]:59050
2023/05/17 22:42:01 Time of connection: 2023-05-17T22:42:01+02:00
2023/05/17 22:42:01 Received 14 bytes from the client. Total bytes received: 14

Подведение итогов

Вот и все! Мы создали сервер, способный обрабатывать множество соединений, регистрировать все детали и контролировать количество одновременных соединений. Это может показаться сложным, но вы легко справитесь благодаря простоте и мощности Go.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Mehran: Network Programming in Go. Building a Robust TCP Server

Предыдущая статьяReact Native: полное руководство по созданию виджета для домашнего экрана для iOS и Android
Следующая статьяЛучшие практики написания кода в Spring Boot