golang

За чистую main

  • суббота, 21 февраля 2026 г. в 00:00:13
https://habr.com/ru/articles/1001928/

Лирическое предисловие

В конечном счете, программист — это ремесленник, а не художник. И это вовсе не унизительно. Да, он дизайнер, он инженер. Но он и ремесленник, конечная задача которого - сделать максимально удобное и минимально дорогое изделие для заказчика. И если художник может сказать мемное "Я так вижу!", то для программиста - это будет маркер профнепригодности.
К чему это я?
Программный продукт может иметь большой жизненный цикл, на протяжении которого он будет расти и развиваться. И если вы сэкономили время, что-то упростив в начале разработки, оно потом может с лихвой аукнуться позже. Ровно потому, что "жадный платит дважды".
Истины, конечно, азбучные. Но! Я не раз замечал, что многим программистам не свойственна эмпатия к себе подобным. И если им свой код кажется вполне понятным, то тех, кому, он не заходит, они подсознательно считают недостаточно профессиональными. И что греха таить - за собой такое тоже замечал.
Поэтому даже если Open closed Principle соблюдается, то стремление сделать код понятным любому джуну - присутствует увы, нечасто.
По этому поводу, недавно вычитал такой афоризм у Германа Горелкина:

"Стремление писать умный код присуще почти всем инженерам; это воспринимается как доказательство компетентности.
Но разработка программного обеспечения - это то, что происходит, когда вы добавляете время и других программистов.
В такой среде ясность - это не вопрос стиля, а снижение операционных рисков."

Обычное предисловие

Театр начинается с вешалки, а любая программа на Go - с функции main. Собственно, что может быть сложного и плохочитаемого в main, казалось бы? Но нет. На написание этой статьи меня сподвиг реальный кейс. Некий вполне себе обычный микросервис, написанный коллегами, содержал main примерно так на шесть экранов. И я бы не сказал, что этот код читался как детская книжка.
Собственно, внутри самой функции не делалось ничего необычного - инициализация зависимостей и запуск основных компонент сервиса. Но как-то совсем не радовал этот код, особенно если ты видел его впервые. И он больше смахивал на лапшу.
Поскольку в дальнейшем планировалось разрабатывать не один и не два микросервиса, возникло острое желание разработать общий подход, шаблон для написания main. Чтобы код был единообразным, читабельным и легко модифицируемым. Пока зоопарк не разросся.

Как хотелось бы

Поскольку компоненты приложения обычно зависят друг от друга, хотелось бы, чтобы при запуске они создавались и стартовали последовательно. Ну и завершались - в обратном порядке. И чтобы код читался как обычный текст, как-то так:

  1. Создать компонент БД

  2. Подключиться к БД

  3. Создать компонент сервера

  4. Запустить сервер

  5. ...

  6. Остановить сервер

  7. Закрыть БД

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

Как не хотелось бы

Но нет. В моём случае, в проекте использовалась популярная библиотека github.com/oklog/run которая запускала в горутинах основные компоненты приложения. И ничего плохого в этом нет, пока вам неважен порядок запуска горутин. Но вот если один компонент должен запускаться строго после запуска другого - вот тут начинались самые неприятные моменты, которые были решены в коде довольно "костыльно".
Последовательное создание зависимостей - тоже не самый оптимальный момент. Стоит нечаянно нарушить порядок и ты получишь nil pointer exception. Благо, с уничтожением оных в Go проблем нет - сборщик мусора рулит (но и расслабляет)! Если зависимостей 3-4 - это не беда. Но вот если их под десяток... Учитывая, что main не покрыта тестами, а на smoke test надежда небольшая - это всё не добавляет радости и ощущения надежности.

Варианты решения

Внедрение зависимостей

ИМХО, оптимальный вариант - использовать например фреймворк dig от компании Uber. Но большие дяди пишут, что данный подход не соответствует философии языка: простота, читаемость, отсутствие скрытой логики.
Ок, делаем в соответствии с философией - без магии.

Первым делом, чтобы сделать main более чистым, целеcообразно вынести из него контейнер зависимостей в отдельную сущность, которая будет отвечать за их хранение и инициализацию. Сказано - сделано:

type App struct {
	config          *config.Config
	repository      repository.Repository
	usecase         *usecase.Usecase
	rest            *rest.Rest
}

func New()(*App, error) {

	a := &App{}

    cfg, err := config.New()
    if err != nil {
        return nil, fmt.Errorf("failed to load config: %w", err)
    }

    a.config = cfg

    sqlSet, err := sqlset.New(queries.QueriesFS)
    if err != nil {
        return nil, fmt.Errorf("failed to load queries: %v", err)
    }

    a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))

    a.usecase = usecase.New(a.repository)

    ...

	return a, nil
}

И всё вроде бы хорошо и красиво, но если вы вдруг нечаянно нарушите порядок инициализации компонент и к примеру, забудете создать a.repository ДО создания a.usecase - приложение упадет с nil pointer exception и не факт, что это произойдет при его старте. Когда приложение содержит много компонент, приходится тщательно следить за графом построения зависимостей. И крайне желательно добавлять в конструкторы каждой из них проверку входных параметров на nil. И это раздражает. И хочется сделать, чтобы оно "как-нибудь само".

Чтобы исключить любые потенциальные ошибки порядка создания зависимостей, я предлагаю использовать паттерн lazy singleton, при котором объект создается ровно оди раз по первому требованию. У данного паттерна есть существенный недостаток: Lazy в приложениях часто приводит к поздним падениям и сложной диагностике. Но если использовать этот паттерн только для основных зависимостей которые гарантировано инициализируются при старте приложения на этапе bootstrap - этот недостаток нивелируется. Получается lazy по механике, но eager по факту исполнения.

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

type App struct {
	config          *config.Config
	repository      repository.Repository
	usecase         *usecase.Usecase
	rest            *rest.Rest
}

func (a *App) Config() *config.Config {
	if a.config == nil {
		var err error
		a.config, err = config.New()
		if err != nil {
			panic(fmt.Errorf("failed to load config: %w", err))
		}
		a.config = cfg
	}

	return a.config
}

func (a *App) Repository() repository.Repository {
	if a.repository == nil {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panic(fmt.Errorf("failed to load queries: %v", err))
		}

		a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))
	}

	return a.repository
}

func (a *App) Usecase() *usecase.Usecase {
	if a.usecase == nil {
		a.usecase = usecase.New(a.Repository())
	}

	return a.usecase
}

func (a *App) Rest() *rest.Rest {
	if a.rest == nil {
		a.rest = rest.New(a.Config().Port, a.Usecase())
	}

	return a.rest
}

Используем геттеры примерно так:

func (a *App) Start(cancel context.CancelCauseFunc) error {
	if err := a.Repository().Connect(a.Config().DBConnStr, 5*time.Second); err != nil {
		return fmt.Errorf("failed to connect to database: %v", err)
	}

	a.Rest().Start(cancel)

	return nil
}

Первое, что не понравилось в таком коде - паники. Поскольку геттер должен возвращать одно значение, при ошибке создания объекта, приходится вызывать panic. И это не очень красивое решение. Хорошо, используем recovery, не вопрос. Создадим особый тип ошибки, чтобы его можно было распознать в recovery и превратить в исходную ошибку в main.
Выглядеть это будет примерно так:

type ErrorPanic struct {
	Err error
}

func (e ErrorPanic) Error() string {
	return e.Err.Error()
}

func panicError(err error) {
	panic(ErrorPanic{Err: err})
}

func (a *App) Recover() {
	if r := recover(); r != nil {
		if err, ok := r.(ErrorPanic); ok {
			slog.Error("failed to initialize application", slog.String("err", err.Error()))

			return
		}

		panic(r)
	}
}

А использоваться - так:

func main() {
	a := app.New()
	defer a.Recover()

    ...

}

Отлично! Проблема решена! Я долго так думал. Пока в одном из кейсов не натолкнулся на периодические странности в поведении приложения. Всё оказалось просто - я не подумал о потокобезопасности. В моей парадигме, все геттеры должны вызываться в главном потоке. Но "должны" - не значит "будут". Когда из двух горутин приложение одновременно обращается к одному геттеру, возникает состояние гонки (race condition). И иногда, объект создается более одного раза. Самое противное, конечно тут это слово "иногда"...

И тут на помощь нам придет замечательный примитив синхронизации sync.Once. Поправив буквально несколько строк получаем такое:

type App struct {
	config     *config.Config
	configOnce sync.Once

	repository     repository.Repository
	repositoryOnce sync.Once

	usecase     *usecase.Usecase
	usecaseOnce sync.Once

	rest     *rest.Rest
	restOnce sync.Once
}

func (a *App) Config() *config.Config {
	a.configOnce.Do(func() {
		var err error
		a.config, err = config.New()
		if err != nil {
			panicError(fmt.Errorf("failed to load config: %w", err))
		}
	})

	return a.config
}

func (a *App) Repository() repository.Repository {
	a.repositoryOnce.Do(func() {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panicError(fmt.Errorf("failed to load queries: %v", err))
		}

		a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))
	})

	return a.repository
}

func (a *App) Usecase() *usecase.Usecase {
	a.usecaseOnce.Do(func() {
		a.usecase = usecase.New(a.Repository())
	})

	return a.usecase
}

func (a *App) Rest() *rest.Rest {
	a.restOnce.Do(func() {
		a.rest = rest.New(a.Config().Port, a.Usecase())
	})

	return a.rest
}

Тут ну всё прекрасно. Но лично мне режет глаз необходимость задавать два поля на каждый компонент приложения - ссылку на объект и свой sync.Once. Интуиция и тяга к прекрасному подсказывает, что правильно объединить это в одну сущность. А еще лучше - сделать шаблон, дженерик. Пробуем...
Дженерик:

type Lazy[T any] struct {
	once sync.Once
	val  T
	ctor func() T
}

func NewLazy[T any](ctor func() T) *Lazy[T] {
	return &Lazy[T]{ctor: ctor}
}

func (l *Lazy[T]) Get() T {
	l.once.Do(func() {
		l.val = l.ctor()
	})
	return l.val
}

Использование:

type App struct {
	config     *Lazy[*config.Config]
	repository *Lazy[repository.Repository]
	usecase    *Lazy[*usecase.Usecase]
	rest       *Lazy[*rest.Rest]
}

Инициализация:

func NewApp() *App {
	app := &App{}

	app.config = NewLazy(func() *config.Config {
		cfg, err := config.New()
		if err != nil {
			panic(fmt.Errorf("failed to load config: %w", err))
		}
		return cfg
	})

	app.repository = NewLazy(func() repository.Repository {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panic(fmt.Errorf("failed to load queries: %w", err))
		}
		return postgres.New(sqlsetpgxhelper.New(sqlSet))
	})

	app.usecase = NewLazy(func() *usecase.Usecase {
		return usecase.New(app.repository.Get())
	})

	app.rest = NewLazy(func() *rest.Rest {
		return rest.New(app.config.Get().Port, app.usecase.Get())
	})

	return app
}

Сделал. Посмотрел. Подумал. Откатил. Кода получается не меньше, а больше. Код стал менее явным. Слишком большая плата за исключение второго поля, ИМХО. И это уже ближе к DI-фреймворкам, которые, как мы помним - не вписываются в философию языка.

В итоге, теперь я ощущаю чистый кайф, когда при добавлении в проект нового компонента, достаточно добавить его геттер в main - пять строк кода. И ни о чем не думать, не бояться поломать инициализацию. Кому как, а мне это правда, приятно.

А аргументы про "неявный граф зависимостей" и "рекурсивную инициализацию" - они справедливы. Но кто сказал, что это - зло? Более того - я ж этого и добивался! Ну а если кто-то, пользуясь lazy выстрелит себе в ногу и соорудит циклическую ссылку зависимостей... Ну я даже не знаю, что сказать... Тут скорее уже вопрос больше о профпригодности такого программиста. Да и всплывет оно в конце-концов при первом же запуске приложения.

Старт приложения

Как известно, родной Go-шный HTTP-сервер, как и его потомки имеет блокирующий метод запуска. И наверное, зачем-то это нужно. Ну или это такой минимализм, полуфабрикат - все что нужно сверх этого, каждый допишет так, как ему удобно. Запуск в фоне должен быть явным выбором, мол.
Тем не менее, это не повод использовать run.group для всей main из примера:

ln, _ := net.Listen("tcp", ":8080")
g.Add(func() error {
	return http.Serve(ln, nil)
}, func(error) {
	ln.Close()
})

Впрочем, в самых простейших случаях - это наверное оправдано. Но в этой статье речь не о них.
Чтобы main была интуитивно понятной и легко читаемой (и модифицируемой), по моему глубокому убеждению, компоненты сервера должны всегда стартовать последовательно, синхронно, не блокируя приложение. Чтобы мы могли легко видеть и управлять очередностью запуска компонент (чего не позволяет использование run.group). И, когда всё гарантировано успешно стартовало, мы можем рапортовать наверх с помощью Kubernetes readinessProbe - я готов!
Поэтому, запускаем наш HTTP-сервер в фоне:

type Rest struct {
	srv     *fuego.Server
	port    int16
	stopCh  chan struct{}
	usecase *usecase.Usecase
	once    sync.Once
}

func (r *Rest) Start(cancel context.CancelCauseFunc) {
	ready := make(chan struct{})

	go func() {
		slog.Info("Starting HTTP server...")
		close(ready)

		err := r.srv.Run()
		if err != nil && err != http.ErrServerClosed {
			cancel(err)
		}

		close(r.stopCh)
	}()

	<-ready // waiting for the goroutine to start
}

func (r *Rest) Stop() error {
	if r.srv == nil {
		return nil
	}

	slog.Info("Stopping HTTP server...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	var err error
	r.once.Do(func() {
		err = r.srv.Shutdown(ctx)
	})
	<-r.stopCh

	return err
}

Отлично. Но что, если нам нужно по каким-то причинам гарантировано удостовериться, что HTTP-сервер стартовал и готов обслуживать клиентов? Увы, единственный 100% способ убедиться в этом - это пинговать сервер. Никто не мешает нам сделать это:

func (r *Rest) Ping(ctx context.Context) error {
	const (
		pingTimeout    = 5 * time.Second
		requestTimeout = 200 * time.Millisecond
		retryTimeout   = 100 * time.Millisecond
	)

	ctx, cancel := context.WithTimeout(ctx, pingTimeout)
	defer cancel()

	client := http.Client{
		Timeout: requestTimeout,
	}

	url := "http://" + r.srv.Addr + "/ping"

	for {
		req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

		resp, err := client.Do(req)
		if err == nil {
			resp.Body.Close() //nolint:errcheck

			if resp.StatusCode == http.StatusOK {
				return nil
			}

			err = fmt.Errorf("unexpected status code: %d", resp.StatusCode)
		}

		select {
		case <-ctx.Done():
			if err != nil {
				return err
			}

			return ctx.Err()
		case <-time.After(retryTimeout):
		}
	}
}

func (a *App) CheckHealth(ctx context.Context) error {
	return a.Rest().Ping(ctx)
}

Вот в принципе и всё. Все методы запуска и остановки приложения убраны "под капот" структуры App что вполне логично. Там же живет контейнер зависимостей. Остался финальный штрих - нарисовать красивую, чистую главную функцию main:

func main() {
	a := app.New()
	defer a.Recover()

	ctx, cancel := context.WithCancelCause(context.Background())
	defer cancel(nil)

	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()

	slog.Debug("application starting...")
	err := a.Start(cancel)
	if err != nil {
		slog.Error("failed to start the application", utils.LogErr(err))

		return
	}
	defer func() {
		err = a.Stop()
		if err != nil {
			slog.Error("error occurred while stopping the application", utils.LogErr(err))
		}

		slog.Debug("application stopped")
	}()

	err = a.CheckHealth(ctx)
	if err != nil {
		slog.Error("failed to check health", utils.LogErr(err))
		cancel(err)

		return
	}

	slog.Debug("application started")

	err = a.Wait(ctx)
	if err != nil {
		slog.Error("shutdown due to error", utils.LogErr(err))
	}

	slog.Debug("application stopping...")
}

Не знаю, как вам, но мне такая main радует глаз. Коротко, чисто, интуитивно понятно. Код открыт для расширения и не требует лишнего напряжения нервных клеток для своего понимания.
Впрочем, вполне возможно я что-то упустил или забыл предусмотреть. Буду рад любым вашим конструктивным предложениям и замечаниям.
Полный исходный код проекта доступен тут: github.com/istovpets/go-base-project