О плохом и хорошем коде

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

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

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

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

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

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

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

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

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

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

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

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

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

Что хорошо:

Что плохо:

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

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

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


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)

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

Что хорошо:

Что плохо:

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

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

def make_downloader(session):
  def wrapper():
    response = session.get("https://prod.com/data")
    return response.json()
  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 собирает объекты, а 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 напрямую не передаётся в приложение, потому, что они находится на уровень ниже.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

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

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

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

@Andrey_Solomatin
18.05.2022 11:12 UTC
Первоисточник

Комментарии

@MentalBlood
18.05.2022 06:40 UTC
+1

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

@demp
18.05.2022 07:14 UTC
+1
18.05.2022 07:25 UTC
0

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

@Andrey_Solomatin
18.05.2022 07:32 UTC
+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)

@pintor
18.05.2022 11:49 UTC
+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` будем возвращать готовые объекты, а не словарь, соответственно можно будет в этот класс добавить функции манипуляции с объектом.
@Andrey_Solomatin
18.05.2022 21:48 UTC
0

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

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

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

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

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

test_api(requests_mock)

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

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

@vodopad
22.05.2022 18:09 UTC
+1

Не обязательно так делать.

json.loads(data)

Лучше так:

import requests
r = requests.get('https://api.github.com/events')
r.json()

https://docs.python-requests.org/en/latest/user/quickstart/

@Andrey_Solomatin
23.05.2022 19:31 UTC
0

Спасибо, чуть упростил.