Git, SOLID на Go, Linux/Bash
3 инженерные темы, которые «выдают» реальный командный опыт не хуже алгоритмических вопросов: Git-воркфлоу, SOLID на Go, базовый Linux.
Git: feature branch workflow, merge vs rebase
Feature branch workflow: под каждую задачу — отдельная ветка от main, работа идёт в ней, main в это время остаётся стабильным и всегда готовым к деплою. По готовности — Pull Request, ревью, слияние обратно.
git merge и git rebase решают одну и ту же задачу (влить изменения ветки в другую) принципиально по-разному, и разница видна на самих хэшах коммитов.
git merge feature из main создаёт НОВЫЙ merge-коммит с ДВУМЯ родителями (последний коммит main и последний коммит feature), а исходные коммиты обеих веток остаются КАК ЕСТЬ — те же хэши, что и были. История получается ветвящейся:
до merge: после merge:
main: A---B main: A---B-------M
\ \ /
feature: C---D feature: C-----D
git rebase main внутри feature «переигрывает» коммиты feature заново, один за другим, поверх текущего конца main — коммиты C и D физически ПЕРЕСОЗДАЮТСЯ как C' и D' с ДРУГИМИ хэшами (хотя с тем же содержимым изменений). История выглядит линейной, без явного слияния:
до rebase: после rebase:
main: A---B main: A---B---C'---D'
\
feature: C---D
Золотое правило, которое стоит объяснять именно через механизм, а не как запрет "на веру": нельзя делать rebase уже запушенной ветки, которую использует кто-то ещё. Если коллега уже сделал git pull ветки feature с коммитами C и D, а ты потом сделал rebase и запушил C' и D' — у коллеги в истории всё ещё старые C и D, у тебя на сервере уже новые C' и D', и Git не может автоматически понять, что это "то же самое, только другое".
Чтобы всё-таки запушить переписанную историю, нужен --force, который перезаписывает историю на сервере — и если коллега в это время строил что-то поверх старых C/D, его работа рискует потеряться или потребовать болезненного разрешения конфликтов задним числом.
SOLID на Go, functional options, рефакторинг
У Go нет классов и наследования — SOLID переносится не буквально, а через интерфейсы и композицию. Смысл каждой буквы легче понять не как абстрактное правило, а через то, что конкретно ломается, если её нарушить.
S — Single Responsibility. У модуля должна быть ровно одна причина меняться. Практическое воплощение в Go-бэкенде — разделение на слои handler → service → repository: у handler причина меняться — изменение HTTP-контракта (новый заголовок, другой формат ответа), у service — изменение бизнес-правила, у repository — смена способа хранения данных. Если весь код лежит в одном хендлере, любое из трёх разных по природе изменений заставляет трогать один и тот же файл.
O — Open/Closed. Модуль открыт для расширения, но закрыт для изменения уже существующего кода. На практике это означает: новое поведение добавляется НОВЫМ типом, реализующим существующий интерфейс, а не правкой имеющегося switch/if-else внутри уже работающей функции.
Если завтра появится третий вид скидки (например, фиксированная сумма), она добавляется НОВЫМ типом FixedDiscount, реализующим тот же интерфейс Discount — функция checkout не меняется ни на строчку, хотя её поведение расширилось. Нарушение принципа выглядело бы как один тип Discount с полем Kind string и switch d.Kind { case "percent": ...; case "none": ... } внутри checkout — тогда каждый новый вид скидки требовал бы правки уже существующей, уже протестированной функции.
L — Liskov Substitution. Любая реализация интерфейса должна быть взаимозаменяема с другими по ПОВЕДЕНИЮ, не только по сигнатуре метода. В примере выше это означает: если бы PercentDiscount с отрицательным процентом вместо уменьшения цены её увеличивал молча (нарушая интуитивный контракт "скидка не может делать товар дороже"), код, написанный для интерфейса Discount в целом, начал бы вести себя неожиданно именно с этой конкретной реализацией — формально сигнатура Apply(price float64) float64 соблюдена, а фактическое поведение нарушает то, что остальной код имеет право ожидать от любого Discount.
I — Interface Segregation. Несколько маленьких интерфейсов лучше одного большого. Канонический пример из самого Go — io.Reader с единственным методом Read(p []byte) (n int, err error): код, которому нужно только читать, зависит именно от этого узкого интерфейса, а не от гипотетического io.ReadWriteCloserSeeker, который заставил бы любую реализацию (даже такую, которая никогда не будет писать или закрываться) реализовывать методы, ей не нужные.
D — Dependency Inversion. Код верхнего уровня должен зависеть от абстракции, а не от конкретной реализации нижнего уровня. В Go это выражается конкретной идиомой, которая на собеседовании звучит как отдельный вопрос: интерфейс объявляется в пакете, который его ПОТРЕБЛЯЕТ, а не в пакете, который его реализует.
// usecase/ports.go — пакет-потребитель сам формулирует, что ему нужно
type UserRepository interface {
FindByID(id int) (string, error)
}
// repository/postgres.go — пакет-реализация просто удовлетворяет уже
// существующему интерфейсу, ничего своего не объявляя
type postgresUserRepo struct{}
func (postgresUserRepo) FindByID(id int) (string, error) {
return fmt.Sprintf("user-%d", id), nil
}
// usecase/service.go — зависит только от интерфейса UserRepository,
// НЕ импортирует пакет repository вообще
type UserService struct{ repo UserRepository }Если бы интерфейс жил в пакете repository, пакету usecase пришлось бы импортировать repository просто чтобы взять оттуда тип интерфейса — то есть бизнес-логика получила бы compile-time зависимость от деталей хранения данных, чего идиома специально избегает. Практическое следствие видно в тестах: usecase тестируется через фейковую реализацию UserRepository, вообще не подключая ни repository, ни настоящую БД.
Functional options — паттерн конфигурации через переменное число ...Option-функций вместо длинного списка позиционных параметров конструктора:
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second} // разумные значения по умолчанию
for _, opt := range opts {
opt(s)
}
return s
}
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
// вызов указывает только то, что хочет изменить:
srv := NewServer(":8080", WithTimeout(5*time.Second))Преимущество видно при эволюции API: добавление новой опции (WithMaxConnections(n)) не требует менять сигнатуру NewServer и не ломает уже написанные вызовы без этой опции — при позиционных параметрах конструктора добавление нового обязательного аргумента сломало бы вообще весь существующий код, который его создаёт.
На секции код-ревью в Т-Банке и Avito эти принципы проверяются практически, а не как экзамен на знание аббревиатуры — интервьюер смотрит на нейминг, обработку ошибок, таймауты в контекстах, закрытие каналов в присланном коде и делает выводы про S/O/D по факту, даже если сам термин "SOLID" ни разу не прозвучит вслух.
Linux/Bash, процесс vs поток
Базовые команды стоит помнить не как список, а по вопросу, на который каждая отвечает: ps aux — "какие процессы работают ПРЯМО СЕЙЧАС" (статичный снимок момента времени). top — тот же вопрос, но живьём, с обновлением в реальном времени. grep — "где в файлах/потоке встречается вот этот шаблон". chmod +x file — "сделать файл исполняемым", без этого ./file откажет с ошибкой прав доступа, даже если содержимое файла синтаксически корректный скрипт.
Процесс vs поток — один из самых частых общих вопросов на собеседовании (по статистике roadmap, около трети интервью его так или иначе касаются), и разница проще всего запоминается через то, что они НЕ делят между собой.
Процесс — изолированное адресное пространство памяти со своим PID. Два процесса физически не могут напрямую прочитать переменную друг друга — для любого обмена данными между ними нужен явный механизм IPC (pipe, socket, shared memory, message queue) — изоляция сильная, но и накладные расходы на переключение между процессами и на сам обмен данными выше.
Поток — единица выполнения ВНУТРИ одного процесса. Все потоки одного процесса делят одно и то же адресное пространство памяти НАПРЯМУЮ — переменная, объявленная в одном потоке, видна и изменяема из другого без всякого IPC. Это удобнее и быстрее для обмена данными, но именно отсюда и берётся необходимость синхронизации (Mutex, atomic — весь материал предыдущих глав) — без нужной защиты два потока, пишущие в одну и ту же память одновременно, портят данные друг друга.
Горутина Go — ни то, ни другое в чистом виде. Она легче потока ОС на порядки (килобайты стека вместо мегабайт, дешёвое создание), и управляется рантаймом Go, а не ядром операционной системы напрямую — планировщик Go сам решает, на какой поток ОС (M) в какой момент посадить конкретную горутину (G), пользуясь логическими процессорами (P), как разбиралось в главе про GMP.
С точки зрения памяти горутины ведут себя как потоки одного процесса (общее адресное пространство, нужна синхронизация), но по цене создания и переключения — на порядки ближе к "почти бесплатной" абстракции, чем настоящий поток ОС.