Go на практике: от синтаксиса до веб-сервисов

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

Основы языка и инструменты: пишем первый консольный калькулятор

Основы языка и инструменты: пишем первый консольный калькулятор

Добро пожаловать в курс Go на практике. Мы не будем тратить время на скучную теорию без дела. Философия этого курса проста: мы изучаем концепцию и сразу же применяем её в реальном коде. К концу курса вы пройдете путь от «Hello World» до написания собственных микросервисов.

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

Почему Go?

Язык Go (или Golang) был разработан в Google для решения конкретных проблем: долгая сборка проектов, сложная работа с зависимостями и трудности в написании конкурентного кода.

Go — это компилируемый, статически типизированный язык. Это значит, что:

  1. Компилируемый: код превращается в бинарный файл (машинный код), который можно запустить на сервере даже без установленного Go.
  2. Статически типизированный: если вы сказали, что переменная — это число, она не может внезапно стать строкой. Это спасает от тысяч глупых ошибок.

Установка инструментов

Прежде чем писать код, нам нужно подготовить рабочее место.

1. Компилятор Go

Зайдите на официальный сайт Go и скачайте установщик для вашей операционной системы (Windows, macOS или Linux). Установка проходит в режиме «Далее — Далее — Готово».

Чтобы проверить, что всё прошло успешно, откройте терминал (или командную строку) и введите:

go version

Вы должны увидеть что-то вроде go version go1.21.0 ....

2. Среда разработки (IDE)

Писать код в блокноте можно, но больно. Я рекомендую два варианта:

  • VS Code: Бесплатный, легкий. Обязательно установите расширение Go от команды Go Team.
  • GoLand: Платный (есть пробный период), мощный, от JetBrains. Идеален для больших проектов.

Анатомия Go-программы

Любая программа на Go имеет четкую структуру. Давайте создадим файл main.go и напишем классический приветственный код, чтобы разобрать его по косточкам.

package main

import "fmt"

func main() {
    fmt.Println("Привет, будущий Gopher!")
}

Разберем каждую строку:

  1. package main: Эта строка говорит компилятору, что этот файл — исполняемая программа, а не библиотека. Точка входа в любую программу на Go всегда находится в пакете main.
  2. import "fmt": Мы подключаем пакет fmt (format). В Go очень богатая стандартная библиотека. Пакет fmt отвечает за ввод и вывод данных (печать текста на экран).
  3. func main() { ... }: Это главная функция. Когда вы запускаете программу, выполнение кода начинается именно отсюда.

Переменные и типы данных

Для нашего калькулятора нам нужно где-то хранить числа и операцию, которую выбрал пользователь. Для этого нужны переменные.

В Go есть два основных способа создать переменную:

1. Полная декларация

Используется ключевое слово var. Мы явно указываем имя и тип.

var age int = 25
var name string = "Alex"

2. Краткая декларация (Магия Go)

Внутри функций можно использовать оператор :=. Go сам догадается, какой тип у переменной, посмотрев на значение справа.

age := 25      // Go понимает, что это int (целое число)
pi := 3.14     // Go понимает, что это float64 (дробное число)

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

Для калькулятора нам понадобятся:

  • float64 — для чисел с плавающей точкой (ведь мы можем захотеть разделить 5 на 2).
  • string — для знака операции (+, -, *, /).

Логика выбора: if и switch

Калькулятор должен уметь принимать решения. «Если пользователь ввел плюс, нужно сложить».

Конструкция if-else

Классическая проверка условия. Обратите внимание: круглые скобки вокруг условия в Go не нужны.

if x > 10 {
    fmt.Println("Больше десяти")
} else {
    fmt.Println("Меньше или равно десяти")
}

Конструкция switch

Когда вариантов много (как операций в калькуляторе), switch подходит лучше. Он выглядит чище.

switch operator {
case "+":
    // код сложения
case "-":
    // код вычитания
default:
    // если ничего не подошло
}

В отличие от других языков (C++, Java), в Go не нужно писать break после каждого case. Go выполняет только один подходящий блок и выходит из switch.

Практика: Пишем консольный калькулятор

Теперь соберем всё вместе. Наша цель — программа, которая:

  1. Спрашивает первое число.
  2. Спрашивает второе число.
  3. Спрашивает операцию.
  4. Выводит результат.

Создайте папку для проекта, откройте её в терминале и инициализируйте модуль (это создаст файл go.mod для управления зависимостями, хорошая привычка с самого начала):

go mod init calculator

Теперь создайте файл main.go и начнем писать код.

Шаг 1: Импорты и переменные

package main

import (
    "fmt"
    "os"
)

func main() {
    var a, b float64 // Переменные для чисел
    var op string    // Переменная для оператора

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

Шаг 2: Чтение данных

Для ввода данных используем fmt.Scan. Эта функция считывает данные из консоли и кладет их в переменную.

    fmt.Print("Введите первое число: ")
    fmt.Scan(&a) // Обратите внимание на знак &

    fmt.Print("Введите второе число: ")
    fmt.Scan(&b)

    fmt.Print("Введите операцию (+, -, *, /): ")
    fmt.Scan(&op)

Почему &a? Символ & означает, что мы передаем не значение переменной (которое пока пустое), а адрес в памяти, где эта переменная лежит. Функция Scan пойдет по этому адресу и запишет туда то, что вы введете с клавиатуры.

Шаг 3: Логика вычислений

Теперь самое интересное. Используем switch для обработки операций.

    var res float64

    switch op {
    case "+":
        res = a + b
    case "-":
        res = a - b
    case "*":
        res = a * b
    case "/":
        if b == 0 {
            fmt.Println("Ошибка: деление на ноль!")
            os.Exit(1) // Завершаем программу с кодом ошибки
        }
        res = a / b
    default:
        fmt.Println("Неизвестная операция")
        os.Exit(1)
    }

    fmt.Printf("Результат: %f\n", res)
}

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

Математически мы проверяем условие:

b≠0b \neq 0

где bb — это делитель (второе введенное число). Если bb равно нулю, операция невозможна.

Также мы использовали fmt.Printf. Это «форматированный вывод». Символ %f заменяется на значение переменной res. \n означает перенос строки.

Полный код проекта

Вот что у вас должно получиться в файле main.go:

package main

import (
	"fmt"
	"os"
)

func main() {
	var a, b float64
	var op string

	fmt.Print("Введите первое число: ")
	fmt.Scan(&a)

	fmt.Print("Введите второе число: ")
	fmt.Scan(&b)

	fmt.Print("Введите операцию (+, -, *, /): ")
	fmt.Scan(&op)

	var res float64

	switch op {
	case "+":
		res = a + b
	case "-":
		res = a - b
	case "*":
		res = a * b
	case "/":
		if b == 0 {
			fmt.Println("Ошибка: деление на ноль!")
			os.Exit(1)
		}
		res = a / b
	default:
		fmt.Println("Неизвестная операция")
		os.Exit(1)
	}

	fmt.Printf("Результат: %.2f\n", res)
}

Небольшое улучшение: я заменил %f на %.2f в последней строке. Это скажет Go выводить только два знака после запятой, чтобы результат выглядел аккуратнее (например, 5.00 вместо 5.000000).

Запуск программы

У нас есть два пути запуска:

  1. Быстрый запуск (для разработки): Команда компилирует и сразу запускает программу, не создавая exe-файл.

    go run main.go
    
  2. Компиляция (для релиза): Команда создает бинарный файл (например, calculator.exe на Windows).

    go build
    

    После этого вы можете запустить созданный файл как обычную программу.

Попробуйте запустить свой калькулятор и посчитать что-нибудь!

Резюме

Сегодня вы сделали огромный шаг. Вы не просто узнали синтаксис, вы:

  • Настроили профессиональное окружение.
  • Поняли структуру Go-файлов (package, import, func).
  • Научились работать с переменными и вводом/выводом.
  • Написали работающую программу с логикой ветвления.

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

Сложные типы данных и структуры: создание менеджера задач (To-Do List)

Сложные типы данных и структуры: создание менеджера задач (To-Do List)

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

Сегодня мы перейдем на следующий уровень. Мы разберем, как Go работает с коллекциями данных, создадим свои собственные типы данных и напишем консольный менеджер задач (To-Do List), который будет хранить ваши дела.

Массивы и Слайсы: в чем разница?

Во многих языках есть понятие «массив» или «список». В Go есть два понятия: Array (Массив) и Slice (Слайс/Срез). Понимание разницы между ними — ключ к эффективной работе.

Массивы (Arrays)

Массив в Go — это структура фиксированной длины. Если вы создали массив на 5 элементов, он не может стать больше или меньше.

var numbers [5]int // Массив из 5 целых чисел
numbers[0] = 10
numbers[4] = 20

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

A=B+(i×s)A = B + (i \times s)

Где AA — итоговый адрес ячейки памяти, BB — базовый адрес начала массива, ii — индекс элемента (номер ячейки), а ss — размер одного элемента в байтах (например, 8 байт для int64).

Однако в реальной разработке массивы используются редко из-за их жесткости. Нам нужна гибкость.

Слайсы (Slices)

Слайс — это гибкая обертка над массивом. У него нет фиксированной длины, и он может расти по мере необходимости. В 99% случаев в Go вы будете использовать именно слайсы.

Создание слайса:

// Пустой слайс строк
var tasks []string

// Слайс с начальными значениями
scores := []int{10, 20, 30}

Главная магия слайсов — функция append. Она добавляет элемент в конец списка, автоматически увеличивая его размер.

tasks = append(tasks, "Купить молоко")
tasks = append(tasks, "Выучить Go")

Важно: Функция append возвращает новый (или обновленный) слайс, поэтому мы обязательно должны присвоить результат обратно переменной: tasks = append(tasks, ...).

Карты (Maps): хранение пар «Ключ-Значение»

Иногда нам нужно найти значение не по индексу (0, 1, 2), а по имени или ID. Для этого существуют Maps (в других языках: словари, хеш-таблицы, ассоциативные массивы).

// Создаем карту, где ключ - строка (имя), а значение - int (возраст)
ages := make(map[string]int)

ages["Alice"] = 25
ages["Bob"] = 30

fmt.Println(ages["Alice"]) // Выведет: 25

Если вы запросите ключ, которого нет, Go не выдаст ошибку, а вернет «нулевое значение» для типа данных (0 для чисел, пустая строка для строк).

Структуры (Structs): создаем свои типы

Слайсы и карты хороши, но что, если нам нужно описать сложный объект? Например, задачу в нашем списке. У задачи есть:

  1. Название (string)
  2. Описание (string)
  3. Статус выполнения (bool)

Хранить это в разных массивах неудобно. На помощь приходят структуры.

type Task struct {
    ID          int
    Title       string
    Description string
    Done        bool
}

Теперь Task — это полноценный тип данных, как int или string. Мы можем создавать переменные этого типа:

myTask := Task{
    ID:    1,
    Title: "Изучить структуры",
    Done:  false,
}

// Обращение к полям через точку
myTask.Done = true

Циклы: Range

В Go есть только один вид цикла — for. Но он умеет всё. Чтобы пройтись по слайсу или карте, используется ключевое слово range.

tasks := []string{"Task 1", "Task 2"}

for index, value := range tasks {
    fmt.Printf("%d. %s\n", index, value)
}

Если индекс вам не нужен (частая ситуация), его можно заменить на _ (пустой идентификатор), чтобы компилятор не ругался на неиспользуемую переменную:

for _, task := range tasks {
    fmt.Println(task)
}

Практика: Пишем To-Do List

Теперь применим знания. Мы напишем программу, которая:

  1. Хранит список задач (используя struct и slice).
  2. Позволяет добавлять задачи.
  3. Позволяет просматривать список.
  4. Позволяет отмечать задачи как выполненные.

Создайте новую папку и файл main.go.

Шаг 1: Подготовка и структура

Нам понадобится пакет bufio для чтения строк с пробелами (так как fmt.Scan читает только до первого пробела).

package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

// Объявляем структуру задачи
type Todo struct {
    Title string
    Done  bool
}

func main() {
    // Создаем пустой слайс для хранения задач
    var todos []Todo

    // Сканер для чтения ввода пользователя
    scanner := bufio.NewScanner(os.Stdin)

    fmt.Println("--- TODO LIST ---")
    // ... здесь будет бесконечный цикл
}

Шаг 2: Главный цикл приложения

Мы будем крутить программу в бесконечном цикле for, пока пользователь не введет команду выхода.

    for {
        fmt.Println("\nВведите команду (add, list, done, exit):")
        scanner.Scan() // Ждем ввода
        command := scanner.Text()
        command = strings.TrimSpace(command) // Убираем лишние пробелы

        switch command {
        case "add":
            // Логика добавления
        case "list":
            // Логика просмотра
        case "done":
            // Логика завершения
        case "exit":
            fmt.Println("До свидания!")
            return // Выход из функции main, а значит и из программы
        default:
            fmt.Println("Неизвестная команда")
        }
    }

Шаг 3: Реализация команд

Добавим код внутрь case блоков.

Добавление (add):

case "add":
    fmt.Print("Введите название задачи: ")
    scanner.Scan()
    text := scanner.Text()

    // Создаем новую задачу
    newTask := Todo{
        Title: text,
        Done:  false,
    }

    // Добавляем в слайс
    todos = append(todos, newTask)
    fmt.Println("Задача добавлена!")

Просмотр (list): Здесь мы используем цикл range. Также добавим красивое отображение галочки выполнения.

case "list":
    if len(todos) == 0 {
        fmt.Println("Список пуст")
        continue
    }

    for index, task := range todos {
        // Выбираем значок: [x] или [ ]
        status := " "
        if task.Done {
            status = "x"
        }
        // Выводим: 1. [ ] Купить хлеб
        fmt.Printf("%d. [%s] %s\n", index+1, status, task.Title)
    }

Завершение задачи (done): Пользователь введет номер задачи, а мы пометим её как выполненную. Нам нужно будет обратиться к элементу слайса по индексу.

case "done":
    fmt.Print("Введите номер задачи: ")
    var id int
    fmt.Scan(&id)
    // Важно: после fmt.Scan в буфере остается символ переноса строки,
    // который может помешать следующему scanner.Scan().
    // Простой хак - вычитать его:
    scanner.Scan()

    // Проверяем, что номер корректный
    // Индексы в Go с 0, а пользователь вводит с 1, поэтому id-1
    if id > 0 && id <= len(todos) {
        todos[id-1].Done = true
        fmt.Println("Задача выполнена!")
    } else {
        fmt.Println("Неверный номер задачи")
    }

Полный код

Соберите все части вместе в main.go. Запустите программу через go run main.go.

Попробуйте:

  1. Ввести add, затем Купить молоко.
  2. Ввести add, затем Изучить Go.
  3. Ввести list.
  4. Ввести done, затем 1.
  5. Снова list, чтобы увидеть результат.

Резюме

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

  • Слайсы — для динамических списков.
  • Структуры — для создания собственных объектов.
  • Циклы Range — для удобного перебора данных.

Ваш To-Do List работает, но пока он "забывает" все задачи после перезапуска программы, так как хранит их в оперативной памяти. В будущих уроках мы научимся сохранять данные в файлы и базы данных.

В следующей статье мы разберем Функции и Указатели — тему, которая часто пугает новичков, но на самом деле делает Go невероятно мощным.

Интерфейсы и композиция: проектирование гибкой системы уведомлений

Интерфейсы и композиция: проектирование гибкой системы уведомлений

Привет, Gopher! В прошлых статьях мы научились работать с базовыми типами, управлять потоком выполнения и создавать собственные структуры данных, такие как задачи для нашего To-Do листа. Но что, если наше приложение растет?

Представьте, что вы пишете сервис, который должен отправлять уведомления пользователям. Сегодня заказчик просит отправлять Email. Завтра он захочет SMS. Послезавтра — сообщения в Telegram. Если писать всё в одну кучу, ваш код превратится в запутанный клубок из условий if-else.

В Go для решения этой проблемы существуют два мощных инструмента: методы и интерфейсы. А вместо привычного для других языков наследования (inheritance), Go предлагает композицию. Сегодня мы научимся проектировать гибкие системы, которые легко расширять.

Методы: оживляем структуры

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

Вспомним нашу структуру User:

type User struct {
    Name  string
    Email string
}

Чтобы создать метод, мы добавляем специальный аргумент перед именем функции — получатель (receiver).

// Метод Notify привязан к структуре User
func (u User) Notify() {
    fmt.Printf("Отправляем письмо пользователю %s на %s\n", u.Name, u.Email)
}

Теперь мы можем вызывать этот метод через точку, как в объектно-ориентированных языках:

user := User{Name: "Alex", Email: "alex@example.com"}
user.Notify() // Вывод: Отправляем письмо пользователю Alex...

Интерфейсы: контракты поведения

Интерфейс в Go — это не структура данных, а контракт. Он говорит: «Мне не важно, кто ты и как ты устроен внутри. Если у тебя есть вот эти методы, ты мне подходишь».

Это ключевая концепция полиморфизма в Go. Давайте создадим интерфейс для отправителя уведомлений.

type Sender interface {
    Send(message string) error
}

Любой тип, у которого есть метод Send, принимающий строку и возвращающий ошибку, автоматически удовлетворяет этому интерфейсу.

Магия неявной реализации

В Java или C# вам нужно писать implements Sender. В Go этого нет. Если метод совпадает по сигнатуре — интерфейс реализован. Это называется Duck Typing («Утиная типизация»): если это ходит как утка и крякает как утка, то это утка.

Давайте создадим два разных сервиса:

// Сервис Email
type EmailSender struct {
    Address string
}

func (e EmailSender) Send(msg string) error {
    fmt.Printf("Email на %s: %s\n", e.Address, msg)
    return nil
}

// Сервис SMS
type SmsSender struct {
    Phone string
}

func (s SmsSender) Send(msg string) error {
    fmt.Printf("SMS на %s: %s\n", s.Phone, msg)
    return nil
}

Теперь мы можем написать функцию, которая принимает интерфейс, а не конкретную структуру:

func NotifyUser(s Sender, msg string) {
    // Нам не важно, Email это или SMS. Мы знаем, что у 's' есть метод Send.
    s.Send(msg)
}

Композиция вместо наследования

Многие новички ищут в Go возможность создать класс-родитель и унаследовать от него дочерние классы. В Go нет наследования. Вместо этого используется композиция (встраивание).

Представьте, что у нас есть базовая информация о пользователе, и мы хотим создать администратора.

type BaseUser struct {
    ID        int
    CreatedAt string
}

func (b BaseUser) ShowID() {
    fmt.Printf("ID: %d\n", b.ID)
}

type Admin struct {
    BaseUser // Встраивание (Embedding)
    Level    int
}

Теперь Admin автоматически имеет доступ к полям и методам BaseUser. Это выглядит как наследование, но технически Admin содержит в себе BaseUser.

admin := Admin{
    BaseUser: BaseUser{ID: 1},
    Level:    5,
}

admin.ShowID() // Работает метод встроенной структуры!

Практика: Система уведомлений

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

Создайте файл main.go:

package main

import "fmt"

// 1. Определяем интерфейс
type Notifier interface {
    Notify(message string)
}

// 2. Реализация для Email
type Email struct {
    Address string
}

func (e Email) Notify(message string) {
    fmt.Printf("[Email] To %s: %s\n", e.Address, message)
}

// 3. Реализация для Telegram
type Telegram struct {
    Username string
}

func (t Telegram) Notify(message string) {
    fmt.Printf("[Telegram] @%s: %s\n", t.Username, message)
}

// 4. Структура, использующая композицию
type SuperUser struct {
    Email    // Встраиваем структуру Email
    Telegram // Встраиваем структуру Telegram
}

func main() {
    // Создаем слайс интерфейсов
    // Мы можем положить сюда и Email, и Telegram, так как они оба Notifier
    var services []Notifier

    emailService := Email{Address: "admin@go.dev"}
    tgService := Telegram{Username: "gopher_king"}

    services = append(services, emailService)
    services = append(services, tgService)

    fmt.Println("--- Начинаем рассылку ---")
    for _, s := range services {
        s.Notify("Система обновлена до версии 2.0")
    }

    fmt.Println("\n--- Пример композиции ---")
    // SuperUser имеет доступ к методам встроенных структур,
    // но здесь возникает нюанс: у обоих есть метод Notify.
    // Go потребует уточнить, чей метод вызывать.
    admin := SuperUser{
        Email:    Email{Address: "boss@company.com"},
        Telegram: Telegram{Username: "boss_tg"},
    }

    admin.Email.Notify("Личное сообщение")
}

Разбор кода

  1. Полиморфизм: Слайс []Notifier хранит разнородные объекты. Для компилятора они все равны, потому что умеют делать Notify.
  2. Расширяемость: Если завтра нам нужно добавить Push-уведомления, мы просто создадим структуру Push с методом Notify. Нам не придется переписывать основной цикл рассылки.
  3. Композиция: Структура SuperUser собрана из готовых кирпичиков (Email и Telegram).

Пустой интерфейс: interface{}

Иногда вы можете встретить конструкцию interface{} (или any в новых версиях Go). Это интерфейс, у которого нет методов. Ему удовлетворяет абсолютно любой тип данных: число, строка, структура, слайс.

func PrintAnything(v interface{}) {
    fmt.Println(v)
}

Совет: Используйте пустой интерфейс с осторожностью. Когда вы используете interface{}, вы отключаете строгую типизацию — главную защиту Go от ошибок. Компилятор перестает понимать, что именно лежит в переменной.

Резюме

Сегодня вы перешли от процедурного стиля к архитектурному проектированию:

  • Методы позволяют структурам совершать действия.
  • Интерфейсы позволяют коду работать с разными типами данных одинаково, если они соблюдают контракт.
  • Композиция позволяет собирать сложные объекты из простых частей.

В следующей статье мы разберем Goroutines и Channels — то, что делает Go королем параллельного программирования. Мы научимся выполнять тысячи задач одновременно!

Магия конкурентности: горутины и каналы в разработке быстрого чекера сайтов

Магия конкурентности: горутины и каналы в разработке быстрого чекера сайтов

Приветствую, Gopher! Мы уже прошли долгий путь: от «Hello World» до сложных структур и интерфейсов. Наши программы работают правильно и надежно. Но есть один нюанс: пока что они делают всё последовательно.

Представьте, что вы готовите завтрак. Вы сначала жарите яйца (5 минут), потом режете хлеб (1 минута), потом варите кофе (3 минуты). Итого: 9 минут. Но ведь можно включить кофеварку, кинуть хлеб в тостер и жарить яйца одновременно! В программировании это называется конкурентность.

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

Горутины (Goroutines): Легковесные потоки

В других языках (Java, C++, Python) создание нового потока (thread) — это дорогая операция, потребляющая много памяти (часто 1-2 МБ на поток). В Go придумали горутины.

Горутина — это функция, которая выполняется конкурентно с другими функциями. Она занимает всего около 2 КБ памяти при старте. Вы можете запустить миллионы горутин на обычном ноутбуке, и он не зависнет.

Как запустить горутину?

Достаточно добавить ключевое слово go перед вызовом функции.

package main

import (
	"fmt"
	"time"
)

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

func main() {
	go sayHello() // Запускаем в отдельном потоке
	fmt.Println("Привет из main!")

	// Ждем немного, иначе программа завершится раньше, чем горутина успеет сработать
	time.Sleep(time.Second)
}

Если убрать time.Sleep, программа, скорее всего, ничего не выведет из sayHello. Почему? Потому что функция main — это тоже горутина. Когда main завершается, программа закрывается, убивая все остальные горутины, даже если они не закончили работу.

Синхронизация: WaitGroup

Использовать time.Sleep для ожидания — плохая практика. Мы не знаем точно, сколько времени займет задача. Для этого в пакете sync есть инструмент WaitGroup.

Он работает как счетчик:

  1. Мы говорим: «Жди 5 задач» (Add).
  2. Каждая задача по завершении говорит: «Я всё» (Done).
  3. Главная функция ждет, пока счетчик не станет равен нулю (Wait).
import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Уменьшаем счетчик при выходе из функции
	fmt.Printf("Рабочий %d начал работу\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Увеличиваем счетчик на 1
		go worker(i, &wg)
	}

	wg.Wait() // Блокируем main, пока счетчик не станет 0
	fmt.Println("Все рабочие закончили")
}

Каналы (Channels): Общение между горутинами

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

Философия Go гласит:

Не общайтесь, используя общую память. Вместо этого делитесь памятью через общение.

Для этого существуют каналы. Представьте их как трубы, по которым горутины передают данные.

Создание и использование

// Создаем канал для передачи строк
messages := make(chan string)

// Отправка данных в канал (стрелка В канал)
go func() {
	messages <- "Привет, канал!"
}()

// Чтение данных из канала (стрелка ИЗ канала)
msg := <-messages
fmt.Println(msg)

Практика: Быстрый чекер сайтов

Давайте напишем реальный инструмент. У нас есть список сайтов, и мы хотим проверить, работают ли они (отвечают ли статусом 200).

Версия 1: Последовательная (Медленная)

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

Математически время выполнения TseqT_{seq} выражается так:

Tseq=∑i=1NtiT_{seq} = \sum_{i=1}^{N} t_i

где TseqT_{seq} — общее время последовательного выполнения, NN — количество сайтов, а tit_i — время проверки ii-го сайта.

Если у нас 10 сайтов и каждый отвечает за 1 секунду, мы будем ждать 10 секунд.

Версия 2: Конкурентная (Быстрая)

Мы запустим проверку каждого сайта в отдельной горутине. Результаты будем собирать через канал.

Создайте файл main.go:

package main

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

// Функция проверки сайта
func checkUrl(url string, c chan string) {
	resp, err := http.Get(url)
	if err != nil {
		c <- fmt.Sprintf("%s - ОШИБКА", url)
		return
	}
	defer resp.Body.Close()
	c <- fmt.Sprintf("%s - OK [%s]", url, resp.Status)
}

func main() {
	start := time.Now()

	urls := []string{
		"https://google.com",
		"https://facebook.com",
		"https://stackoverflow.com",
		"https://go.dev",
		"https://amazon.com",
	}

	// Создаем канал для строк
	c := make(chan string)

	// Запускаем горутину для каждого сайта
	for _, url := range urls {
		go checkUrl(url, c)
	}

	// Читаем результаты
	// Мы знаем, что запустили len(urls) горутин, значит ждем столько же ответов
	for i := 0; i < len(urls); i++ {
		fmt.Println(<-c)
	}

	fmt.Printf("Всего времени: %s\n", time.Since(start))
}

Разбор кода

  1. checkUrl: Функция делает HTTP-запрос. Вместо return она отправляет результат в канал c <- ....
  2. go checkUrl(...): Мы запускаем проверку в фоне. Цикл for пролетает мгновенно, запуская 5 горутин.
  3. Сбор данных: Второй цикл for ждет ровно 5 сообщений из канала. Как только сообщение приходит, оно печатается.

При идеальном распараллеливании время выполнения TparT_{par} стремится к времени самого медленного запроса:

Tpar≈max⁡(t1,t2,...,tN)T_{par} \approx \max(t_1, t_2, ..., t_N)

где TparT_{par} — время параллельного выполнения, а max⁡(ti)\max(t_i) — максимальное время отклика среди всех сайтов.

В реальности есть небольшие накладные расходы, но ускорение будет колоссальным. Вместо 5-10 секунд программа отработает за 1-2 секунды.

Буферизированные каналы

В примере выше мы использовали обычный (небуферизированный) канал. Это значит, что отправка c <- val блокирует горутину, пока кто-то не прочитает <-c.

Иногда полезно иметь «запас» места в канале. Это называется буферизированный канал.

ch := make(chan string, 3) // Емкость 3 сообщения
ch <- "A"
ch <- "B"
ch <- "C"
// Четвертая запись заблокирует поток, пока кто-то не прочитает из канала

Резюме

Сегодня вы открыли для себя суперсилу Go:

  • Горутины позволяют делать много дел одновременно с минимальными затратами ресурсов.
  • WaitGroup помогает дождаться завершения всех задач.
  • Каналы позволяют безопасно передавать данные между горутинами без использования глобальных переменных и мьютексов.

Теперь ваш чекер сайтов работает молниеносно. Но что, если нам нужно сделать из этого настоящий веб-сервис, который принимает запросы от пользователей через браузер? В следующей статье мы изучим стандартную библиотеку net/http и напишем свой первый веб-сервер!

Сетевое программирование: разработка полноценного REST API сервиса для библиотеки книг

Сетевое программирование: разработка полноценного REST API сервиса для библиотеки книг

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

В этой статье мы превратим ваш компьютер в веб-сервер. Мы не будем использовать сторонние фреймворки (как Gin или Echo), потому что стандартная библиотека Go настолько мощная, что позволяет писать production-ready сервисы «из коробки». Наша цель — создать REST API для цифровой библиотеки.

Что такое REST API?

Прежде чем писать код, разберемся с терминами. API (Application Programming Interface) — это контракт, по которому одна программа может попросить другую что-то сделать.

REST (Representational State Transfer) — это стиль архитектуры, где мы работаем с ресурсами (книгами, пользователями) через стандартные HTTP-методы:

  • GET — получить данные (дай мне список книг).
  • POST — создать данные (добавь новую книгу).
  • PUT/PATCH — обновить данные (измени название книги).
  • DELETE — удалить данные (удали книгу).

Инструменты стандартной библиотеки

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

  1. net/http — сердце веб-разработки в Go. Он умеет слушать порт, принимать соединения и отправлять ответы.
  2. encoding/json — инструмент для перевода структур Go в формат JSON (текстовый формат обмена данными) и обратно.

Шаг 1: Проектируем структуру данных

Любой сервис начинается с данных. Наша сущность — Книга. Создайте файл main.go и опишите структуру.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"
)

// Book определяет структуру книги в нашей библиотеке
type Book struct {
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
	Year   int    `json:"year"`
}

// Имитация базы данных в памяти
var library []Book
var idCounter int

Обратите внимание на странные записи вроде `json:"title"`. Это теги структур. Они говорят кодировщику JSON: «Когда будешь превращать эту структуру в текст, назови поле Title маленькими буквами title». Это стандарт в веб-разработке.

Шаг 2: Пишем первый обработчик (Handler)

Обработчик — это функция, которая принимает запрос от пользователя и пишет ответ. В Go сигнатура обработчика всегда одинаковая:

func(w http.ResponseWriter, r *http.Request)
  • w (Writer) — сюда мы пишем ответ (текст, JSON, статус-код).
  • r (Request) — отсюда мы читаем данные запроса (какой URL, какой метод, какое тело).

Давайте напишем функцию, которая возвращает список всех книг.

func getBooksHandler(w http.ResponseWriter, r *http.Request) {
	// Устанавливаем заголовок, чтобы браузер понял, что мы шлем JSON
	w.Header().Set("Content-Type", "application/json")

	// Кодируем слайс library в JSON и пишем прямо в ответ
	json.NewEncoder(w).Encode(library)
}

Всё просто! json.NewEncoder(w) создает кодировщик, который пишет сразу в поток вывода w. Метод Encode(library) берет наши данные и превращает их в строку вида [{"id":1, "title":"..."}].

Шаг 3: Обработка создания книги (POST)

Теперь сложнее. Нам нужно прочитать JSON, который прислал пользователь, превратить его в структуру Book и сохранить.

func createBookHandler(w http.ResponseWriter, r *http.Request) {
	// Проверяем, что метод именно POST
	if r.Method != http.MethodPost {
		http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
		return
	}

	var newBook Book

	// Декодируем тело запроса в структуру
	err := json.NewDecoder(r.Body).Decode(&newBook)
	if err != nil {
		http.Error(w, "Некорректный JSON", http.StatusBadRequest)
		return
	}

	// Присваиваем ID и сохраняем
	idCounter++
	newBook.ID = idCounter
	library = append(library, newBook)

	// Возвращаем созданный объект и статус 201 (Created)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(newBook)
}

Здесь мы используем http.Error для быстрой отправки ошибок. Это удобная обертка, которая устанавливает статус-код и пишет текстовое сообщение.

Шаг 4: Маршрутизация и запуск сервера

Теперь нам нужно сказать серверу: «Если пришли на адрес /books, вызывай этот код». До версии Go 1.22 маршрутизация была сложной, но теперь она стала намного удобнее. Однако, для понимания основ, мы объединим логику в одну функцию-распределитель.

func booksHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		getBooksHandler(w, r)
	case http.MethodPost:
		createBookHandler(w, r)
	default:
		http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
	}
}

func main() {
	// Инициализируем данные для примера
	library = append(library, Book{ID: 1, Title: "The Go Programming Language", Author: "Alan Donovan", Year: 2015})
	idCounter = 1

	// Регистрируем маршрут
	http.HandleFunc("/books", booksHandler)

	fmt.Println("Сервер запущен на порту 8080...")
	// Запускаем сервер. nil означает использование стандартного маршрутизатора
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Функция http.ListenAndServe запускает бесконечный цикл, который принимает соединения. Если произойдет ошибка (например, порт занят), она вернет её, и log.Fatal завершит программу с сообщением.

Полный код сервиса

Соберите все части в один файл main.go. У вас должен получиться рабочий веб-сервер.

Как проверить работу?

Поскольку у нас нет фронтенда (кнопок и форм), мы будем использовать инструменты разработчика. Вы можете использовать программу Postman или консольную утилиту curl.

  1. Запустите сервер:

    go run main.go
    
  2. Получить список книг (в другом окне терминала):

    curl http://localhost:8080/books
    

    Ответ: [{"id":1,"title":"The Go Programming Language","author":"Alan Donovan","year":2015}]

  3. Добавить книгу:

    curl -X POST http://localhost:8080/books \
      -H "Content-Type: application/json" \
      -d '{"title": "Concurrency in Go", "author": "Katherine Cox-Buday", "year": 2017}'
    

    Ответ: {"id":2,"title":"Concurrency in Go"...}

Работа с JSON: Marshaling и Unmarshaling

В коде мы использовали Encoder и Decoder. Это потоковые методы, они хороши для HTTP. Но иногда вам нужно просто превратить структуру в строку или наоборот. Для этого есть функции:

  • json.Marshal(v) — превращает структуру v в байтовый срез (JSON).
  • json.Unmarshal(data, &v) — парсит JSON data и кладет результат в переменную v.

Важно: Чтобы пакет json мог читать и писать поля вашей структуры, они обязательно должны начинаться с заглавной буквы (быть экспортируемыми). Если вы назовете поле title (с маленькой буквы), JSON-кодировщик его просто проигнорирует.

Резюме

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

Сегодня мы узнали:

  1. Как работает пакет net/http.
  2. Как использовать теги структур для управления JSON.
  3. Как писать обработчики для GET и POST запросов.
  4. Как тестировать API с помощью curl.

В следующей статье мы решим проблему потери данных. Мы подключим к нашему сервису настоящую базу данных и научимся писать SQL-запросы прямо из Go кода. Готовьтесь, будет много практики!