Pull to refresh
72.45

Flamingo, Go ahead! или Как реализовать DDD в Go?

Reading time7 min
Views5K

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

При проектировании нового программного решения была поставлена задача выбрать язык и фреймворк. По результатам проведенного исследования был выбран язык Go, как обеспечивающий высокую производительность вместе со скоростью разработки, а также фреймворк Flamingo для реализации принципов Domain Driven Design. Всем, кому интересно узнать, что же за птица такая Flamingo, приглашаю под кат.

Концепция предметно-ориентированного проектирования, она же DDD (Domain Driven Design), описанная Эриком Эвансом, активно используется при построении информационных систем для предприятий. Не стоит пересказывать основные принципы DDD, благо, помимо книги самого Эванса, они описаны в большом количестве статей. Нам важно другое. Эти принципы гораздо проще реализовать в своей информационной системе, если они поддерживаются фреймворком.

Для .Net, например, есть ASP.NET Boilerplate, полностью реализующая все компоненты DDD – Entity, Value, Repository, Domain Service, Unit of Work и еще много всего. Но мы для одной из своих внутренних информационных систем используем Go. 

Мы решили использовать фреймворк Flamingo, распространяемый под лицензией MIT. Он разработан немецкой компанией AOE GmbH в 2018 году и к настоящему моменту “дорос” до версии 3.4 и до 331 звезды на Github. Flamingo используется в информационных системах аэропортов Окланда, Франкфурта и Хитроу, а также в T-Mobile.

К сожалению, DDD в Flamingo реализуется не полностью. В частности, для работы с репозиториями придется использовать механизмы ORM (например, GORM).

Фреймворк состоит из двух частей – Flamingo Core и Flamingo Commerce. Core – собственно ядро системы, включающее в себя поддержку модулей, inject, портов с адаптерами и всего остального. Commerce же, как следует из названия, предназначен для создания web-приложения для электронной коммерции и представляет из себя набор готовых модулей для реализации списка товаров, корзины покупок, цен и прочих элементов для выполнения бизнес-процессов и создания пользовательского интерфейса для электронного магазина (как витрины, так и админки).

Для своей информационной системы мы использовали только Flamingo Core, поэтому рассмотрение Flamingo Commerce выходит за рамки данной статьи. Но модули Commerce могут быть полезны, чтобы «подглядеть» готовую реализацию и не изобретать велосипед. Итак, давайте разберемся, что нам предлагает Flamingo Core.

Модульная архитектура

Модуль – это реализация конкретной бизнес-логики вместе с необходимыми данными (контекстом). Фактически модуль – это поддомен в терминах DDD. 

Модули должны быть максимально независимыми друг от друга, но могут обмениваться данными. Так, модуль Cart из Flamingo Commerce используется для наполнения корзины товаров, а модуль Checkout выполняет оформление заказа этих товаров (при этом используя данные о покупателе из модуля Customer).

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

Каждый модуль имеет следующую структуру:

С помощью функции Depend модуль может определять зависимости от других модулей.

Порты и адаптеры

Это элементы реализации во фреймворке концепции чистой архитектуры (clean architecture). Согласно этой концепции программное обеспечение состоит из нескольких уровней.

В центре, на доменном уровне, находится доменная модель, реализованная на едином доменном языке (Ubiquitous Domain Language). Здесь находятся объекты сущностей (entities), для которых описаны поля с данными и базовые методы, изменяющие значения этих самых сущностей (например, метод Discount уменьшает значение поля «цена» на 25%).

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

Уровень интерфейсов определяет универсальные адаптеры, позволяющие взаимодействовать с внешними сервисами, такими как базы данных, очереди событий, веб-сервисы, объекты преобразования данных (DTO) и так далее. Важно, что в данным случае детали реализации скрыты, и один и тот же порт будет одинаково хорошо работать, например, как с реляционной СУБД, так и с NoSQL. Фактически адаптеры в данном контексте являются аналогом интерфейса.

И наконец, на уровне инфраструктуры реализованы порты, обеспечивающие конкретную реализацию (или несколько различных реализаций) адаптеров. Например, в зависимости от конкретной инфраструктуры, можно использовать RabbitMQ или Kafka. Также в порты можно вынести расчет налогов для конкретного государства, в этом случае разработчику будет достаточно подключить порт, вычисляющий налоги, в соответствии с определенным законодательством.

Порты бывают первичные и вторичные.

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

Вторичные порты (порты данных) - интерфейсы для вторичных адаптеров к которым обращается само приложение. Примером вторичного порта является интерфейс для хранения объектов.

Dependency Injection

Для того, чтобы подключать различные порты к адаптерам, используется механизм внедрения зависимостей (DI). Разработчики Flamingo не стали мелочиться и использовать готовую библиотеку, а написали свою. Называется Dingo.

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

type BillingService struct {
	processor CreditCardProcessor // это адаптер
	transactionLog TransactionLog // и это адаптер
}
 
func (billingservice *BillingService) ChargeOrder(order PizzaOrder, creditCard CreditCard) Receipt {
	// ...
}

А теперь используем функцию Inject, чтобы задать конкретные порты для адаптеров:

func (billingservice *BillingService) Inject(processor CreditCardProcessor, transactionLog TransactionLog) {
	billingservice.processor = processor
	billingservice.transactionLog = transactionLog
}

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

type BillingModule struct {}
 
func (module *BillingModule) Configure(injector *dingo.Injector) {
	// Сообщает Dingo, что всякий раз, когда он видит зависимость от TransactionLog,
	// он должен реализовывать зависимости с помощью DatabaseTransactionLog.
	injector.Bind(new(TransactionLog)).To(DatabaseTransactionLog{})
 
	// То же самое происходит с адаптером и портом, отвечающими за оплату
	// по кредитным картам
	injector.Bind(new(CreditCardProcessor)).To(PaypalCreditCardProcessor{})
}

Но это еще не все. Дополнительно можно определять, будет ли привязка множественной или единичной. То есть, будет ли при каждом внедрении зависимости создаваться отдельный объект, или будет использоваться один и тот же. Для этого используется конструкция Singleton.

injector.Bind(new(Something)).In(dingo.Singleton).To(MyType{})

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

func MyTypeProvider(se SomethingElse) *MyType {
	return &MyType{
    	Special: se.DoSomething(),
	}
}
 
injector.Bind(new(Something)).ToProvider(MyTypeProvider)

Поддержка событий

Использование событий (events) позволяет организовать удобный обмен данными между различными модулями приложения и обеспечить своевременную реакцию на изменения.

Для того, чтобы передать событие, определяем EventRouter и вызываем функцию Dispatch, указывая в качестве аргумента информацию о событии:

eventRouter flamingo.EventRouter
eventRouter.Dispatch(ctx, &MyEvent{Data: "Hello"})

Для получения информации о событии, надо определить функцию Notify, которая будет вызываться при получении события:

type (
	EventSubscriber struct{}
)
 
func (subscriber *EventSubscriber) Notify(ctx context.Context, event flamingo.Event) {
	if e, ok := event.(*MyEvent); ok {
    	subscriber.OnMyEvent(e)
	}
}

Осталось подписаться на конкретные события. Для этого используется уже знакомая нам функция Configure модуля:

func (m *MyModule) Configure(injector *dingo.Injector) {
	flamingo.BindEventSubscriber(injector).To(new(EventSubscriber))
}

Поддержка GraphQL API

Помимо широко используемого REST API (реализованного в модуле Web), Flamingo из коробки предоставляет реализацию более современного и удобного (вопрос дискуссионный) GraphQL API. Можете сами оценить насколько это просто.

import (
	"flamingo.me/dingo"
	"flamingo.me/graphql"
	"flamingo.me/graphql/example/todo/domain"
	"flamingo.me/graphql/example/user"
	"github.com/99designs/gqlgen/codegen/config"
)
 
// Создаем отдельный сервис для graphql
type service struct{}
 
// Теперь определяем схему запросов graphql
func (*service) Schema() []byte {
	// language=graphql
	return []byte(`
type Todo {
	id: ID!
	task: String!
	done: Boolean!
}
extend type User {
	todos: [Todo]
}
extend type Mutation {
	TodoAdd(user: ID!, task: String!): Todo
	TodoDone(todo: ID!, done: Boolean!): Todo
}
`)
}
 
// Опишем связи между типами graphql и go
func (*service) Types(types *graphql.Types) {
	types.Map("Todo", domain.Todo{})
	config.Resolve("User", "todos", UserResolver{}, "Todos")
	config.Resolve("Mutation", "TodoAdd", MutationResolver{}, "TodoAdd")
	config.Resolve("Mutation", "TodoDone", MutationResolver{}, "TodoDone")
}
 
// Создаем модуль todotype Module struct{}
 
// Регистрируем сервис graphql
func (Module) Configure(injector *dingo.Injector) {
	injector.BindMulti(new(graphql.Service)).To(new(service))
}
 
// Описываем зависимость модуля todo от модуля graphql
func (*Module) Depends() []dingo.Module {
	return []dingo.Module{
    	new(graphql.Module),
	}
}
import (
	"flamingo.me/dingo"
	"flamingo.me/graphql"
	"flamingo.me/graphql/example/todo/domain"
	"flamingo.me/graphql/example/user"
	"github.com/99designs/gqlgen/codegen/config"
)
 
// Создаем отдельный сервис для graphql
type service struct{}
 
// Теперь определяем схему запросов graphql
func (*service) Schema() []byte {
	// language=graphql
	return []byte(`
type Todo {
	id: ID!
	task: String!
	done: Boolean!
}
extend type User {
	todos: [Todo]
}
extend type Mutation {
	TodoAdd(user: ID!, task: String!): Todo
	TodoDone(todo: ID!, done: Boolean!): Todo
}
`)
}
 
// Опишем связи между типами graphql и go
func (*service) Types(types *graphql.Types) {
	types.Map("Todo", domain.Todo{})
	config.Resolve("User", "todos", UserResolver{}, "Todos")
	config.Resolve("Mutation", "TodoAdd", MutationResolver{}, "TodoAdd")
	config.Resolve("Mutation", "TodoDone", MutationResolver{}, "TodoDone")
}
 
// Создаем модуль todotype Module struct{}
 
// Регистрируем сервис graphql
func (Module) Configure(injector *dingo.Injector) {
	injector.BindMulti(new(graphql.Service)).To(new(service))
}
 
// Описываем зависимость модуля todo от модуля graphql
func (*Module) Depends() []dingo.Module {
	return []dingo.Module{
    	new(graphql.Module),
	}
}

За рамками статьи остался целый ряд возможностей Flamingo, например Pug Template, Form Package и Security. О них мы поговорим в следующих статьях данного цикла, а заодно и создадим первое приложение на Flamingo с использованием концепции DDD.

Tags:
Hubs:
Total votes 5: ↑4 and ↓1+3
Comments11

Articles

Information

Website
aktiv-company.ru
Registered
Founded
1994
Employees
101–200 employees
Location
Россия