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

Если вы идете по тому же пути, что и я, я думаю, вы также заметили, что люди (инженеры) часто приходят и уходят. Код этой компании также переживает одного инженера. Эти проблемы могут описывать нашу рабочую среду, производственный контекст.

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

Итак, вот некоторые базовые знания, которые я использовал для реализации внедрения зависимостей.

Внедрение зависимостей (неконтейнерный DI)

Внедрение зависимостей - это метод, с помощью которого один объект (или статический метод) предоставляет зависимости другого объекта. Зависимость - это объект, который можно использовать (услуга). Например, если у вас есть объект, которому может потребоваться клиент базы данных для запуска функции, это означает, что ваш объект зависит от объекта базы данных. Поэтому вместо того, чтобы создавать новое соединение с базой данных каждый раз, когда вы собираетесь вызывать эту функцию, мы вводим объект базы данных один раз в качестве параметра, так что этой функции не нужно создавать его снова и снова.

Реализуя эту технику, я намерен отделить объекты и позволить новому инженеру, который присоединится к команде, легко понять, что Resource (может быть package в реальном случае) зависит от соединения с базой данных, чтобы правильно его запустить. Перед тем, как перейти к внедрению зависимостей, вот пример простого объекта golang, который зависит от объекта базы данных.

package resource
import "database/sql"

// Resource is type that being used as an object to receive 
// dependency
type Resource struct {
	db *sql.DB
}

// New is constructor function that do the injection to Resource
func New(db *sql.DB) *Resource {
	r := &Resource{
		db: db,
	}

	return r
}

// PingDB is method receiver from type Resource which using 
// database injection from constructor func
func (r *Resource) PingDB() error {
	return r.db.Ping()
}

В приведенном выше фрагменте мы видим, что функция-конструктор New() получает объект базы данных в качестве параметра для создания объекта Resource. Эта функция-конструктор может помочь нам определить, что Resource необходимо для правильного выполнения, и поможет нам проверить любую ошибку во время компиляции, если есть добавление / удаление параметров.

Интерфейс

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

package main

// Dog struct
type Dog struct{}

// Speak is receiver method from Dog struct
func (d *Dog) Speak() string {
	return "woof"
}

// Animal interface will be like a contract that going to 
// implement any animal that implementing Speak() method
type Animal interface {
	Speak() string
}

func main() {
	var a Animal
	a = &Dog{} // example of using Dog as an Animal

	fmt.Println(a.Speak())
}

В приведенном выше примере интерфейс Animal может получать любой тип, если у этого типа есть метод-получатель Speak() string. Преимущество этого способа в том, что мы можем создать слабосвязанный код, поскольку нам не нужно знать, что на самом деле происходит на (d *Dog) Speak() или, я могу сказать, на инжекторе. Более того, мы можем сгенерировать фиктивную функцию через интерфейс Animal, а также с помощью gomock. С этой возможностью нашему сервису было бы проще тестировать конкретную функцию, поскольку мы используем объявленный интерфейс для создания имитаций и уделяем больше внимания тестированию другой бизнес-логики.

Реализация внедрения зависимостей

Основываясь на ранее полученных знаниях, я реализовал внедрение зависимостей в три части: Инжектор, Сервис, Ресурс. Для простоты и обучаемости я буду использовать случай, когда пользователь собирается сделать заказ на торговой площадке.

Ресурс - это часть, где происходят все вызовы зависимостей (база данных, redis и т. д.). Сам вызов будет вызываться путем введения зависимости ресурса в качестве параметра функции конструктора, и эта часть может использовать одно соединение снова и снова (в случае соединения с базой данных). Я реализую все, что связано с преобразованием данных в ресурсную часть.

package order
import "database/sql"
// Resource is struct that containing of package user dependencies
type Resource struct {
 db *sql.DB
}
// New is constructor function to inject dependency to user resource
func New(db *sql.DB) *Resource {
 return &Resource{
  db: db,
 }
}
// CreateOrder do insert order data to database
// Will return error when no record found
func (r *Resource) CreateOrder(userID int64, item string, quantity int) error {
 /*
  insert data to database
  _, err := r.db.Exec()
 */
 return nil
}

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

package order
import "errors"
type (
 // Order represent order table
 Order struct {
  ID       int64  `db:"id"`
  UserID   int64  `db:"user_id"`
  Item     string `db:"item"`
  Quantity int    `db:"quantity"`
  Amount   int64  `db:"amount"`
 }
// UserService indicate what function from user service that being 
// used by order service
 UserService interface {
  IsLoggedIn(userID int64) (bool, error)
 }
// OrderResource indicate what function being used from resource 
// order
 OrderResource interface {
  CreateOrder(userID int64, item string, quantity int) error
 }
// Service struct containing list of dependency from service order
Service struct {
  userService   UserService // This is how each service communicate with each others
  orderResource OrderResource
 }
)
// New construct new order service
func New(orderResource OrderResource, userService UserService) *Service {
 return &Service{
  orderResource: orderResource,
  userService:   userService,
 }
}
// NewOrder is example of using multiple service
func (s *Service) NewOrder(userID int64, item string, qty int) error {
 loggedIn, err := s.userService.IsLoggedIn(userID)
 if err != nil {
  return err
 }
if !loggedIn {
  return errors.New("please login")
 }
return s.orderResource.CreateOrder(userID, item, qty)
}

В приведенном выше коде мы видим, что мы используем userservice и помещаем его как зависимость от orderservice. Поскольку userservice - это интерфейс, мы можем легко сделать mock из этой службы, и когда мы собираемся протестировать функцию, которая использует его, наш тест будет больше сосредоточен на тестировании бизнес-логики.

Инжектор будет работать как место, где вы создаете экземпляры всех своих зависимостей, таких как database connection, service packages, resource packages, etc , и выполняете инъекцию в каждую часть. Я не реализовал подход контейнера зависимостей или что-то подобное, вроде uber-go / dig или google / wire, а довольно простой подход, рассматривая зависимости как параметры для типа или конструктора.

package main
import (
 resourceorder "github.com/nydan/resource/order/order"
 resourceuser "github.com/nydan/resource/user/user"
 serviceorder "github.com/nydan/service/order/order"
 serviceuser "github.com/nydan/service/user/user"
)
func main() {
 cfg := GetConfig()
 db, err := ConnectDatabase(cfg.Database)
 if err != nil {
  panic(err)
 }
 cache, err := ConnectRedis(cfg.Redis)
 if err != nil {
  panic(err)
 }
 resourceUser := resourceuser.New(db, cache)
 serviceUser := serviceuser.New(resourceUser)
 resourceOrder := resourceorder.New(db)
 serviceOrder := serviceorder.New(resourceOrder, serviceUser)
 server := NewServer(cfg.ListenAddr, serviceUser, serviceOrder)
 server.Run()
}

В приведенном выше коде к концу все созданные экземпляры служб станут параметром на NewServer, где вы можете поместить туда свой http.Handler. Для своей реализации я сделал еще один пакет для управления своим API и снова определил интерфейс в точке использования для того, какой сервис будет открыт для публики.

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