Советы по архитектуре кода для начинающих

70a9daff6450cff0037385ac2fd6064e

Для кого статья

Вы уже написали свои первые 1000 строк кода и сейчас хотите сделать их понятнее, потому что внесение изменений занимает столько-же времени, сколько написать заново, но советы из ООП, SOLID, clean architecture и т.д. непонятны вам.

О чем статья

Эта статья — не объяснение принципов ООП, SOLID своими словами, а попытка создать промежуточный уровень между никакой и чистой архитектурами. 100% советы будут накладываться друг на друга и перефразировать SOLID, но так даже лучше.

От кого статья

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

Отказ от ответственности

Уверен, каждый пункт из статьи может быть предметом спора, но на то это и вольный пересказ. Вся статья идет под эмблемой «Лучше применить такую архитектуру, чем не применять вообще никакой».

Формат статьи — наводящие советы / вопросы.

Содержание:

  1. К чему относится функция.

  2. Как вы будете модернизировать одну функцию, не затрагивая другую.

  3. На сколько логических частей я могу раздробить мою функцию?

  4. Повторяющиеся слова в названиях функций / переменных.

  5. Что является центральными объектами вашего кода.

  6. На какие аналогичные функции может быть заменена ваша функция?

  7. Как выглядит идеальный псевдокод вашей функции?

  8. Обращайте внимание на формат данных.

  9. Отдавайте предпочтение простарнству имен, а не ветвлениям.

  10. Скрывайте постоянные аргументы функции внутри отдельной функции.

Совет номер 1

Когда пишете код и не знаете как его организовать — задайте себе вопрос следующего типа:
«К чему относится моя функция?» / «К чему относится этот функционал?» / «За что отвечает этот функционал?»
Попробуйте мысленно проставить хэштеги вашей функции:
#обработка, #валидация, #проверка, #БД, #отображение.
Безусловно, запрос к БД может являться частью обработки, но он же в будущем может использоваться и для другой функции,
даже если пока написан только для этой.
Ремарка: Вообще в разработке уже есть устаявщийся набор таких тегов, некоторые из них: validate, check, get, set, show, load, send. Сюда же входит CRUD и HTTP заголовки.

Совет номер 2

Подумайте, что может быть причиной модернизации вашей функции, что заставит ее измениться.

Небольшие изменения не должно существенно затрагивать другие функции.

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

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

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

Совет номер 3

«На какие части я бы разделил этот функционал?», «На какие еще подфункции можно разделить код этой функции?».

Рекурсивно задавая себе этот вопрос, вы придете к моменту, когда функция становится «атомарной», ее функционал логически больше не имеет смысла дробить (не путать с атомарной операцией).

def get_product_price():
… # Здесь код

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

  1. Применить общую формулу процентов. — Та самая атомарная операция.
    Раздробить это действие уже не получится.

  2. Применить ограничения к цене.
    Товар не может стоить меньше, чем похожий товар из прошлогодней коллекции и т.п.

  3. Применить скидку. Скидка не может быть отрицательной, больше 100%, и т.п.

Две функции ниже могут быть общими для всего проекта и находиться в модуле «util.py».
Классы могут использовать эти функции под разными и аргументами, делая обертку вокруг них.

Примечание: не все программисты одобряют такой модуль в проекте, я встречал статью с критикой такого подхода, но на этом этапе это вполне оправдано.

# (Не очень удачное название)
def calculate_percentage_number(number: int, percentage: int) -> int:
    return number * (percentage / 100)


def limit_number(number: int, min_: int, max_: int, ) -> int:
    """Вернет число или ограничение числа."""
    return min(max(min_, number), max_)


def get_product_price(price: int, discount: int, ) -> int:
    min_discount = 10  # Лучше поместить внутрь класса    
    max_discount = 20  # Лучше поместить внутрь класса
    discount = calculate_percentage_number(number=price, percentage=discount, )
    discount = limit_number(
        number=discount,
        min_=min_discount,
        max_=max_discount,
    )
    discounted_price = price - discount
    if 0 < discounted_price < price:
        return discounted_price
    # Игнорируем скидку в случае ошибки. 
    logger.log(DiscountError)
    return price  # Более разумным будет применить базовую скидку.

Обратите внимание как меняются имена переменных в зависимости от контекста,
price -> number, discount -> percentage.

Подсказка: Если функцию без труда можно записать в функциональном стиле
(когда наша функция в качества аргумента вызывает другую функцию) — то к ней применимо правило дробления.

Разумеется, не нужно сразу дробить ваш функционал на 1000 частей, далеко не все вам понадобится (принцип YAGNI), но вы должно быть к этому готовым.
Подсказка: Для процентов можно создать отдельный тип, что бы не путать с обчыными числами.

Совет номер 4

Обратите внимание на повторяющиеся »user» в названии функций.

def get_user_data():    
    ...


def notify_user_friends():    
    ...


def create_user_post():    
    ...

Такие повторения явный признак, что пора создавать класс для повторяющегося слова:
В пайтоне класс это не только объект, который должен быть создан много раз, но и пространство имен (удобная организация функций).

Ремарка: Лично я считаю, что инструкция »class» в пайтоне перегружена,
это и пространство имен, и структура данных, и сами классы собственно.

Лучше будет:

class User():
    def get_data():
        ...

    def notify_friends():
        ...

Совет номер 5

ООП вращается вокруг объектов / сущностей / моделей, которые определяет бизнес / работодатель.

В проекте условного мессенджера класс »сообщение» будет большим,
а в проекте про такси класс »сообщение» будет куда меньше, зато будет большой класс »автомобиль».

Определите для себя, какие классы в вашем проекте центральные и наполняйте их методами.

Ремарка: возможно в ближайшем будущем какой-нибудь ИИ создаст универсальную структуру для каждого объекта на земле и в каждом проекте будут одинаковые объекты, но скорее всего ИИ просто научится программировать лучше нас, без всякой организации кода :)

На моей практике начало любого проекта это небольшой набор стандартных функций и классов, например:
View, DB, User, Mail. Они используются для общих целей.
Очень быстро в сервисе такси класс Taxi перерастет остальные классы и будет иметь собственный метод приветствия.

def some_func(user: User):
    ...
    View.say_hello(name=user.name, )  # Общее приветствие.
    taxi.say_hello(name=user.name, )  # Приветствие от конкретного такси.
    ...

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

Общий метод say_hello помещается в общий класс View,
а вот taxi_say_hello в класс Taxi.

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

Ремарка: насколько я знаю, подход MVC (Model-View-Controller) имеет как сторонников, так и противников.

Поэтому в первую очередь все должно зависеть от требований к проекту.

Совет номер 6

На что я МОГУ заменить свою функцию / класс?

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

В какой-то момент вы решили сменить этот фреймворк.

Старый фреймворк:

recipient = BarMailAgent.serialize_recipient(recipient=...) 
FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)

Новый фреймворк:

recipient serialization already inside the method
BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)

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

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

class User:
    def send_email(self, version: int = 1, arguments=...):
        if version == 1:
            recipient = BarMailAgent.serialize_recipient(recipient=...)
            FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)
        else:
            # recipient serialization already inside the method
            BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)

Совсем безболезненно заменить фреймворк трудно, но это явно облегчит задачу.

Совет номер 7

Напишите сперва идеальный псевдокод, как в идеальном случае должен выглядеть ваш код, пример:

def register(user: User):
    user.validate()
    user.save()
    logger.log(event=events.registration, entity=user, )
    mail.send(event=events.registration, recipient=user.email, )
    notifier.notify(event=events.registration, recipients=user.possible_friends, )
    statistics.add_record(event=events.registration, recipient=user.email,)

Ремарка: Я пользуюсь правилом: 1 строчка — 1 действие.

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

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

Где-то снаружи код может выглядеть так:

def register_handler(update, context):
    try:
        events.register(user=context.user)
    except Exceptions.Registration.ValidationError:
        # Где-то внутри будет: "400. Увы, вы ввели некорректные данные, мы не можем сохранить такого пользователя."
        events.fails.registration(user=user)
    except Exceptions.Registration.DbError:
        # Где-то внутри будет: "503. Внутрення ошибка, приносим свои извинения."
        events.fails.registration(user=user)

Должен отметить, что этот код вызывает у меня самого несколько некритичных сомнений:

  1. Должен ли блок try/except быть снаружи метода »register»?

  2. Можно ли упаковать »user» в »events.registration»?

  3. Нужно ли передавать целиком пользователя или только необходимые атрибуты?
    С 1-ой стороны это делает код очевиднее, с другой — при изменении необходимого набора — придется больше писать.
    Я для себя пришел к такому компромиссу:
    Если атрибут неотъемлемая часть объекта (почта, телефон, айди) — передаем объект целиком, иначе — только атрибут.

В любом случае, это неплохой вариант для архитектуры.

Совет номер 8

Обращайте внимание на формат данных.

Какой-нибудь фреймворк может передавать на вход вашим обработчикам объект под названием event / update.

Функции проверки из этого объекта нужен только атрибут »user»,
а базе данных из этого объекта нужен только атрибут »ID» или »role».

Т.е. условная проверка прав доступа может выглядеть так:
update / event — передано в обработчик.
update.user — передано в функцию проверки.
user.id — передано в запрос к базе данных.

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

Мои функции валидации / проверки не зависят от формата данных предоставленных фреймворком.

Совет номер 9

Отдавайте предпочтение простарнству имен, а не ветвлениям.
Каждая ветка if/else усложняет код, создает потенциальную возможность ошибки и усложняет тестирование.

Ремарка: в архитектуре существуют метрики сложности кода, чрезмерное ветвление ухудшает показатели.

Теоретически, все API можно написать на ветвлениях, но не нужно:

def gloabal_handler(request):
    if request.url == 'settings':
        ...
    elif request.url == 'photos':
        ...

Отдавайте ветвления на откуп ЯП, ведь в конечном счете пространство имен можно представить как:

for key in namespace:
    if key == dot_value:
        return key

Ремарка: Лично я, кроме обычного кейсов, использую компромиссный подход, применяю ветвление если от него зависят аргументы функции, но не сама функция (и не будет зависеть в будущем).
(функцию тоже можно представить как аргумент, но это часто может усложнить код, оставьте это для декораторов).

Совет номер 10

Скрывайте постоянные аргументы функции внутри отдельной функции.

Здесь аргумент 'hello' всегда одинаковый, он не несет никакой полезной нагрузки при анализе кода, в 9 / 10 случаях при чтении кода мы НЕ хотим концентрировать свое внимание на том, какой текст отправляется, но код ниже заставляет нас это делать.

Легко читаемая функция, но может быть еще проще.

# Используйте переменную-константу вместо 'hello'
bot.send_message(text='hello', recipient=user.id, )

Краткость — сестра таланта.

View.say_hello(recipient=recipient, ) # bot.send_message внутри

Благодарю за внимание.

Habrahabr.ru прочитано 34628 раз