Приветствую вас, хабровчане, решил поделиться с сообществом переводом довольно часто (по личным наблюдениям) упоминаемого поста SOLID Go Design из блога Dave Cheney, который выполнял для собственных нужд, но кто-то говорил, что нужно делиться. Возможно для кого-то это окажется полезным. Этот пост на основе текста из основного доклада GolangUK прошедшего 18-ого Августа 2016.
Запись выступления доступна в YouTube. Как много программистов на Go в мире? Подумайте о числе и держите его в своей голове,
мы вернемся к этому вопросу в конце разговора. Кто здесь проводит рецензирование кода, как часть своей работы? (большая часть аудитории поднимает свои руки, что обнадеживает). Хорошо, почему вы делаете рецензирование кода? (кто то выкрикивает "чтобы сделать код лучше") Если рецензирование кода нужно для того, чтобы отловить плохой код, тогда как вы узнаете, что код, который вы рецензируете, хороший или плохой? Сейчас это нормально сказать "этот код ужасен" или "ого, этот код прекрасен", точно так же, как если бы вы сказали "эта живопись прекрасна" или "эта комната прекрасна", но это субъективные понятия, а я ищу объективные пути, чтобы говорить о свойствах хорошего или плохого кода. Какими могут быть свойства плохого кода, которые вы можете использовать при рецензировании? Относятся ли эти слова к позитивным? Доставило бы вам удовольствие слышать эти слова при рецензировании вашего кода? Возможно, что нет. Но это улучшение, теперь мы можем сказать что то вроде "мне не нравится это потому, что слишком сложно модифицировать", или "мне не нравится это потому, что я не могу сказать, что этот код пытается сделать", но что насчет того, чтобы вести обсуждение позитивно? Разве это не было бы здорово, если бы существовал способ описать свойства хорошего дизайна, а не только плохого и иметь возможность рассуждать объективными терминами? В 2002 году Роберт Мартин опубликовал свою книгу Agile Software Development, Principles, Patterns, and Practices. В ней он описал пять принципов переиспользуемого дизайна програмного обеспечения, которые он назвал SOLID принципами, аббревиатурой их названий. Эта книга слегка устарела, языки о которых ведется разговор использовались порядка 10 лет назад. Но, возможно, существуют некоторые аспекты SOLID принципа, которые могут предоставить нам ключ к разгадке того, как говорить о хорошо разработанных программах на Go. Это именно то, что я хотел бы обсудить с вами этим утром. Первый принцип SOLID, это S — принцип единой ответственности. Go совершенно не содержит классов, вместо них мы имеем гораздо более мощную концепцию композиции, но если вы посмотрите на историю использования понятия класс, я думаю здесь есть определенный смысл. Почему это так важно, чтобы один участок кода имел только одну причину для изменений? Чтож, идея того, что ваш код может изменяться мучительна, но она гораздо менее тягостная, чем то, что код от которого зависит ваш код, тоже может изменяться. И когда ваш код должен изменяться, он должен делать это в соответствии с конкретным требованием, а не быть жертвой сопутствуюещго ущерба. Итак код, который отвечает за единственную задачу, будет иметь меньше причин для внесения изменений. Два слова, которые описывают на сколько просто вносить изменения в вашу программу, это связанность и единство. Связанность -это просто понятие которое описывает одновременное изменение в двух участках кода, когда изменения в одном месте означает обязательное изменение в другом. Смежное, но отдельное понятие, это единство — сила взаимного притяжения. В контексте программного обеспечения единство -это свойство описывающее то, что участки кода естественно связанны между собой. Чтобы описать реализацию принципов связанности и единство в программе на Go, мы могли бы поговорить о функциях и методах, как это часто бывает при обсуждении SRP(принципа единой ответственности), но я верю, что все начинается с системы пакетов в Go. В Go весь код существует внутри пакетов, и хороший дизайн пакета начинается с его имени. Имя пакета-это и описание его назначения и префикс пространства имен. В качестве примера хороших имен пакетов из стандартной библиотеки Go, можно привести: Когда вы используете символы другого пакета внутри своего собственного, это выполняется с помощью ключевого слова import, которое устанавливает связанность на уровне исходного кода между двумя пакетами. Теперь они знают о существовании друг друга. Подобное фокусирование на именовании не просто педантичность. Плохо именованные пакеты упускают возможность описать свою задачу, даже если бы она у них была. Какую возможность предоставляет package server?.. возможно это сервер, но с какой протокол он реализует? Какую возможность предоставляет package private? Штуки, которые я не должен увидеть? Должен ли он вообще иметь какие -то публичные символы? И package common, ровно как и его соучастник package utils часто находятся рядом с другими злостными нарушителями. Привлечение подобных пакетов, превращает код в свалку потому, что они имеют много обязанностей и часто изменяются без причины. В моем представлении, никакое обсуждение раздельного дизайна не будет полным без упоминания труда Дугласа Маклроя "Филисофия UNIX"; мелкие, острые инструменты, которые сочетаются для решения более крупных задач, часто таких, которые не были предусмотренны оригинальными авторами. Я думаю что пакеты Go воплощают дух философии UNIX. В действительности каждый пакет Go сам по себе -это маленькая Go программа, единственная точка изменений с единственной ответственностью. Второй принцип O — принцип открытости / закрытости Бертрана Мейера, который в 1988 году писал: Как этот совет применялся для языков созданных 21 год назад? У нас есть тип A, с полем year и методом Greet. Мы имеем второй тип B в который встроен A, вызовы методов B перекрывают вызовы методов A поскольку A встроен, как поле в B и B предлогает свой собственный метод Greet скрывая аналогичный в A. Но встраивание существует не только для методов, оно также предоставляет доступ к встроенным полям типа. Как вы можете увидеть, поскольку оба A и B определены в одном пакете, B может получить доступ к приватному полю year в A, как будто оно было определенно внутри B. Итак встраивание-это мощный инструмент, который позволяет типам в Go быть открытыми для расширения. В этом примере у нас есть тип Cat, который может посчитать колличество ног с помощью своего метода Legs. Мы встраиваем этот тип Cat в новый тип OctoCat и декларируем то, что OctocatS имеет пять ног. При этом OctoCat определяет свой собственный метод Legs, который возвращается 5, когда вызывается метод PrintLegs, он возвращает 4. Это происходит потому, что PrintLegs определен внутри типа Cat. Он принимает Cat в качестве ресивера и отсылает к методу Legs типа Cat. Cat должен знать о типе в который он был встроен, поэтому его метод не может быть изменен встраиванием. Отсюда мы можем сказать, что типы в Go открыты для расширения и закрыты для модификации. На самом деле методы в Go это несколько больше, чем просто синтаксический сахар вокруг функции с преобъявленными формальными параметрами, они являются ресиверами. Ресивер-это в точности то, что вы передаете в него, первый параметр функции и посколько Go не поддерживает перегрузку функций, OctoCat не является взаимозаменяемым с обычным типом Cats. Что подводит меня к следующему принципу. Придуманный Барбарой Лисков, принцип подстановки Лисков утверждает, что два типа являются взаимозаменяемыми, если они проявляют такое поведение, при котором вызывающий не может определить разницу. В языке основанном на классах, принцип подстановки Лисков часто интерпретируется, как спецификация для абстрактного класса с различными конкретными подтипами. Но в Go нет классов или наследования, потому подстановка не может быть реализованна в терминах иерархии абстратного класса. Вместо этого подстановка, это компетенция интерфейсов в Go. В Go от типов не требуют имплементации конкретного интерфейса, вместо этого любой тип имплементирующий интерфейс просто содержит метод чья сигнатура соответствует декларации интерфейса. Мы говорим, что в Go интерфейсы удовлетворяются неявно вместо явного соответствия и это оказывает глубокое влияние на то, как они используются в языке. Хорошо продуманный интерфейс, это скорее всего маленький интерфейс; преобладающая идиома это то, что интерфейс содержит только единственный метод. Вполне логично, что маленький интерфейс содержит простую имплементацию, поскольку сложно сделать иначе. Из чего следует, что пакеты -это компромисное решение простых имплементаций связанных обычным поведением. Что приводит меня к io.Reader моему любимому интерфейсу в Go. Интерфейс io.Reader очень прост; Read читает данные в указанный буфер и возвращает вызывающему коду число байт, которые были прочитаны, и любую ошибку, которая может возникнуть в процесе чтения. Это выглядит просто, но это очень мощно. Поскольку io.Reader имеет дело с чем угодно, что может быть выраженно как поток байт, мы может конструировать объекты читатели буквально из чего угодно; константной строки, массива байт, стандартного потока входа, сетевого потока, архива gzip tar, стандартного выходного потока или команды выполненной удаленно через ssh. И все эти реализации взаимозаменяемы, поскольку они удовлетворяют одному простому контракту. Итак, принцип подстановки Лисков применим в Go и сказанное можно суммировать прекрасным афоризмом покойного Джима Вейриха: И это отличный переход к четвертому принципу SOLID. Четвертый принцип, это принцип разделения интерфейса, который читается, как: В Go применение принципа разделения интерфейса может пониматься, как процесс изоляции поведения необходимого функции для выполнения ее работы. В качестве конкретного примера скажем, у меня есть задача написать функцию, которая сохраняет структуру Document на диск. Я могу определить такую функцию. давайте назовем ее Save, она принимает *os.File как источник для записи предоставленного Document. Но здесь возникает несколько проблем. Сигнатура Save препятствует возможности записи данных по какому ту адресу в сети. Предположим, что сетевое хранилище скорее всего станет требованием в дальнейшем и сигнатура этой функции может измениться, что повлияет на всех, кто ее вызывает. Поскольку Save оперирует непосредственно файлами на диске, тестировать ее довольно неприятно. Чтобы верифицировать операции тесты должны считать содержимое файла после записи. Кроме того тесты должны убедиться, что f был записан во временное хранилище и всегда удаляется впоследствии. *os.File также определяет много методов, которые не релевантны Save, как чтение дирректорий и проверка того, является ли путь символической ссылкой. Было бы полезно, если бы сигнатура нашей функции Save была описанна только теми частями *os.File, которые релевантны ее задаче. Что мы можем сделать с этими проблемами? Используя io.ReadWriteCloser мы можем применить принцип разделения интерфейса для переопределения Save таким образом, чтобы она принимала интерфейс, который описывает более общие задачи операций с файлами. С этими изменениями любой тип, который реализует интерфейс io.ReadWriteCloser может быть замещен предыдущим *os.File. Это делает применение Save более широким и поясняет стороне вызывающей Save, какой метод из типа *os.File релевантный требуемой операции. Как автор Save я более не должен иметь возможность вызова всех нерелевантных методов из *os.File, поскольку они спрятанны за io.ReadWriteCloser интерфейсом. Но мы можем пойти немного дальше с методом разделения интерфейса. Во-первых, вряд ли Save следует принципу единственной ответственности, он будет читать файл, в который только что произошла запись, чтобы проверить содержимое, что должно быть ответственностью другой части кода. Потому мы можем сузить спецификацию интерфейса, который мы передаем в Save исключительно до открытия и закрытия файла. Во-вторых, предоставляя Save с механизмом закрытия потока, который мы унаследовали с желанием сделать это похожим на обычный механизм работы с файлом, возникает вопрос, при каких обстоятельствах wc будет закрыт. Возможно Save будет вызывать Close без каки либо условий, или Close будет вызван в случае успеха. Все это представляет пороблему для вызывающего Save, поскольку он может пожелать добавить дополнительную информацию в поток, после того, как документ уже записан. Грубым решением будет определение нового типа, который встраивает io.Writer и переопределяет метод Close, предотвращая вызов Save из закрытого основного потока. Но это будет скорее всего нарушением принципа подстановки Барбары Лисков, поскольку NopCloser на самом деле ничего не закрывает. Куда лучше будет решение переопределить Save, принимая только io.Writer, полностью лишив его возможности делать что -то помимо записи данных в поток. Но применяя принцип разделения интерфейса к нашей функции Save, результат одновременно становится функцией, которая наиболее специфична в условиях ее требований, единственное, что ей нужно — это что-то куда можно писать и наиболее важное в этой функции то, что мы можем использовать Save для сохранения наших данных в любое место, где реализован интерфейс io.Writer. Приведенная выше цитата интересный мем, который просочился в дух Go в течении нескольких последних лет. В этой версии в рамках стандартного твита не хватает одного нюанса и это не вина Джека, но я думаю, что она представляет одну из главных причин появления приданий о дизайне языка Go. Последний принцип SOLID, это принцип инверсии зависимостей, который утверждает: Но что означает инверсия зависмостей на практике для программиста на Go? Если вы применяете все принципы о которых мы говорили до этого момента, тогда ваш код уже должен быть расположен в дискретных пакетах, каждый с единственной и хорошо определенной зависимостью или целью. Ваш код должен описывать свои зависимости в терминах интерфейсов и эти интерфейсы должны быть направленны на описание исключительно того поведения, которое требуется этим функциям. Другими словами, не должно остаться много работы. Итак, как я представляю себе то, о чем Мартин тут говорит, в основном в контексте Go-это структура вашего графа импортов. В Go ваш граф импортов должен быть ацикличным. Попытка игнорирования ацикличности приведет к ошибке компиляции, но гораздо более серьезная ошибка может быть в архитектуре. При прочих равных условиях граф импорта хорошо разработанной Go программы должен быть широким и относительно плоским вместо того, чтобы быть высоким и узким. Если у вас есть пакет, функции которого не могут исполняться без привлечения помощи другого пакета, возможно это сигнал о том, что не достаточно хорошо определены границы пакета. Принцип инверсии зависимостей поощряет вас переносить спецефическую ответственность на столько высоко, на сколько возможно в графе импортов, в ваш пакет main или на верхний уровень обработчика, оставляя нижний уровень кода работать с абстракциями и интерфейсами. В качестве резюме, когда каждый из принципов SOLID применяется в отношении Go они являются мощным инструментом дизайна, но используемые вместе, они являются главной темой. Принцип единой ответственности поощряет вас стуктурировать функции, типы и методы в пакеты, которые естественно связанны между собой; типы и функции вместе служат единственной цели. Принцип открытости / закрытости поощряет вас к компромису простых типов и более сложных путем использования встривания. Принцип подстановки Барбары Лисков поощряет вас выражать зависимости между вашими пакетами в терминах интерфейсов, а не конкретных типов. Определяя небольшые интерфейсы мы можем быть более уверенны, что реализации будут удовлетворять своим контрактам. Принцип разделения интерфейса продолжает эту идею и поощряет вас определять функции и методы которые зависят только на том поведении, которое им нужно. Если ваши функции нуждаются только в параметре типа интерфейса с единственным методом, тогда скорее всего эти функции имеют единственную ответственность. Принцип инверсии заисимостей поощряет вас перемещать знания о зависимостях вашего пакета от этапа компиляции, в Go мы наблюдаем это с сокращением числа импортов используемых конкретным пакетом, на этап выполнения кода. Если вы хотите подвести итог этого разговора, то скорее всего это будет: интерфейсы позволяют вам применять принципы SOLID в Go программах. Поскольку интерфейсы позволяют программистам Go описывать возможности своих пакетов, а не конкретную реализацию. Все это просто еще один способ сказать "разъединение", что и есть цель, поскольку слабосвязанный код легче поддается изменениям. Как заметил Санди Метз: Потому, что если Go планирует быть языком в который компании инвестируют в долгосрочной перспективе, то ключевым фактором в их решении будет то, на сколько легко поддерживать код на Go и как просто он поддается изменениям. В завершении давайте вернемся к вопросу, которым я открыл этот разговор. Как много программистов на Go во всем мире? Вот мое предположение: Что половина миллиона программистов на Go будут делать со своим временем? Чтож, очевидно, они будут писать много кода на Go и если мы будем честны, не весь код будет хорошим, часть кода будет плохим. Пожалуйста поймите, что я не пытаюсь быть жестоким, но каждый из вас в этой комнате с опытом разработки на других языках, языках из которых вы пришли в Go знает из собственного опыта, что в этом предсказании есть доля правды. Возможность помочь нашему языку добиться успеха для каждого программиста заключается в том, чтобы не создавать такой беспорядок, о котором люди начинают говорить, когда они шутят о C++ сегодня. Рассказы высмеивающие другие языки за то, что они раздуты, многословны и перегруженны однажды, могут быть применены в отношении Go и я не хочу, чтобы это случилось и потому у меня есть просьба. Go программистам нужно перестать говорить о фреймворках и начать больше говорить о дизайне. Мы должны перестать фокусировать на производительности любой ценой и вместо этого сфокусироваться не переиспользовании любой ценой. Я хотел бы увидеть сегодня, как люди говорят о том, как использовать язык, который у нас есть, независимо от их выбора и ограничений, чтобы создавать решения и решать реальные проблемы. Я бы хотел услышать сегодня о том, как люди говорят о дизайне программ на Go таким способом, чтобы они были хорошо спроектированны, разъединенные, переиспользуемые и отзывчивые к изменениям. Сейчас это отлично, что так много людей сегодня пришли, чтобы послушать такой отличный состав спикеров, но реальность такова, что не важно на сколько эта конференция вырастет, в сравнении с общим числом людей, которые используют Go в течение всей его жизни, мы лишь малая часть. Потому наша задача рассказать оставшейся части мира, как хорошее програмное обеспечение должно быть написанно. Хорошее програмное обеспечение, совместимое, поддающееся изменениям и показать им, как сделать это используя Go. И это начинается с тебя. Я хочу, чтобы вы начали говорить о дизайне, возможно используйте часть идей, которые я представил тут, надеюсь вы проведете свое собственное исследование и примените эти идеи для своего проекта. Тогда я хочу чтобы вы: -Написали пост в блоге об этом.
-Расскажите на семинаре о том, что вы сделали.
-Напишите книгу о том, чему вы научились.
И приходите снова на эту конференцию в следующем году и расскажите о том, чего вы добились. Делая все это мы сможем сформировать экосистему разработчиков на Go, которые заботятся о своих программах, разработанных для того, чтобы продолжать работать. Спасибо. Original post by Dave Cheney Java Developer