Знаю, что тема уже изъезжана вдоль и поперек, но я хотел бы поделиться своим видением Open/Close Principle из всеми любимым SOLID подходу к построении архитектуры софта. Ведь дядюшка Боб херни не посоветует, все таки опыта ему не занимать, поскольку он с 70х годов в разработке и знает базу, что нам и нужно. Да, современный софт ушел далеко от того какой он был в 70-х, когда писали логические цепочек на перфокартах, делая дырки в картоне и компиляция занимала прямо пропорционально количеству этих самых карточек, где скорость выполнения считалась количеством символов в минуту. За все это время Дядюшка Боб собирал лучшие практики из которых и получились эти 5 принципов, которые помогут построить софт, который будет не так сильно с течением времени влиять на стоимость одной строки кода. (О чем он и пишет в своей книги «Чистая архитектура»).
Хочу отметить то, что есть мнение, что принципы SOLID — это про ООП и для языков, которые не следуют этой парадигме это не актуально, нет. Эти принципы построения архитектуры приложения не зависят от языка.
Если вы читали книгу «Чистая архитектуры» и дошли до Open/Close principle (SOLID) и из примера ничего не поняли, тогда вы пришли по адресу, поскольку я буду рассматривать именно этот пример. Для меня лично OCP это один из принципов, который заставляет продумывать архитектуру приложения, что очень важно.
Я не буду писать тут тесты или использовать TDD подход к написанию кода, потому, что это отдельная тема, я сделаю простой http сервер с одним эндпойнтом для получения финансового отчета в разных форматах.
Невнятное ТЗ — результат ХЗ?
Самое сложное это получить подробное детальное ТЗ, это касается разработчиков любого уровня. Зачем? Для того, что бы мы смогли выделить бизнес логику, которую нужно закрыть от изменений и дать возможность только расширять функционал.
-
Если вы джун, миддл и получили тикет в джире, где все написано, надо прочитать и вникнуть, сможете ли вы из этого ТЗ выделить нужные для вас домены, бизнес логику, респондентов? Если нет, то вам надо сформировать ряд вопросов и пойти к лиду и задать их, а еще лучше созвониться с ним и попросить в деталях рассказать, что бизнес хочет (часто бывает так, что он мог что‑то упустить).
-
Если вы сеньор то вам тем более надо это выудить от бизнеса, что бы расписать подробно что именно и как надо сделать. без доп вопросов не получится.
-
Если вы лид — ну вы в курсе да?
Итак вот мы получили внятное ТЗ, которое у нас выглядит как:
Необходимо реализовать отчет по транзакциям за определенный период. Нужно отобразить общую сумму. Для каждой транзакции нужно вывести id, date, amount, description. Отчет может быть запрошен в разных форматах (пока что для web клиента в формате html) форматы пока на уточнении.
Не буду упарываться с тем, что если есть какая то верстка, то надо еще и дизайнера подключить, опустим этот момент для нашего примера
В реальном проекте вы уже имеет работающее приложение и вам надо будет продумать как встроить данную фичу в нее, но я для примера буду делать с чистого листа.
Сделаем структуру проекта, которая будет помимо OCP, отвечать еще и чистой архитектуре и даже некоторым остальным принципам (сюрприз, при реализации принципов SOLID вам придется реализовывать их все, потому, что эти принципы тянут друг друга так, что их все придется реализовать)
├── main.go # Точка входа, сборка зависимостей (Composition Root) | ├── domain/ # Сущности. Ядро. Ни от чего не зависит. │ └── report.go | ├── usecase/ # Сценарии использования (Interactors). Зависят только от domain. │ ├── interfaces.go # Абстракции (интерфейсы) для внешних слоев. │ └── report_generator.go | └── interfaces/ # Внешний слой: адаптеры к фреймворкам, БД, UI. ├── controllers/ # Обработчики HTTP-запросов. │ └── report_controller.go ├── gateways/ # Реализации шлюзов к данным. │ └── in_memory_gateway.go └── presenters/ # Презентеры и View-модели. └── report_presenter.go
Тут для наглядности можно посмотреть на то как выглядит чистая архитектуры в разрезе

Начать надо с наших объектов, это транзакции и отчет, сделаем домены (модели) для них в /domain/report.go
package domain import "time" // Transaction - это базовая бизнес-сущность. type Transaction struct { ID uint `json:"id"` Date time.Time `json:"date"` Amount float64 `json:"amount"` Description string `json:"description"` } // ReportData - это структура, с которой работает Interactor. // Она не содержит информации о форматировании. type ReportData struct { Transactions []Transaction `json:"transactions"` Total float64 `json:"total"` }
Тут все понятно, есть модель транзакции, есть модель для репорта, которые не зависят ни от чего.
DAL — Data Access Layer
Очень частый подход для реализации доступа к данным. Данные могу хранится как в СУБД, так и в файле а могут быть получены по сети, не важно, данный слой мы будет реализовывать с оглядкой на интерфейс, которым будет пользоваться наш usecase. Итак прежде чем реализовать DAL, давайте накидаем интерфес и положем его там где мы будем его использовать (не реализовывать — это важно) создадим файл для интерфесов /usecase/interfaces.go
package usecase import ( "context" "solid-go/domain" "time" ) // FinancialDataGateway - это порт (абстракция) для получения данных. // Interactor зависит от этого интерфейса, а не от конкретной БД. type FinancialDataGateway interface { GetTransactions(ctx context.Context, start, end time.Time) ([]domain.Transaction, error) } // ReportPresenter - это порт для вывода данных. // Он получает чистые бизнес-данные и отвечает за их подготовку к отображению. type ReportPresenter interface { Present(ctx context.Context, data domain.ReportData) ([]byte, error) }
Тут содержится еще один интерфейс, это интерфейс нашей будущей реализации презентера — модуль, который будет формировать именно презентацию нашего отчета (html/json/xml/bytes/text). А теперь давайте сделаем реализацию нашего гейтвея для данных, положим его в /interfaces/gateway/in_memory_gateway.go
package gateway import ( "context" "solid-go/domain" "sync" "time" ) type InMemoryReportGateway struct { mu *sync.Mutex nextId uint transactions map[uint]domain.Transaction } func NewInMemoryReportGateway() *InMemoryReportGateway { return &InMemoryReportGateway{ mu: &sync.Mutex{}, nextId: 0, transactions: make(map[uint]domain.Transaction), } } // func (rg *InMemoryReportGateway) getNextIdLock() uint { // rg.mu.Lock() // defer rg.mu.Unlock() // return rg.getNextId() // } func (rg *InMemoryReportGateway) getNextId() uint { rg.nextId++ return rg.nextId } func (rg *InMemoryReportGateway) Init() { rg.mu.Lock() defer rg.mu.Unlock() id := rg.getNextId() rg.transactions[id] = domain.Transaction{ Date: time.Now(), Amount: 150.50, Description: "Buying the book", ID: id, } id = rg.getNextId() rg.transactions[id] = domain.Transaction{ Date: time.Now(), Amount: -30.00, Description: "Refund", ID: id, } id = rg.getNextId() rg.transactions[id] = domain.Transaction{ Date: time.Now(), Amount: 1200.00, Description: "Salary", ID: id, } } func (rg *InMemoryReportGateway) GetTransactions(_ context.Context, start, end time.Time) ([]domain.Transaction, error) { transactions := make([]domain.Transaction, 0, len(rg.transactions)) for _, tx := range rg.transactions { transactions = append(transactions, tx) } return transactions, nil }
Очень условный пример, понятно, что можно было обойтись без Мьютекса, и просто вернуть тут слайс, но я сделал так, потому, что я все таки буду использовать это далее, в этом учебном проекте, и тут будет и добавление и удаление и поиск. Еще я тут не ищу по датам, даты тут просто для примера.
Теперь, когда у нас есть интерфейсы давайте реализуем наш usecase, который и будет у нас Close (Не изменяемой частью бизнес логики). /usecase/report_generator.go
package usecase import ( "context" "solid-go/domain" "time" ) // ReportGenerator - это наш Interactor (Use Case). type ReportGenerator struct { gateway FinancialDataGateway presenter ReportPresenter } // NewReportGenerator - конструктор для Interactor'а. func NewReportGenerator(gw FinancialDataGateway, p ReportPresenter) *ReportGenerator { return &ReportGenerator{ gateway: gw, presenter: p, } } // Generate - выполняет основной сценарий использования. func (rg *ReportGenerator) Generate(ctx context.Context, start, end time.Time) ([]byte, error) { // 1. Получить данные через абстрактный шлюз transactions, err := rg.gateway.GetTransactions(ctx, start, end) if err != nil { return nil, err } // 2. Выполнить бизнес-логику (здесь - простое суммирование) var total float64 for _, t := range transactions { total += t.Amount } reportData := domain.ReportData{ Transactions: transactions, Total: total, } // 3. Передать данные в абстрактный презентер для форматирования return rg.presenter.Present(ctx, reportData) }
Именно тут заложен главный принцип Открытости/Закрытости. Мы закрываем нашу бизнес логику от изменений, но открываем возможность для расширения. Если нам надо будет добавить новый формат отображения, мы просто создадим новый модуль, который будет удовлетворять интерфейс ReportPresenter и все. Итак давайте же напишем модули, которые будут отвечать ему /interfaces/presenters/report_presenter.go
package presenters import ( "bytes" "context" "encoding/json" "fmt" "html/template" "solid-go/domain" ) // ViewModel - структура, оптимизированная для отображения. // Презентер преобразует domain.ReportData в ViewModel. type reportViewModel struct { GeneratedDate string Transactions []transactionViewModel FinalTotal string } type transactionViewModel struct { Date string Description string Amount string } // --- HTML Presenter --- type HtmlReportPresenter struct{} func NewHtmlReportPresenter() *HtmlReportPresenter { return &HtmlReportPresenter{} } func (p *HtmlReportPresenter) Present(_ context.Context, data domain.ReportData) ([]byte, error) { viewModel := p.toViewModel(data) // Здесь мы бы использовали html/template для генерации красивого отчета. // Для простоты, сгенерируем простую HTML-строку. tpl := ` <!DOCTYPE html> <html> <head><title>Finance report</title></head> <body> <h1>Report from {{.GeneratedDate}}</h1> <table border="1"> <tr><th>Date</th><th>Description</th><th>Sum</th></tr> {{range .Transactions}} <tr><td>{{.Date}}</td><td>{{.Description}}</td><td>{{.Amount}}</td></tr> {{end}} </table> <p>Total: <strong>{{.FinalTotal}}</strong></p> </body> </html>` tmpl, err := template.New("report").Parse(tpl) if err != nil { return nil, err } var buf bytes.Buffer if err := tmpl.Execute(&buf, viewModel); err != nil { return nil, err } return buf.Bytes(), nil } // --- JSON Presenter --- type JsonReportPresenter struct{} func NewJsonReportPresenter() *JsonReportPresenter { return &JsonReportPresenter{} } func (p *JsonReportPresenter) Present(_ context.Context, data domain.ReportData) ([]byte, error) { viewModel := p.toViewModel(data) return json.MarshalIndent(viewModel, "", " ") } // Общая логика для обоих презентеров func (p *HtmlReportPresenter) toViewModel(data domain.ReportData) reportViewModel { return commonToViewModel(data) } func (p *JsonReportPresenter) toViewModel(data domain.ReportData) reportViewModel { return commonToViewModel(data) } func commonToViewModel(data domain.ReportData) reportViewModel { txViewModels := make([]transactionViewModel, len(data.Transactions)) for i, tx := range data.Transactions { txViewModels[i] = transactionViewModel{ Date: tx.Date.Format("02-01-2006"), Description: tx.Description, Amount: fmt.Sprintf("%.2f", tx.Amount), } } return reportViewModel{ GeneratedDate: "Today", Transactions: txViewModels, FinalTotal: fmt.Sprintf("%.2f RUB", data.Total), } }
Тут у нас любой презентер возвращает слайс байтов, что можно сконвертировать в конроллере в нужный формат. Добавим наш контроллер /interfaces/controllers/report_controller.go
package controllers import ( "context" "net/http" "solid-go/interfaces/presenters" "solid-go/usecase" "time" ) type ReportController struct { // Зависимости контроллера - это фабрики или конкретные реализации // из внешних слоев. В данном случае - шлюз. gateway usecase.FinancialDataGateway } func NewReportController(gateway usecase.FinancialDataGateway) *ReportController { return &ReportController{gateway: gateway} } func (c *ReportController) GenerateReportHandler(w http.ResponseWriter, r *http.Request) { // 1. Выбираем нужный Presenter в зависимости от запроса. // Это ключевой момент для OCP! var presenter usecase.ReportPresenter format := r.URL.Query().Get("format") contentType := "" switch format { case "json": presenter = presenters.NewJsonReportPresenter() contentType = "application/json" case "html": fallthrough // html будет по умолчанию default: presenter = presenters.NewHtmlReportPresenter() contentType = "text/html" } // 2. Создаем Interactor, "внедряя" в него нужные зависимости. reportGenerator := usecase.NewReportGenerator(c.gateway, presenter) // 3. Запускаем Use Case. report, err := reportGenerator.Generate(context.Background(), time.Now(), time.Now()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // 4. Отдаем результат. w.Header().Set("Content-Type", contentType) w.Write(report) }
Ну и вишенка на торте это наша точка старта приложения main.go
package main import ( "log" "net/http" "solid-go/interfaces/controllers" "solid-go/interfaces/gateway" ) func main() { reportBbGateway := gateway.NewInMemoryReportGateway() reportBbGateway.Init() reportController := controllers.NewReportController(reportBbGateway) // --- Настройка веб-сервера --- http.HandleFunc("/report", reportController.GenerateReportHandler) log.Println("Сервер запущен на http://localhost:8080") log.Println("Примеры запросов:") log.Println("http://localhost:8080/report?format=html") log.Println("http://localhost:8080/report?format=json") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Не удалось запустить сервер: %v", err) } }
Надеюсь эта статья найдет своего читателя и поможет разобраться с SOLID и архитектурой.
ссылка на оригинал статьи https://habr.com/ru/articles/917048/
Добавить комментарий