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

Kate

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

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

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

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

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

Низкоуровневая логика: Код, который делает реальную работу. Сюда попадает установка соединений, сетевые запросы, парсинг 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 строк или запихать кучу бизнес-логики одной функцией на тысячу строк это явные промахи.

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

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

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

 
Сверху