Как перестать наступать на грабли в Go: набор рабочих рецептов
- среда, 25 марта 2026 г. в 00:00:07

Пишете на 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:
Вызывайте Add строго до слова go. Иначе планировщик может отработать так быстро, что вызовет Done до того, как счётчик увеличится.
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 встроена прямо в стандартную библиотеку языка (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) } }
В 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 ООО «МТ ФИНАНС»