Открытое собеседование на Junior Go разработчика
Сегодня мы разберём процесс проведения технического собеседования на позицию Go-разработчика, включающий решение задач на многопоточность, работу с каналами и контекстами, а также алгоритмическую задачу на сжатие строк. В ходе интервью кандидат продемонстрировал базовое понимание языка Go, однако столкнулся с трудностями при работе с замыканиями, конструкцией select и при проектировании параллельных воркеров, что в итоге привело к необходимости использования подсказок интервьюера для завершения задач.
Вопрос 1. Расскажи о своём опыте: где работал, какие задачи выполнял, какой стек технологий используешь?
Таймкод: 00:04:40
Ответ собеседника: неполный. Кандидат упоминает опыт в стартапах, две практики, использование Go, Docker Compose, брокера сообщений без фреймворков. Задача — пересмотр архитектуры, настройка слоистости кода, рефакторинг названий функций.
Правильный ответ:
Ответ кандидата слишком поверхностный и не даёт интервьюеру целостной картины опыта. На вопрос «расскажи о своём опыте» на позиции Go-разработчика ожидается структурированный рассказ, который демонстрирует глубину компетенций. Вот как стоило бы ответить:
Структура хорошего ответа о себе
Рассказ стоит строить по схеме: контекст компании → роль → задачи → стек → результат. Это позволяет интервьюеру быстро оценить уровень кандидата.
Пример развёрнутого ответа:
«Последние 2 года работаю Go-разработчиком. Начинал в стартапе, где строили микросервисную платформу для обработки событий в реальном времени. Моя роль — проектирование и реализация сервисов с нуля, code review, участие в архитектурных решениях.
Задачи, которые решал:
- Разрабатывал высоконагруженные HTTP- и gRPC-сервисы на чистом Go без тяжёлых фреймворков (иногда chi или echo для роутинга).
- Проектировал и внедрял событийную архитектуру через Kafka/RabbitMQ: публикация доменных событий, обработка с гарантиями at-least-once и exactly-once.
- Писал и оптимизировал SQL-запросы (PostgreSQL), работал с индексами, партиционированием, миграциями через goose/migrate.
- Настраивал CI/CD-пайплайны, контейнеризацию через Docker и Docker Compose для локальной разработки.
- Внедрял слоистую архитектуру (handler → service → repository), писал unit- и integration-тесты, настраивал graceful shutdown и health-check endpoints.
- Участвовал в рефакторинге легаси-кода: переименование функций и пакетов для соответствия domain-терминологии, разделение ответственности между слоями.
Стек: Go 1.21+, PostgreSQL, Redis, Kafka, gRPC, Docker, Docker Compose, Git, Linux. Из фреймворков — минимум: иногда chi для маршрутизации, но предпочитаю стандартную библиотеку.
Результат: один из сервисов, который я переписал с нуля, стал обрабатывать в 3 раза больше RPS при меньшем потреблении памяти благодаря правильному использованию пулов воркеров и буферизированных каналов.»
Почему такой ответ лучше:
- Конкретика: названия технологий, версии, метрики (RPS, потребление памяти).
- Глубина: упоминание паттернов (слоистая архитектура, доменные события), гарантий доставки, инструментов миграции.
- Результат: кандидат показывает, что его работа принесла измеримую пользу.
- Позиционирование: кандидат демонстрирует зрелость — он не просто «пишет код», а участвует в архитектурных решениях и рефакторинге.
Чего стоит избегать:
- Размытых формулировок вроде «работал с брокером сообщений» — какой именно? Kafka? RabbitMQ? NATS?
- Отсутствия контекста — стартап — это что? Продукт? B2B? B2C?
- Отсутствия результатов — что изменилось благодаря вашей работе?
Вопрос 2. Есть ли учебные или пет-проекты? Расскажи подробнее о них.
Таймкод: 00:08:11
Ответ собеседника: неполный. Кандидат упоминает чат-бот для проведения собеседований с использованием GPT-4, сбор входных параметров (грейд, опыт, стек), стоимость API-запросов. Проект не доведён до рабочего состояния после проблем с деплоем.
Правильный ответ:
Кандидат дал поверхностный ответ и не раскрыл техническую глубину проекта. Хороший ответ на этот вопрос должен показать архитектурное мышление, технические решения и способность доводить проекты до конца.
Структура хорошего ответа о пет-проекте
Формат: идея → архитектура → реализация → сложности → результат/планы.
Пример развёрнутого ответа:
«Да, сейчас активно развиваю проект — платформу для проведения технических интервью с ИИ. Идея родилась из личной потребности: хотел практиковаться перед реальными собеседованиями.
Архитектура:
Проект построен на микросервисном подходе:
- API Gateway на Go (chi router) — принимает HTTP-запросы, валидирует входные данные, маршрутизирует запросы.
- Interview Service — основной сервис, управляет сессиями интервью, хранит состояние диалога в PostgreSQL.
- AI Provider — отдельный адаптер для работы с OpenAI API (GPT-4), реализует интерфейс, чтобы можно было подменить провайдера (например, на локальную модель через Ollama).
- Session Store — Redis для хранения активных сессий и контекста диалога.
Технические детали:
Перед началом интервью пользователь указывает грейд (Junior/Middle/Senior), количество месяцев опыта и стек технологий. На основе этих параметров формируется system prompt для GPT-4, который задаёт стиль и сложность вопросов.
Запрос к OpenAI выполняется асинхронно через буферизированный канал, чтобы не блокировать основной goroutine:
type AIRequest struct {
SessionID string
Messages []openai.ChatMessage
Result chan AIResponse
}
func (p *OpenAIProvider) ProcessRequests(requests <-chan AIRequest) {
for req := range requests {
resp, err := p.client.CreateChatCompletion(context.Background(), req.Messages)
req.Result <- AIResponse{Reply: resp, Err: err}
}
}
Это позволяет контролировать скорость запросов и не превышать rate limits OpenAI.
Сложности и решения:
- Cost control: токены денег стоят, поэтому внедрил подсчёт токенов и лимит на сессию (максимум 30 сообщений). Стоимость действительно вышла ~$15/месяц при умеренном использовании.
- Context window: GPT-4 имеет ограничение на длину контекста. Решил через суммаризацию — после каждых 10 реплик отправляю отдельный запрос на сокращение контекста.
- Деплой: разворачивал на VPS через Docker Compose. Столкнулся с проблемой health-checks — сервис AI Provider стартовал быстрее, чем Redis. Исправил через wait-for-it скрипт в entrypoint.
Текущий статус и планы:
Базовый функционал работает: можно начать интервью, получить вопросы, отвечать, получить фидбек. Планирую добавить:
- Веб-интерфейс на htmx (без тяжёлого JS-фреймворка).
- Поддержку нескольких провайдеров ИИ (Anthropic, локальные модели).
- Систему рейтинга ответов с сохранением истории.
- Graceful degradation при недоступности OpenAI — fallback на заранее заготовленные вопросы.
Чему научился на этом проекте:
- Практика работы с внешними API и паттерном Circuit Breaker.
- Управление контекстом диалога и оптимизация токенов.
- Настройка Docker Compose для локальной разработки и production-деплоя.
- Проектирование адаптерного слоя для внешних зависимостей.
Что стоит улучшить в ответе кандидата:
- Не бросать проект при первых сложностях с деплоем — это сигнализирует о недостаточной настойчивости.
- Показывать архитектурное мышление: как организованы пакеты, где границы ответственности.
- Демонстрировать измеримые результаты или хотя бы планы по развитию.
- Упоминать тесты — даже базовые unit-тесты для бизнес-логики будут плюсом.»
Вопрос 3. С какими технологиями работал за время практик? Использовал ли Kubernetes?
Таймкод: 00:10:52
Ответ собеседника: неполный. Кандидат упоминает Docker Compose, считает Kubernetes избыточным для своих задач, курс от Avito просмотрен, но практического опыта с Kubernetes нет.
Правильный ответ:
Кандидат дал слишком краткий ответ и не раскрыл полный стек технологий. Формулировка «Kubernetes избыточен» без контекста может быть воспринята как непонимание области применения технологии. Хороший ответ должен показать осознанный выбор инструментов и понимание их места в экосистеме.
Структура хорошего ответа
Формат: полный стек → обоснование выбора → отношение к Kubernetes с пониманием контекста.
Пример развёрнутого ответа:
«За время практик и пет-проектов я работал со следующим стеком:
Язык и стандартная библиотека:
Go 1.20+ — основной язык. Стараюсь максимально использовать стандартную библиотеку: net/http, context, sync, encoding/json. Из сторонних зависимостей минимум — принцип «меньше зависимостей — меньше проблем».
База данных:
PostgreSQL — основное хранилище. Использую pgx как драйвер, sqlx для удобного сканирования строк в структуры. Миграции через golang-migrate/migrate. Для кэширования и хранения сессий — Redis через библиотеку go-redis.
Брокеры сообщений:
На одной из практик работал с RabbitMQ через библиотеку amqp091-go. Реализовывал паттерн publisher/subscriber с подтверждением доставки и dead letter exchange для обработки ошибок.
Контейнеризация и оркестрация:
Docker — для сборки и упаковки сервисов. Docker Compose — для локальной разработки и интеграционного тестирования. Типичный docker-compose.yml для моего проекта включает:
version: '3.8'
services:
api:
build: .
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DATABASE_URL=postgres://user:pass@postgres:5432/db
- REDIS_URL=redis:6379
postgres:
image: postgres:15-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
CI/CD:
GitHub Actions — для автоматизации тестов и сборки образов. Базовый пайплайн: lint → test → build → push в Container Registry.
Мониторинг и логирование:
Структурированные логи через zerolog или slog (начиная с Go 1.21). Метрики через expvars или Prometheus client. OpenTelemetry для трассировки — изучал, но ещё не внедрял в production.
По поводу Kubernetes:
Практического опыта с Kubernetes у меня нет, но я понимаю, где он применяется и почему. Docker Compose действительно избыточен для production-нагрузок с десятками сервисами, авто-scaling'ом, rolling updates и service discovery. Kubernetes решает задачи оркестрации, которые Docker Compose не покрывает.
Я проходил курс, где разбирали основы: Pod, Deployment, Service, Ingress, ConfigMap, Secret. Понимаю концепцию declarative configuration и reconciliation loop. Для моих текущих проектов Kubernetes действительно избыточен — один-два сервиса на VPS проще деплоить через Docker Compose или даже напрямую бинарником с systemd.
Но если команда растёт, сервисов становится больше, появляются требования к отказоустойчивости и автомасштабированию — я понимаю, почему Kubernetes становится необходимым, и готов быстро освоить практическую работу с ним.»
Почему такой ответ лучше:
- Полнота стека: кандидат показывает знание всей цепочки — от языка до мониторинга.
- Осознанность: упоминание конкретных библиотек (
pgx,sqlx,zerolog) и версий показывает реальный опыт. - Практические примеры: docker-compose.yml с healthcheck'ами демонстрирует понимание реальных проблем оркестрации.
- Зрелое отношение к Kubernetes: кандидат не говорит «Kubernetes не нужен», а объясняет, почему для его задач он избыточен, и показывает понимание контекста применения.
- Готовность к обучению: упоминание курса и базового понимания концепций Kubernetes.
Чего стоит избегать:
- Формулировок вроде «Kubernetes — это сложно и не нужно» без обоснования.
- Перечисления технологий без контекста — зачем и как использовались.
- Отсутствия примеров кода или конфигураций — они показывают глубину понимания.
Вопрос 4. Был ли опыт работы с Kubernetes и администрированием серверов?
Таймкод: 00:11:47
Ответ собеседника: неполный. Кандидат упоминает незаконченный курс по Kubernetes, стажировку в КБ-компании с задачами DevSecOps: GitLab CI/CD, ручной деплой через SCP, настройка DNS и инфраструктуры. Практического опыта с Kubernetes нет.
Правильный ответ:
Кандидат дал более развёрнутый ответ, чем на предыдущий вопрос, но всё ещё не структурировал информацию должным образом. Упоминание стажировки — это плюс, но нужно показать глубину понимания DevOps-практик и их связь с разработкой.
Структура хорошего ответа
Формат: Kubernetes — честный статус → администрирование серверов → DevOps-практики → связь с разработкой.
Пример развёрнутого ответа:
Kubernetes:
Прямого практического опыта с Kubernetes у меня нет. Я начал изучать теорию через курс — освоил базовые концепции: Pod как минимальная единица деплоя, Deployment для управления репликами, Service для сетевого доступа, Ingress для маршрутизации внешнего трафика, ConfigMap и Secret для конфигурации.
Понимаю, что Kubernetes решает задачи, которые Docker Compose не покрывает: автоматический restart упавших контейнеров, rolling updates без downtime, автомасштабирование подов по метрикам (HPA), service discovery между микросервисами.
Для локальной разработки с Kubernetes можно использовать minikube, kind или k3s — они не требуют сложной установки. В будущем планирую настроить локальный кластер и задеплоить свой пет-проект для практики.
Администрирование серверов и DevOps-практики:
Летом проходил трёхмесячную стажировку в компании, связанной с информационной безопасностью. Мои задачи лежали на стыке DevSecOps:
CI/CD пайплайны в GitLab:
Настраивал пайплайны для сборки и деплоя Go-сервисов. Типичный пайплайн выглядел так:
stages:
- build
- test
- deploy
build:
stage: build
script:
- go build -o bin/app ./cmd/app
artifacts:
paths:
- bin/
test:
stage: test
script:
- go test -v -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
deploy:
stage: deploy
script:
- scp bin/app user@server:/opt/app/
- ssh user@server "sudo systemctl restart app"
when: manual
only:
- main
Ключевое решение — ручной деплой (when: manual). Это позволяло не пропускать плохой коммит сразу в production. Сначала проверяли работоспособность на staging, потом вручную подтверждали выкатку.
Инфраструктура:
- Настраивал DNS-записи для внутренних сервисов.
- Конфигурировал Nginx как reverse proxy с TLS-сертификатами через Let's Encrypt.
- Настраивал firewall правила (iptables) для ограничения доступа к сервисам.
- Работал с systemd для управления сервисами: автозапуск, перезапуск при падении, логирование через journalctl.
Безопасность:
- Настраивал сканирование образов на уязвимости через Trivy в CI-пайплайне.
- Реализовывал secret management через GitLab CI/CD variables вместо хранения секретов в коде.
- Настраивал минимальные привилегии для контейнеров (non-root user, read-only filesystem).
Чему научился:
Эта стажировка дала мне понимание полного цикла доставки кода: от коммита до production. Я понимаю, почему важно делать неизменяемые артефакты (build once, deploy everywhere), зачем нужны health-check endpoints в сервисах, и как правильно организовать graceful shutdown при перезапуске.
Связь с разработкой:
Понимание DevOps-практик помогает мне как разработчику писать код, который легче деплоить и мониторить. Например, я всегда добавляю /health endpoint, структурированные логи и метрики — потому что видел, как сложно отлаживать сервис без этих вещей в production.
Почему такой ответ лучше:
- Честность про Kubernetes: кандидат не придумывает опыт, но показывает понимание концепций и планы по изучению.
- Конкретика в DevOps: пример
.gitlab-ci.ymlпоказывает реальный опыт, а не абстрактное «настраивал пайплайны». - Понимание причин: объяснение, зачем ручной деплой, показывает зрелость мышления.
- Связь с разработкой: кандидат показывает, как DevOps-опыт влияет на качество кода.
- Безопасность: упоминание Trivy, secret management, минимальных привилегий — актуальные темы для любой компании.
Чего стоит избегать:
- Формулировок «напугало, что нужно что-то устанавливать» — это звучит как оправдание. Лучше: «пока не было практической необходимости, но планирую освоить».
- Описания задач без объяснения причин и контекста.
- Отсутствия примеров конфигураций или кода.
Вопрос 5. Когда познакомился с Go? Какие проекты писал на Go? Был ли опыт работы с базами данных (MongoDB, PostgreSQL)?
Таймкод: 00:15:39
Ответ собеседника: неполный. Кандидат упоминает 2 года знакомства с Go, проекты: чат-бот-интервьюер, социальная сеть с Google Sheets, парсер PDF. Работал с MongoDB (простые запросы, join'ы, агрегации) и PostgreSQL (pgx, пул соединений, squirrel для построения запросов).
Правильный ответ:
Кандидат перечислил проекты и технологии, но не раскрыл техническую глубину. Хороший ответ должен показать понимание экосистемы Go, паттернов работы с базами данных и архитектурных решений.
Структура хорошего ответа
Формат: история знакомства с Go → проекты с деталями → опыт с БД → чему научился.
Пример развёрнутого ответа:
Знакомство с Go:
Познакомился с Go около 2 лет назад. До этого немного писал на Python и JavaScript. Выбрал Go из-за простоты синтаксиса, быстрой компиляции и встроенной поддержки конкурентности. Первый месяц проходил A Tour of Go и читал «The Go Programming Language» Донована и Кернигана. Активно писать на Go начал примерно год назад — с этого момента считаю реальный практический опыт.
Проекты на Go:
1. Чат-бот-интервьюер с ИИ:
Основной пет-проект. Сервис для проведения технических интервью через GPT-4.
Архитектура: слоистая структура — handler → service → repository. Для маршрутизации использую chi, для работы с OpenAI — официальный SDK.
Ключевые решения:
- Асинхронная обработка запросов к OpenAI через буферизированные каналы для контроля rate limits.
- Хранение контекста диалога в Redis с TTL на сессию.
- Суммаризация контекста при превышении лимита токенов.
type InterviewService struct {
aiProvider AIProvider
sessionRepo SessionRepository
rateLimiter chan struct{}
}
func NewInterviewService(provider AIProvider, repo SessionRepository, maxRPS int) *InterviewService {
return &InterviewService{
aiProvider: provider,
sessionRepo: repo,
rateLimiter: make(chan struct{}, maxRPS),
}
}
func (s *InterviewService) ProcessMessage(ctx context.Context, sessionID string, userMessage string) (string, error) {
s.rateLimiter <- struct{}{}
defer func() { <-s.rateLimiter }()
history, err := s.sessionRepo.GetHistory(ctx, sessionID)
if err != nil {
return "", fmt.Errorf("get history: %w", err)
}
reply, err := s.aiProvider.GenerateReply(ctx, history, userMessage)
if err != nil {
return "", fmt.Errorf("generate reply: %w", err)
}
if err := s.sessionRepo.AppendMessage(ctx, sessionID, userMessage, reply); err != nil {
return "", fmt.Errorf("append message: %w", err)
}
return reply, nil
}
2. Социальная сеть с Google Sheets:
Учебный проект для практики работы с внешними API. Использовал Google Sheets API v4 как хранилище данных — не для production, а для быстрого прототипирования без настройки отдельной БД.
Реализовал: регистрацию пользователей, посты, комментарии, подписки. Каждая сущность — отдельный лист в таблице. Для аутентификации — JWT-токены.
Чему научился: работа с OAuth2, обработка rate limits Google API (429 Too Many Requests), паттерн retry с exponential backoff.
3. Парсер PDF в Google Sheets:
Утилита для извлечения табличных данных из PDF-файлов и экспорта в Google Sheets. Использовал библиотеку ledongthuc/pdf для извлечения текста и Google Sheets API для записи.
Особенность: PDF — это не структурированный формат, поэтому пришлось писать эвристики для определения границ таблиц и ячеек. Обработка ошибок и валидация данных были ключевой частью.
Опыт с базами данных:
MongoDB:
Работал с MongoDB на одной из практик. Использовал официальный драйвер mongo-go-driver.
Выполнял операции CRUD, агрегационные пайплайны для аналитики. Пример агрегации для подсчёта количества событий по типам:
pipeline := mongo.Pipeline{
{{"$match", bson.M{
"created_at": bson.M{
"$gte": startDate,
"$lte": endDate,
},
}}},
{{"$group", bson.M{
"_id": "$event_type",
"count": bson.M{"$sum": 1},
}}},
{{"$sort", bson.M{"count": -1}}},
}
cursor, err := collection.Aggregate(ctx, pipeline)
if err != nil {
return fmt.Errorf("aggregate: %w", err)
}
defer cursor.Close(ctx)
Понимаю, когда MongoDB подходит лучше, чем реляционные БД: гибкая схема, быстрая запись, документная модель. Но также понимаю ограничения: отсутствие транзакций (до версии 4.0), сложности с джойнами, дублирование данных.
PostgreSQL:
Основная реляционная БД в моих проектах. Использую драйвер pgx — он быстрее стандартного lib/pq и поддерживает расширенные типы PostgreSQL.
Работа с пулом соединений:
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
config.MaxConns = 10
config.MinConns = 2
config.MaxConnLifetime = 30 * time.Minute
config.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
Для построения динамических SQL-запросов пробовал писать свой билдер — быстро понял, что это велосипед. Перешёл на squirrel — удобный и безопасный инструмент:
import sq "github.com/Masterminds/squirrel"
func (r *UserRepository) FindUsers(ctx context.Context, filters UserFilter) ([]User, error) {
builder := sq.Select("id", "name", "email", "created_at").
From("users").
PlaceholderFormat(sq.Dollar)
if filters.Name != "" {
builder = builder.Where(sq.Like{"name": "%" + filters.Name + "%"})
}
if filters.Email != "" {
builder = builder.Where(sq.Eq{"email": filters.Email})
}
query, args, err := builder.ToSql()
if err != nil {
return nil, fmt.Errorf("build query: %w", err)
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowToStructByName[User])
}
Также использую sqlx для удобного сканирования результатов в структуры и golang-migrate для управления миграциями схемы.
Чему научился:
- Выбор БД зависит от задачи: PostgreSQL для структурированных данных с отношениями, MongoDB для гибкой схемы и быстрой записи.
- Пул соединений критически важен для производительности — без него каждый запрос создаёт новое TCP-соединение.
- Динамические SQL-запросы нужно строить через проверенные библиотеки, а не конкатенацию строк — это защита от SQL-инъекций.
- Миграции схемы должны быть версионированными и применяться автоматически при деплое.
Почему такой ответ лучше:
- Конкретика: примеры кода показывают реальный опыт, а не абстрактное «работал с PostgreSQL».
- Понимание инструментов: кандидат объясняет, почему выбрал pgx вместо lib/pq, squirrel вместо велосипеда.
- Архитектурное мышление: слоистая архитектура, паттерн пула соединений, rate limiting.
- Сравнение БД: понимание, когда использовать MongoDB, а когда PostgreSQL.
- Обучение на ошибках: упоминание велосипеда и перехода на готовое решение показывает способность учиться.
Чего стоит избегать:
- Перечисления проектов без технических деталей.
- Фраз «работал с MongoDB» без объяснения, что именно делал.
- Отсутствия примеров кода — они лучший способ подтвердить опыт.
Вопрос 6. Что такое pgx Pool и зачем он нужен?
Таймкод: 00:18:58
Ответ собеседника: частично правильный. Кандидат правильно описал пул соединений как механизм для параллельной отправки запросов. Упомянул, что пул нужен в продакшене, а один коннект — для тестирования, но это было неточно сформулировано.
Правильный ответ:
Кандидат дал базовое описание, но не раскрыл внутреннее устройство, настройку и практические аспекты использования pgx Pool. Вот полный ответ:
Что такое pgx Pool:
pgxpool.Pool — это реализация пула соединений к PostgreSQL из библиотеки pgx. Вместо создания нового TCP-соединения к базе данных для каждого запроса, пул поддерживает набор заранее установленных соединений и переиспользует их.
Зачем нужен пул соединений:
1. Производительность. Установка TCP-соединения к PostgreSQL — дорогая операция: требуется TCP handshake, аутентификация, инициализация сессии. Пул устраняет этот overhead, переиспользуя существующие соединения.
2. Конкурентность. Go-сервис обрабатывает запросы конкурентно — каждый HTTP-запрос в своей goroutine. Без пула все запросы выстраиваются в очередь на единственном соединении. С пулом несколько запросов выполняются параллельно на разных соединениях.
3. Контроль ресурсов. PostgreSQL имеет параметр max_connections (по умолчанию 100). Без пула каждый инстанс сервиса может занять все доступные соединения. Пул ограничивает максимальное количество соединений на один инстанс.
4. Устойчивость. Пул автоматически пересоздаёт разорванные соединения и проверяет их работоспособность перед выдачей.
Внутреннее устройство:
pgx Pool использует канал (chan *pgxpool.Conn) для хранения свободных соединений. Когда goroutine запрашивает соединение через Acquire(), пул либо возвращает свободное соединение из канала, либо создаёт новое (если не достигнут лимит). После использования соединение возвращается в пул через Release().
Настройка пула:
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPool(databaseURL string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
// Максимальное количество соединений в пуле
config.MaxConns = 20
// Минимальное количество соединений, которые пул поддерживает открытыми
config.MinConns = 5
// Максимальное время жизни соединения
config.MaxConnLifetime = 30 * time.Minute
// Максимальное время простоя соединения перед закрытием
config.MaxConnIdleTime = 5 * time.Minute
// Таймаут на получение соединения из пула
config.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
return pool, nil
}
Ключевые параметры:
- MaxConns — верхний предел соединений. Рекомендуется формула:
number_of_cpu_cores * 2 + number_of_disksдля OLTP-нагрузки. - MinConns — минимальное количество горячих соединений. Устраняет latency при старте и после пиковых нагрузок.
- MaxConnLifetime — предотвращает проблемы с «протухшими» соединениями при использовании прокси (PgBouncer, HAProxy).
- HealthCheckPeriod — период проверки здоровья соединений.
Использование в коде:
// Получение соединения из пула
func (r *UserRepository) GetUser(ctx context.Context, id int64) (*User, error) {
conn, err := r.pool.Acquire(ctx)
if err != nil {
return nil, fmt.Errorf("acquire connection: %w", err)
}
defer conn.Release() // Важно: всегда возвращать соединение в пул
var user User
err = conn.QueryRow(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).Scan(
&user.ID, &user.Name, &user.Email,
)
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &user, nil
}
// Или проще — использовать методы пула напрямую
func (r *UserRepository) GetUserSimple(ctx context.Context, id int64) (*User, error) {
var user User
err := r.pool.QueryRow(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).Scan(
&user.ID, &user.Name, &user.Email,
)
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &user, nil
}
Транзакции с пулом:
func (r *UserRepository) TransferBalance(ctx context.Context, fromID, toID int64, amount int64) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx) // Безопасно: если Commit уже вызван, Rollback вернёт ошибку, которую можно проигнорировать
_, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
return fmt.Errorf("debit: %w", err)
}
_, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
return fmt.Errorf("credit: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
Пул vs одиночное соединение:
- pgx.Conn — одно соединение. Подходит для простых CLI-утилит, миграций, тестов, где конкурентности нет.
- pgxpool.Pool — пул соединений. Обязательно для серверных приложений с конкурентными запросами.
В тестах можно использовать и пул, и одиночное соединение — зависит от того, что вы тестируете. Для unit-тестов с in-memory SQLite пул не нужен. Для integration-тестов с реальным PostgreSQL пул может быть полезен, если тесты запускаются параллельно.
Мониторинг пула:
pgx предоставляет статистику пула через метод Stat():
stats := r.pool.Stat()
log.Printf("total connections: %d", stats.TotalConns())
log.Printf("acquired connections: %d", stats.AcquiredConns())
log.Printf("idle connections: %d", stats.IdleConns())
log.Printf("constructing connections: %d", stats.ConstructingConns())
Это полезно для мониторинга и настройки параметров пула под реальную нагрузку.
Почему этот ответ лучше:
- Объясняет не только «что это», но и «зачем» — производительность, конкурентность, контроль ресурсов.
- Показывает внутреннее устройство — как пул работает внутри.
- Приводит примеры кода с настройкой, использованием и транзакциями.
- Даёт рекомендации по настройке параметров.
- Объясняет разницу между пулом и одиночным соединением.
Вопрос 7. Обсуди код с горутинами и замыканием: что выведет этот фрагмент? Какие возможные варианты вывода и почему?
Таймкод: 00:20:35
Ответ собеседника: неполный. Кандидат правильно определил два варианта вывода (10 десять раз или произвольные числа 0-10 с дубликатами), упомянул проблему замыкания и преждевременного завершения main. Однако путался в объяснении механизма появления дубликатов.
Правильный ответ:
Это классическая ловушка в Go, связанная с захватом переменных цикла замыканиями. Рассмотрим код:
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Second)
}
Проблема: захват переменной по ссылке
Горутина внутри замыкания захватывает переменную i из внешнего скоупа — не её копию, а ссылку на ту же самую переменную. К моменту, когда планировщик Go начнёт выполнять горутины, цикл уже может завершиться, и i будет равно 10 (значение, при котором условие i < 10 стало false).
Возможные варианты вывода:
Вариант 1: Число 10 десять раз.
Самый частый сценарий. Цикл выполняется очень быстро — за микросекунды. Все 10 горутин создаются, но планировщик Go не успевает начать их выполнение до завершения цикла. Когда горутины наконец запускаются, i уже равно 10.
10
10
10
10
10
10
10
10
10
10
Вариант 2: Произвольные числа от 0 до 10, включая дубликаты.
Если планировщик Go начинает выполнять горутины до завершения цикла, каждая горутина прочитает текущее значение i на момент своего выполнения, а не на момент создания.
Например:
- Горутина 3 запустилась, когда
iбыло 7 — выведет 7. - Горутина 5 запустилась, когда
iтоже было 7 — тоже выведет 7 (дубликат). - Горутина 8 запустилась, когда цикл завершился — выведет 10.
7
3
7
10
1
10
5
10
2
10
Почему появляются дубликаты:
Дубликаты возникают, потому что несколько горутин могут прочитать одно и то же значение i до того, как цикл инкрементирует его. Чтение i и инкремент i++ — не атомарная операция. Между проверкой условия и телом цикла горутина может быть вытеснена, и несколько горутин прочитают одно значение.
Вариант 3: Ничего не выведется.
Если убрать time.Sleep, функция main завершится до того, как горутины начнут выполняться. При завершении main все горутины уничтожаются.
Механизм подробно:
Итерация 0: i=0, создана горутина 0 (захватила &i)
Итерация 1: i=1, создана горутина 1 (захватила &i)
...
Итерация 9: i=9, создана горутина 9 (захватила &i)
Цикл завершился: i=10
Планировщик начинает выполнять горутины:
Горутина 0 читает *i → 10
Горутина 1 читает *i → 10
...
Все горутины ссылаются на одну и ту же переменную i в памяти. К моменту выполнения горутин эта переменная уже равна 10.
Исправление 1: Передача параметра в замыкание
for i := 0; i < 10; i++ {
go func(n int) {
fmt.Println(n)
}(i) // Передаём текущее значение i как аргумент
}
Теперь каждая горутина получает свою копию значения. Вывод будет содержать числа 0-9 в произвольном порядке (без дубликатов и без 10):
3
0
7
1
9
2
5
4
8
6
Исправление 2: Локальная переменная внутри цикла
for i := 0; i < 10; i++ {
n := i // Новая переменная на каждой итерации
go func() {
fmt.Println(n)
}()
}
Переменная n создаётся заново на каждой итерации цикла, поэтому каждая горутина захватывает свою собственную переменную.
Исправление 3: Использование sync.WaitGroup вместо time.Sleep
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait() // Ждём завершения всех горутин
Это правильный способ ожидания завершения горутин — WaitGroup гарантирует, что main не завершится раньше.
Когда это стало менее проблемначным:
Начиная с Go 1.22 (февраль 2024), поведение изменилось. В Go 1.22+ каждая итерация цикла for создаёт новую переменную, поэтому проблема замыкания больше не возникает:
// Go 1.22+: вывод 0-9 в произвольном порядке, без дубликатов и без 10
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // Безопасно в Go 1.22+
}()
}
Это изменение описано в Go Proposal #60078 и является одним из самых значимых изменений в языке за последние годы.
Почему этот ответ лучше:
- Подробное объяснение механизма: захват по ссылке, а не по значению.
- Три возможных варианта вывода с объяснением каждого.
- Чёткое объяснение причины дубликатов: неатомарность чтения и инкремента.
- Три способа исправления с примерами кода.
- Упоминание изменения в Go 1.22, которое устранило эту ловушку.
- Правильный паттерн ожидания горутин через
sync.WaitGroup.
Вопрос 8. Как исправить код, чтобы увидеть все 10 уникальных чисел на экране?
Таймкод: 00:27:28
Ответ собеседника: частично правильный. Кандидат правильно предложил передачу i как параметра и использование WaitGroup. Однако допустил ошибку с созданием WaitGroup через new, получив nil-указатель.
Правильный ответ:
Кандидат правильно определил оба направления исправления, но допустил критическую ошибку с WaitGroup. Вот полный и корректный ответ:
Проблема исходного кода:
// НЕПРАВИЛЬНЫЙ КОД
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // Захват переменной по ссылке
}()
}
time.Sleep(time.Second) // Ненадёжное ожидание
}
Две проблемы:
- Замыкание захватывает
iпо ссылке, а не по значению. time.Sleep— ненадёжный способ ожидания горутин.
Исправленный код:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i) // Передаём текущее значение i как аргумент
}
wg.Wait() // Ждём завершения всех горутин
}
Почему это работает:
1. Передача i как параметра функции:
go func(n int) {
fmt.Println(n) // n — локальная копия, своя для каждой горутины
}(i) // i вычисляется и копируется в момент вызова
Каждый вызов go func(n int)(i) создаёт новую переменную n в стеке горутины и копирует в неё текущее значение i. Горутины больше не зависят от изменения i в цикле.
2. sync.WaitGroup для ожидания:
var wg sync.WaitGroup // Объявляем значение, а не указатель
wg.Add(1) // Увеличиваем счётчик перед запуском горутины
defer wg.Done() // Уменьшаем счётчик при завершении горутины
wg.Wait() // Блокируемся, пока счётчик не станет 0
Важно: WaitGroup — это значение, а не указатель:
Кандидат ошибся, используя new(sync.WaitGroup). Правильный способ:
// ПРАВИЛЬНО: объявляем переменную
var wg sync.WaitGroup
// ПРАВИЛЬНО: передаём указатель в функции, если нужно
go func(wg *sync.WaitGroup) {
defer wg.Done()
// ...
}(&wg)
// НЕПРАВИЛЬНО: new возвращает укатель на нулевое значение
wg := new(sync.WaitGroup) // Это работает, но идиоматичнее использовать var wg sync.WaitGroup
Оба варианта (var wg sync.WaitGroup и wg := new(sync.WaitGroup)) технически работают, но var wg sync.WaitGroup — более идиоматичный стиль в Go. Разница: new возвращает *sync.WaitGroup (указатель), а var даёт sync.WaitGroup (значение). При передаче в функцию по указателю разницы в поведении нет, но стиль с var предпочтительнее.
Альтернативное исправление: локальная переменная
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
n := i // Новая переменная на каждой итерации
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(n)
}()
}
wg.Wait()
}
Этот вариант тоже работает, потому что n создаётся заново на каждой итерации цикла. Однако передача параметра явнее и менее подвержена ошибкам при рефакторинге.
Вывод программы:
Программа выведет числа 0-9 в произвольном порядке (порядок не гарантирован из-за конкурентного выполнения):
7
2
0
5
9
1
3
8
4
6
Дополнительные соображения:
1. Порядок вывода не гарантирован. Если нужен упорядоченный вывод, горутины не подходят для этой задачи. Используйте каналы для сбора результатов:
func main() {
results := make(chan int, 10)
for i := 0; i < 10; i++ {
go func(n int) {
results <- n
}(i)
}
for i := 0; i < 10; i++ {
fmt.Println(<-results)
}
}
2. WaitGroup нельзя копировать. После первого использования sync.WaitGroup нельзя копировать. Если передавать в функцию, только по указателю:
// НЕПРАВИЛЬНО: копирование WaitGroup
func process(wg sync.WaitGroup) { // Копирование!
wg.Done()
}
// ПРАВИЛЬНО: передача по указателю
func process(wg *sync.WaitGroup) {
wg.Done()
}
3. Add должен быть вызван до запуска горутины. Если вызвать wg.Add(1) внутри горутины, есть race condition: wg.Wait() в main может выполниться до того, как wg.Add(1) увеличит счётчик.
// ПРАВИЛЬНО: Add до запуска горутины
wg.Add(1)
go func() {
defer wg.Done()
// ...
}()
// НЕПРАВИЛЬНО: Add внутри горутины (race condition)
go func() {
wg.Add(1) // Может выполниться после wg.Wait()
defer wg.Done()
// ...
}()
Почему этот ответ лучше:
- Полный исправленный код с пояснениями.
- Объяснение обеих проблем: замыкание и ожидание.
- Разбор ошибки кандидата с
WaitGroupи правильного способа использования. - Альтернативное решение с локальной переменной.
- Дополнительные соображения: порядок вывода, каналы, некопируемость WaitGroup, правильное расположение
Add.
Вопрос 9. Напиши функцию parallelDownload: принимает канал с URL и количество воркеров, распределяет скачивание сайтов между воркерами параллельно, собирает результаты в map[url]siteContent и возвращает её после закрытия входного канала.
Таймкод: 00:32:52
Ответ собеседника: неполный. Кандидат начал с правильной идеи (sync.Mutex, запуск N воркеров), но не знал паттерна for range по каналу. После подсказки добавил цикл, но попытка использовать select с context.Done() привела к ошибкам в структуре. Задача не доведена до рабочего состояния.
Правильный ответ:
Это классическая задача на паттерн Worker Pool в Go. Кандидат должен знать for range по каналу и базовый select. Вот полное решение:
Базовое решение без контекста:
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
type siteContent struct {
URL string
Body []byte
Err error
}
func parallelDownload(urls <-chan string, numWorkers int) map[string]*siteContent {
results := make(map[string]*siteContent)
var mu sync.Mutex
var wg sync.WaitGroup
// Запускаем пул воркеров
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
// for range по каналу — горутина читает URL до закрытия канала
for url := range urls {
content := download(url)
mu.Lock()
results[url] = content
mu.Unlock()
}
}(i)
}
// Ждём завершения всех воркеров
wg.Wait()
return results
}
func download(url string) *siteContent {
resp, err := http.Get(url)
if err != nil {
return &siteContent{URL: url, Err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return &siteContent{URL: url, Err: err}
}
return &siteContent{URL: url, Body: body}
}
Ключевые моменты базового решения:
1. for range по каналу:
for url := range urls {
// Обрабатываем URL
}
Горутина блокируется на чтении из канала и завершается, когда канал закрыт. Это идиоматический способ чтения из канала в Go.
2. sync.Mutex для защиты map:
mu.Lock()
results[url] = content
mu.Unlock()
Мапы в Go не безопасны для конкурентной записи. Без мьютекса будет race condition и возможен panic.
3. WaitGroup для ожидания воркеров:
wg.Wait() // Блокируемся, пока все воркеры не вызовут Done()
Улучшенное решение с контекстом:
package main
import (
"context"
"fmt"
"io"
"net/http"
"sync"
)
type siteContent struct {
URL string
Body []byte
Err error
}
func parallelDownload(ctx context.Context, urls <-chan string, numWorkers int) (map[string]*siteContent, error) {
results := make(map[string]*siteContent)
var mu sync.Mutex
var wg sync.WaitGroup
// Канал для сбора ошибок от воркеров
errCh := make(chan error, 1)
var once sync.Once // Для отправки только первой ошибки
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
// Контекст отменён — выходим
return
case url, ok := <-urls:
if !ok {
// Канал закрыт — выходим
return
}
content, err := download(ctx, url)
if err != nil {
once.Do(func() {
select {
case errCh <- err:
default:
}
})
return
}
mu.Lock()
results[url] = content
mu.Unlock()
}
}
}(i)
}
wg.Wait()
// Проверяем, была ли ошибка
select {
case err := <-errCh:
return results, err
default:
return results, nil
}
}
func download(ctx context.Context, url string) (*siteContent, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request for %s: %w", url, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download %s: %w", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body for %s: %w", url, err)
}
return &siteContent{URL: url, Body: body}, nil
}
Как работает select:
select {
case <-ctx.Done():
return // Контекст отменён
case url, ok := <-urls:
if !ok {
return // Канал закрыт
}
// Обрабатываем URL
}
select блокируется до тех пор, пока один из case не станет готовым. Если оба готовы одновременно, Go выбирает случайный. Это правильный паттерн для мультиплексирования каналов — один select с несколькими case, а не вложенные select.
Использование:
func main() {
urls := make(chan string, 10)
// Отправляем URL в канал
go func() {
defer close(urls) // Важно: закрываем канал после отправки всех URL
urls <- "https://example.com"
urls <- "https://golang.org"
urls <- "https://pkg.go.dev"
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
results, err := parallelDownload(ctx, urls, 3)
if err != nil {
fmt.Printf("Error: %v\n", err)
}
for url, content := range results {
if content.Err != nil {
fmt.Printf("Failed %s: %v\n", url, content.Err)
} else {
fmt.Printf("Downloaded %s: %d bytes\n", url, len(content.Body))
}
}
}
Ошибки, которых стоит избегать:
1. Забыть закрыть канал:
// НЕПРАВИЛЬНО: канал не закрыт → горутины зависнут навсегда
go func() {
urls <- "https://example.com"
// Забыли close(urls)
}()
2. Конкурентная запись в map без мьютекса:
// НЕПРАВИЛЬНО: race condition, panic
results[url] = content // Несколько горутин пишут одновременно
3. Вызвать wg.Add() внутри горутины:
// НЕПРАВИЛЬНО: race condition с wg.Wait()
go func() {
wg.Add(1) // Может выполниться после wg.Wait()
defer wg.Done()
}()
4. Вложенные select вместо одного:
// НЕПРАВИЛЬНО: избыточная вложенность
select {
case url := <-urls:
select {
case <-ctx.Done():
return
default:
// обработка
}
}
// ПРАВИЛЬНО: один select
select {
case <-ctx.Done():
return
case url, ok := <-urls:
if !ok {
return
}
// обработка
}
Альтернативное решение с каналов результатов:
func parallelDownload(ctx context.Context, urls <-chan string, numWorkers int) (map[string]*siteContent, error) {
results := make(map[string]*siteContent)
resultCh := make(chan *siteContent, 100)
var wg sync.WaitGroup
// Запускаем воркеров
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range urls {
content, err := download(ctx, url)
if err != nil {
resultCh <- &siteContent{URL: url, Err: err}
continue
}
resultCh <- content
}
}()
}
// Горутина для закрытия resultCh после завершения воркеров
go func() {
wg.Wait()
close(resultCh)
}()
// Собираем результаты
for content := range resultCh {
results[content.URL] = content
}
return results, nil
}
Это решение использует канал результатов вместо мьютекса — другой идиоматический подход в Go: «Don't communicate by sharing memory; share memory by communicating».
Почему этот ответ лучше:
- Два решения: базовое и с контекстом.
- Объяснение паттерна
for rangeпо каналу — кандидат этого не знал. - Правильное использование
selectбез вложенности. - Примеры типичных ошибок с объяснением.
- Альтернативное решение через каналы (CSP-стиль).
- Показано правильное использование
WaitGroup,Mutex,Context.
Вопрос 10. Реализуй алгоритм Run-Length Encoding (RLE) — сжатие строки, где подряд идущие одинаковые символы заменяются на символ+количество.
Таймкод: 01:05:28
Ответ собеседника: неполный. Кандидат верно описал алгоритм словами, правильно отверг использование map. Однако при реализации допустил ошибки: забыл continue, не обработал последнюю группу символов, попытался добавить «костыль» с фиктивным символом. Код не завершён.
Правильный ответ:
Кандидат правильно понял логику, но допустил типичные ошибки при реализации: забыл обработать последнюю группу и неправильно организовал ветвление. Вот полное решение:
Решение с использованием strings.Builder:
package main
import (
"fmt"
"strconv"
"strings"
)
func encodeRLE(input string) string {
if len(input) == 0 {
return ""
}
var sb strings.Builder
// Предвыделяем память — результат обычно короче исходной строки
sb.Grow(len(input))
currentChar := input[0]
count := 1
for i := 1; i < len(input); i++ {
if input[i] == currentChar {
count++
} else {
// Записываем предыдущую группу
sb.WriteByte(currentChar)
sb.WriteString(strconv.Itoa(count))
// Начинаем новую группу
currentChar = input[i]
count = 1
}
}
// Не забываем записать последнюю группу!
sb.WriteByte(currentChar)
sb.WriteString(strconv.Itoa(count))
return sb.String()
}
func main() {
fmt.Println(encodeRLE("aaaabbbccaa")) // a4b3c2a2
fmt.Println(encodeRLE("a")) // a1
fmt.Println(encodeRLE("abc")) // a1b1c1
fmt.Println(encodeRLE("aaaa")) // a4
fmt.Println(encodeRLE("")) // (пустая строка)
}
Ключевые моменты реализации:
1. Обработка пустой строки:
if len(input) == 0 {
return ""
}
Без этой проверки input[0] вызовет panic при пустой строке.
2. Цикл начинается с индекса 1, а не 0:
currentChar := input[0] // Первый символ уже "в работе"
count := 1
for i := 1; i < len(input); i++ {
// Сравниваем с текущим символом
}
Мы начинаем с первого символа и считаем его как начало первой группы. Цикл идёт со второго символа, сравнивая с текущим.
3. Запись последней группы после цикла:
// После завершения цикла записываем последнюю группу
sb.WriteByte(currentChar)
sb.WriteString(strconv.Itoa(count))
Это критически важная часть, которую кандидат пропустил. Цикл записывает группу только при обнаружении нового символа. Последняя группа не вызывает «переключения», поэтому её нужно записать отдельно после цикла.
4. strings.Builder вместо конкатенации:
var sb strings.Builder
sb.Grow(len(input)) // Предвыделяем память
strings.Builder эффективнее конкатенации через +, потому что не создаёт промежуточные строки.
Альтернативное решение с rune для Unicode:
func encodeRLEUnicode(input string) string {
if len(input) == 0 {
return ""
}
var sb strings.Builder
sb.Grow(len(input))
runes := []rune(input)
currentChar := runes[0]
count := 1
for i := 1; i < len(runes); i++ {
if runes[i] == currentChar {
count++
} else {
sb.WriteRune(currentChar)
sb.WriteString(strconv.Itoa(count))
currentChar = runes[i]
count = 1
}
}
sb.WriteRune(currentChar)
sb.WriteString(strconv.Itoa(count))
return sb.String()
}
Это решение корректно обрабатывает Unicode-символы, которые могут занимать несколько байт.
Решение с декодером:
func decodeRLE(encoded string) (string, error) {
if len(encoded) == 0 {
return "", nil
}
var sb strings.Builder
i := 0
for i < len(encoded) {
// Читаем символ
char := rune(encoded[i])
i++
// Читаем число (может быть многозначным)
numStart := i
for i < len(encoded) && encoded[i] >= '0' && encoded[i] <= '9' {
i++
}
if numStart == i {
return "", fmt.Errorf("expected digit at position %d", i)
}
count, err := strconv.Atoi(encoded[numStart:i])
if err != nil {
return "", fmt.Errorf("parse count: %w", err)
}
for j := 0; j < count; j++ {
sb.WriteRune(char)
}
}
return sb.String(), nil
}
Тесты:
import "testing"
func TestEncodeRLE(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"aaaabbbccaa", "a4b3c2a2"},
{"a", "a1"},
{"abc", "a1b1c1"},
{"aaaa", "a4"},
{"", ""},
{"aabbaa", "a2b2a2"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := encodeRLE(tt.input)
if result != tt.expected {
t.Errorf("encodeRLE(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestDecodeRLE(t *testing.T) {
tests := []struct {
encoded string
expected string
}{
{"a4b3c2a2", "aaaabbbccaa"},
{"a1", "a"},
{"a1b1c1", "abc"},
{"a4", "aaaa"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.encoded, func(t *testing.T) {
result, err := decodeRLE(tt.encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("decodeRLE(%q) = %q, want %q", tt.encoded, result, tt.expected)
}
})
}
}
func TestRoundTrip(t *testing.T) {
inputs := []string{"aaaabbbccaa", "a", "abc", "aaaa", "aabbaa"}
for _, input := range inputs {
encoded := encodeRLE(input)
decoded, err := decodeRLE(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != input {
t.Errorf("round trip failed: %q → %q → %q", input, encoded, decoded)
}
}
}
Типичные ошибки, которых стоит избегать:
1. Забыть записать последнюю группу:
// НЕПРАВИЛЬНО: последняя группа теряется
for i := 1; i < len(input); i++ {
if input[i] == currentChar {
count++
} else {
sb.WriteByte(currentChar)
sb.WriteString(strconv.Itoa(count))
currentChar = input[i]
count = 1
}
}
// Нет записи последней группы!
2. Использовать map вместо последовательного подсчёта:
// НЕПРАВИЛЬНО: map не сохраняет порядок и группировку
counts := make(map[byte]int)
for i := 0; i < len(input); i++ {
counts[input[i]]++
}
// Для "aaaabbbccaa" получим {a:6, b:3, c:2}, а не a4b3c2a2
3. Не обрабатывать пустую строку:
// НЕПРАВИЛЬНО: panic при пустой строке
currentChar := input[0] // index out of range
Почему этот ответ лучше:
- Рабочий код с обработкой edge cases.
- Объяснение ключевых моментов: начало с индекса 1, запись последней группы.
- Версия с поддержкой Unicode.
- Декодер для полноты решения.
- Тесты: модульные и round-trip.
- Разбор типичных ошибок.
