Введение

В этой статье мы сначала рассмотрим цели и принципы гексагональной архитектуры, а затем реализуем простую конечную точку HTTP API в Go.

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

Код доступен на Github.

Теория шестиугольной архитектуры

Гексагональная архитектура была впервые определена Алистером Кокберном в 2005 году. Название «гексагональная» происходит от оригинальных диаграмм с шестисторонними фигурами. Количество сторон на диаграмме произвольное, и позже Кокберн переименовал ее в «Шаблон портов и адаптеров».

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

Результатом использования гексагональной архитектуры является то, что бизнес-логика инкапсулируется в «основной» пакет, который не связан с остальной частью вашего проекта.

Актеры

В гексагональной архитектуре мы называем все, с чем взаимодействует ядро, «актером».

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

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

Порты

«Порты» — это интерфейсы, которые описывают, как должна происходить связь между ядром и участниками. Первичный порт — это тот, который подключается к первичному актору, а вторичный порт — к управляемому актору.

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

Порты принадлежат ядру. Важно, чтобы основной пакет не зависел ни от чего, что не определено в ядре; Помните, что мы пытаемся отделить нашу бизнес-логику от остальной части нашего приложения. Конкретно, основной пакет будет использовать интерфейсы для описания портов.

Адаптеры

«Адаптеры» отвечают за преобразование связи между ядром и внешними по отношению к нему субъектами. Роль адаптера состоит в том, чтобы позволить компонентам с несовместимыми интерфейсами кода взаимодействовать. Адаптер для порта драйвера (основного) преобразует входящий внешний запрос в вызов основной службы.

Адаптер управляемого (вторичного) порта преобразует вызов ядра в запрос актора. Например, ядро ​​может запросить «получить идентификатор пользователя X», и это может быть переведено в реализацию MySQL. Ядро не знает о технологии, реализующей постоянство, поэтому мы можем переключиться на базу данных Postgres, не внося никаких изменений в ядро.

Пример реализации на Go

Давайте реализуем простой HTTP API, который позволит нам создавать пользователей, а затем извлекать их.

Это древовидная структура того, что мы будем реализовывать:

.
├── helpers                 obtaining database conection
├── internal
│   ├── repositories        secondary adapters            
│   ├── core                must only depend on things defined in core
│   │   ├── domain          my internal domain objects
│   │   ├── ports
│   │   └── usecases        
│   └── handlers            primary adapters
└── orm                     the externally defined project domain objects

В более крупном проекте мы могли бы разбить функциональность на модули и поместить «внутренний» каталог в модуль.

Используя формат приведенной выше диаграммы, вот как будет выглядеть наш конкретный пример:

Важно отметить, что у нас может быть несколько основных адаптеров для нашего ядра. Например, мы могли бы также предложить GRPC или CLI в качестве средства взаимодействия с ядром. Точно так же мы могли бы использовать несколько или разные вторичные адаптеры; Например, используя Postgres вместо MySQL или обращаясь к другим службам, таким как служба авторизации.

Я собираюсь использовать Google Wire для управления внедрением зависимостей, Gorilla mux для маршрутизации и Gorm для имитации объекта домена проекта. Не стесняйтесь использовать здесь любые библиотеки, которые вам нравятся; в этом суть гексагональной архитектуры — ваш выбор библиотек не должен влиять на основную логику.

Я собираюсь начать строить изнутри — определить мою основную функциональность в портах. Это даст мне хорошее представление о том, где я хочу оказаться.

Мне нужно иметь возможность создать пользователя и получить его, поэтому вот основные функции, которые мне нужны:

package ports

import (
 "context"
 "github.com/andybeak/hexagonal-demo/internal/core/domain"
)

// UserUseCase is a primary port that the core must respond to
type UserUseCase interface {
 CreateUser(ctx context.Context, name string) (domain.User, error)
 GetUserById(ctx context.Context, id string) (domain.User, error)
}

// UserRepository is a secondary port that the core will make calls to
type UserRepository interface {
 Save(user domain.User) (domain.User, error)
 GetUserById(id string) (domain.User, error)
}

Обратите внимание, что я ссылаюсь на domain.User. Мое ядро ​​​​не может знать или заботиться о том, что проект думает о пользователе. Мое ядро ​​определяет своего собственного пользователя, а адаптеры переводят пользователя внешнего мира в то, о чем знает ядро.

Давайте быстро реализуем этого основного пользователя:

package domain

import "github.com/google/uuid"

type User struct {
 Id   string `json:"id"`
 Name string `json:"name"`
}

func NewUser(name string) User {
 return User{
  Id:   uuid.New().String(),
  Name: name,
 }
}

Обратите внимание, что я даю ему подсказки по сериализации, но не даю никакой информации о сохраняемости.

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

Сравните это со структурой Gorm, которую я использую для имитации сущности проекта:

package orm

import (
 "github.com/google/uuid"
 "gorm.io/gorm"
 "time"
)

type Model struct {
 ID        uuid.UUID `gorm:"type:char(36);primary_key"`
 CreatedAt time.Time
 UpdatedAt time.Time
 DeletedAt gorm.DeletedAt
}

// User can be a project entity, or just a DTO for persistence
type User struct {
 Model
 // Notice that we include persistence details in the struct that is external to core 
 Name string `gorm:"type:varchar(32)"`
}

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

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

package usecases

import (
 "context"
 "github.com/andybeak/hexagonal-demo/internal/core/domain"
 "github.com/andybeak/hexagonal-demo/internal/core/ports"
)

func ProvideUserUseCase(
 userRepository ports.UserRepository,
) ports.UserUseCase {
 return &userUseCase{
  userRepository: userRepository,
 }
}

// userUseCase implements ports.UserUseCase
type userUseCase struct {
 userRepository ports.UserRepository
}

func (u userUseCase) CreateUser(ctx context.Context, name string) (domain.User, error) {
 user := domain.NewUser(name)
 return u.userRepository.Save(user)
}

func (u userUseCase) GetUserById(ctx context.Context, id string) (domain.User, error) {
 return u.userRepository.GetUserById(id)
}

Обратите внимание на следующее:

  • Мы внедряем репозиторий, который реализует вторичный порт, определенный в нашем ядре; Ядро не знает, как реализуется персистентность.
  • Нам не нужно (не следует) экспортировать структуру userUseCase, потому что основной порт ( ports.UserUseCase) экспортируется
  • Мы используем нашу внутреннюю сущность домена, а не пользователя проекта. Мы можем определить функции в нашей структуре домена для реализации доменной логики, и нам не нужно полагаться на все функции, определенные во внешнем пользователе; Это должно заставить вас задуматься о S и I SOLID.

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

package adapters

import (
 "github.com/andybeak/hexagonal-demo/internal/core/domain"
 "github.com/andybeak/hexagonal-demo/internal/core/ports"
 "github.com/andybeak/hexagonal-demo/orm"
 "github.com/google/uuid"
 "gorm.io/gorm"
)

func ProvideUserRepository(db *gorm.DB) ports.UserRepository {
 return &mySQLUserRepository{
  db: db,
 }
}

// mySQLUserRepository implements ports.UserRepository
type mySQLUserRepository struct {
 db *gorm.DB
}

func (u mySQLUserRepository) Save(user domain.User) (domain.User, error) {
 ormUser := orm.User{
  Model: orm.Model{
   ID: uuid.Must(uuid.Parse(user.Id)),
  },
  Name: user.Name,
 }
 if err := u.db.Create(&ormUser).Error; err != nil {
  return domain.User{}, err
 }
 return user, nil
}

func (u mySQLUserRepository) GetUserById(id string) (domain.User, error) {
 var ormUser orm.User
 if err := u.db.First(&ormUser, "id = ?", id).Error; err != nil {
  return domain.User{}, err
 }
 return domain.User{
  Id:   ormUser.ID.String(),
  Name: ormUser.Name,
 }, nil
}

Опять же, мы реализуем порт, а не экспортируем структуру, которая делает эту реализацию.

Обратите внимание, как репозиторий адаптирует внутренний основной пользовательский объект к структуре, которую нам нужно использовать с остальной частью проекта.

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

Теперь мы можем совершать вызовы к нашему ядру через вариант использования, который реализует основной порт. Мы можем обращаться к базе данных через репозиторий, реализующий вторичный порт.

Давайте создадим веб-обработчик в качестве примера способа взаимодействия с ядром. Мы также могли бы внедрить GRPC или CLI, и они использовали бы тот же интерфейс, что и HTTP.

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

package handlers

import (
 "encoding/json"
 "errors"
 "fmt"
 "github.com/andybeak/hexagonal-demo/internal/core/ports"
 "github.com/golang/gddo/httputil/header"
 "github.com/gorilla/mux"
 "io"
 "log"
 "net/http"
 "strings"
)

func ProvideUserHttpHandler(
 uuc ports.UserUseCase,
) *UserHttpHandler {
 return &UserHttpHandler{
  uuc: uuc,
 }
}

type UserHttpHandler struct {
 uuc ports.UserUseCase
}

func (u *UserHttpHandler) getUserById(w http.ResponseWriter, r *http.Request) {
 ctx := r.Context()
 vars := mux.Vars(r)
 user, err := u.uuc.GetUserById(ctx, vars["id"])
 if err != nil {
  writeError(w, http.StatusInternalServerError, "Could not get user by id")
 }
 writeJson(w, user)
}

Здесь нам нужно заметить, что веб-обработчик зависит от основного порта.

Мы внедряем этот обработчик в маршрутизатор, чтобы Gorilla могла сделать его доступным для нашего веб-пользователя:

package handlers

import "github.com/gorilla/mux"

func ProvideRouter(
 userHandler *UserHttpHandler,
) *mux.Router {
 r := mux.NewRouter()
 router := r.PathPrefix("/v1").Subrouter()
 router.HandleFunc("/users/{id}", userHandler.getUserById).Methods("GET")
 return router
}

Затем мы оборачиваем это в http-сервис, который мы внедрим в наше приложение. Я не буду вставлять это сюда, потому что это не особо интересно, и вы можете увидеть это в репозитории.

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

В более крупном проекте будут способы подключения к вашим различным базам данных и кешам. В этом проекте я поместил это в пакет «помощников».

Сводка

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

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

Репозиторий доступен по адресу https://github.com/andybeak/hexagonal-demo.