#6: Прагматичный Golang: design, dry, Go
Разбор второй главы «Программиста-прагматика»: хороший дизайн — тот, который легче изменить, DRY — про знания, а не только про код, ортогональные системы и обратимость решений. Во второй части Александр Пахомов делится первыми впечатлениями Java-разработчика от Go: почему не Kotlin, не Rust и не Python, как Advent of Code стал полигоном для нового языка и чем подкупают gofmt, go mod и простота вместо вопроса «как реализован HashSet». Рекомендация выпуска — «Чистая архитектура» Роберта Мартина.
Главное
- Хороший дизайн — тот, который легче изменить: все принципы проектирования (SRP, DRY, open-closed) в конечном счёте служат лёгкости изменений.
- Лёгкость изменений — это ценность, а не правило: для одноразового скрипта «хороший дизайн» может быть оверкилом.
- DRY — про знания, а не только про код: не дублируйте документацию, описание API и схемы данных — генерируйте их из единственного источника правды (OpenAPI, генераторы клиентов).
- Не всякое дублирование — зло: одинаковые сегодня валидации двух разных бизнес-правил (18+ для покупки вина и для брака) объединять не стоит — завтра правила разойдутся, и общий код станет проблемой.
- Ортогональность — изменения в одной части не трогают другие: это локализует баги, позволяет собирать компоненты как кубики и прячет вендора в одном месте.
- Золотое правило: лёгкость написания юнит-тестов — отличный признак ортогональности системы.
- Не существует окончательных решений: AWS завтра сменится на bare metal — проектируйте большие изменения обратимыми и разбивайте даже монолит на компоненты.
- Go подкупает прагматизмом: единый gofmt и модули из коробки убирают холивары, язык читается без вопросов, порог входа — пара часов; Advent of Code — идеальный полигон для изучения нового языка.
Ссылки
Расшифровка
[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это шестой выпуск подкаста «Тысяча фичей». В этом выпуске мы перейдём к разбору второй главы книги «Программист-прагматик», затем я поделюсь первыми эмоциями от программирования на Go, а в конце — рекомендация с книгой. Поехали!
[00:38] Итак, я продолжаю читать и рефлексировать над вторым изданием «Программиста-прагматика». Сегодня обсудим первую часть второй главы; предыдущую главу я разбирал в двух прошлых выпусках. Если вы их не слышали и это вообще ваш первый выпуск — во-первых, добро пожаловать, надеюсь, вы не останетесь разочарованы, а во-вторых, прослушать предыдущие выпуски можно и позже: контекст не так важен. Глава «Прагматичный подход» довольно большая, тем в ней много, поэтому я разбил её на две части. Сегодня обсудим важность дизайна, принцип don’t repeat yourself, ортогональные системы и то, что наши изменения должны быть обратимы. Начинается глава не сразу с тем, а с месседжа: все идеи «Прагматичного подхода» применимы почти на всех уровнях — на уровне дизайна всей системы, сервисов, компонентов, возможно, даже класса. И не столько к коду, сколько ко всему, с чем мы работаем и что производим.
[02:21] Первое — важность дизайна. Есть дизайн хороший, а есть плохой. Чем характеризуется хороший? Наличием пяти и более компонентов? Наличием документации? Нормальным планом UML-диаграмм? Авторы дают исчерпывающее определение: хороший дизайн легче изменить, чем плохой. Вот, собственно, и всё. Если разобраться — мы ведь и дизайним системы для того, чтобы их было легко менять: они должны подстраиваться под изменяющиеся требования. Если поменять тему или конкретный цвет во фронтенде можно небольшим количеством действий — скорее всего, дизайн хороший. Если мы меняем MongoDB на Postgres на более-менее начальных этапах (переезд с данными — отдельный топик, но в теории), и у нас Spring Data и плюс-минус простое приложение — тоже не сильно сложно: хороший дизайн. А вот если запросы с фронта летят напрямую в базу и фронт знает про схему БД — менять её будет сложнее, придётся править код минимум в двух местах. Это дизайн плохой. Все принципы проектирования, говорят авторы, направлены именно на это — на лёгкость изменений: single responsibility, don’t repeat yourself, open-closed — любой. Система, согласованная с принципами, проще в изменении и расширении. С этим я прямо согласен.
[04:17] Дальше важное: лёгкость изменений — это не правило, которому нужно следовать всегда. Это ценность, value, которое мы приносим, делая хороший дизайн. Почему именно так об этом думать? Потому что лёгкость изменений не всегда для нас ценность. Если я пишу скрипт для автоматизации личного процесса — скопировать RPM-пакет с локального компьютера на удалённую SSH-машину и развернуть его, — зачем мне продумывать «все возможные пакеты и все возможные SSH»? У меня конкретный сервер и конкретное действие. Ценности в лёгкости изменений тут нет — и следовать ей я, скорее всего, не буду. И последнее: хорошо бы понять, в какую сторону пойдут изменения, которые мы хотим делать лёгкими. Тема сайта, шрифты, языки — в идеале всё это меняется «в одном месте», и современные фронтенд-системы это позволяют. Но когда проектируешь с нуля — откуда знать, где будет больше всего изменений, где подложить соломки, а где не тратить силы? Спроектировать так, чтобы все изменения были в одном месте, невозможно. Авторы говорят: с опытом у инженеров развивается интуиция. Проектируя сайт для блогов, мы понимаем, куда он будет развиваться: социальная составляющая, шеринг, читаемость, темы — но навряд ли стоит сразу думать про распределённые системы и масштабирование бэкенда; возможно, потом это вырастет в большую соцсеть, но это потом. Однако не всегда мы работаем там, где интуиция настолько развита, — чаще предусмотреть не можем. На этот случай авторы рекомендуют следовать принципам низкой связанности и высокой связности — система должна быть decoupled and cohesive. Я считаю, что ортогональная система (о ней дальше) как раз такая. На этом про дизайн всё.
[07:41] Следующий принцип — DRY, don’t repeat yourself. Пожалуй, самый популярный принцип проектирования, и не только в программировании. Авторы делают акцент: DRY — не только и не столько про код, сколько про знания. Мы не должны дублировать знания: код и документацию про него, описание API, знание о том, как запускать тесты или end-to-end. Всё должно быть в одном месте — и меняться в одном месте. Про сам код всё просто — это самый банальный пример правила хорошего кода. Но есть момент, на котором я остановлюсь: не всегда дупликация кода — это плохо. Иногда она должна там быть, и авторы про это тоже говорят. Пишем валидации на формы: в форме покупки вина проверяем, что возраст — integer и больше 18. Логично, два чека навесили. Потом такая же форма — для заключения брака, и правила те же: integer, больше 18. Вроде бы две валидации один в один — можно схлопнуть в одну. Но, скорее всего, делать этого не стоит: это два разных бизнес-правила, два разных бизнес-процесса, которые просто сейчас описываются одинаково. А в будущем может оказаться, что заключать брак в нашей системе можно с 16 лет. И что делать, когда правила слиты в общее место? Это общее место становится проблемой: мы вроде следовали don’t repeat yourself, а вносить изменения стало сложнее — теперь в общем коде нужны if’ы «если то — 18, если это — 16», вместо того чтобы просто поменять циферку над одним полем. Это отражение действительности, реальных правил — и их не нужно объединять. С DRY не нужно перегибать.
[10:29] Дальше — про знания. Не дублируйте документацию к коду и сам код. Банальный пример: комментарии очень подробно объясняют, что происходит в коде, а сам код странный. Это любимый пример Роберта Мартина из «Чистого кода»: код должен быть самодокументируемым, и если к нему нужны комментарии, описывающие, как он работает, — это проблема. Иногда правда: изменения в код, скорее всего, внесёт другой программист — и, скорее всего, не внесёт их в комментарии; комментарии разъедутся с кодом, и это плохо. Но не всегда комментарий — code smell: контекст, который код физически нести не может — почему сделано так, а не иначе, — комментарием описать вполне хорошо. Там же, где дублируется именно логика кода и комментариев, дублирование нужно убирать — оставлять только код. Согласен. Помимо документации кода есть документация к API — её тоже хорошо бы генерировать из кода, а не описывать отдельно: иначе у нас два источника правды. Авторы предлагают генераторы документации и клиентов: для внутренней системы — вышла новая версия бэкенда, сразу сгенерировался клиент и подтянулся в клиентский код; если есть возможность так сделать — суперкруто. Для публичного API — OpenAPI Specification: у меня был про это выпуск (первый или второй — советую послушать), и добавить нечего: OpenAPI тоже служит принципу DRY — объявляем единственный источник правды про API, ему следует бэкенд, из него генерируются клиенты.
[12:45] Ещё авторы предлагают don’t repeat yourself в источниках данных: если где-то описана схема базы, то код — DTO-шки — должен скорее генерироваться из этого описания. Spring Data, Hibernate плюс-минус про это, когда мы из описания в коде генерируем entity в базе, — такая спецификация, только с базой данных. В Java это распространено; проблемы там есть — про ORM-фреймворки я вспоминаю с дрожью, — но иногда это действительно полезно. И ещё совет: используйте мапы — кладите данные из базы в мапы, они гибкие: поменяется схема — код перекомпилировать не придётся, добавьте валидации на мапы. Честно говоря, я так никогда не делал. Единственное, где я применял мапы в DTO-шках — интеграции со сторонними REST-сервисами: указывал мапу как контейнер для свойств, которые не замапились на объект. Описаны возраст, пол, имя — и мапа «прочие свойства»: если внешняя система добавит десяток полей, сериализация у меня не сломается, всё новое улетит в эту «корзину». Такая мусорка, чтобы строгая сериализация не падала (ну, или можно просто настроить нестрогую). А чтобы такое делали с базой данных — не встречал; если встречали и считаете, что это ок, — поделитесь, но, по-моему, в Java-мире так не принято.
[14:45] Заканчивается глава тем, что don’t repeat yourself должен работать не только в коде, документации и спецификациях, но и на уровне системы, на уровне компании. Если одна команда сделала сервис, скажем, валидации ИНН или каких-то идентификаторов — все, по идее, должны использовать этот код, а не писать каждому свою реализацию. Тут есть разные мнения. Я считаю, что внутренние митапы, шеринг и нетворкинг внутри компании — суперкруто; если кто-то что-то разработал так, что это можно использовать — нормальная библиотека, конфигурируемая, фреймворк-независимая, выложенная, которую я могу просто добавить в dependency, — это супер. Но часто наработка в коде не допилена до состояния, когда её можно консюмить внутри компании, тем более на паблик. Общие вещи сваливаются в хелперы, утилсы, статические штуки — в жесть, которая не предназначена для переиспользования. Люди редко пишут библиотечный код — пишут utils and helpers. Меня это немного напрягает; возможно, подкасты и существуют для того, чтобы менять сознание и заставлять думать в сторону библиотек. Совет части — пишите код, который легко переиспользовать. Собственно, об этом я и говорил.
[16:47] Следующая тема — ортогональные системы. Математическое значение описывать не буду, а в смысле авторов: две и более системы ортогональны, если изменения в одной никак не сказываются на остальных. Мы должны проектировать так, чтобы изменения в одной части не приводили к изменениям в другой. Совет главы: снижайте влияние изменений независимых вещей друг на друга. Логично, понятно. Дальше тейк: ортогональные системы повышают продуктивность и снижают риски. Исследований авторы, по-моему, не приводят — скорее всего, это их мнение, но я с ним согласен. Как повышается продуктивность? Первое — локализация изменений: меняем в одном месте, багу фиксим в одном месте, это быстрее. Второе — переиспользование: ортогональные компоненты независимы, их можно собирать как кубики, и комбинация компонентов даёт новый компонент. Например, один компонент разбирает входной поток в токены, другой превращает токены в бесконечный стрим — соединяем и получаем компонент «вход → бесконечный стрим». Пример примитивный, но идея понятна. Как снижаются риски? Опять локализация: сломалась, скажем, криптографическая функция — фикс будет в одном месте, быстро поправим и раскатим, не нужно править в каждом сервисе. Улучшается тестирование: ортогональную систему просто тестировать — компоненту не нужны другие компоненты, чтобы функционировать. Тестируем компонент работы со стораджем — ему не нужен компонент работы с сетью: даём на вход только то, что нужно для базы, и никаких моков вокруг. И ещё: зависимость от вендора прячется в одном месте. Завязались на MongoDB — компонент Storage скрывает все детали работы с ней и предоставляет API, абстрагированный от реализации; захотим переехать на другое хранилище — сможем, потому что детали в одном компоненте, и изменения в нём не отразятся на других.
[20:03] Дальше набор советов, которых стоит придерживаться в коде. Меньше деталей реализации наружу: если детали скрыты внутри модуля, изменения внутри не влияют на тех, кто его использует. Избегайте глобальных данных: shared state всегда неортогонален — изменения в общем состоянии могут привести к изменениям во многих местах. По той же причине аккуратнее с синглтонами: соблазн использовать их везде велик, но они становятся точкой поломки ортогональности. И если в коде видно дублирование — это, скорее всего (мы уже обсудили, что не всегда), плохой знак: стоит выделить общий код. А дальше авторы выносят в отдельный абзац мысль, с которой я согласен на 100% — для меня это вообще основополагающее: лёгкость написания модульных тестов — отличный признак ортогональной системы. Если можно написать юнит-тест, не парясь ни о чём, кроме того, что мы действительно тестируем, — значит, как минимум этот компонент ортогонален. Золотое правило, всегда вспоминайте о нём. И последнее про ортогональность: документация тоже должна быть ортогональной — лучше писать её в Markdown: тогда мы не зависим от репрезентации, от того, как она рендерится, и думаем только о сути. Документация в Markdown — лайк.
[21:53] И последняя тема на сегодня — обратимость изменений. Тут совсем коротко, она вытекает из предыдущей. Смысл: проектируйте компоненты и большие изменения так, чтобы их можно было отменить. Большие изменения часто оказываются необратимыми: переехали из одного хранилища в другое — а переехать обратно, если ожидания не оправдались, практически невозможно (ну, или это равносильно переезду на ещё одну базу). Совет топика: не существует окончательных решений. Думайте так: ни одно решение — наше или бизнеса — не выбито в камне. Используем AWS — завтра это Google Cloud, послезавтра bare metal. Используем базу данных — завтра файловая система. Ничто не вечно, и нам как инженерам нужно это закладывать — или хотя бы об этом думать — и проектировать систему так, чтобы большие вещи были обратимы. И последний тейк этой части: разбивайте приложение на компоненты, даже если это монолит. Возможно, потом будет проще разбить его на микросервисы; а может, и не будем разбивать — но работать с приложением, разбитым на компоненты, проще в принципе, и тестировать легче. Совет универсальный.
[23:46] Во второй части подкаста поделюсь первыми эмоциями от знакомства с языком Go. Зачем мне ещё один язык для бэкенда, когда я и так нормально знаю язык для бэкенда? В предыдущем выпуске я касался этой темы — кстати, она тоже из «Программиста-прагматика»: желательно изучать новые языки вне зависимости от того, нужны ли они на текущей работе. Я долго думал, что мне нужно овладеть вторым языком в совершенстве — на уровне, на котором я владею Java, чтобы писать так же легко. Долгое время естественным выбором Java-разработчика был Kotlin. Скажу так: я писал на Kotlin какое-то время — не продакшн, для себя, — много читал кода на нём, и ничего плохого говорить не хочу, но для меня это вторая Scala, только не функциональная, со всеми известными проблемами Scala. А второй раз писать на Scala я не хочу: я начинал карьеру Scala-разработчиком и вспоминаю это далеко не с любовью — те полотна сложного кода (даже не обязательно функционального — дело не в ФП, а в том, как язык себя позиционирует) было сложно читать, я осознанно ушёл от Scala и возвращаться не хочу. Глядя на Kotlin, у меня полное ощущение, что это Scala номер два. При этом Kotlin — замечательный язык: на Android — пожалуйста, если я когда-то буду писать Android-приложение, это, естественно, Kotlin и Jetpack Compose. Но на бэкенде — для меня не то.
[25:39] Так что же, если не Kotlin? Ещё один тейк: я решил, что мне не нужен второй JVM-язык. Я могу написать и прочитать код на Groovy, Scala, Kotlin — зачем мне vendor lock на JVM? Не хочу. Долго думал про Rust как дополнение к Java: они решают прямо разные задачи, понятно, где какой использовать. Даже начал читать книжку по Rust — но не зашёл: прямо сложный синтаксис, и я не понимаю, где мне его сейчас применить. Изучить могу, но задач для Rust лично у меня нет. Почему не распространённый простой язык типа Python или JavaScript? Знать фронтенд и бэкенд полезно, но на JavaScript я писал не раз и при необходимости всегда вкатываюсь: там сложнее не язык, а актуальный фреймворк; вкатился во фреймворк — вкатился и в язык, тем более он в целом похож на Python. Писать на нём необходимости нет, изучать его как язык неинтересно. Python — хороший язык, отличный: всегда лучший второй язык для решения любой задачи. Но я на нём писал достаточно много и плотно, знаю на приемлемом уровне — и плотно изучать его мне тоже неинтересно. К чему я пришёл: хочу изучить Go — на достаточно хорошем уровне, погрузиться глубоко: например, написать сервис с горутинами и каналами. У меня для него есть use case: для домашних проектов мне нужно писать command-line-приложения — Go подходит отлично; нужны легковесные сервисы, которые общаются по сети и отдают JSON-ы — тоже подходит. И Go — это не JVM. Поэтому Go.
[28:18] Как изучать? Я не могу изучать теорию без практики — у меня в голове так не работает: не могу прочитать книжку и сказать «ну, теперь я могу программировать на Go». Мне нужно читать и программировать одновременно. Книжки по Go вроде есть, но какой довериться — не знаю, да и не настроен я уже читать книжки по языкам. К тому же у Go есть замечательные ресурсы — Go by Example и A Tour of Go: короткие, но очень содержательные. Я их почти все прочитал и готов писать код. А где и какой? Надо придумать задачу. И сейчас есть отличная возможность — Advent of Code: онлайн-фестиваль задачек по программированию, который проходит каждый декабрь. 25 задачек, достаточно простые — типа поиска первого-второго-третьего максимума в последовательности (это и была первая задача). Читаешь задачку, берёшь input, пишешь программу, отправляешь ответ. Многие проходят Advent of Code на языках, которые уже хорошо знают, и пытаются решать хитро — изучая крайние возможности языка, как на работе решать нельзя. Для меня это совершенно неинтересно: писать на Java поиск максимума — что за бред? Неинтересно писать на том же языке, на котором я разрабатываю. А вот поучаствовать в челлендже интересно. Идеальная пара: Advent of Code плюс изучение Go. Я их сматчил — и теперь решаю задачки на Go каждый день. Времени они занимают немного, и это та самая ежедневная практика, которая необходима для изучения языка.
[30:25] Какие ощущения у прожжённого Java-разработчика от Go? Во-первых, экосистема, продуманная изначально. Это я про go mod — систему модулей, зашитую в язык, — и про форматирование gofmt, которое тоже зашито и одно-единственное. Эта идея — офигеть какая прикольная: стандартное форматирование, одинаковое везде, поставляется с языком из коробки. У нас в Java код-стайлы в каждом проекте плюс-минус одинаковые, но в деталях различаются — целое поле для дебатов и холиваров. Я не понимаю, зачем спорить, когда можно просто сказать: делаем вот так, и это не обсуждается. By design. Всеми руками за — gofmt класс. То же про модули: я могу подключать модули и описывать зависимости между ними на уровне языка. В Java такого понятия, в принципе, нет (девятую Java с её модулями не берём — там немного другое): есть джарники как единицы поставки кода и системы сборки типа Maven и Gradle, которые подкладывают джарники в нужное место и говорят Java «вот твой classpath» — в котором просто есть всё. Это выглядит странновато, когда понимаешь, что модули — это, наверное, то, как должно быть изначально. Go mod мне безумно зашёл. Опять же, дисклеймер: я только знакомлюсь — в этом и прикол выпуска, делюсь первыми впечатлениями. Я ещё не написал код с каналами, не собрал executable, не сделал REST-сервис. Говорю о первом впечатлении от языка и экосистемы — может, в чём-то не прав: пишите в комментариях, поправьте, я тут профан.
[32:45] Что ещё безумно зашло: сам язык позиционирует себя как максимально прагматичный, читаемый и понятный. Это то, что я ценю в инженерии в целом и чего добиваюсь от своего кода даже на Java: чтобы он был читаемый и не вызывал вопросов. Когда я пишу или читаю код на Go — по крайней мере, простой, без жести, без каналов и горутин, — у меня вообще не возникает вопросов в голове: «а почему тут так? а почему defer? а почему panic?» Всё понятно и логично. Это поражает и подкупает. Знакомясь с языком, ожидаешь открыть что-то новое, какие-то извороты, какие-нибудь enum’ы с тайпами — а тут вообще нет: есть структуры, есть интерфейсы, есть циклы, есть if’ы — берёшь и пользуешься. Структуры данных есть — пожалуйста. Простота — это классно, мне очень нравится.
[33:53] Отличный пример разницы подходов — прагматизм Go против Java. В Java есть прекрасная структура данных Set с реализациями TreeSet, HashSet. Мы знаем: структура хорошо оптимизирована под поиск. И любимый вопрос на собеседовании: «А как реализован HashSet?» А прикол в том, что он реализован через HashMap: это хешмапа, в которой значения — пустые объекты-индикаторы. Есть значение по ключу — ключ присутствует во множестве; нет — не присутствует. Казалось бы: зачем мне, пользователю Set, знать, что внутри мапа? Мне важно, что он оптимизирован под поиск, что я могу часто проверять contains и считать пересечения. Что там внутри — какая разница, и почему я должен знать об этом на собеседовании? В Go сетов вообще нет — я, по крайней мере, пока не нашёл. И когда я пытался понять, как сделать в Go структуру set, мне сказали: бери мапу. Бери мапу, и значения будут индикатором присутствия ключа. Нужен сет — беру мапу и делаю из неё сет: реализация выставлена наружу, и читая код, я понимаю, что внутри лежит мапа. Другой подход: никто не спросит «как сделать сет» — реализация перед тобой, и ты видишь, как это работает. В Java перед тобой интерфейс Set, за которым непонятно что; в Go всё на поверхности.
[35:59] И ещё один поинт, который я забыл: Go не зависит от IDE. Мне не обязательно брать IDEA, чтобы писать на Go: можно в GoLand, можно — как я сейчас — во Fleet (просто попробовать, что за продукт), можно в VS Code. Можно, в принципе, и в Vim — форматирование же из коробки; не будет разве что поиска по коду и автодополнения. Скорее всего, в итоге это будет GoLand — но сама возможность писать где угодно тоже важна. На этом мой довольно сумбурный набор первых мыслей о Go подходит к концу. Скажу, что первую задачу я решал долговато — пальчики привыкали к синтаксису, — а последние две решил, уже не глядя в интернет: функции чтения и прочее уже были написаны. Порог входа — буквально два часа написания кода, и ты уже нормально пишешь на Go. Свою первую структуру я объявил, не зная ни синтаксиса, ни того, существуют ли они вообще: подумал «классов нет — наверное, нужен какой-нибудь struct», начал писать, Fleet дополнил — ага. Написал struct и стал использовать, не подглядывая. Или пишу fmt.Println, ставлю запятую и думаю: «я бы здесь сделал varargs, чтобы передавать значения через запятую» — и действительно, так и есть. Стандартная библиотека сделана так, как сделал бы я. Возможно, это пришла какая-то программистская зрелость, и чувство не уникально для Go — с Python, может, было так же, я уже не помню. Но записать и запечатлеть первое чувство мне кажется ценным и прикольным. Сорян, если что-то сказал не то и наделал ошибок, — это первый опыт, первое знакомство и лишь мои ощущения от происходящего.
[38:17] На этом выпуск подходит к концу, и полезняшка на этот раз классическая и в каком-то смысле даже банальная — книга Роберта Мартина «Чистая архитектура». Я сейчас её читаю, и складывается ощущение, что её неплохо было бы прочитать всем инженерам — но без фанатизма. Не забывайте оставлять отзывы и предлагать темы для следующих выпусков в комментариях к посту в Telegram-канале или любым другим удобным способом. Ну а на этом всё. Услышимся!