Недавно я спроектировал и написал огромный сервис, и в прошлом месяце (наконец-то) состоялся его запуск. В процессе проектирования и имплементации я обнаружил, что ряд закономерностей, которые я приведу ниже, раз за разом всплывает в самых разных сценариях.

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

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

1. Придерживайтесь одного источника истины


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

Суть в следующем: если вы пытаетесь определить какое-то состояние сразу в двух местах в пределах одного сервиса… лучше сразу откажитесь от этой мысли. Более удачным решением будет просто отсылать к одному и тому же состоянию везде, где это возможно. Например, при поддержке фронтенд-приложения, которое получает банковский баланс с сервера, я бы предпочел во всех ситуациях получать информацию о балансе с сервера – я повидал на своем веку достаточно багов синхронизации. Если на основе этого значения рассчитывается еще какой-то баланс, например «доступный для трат» в противовес «общему» (скажем, некоторые банки требуют, чтобы на счету оставалась определенная минимальная сумма), тогда информация о балансе доступных для трат средств должна запрашиваться в режиме реального времени, а не храниться где-то отдельно. В противном случае для каждой транзакции вам придется обновлять оба баланса.

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

2. Да, пожалуйста, повторяйтесь


Все мы слышали о принципе DRY (Don’t Repeat Yourself – Не повторяйтесь), а теперь я предлагаю вашему вниманию принцип PRY (Please Repeat Yourself – Да, пожалуйста, повторяйтесь).

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

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

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

3. Не увлекайтесь моками


Моки. Люблю их и ненавижу. Моя любимая короткая цитата из обсуждения этой темы на Reddit: «С моками мы повышаем простоту тестирования в ущерб надежности».

Моки – это здорово, когда нужно написать модульные тесты, чтобы что-то по-быстрому проверить, а возиться с кодом продакшн-уровня неохота. Моки – это менее здорово, когда в проде что-то ломается из-за того, что, как выясняется, что-то пошло не так с тем, что вы поспешно набросали где-то в глубине стека, пусть даже этими глубинами занимается другая команда. Значения это не имеет: сломался ваш сервис – значит, вам и чинить.

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

Гораздо приятнее обнаруживать всякие неожиданности в процессе разработки, а не в проде. Чем дольше я пишу программы, тем больше склоняюсь к тому, чтобы по возможности держаться подальше от моков. Пусть лучше тесты будут немного более громоздкими – оно того стоит, если на кону стоит значительное повышение надежности. Если моки действительно необходимы и на этом настаивает коллега, проводящий инспекцию кода, то я скорее напишу побольше тестов (возможно, даже с избытком), чем пропущу какие-то из них. Если даже я не могу использовать в тесте реальную зависимость, то постараюсь найти другие варианты, прежде чем прибегать к мокам – например, локальный сервер.

На тот счет есть полезные замечания в статье 2013 года Testing on the Toilet от Google. По их словам, излишнее употребление моков приводит к следующему:
  • Тесты становятся сложнее для понимания, так как вдобавок к коду из прода появляется еще и дополнительный код, в котором приходится разбираться.
  • Поддержка тестов усложняется, так как нужно задать моку нужное поведение, а значит, в тест проникают детали имплементации.
  • Тесты в целом дают меньше гарантий, так как за корректность программы теперь можно ручаться только в том случае, если мок работает точь-в-точь так же, как и реальная имплементация (что далеко не факт, часто синхронизация между ними нарушается).

4. Сведите изменяемые состояния к минимуму


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

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

Комментарии (21)


  1. Dominux
    06.07.2024 16:13
    +8

    Придерживайтесь одного источника истины

    Централизация, одним словом

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

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


    1. Voffka39
      06.07.2024 16:13

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


      1. Dominux
        06.07.2024 16:13

        Вообще, я не продвигал ее использование в своем комментарии, а говорил о том, как важно именно индивидуально подстраивать реализацию под ТЗ, если говорить по простому

        Но если вам интересно, то можете прочесть одноименную книгу дяди Боба (или хотя бы рецензию), в ней он подробно излагает смысл. Чистая архитектура - лишь идея! В самой книге рассматривается множество проблематик и подходов к их решению, удовлетворяющие понятию "Чистой архитектуры", которую как раз-таки каждый, по непонятной мне причине, пытается описать чем-то конкретным


      1. Bagir123
        06.07.2024 16:13

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

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


        1. akakoychenko
          06.07.2024 16:13

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

          Где реально нужна качественная архитектура, так это B2B продукт с кастомизацией под каждого клиента. Ибо надо найти идеальный баланс, чтобы одновременно достаточно гибко удовлетворить каждого, но при этом, все ещё сохранить возможность одновременно исправить баг сразу у всех, если он в общем функционале. Ну и да, работа с разными клиентами по разным версиям API сюда же, внедрение новых фич, которые дублируют кастомные наработки для некоторых клиентов... Много ловушек, в которые попадают практически все, ибо в первые годы любого такого продукта без говнокода не выжить, а понять момент, когда точки масштабирования стабилизировались, и переписать заново не у всех выходит


          1. Bagir123
            06.07.2024 16:13

            А теперь представьте, что один и тот же код за 3 года был продан 500 клиентам и у каждого из них свои доработки

            Хотите вставит кусок кода отмотав 100 клиентов назад без чистой архитектуры? :)

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


    1. kspshnik
      06.07.2024 16:13
      +1

      И даже не сильнейший, а наиболее адаптивный, если мы смотрим на плюс-минус длительный период :)


  1. SergeyPo
    06.07.2024 16:13
    +12

    Правильно отметил автор в пункте 2 про DRY. Расширю мысль -- программирование как инженерная задача столь сложна, что она не сводится к набору примитивных аббревиатур и принципов. Пытаемся убрать дублирующий код, т к заметили сходства в двух алгоритмах, а потом нас просят что то изменить в первом, но не трогать второй, и сходства уже меньше, а проблем от объединения кода в один класс или метод все больше. Так же SPR из SOLID, на который некоторые молятся -- и доходят в этом до абсурда, превращая 50 рационально разработанных классов в 500 микро-классов. Да, каждый из этих 500 классов конечно стал проще, но в целом каша из 500 микро-классов стала сложнее чем 50 нормальных. А корень всей этой проблемы в одном -- программирование это сложно, опыт нужно набирать годами, и все равно каждый раз думать головой -- где нужно разбить на два класса, где нужно два объединить в один, а где оставить как есть. Многие же люди, особенно которые недавно "вкатились в айти", думать не хотят, хотят следовать простым принципам, находят их в разных книжках, и потом пытаются найденным молотком забить все, как будто кругом только гвозди.


    1. voldemar_d
      06.07.2024 16:13

       проблем от объединения кода в один класс или метод все больше

      Есть ещё принцип "работает - не трогай".


    1. dedmagic
      06.07.2024 16:13

      Не нужно набирать опыт годами, чтобы принять решение "объединять/не объединять".

      Просто ответьте на один вопрос: могут ли у этих двух классов (функций, модулей, структур данных и т.д.) изменяться требования независимо друг от друга? Если ответ "да", то не объединяем, копипаста обязательна. Будущий Вы будет Вам очень благодарен.


    1. Voffka39
      06.07.2024 16:13

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


  1. Asker1987
    06.07.2024 16:13
    +7

    И снова непонимание ООП, SOLID, DRY. Война против абстракций будет вечна, потому что это требует особых усилий. Проблема не в том, что сделали абстракцию, а в том, как Вы сами отметили, что кто-то зачем-то в абстракцию что-то добавил или изменил. Не надо подменять понятия и суть проблемы. Допустим, это сделали не Вы, тогда кто пропустил на код ревью такое вмешательство в абстракцию? Думаю, вопрос в квалификации. Прекратите воевать с ООП и SOLID.


    1. Dywar
      06.07.2024 16:13

      По пункту № 2. Буква С в СОЛИД как раз и говорит о том что не надо связывать код разных "похожих" модулей. Потому что потом они начнут отличаться. Это же говорил и Мартин Фаулер в книге рефакторинг. Так что мысль старая и хорошая. Согласен что ООП и СОЛИД ни как не противоречит указанным пунктам в статье.


  1. 3263927
    06.07.2024 16:13

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


    1. CrazyElf
      06.07.2024 16:13

      Ну честно говоря эта война с моками мне не очень понятна. Моки позволяют тестировать разные компоненты отдельно. Например, бизнес-логику отдельно от БД или интернета. Если где-то узкое место в производительности, то моки позволяют в это горлышко не упираться.


      1. 3263927
        06.07.2024 16:13

        да войны особо нет никакой, это СВО :D:D:D

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


  1. bonArt0
    06.07.2024 16:13

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

    Да вообще-то нет, вполне себе чёткая.

    Юнит-тест покрывает одну единицу кода - класс, например. Логика всего, что лежит за его пределами (за исключением совсем уж тривиальных вещей), эту единицу кода не волнует от слова совсем и на её внутреннюю логику не влияет. Её волнуют лишь входы/выходы этого запредельного. Соответственно, запредельное мокируется.

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


  1. DX28
    06.07.2024 16:13

    По поводу хранения инфы о балансе.

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


    1. akakoychenko
      06.07.2024 16:13
      +1

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


  1. chaetal
    06.07.2024 16:13

    Не осилил DRY — "придумал" PRY!

    Вы серьёзно?


  1. dime_makarov
    06.07.2024 16:13
    +1

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

    У "реальной имплементации" должен быть свой набор тестов, который проверяет, что она соответствует своему интерфейсу