Шаблоны проектирования GO: введение в SOLID

Шаблоны проектирования GO: введение в SOLID

16 февраля 2023 г.


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

SOLID означает: Единая ответственность, Открыто-закрыто, Замена Лискова, Разделение интерфейса и Инверсия зависимостей. В совокупности эти принципы составляют основу для создания удобного в сопровождении и масштабируемого программного обеспечения и считаются лучшими практиками разработки программного обеспечения.

В этой записи блога я подробно рассмотрю каждый из принципов SOLID, изучу их значение и то, как их можно применить в GO. Понимание шаблона проектирования SOLID является важной частью написания высококачественного кода. Итак, давайте погрузимся в мир проектирования SOLID и узнаем, как эти принципы могут помочь нам создавать лучшее программное обеспечение.


S — Принцип единой ответственности (SRP)

Этот принцип гласит, что у структуры должна быть только одна причина для изменения, а это означает, что у структуры должна быть только одна обязанность. Это помогает поддерживать чистоту и удобство кода, поскольку изменения в структуре нужно вносить только в одном месте.

Допустим, у меня есть структура Employee, которая отслеживает имя, зарплату и адрес сотрудника:

 

type Employee struct {
        Name    string
        Salary  float64
        Address string
    }
    

Согласно SRP каждая структура должна иметь только одну ответственность, поэтому в этом случае было бы лучше разделить обязанности структуры Employee на две отдельные структуры: EmployeeInfo и Адрес сотрудника.

 

type EmployeeInfo struct {
        Name   string
        Salary float64
    }
    type EmployeeAddress struct {
        Address string
    }
    

Теперь у нас могут быть отдельные функции, отвечающие за различные обязанности каждой структуры:

 

func (e EmployeeInfo) GetSalary() float64 {
        return e.Salary
    }
    func (e EmployeeAddress) GetAddress() string {
        return e.Address
    }
    

Следуя SRP, я сделал код более удобным для сопровождения и более понятным, поскольку теперь у каждой структуры есть четкая и конкретная ответственность. Если мне нужно внести изменения в расчет заработной платы или обработку адресов, я точно знаю, где искать, и мне не нужно пробираться через много несвязанного кода.


O — принцип открытия-закрытия (OCP)

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

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

 

package main
    import "fmt"
    type PaymentMethod interface {
      Pay()
    }
    type Payment struct{}
    func (p Payment) Process(pm PaymentMethod) {
      pm.Pay()
    }
    type CreditCard struct {
      amount float64
    }
    func (cc CreditCard) Pay() {
      fmt.Printf("Paid %.2f using CreditCard", cc.amount)
    }
    func main() {
      p := Payment{}
      cc := CreditCard{12.23}
      p.Process(cc)
    }
    

Согласно OCP, моя структура Payment открыта для расширения и закрыта для модификации. Поскольку я использую интерфейс PaymentMethod, мне не нужно редактировать поведение Payment при добавлении новых способов оплаты. Добавление чего-то вроде PayPal будет выглядеть так:

 

type PayPal struct {
      amount float64
    }
    func (pp PayPal) Pay() {
      fmt.Printf("Paid %.2f using PayPal", pp.amount)
    }
    // then in main()
    pp := PayPal{22.33}
    p.Process(pp)
    

L – принцип подстановки Лискова (LSP)

Давайте рассмотрим структуру Animal:

type Animal struct {
      Name string
    }
    func (a Animal) MakeSound() {
      fmt.Println("Animal sound")
    }
    

Теперь предположим, что мы хотим создать новую структуру Bird, представляющую определенный тип животных:

 

type Bird struct {
      Animal
    }
    func (b Bird) MakeSound() {
      fmt.Println("Chirp chirp")
    }
    

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

 

type AnimalBehavior interface {
      MakeSound()
    }
    // MakeSound represent a program that works with animals and is expected
    // to work with base class (Animal) or any subclass (Bird in this case)
    func MakeSound(ab AnimalBehavior) {
      ab.MakeSound()
    }
    a := Animal{}
    b := Bird{}
    MakeSound(a)
    MakeSound(b)
    

Это демонстрирует наследование в Go, а также принцип подстановки Лискова, поскольку объекты подтипа Bird могут использоваться везде, где ожидаются объекты базового типа Animal, не влияя на корректность программы.


I — Принцип разделения интерфейсов (ISP)

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


D – принцип инверсии зависимостей (DIP)

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

Предположим, что у нас есть структура Worker, которая представляет рабочего в компании, и структура Supervisor, которая представляет руководителя:

 

type Worker struct {
      ID int
      Name string
    }
    func (w Worker) GetID() int {
      return w.ID
    }
    func (w Worker) GetName() string {
      return w.Name
    }
    type Supervisor struct {
      ID int
      Name string
    }
    func (s Supervisor) GetID() int {
      return s.ID
    }
    func (s Supervisor) GetName() string {
      return s.Name
    }
    

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

 

type Department struct {
      Workers []Worker
      Supervisors []Supervisor
    }
    

Согласно принципу инверсии зависимостей, модули высокого уровня не должны зависеть от модулей низкого уровня. Вместо этого оба должны зависеть от абстракций. Чтобы исправить мой пример с анти-шаблоном, я могу создать интерфейс Employee, который представляет и работника, и руководителя:

 

type Employee interface {
      GetID() int
      GetName() string
    }
    

Теперь я могу обновить структуру Department, чтобы она больше не зависела от низкоуровневых модулей:

 

type Department struct {
      Employees []Employee
    }
    

И для полного рабочего примера:

 

package main
    import "fmt"
    type Worker struct {
      ID   int
      Name string
    }
    func (w Worker) GetID() int {
      return w.ID
    }
    func (w Worker) GetName() string {
      return w.Name
    }
    type Supervisor struct {
      ID   int
      Name string
    }
    func (s Supervisor) GetID() int {
      return s.ID
    }
    func (s Supervisor) GetName() string {
      return s.Name
    }
    type Employee interface {
      GetID() int
      GetName() string
    }
    type Department struct {
      Employees []Employee
    }
    func (d *Department) AddEmployee(e Employee) {
      d.Employees = append(d.Employees, e)
    }
    func (d *Department) GetEmployeeNames() (res []string) {
      for _, e := range d.Employees {
        res = append(res, e.GetName())
      }
      return
    }
    func (d *Department) GetEmployee(id int) Employee {
      for _, e := range d.Employees {
        if e.GetID() == id {
          return e
        }
      }
      return nil
    }
    func main() {
      dep := &Department{}
      dep.AddEmployee(Worker{ID: 1, Name: "John"})
      dep.AddEmployee(Supervisor{ID: 2, Name: "Jane"})
      fmt.Println(dep.GetEmployeeNames())
      e := dep.GetEmployee(1)
      switch v := e.(type) {
      case Worker:
        fmt.Printf("found worker %+vn", v)
      case Supervisor:
        fmt.Printf("found supervisor %+vn", v)
      default:
        fmt.Printf("could not find an employee by id: %dn", 1)
      }
    }
    

Это демонстрирует принцип инверсии зависимостей, поскольку структура Department зависит от абстракции (интерфейса Employee), а не от конкретной реализации (Worker или структуру Supervisor). Это делает код более гибким и простым в обслуживании, поскольку изменения в реализации рабочих и супервайзеров не повлияют на структуру Department.


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

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

Поэтому найдите время, чтобы понять принципы SOLID и внедрить их в свой следующий проект. Ваше будущее скажет вам спасибо!


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE