Выпуск 15 · подкаст «Тысяча фичей»

#15: Fib.nth: о чем не думают инженеры

42:19
↓ скачать mp3

Глава «While You Are Coding» из «Программиста-прагматика» глазами Александра: как победить боязнь белого листа прототипом-игрой, почему нельзя коммитить код, который не понимаешь (история с незадокументированным бином Spring Shell), «мышление графиками» вместо зубрёжки О-большого, атомарный рефакторинг с точки зрения свежеиспечённого коммитера Apache-проекта, TDD без карго-культа, property-based testing для проверки инвариантов и капитанские, но забываемые правила security. И хохма про Fib.nth, давшая выпуску имя.

Главное

  • Против боязни белого листа: начните с «прототипа-игры» без ответственности, получите end-to-end эффект — а потом сотрите всё и пишите нормальный продакшн-код.
  • Не коммитьте код, который не понимаете: «работает» на ваших данных — ещё не значит работает; обход контракта библиотеки (незадокументированный бин Spring Shell) рано или поздно ломается мажорным обновлением.
  • Документируйте вынужденные хаки максимально подробно — это тот случай, когда комментарии в коде необходимы, — и тестируйте сами предположения: assumeThat в JUnit 5 скипает тест, а не валит его.
  • О-большое — это «мышление графиками»: представьте кривую — и понятно, как время растёт от размера входа; лучший в теории алгоритм на маленьких данных может проигрывать простому линейному.
  • Рефакторинг — регулярная практика, но атомарная: не мешайте его с фичами в одном pull request — оставьте TODO со слинкованным джира-тикетом; ревьюеры скажут спасибо.
  • Тест, дизайн и кодинг — всё это программирование: оценивать имплементацию без тестов бессмысленно; а 100% покрытие — вообще не про TDD, это просто метрика.
  • Property-based testing проверяет инварианты (вероятность всегда от 0 до 1) на сотнях сгенерированных входов и находит кейсы, о которых вы не подумали.
  • Security капитанская, но забываемая: входные И выходные данные — векторы атаки («такой пароль уже используется» — подарок брутфорсеру), а аутентификация отсекает DoS-запросы до базы данных.
Расшифровка

[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это пятнадцатый выпуск подкаста «Тысяча фичей». Сегодня мы поговорим про сам процесс программирования: про то, о чём мы думаем и о чём иногда забываем подумать. Тут вам и оценка скорости алгоритмов, и property-based тесты, и даже немного секьюрити. Поехали!

[00:52] Напомню: я читаю второе издание «Программиста-прагматика» и иногда черпаю оттуда темы для подкаста. Это тот самый случай — глава называется что-то типа «While You Are Coding» и разбирает аспекты процесса программирования: тестирование, размышления над кодом, названия переменных. Отмечу: этот выпуск — да и все выпуски, касающиеся книги, — не читательский клуб. Это рефлексия над прочитанным, и мысли я высказываю в основном свои; книга — повод и толчок. Иногда сложно придумать тему, когда всю неделю отлавливал багу, — а тут вечером или на выходных осознанно почитал, записал ноуты и поделился тем, что сам думаю. Так что начнём.

[02:09] В начале главы авторы рассуждают про боязнь белого листа: с чего начать, когда садишься писать сервис или продукт? Начать очень сложно. У меня такое было раньше, когда я ощущал себя в программировании неуверенно: я не понимал, что вообще хочу получить и с какой стороны подойти. С опытом это перестало быть проблемой. Из нормальных советов авторов: представьте, что вы пишете не конечный продакшн-сервис, а прототип — просто играетесь с кодом и компьютером: пишете методы, веб-сервис, страничку, тыкаете кнопочки, раскрашиваете. Это облегчает порог входа: ответственности ноль, мы просто пробуем. А когда что-то написано и в голове складывается картинка, как оно должно выглядеть, — стираем всё нафиг и пишем нормальный продакшн-код на тех технологиях, которые договорились использовать. Я такой подход тоже использую: сажусь и начинаю, условно говоря, говнокодить — просто фигачу, чтобы получить хоть какой-то конечный результат. Важно, чтобы был end-to-end эффект: код как-то работает, и я как пользователь или заказчик вижу конечный результат и понимаю, устраивает он меня или нет. Нормально говнокодить сервис, который вместо ошибок возвращает стектрейсы и никаким принципам не следует, — но решает базовую задачу и предоставляет интерфейс, который я хочу. Я его потыкаю, добавлю или уберу требования, пойму «то или не то» — а после этого этапа сажусь и спокойно пишу: боязни чистого листа уже нет, потому что, справедливости ради, это уже не чистый лист — работа проделана, дальше по накатанной.

[05:09] Дальше авторы разгоняют тему programming by coincidence — «программирование по воле случая». Бывают ситуации, когда мы пишем код и не осознаём, почему он работает: что-то слепили, вроde работает как надо — всё, задача решена. Тут нужно быть очень аккуратным. Я, честно говоря, давно не помню, чтобы писал что-то, чего не понимаю: последние несколько лет я программирую достаточно осознанно и принципиально не коммичу в продакшн код, который не понимаю, как работает. Степень неуверенности в том, что может пойти не так, меня настолько пугает, что мне просто некомфортно отдавать такой код. Хотя бывает: когда я был джуном, понимать, как работает код, было невозможно по определению — недостаточно опыта, первый раз видишь задачу, технологию, фреймворк. Ты априори не понимаешь, как работает annotation processing в Micronaut, если ты джун, — ты просто ставишь аннотацию, и оно работает. Это норм. Но для более серьёзных программистов это уже не должно быть нормой. Аргументы авторов, почему подход плохой: если выглядит, будто работает правильно, — не факт, что работает правильно: может работать случайно, by accident — на ваших данных, тестах и сценариях, а в продакшне или у тестировщиков перестанет. Дальше: когда мы не понимаем, как что-то работает, мы либо не знаем контракт библиотеки или фреймворка (совсем плохо), либо используем незадокументированные фичи — в обход контракта. Такое случается: недавно я столкнулся с ситуацией в Spring Shell — в коде переопределяли внутренний бин библиотеки (так можно сделать), отвечавший за отображение истории в CLI, автодополнение, маскирование паролей в истории. Бин не задокументирован. И, естественно, в мажорном обновлении его нафиг грохнули и переписали всё к чертям — Spring 3 хочет нативно компилироваться, они всё порефакторили. Обратная совместимость сломана — и ты не можешь обновиться. Авторы об этом и говорят: используя незадокументированное поведение, ты программируешь волей случая — никто не гарантирует, что в следующей версии это будет работать; по возможности избегайте. И ещё: в коде, который ты не понимаешь досконально, могут жить лишние вызовы и замысловатая, завёрнутая пару раз вокруг логика — код просто медленнее, а как оптимизировать, ты не поймёшь; и мест для новых багов больше.

[09:36] В противовес — программирование осознанное. Всегда будьте в курсе того, что делаете. Используете библиотеку — посмотрите её контракт; идёте в обход — поймите почему и хорошенько задокументируйте. Инсайт главы: мы живём в реальном мире, и иногда по-другому нельзя — приходится использовать скрытую функциональность библиотеки или сделать грязный хак через рефлексию, просто потому что задачу надо решить, а взять другую библиотеку или написать свою мы не можем. Бывает, согласен. В этом случае — максимально подробно задокументируйте решение: почему мы это делаем и какие предположения делаем, чтобы наш код работал. Отдельной документацией или комментариями в коде. И вот тут я вспомнил «Чистый код» с его «комментарии — зло, не используйте»: это как раз пример из жизни, где комментарии действительно полезны и необходимы, без них никуда. Дальше: попробуйте объяснить написанный код джуниор-программисту. Получилось, и он понял — скорее всего, вы понимаете, что пишете. Не получилось — либо переработайте код, либо разберитесь ещё разок: возможно, вы не до конца понимаете, что делаете. И ещё зацепило: тестируйте не только код и зависимости, но и свои предположения — assumptions. Это часть assertive programming, даже дизайна по контракту. Вы предполагаете, что переопределённый внутренний бин маскирует пароли в истории, — это предположение: оно не задокументировано, и даже если вы посмотрели в код и убедились, в следующей версии может быть иначе. Полезно тестировать не только свой код маскирования, но и то, что переопределяемый бин — именно тот, который вы предполагаете. Как — другой вопрос: думаю, тут лучше подойдут end-to-end или интеграционные тесты с маскированием паролей. Кстати, в JUnit 5 есть специальное — assumeThat: тест «предполагает» что-то, и отличие assumption от assert в том, что непрошедший assumption не валит тест, а скипает его. Это важно. Я, правда, редко эти штуки использую.

[13:10] Следующая тема простая и базовая — казалось бы, что тут размышлять; но то, что одному очевидно, другому принесёт интересные идеи. Алгоритмы и оценка их скорости. Есть два лагеря: «алгоритмы нафиг не нужны» и «без них программист не программист» — в эти дебри я лезть не хочу. Моё мнение: алгоритмы — база, и оценка скорости на том уровне, о котором мы говорим, — тоже база; полезно знать, помогает в day-to-day разработке. Скорость алгоритмов принято оценивать нотацией «О большое», пришедшей из математики. Я понимаю эту тему с университета, но после него копнул глубже, чтобы чётко уложить в голове, — и всё, что осталось от этого представления, — мышление графиками. Что я имею в виду. Линейная сложность, O(n), — представляю график: оси X и Y, прямая примерно под 45 градусов (на самом деле угол любой — зависит от коэффициента; важен не угол, а что это прямая). Что мне это даёт? Понимание: если X равен 1, Y будет, например, 2; X равен 3 — Y равен 6. Я вижу, насколько Y меняется в зависимости от X. В программировании это понимание того, насколько дольше код будет работать в зависимости от входных данных: X — количество входных данных (скажем, размер сортируемого списка), Y — затраченное время. Представил прямую — и легко мыслить: при X = 100 или 1000 Y будет примерно вон там, около 1000–3000 миллисекунд; приемлемо. А если сложность не линейная? Возьмём логарифм: график — от серединки горбик вверх, потом горбик всё меньше и меньше, вытягивается почти в параллельную X прямую. Это значит: вначале, на маленьких объёмах, время исполнения растёт быстро — на X = 1, 2, 3, 4, 5 изменение скорости сильно заметно; а при X в десять тысяч или миллион изменение больше, чем при десяти, но уже не такое большое. Опять же — проецирую на график, как будет работать код. Примерно; это всё примерно. Дальше авторы разгоняют про O(n log n) и сортировки — об этом говорить не буду: материалов в интернете полно, тренироваться можно на LeetCode. От себя отмечу: при изучении темы после университета мне помогла книга «Cracking the Coding Interview» (изданий много, я читал пятое или шестое, готовясь к очередному собеседованию). Если хотите подготовиться так, чтобы вопросов не осталось, — прочитайте: она короткая, читается быстро, раскладывает всё по полочкам — про О-большое там львиная доля книги — и сэкономит кучу времени на LeetCode. Из интересного у авторов: лучшее по О-большому в теории — не всегда лучшее на практике. На достаточно маленьких данных «хороший быстрый» алгоритм может перформить хуже линейного — нарисуйте графики, и видно: при маленьких X его Y больше. Тогда проще использовать линейный. К тому же линейные алгоритмы — циклы — проще для понимания. Отсюда про преждевременную оптимизацию: возможно, она того не стоит, если входные данные точно ограничены. Капитанский совет, к которому присоединяюсь: сначала найдём узкое место — вот здесь, в сортировке, в выборке — и только потом оптимизируем. Но честно: очевидные неоптимальности, которые бросаются в глаза и которые просто поправить, — я думаю, надо править прямо на месте, не запуская бенчмарки.

[18:58] Следующая подтема — рефакторинг. Банальная, избитая. Авторы по Фаулеру, по классике, разъясняют: рефакторинг — это регулярное изменение внутренней имплементации кода без изменения его внешнего поведения (вольный перевод). Акценты: «регулярное» — рефакторинг происходит всегда, это не одноразовая акция; и «без изменения внешнего поведения» — тут логично вспомнить тестирование: доказать, что внешнее API не поменяло поведение, можно тестами — до рефакторинга проходили и после проходят, значит, это был рефакторинг, а не поломка. Это всё банально. А вот что хочу отметить: авторы неявно пушат «видишь место, которое нужно подрефачить, — подрефачь». И до недавнего времени я был той же точки зрения — continuous improvement, каждый раз делай лучше, чем было до тебя. Но недавно я стал коммитером Apache-проекта — а коммитер имеет право мерджить pull-реквесты в main. И я начал много, осознанно и крепко смотреть пул-реквесты: проверять локально, гонять тесты, собирать сборки — я беру на себя ответственность за мердж и должен досконально понимать, что делаю. И вот когда идёт фича или багфикс — я могу протестировать именно эту часть, сконцентрироваться на ней: атомарное изменение вливать легче. А когда вместе с изменением едет глобальный рефакторинг — импорты меняются в 35 файлах, переносится имя пакета, — это вроде бы минорное, банальное изменение, но оно сильно-сильно увеличивает когнитивную нагрузку при понимании pull-реквеста. Я бы всегда хотел, чтобы изменения были атомарными. Авторы тоже говорят «не рефакторьте и не добавляйте функциональность одновременно», но как-то вскользь. Вот мой современный рецепт: делаете фичу — новый метод в REST API, тесты, инфраструктуру, OpenAPI-спеку — и видите, что нужен рефакторинг: скажем, все контроллеры называются MyControllerImpl и лежат в левом пакете, а их 20 штук. Не делайте это в том же месте. Заведите TODO, проставьте его в коде, чтобы не забыть, и слинкуйте с тикетом в джире. А тикет нормально создайте: если процесс разработки построен грамотно, он не потеряется и не заваляется в бэклоге — попадёт в следующий рефайнмент или спринт. Я лично так и делаю: стараюсь не мешать. Тем, кто вливает и ревьюит ваш код, будет сильно проще — и они скажут спасибо за такое разделение.

[23:07] Дальше — тестирование. Тесты — моя любимая тема, повторяться не хочу. Из интересного: авторы описали test-driven development (неплохо описали) — и затем говорят: если вы только начинаете писать тесты, навык полезный, но некоторые превращают TDD в карго-культ и становятся его адептами. И вот тут меня улыбнуло: они пишут, что эти люди тратят слишком много времени на достижение 100% покрытия тестами. Я вообще не понимаю, в чём тейк: при чём здесь покрытие и TDD? Это разные вещи — не понимаю, как они могут рядом стоять. Покрытие — просто метрика, которую можно померить, если хочется; я за всю жизнь один раз померил покрытие — ради интереса — и больше никогда. Да, есть люди, которые упарываются по метрике, но виноват тут не TDD — тут немного другие проблемы. Дальше они разгоняют про redundant tests — мусорные тесты: люди, которые реально упарываются по TDD, сначала пишут тест, создающий инстанс несуществующего класса; тест не компилируется — идут создавать класс; тест компилируется — это, типа, одна стадия TDD; потом вызывают несуществующий метод… Понимаете — доводят идею до абсурда. Я понимаю, на чём основано это подтрунивание — такие люди есть. Но есть и здравая середина: создавать инстанс несуществующего класса — ну, если вам так комфортно, можно (к тому же IDEA создаст класс на месте — может, в этом есть смысл); я так не делаю. В целом авторы ничего против не имеют, но тестирование не должно быть карго-культом — нужен баланс, и тут, думаю, все согласны. Я, кстати, в последнее время пишу тесты вперёд, когда есть тестовая инфраструктура: хочу добавить новую ручку REST API, и у меня есть интеграционный или end-to-end тест на REST. Я иду в него и просто добавляю новый тест-кейс: вот endpoint, вызываю GET, передаю path-параметры, получаю такой-то результат. У меня есть задизайненная спека для REST, выраженная тестом, — он падает, и это стартовая точка идти имплементировать. На таком высоком уровне, когда инфраструктура есть и это делается легко и непринуждённо, я пишу тест вперёд. А когда инфраструктуры ноль и я ещё не до конца понимаю, что пишу, — сначала напишу какой-то код, поиграюсь, потыкаю; когда появится понимание, что это должно собой представлять, — сделаю интерфейс, на интерфейс — тест, и заимплементирую. Извилистый путь, но я сейчас использую такой. Заканчивается раздел фразой: тест, дизайн и кодинг — это всё программирование. Она меня зацепила вот почему. Иногда при оценке задач звучит вопрос: «а тесты мы оцениваем?» Если тесты — не какая-то отдельная система в другом репозитории, а просто тесты на код, — это же одно и то же! Когда такие вопросы задают, у меня внутри дискомфорт: как можно оценивать имплементацию без тестов? Я всегда стараюсь писать тесты, если это возможно. Тесты — не отдельная часть: это и есть код, то, что мы производим вместе, — как и дизайн: без нормального дизайна и без нормальных тестов далеко не уедешь. С фразой согласен.

[28:22] Дальше — property-based testing. Кстати, раздел в книге начинается цитатой «Доверяй, но проверяй» — написанной по-русски, с переводом на английский внизу. Юнит- и интеграционные тесты — хорошо, но мы люди: можем пропустить краевой случай и просто его не протестировать — невозможно постоянно генерить из себя классы эквивалентности и тестировать все ветки (да, возможно, и не нужно — баланс). Авторы говорят: есть дисциплина property-based testing, и в каждом более-менее популярном языке есть библиотеки и фреймворки для неё. Она защищает от случаев «забыли покрыть кейс» — и от ошибки, сделанной одновременно и в коде, и в юнит-тесте, когда тест всегда проходит, а ошибка есть. Что это такое — своими словами (я, кстати, писал магистерский диплом про тестирование и делал собственный тестовый инструмент — рассматривал property-based testing, фаззинг и прочие свежие подходы; может, запишу про него подкаст). Мы начинаем выглядывать в коде инварианты — опять же, из дизайна по контракту: инварианты, пре- и пост-кондишены. Например, мы считаем вероятность — число от нуля до единицы. Вероятность не может быть больше единицы и меньше нуля: это аксиома, инвариант, выполняющийся при любых входных данных. Может быть ноль — но не минус 0,1. Составляем тест: какой бы интеджер (или набор интеджеров) ни пришёл на вход методу — выходной дабл будет от нуля до единицы. Это контракт, инвариант. Запускаем инфраструктуру — она генерирует кучу входных данных, сотни, тысячи. И если на какой-то комбинации контракт не соблюдается — вернулось, скажем, 100 вместо единицы, — тест падает: «ай-яй-яй, смотри: вот данные, вот ошибка». Она находит неочевидные комбинации, о которых мы просто не подумали. Авторы приводят весомый пример найденной баги. Подход прикольный; честно скажу — в промышленной разработке я его пока не использовал, но очень хочу: учитывая, что я разрабатываю базу данных, property-based testing — кажется, прямо то, что нужно. Надо подумать и заиспользовать. Если вы используете property-based тесты — приходите в Telegram-канал «Тысячи фичей» и пишите в комментариях: какие сценарии вы ловили и реально ли работает. Мне интересно и другое: как их интегрировать? Они же, кажется, не должны быть частью билда: генерация и прогон могут занимать долго. И если это часть CI — является ли это условием мерджа? Генерация иногда выдаст набор, который проходит, а иногда нет. Скорее всего, это должны быть какие-то nightly-, daily- или weekly-тесты — сессионные прогоны, которые что-то генерят и выдают output, но не являются пайплайном. Так это вижу я; если у вас другие кейсы — пишите, реально очень интересно.

[32:53] Следующая подтема — security; абзац называется stay safe out there. Прикольный заход в начале: когда вы сделали 90% работы, не расслабляйтесь — у вас впереди ещё остальные 90%. Большинство программистов (и я иногда в том числе) о многом вообще не думают, когда делают имплементацию. О чём стоит — авторы выносят пять пунктов: минимизируйте возможные векторы атаки; всегда используйте principle of least privilege — принцип минимальных прав; думайте о security defaults — чтобы дефолтные пароли не были очевидными; encrypt sensitive data — не храните пароли plain-текстом; и maintain security updates — накатывайте security-фиксы. Звучит очевидно и капитански — я вынес это сюда, просто чтобы лишний раз напомнить. Но есть и интересное — про минимизацию attack surface. Сложность кода влечёт большее количество векторов атаки — ладно, это не очень интересно. Дальше: входные данные — это вектор атаки. Всё, что вы принимаете от пользователей и других систем — всё, что не лежит в недоступной никому конфигурации и не захардкожено, всё, что приходит извне, — может быть вектором атаки. Та самая знаменитая история log4j, банальные SQL-инъекции: всё, что можно хакнуть через вводимую строку, передав потенциально опасную команду. Будьте аккуратны: лишний раз проверяйте входные данные и используйте флажки фреймворков, запрещающие передавать executable-вещи. Про это частенько забывают. Дальше: неаутентифицированные сервисы — сервисы, дающие доступ к API без аутентификации, — подвержены denial of service. Можно задедосить какой-нибудь некэшируемый GET, который ходит в базу данных, — и просто задолбать базу, приведя сервис к отказу. Это не взлом и не хищение данных, но вещь неприятная — в чёрную пятницу, например, может навредить конкурентам. Аутентификация — базовый и важный критерий веб-сервисов, и хорошо бы её включать. Что она даёт: сервис применяет входные проверки на запрос — при basic-аутентификации в хедерах должны быть имя пользователя и пароль в Base64, — и если их нет, дальше запрос не пускается: сразу 403 Forbidden, «пошёл отсюда». До базы данных запрос не доходит — вот что важно: база не крякнет от дедоса, а сервис отмасштабируется в Kubernetes — и DoS через эту дыру перестаёт быть вектором. Дальше: сервисы, предоставляющие аутентификацию, — сами вектор атаки: подумайте про дефолтных пользователей и пароли — их можно перебрать обычным брутфорсом. Очевидно. А вот интересное: output data is an attack vector — выходные данные тоже вектор атаки. Пример: сервис пишет «password is used by another user» — пароль используется другим пользователем. Само по себе странно, но если такое есть — злоумышленник уже знает, что в системе существует такой пароль, и может положить его себе в пул для брутфорса. То же, вообще говоря, можно проделать с именами пользователей: перебором создавать имена и собирать «already exists» — не прямо вектор атаки, но сокращает работу брутфорсерам. Хотя и не писать «already exists» странно — тут я, кстати, не знаю, что делать. Ну и debugging information может быть вектором — но это уже не так интересно. Советы капитанские, но забывать о них нельзя. Может, сделаю отдельный выпуск про security — тема важная, я в неё погружался как обычный инженер, не как security-инженер.

[38:40] И последний топик — последняя, я бы сказал, хохма. Глава называется naming things — именование переменных. Там всё плюс-минус стандартно: имена важны, придумать имя — самое сложное; повторяться не хочу. Но что меня очень посмешило — их примеры «нормальных» имён, и конкретно один. Смотрите: есть модуль Fib — очевидно, Фибоначчи, — и у него метод fib, который принимает число и возвращает число Фибоначчи. Вызов извне выглядит как fib.fib(2). Авторы говорят: какая-то фигня — понятно, но зачем дублировать? А что, если назвать fib.of(2)? Вроде нормально — я и сам часто называю фабричные методы of или from: Person.from(…) читается лаконично. Но второй вариант они предлагают такой — внимание: fib.nth. Fib, точка, N, T, H. Что это значит?! Сокращение от Фибоначчи? Вроде нет. NTH — это вообще что за сокращение? Я как увидел это название — такой: чего, блин? Если вы знаете, что такое метод fib.nth, — напишите в Telegram-канале «Тысячи фичей» или мне на почту, я реально не знаю; напишу апдейт. Они такие: «а как насчёт такого имени?» — я прочитал и офигел. Да уж лучше fib.fib! Реально смешно, такой кек. Из остального — банальное, но отмечу: уважайте нормы именования, принятые в конкретном языке. В C или Java переменные внутри цикла называют i, j, k — и это нормально; строки называют s. Но не несите эти нормы в другие языки: авторы приводят пример, что i в цикле в Clojure выглядит, типа, крипово, — не надо так. Если комьюнити языка привыкло писать так — в чужой монастырь со своим уставом не ходят; и выносить свои привычки оттуда тоже не надо. Очевидно, вроде, понятно.

[41:30] А на этом выпуск подошёл к концу. В этот раз полезняшка находится не тут, а в Telegram-канале подкаста «Тысяча фичей»: там я размещаю не только выпуски, но и бонусы для подписчиков. Переходите по ссылке в шоу-нотах, если интересно. Также не забывайте делиться подкастом с окружающими — давайте прокачивать себя и людей вокруг. Ну а на этом всё. Услышимся!