
Сегодня я хочу поговорить о том, как можно организовать код внутри своего проекта таким образом, чтобы всем было удобно им пользоваться. Считаю, что это важный этап разработки, который напрямую влияет на многие аспекты, в том числе на удовлетворённость от работы.
Эта тема, по моему мнению, является дискуссионной. Здесь я делюсь своим видением структуры проекта. Критерии удобства и читаемости структуры достаточно индивидуальны и могут различаться в зависимости от компании и даже от команды внутри неё.
Мой подход кому-то может показаться антипаттерном, но я уверен, что обмен опытом полезен. Это позволяет взглянуть на проблему под другим углом, почерпнуть что-то новое для себя и попробовать в своей работе.
Все примеры кода написаны на Python.
В первые дни на новом рабочем месте можно укрепить или, наоборот, ослабить доверие к компании. Когда код у команды понятен и структурирован, с ним гораздо приятнее работать. Это благоприятно сказывается на ощущениях от повседневной работы и повышает общую удовлетворённость. Хотя это лишь малая часть общего впечатления, именно детали часто определяют наше отношение к компании.
Отсюда вытекает ещё один важный аспект — репутация. Иногда может казаться, что вы присоединяетесь к команде мечты, но в реальности получается не совсем так. Однако если команда стремится держать планку, это значительно улучшает общее впечатление, полученное на собеседованиях, и подкрепляет ожидаемый уровень репутации на новом месте.
Но ещё важнее скорость работы. Когда вы открываете новый проект и сразу понимаете, как его запустить, где лежит основная бизнес-логика, а где логика работы с базой данных, начинать работу становится гораздо проще. Входной порог будет ниже, это экономит время, а время — это деньги.
Преимущества можно перечислять и дальше, но, думаю, этого достаточно, чтобы сделать вывод о важности поддержания кода в порядке. Поэтому предлагаю потратить остальное время на практическую часть, где я поделюсь своим опытом.
Работа нашей команды связана с моделями машинного обучения, и я расскажу о создании сервиса обёртки вокруг модели. Кроме того, мы используем микросервисную архитектуру. Всё это накладывает определённую специфику на нашу работу.
Сервисы обёртки вокруг моделей у нас достаточно лаконичны и не содержат сложной бизнес-логики. Их главная задача — вызвать внутри себя модель (это отдельная интересная тема для другой статьи) и вернуть ответ. Поэтому я начну с этих вводных.
Есть такая фраза: «так плохо, что уже хорошо» — вот мы так не делаем. Мы придерживаемся другого подхода: «делай хорошо, плохо не делай». Наша культура (я имею в виду культуру нашей страны) настолько богата различными поговорками, что можно легко найти подходящую для любой ситуации. Я постарался использовать языковое богатство и вписать культурный код в тему статьи, используя поговорки в качестве заголовков.
Для практической части предлагаю следующий план:
Показываю пример, по моему мнению, не самого удачного проекта
Анализирую его и ищу слабые места
Обсуждаю паттерны, которые можно использовать, чтобы улучшить проект
Выбираю подходящее решение
Применяю его в проекте
От беспорядка всякое дело шатко
Сейчас проекты на Python, помимо кода, часто содержат ещё множество разных файлов: конфигурации, docker‑compose, Docker‑файлы, __init__.py и т.д. Для лучшей читаемости дерева проекта я намеренно исключил часть из них, но часть оставил, чтобы раскрыть основные идеи, как с ними поступить.
Предлагаю рассмотреть пример. Допустим, у нас есть сервис, который умеет распознавать по входящей картинке, есть на ней машина или нет, а также записывает этот результат в базу данных.
|----- docker-compose.ci.yml # отвечает за CI
|
|----- ci.env # отвечает за CI
|
|----- 20250620_01_init-db.py # файл миграции для БД
|
|----- 20250621_02_change_column.py # файл миграции для БД
|
|----- server.py # некий сервер, возможно, HTTP
|
|----- check.py # работа с основной функциональностью, внутри есть работа с БД, больше тысячи строк
|
|----- engine.py # внутри ведётся работа с моделью детекции автомобилей
|
|----- serializes.py # работа с pydantic
|
|----- default.conf # дефолтные настройки для сервиса
|
|----- prod.conf # prod настройки для сервиса
|
|----- conftest.py # тесты с использованием pytest
|
|----- many_tests.py # тесты с использованием pytest
|
|----- Dockerfile # контейнер для разворота сервиса в кластере куба
|
|----- pyproject.toml # зависимости
Пример небольшой, но глаза уже разбегаются. Как в известной фразу: «Смешались в кучу кони, люди». В корне проекта лежит множество файлов, каждый со своим предназначением: работа с базой данных, бизнес‑логика, сериализаторы и вспомогательные файлы. Но как это всё запустить и проверить работоспособность? Где находится точка входа в приложение — непонятно. Этот сервис принимает запросы по протоколу HTTP или, может быть, AMQP?
То есть, чтобы просто начать работать с этим проектом, потребуется время на поиск отправной точки.
Предлагаю разобраться во всём и навести порядок.
Глаза боятся, а руки делают
Каждый из вас может предложить своё решение для организации кода в этом примере. Но как сделать так, чтобы код был понятен не только вам, но и другим разработчикам? Важно выработать подход, в котором разберётся большинство. Только так мы сможем снизить порог вхождения в проект и получить все преимущества рефакторинга. Иначе просто сделаем решение, удобное для себя, но непонятное для других.
Всё новое — это хорошо забытое старое
На помощь приходят уже готовые решения для рефакторинга. Моя цель — показать подходы, основанные на общепринятых правилах и паттернах. Это повысит вероятность того, что рефакторинг сделает код понятным для большинства разработчиков.
Для начала дам несколько базовых рекомендаций, которые помогут быстро улучшить читаемость кода:
Названия модулей и остальных объектов должны быть «говорящими за себя». Модули лучше называть во множественном числе, хотя можно и в единственном — выбор зависит от контекста. Функции будем называть глаголами. Думаю, смысл правила понятен, ведь, как говорится, «Как корабль назовёшь, так он и поплывёт».
Отделяйте основной код от тестов и других вспомогательных файлов проекта. Во‑первых, так будет проще и быстрее понять суть проекта. Во‑вторых, при сборке проекта в Docker удобнее оставлять только основной код для слоя prod.
Избегайте циклических импортов, старайтесь делать однонаправленный путь. Циклический импорт может привести к ошибкам при запуске сервиса, а также усложнит читаемость кода. И в этом смысле есть зависимость между деревом импортов и структурой проекта: чем проще и ровнее будет дерево, тем понятнее сам проект.
Держите объекты, связанные одной темой, в одном модуле или папке. Старайтесь группировать код по смыслу, чтобы упростить структуру проекта и уменьшить количество разбросанных модулей.
Делайте стандартную точку входа в приложение. Как правило, это
main.py.Не пишите весь код в одном модуле, поскольку большие файлы трудно читать. Старайтесь разбивать такие модули на части.
Это универсальные рекомендации, которые подходят не только для ML-сервисов.
Наш проект на данный момент находится в таком состоянии, что сначала нужно привести его в нормальный вид, а затем уже дорабатывать, учитывая специфику именно ML-сервисов.
Давайте пошагово применим каждую рекомендацию и посмотрим на результат.
Начнём с рекомендации №2: отделим основной код от вспомогательного. Чаще всего папку с основным кодом называют service. Предлагаю остановиться на этом устоявшемся решении.
|----- docker-compose.ci.yml
|
|----- ci.env
|
|----- 20250620_01_init-db.py
|
|----- 20250621_02_change_column.py
|
|----- service
| |----- server.py
| |----- check.py
| |----- engine.py
| |----- serializes.py
|
|----- default.conf
|
|----- prod.conf
|
|----- conftest.py
|
|----- many_tests.py
|
|----- Dockerfile
|
|----- pyproject.toml
Стало чуть лучше, но проект по-прежнему выглядит неорганизованно. Да, есть папка service, однако всё остальное пока лежит в корне проекта, и с этой проблемой следует разобраться. Для этого предлагаю применить рекомендацию №4.
|----- ci
| |----- docker-compose.ci.yml
| |----- ci.env
|
|----- migrations
| |----- 20250620_01_init-db.py
| |----- 20250621_02_change_column.py
|
|----- service
| |----- server.py
| |----- check.py
| |----- engine.py
| |----- serializes.py
|
|----- settings
| |----- default.conf
| |----- prod.conf
|
|----- tests
| |----- conftest.py
| |----- many_tests.py
|
|----- Dockerfile
|
|----- pyproject.toml
Мне кажется, что проект сейчас выглядит ещё лучше. На верхнем уровне всего два файла.pyproject.toml часто находится именно в корне проекта, Dockerfile — тоже. Для них можно сделать отдельные папки, но в нашем случае я не вижу в этом смысла. Все остальные файлы я распределил по папкам, и теперь, когда мы откроем проект, то увидим не кучу файлов, а папки с понятными названиями (это, кстати, рекомендация №1), и сразу поймём, где лежит основной код, а где, например, миграции баз данных и т.д. Поэтому обращаю ваше внимание на важность адекватных названий файлов.
Теперь вернёмся к папке service и продолжим рефакторить уже внутри неё. Для начала применим правило №5. Как видите, проблема определения стартовой точки сервиса по‑прежнему актуальна. В Python, как правило, ею является __main__.py, поэтому предлагаю не «изобретать велосипед», а следовать традициям.
Далее переходим к правилу №6. Как упоминалось выше, внутри check.py есть как определение наличия машины, так и работа с базой данных. Да и сам модуль достаточно большой (содержит более 1 тыс. строк). Для удобства читаемости также есть устоявшийся стандарт — максимум 1 тыс. строк на модуль. В нашем случае будет логично разделить файл check.py на две части: одна будет отвечать за определение наличия машины, вторая — за базу данных.
Теперь подумаем над названием новых модулей. Начнём с определения машин. По сути, это API нашего сервиса, поэтому предлагаю пока остановиться на названии api.py.
Далее идёт модуль, отвечающий за работу с базой данных. На этом этапе предлагаю назвать его db.py. Эти названия уже говорят за себя, но и тут есть, что порефакторить. Но к этому вопросу мы вернёмся позже. А пока:
|----- ci
| |----- docker-compose.ci.yml
| |----- ci.env
|
|----- migrations
| |----- 20250620_01_init-db.py
| |----- 20250621_02_change_column.py
|
|----- service
| |----- server.py
| |----- api.py
| |----- db.py
| |----- engine.py
| |----- serializes.py
| |----- __main__.py
|
|----- settings
| |----- default.conf
| |----- prod.conf
|
|----- tests
| |----- conftest.py
| |----- many_tests.py
|
|----- Dockerfile
|
|----- pyproject.toml
Теперь в папке service есть модули, по названиям которых можно понять, за что именно они будут отвечать. Но вопросы по‑прежнему имеются. Прежде всего, что находится внутри server.py? Да, по названию понятно, что там некий сервер, но предлагаю над этим поработать ещё. К тому же есть вопросы и к api.py.
Мы получили уже достаточно хороший результат: проект выглядит более понятным. У него появилась организованность, а по названиям модулей ясно, какой и за что отвечает. Применив шесть рекомендаций, мы сделали первый шаг к решению нашей проблемы.
Внимательный читатель заметит, что я ничего не сказал про рекомендацию №3. Так и есть. Я намеренно пропустил этот шаг, чтобы вернуться к нему позже, потому что перед этим хочу обсудить следующий шаг рефакторинга.
На предыдущем шаге мы применили базовые правила, но пришло время копнуть глубже.
Посмотрим, есть ли связь между шаблонами архитектуры системы и структурой проекта. Да, эти паттерны могут диктовать конкретные решения непосредственно в коде, но нас интересует та часть паттернов, которая касается структуры проекта, а также решения, которые они могут предложить в этом случае, и какие из них лучше подходят для ML-сервисов. Но чтобы в голове не образовалась каша из двух подходов, предлагаю оставить эту тему для второй части статьи, а сейчас попрактиковаться с описанными шестью рекомендациями.
Немного забегу вперёд и расскажу, о чём ещё мы будем говорить во второй части:
Кратко рассмотрим основные шаблоны архитектуры системы, выберем подходящий (с учётом особенностей наших ML-сервисов) и применим его к выбранному, чтобы ответить на оставшиеся вопросы.
Рассмотрим работу с ресурсами (базами данных, брокерами сообщений и т.д.) в контексте организации кода.
Построим графы импортов получившейся системы и покажем, как будет выглядеть дерево. Порассуждаем на тему зависимости между сложностью дерева импортов и сложностью структуры проекта.
На этом пока всё. Пишите в комментах, какими поговорками можно описать ваши сервисы. Интересно будет почитать, как организовываете свой код вы.