OCP из SOLID

от автора

Знаю, что тема уже изъезжана вдоль и поперек, но я хотел бы поделиться своим видением 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *