"Что, если есть способ менее субъективно говорить о свойствах хорошего и плохого кода?" - Дэйв Чейни

Дэйв Чейни в своем потрясающем посте SOLID Go Design предложил, как принцип SOLID помогает нам определить хорошо разработанную программу Go в несубъективной манере.

SOLID означает:

  1. S: Принцип единственной ответственности
  2. O: Принцип открытия / закрытия
  3. L: Принцип подстановки Лискова
  4. I: Принцип разделения интерфейса
  5. D: Принцип инверсии зависимостей

Эти принципы были представлены Робертом К. Мартином в его статье Принципы проектирования и шаблоны проектирования.

По словам Роберта К. Мартина, симптомы гниющего дизайна или плохого кода:

  1. Жесткость

Код, который будет сложно изменить, даже если изменение небольшое

  1. Хрупкость

Код для взлома всякий раз, когда в систему вводится новое изменение

  1. Неподвижность

Код нельзя использовать повторно

  1. Вязкость

Взламывать, а не искать решение, сохраняющее дизайн, когда дело доходит до изменений

В этой статье я вернусь к этим принципам с некоторыми примерами и диаграммами из перспектив Голанга.

Принцип единой ответственности

«Делай одно и делай это хорошо» - Макилрой (философия Unix)

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

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

Возьмем, к примеру, Голанг. Допустим, у нас есть модуль Command в системе, управляемой командами. Модуль Command декодирует, проверяет и, наконец, выполняет входящие команды.

type Command struct {
   commandType string 
   args []string
}
func (c Command) Decode(data []byte) {
   // decodes and initialise
}
func (c Command) ValidateCommandType() bool {
   // validates command type
}
func (c Command) ValidateArgs() bool {
   // validate provided args as if input
}
func (c Command) Execute() {
   // Executes seperate types of commands 
}

В этом случае изменения в способе декодирования и проверки Command и выполнения команды напрямую повлияют на модульCommand. Следовательно, модуль выполняет несколько функций и тесно связан. В соответствии с принципом единой ответственности Decode() и Validate() являются отдельной проблемой, чем Execute(), и должны обрабатываться в отдельных модулях.

Мы можем представить модуль CommandFactory, который анализирует, проверяет и инициализирует команду, а модуль CommandExecutor выполняет команду. Теперь CommandFactory и CommandExecutor слабо связаны через модуль Command. Также обратите внимание, как мы разделили проверку типа команды и ввод в соответствующий модуль.

type Command struct {
    commandType string 
    args []string
}
type CommandFactory struct {
    ...
}
// Create decode and validate the command
func (cf CommandFactory) Create(data []byte) (*Command, error) {
    // decode command
    command, err := cf.Decode(data)
    if err != nil {
        return nil, err
    }
    // validate type
    switch cf.Type { 
       case Foo:
       case Bar:
       default:
          return nil, InvalidCommandType    
    }
    return command, nil
}
type CommandExecutor struct {
}
// Execute executes the command 
func (ce CommandExecutor) Execute(command *Command) ([]byte, error) {
   // validate input and execute 
   switch command.Type {
   case Foo: 
       if len(args) == 0 || len(args[0]) == 0 {
           return nil, InvalidInput
       }
       return ExecuteFoo(command)
   case Bar: // Bar doesn't take any input
       return ExecuteBar(command)
   }
}

Принцип открытия / закрытия

«Модуль должен быть открыт для расширений, но закрыт для модификации» - Роберт К. Мартин.

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

Предположим, у нас есть абстрактный класс S, который предоставляет общий метод F() для производных типов A и B. Класс S будет считаться закрытым для расширения, если методу F() необходимо знать о существовании производных классов. Это означает, что добавление нового производного класса, скажем, C, потребует F() изменения, что сделает F() открытым для модификации.

Одно из решений - заставить F() работать с определенным интерфейсом, а не обрабатывать подтипы. Скажем, интерфейс I определяет необходимый абстрактный метод и должен быть реализован подтипами _30 _, _ 31_ и C. Интерфейс I может иметь множество подтипов, поэтому он открыт для расширения. И F() реализован отдельно для работы с интерфейсом I, поэтому он закрыт для модификации.

В Голанге нет понятия обобщения. Возможность повторного использования доступна как форма встраивания. Хотя похожую картину можно было увидеть на практике. Возьмем, к примеру, CommandExecutor, который отвечает за выполнение команд. Методы Execute() и ValidateInput() должны обрабатывать каждую команду отдельно. Поэтому каждый раз, когда добавляется новая команда, Execute() реализация должна меняться.

Здесь мы можем использовать Command интерфейс с Execute() и ValidateInput() методами.

type Command interface {
     Execute() ([]byte, error)
     ValidateInput() bool
}
type CommandExecutor struct {
}
func (c CommandExecutor) Execute(command Command) {
     if command.ValidateInput() {
          command.Execute()
     }
}
type FooCommand struct {
     args []string // need args
}
func (c FooCommand) ValidateInput() {
    // validate args 
    if len(args) >= 1 && len(args[0]) > 0 {
       return true
    }
    return false
}
func (c FooCommand) Execute() ([]byte, error) {
    ...
}
type BarCommand struct {
}
func (c BarCommand) ValidateInput() {
    // does nothing 
    return false
}
func (c BarCommand) Execute() ([]byte, error) {
    ...
}

Принцип подстановки Лискова

«Производные методы не должны ожидать большего и обеспечивать не меньше» - Роберт К. Мартин.

В объектно-ориентированном языке, основанном на классах, концепция принципа подстановки Лискова заключается в том, что пользователь базового класса должен иметь возможность правильно работать со всеми производными классами.

Это означает, что если клиент C использует класс A. И B - это класс, производный от класса A. Тогда функции клиента C, которые зависят от методов класса A, должны работать так же, как и с тем же методом класса B. Класс B не должен предоставлять никакого специального метода для клиента C, а также он не должен оставлять какой-либо метод нереализованным. Но на практике часто случается, что мы попадаем в ситуацию, когда клиенту необходимо обрабатывать базовый класс и подкласс отдельно.

Принцип подстановки Лискова предполагает, что клиент и производные классы должны взаимодействовать через Контракт, который определяет намерение клиента.

Функциональные возможности клиента C, которые зависят от методов класса A, должны быть заменены абстрактным базовым классом T, где A и B являются конкретными подтипами. Класс T становится Контрактом, а клиент C использует методы Контракта.

Как мы обсуждали ранее, в Golang нет наследования на основе классов. Вместо этого Golang предлагает более мощный подход к полиморфизму через интерфейсы и встраивание структур. В отличие от языка, основанного на классах, полиморфизм Go предполагает создание множества различных типов данных, удовлетворяющих общему интерфейсу.

В приведенном выше примере клиент C должен использовать интерфейс T (Контракт), чтобы можно было передать несколько конкретных типов A и B. Хорошо то, что не нужно знать обо всех контрактах во время определения типа. Как и в Go, интерфейсы удовлетворяются неявно, а неявно.

Дэйв Чейни в своем блоге SOLID Go Design упомянул:

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

Проектирование простых интерфейсов было основой экосистемы Golang. Пример такого интерфейса включает

Io.Reader

> "ошибка"

Context.Context

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

В нашем предыдущем примере мы предоставили интерфейс theCommand. Что довольно просто, но достаточно ли это хорошо?

BarCommand ничего не вводит. По той же причине ValidateInput() всегда возвращает False. Теперь клиент CommandExecutor выйдет из строя, поскольку ожидает, что ValidateInput() будет работать. Здесь BarCommand дает меньше, чем ожидалось.

В качестве альтернативы мы можем разделить интерфейс на Command и CommandWithInput как

type Command interface {
     Execute() ([]byte, error)
}
type CommandWithInput interface {
     Command
     ValidateInput() bool
}

Это подводит нас к следующему принципу

Принцип разделения интерфейса

«Многие клиентские интерфейсы лучше, чем один интерфейс общего назначения» - Роберт К. Мартин.

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

Скажем, клиент C1 использует метод F1, C2 использует метод F2. Интерфейс I обеспечивает F1и F2. класс A реализует интерфейс I. Проблемы с обобщенным интерфейсом заключаются в следующем:

  1. Изменения в методах клиента C1 могут вызвать изменения в методе C2
  2. Новый класс B реализует интерфейс I, но B используется только клиентом C2. Что продвигает нереализованные методы в B.

Принцип разделения интерфейса предполагает разделение интерфейса I на IC1 и IC2, так что IC1 отвечает за клиента C1, а IC2 отвечает за клиента C2.

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

type I1 interface { // consumed by C1
    M1()
    M2()
    M3()       
}
type I2 interface { // consumed by C2 and C3
    M3()       
    M4()
}

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

Роберт С. Мартин в своей статье Принципы дизайна и шаблоны дизайна упомянул:

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

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

type I1 interface { // consumed by C1
    M1()
    M2()
    M3()       // also defined in I2
}
type I2 interface { // consumed by C2 and C3
    M3()       // here M3 not in a separate interface as no client
               // use an interface with only M3()
    M4()
}
type I3 interface { // consumed by C4
    M5()       // similarly M5() only used along with I1 and I2
               // thus not needed to have it in a separate interface
    I1
    I2
}

В нашем предыдущем примере мы разделили интерфейс Command на два интерфейса:

type Command interface {
     Execute() ([]byte, error)
}
type CommandWithInput interface {
     Command
     ValidateInput() bool
}

Хотя у нас есть только один клиент CommandExecutor, который его потребляет. Поэтому разбивать его на две части - не лучшая идея. В качестве альтернативы мы могли бы добавить метод NeedInput(), который возвращает либо истину, либо ложь. Таким образом мы также завершаем Контракт.

type Command interface {
     Execute() ([]byte, error)
     HasInput() bool
     ValidateInput() bool
}

и измените CommandExecutor на

func (c CommandExecutor) Execute(command Command) {
     if !command.HasInput() || command.ValidateInput() {
          command.Execute()
     }
}

Принцип инверсии зависимостей

«Положитесь на абстракции. Не полагайтесь на конкременты »- Роберт К. Мартин.

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

Скажем, класс A зависит от класса B и используйте его напрямую. Класс B - это конкретный тип, что означает, что любые изменения в классе B будут напрямую влиять на класс A. Аналогичным образом, изменения в классе A могут потребовать изменения claas B. Если класс B используется более чем одним классом, он нарушит и другие зависимости.

Принцип инверсии зависимостей предлагает предоставить интерфейс I, который предоставляет методы, необходимые для класса A. И класс B должен реализовывать интерфейс, чтобы его мог использовать класс A. Таким образом, может существовать одна или несколько реализаций интерфейса I. А класс A может использоваться другими классами с другими интерфейсами.

В нашем примере мы уже сделали что-то похожее на интерфейс theCommand. Давайте посмотрим, как мы можем расширить его. Наш CommandFactory берет закодированную строку, проверяет ее и создает конкретную Command. Предположим, что закодированный ввод представляет собой строку JSON, которую необходимо сначала декодировать и проверить. На основании принципа единой ответственности мы можем использовать отдельный JsonDecoder для обработки Decode().

type CommandFactory struct {
     decoder JsonDecoder // decoder decodes the command
}
// Create decode and validate the command
func (cf CommandFactory) Create(encoded String) (Command, error) {
    // decode command
    command, err := cf.decoder.Decode(data)
    if err != nil {
        return nil, err
    }
    ...
}

Хотя в будущем стратегия кодирования может измениться, исправление Encoder делает ее устойчивой к изменениям. Вместо этого мы можем изменить CommandFactory на использование интерфейса CommandDecoder.

type CommandDecoder interface {
        func Decode(data []byte) (Command, err)
}
type CommandFactory struct {
     Decoder CommandDecoder
}
type JsonDecoder struct {}
func (jcd JsonDecoder) Decode(data string) (Command, err){
  // json command decode logic
}

Мы можем передать право CommandDecoder во время инициализации фабрики. Таким образом, фабрика зависит от абстракции, а не от конкреции.

// Initialize CommandFactory with required CommandDecoder 
factory = CommandFactory{Decoder: JsonCommandDecoder{}}

Принцип внедрения зависимостей (БОНУС)

«Инъекция! = Инверсия»

// ДЕЛАТЬ

Синглтон - это антипаттерн (БОНУС)

// ДЕЛАТЬ

Использованная литература:

Принципы дизайна и шаблоны дизайна - Роберт К. Мартин

SOLID Go Design - Дэйв Чейни

Твердые принципы: объяснение и примеры - Саймон Л.Х.

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

И если вы найдете эту рецензию полезной, пожалуйста, пожалейте несколько аплодисментов 👏 😃