
Пишете на 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. Маршалинг и теги
Работа с 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("%+vn", 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("%+vn", users)
}
Подводя итог
Выше мы разобрали основной рабочий арсенал Go-разработчика. Горутины, каналы, контексты, обработка JSON и HTTP-роутинг покрывают процентов 80 типовых задач на бэкенде.
Главная фишка Go кроется в простоте. Код читается сверху вниз, магии под капотом минимум. Старайтесь не усложнять архитектуру абстракциями без острой необходимости. Начинайте с самого топорного и понятного решения, а рефакторинг оставляйте на потом.
Удачи в разработке!
© 2026 ООО «МТ ФИНАНС»
Автор: rRenegat
