March 15

Uber.Fx

Теоретическая часть

Uber.Fx - это система инъекция зависимостей между слоями

Что такое инъекция зависимостей?

Если мы захотим строить удобное и расширяемое приложения мы непременно придем к использованию интерфейсов и разделению сервиса на слои. Основная концепция, которая преобладает в масштабируемых приложениях это чистая архитектура (Clean Architecture by Uncle Bob), ее часто называют луковой или трехслойной. Более подробно с ней можно ознакомиться здесь.

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

Зачем придумали Uber.Fx?

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

func main() {
	zap.ReplaceGlobals(zap.Must(zap.NewProduction())) // Zap

	if err := initConfig(); err != nil {
		zap.L().Error("error initializing configs", zap.Error(err))
		return
	}

	if err := godotenv.Load(); err != nil {
		zap.L().Error("env error", zap.Error(err))
		return
	}

	db, err := repo.NewPostgresDB(repo.Config{
		Host:     viper.GetString("db.host"),
		Port:     viper.GetString("db.port"),
		Username: viper.GetString("db.username"),
		Password: os.Getenv("DB_PASSWORD"),
		DBName:   viper.GetString("db.dbname"),
		SSLMode:  viper.GetString("db.sslmode"),
	})
	if err != nil {
		zap.L().Error("db initialize error", zap.Error(err))
		return
	}

	repository := repo.NewRepo(db)
	services := usecase.NewUseCase(repository)
	cacheService := cache.NewCache(services)
	handlers := handler.NewHandler(services, cacheService)

	// NATS Setup
	nc, err := nats.Connect(nats.DefaultURL)

	natsHandler := natsService.NewNats(services, nc, cacheService)

	nc.Subscribe("newOrder", func(m *nats.Msg) {
		natsHandler.CreateOrderNATS(m)
	})

	nc.Subscribe("getOrder", func(m *nats.Msg) {
		natsHandler.GetOrderByIdNATS(m)
	})

	if err != nil {
		zap.L().Error("nats connect error", zap.Error(err))
		return
	}

	// Cache Setup
	err = cacheService.LoadDataFromDatabase()
	if err != nil {
		zap.L().Error("cache reload error", zap.Error(err))
		return
	}

	// Web Setup
	srv := new(WB_Intern_L0.Server)
	if err := srv.Run(viper.GetString("ip")+":"+viper.GetString("port"), handlers.InitRoute()); err != nil {
		fmt.Println(err)
		zap.L().Error("Error starting server")
		return
	}
}

Удобно ли это? Конечно нет, поэтому Uber придумал свое собственное решение, чтобы убрать длинный main и превратить его в место, где мы можем легко разобраться в том, какие модули существуют у нас в приложении.

Примерно так выглядит проект, который использует Uber.Fx:

func main() {
    // Create global zap
	zap.ReplaceGlobals(zap.Must(zap.NewProduction()))

	// Create context
	ctx := context.Background()

	app := fx.New(
		fx.Provide(
			zap.NewProduction,
			configs.NewServiceConfig,
			repository.NewRepoConfig,
			repository.NewPostgresDB,
			repository.NewRepo,
			usecase.NewUseCase,
			handler.NewHandler,
			handler.InitRoute,
			CAndC.NewServer,
		),
		fx.Invoke(
			configs.InitConfig,
			CAndC.StartServer,
		),
	)

	err := app.Start(ctx)
	if err != nil {
		zap.L().Error("can't start app")
		return
	}
}

Таким образом, мы сразу видим какие именно зависимости есть в нашем проекте, они перечислены внутри скобок после fx.Provide. В нашем конкретном случае это:

  • Логгер в виде Zap'a
  • Конфиг нашего приложения
  • Конфиг слоя базы данных
  • Слой базы данных
  • Слой бизнес-логики
  • Слой хэндлеров
  • Прокидывание ручек в http
  • Запуск приложения

Функция Invoke явно запускает конструкторы, тем самым конфигурия приложение.

Как устроен Uber.Fx

Основная работа fx скрыта для нас под капотом и выглядит как настоящая магия, давайте разберемся что происходит на самом деле.

Когда мы передаем в fx.Provide наши конструкторы, он начинает строить граф зависимости, для приведенного приложения выше он будет выглядеть следующим образом:

Dependency Graph

На основе графа зависимостей при вызове он заполняет нужные слои при помощи конструкторов, таким образом мы сохраняем чистый main и имеем гарантии в том, что все элементы структур проинициализированы.

В чем плюсы использования Uber.Fx?

  • У нас есть гарантии в том что все наши слои проинициализированы в момент использования, иначе Uber.Fx просто не запуститься
  • Если каким-то образом у вас окажется циклическая зависимость, то Uber.Fx выявит это и не даст запустить приложение.
  • Мы явно выделяем компоненты нашего приложения, что означает, что код становится читаемым и самодокументируемым
  • Uber.Fx в рамках инициализации компонентов позволяет контролировать запуск и завершение компонента через fx.Lifecycle. Он позволяет создавать компоненты с GracefulShutdown

В чем минус использования Uber.Fx?

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

Практическая часть

Рассмотрим работу с Uber.Fx в рамках рабочего проекта

func runApplication() {
	serverConfig, err := config.Load()
	if err != nil {
	}

	app := fx.New(
		fx.Supply(serverConfig),
		fx.StopTimeout(serverConfig.ServerConfig.GracefulShutdown+time.Second),
		fx.Provide(
			// setup database
			database.NewDatabase,
			// setup auth
			authentication.NewAuthRepository,
			server.NewHandler,
			report.NewReportRepository,
			newServer,
		),
		fx.Invoke(
			server.RouteV1,
			//datagenerator.RouteV1, //TODO
			func(r *gin.Engine) {},
		),
	)
	app.Run()
}

Через fx.New мы инициализируем новое приложение, в данном случае мы используем fx.Supply для того чтобы подготовить граф зависимости без запуска самого конструктора serverConfig.

При помощи StopTimeout мы меняем завершение приложения.

Дальше мы при помощи fx.Provide инициализируем нашу инъекцию зависимостей и соединяем слои. Граф зависимостей простраивается на основе порядка вызова конструкторов, а затем при помощи Invoke мы указываем, как именно запустить наш сервис.

Следующим действием через инструкцию app.Run() мы запускаем наше приложение, которое вызывает Invoke и используя граф зависимостей простраивает приложения и запускает его.

Таким образом Uber.Fx позволяет нам довольно быстро охарактеризовать приложение следующим образом:

  • Наш сервис работает с базой данных
  • Имеет механизм авторизации
  • Получает данные через Handlers
  • В базе данных мы храним Report (Отчеты)

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

Разберемся с конструктором сервера

func newServer(lc fx.Lifecycle, cfg *config.Config) *gin.Engine {
	gin.SetMode(gin.DebugMode)
	r := gin.New()
	r.NoRoute(func(c *gin.Context) {
		c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "message": "Page not found"})
	})
	r.Use(middleware.TimeoutMiddleware(cfg.ServerConfig.WriteTimeout))

	srv := &http.Server{
		Addr:         fmt.Sprintf(":%d", cfg.ServerConfig.Port),
		Handler:      r,
		ReadTimeout:  cfg.ServerConfig.ReadTimeout,
		WriteTimeout: cfg.ServerConfig.WriteTimeout,
	}

	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			go func() {
				if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			return srv.Shutdown(ctx)
		},
	})
	return r
}

Тут конкретно нас интересует fx.Lifecycle, он позволяет нам управлять работой и завершением нашего приложения, и благодаря нему на основе контекстов, мы можем организовывать GracefulShutdown на случай экстренного завершения нашего приложения.