Принцип SOLID в языке Go

Приветствую вас, хабровчане, решил поделиться с сообществом переводом довольно часто (по личным наблюдениям) упоминаемого поста SOLID Go Design из блога Dave Cheney, который выполнял для собственных нужд, но кто-то говорил, что нужно делиться. Возможно для кого-то это окажется полезным.

SOLID дизайн Go

Этот пост на основе текста из основного доклада GolangUK прошедшего 18-ого Августа 2016.
Запись выступления доступна в YouTube.

Как много программистов на Go в мире?

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

Рецензирование кода

Кто здесь проводит рецензирование кода, как часть своей работы? (большая часть аудитории поднимает свои руки, что обнадеживает). Хорошо, почему вы делаете рецензирование кода? (кто то выкрикивает «чтобы сделать код лучше»)

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

Сейчас это нормально сказать «этот код ужасен» или «ого, этот код прекрасен», точно так же, как если бы вы сказали «эта живопись прекрасна» или «эта комната прекрасна», но это субъективные понятия, а я ищу объективные пути, чтобы говорить о свойствах хорошего или плохого кода.

Плохой код

Какими могут быть свойства плохого кода, которые вы можете использовать при рецензировании?

  • Негибкий. Является ли код негибким? Содержит ли он жесткий набор типов и параметров, что затрудняет модификацию.
  • Хрупкий. Является ли код хрупким? Вызывает ли хаос малейшее изменение кодовой базы?
  • Неподвижный. Сложно ли код поддается рефакторингу? Находится ли он в одном нажатии клавиши от цикличного импорта?
  • Cложный. Существует ли этот код только ради кода и не является ли он переусложненным?
  • Многословный. Использование кода изнурительно? Можете ли вы глядя на код сказать, что он пытается сделать?

Относятся ли эти слова к позитивным? Доставило бы вам удовольствие слышать эти слова при рецензировании вашего кода?

Возможно, что нет.

Хороший дизайн

Но это улучшение, теперь мы можем сказать что то вроде «мне не нравится это потому, что слишком сложно модифицировать», или «мне не нравится это потому, что я не могу сказать, что этот код пытается сделать», но что насчет того, чтобы вести обсуждение позитивно?

Разве это не было бы здорово, если бы существовал способ описать свойства хорошего дизайна, а не только плохого и иметь возможность рассуждать объективными терминами?

SOLID

В 2002 году Роберт Мартин опубликовал свою книгу Agile Software Development, Principles, Patterns, and Practices. В ней он описал пять принципов переиспользуемого дизайна програмного обеспечения, которые он назвал SOLID принципами, аббревиатурой их названий.

  • Принцип единственной ответственности
  • Принцип открытости/закрытости
  • Принцип подстановки Барбары Лисков
  • Принцип разделения интерфейса
  • Принцип инверсии зависимостей

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

Это именно то, что я хотел бы обсудить с вами этим утром.

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

Первый принцип SOLID, это S — принцип единой ответственности.

Класс должен иметь одну и только одну причину для изменений.
-Роберт С. Мартин

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

Почему это так важно, чтобы один участок кода имел только одну причину для изменений? Чтож, идея того, что ваш код может изменяться мучительна, но она гораздо менее тягостная, чем то, что код от которого зависит ваш код, тоже может изменяться. И когда ваш код должен изменяться, он должен делать это в соответствии с конкретным требованием, а не быть жертвой сопутствуюещго ущерба.

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

Связанность & Единство

Два слова, которые описывают на сколько просто вносить изменения в вашу программу, это связанность и единство.

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

Смежное, но отдельное понятие, это единство — сила взаимного притяжения.

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

Чтобы описать реализацию принципов связанности и единство в программе на Go, мы могли бы поговорить о функциях и методах, как это часто бывает при обсуждении SRP(принципа единой ответственности), но я верю, что все начинается с системы пакетов в Go.

Имена пакетов

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

  • net/http, который предоставляет http клиент и сервер.
  • os/exec, который запускает внешние команды.
  • encoding/json, который реализует кодирование и декодирование документов JSON.

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

Плохие имена пакетов

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

Какую возможность предоставляет package server?.. возможно это сервер, но с какой протокол он реализует?

Какую возможность предоставляет package private? Штуки, которые я не должен увидеть? Должен ли он вообще иметь какие -то публичные символы?

И package common, ровно как и его соучастник package utils часто находятся рядом с другими злостными нарушителями.

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

Филисофия UNIX в Go

В моем представлении, никакое обсуждение раздельного дизайна не будет полным без упоминания труда Дугласа Маклроя «Филисофия UNIX»; мелкие, острые инструменты, которые сочетаются для решения более крупных задач, часто таких, которые не были предусмотренны оригинальными авторами. Я думаю что пакеты Go воплощают дух философии UNIX. В действительности каждый пакет Go сам по себе -это маленькая Go программа, единственная точка изменений с единственной ответственностью.

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

Второй принцип O — принцип открытости / закрытости Бертрана Мейера, который в 1988 году писал:

Програмные объекты должны быть открыты для расширения и закрыты для модификации.
-Бертран Мейер, Построение Объектно-Ориентированного Программного Обеспечения

Как этот совет применялся для языков созданных 21 год назад?

package main  type A struct {         year int }  func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }  type B struct {         A }  func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }  func main() {         var a A         a.year = 2016         var b B         b.year = 2016         a.Greet() // Hello GolangUK 2016         b.Greet() // Welcome to GolangUK 2016 }

У нас есть тип A, с полем year и методом Greet. Мы имеем второй тип B в который встроен A, вызовы методов B перекрывают вызовы методов A поскольку A встроен, как поле в B и B предлогает свой собственный метод Greet скрывая аналогичный в A.

Но встраивание существует не только для методов, оно также предоставляет доступ к встроенным полям типа. Как вы можете увидеть, поскольку оба A и B определены в одном пакете, B может получить доступ к приватному полю year в A, как будто оно было определенно внутри B.

Итак встраивание-это мощный инструмент, который позволяет типам в Go быть открытыми для расширения.

package main  type Cat struct {         Name string }  func (c Cat) Legs() int { return 4 }  func (c Cat) PrintLegs() {         fmt.Printf("I have %d legs\n", c.Legs()) }  type OctoCat struct {         Cat }  func (o OctoCat) Legs() int { return 5 }  func main() {         var octo OctoCat         fmt.Println(octo.Legs()) // 5         octo.PrintLegs()         // I have 4 legs }

В этом примере у нас есть тип Cat, который может посчитать колличество ног с помощью своего метода Legs. Мы встраиваем этот тип Cat в новый тип OctoCat и декларируем то, что OctocatS имеет пять ног. При этом OctoCat определяет свой собственный метод Legs, который возвращается 5, когда вызывается метод PrintLegs, он возвращает 4.

Это происходит потому, что PrintLegs определен внутри типа Cat. Он принимает Cat в качестве ресивера и отсылает к методу Legs типа Cat. Cat должен знать о типе в который он был встроен, поэтому его метод не может быть изменен встраиванием.

Отсюда мы можем сказать, что типы в Go открыты для расширения и закрыты для модификации.

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

func (c Cat) PrintLegs() {         fmt.Printf("I have %d legs\n", c.Legs()) }  func PrintLegs(c Cat) {         fmt.Printf("I have %d legs\n", c.Legs()) }

Ресивер-это в точности то, что вы передаете в него, первый параметр функции и посколько Go не поддерживает перегрузку функций, OctoCat не является взаимозаменяемым с обычным типом Cats. Что подводит меня к следующему принципу.

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

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

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

Интерфейсы

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

Мы говорим, что в Go интерфейсы удовлетворяются неявно вместо явного соответствия и это оказывает глубокое влияние на то, как они используются в языке.

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

io.Reader

type Reader interface {         // Read reads up to len(buf) bytes into buf.         Read(buf []byte) (n int, err error) }

Что приводит меня к io.Reader моему любимому интерфейсу в Go.

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

Поскольку io.Reader имеет дело с чем угодно, что может быть выраженно как поток байт, мы может конструировать объекты читатели буквально из чего угодно; константной строки, массива байт, стандартного потока входа, сетевого потока, архива gzip tar, стандартного выходного потока или команды выполненной удаленно через ssh.

И все эти реализации взаимозаменяемы, поскольку они удовлетворяют одному простому контракту.

Итак, принцип подстановки Лисков применим в Go и сказанное можно суммировать прекрасным афоризмом покойного Джима Вейриха:

Требуй не больше, обещай не меньше.
-Джим Вейрих

И это отличный переход к четвертому принципу SOLID.

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

Четвертый принцип, это принцип разделения интерфейса, который читается, как:

Клиенты не должны быть вынужденны зависеть от методов, которые они не используют.
-Роберт С. Мартин

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

// Save writes the contents of doc to the file f. func Save(f *os.File, doc *Document) error

Я могу определить такую функцию. давайте назовем ее Save, она принимает *os.File как источник для записи предоставленного Document. Но здесь возникает несколько проблем.

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

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

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

Что мы можем сделать с этими проблемами?

// Save writes the contents of doc to the supplied ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error

Используя io.ReadWriteCloser мы можем применить принцип разделения интерфейса для переопределения Save таким образом, чтобы она принимала интерфейс, который описывает более общие задачи операций с файлами.

С этими изменениями любой тип, который реализует интерфейс io.ReadWriteCloser может быть замещен предыдущим *os.File. Это делает применение Save более широким и поясняет стороне вызывающей Save, какой метод из типа *os.File релевантный требуемой операции.

Как автор Save я более не должен иметь возможность вызова всех нерелевантных методов из *os.File, поскольку они спрятанны за io.ReadWriteCloser интерфейсом. Но мы можем пойти немного дальше с методом разделения интерфейса.

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

// Save writes the contents of doc to the supplied WriteCloser. func Save(wc io.WriteCloser, doc *Document) error

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

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

type NopCloser struct {         io.Writer }  // Close has no effect on the underlying writer. func (c *NopCloser) Close() error { return nil }

Грубым решением будет определение нового типа, который встраивает io.Writer и переопределяет метод Close, предотвращая вызов Save из закрытого основного потока.

Но это будет скорее всего нарушением принципа подстановки Барбары Лисков, поскольку NopCloser на самом деле ничего не закрывает.

// Save writes the contents of doc to the supplied Writer. func Save(w io.Writer, doc *Document) error

Куда лучше будет решение переопределить Save, принимая только io.Writer, полностью лишив его возможности делать что -то помимо записи данных в поток.

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

Важное эмпирическое правило для Go, принимать интерфейсы, а возвращать структуры.
-Джек Линдамуд

Приведенная выше цитата интересный мем, который просочился в дух Go в течении нескольких последних лет.

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

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

Последний принцип SOLID, это принцип инверсии зависимостей, который утверждает:

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба уровня должны зависеть от абстракций.
Абстракции не должны зависеть от их деталей. Детали должны зависеть от абстракций.
-Роберт С. Мартин

Но что означает инверсия зависмостей на практике для программиста на Go?

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

Итак, как я представляю себе то, о чем Мартин тут говорит, в основном в контексте Go-это структура вашего графа импортов.

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

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

SOLID дизайн Go

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

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

Принцип открытости / закрытости поощряет вас к компромису простых типов и более сложных путем использования встривания.

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

Принцип разделения интерфейса продолжает эту идею и поощряет вас определять функции и методы которые зависят только на том поведении, которое им нужно. Если ваши функции нуждаются только в параметре типа интерфейса с единственным методом, тогда скорее всего эти функции имеют единственную ответственность.

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

Если вы хотите подвести итог этого разговора, то скорее всего это будет: интерфейсы позволяют вам применять принципы SOLID в Go программах.

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

Как заметил Санди Метз:

Дизайн- это искусство организации кода, который должен работать сегодня и легко поддаваться изменениям всегда
-Санди Метз

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

Заключение

В завершении давайте вернемся к вопросу, которым я открыл этот разговор. Как много программистов на Go во всем мире? Вот мое предположение:

В 2020 году будет 500,000 разработчиков на Go.
-Дейв Чейни

Что половина миллиона программистов на Go будут делать со своим временем? Чтож, очевидно, они будут писать много кода на Go и если мы будем честны, не весь код будет хорошим, часть кода будет плохим.

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

В С++ есть гораздо более чистый и эволюционный язык, который пытается выйти.
-Бьёрн Страуструп, Дизайн и Эволюция С++

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

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

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

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

Я бы хотел услышать сегодня о том, как люди говорят о дизайне программ на Go таким способом, чтобы они были хорошо спроектированны, разъединенные, переиспользуемые и отзывчивые к изменениям.

… и еще одна деталь

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

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

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

-Написали пост в блоге об этом.
-Расскажите на семинаре о том, что вы сделали.
-Напишите книгу о том, чему вы научились.
И приходите снова на эту конференцию в следующем году и расскажите о том, чего вы добились.

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

Спасибо.


Original post by Dave Cheney

FavoriteLoadingДобавить в избранное

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

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