Чтобы понять хороший код или плохой, недостаточно на него посмотреть, надо еще знать и контекст, в котором он написан. Давайте попробуем решить одну простую задачу тремя способами и найдем в каком контексте каждое решение будет хорошим или плохим. Задача простая, но вполне жизненная: взять данные, поменять, сохранить.
Давайте для начала договоримся об используемых терминах.
Публикация: это момент - когда ваш код становится доступным для других людей. В зависимости от процесса разработки это может быть создание пулреквеста или добавление кода в общую ветку.
Архитектура: дополнительный код, который не относится напрямую к решению задачи.
Бизнес логика: код, который решает задачу бизнеса.
Низкоуровневая логика: Код, который делает реальную работу. Сюда попадает установка соединений, сетевые запросы, парсинг JSON, всякая работа с байтами. В общем, все то, чему учат программиста.
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. Но это практически ничего не меняет в лучшую сторону.
Этот код является хорошим, когда у вас нет публикации.
Я так пишу в следующих случаях:
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)
Контекст для такого кода, это когда я хочу его опубликовать. Это может быть как миграция, которую применят пару раз, а потом оставят пылиться в архиве или фича на ранних итерациях. То есть я еще не знаю, что ждет этот код в будущем и будет ли это будущие.
Что хорошо:
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 собирает объекты, а 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 напрямую не передаётся в приложение, потому, что они находится на уровень ниже.
Архитектура нужна для удобства расширения и поддержки кода.
Давайте проверим этот код на гибкость и поддерживаемость.
В обоих случаях на 100% покрыл логическую часть тестами. Интеграцию тестировал руками, но она практически не менялась со временем. Запуск юниттестов занимал пол секунды, и была возможность локального дебага.
Количество ошибок в проде связанных с тем, что код работает не так как я хотел, около нуля, про количество их до тестов, я деликатно умолчу. Количество ручных прогонов на финальном этапе внесения изменений чуть больше единицы. Да где-то лажали тесты, где-то интеграция работала не так, как я ожидал.
В лямбде изменений было достаточно много, и интерфейсы серьёзно менялись, и нестыковки с требованиями были. Я менял код достаточно агрессивно и быстро, потому что верил, что тесты всё поймают. Для уверенности в тестовом покрытии смотрел детальный отчёт по покрытию кода.
Самые большие тесты на внешний уровень были строчек на 20ть из них: 10 - создание заглушек и входных данных, одна строка вызова, 10 строк проверки, что заглушки вызваны. В основном же это было 3-4 строки. Всего тестов было около сотни. Я активно использую параметризованные тесты, поэтому самих тестовых функций было поменьше.
Конечно лямбду можно запустить на локальной машине, но надо создать окружение и внешние ресурсы. Это тоже требует времени, сложно портировать на другие машины и не запускается автоматически при каждом билде.
Сами тесты получились вполне короткими, но с фикстурами пришлось повозиться (ссылка на Github).
За некоторое переусложнение кода я получил возможность разрабатывать практически не запуская игру. Это тоже своего рода контекст, и глядя на код его просто так не разглядишь.
Тот код, который вчера еще был хорошим сегодня может оказаться плохим потому, что изменился контекст.
Юнит-тесты - очень хороший показатель архитектуры. Если получается писать простые юнит-тесты, значит в проекте модельность соответствует сложности.
Попробовать можно на отдельном взятом модуле любого размера. В большом проекте без архитектуры можно найти и изолировать некоторые модули. Это не спасёт проект, но сделает поддержку этого конкретного модуля несколько проще.
Давайте для начала договоримся об используемых терминах.
Публикация: это момент - когда ваш код становится доступным для других людей. В зависимости от процесса разработки это может быть создание пулреквеста или добавление кода в общую ветку.
Архитектура: дополнительный код, который не относится напрямую к решению задачи.
Бизнес логика: код, который решает задачу бизнеса.
Низкоуровневая логика: Код, который делает реальную работу. Сюда попадает установка соединений, сетевые запросы, парсинг 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 строк или запихать кучу бизнес-логики одной функцией на тысячу строк это явные промахи.Тот код, который вчера еще был хорошим сегодня может оказаться плохим потому, что изменился контекст.
Юнит-тесты - очень хороший показатель архитектуры. Если получается писать простые юнит-тесты, значит в проекте модельность соответствует сложности.
Попробовать можно на отдельном взятом модуле любого размера. В большом проекте без архитектуры можно найти и изолировать некоторые модули. Это не спасёт проект, но сделает поддержку этого конкретного модуля несколько проще.
О плохом и хорошем коде
Чтобы понять хороший код или плохой, недостаточно на него посмотреть, надо еще знать и контекст, в котором он написан. Давайте попробуем решить одну простую задачу тремя способами и найдем в каком...
habr.com