Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Чтобы понять хороший код или плохой, недостаточно на него посмотреть, надо еще знать и контекст, в котором он написан. Давайте попробуем решить одну простую задачу тремя способами и найдем в каком контексте каждое решение будет хорошим или плохим. Задача простая, но вполне жизненная: взять данные, поменять, сохранить.
Давайте для начала договоримся об используемых терминах.
Публикация: это момент - когда ваш код становится доступным для других людей. В зависимости от процесса разработки это может быть создание пулреквеста или добавление кода в общую ветку.
Архитектура: дополнительный код, который не относится напрямую к решению задачи.
Бизнес логика: код, который решает задачу бизнеса.
Низкоуровневая логика: Код, который делает реальную работу. Сюда попадает установка соединений, сетевые запросы, парсинг 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 вынес в отдельный модуль и логику стало возможно запускать локально. https://github.com/freeorion/freeorion/tree/master/default/python/AI/freeorion_tools/chat_handler
Сами тесты получились вполне короткими, но с фикстурами пришлось повозиться. https://github.com/freeorion/freeorion/blob/master/default/python/tests/AI/test_debug_chat.py#L129
За некоторое переусложнение кода я получил возможность разрабатывать практически не запуская игру. Это тоже своего рода контекст, и глядя на код его просто так не разглядишь.
Заключение
Каждому коду есть свой контекст. Написать фреймворк, вместо скрипта на 100 строк или запихать кучу бизнес-логики одной функцией на тысячу строк это явные промахи.
Тот код, который вчера еще был хорошим сегодня может оказаться плохим потому, что изменился контекст.
Юнит-тесты - очень хороший показатель архитектуры. Если получается писать простые юнит-тесты, значит в проекте модельность соответствует сложности.
Попробовать можно на отдельном взятом модуле любого размера. В большом проекте без архитектуры можно найти и изолировать некоторые модули. Это не спасёт проект, но сделает поддержку этого конкретного модуля несколько проще.