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

При проектировании нового программного решения была поставлена задача выбрать язык и фреймворк. По результатам проведенного исследования был выбран язык 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.

Комментарии (6)


  1. wmns
    28.12.2022 04:59
    -1

    Правило Go никогда не упоминать о Бойцовском клубе не использовать фреймворки. Если не секрет, на чем писали до Go?


    1. MrDEX123
      28.12.2022 11:46

      Разрешите поинтересоваться в причинах данной практики?

      Ps. сам в го не разбираюсь, спрашиваю для общего развития


      1. 12rbah
        29.12.2022 11:49

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


      1. GreyCheshire
        30.12.2022 13:50

        Добавление слишком большой сложности ради небольшой экономии времени.

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

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

        injector.Bind - прям Guice веет из Java


      1. F123456
        30.12.2022 13:50

        Иначе это будет Go++, а этого допустить никак нельзя.


  1. midia21
    30.12.2022 13:51

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

    A straightforward translation of a C++ or Java program into Go is unlikely to produce a satisfactory result—Java programs are written in Java, not Go. On the other hand, thinking about the problem from a Go perspective could produce a successful but quite different program. In other words, to write Go well, it's important to understand its properties and idioms.

    Чуть дальше сказано, что можно руководствоваться исходным кодом Go (который на данный момент уже написан на самом себе) как примером правильного использования языка:

    The Go package sources are intended to serve not only as the core library but also as examples of how to use the language.

    В исходом коде - опять же не используется ddd (хотя кодовая база не маленькая - 70 тыщ строк где-то). Если приглядеться - в нем не найти общих названий папок, например: domain, controllers, handlers, - объединяющих несколько сущностей по признаку контекста использования. Также там не используется DI контейнеры - все построено на интерфейсах (причем интерфейсы объявлены на принимающей стороне, а не на реализующей). И в целом архитектура примитивна в хорошем смысле. Тем не менее, судя по слухам, кодовая база Go очень гибко построена, язык кажется не встречает архитектурных препятствий в дальнейшем развитии. Мне кажется такой минимализм - часть философии Go, и отказ от излишних наворотов в виде фрэймворка, di контейнеров, orm, ddd и так далее, в пользу принятия "go way" может оказать большую эффективность на процесс разработки.

    Я не утверждаю, что прав. Возможно и вероятно, мое мнение ограничено недостатком опыта и в более энтерпрайзных проектах такой подход - выигрышный. Хотелось бы услышать тех, кто применял и данную архитектуру и "go way". Какой подход более эффективен и в каких случая? Заранее спасибо :)