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

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

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

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

Бизнес логика: код, который решает задачу бизнеса.

Низкоуровневая логика: Код, который делает реальную работу. Сюда попадает установка соединений, сетевые запросы, парсинг JSON, всякая работа с байтами. В общем, все то, чему учат программиста.

Варианты решения

Обработку данных я специально упростил. В жизни объекты сложнее и обработка может быть достаточно сложной.

Вариант 1. Сделал дело - гуляй смело

session = requests.Session()
session.post("https://prod.com/login", json={"user": "u", "password": "p"})

data = session.get("https://prod.com/data")
data = json.loads(data)
updated_data = [field.upper() for field in data]
session.post("https://prod.com/data", json=updated_data)

Более продвинутая версия - это завернуть этот код в функцию main. Но это практически ничего не меняет в лучшую сторону.

Этот код является хорошим, когда у вас нет публикации.

Я так пишу в следующих случаях:

  • Нужно сделать одноразовую работу и выкинуть этот скрипт. Кроме меня его никто не увидит.

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

Что хорошо:

  • Это самое минимальное количество кода, которое можно написать.

Что плохо:

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

  • Попытка писать юниттесты на такой код - боль. Можно заработать отвращение к тестам.

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

Варинат 2. Вперёд к светлому будущему

def download_data(session):
  data = session.get("https://prod.com/data")
  return json.loads(data)


def process_data(data):
  return [field.upper() for field in data]


def upload_data(session, updated_data):
  session.post("https://prod.com/data", json=updated_data)


def get_session():
  session = requests.Session()
  session.post("https://prod.com/login", json={"user": "u", "password": "p"})
  return session


def main():
  session = get_session()
  data = download_data(session)
  updated_data = process_data(data)
  upload_data(session, updated_data)

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

Что хорошо:

  • Бизнес-логика и низкоуровневая логика разделены и код можно читать как справочник, по частям. Прочитав оглавление (main) можно решить, стоит ли заглядывать в отдельные главы. process_data - это бизнес-логика, туда стоит посмотреть повнимательней. Остальные части - низкоуровневая логика.

  • Сложность каждой отдельной части очень небольшая, каждая функция сфокусирована на отдельной задаче.

  • Тестируемость кода сильно возросла. Появилась возможность частично тестировать код. Покрыв 100% process_data тестами и протестировав остальное рукуми мы получим очень неплохой результат. Остальные методы обычно меняются значительно реже чем бизнеслогика, а тестировать их значительно сложнее.

  • Появилась возможность совместно работать на кодом. Например, мы можем паралелльно менять бизнес-логику и транспортный протокол.

Что плохо:

  • Нужно писать больше кода.

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

Вариант 3. Сириус бизнес

def make_downloader(session):
  def wrapper():
    data = session.get("https://prod.com/data")
    return json.loads(data)
  return wrapper


def process_data(data):
  return [field.upper() for field in data]


def make_uploader(session):
  def wrapper(updated_data):
    session.post("https://prod.com/data", json=updated_data)
  return wrapper


def application(downloader, processor, uploader):
  data = downloader()
  updated_data = processor(data)
  uploader(updated_data)


def get_session():
  session = requests.Session()
  session.post("https://prod.com/login", json={"user": "u", "password": "p"})
  return session


def main():
  session = get_session()
  downloader = make_downloader(session)
  uploader = make_uploader(session)
  application(downloader, process_data, uploader)

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

Если у вас есть возможность использовать готовый фреймворк, то изобретать велосипед тоже не стоит.

Что хорошо:

  • Бизнес-логика полностью отделена от низкоуровневой. Теперь можно протестировать как мы связываем все части воедино через простые заглушки. Легко писать тесты, значит, легко менять код. Из-за хорошего разделения, проще контролировать на что влияют изменения.

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

  • Появляется возможность создавать много точек входа. То есть, если у вас есть несколько окружений для запуска кода, вы можете написать свой main для каждого.

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

  • Много можно сделать не запуская всю систему, это порой сильно экономит время.

Что плохо:

  • Добавочная сложность вполне увесистая. В общем, создание собственных фреймворков, даже если они микро никогда не было простым решением. Архитектурного кода может оказаться значительно больше чем бизнес-логики.

  • Для разработки ядра фреймворк стоит иметь опытного специалиста.

Немного о реализации.

Архитектура состоит из двух уровней: main собирает объекты, а application уже делает нужную работу. В примере реализована ручная инъекция зависимостей, в жизни же может быть другая конструкция, например в Django аналогами main и application являются settings.py и apps.py.


Более подробно расскажу о тестировании. Протестируем сердце нашего кода - application.

def test_application():
  downloader = lambda _: ['a', 'b', 'c']
  uploader = Mock()
  application(downloader, process_data, uploader)
  uploader.assert_called_with(['A', 'B', 'C'])

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

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

Архитектура нужна для удобства расширения и поддержки кода.
Давайте проверим этот код на гибкость и поддерживаемость.

  • Нужно скачивать не с сервера а из базы данных: добавляю модуль с чтением из базы, добавляю новую точку входа (main). Есть только расширение и никакого изменения.

  • Нужно поддерживать загрузку в два места одновременн: на сервер и в очередь сообщений. Добавляю новый загрузчик в очередь сообщений. Добавляю еще один загрузчик, который вызывает загрузчик на сервер и в очередь (паттерн компоновщик, composite). Создаю новую точку входа, где использую второй загрузчик. Есть только раширение и никакого изменения.

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

  • Нужно поправить ошибку в загрузчике. Меняем файл загрузчика. Проблема чётко локализована в одном модуле.

  • Меняем контракт для загрузки данных, теперь нужно передавать не только объект но и метаданные. Как только меняются интерфейсы - это боль. В данном случае нужно поменять приложение, нужно поменять все реализации загрузчиков. Ну и всё перетестировать. Хотя можно схитрить: просто сделать второе приложение и использовать новые загрузчики с ним.

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

Немного опыта из личной жизни

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

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

Лямбды (коммерческая разработка)

В микросервисной архитектуре отвечал за несколько сервисов реализованных с помощью AWS Lambda. Использовал в них код похожий на третий пример.

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

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

Самые большие тесты на внешний уровень были строчек на 20ть из них: 10 - создание заглушек и входных данных, одна строка вызова, 10 строк проверки, что заглушки вызваны. В основном же это было 3-4 строки. Всего тестов было около сотни. Я активно использую параметризованные тесты, поэтому самих тестовых функций было поменьше.

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

Код внутри рантайма

Пошаговая стратегия, внутри есть чат с игроками. Для удобства разработки добавил возможность поговорить с компьютерным игроком и открыть REPL в его состояние. Такой интерактивный дебаг для ленивых. Процесс AI как и С++ API которое он исползует существуют только в рантайме. Я взаимодействие с API вынес в отдельный модуль и логику стало возможно запускать локально (ссылка на Github).

Сами тесты получились вполне короткими, но с фикстурами пришлось повозиться (ссылка на Github).

За некоторое переусложнение кода я получил возможность разрабатывать практически не запуская игру. Это тоже своего рода контекст, и глядя на код его просто так не разглядишь.

Заключение

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

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

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

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

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


  1. MentalBlood
    18.05.2022 09:40
    +1

    В "Сириус бизнес" ожидал увидеть ООП


    1. demp
      18.05.2022 10:14
      +1

      1. MentalBlood
        18.05.2022 10:25

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


    1. Andrey_Solomatin Автор
      18.05.2022 10:32
      +1

      Кроме наследования, всё остальные столпы ООП в коде пристутствуют.

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

      Для аннотаций типов использую Protocol, тогда анализаторы принимают и класс и функцию.

      from typing import Protocol
      
      
      class Uploader(Protocol):
        def __call__(self, path, data):
          ...
      
      
      class UploadClass(Protocol):
        def __init__(self, session):
          self._sesion = session
      
        def __call__(self, path, data):
          return self._sesion.post(path, data)


  1. pintor
    18.05.2022 14:49
    +1

    Не до конца я понял вашу идею. Как-то очень запутанно получилось.
    Я бы сделак что-то вроде:

    # инкапсулируем логику работы с API  в отдельный класс
    class Api:
      def fetch_data(): ...
      def save_data(data:): ...
    
    # это по сути основная бизнес логика приложения
    def update_data(data):
      return [fileld.upper() for field in data]
    
    # ну а это наш контроллер
    def do_work():
      api = Api()
      data = api.fetch_data()
      updated_data = update_data(data)
      api.save(updated_data)
    


    Тестировал бы используя подход outside-in, т.е. мокировал бы библиотеку requests и проверял бы, что мы отправляем то, что API ожидает.

    def test_api(requests_mock):
      do_work()
      requests_mock.assert_called_with(['FIELD1', 'FIELD2', 'FIELD3'])
    


    Ну и если `data` это достаточно сложный объект, мжно добавиь `dataclass` и дополнительно сериализацию/десериализацию, в таком случае из `Api` будем возвращать готовые объекты, а не словарь, соответственно можно будет в этот класс добавить функции манипуляции с объектом.


    1. Andrey_Solomatin Автор
      19.05.2022 00:48

      Этот код аналог второго примера. А сложность она вся в третьем.

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

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

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

      В функции есть три подвижные части, которые могут менять по разным причинам: upload/download/logic

      test_api(requests_mock)

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

      3 подвижных части, даже если взять по 2 варианте, успех и ошибка, то это 8 сценариев для outside-in тестирования. Логичекси исключим сценарии после ошибки, будет 5 тестов.
      Для 100% покрытия важной логики такой подход мне кажется сложным, количество тестов растёт экспоненциально относительно подвижных частей.