Введение в DDD: когда микросервисов на Go недостаточно
Микросервисная архитектура помогает строить гибкие и масштабируемые приложения. Однако в случае бессистемного использования этого подхода вы довольно быстро столкнётесь с разочарованием и неконтролируемыми сложностями. Избежать проблем помогает Domain-Driven Design (DDD) — предметно-ориентированное проектирование. В статье расскажем о принципах его работы, а также разберём основы стратегических паттернов в Golang.
Нет компромисса между качеством и скоростью
Всегда есть соблазн реализовать проект, над которым вы работаете, самым простым способом. Действительно, можно отказаться от использования микросервисов и сэкономить время. Но только в краткосрочной перспективе.
Разберём на примере тестов любого вида — вы можете пропустить их написание вначале и сдать всё раньше. Но когда проект разрастётся, команда начнёт бояться вносить изменения. В итоге для работы понадобится сильно больше времени, чем если бы вы сразу внедрили тесты. Жертвуя качеством ради быстрого повышения производительности вначале, в долгосрочной перспективе вы будете замедляться.
То же самое и с DDD. Для его использования вам понадобится немного больше времени на старте, при этом долгосрочная экономия огромна. Но, стоит отметить, что не каждый проект достаточно сложен, чтобы использовать методы вроде DDD.
Действительно ли DDD работает?
Четыре года назад вышла книга Accelerate: The Science of Lean Software and DevOps: Building and Scaling High Performance Technology Organizations, представляющая собой описание факторов производительности команд разработчиков. Одна из причин популярности заключается в том, что она основана на научных исследованиях.
В рамках этой статьи нас интересует часть, где рассказывается, как командам быть самыми эффективными. В книге приводятся и очевидные вещи вроде знакомства с DevOps или CI/CD. Но также Accelerate говорит, что высокая производительность возможна со всеми типами систем, при условии, что системы и команды, которые их создают и поддерживают, слабо связаны между собой. Это архитектурное свойство позволяет командам легко тестировать и развёртывать отдельные компоненты или службы даже по мере роста компании и количества эксплуатируемых ею систем.
К сожалению, в реальной жизни недостаточно просто использовать архитектуру микросервисов и разбивать сервисы на более мелкие части. Если сделать это неправильно, возникнут дополнительные сложности, замедляющие работу команды. Помочь здесь может DDD.
«Микросервисы: проектирование и интеграция на GO»
Что такое DDD
Domain-Driven Design (DDD) — это подход, который нацелен на изучение предметной области предприятия в целом или каких-то отдельных бизнес-процессов. Используется в проектах, где сложность бизнес-логики достаточно велика. Его применение призвано снизить эту сложность, насколько возможно.
Если вас интересует исторический контекст, как и при каких обстоятельствах создавался DDD, рекомендуем посмотреть Tackling Complexity in the Heart of Software.
Если говорить совсем просто, то действуя согласно DDD, вы должны убедиться, что решаете проблему оптимальным способом. А затем внедрить решение так, чтобы его понял бизнес без дополнительного перевода с технического языка.
Как этого добиться?
Программирование — это война, чтобы победить, нужна стратегия
Прежде чем приступить к написанию любого кода, вы должны убедиться, что действительно решаете проблему. Это кажется очевидным, но на практике всё оказывается не так просто. Бывает так, что решение, созданное инженерами, на самом деле не решает проблему, которую запросил бизнес. И здесь помогают стратегические паттерны DDD.
В реальной жизни стратегические паттерны DDD часто игнорируют. Причина проста: разработчикам нравится писать код, а не общаться с «деловыми людьми». Такой подход имеет ряд недостатков:
недоверие со стороны бизнеса;
отсутствие знаний о том, как работает система — как со стороны бизнеса, так и со стороны инженеров;
решение неправильных задач.
Без стратегических паттернов вы получаете примерно 30% от всех преимуществ, которые может дать DDD, поэтому перейдём к их разбору в Go.
Wild Workouts
Для примера возьмём Trainer service — службу, которая отвечает за ведение расписания позволяет планировать тренировки. Первоначальная реализация была не лучшей. Несмотря на то, что логики не так много, некоторые части кода довольно беспорядочны:
func (g GrpcServer) UpdateHour(ctx context.Context, req *trainer.UpdateHourRequest) (*trainer.EmptyResponse, error) {
trainingTime, err := grpcTimestampToTime(req.Time)
if err != nil {
return nil, status.Error(codes.InvalidArgument, "unable to parse time")
}
date, err := g.db.DateModel(ctx, trainingTime)
if err != nil {
return nil, status.Error(codes.Internal, fmt.Sprintf("unable to get data model: %s", err))
}
hour, found := date.FindHourInDate(trainingTime)
if !found {
return nil, status.Error(codes.NotFound, fmt.Sprintf("%s hour not found in schedule", trainingTime))
}
if req.HasTrainingScheduled && !hour.Available {
return nil, status.Error(codes.FailedPrecondition, "hour is not available for training")
}
if req.Available && req.HasTrainingScheduled {
return nil, status.Error(codes.FailedPrecondition, "cannot set hour as available when it have training scheduled")
}
if !req.Available && !req.HasTrainingScheduled {
return nil, status.Error(codes.FailedPrecondition, "cannot set hour as unavailable when it have no training scheduled")
}
hour.Available = req.Available
if hour.HasTrainingScheduled && hour.HasTrainingScheduled == req.HasTrainingScheduled {
return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("hour HasTrainingScheduled is already %t", hour.HasTrainingScheduled))
}
hour.HasTrainingScheduled = req.HasTrainingScheduled
Возможно, сейчас это не выглядит как самый плохой код, но через какое-то время появятся новые функции, и станет намного хуже. Также здесь сложно имитировать зависимости, поэтому юнит-тесты отсутствуют.
Первое правило — отражайте бизнес-логику буквально.
При реализации домена перестаньте думать о фиктивных структурах данных или «ORM-подобных» сущностях со списком сеттеров и геттеров. Когда вы разговариваете с представителями бизнеса, они говорят: «Я планирую обучение на 13:00», а не «Я устанавливаю состояние атрибута на «обучение запланировано» на 13:00». Они вряд ли скажут: «Вы не можете установить статус атрибута в «training_scheduled»». Скорее: «Вы не можете запланировать тренировку, если время занято».
Как это прописать прямо в коде?
func (h *Hour) ScheduleTraining() error {
if !h.IsAvailable() {
return ErrHourNotAvailable
}
h.availability = TrainingScheduled
return nil
}
Также можно легко добавить юнит-тесты:
func TestHour_ScheduleTraining(t *testing.T) {
h, err := hour.NewAvailableHour(validTrainingHour())
require.NoError(t, err)
require.NoError(t, h.ScheduleTraining())
assert.True(t, h.HasTrainingScheduled())
assert.False(t, h.IsAvailable())
}
func TestHour_ScheduleTraining_with_not_available(t *testing.T) {
h := newNotAvailableHour(t)
assert.Equal(t, hour.ErrHourNotAvailable, h.ScheduleTraining())
}
Теперь, если кто-то задаст вопрос «Когда я смогу запланировать тренировку», вы быстро на него ответите.
Помимо этого полезно иметь помощников по тестам для создания объектов домена. Например: newExampleTrainingWithTime, newCanceledTraining и др.
Библиотеку github.com/google/go-cmp можно использовать для сравнения сложных структур. Это позволяет сравнивать типы доменов с частным полем, пропускать проверку некоторых полей или реализовывать пользовательские функции проверки:
func assertTrainingsEquals(t *testing.T, tr1, tr2 *training.Training) {
cmpOpts := []cmp.Option{
cmpRoundTimeOpt,
cmp.AllowUnexported(
training.UserType{},
time.Time{},
training.Training{},
),
}
assert.True(
t,
cmp.Equal(tr1, tr2, cmpOpts...),
cmp.Diff(tr1, tr2, cmpOpts...),
)
}
Также неплохо предоставить версию Must для часто используемых конструкторов, например, MustNewUser. В отличие от обычных конструкторов, они будут сигнализировать, если параметры недействительны:
func NewUser(userUUID string, userType UserType) (User, error) {
if userUUID == "" {
return User{}, errors.New("missing user UUID")
}
if userType.IsZero() {
return User{}, errors.New("missing user type")
}
return User{userUUID: userUUID, userType: userType}, nil
}
func MustNewUser(userUUID string, userType UserType) User {
u, err := NewUser(userUUID, userType)
if err != nil {
panic(err)
}
return u
}
Второе правило — всегда держите в памяти валидное состояние. Разработка новых функций происходит намного медленнее без уверенности в том, что вы правильно используете код.
Цель — провести валидацию только в одном месте (good DRY) и гарантировать, что никто не сможет изменить внутреннее состояние Hour. Единственным общедоступным API объекта должны быть методы, описывающие поведение. Также нужно поместить сущности в отдельный пакет и сделать все атрибуты приватными.
type Hour struct {
hour time.Time
availability Availability
}
// ...
func NewAvailableHour(hour time.Time) (*Hour, error) {
if err := validateTime(hour); err != nil {
return nil, err
}
return &Hour{
hour: hour,
availability: Available,
}, nil
}
Помимо этого нужно убедиться, что мы не нарушаем никаких правил внутри сущности. Плохой пример:
h := hour.NewAvailableHour("13:00")
if h.HasTrainingScheduled() {
h.SetState(hour.Available)
} else {
return errors.New("unable to cancel training")
}
Хороший пример:
func (h *Hour) CancelTraining() error {
if !h.HasTrainingScheduled() {
return ErrNoTrainingScheduled
}
h.availability = Available
return nil
}
// ...
h := hour.NewAvailableHour("13:00")
if err := h.CancelTraining(); err != nil {
return err
}
Третье правило — домен должен быть независимым от базы данных.
Кто-то говорит, что это нормально, когда клиент базы данных воздействует на домен. Но по нашему опыту, лучше избегать этого. Например, потому что типы доменов не зависят от используемого решения для базы данных — должны формироваться только по бизнес-правилам.
Чтобы не делать статью слишком длинной, представим интерфейс репозитория и предположим, что он работает:
type Repository interface {
GetOrCreateHour(ctx context.Context, time time.Time) (*Hour, error)
UpdateHour(
ctx context.Context,
hourTime time.Time,
updateFn func(h *Hour) (*Hour, error),
) error
}
Проведём небольшой рефакторинг конечных точек gRPC, чтобы предоставить более «behavior-oriented» API, чем CRUD. Он лучше отражает новую характеристику домена. Плюс, гораздо проще поддерживать несколько небольших методов, чем один «божественный» метод, позволяющий нам обновлять всё.
--- a/api/protobuf/trainer.proto
+++ b/api/protobuf/trainer.proto
@@ -6,7 +6,9 @@ import "google/protobuf/timestamp.proto";
service TrainerService {
rpc IsHourAvailable(IsHourAvailableRequest) returns (IsHourAvailableResponse) {}
- rpc UpdateHour(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc ScheduleTraining(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc CancelTraining(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc MakeHourAvailable(UpdateHourRequest) returns (EmptyResponse) {}
}
message IsHourAvailableRequest {
@@ -19,9 +21,6 @@ message IsHourAvailableResponse {
message UpdateHourRequest {
google.protobuf.Timestamp time = 1;
-
- bool has_training_scheduled = 2;
- bool available = 3;
}
message EmptyResponse {}
Реализация стала значительно проще и понятнее. Здесь нет логики — просто оркестровка. Обработчик gRPC теперь имеет 18 строк и не имеет доменной логики:
func (g GrpcServer) MakeHourAvailable(ctx context.Context, request *trainer.UpdateHourRequest) (*trainer.EmptyResponse, error) {
trainingTime, err := protoTimestampToTime(request.Time)
if err != nil {
return nil, status.Error(codes.InvalidArgument, "unable to parse time")
}
if err := g.hourRepository.UpdateHour(ctx, trainingTime, func(h *hour.Hour) (*hour.Hour, error) {
if err := h.MakeAvailable(); err != nil {
return nil, err
}
return h, nil
}); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &trainer.EmptyResponse{}, nil
}
Это всё на сегодня
Это только начало, но некоторые упрощения в коде уже видны.
Текущая реализация модели тоже не идеальна, что хорошо. Вы никогда не реализуете идеальную модель с самого начала. Лучше быть готовым легко менять её, чем тратить время на то, чтобы сделать идеальной. После того, как мы добавили тесты и отделили модель от остального приложения, мы можем менять её без каких-либо опасений.
«Микросервисы: проектирование и интеграция на GO»
Материал основан на статье «Introduction to DDD Lite: When microservices in Go are not enough»