golang

Как перестать наступать на грабли в Go: набор рабочих рецептов

  • среда, 25 марта 2026 г. в 00:00:07
https://habr.com/ru/companies/ruvds/articles/1012798/

Пишете на Go или только начинаете изучать язык? Эта шпаргалка точно сэкономит вам кучу времени. Никакой воды, абстрактных рассуждений и скучных введений. Мы пройдёмся по тем самым ситуациям, с которыми бэкендеры сталкиваются на каждом проекте: конкурентность, сеть, работа с JSON, обработка ошибок, тесты и дебаг.

Можете смело добавлять это в закладки. Забыли синтаксис или паттерн, открыли нужный раздел, скопировали, адаптировали и поехали дальше.


Каждый блок кода ниже — это самостоятельный пример. Не пытайтесь скопировать их все в один файл main.go, иначе можно получить конфликт имён. Лучше адаптируйте нужный сниппет под себя и используйте.

Горутины: как запускать и не терять контроль

Горутины часто становятся главной причиной перехода на Go. Это легковесные потоки выполнения, которыми управляет не операционка, а сам рантайм языка. Их можно плодить десятками тысяч, и памяти на это уйдёт минимум. 

Именно на них строится вся прелесть параллельной работы в Go.

В самом банальном виде запуск выглядит вот так:

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Привет из горутины")
}

func main() {
    go sayHello()
    time.Sleep(time.Millisecond * 100)
}

Тут затаилась классическая ловушка для новичков: функция main завершается быстрее, чем фоновая задача успевает отработать. Поэтому тут висит Sleep. В продакшене так делать категорически нельзя.

По-хорошему нам нужно явно дождаться окончания всех процессов. Берем sync.WaitGroup, он работает как обычный счетчик.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Обязательно минусуем счетчик в конце
    fmt.Printf("Воркер %d начал работу\n", id)
    // Тут какая-то полезная нагрузка
    fmt.Printf("Воркер %d закончил\n", id)
}

func main() {
//счетчик
    var wg sync.WaitGroup 

    for i := 1; i <= 5; i++ {
        wg.Add(1) //плюсуем ДО запуска горутины
        go worker(i, &wg)
    }

    wg.Wait() //висит пока счетчик не станет нулем
    fmt.Println("Все задачи выполнены")
}

Советую придерживаться двух важных правил WaitGroup:

  1. Вызывайте Add строго до слова go. Иначе планировщик может отработать так быстро, что вызовет Done до того, как счётчик увеличится.

  2. Done лучше всегда вызывать через defer. Если функция вылетит с ошибкой и не вызовет Done, ваш Waitзависнет навсегда и случится deadlock.

А что, если нам нужно получить результат из горутины? Сами по себе они не могут вернуть значение в вызывающую функцию (return просто завершит саму горутину). Тут на помощь приходят каналы.

package main

import (
    "fmt"
    "sync"
)

func calculateSquare(number int, resultChan chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    resultChan <- number * number
}

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    resultChan := make(chan int, len(numbers))
    var wg sync.WaitGroup

    for _, num := range numbers {
        wg.Add(1)
        go calculateSquare(num, resultChan, &wg)
    }

    //запускаем ожидание в отдельной горутине чтобы не блочить main
    go func() {
        wg.Wait()
        close(resultChan)
    }()

    //чтение данных по мере их поступления
    for result := range resultChan {
        fmt.Println(result)
    }
}

Каналы

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

Самый простой вариант без буфера:

package main

import "fmt"

func main() {
    ch := make(chan string)

    go func() {
        ch <- "важное сообщение" // отправка заблокируется, пока кто-то не прочитает
    }()

    msg := <-ch // чтение заблокируется, пока кто-то не напишет
    fmt.Println(msg)
}

Такая блокировка часто играет на руку, избавляя от лишней ручной синхронизации.

Когда данные кончились, канал принято закрывать. Это даёт понять получателю, что ждать больше нечего.

ch := make(chan int)
close(ch) 

val, ok := <-ch // Если ok == false, значит канал закрыт и пуст
if !ok {
    fmt.Println("Канал закрыт, расходимся")
}

В Go канал всегда закрывает только тот, кто в него пишет, то есть отправитель. Если получатель попытается закрыть канал, а отправитель продолжит в него писать, то ваша программа упадёт с паникой.

В случае, если вам не нужна жёсткая блокировка, можно использовать буферизованные каналы. Тогда отправитель не будет ждать, пока есть свободное место.

package main

import "fmt"

func main() {
    ch := make(chan int, 2) // Буфер на 2 элемента
    ch <- 1 //пройдет мгновенно
    ch <- 2

    fmt.Println(<-ch) 
    fmt.Println(<-ch)
}

Запись в закрытый канал всегда вызывает панику. Будьте аккуратны.

Конструкция select работает как switch, но для каналов. Незаменимая вещь для тайм-аутов или ожидания первой отработавшей задачи. Меня часто выручает при запросах в две разные реплики БД.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string, 1)
    ch2 := make(chan string, 1)
    go func() { time.Sleep(time.Second); ch1 <- "быстрый ответ" }()
    go func() { time.Sleep(time.Second * 2); ch2 <- "долгий ответ" }()

    select {
    case msg := <-ch1:
        fmt.Println(msg)
    case msg := <-ch2:
        fmt.Println(msg)
    case <-time.After(time.Second * 3):
        fmt.Println("Никто не ответил")
    }
}

Часто каналы вычитывают обычным циклом for range. Это нормальный Goшный код, который и читать приятно, и поддерживать не больно. Особенно в паттерне Producer-Consumer.

package main

import (
    "fmt"
    "time"
)

func sendMessages(ch chan<- string) {
    messages := []string{"раз", "два", "три"}
    for _, msg := range messages {
        ch <- msg
        time.Sleep(time.Second / 2)
    }
    close(ch) 
}

func main() {
    ch := make(chan string)
    go sendMessages(ch)

    
    for msg := range ch { 
        fmt.Println("Получили:", msg)
    }
    fmt.Println("Все обработано")
}

Работа с JSON. Маршалинг и теги

Работа с JSON встроена прямо в стандартную библиотеку языка (encoding/json). Достаточно накидать структуру и прописать теги.

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    user := User{Name: "Иван", Age: 30}

    data, err := json.Marshal(user)
    if err != nil {
        panic(err)
    }

    fmt.Println(string(data)) 

    var newUser User
    err = json.Unmarshal(data, &newUser)
    if err != nil {
        panic(err)
    }

    fmt.Printf("%+v\n", newUser) 
}

Теги дают массу возможностей. Например тег omitempty скроет поле, если оно пустое, а знак минуса вообще выкинет его из сериализации. Полезно для паролей и их подобных.

type Profile struct {
    Email string `json:"email,omitempty"`
    Phone string `json:"-"`
}

Иногда требуется кастомное форматирование. Тогда мы просто реализуем интерфейсы Marshaler и Unmarshaler.

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type CustomTime struct {
    time.Time
}

func (ct *CustomTime) MarshalJSON() ([]byte, error) {
  
    //используем RFC3339, чтобы сохранить информацию о часовом поясе
    formatted := ct.Time.Format(time.RFC3339) 
    return json.Marshal(formatted)
}

func (ct *CustomTime) UnmarshalJSON(data []byte) error {
    var formatted string
    if err := json.Unmarshal(data, &formatted); err != nil {
        return err
    }
    t, err := time.Parse(time.RFC3339, formatted)
    if err != nil {
        return err
    }
    ct.Time = t
    return nil
}

type Event struct {
    Name string      `json:"name"`
    Time CustomTime  `json:"time"`
}

func main() {
    event := Event{
        Name: "Синхронизация",
        Time: CustomTime{time.Now()},
    }

    data, _ := json.Marshal(event)
    fmt.Println(string(data))
}

Работа с файлами

Если файл небольшой, хватит обычных os.ReadFile и os.WriteFile. Они грузят файл в память целиком.

package main

import (
    "fmt"
    "os"
)

func main() {
    content, err := os.ReadFile("config.txt")
    if err != nil {
        panic(err)
    }

    fmt.Println(string(content))

    // право доступа 0644 - владелец пишет и читает, остальные только читают
    err = os.WriteFile("backup.txt", content, 0644)
    if err != nil {
        panic(err)
    }
}

Если файл весит пару гигабайт, читать его целиком, очевидно, не вариант. Берём bufio.Scanner и идём построчно. Если строки слишком длинные или вы имеете дело с бинарными данными, то лучше использовать bufio.Reader или io.Reader.

package main

import (
    "bufio"
    "os"
)

func main() {
    file, err := os.Open("huge_logs.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        _ = line
    }

    if err := scanner.Err(); err != nil {
        panic(err)
    }
}

Для бинарников или стриминга отлично подходит связка io.Reader и io.Writer.

package main

import (
    "io"
    "os"
)

func copyFile(source, destination string) error {
    src, err := os.Open(source)
    if err != nil {
        return err
    }
    defer src.Close()

    dst, err := os.Create(destination)
    if err != nil {
        return err
    }

    defer dst.Close()

    _, err = io.Copy(dst, src)
    return err
}

func main() {
    err := copyFile("video.mp4", "copy.mp4")
    if err != nil {
        panic(err)
    }
}

Пишем HTTP-сервер

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

package main

import (
    "fmt"
    "log"
    "net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Привет, %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/", helloHandler)
    if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal(err)
}
}

Если пишете API, нужно проверять методы и парсить тело запроса.

package main

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

type RequestData struct {
    Name string `json:"name"`
}

type ResponseData struct {
    Message string `json:"message"`
}

func apiHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Нужен POST запрос", http.StatusMethodNotAllowed)
        return
    }

    defer r.Body.Close()

	var req RequestData
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Невалидный JSON", http.StatusBadRequest)
        return
    }

    resp := ResponseData{Message: "Салют, " + req.Name}
    w.Header().Set("Content-Type", "application/json")
    if err := json.NewEncoder(w).Encode(resp); err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
}
}

func main() {
    http.HandleFunc("/api", apiHandler)
    http.ListenAndServe(":8080", nil)
}

А вот так накручиваются middleware для логирования, проверки токенов и прочего:

package main

import (
	"encoding/json"
    "log"
    "net/http"
    "time"
)

func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next(w, r)
        log.Printf("%s %s заняло %v", r.Method, r.URL.Path, time.Since(start))
    }
}

func main() {
    helloHandler := func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Привет!"))
    }

    http.HandleFunc("/", loggingMiddleware(helloHandler))
    http.ListenAndServe(":8080", nil)
}

Честная обработка ошибок

Никаких непредсказуемых try/catch. Ошибка в Go — это обычное значение, которое функция возвращает наравне с результатом. Сразу видно, где может сломаться код.

package main

import (
    "errors"
    "fmt"
)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("на ноль делить нельзя!!!")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil { 
        fmt.Println("Поймали ошибку:", err)
        return 
    }
    fmt.Println("Итог:", result)
}

Начиная с Go 1.13, появилось удобное оборачивание ошибок через %w.

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("объект не найден")

func findUser(id int) error {
    return fmt.Errorf("поиск юзера %d: %w", id, ErrNotFound)
}

func main() {
    err := findUser(42)
    //можно проверять конкретную ошибку (с учетом оберток)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Пользователя нет в базе")
    }
}

Тестирование

Тесты пишутся прямо в пакете рядом с кодом. Достаточно встроенного testing.

package main

import "testing"

func Add(a, b int) int {
    return a + b
}

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("ждали 5, а получили %d", result) 
    }
}

Если сценариев много, пишут table driven tests.

func TestAddTable(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"положительные", 2, 3, 5},
        {"отрицательные", -2, -3, -5},
        {"с нулем", 0, 5, 5},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d, ждали %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Хэндлеры удобно тестировать через net/http/httptest, имитируя реальные запросы.

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)
// этот тест зависит от helloHandler из примера выше
// если запускаете отдельно, то добавьте реализацию handleк
func TestHelloHandler(t *testing.T) {
    req, err := http.NewRequest("GET", "/Иван", nil)
    if err != nil {
        t.Fatal(err)
    }

    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(helloHandler)
    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("статус %v, ждали %v", status, http.StatusOK)
    }

    expected := "Привет, Иван!"
    if rr.Body.String() != expected {
        t.Errorf("тело ответа %v, ждали %v", rr.Body.String(), expected)
    }
}

Контекст: управляем отменой операций

Пакет context — это спасательный круг для сетевых запросов и работы с БД. Он позволяет обрубать зависшие операции по тайм-ауту.

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, done chan<- struct{}) {
    defer close(done) //сообщаем, что воркер закончил работу

    timer := time.NewTimer(time.Second * 2)
    defer timer.Stop() //нужен, чтобы корректно освободить ресурсы таймера

    select {
    case <-timer.C:
        fmt.Println("Успешно отработано")
    case <-ctx.Done():
        fmt.Println("Отменили:", ctx.Err()) 
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    done := make(chan struct{})
    go worker(ctx, done)
    
    <-done
}

Он незаменим в HTTP-клиентах, чтобы не ждать вечно ответа от лежащего стороннего API.

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func makeRequest(ctx context.Context, url string) error {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return err
    }

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    fmt.Println("Код ответа:", resp.Status)
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
    defer cancel()

    err := makeRequest(ctx, "https://example.com")
    if err != nil {
        fmt.Println("Упало с ошибкой:", err)
    }
}

Через контекст можно прокидывать Request ID для логов, но лучше не пихать туда обычные аргументы функций, это считается антипаттерном.

Немного про базы данных

Глубокое погружение в БД требует отдельного гайда, но вот абсолютный минимум для PostgreSQL.

package main

import (
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
    "log"
)

type User struct {
    ID   int
    Name string
}

func main() {
    connStr := "user=postgres dbname=test sslmode=disable"
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err = db.Ping(); err != nil {
        log.Fatal(err)
    }

    _, err = db.Exec("INSERT INTO users (name) VALUES ($1)", "Иван")
    if err != nil {
        log.Fatal(err)
    }

    rows, err := db.Query("SELECT id, name FROM users")
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close() //всегда закрываем rows!!

    var users []User
    for rows.Next() {
        var u User
        if err = rows.Scan(&u.ID, &u.Name); err != nil {
            log.Fatal(err)
        }
        users = append(users, u)
    }

    //проверка на ошибки
    if err = rows.Err(); err != nil {
        log.Fatal("ошибка при чтении строк:", err)
    }

    fmt.Printf("%+v\n", users)
}

Подводя итог

Выше мы разобрали основной рабочий арсенал Go-разработчика. Горутины, каналы, контексты, обработка JSON и HTTP-роутинг покрывают процентов 80 типовых задач на бэкенде.

Главная фишка Go кроется в простоте. Код читается сверху вниз, магии под капотом минимум. Старайтесь не усложнять архитектуру абстракциями без острой необходимости. Начинайте с самого топорного и понятного решения, а рефакторинг оставляйте на потом.

Удачи в разработке!

© 2026 ООО «МТ ФИНАНС»