Pull to refresh

Тебе не нужно классическое ООП в твоём бэкенд микросервисе

Reading time24 min
Views19K
Результат генерации по запросу «Классическое объектно-ориентированное программирование», стиль: 4k. Все изображения в статье сгенерированы нейросетью Kandinsky 2.1.
Результат генерации по запросу «Классическое объектно-ориентированное программирование», стиль: 4k. Все изображения в статье сгенерированы нейросетью Kandinsky 2.1.

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

Начну с небольшой предыстории о том, как я впервые начал размышлять на эту тему. Когда-то давно я писал первый эксплуатационный код на таких языках, как Perl и Python, а затем к ним добавился JavaScript и TypeScript. Во время обучения было ещё немного С. В тот момент я никогда не сталкивался с такими языками, как Java, C# и C++. Однако обучающая литература и тогда, и сейчас насквозь пронизана примерами кода и подходами именно из этих языков. Чистая архитектура, паттерны проектирования, примеры реализации принципов SOLID в виде кода и так далее — всё это преподносится, по большей части, через призму именно Java и её классического ООП.

Однако, когда я попытался применить это всё в Python, то столкнулся с трудностями. На этот язык не ложились многие примеры Java-кода. Мне казалось, что я чего-то не понимаю или упускаю. Но потом я осознал, что код на Python можно писать намного проще, и это будет абсолютно правильно — просто по-другому. Python активно эволюционировал и предоставляет множество возможностей, которые были просто недоступны раньше. Например, теперь можно оперировать функциями как объектами первого порядка, и передавать функции в функции в качестве аргументов. Одно это уже меняет очень и очень многое. А ведь это далеко не единственное, что теперь доступно по умолчанию в современных языках.

На Хабре я видел комментарий о том, что многие паттерны проектирования, по большей части, это костыли для древних версий Java, чтобы заставить ее нормально работать. Отчасти я с этим согласен. Подчеркну, отчасти.

Я решил написать эту статью по двум причинам. Точнее, причина и повод. Причина — это желание помочь начинающим разработчикам, которые выбрали такие языки, как Python или JavaScript, быстрее понять, что многие лучшие практики стоит рассматривать совсем под другим углом, чем нам навязывает большинство обучающей литературы. Я хочу облегчить им тот путь, который я когда-то проходил сам. А побудивший меня к написанию статьи повод — это YouTube-канал ArjanCodes. Он мне очень нравится, я его очень рекомендую всем начинающим и не только. Одно из последних видео поднимает тему «Когда нам писать функции, а когда классы». Если совсем коротко, то в системах action-driven нужно писать функции, а в state-driven — классы. Можете посмотреть видео по этому теме на канале ArjanCodes, а я бы хотел пойти дальше и раскрыть своё видение.

Зачем нам вообще нужны классы?

Для чего их придумали? Одной из важных причин было то, чтобы наши функции ходили вместе с данными. Это произошло в те времена, когда возможности статической типизации были сильно ограничены, и не все было так хорошо с пространствами имен. А очень хотелось получить инкапсуляцию и контролируемое управление изменением объекта. Функции, которые стали методом, знают, как изменять объект. А пользователи сами этот объект руками менять не должны. Но вы же помните, как в том же Python мы в целом относимся к инкапсуляции? Мы подскажем, что это трогать нельзя. Но если очень хочется, то можно. То есть мы в целом не пытаемся делать что-то приватным или публичным. Мы только на уровне конвенции наименования подсказываем, что если тут в начале одно или два нижних подчеркивания в имени метода или функции, то лучше это сам не трогай. Но по факту ты можешь. Какой вывод из этого следует? Нам на самом деле не очень важно, привязана ли функция к объекту данных и является методом, или функция на вход получает объект, изменяет его и отдает в новом состоянии. Технически мы, как пользователи чужого кода, все равно всегда имеем возможность натворить всякого с состоянием объекта, если почему-то нам такое взбредет в голову. Поэтому то, что функция привязана к объекту или нет, никогда на сто процентов не выполняло функцию классической инкапсуляции. Рассмотрим пример: мы создаём класс и в его конструкторе фактически определяем структуру наших данных. А привязывая функции к этой структуре и делая из них методы, мы решаем вопрос, с какими именно данными они работают.

Результат генерации по запросу «Почтальон Печкин: Я раньше почему злой был, у меня статической типизации не было», стиль: anime
Результат генерации по запросу «Почтальон Печкин: Я раньше почему злой был, у меня статической типизации не было», стиль: anime

Представим себе где-то в коде абстрактную функцию:

def get_unread_comments(post):
    ...

Что за пост она принимает? Какого формата этот объект? Какие поля он должен содержать? Ничего не понятно. И это проблема, которую хотелось бы как-то решить. Логичным шагом становится создать класс Post и сделать его методом get_unread_comments:

class Post:
    def get_unread_comments(self):
        ...


И вот теперь нам уже намного проще. Мы знаем, что метод get_unread_comments работает с экземпляром класса Post. Значит, он знает структуру этого объекта и как её преобразовать. В общем, не запутаемся.

А если у нас статическая типизация?

def get_unread_comments(post: Post) -> list[Comment]:
    ...

Получается очень интересная штука: нам больше не обязательно привязывать поведение и сами данные, они могут существовать раздельно в нашей кодовой базе. При этом компилятор или статический анализатор всегда подскажет нам, что мы используем не те данные не в том месте. Просто выбирайте те функции, которые выполняют нужные вам действия над объектом, принимая его на вход согласно своей сигнатуре. Более того, благодаря структурной типизации Python, ваша функция get_unread_comments умеет работать со всеми типами, которые расширяют Post. Если у вас появится потом тип News, расширяющий Post или другими словами, структурно соответствующий типу Post, но содержащий дополнительные поля, то вы спокойно можете работать с ним с помощью всех функций, которые умеют работать с Post. Вспоминаем принцип подстановки Лисков (Liskov Substitution Principle, LSP) из SOLID. И вообще, ваша функция get_unread_comments может принимать не сам тип Post, а определить интерфейс для работы с ней.

from typing import Protocol

class Publication(Protocol):

  @property
  def comments(self) -> list[Comment]:
    ...
def get_unread_comments(publication: Publication) -> list[Comment]:
  ...

Вспоминаем принцип инверсии зависимостей из SOLID. Теперь функция get_unread_comments может работать вообще с любым типом, который удовлетворяет интерфейсу Publication. А это хорошо еще и тем, что функция для своей работы получает только то, что ей нужно. Ей не нужно знать обо всех деталях таких сущностей, как Post или News. При этом обратите внимание, насколько просто происходит рефакторинг нашего кода. Если изначально вы создали функцию get_unread_comments и в вашей системе были только сущности Post, и вы создали get_unread_comments в нашем изначальном варианте, то перевести вашу функцию для работы с интерфейсом не составляет большого труда.

Вы спросите: «А как же наследование? Теперь ты потерял возможность наследовать класс от класса». А я отвечу, что в целом это хорошо. Ведь ещё Банда Четырёх в своей книге про паттерны рекомендовала избегать наследования и использовать композицию. А ещё об этом говорил Бьёрн Страуструп в своем докладе. А ещё Джеймс Гослинг прямо сказал, что если бы он мог создать Java по-другому, то отказался бы от иерархии наследования в пользу чистых интерфейсов. В общем, вы поняли, наследования следует избегать.

И, как вы видите, полиморфизм от нас никуда не делся. Функция get_unread_comments - это пример структурного полиморфизма. Существует множество видов полиморфизма, и он сам по себе не имеет прямого отношения к классическому ООП с классами и наследованием.

Ещё хочу вспомнить знаковое выступление core-разработчика Python Джека Дидериха "Stop Writing Classes by Jack Diederich" от 2012 года. В нём он сказал то, что мне кажется очень правильным: если у вас есть класс из двух методов, один из которых init, то вам просто нужна функция. Ну и про статические методы тоже не грех будет упомянуть. Появились статические методы, пришедшие из времён ранней Java, когда вы просто физически не могли создать функцию. Они даже не привязаны к тем данным, которые определяет ваш класс. По сути, это просто сторонняя вспомогательная функция. Так пусть она всегда и будет функцией в Python. Сейчас у нас нет препятствий сделать это.

Теперь, вооружившись пониманием того, что нам не всегда нужно писать классы, мы должны понять, как же организовать наши функции. Ответ в данном случае довольно прост: использовать пространства имён. Создавайте модули, в которых вы структурируете ваши функции согласно архитектуре приложения, и импортируйте их туда, где они вам нужны. Это может звучать банально, но на этом уровне модули уже являются достаточно мощной абстракцией, которая способна решать множество ваших проблем и потребностей. Чтобы увидеть примеры, можно посмотреть на модули из стандартной библиотеки Python, такие как functools и другие.

Результат генерации по запросу «анемичная модель данных», стиль: 4k
Результат генерации по запросу «анемичная модель данных», стиль: 4k

Некоторые могли заметить, что мы сейчас идём прямой дорогой к такому понятию, как «анемичная модель данных». Её критиковали в своё время многие разработчики, в том числе Мартин Фаулер в своей книге "Patterns of Enterprise Application Architecture". Но, во-первых, это было уже достаточно давно, как и в случае с книгой Банды Четырёх. А, во-вторых, далеко не все согласны с мнением, что эта модель является сама по себе анти-паттерном. От себя добавлю, что Фаулер писал свою книгу под влиянием его опыта "Enterprise Applications". Он писал её под грузом опыта решения энтерпрайз-проблем старого кода, в те времена, когда о функциональном программировании в серьёзных корпоративных системах никто не думал. Это было время царствования классического Java ООП, проблемы которого и приходилось решать. Но в том же функциональном программировании вообще не ставится вопрос объединения данных и логики. Они разделены по умолчанию всегда. И при этом на функциональных языках уже давно разрабатываются системы для сурового энтерпрайза-уровня.

А ещё раньше возможности статического анализа были заметно меньше и, скорее, являлись подсказками компилятора, нежели помощью в проектировании систем с точки зрения бизнес-процессов. Но с тех пор ситуация сильно изменилась, и не только такие языки как Haskell могут похвастаться достаточно богатой и функциональной системой типов, но и в Python или TypeScript нам уже доступны такие крутые вещи, как алгебраические типы данных, которые дают потрясающие возможности для моделирования бизнес-доменов прямо в вашем коде. На эту тему хочу посоветовать очень и очень хорошую книгу "Domain Modeling Made Functional" авторства Скотта Влашина (Scott Wlaschin). Хоть там и рассматривается в качестве примеров код F#, но с точки зрения работы с бизнес-доменом книга показывает и раскрывает потрясающие возможности моделирования бизнес-логики с помощью сильной системы типов вашего языка, если таковая в нём есть.

State-driven и action-driven

Теперь давайте подумаем, что такое state-driven, а что такое action-driven? Когда мы имеем систему state-driven, это значит, что где-то в нашем коде в runtime есть долгоживущие структуры данных, которые изменяются на протяжении всего жизненного цикла приложения. Пока оно работает, структуры находятся где-то в оперативной памяти и хранят текущее состояние своей части программы.

А action-driven означает, что нам в первую очередь нужно выполнить последовательность некоторых шагов и получить на выходе какой-то конечный результат, который мы куда-то сохраним или отправим, это уже не так важно. Для подхода state-driven логичным выглядит написание классов, которые в том же Python хорошо приспособлены для такого рода задач (хотя даже в этом случае я предпочитаю писать функции всегда, когда это возможно. Иногда к написанию класса тебя подталкивает, скорее, ограниченность выразительности функционального подхода в Python, нежели реальная безальтернативность). При action-driven нам достаточно написать несколько функций, которые мы сможем организовать в некий конвейер последовательных действий, которые выдадут нам нужный результат в конце цепочки вызовов.

И теперь рассмотрим ещё один фундаментальный вопрос в свете последнего абзаца: какими сейчас являются наши веб-бэкенд-серверы? Они stateless, то есть не хранят состояние во время своей работы. Благодаря этому мы можем поднимать в кластерах Kubernetes десятки подов с экземплярами нашего сервера и балансировщиками распределять между ними возросшую нагрузку. Каждый отдельный запрос пользователя может быть обработан произвольным экземпляром в любой момент времени. А потом мы можем спокойно потушить половину из них, не боясь, что потеряем какое-то не сохранённое состояние, с которым работал наш пользователь. Зная это, мы фактически разрабатываем серверы как системы action-driven. Точкой входа являются обработчики входящих запросов, которые инициируют цепочку действий, в результате которой обработанные нами данные сохраняются в базу, или отправляются в другой сервис, или уходят в какую-то очередь. Это уже детали. Но самое главное, что процесс в общем случае выглядит так:

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

Проще попросить прощения, чем разрешения

Тут хотел бы ещё раз упомянуть Скотта Влашина и его концепцию Railway Oriented Programming. В той же самой книге "Modeling Made Functional" и нескольких своих докладах, которые есть на YouTube, а также на его сайте "F# for Fun and Profit" он очень подробно и доходчиво описывает эту концепцию. Если в двух словах, то мы разрабатываем наши программы по принципу двухполосной железной дороги, где одни рельсы представляют собой успешное выполнение всего конвейера нашей программы, а вторые — представляют собой ошибочный ход выполнения программы. Когда происходит какое-то исключение, мы не выбрасываем ошибку, а передаём её дальше до самого конца, откуда вызывалась наша функция. Это позволяет нам уже на этапе вызова понимать все штатные ошибки, которыми может завершиться функция, и обработать каждую должным образом.

Вообще, концепция выбрасывания исключений или питоновское «проще попросить прощения, чем разрешения» мне не очень импонирует. Как мне кажется, это порождает слишком непрозрачный код, в котором мы часто не видим штатные пути завершения нашей программы. Они лежат в местах выбрасываемых исключений и являются абсолютно непрозрачными. Именно из-за этого в фокусе внимания часто находятся только успешные пути выполнения программы, а ошибки мы периодически правим уже по факту, после их выстрела где-то во время работы приложения. И хорошо, если на этапе написания автотестов или где-то на тестовом стенде в ходе ручного тестирования.

Результат генерации по запросу «проще попросить прощения, чем просить разрешения», стиль: artstation
Результат генерации по запросу «проще попросить прощения, чем просить разрешения», стиль: artstation

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

Но, как мне кажется, Railway Oriented Programming — именно то, что помогло бы Go избавиться от всех этих раздражающих if err != nil, сделав код более выразительным, читаемым и поддерживаемым, если бы создатели языка заранее об этом задумались. Сейчас в Go, как и в Python, использование Railway Oriented Programming затруднительно. На мой взгляд, там он пока что слишком чужеродно выглядит в виду тех синтаксических возможностей и подходов, которые дают нам эти языки. Для красивой и лаконичной реализации ROP нам нужны контейнеры, которые мы сможем буквально автоматически передавать до самого конца исполнения конвейера, без необходимости на каждом шаге делать ручную проверку на наличие ошибки, или, другими словами, схода на вторые рельсы исполнения нашего кода. Вообще, контейнеры - это монады в данном случае, но при слове "монада" почему-то людям часто становится не по себе, поэтому пока можете думать об этом как о специальном контейнере. В Python и Go пока такие вещи смотрятся чужеродно.

При этом стоит отметить, что иногда в языках концепции монад и монадических вычислений умело реализуются абсолютно не заметно для пользователей языка. Для примера, хотелось бы привести Javascript и его оператор optional chaining, который позволяет крайне лаконично обращаться к вложенным свойствам объекта без проверок на ошибки:

const myObject = { someProperty: 1 }

// обращение к несуществующему свойству в JS вернет undefined
const anotherProperty = myObject.anotherProperty // undefined

// обращение к несуществующему вложенному свойству вызовет TypeError
const deepProperty = myObject.anotherProperty.andAnother // TypeError

// обращение к несуществующему вложенному свойству через оператор ?. 
// позоволит завершить цепочку без ошибок
const deepProperty = myObject.anotherProperty?.andAnother // undefined


Но к чему я сделал эти отступления про Railway Oriented Programming? Всё к тому, что именно через призму этого подхода веб-сервер раскрывается именно как система action-driven. Представьте: приходит запрос на ручку вашего API и вы запускаете в обработчике своего веб-сервера конвейер, который на выходе отдаст вам либо результат, либо вернёт информацию о возникшей ошибке. И в этом обработчике вы аккуратно сможете обработать и тот, и другой случай, вернув пользователю нужный ответ. А всё, что делает ваш конвейер, это последовательно передаёт обработанные данные из функции в функцию до получения необходимого результата. Путей исполнения может быть несколько, в зависимости от входящих условий, промежуточных результатов. Но мы всё равно последовательно шаг за шагом передаём наши обрабатываемые данные дальше и дальше по пути исполнения до момента, пока эти данные не покинут экземпляр нашего приложения, которое в конце всё также останется stateless. Мы не возвращаемся назад по этим путям железной дороги, наш путь всегда только вперёд.

Результат генерации по запросу "Railway Oriented Programming", стиль: artstation
Результат генерации по запросу "Railway Oriented Programming", стиль: artstation

Классы не нужны?

Итак, мы поняли, что классы нам по факту не очень нужны. Писать будем функции. А что с данными? Как мы их будем структурировать, как будем придавать им «нужную» форму, которая не развалится по ходу приложения? На примере того же Python? И вот тут стоит сказать, что классы всё-таки нам понадобятся. Но немного другие. Классы данных, или их прокачанные версии в виде Pydantic-моделей. По факту нам не сами классы нужны, а типы. Но в таких языках как Python нам в руки даётся именно этот инструмент для решения этой задачи. Вся прелесть в том, что, объявляя класс данных, мы можем потом использовать его как тип по всему нашему приложению. Набирающий популярность FastAPI широко использует возможности этого подхода. Банальный базовый пример:

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

def get_person_info(person: Person) -> str:
    return f"{person.name} is {person.age} years old"

И это даёт нам широкие возможности по структурированию нашего приложения. Функции больше не нуждаются в жёсткой привязке к самим данным. Вы можете мыслить отдельно о самих сущностях, о данных, которые они содержат, и отдельно о тех операциях, которые вы можете над ними выполнять. А потом вы собираете из этих маленьких функций, как из маленьких кубиков Лего, более большие функции, которые реализуют ваши бизнес-процессы в приложении. У всё того же Скотта Влашина есть замечательные выступления, в которых он очень занимательно и, самое главное, понятно описывает «философию Лего», которая хорошо отражает функциональный взгляд на мир. Ссылку на один из его докладов я оставлю ниже в описании, обязательно посмотрите, если ещё не смотрели.

Вы уже заметили, что я много говорю о функциональном программировании? Да, мне оно очень нравится. Но сегодня отложим в сторону разговоры о чистоте функций и иммутабельности данных. Сейчас наш разговор больше о возможностях композиции функций. И если вы ещё не знали, на старости лет функциональное программирование даже очень нравится тому же дяде Бобу Мартину, написавшему столько книг о том, как писать чистый код на таких языках как Java. Теперь он пишет на Clojure и невероятно счастлив. В статье своего блога под названием "Why Clojure?" он пишет следующее:

What I found, instead, was that the minimal syntax of Clojure is far more conducive to building large systems, than the heavier syntax of Java or C++. In fact, it’s no contest. Building large systems is Clojure is just simpler and easier than in any other language I’ve used.

Вместо этого я обнаружил, что минимальный синтаксис Clojure гораздо больше подходит для создания больших систем, чем более сложный синтаксис Java или C++. На самом деле, здесь нет сомнения. Создание больших систем на Clojure проще и легче, чем на любом другом языке, который я использовал.

А завершает он эту статью следующей фразой:

And the future is looking very functional to me.

И для меня будущее выглядит очень функциональным.

Вообще, рекомендую зайти к нему в блог и почитать подробнее его мысли последних лет на эту тему. В 2023 году дядя Боб продолжает писать в своём блоге именно про Clojure. Хотя я не разделю его энтузиазма по поводу отсутствия там статической типизации, но, тем не менее, его мысли выглядят крайне интересными. Ссылку оставлю в списке материалов в конце статьи.

При этом дядя Боб не противопоставляет друг другу FP и OOP. Он пишет следующее:

The bottom line is:

There is no FP vs OO.

FP and OO work nicely together. Both attributes are desirable as part of modern systems. A system that is built on both OO and FP principles will maximize flexibility, maintainability, testability, simplicity, and robustness. Excluding one in favor of the other can only weaken the structure of a system.

В итоге можно сказать следующее:

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

Функциональное и объектно-ориентированное программирование могут взаимодействовать в системе. Оба подхода желательны в современных системах. Система, построенная на принципах ОО и ФП, обеспечит максимальную гибкость, поддерживаемость, тестируемость, простоту и надёжность. Исключение одного в пользу другого может только ослабить структуру системы.

Результат генерации по запросу "functional programming versus object oriented programming", стиль: anime
Результат генерации по запросу "functional programming versus object oriented programming", стиль: anime

Классы в JavaScript

Если вы внимательно посмотрите на то, о чём мы думали и рассуждали выше, то поймёте, что объекты никуда не делись из нашего кода и системы. Мы продолжаем использовать их, комбинируя с возможностями функционального программирования нашего языка. Как написано в заголовке, мы отказываемся именно от классического Java-ООП, которым нас пичкают достаточно устаревшие учебные материалы и подходы.

Для многих ООП — это Java, но на самом деле это далеко не так. У некоторых начинающих программистов вызывает ступор утверждение, что JavaScript — это ООП-язык с самого начала, просто использующий прототипное наследование, и даже оно лучше классического Java-наследования. Если интересно, есть хорошая статья Эрика Эллиота (Eric Elliott) "Common Misconceptions About Inheritance in JavaScript", в которой он называет появившиеся в JavaScript классы «инвазивным видом». И тут я с ним полностью согласен. JavaScript никогда не нуждался в классах, которые в него добавили в 2015 году. Тем более, что под капотом всё продолжило работать с помощью всё того же прототипного наследования.

Появление классов в JavaScript связано исключительно с обильной миграцией разработчиков из Java и C# в веб, которые смогли-таки продавить эту концепцию в другой язык, который на самом деле никогда в этом не нуждался. Посмотрите хотя бы на эволюцию современного React, который в итоге давно перешёл от реализации компонентов на классах к реализации на функциях. И, по моему мнению, стал от этого намного лучше.

В том, что я написал выше про классы и JavaScript, на самом деле очень много смысла с более общей точки зрения, если хорошо над этим поразмыслить. Именно того смысла, который я озвучил в заголовке этой статьи. Беря в руки новый инструмент, мы должны хорошо изучить его возможности, сильные и слабые стороны, а не пытаться тащить в него то, что там абсолютно не нужно, но что мы просто привыкли делать годами до этого, или чему нас пытаются научить те, кто привык делать что-то с помощью совершенно других инструментов в совершенно другое время.

Встроенные паттерны

Некоторые паттерны уже заранее встроены в ваш язык. К примеру, в Python уже из коробки вы имеете доступ к итераторам, генераторам, декораторам. А некоторые паттерны просто неактуальны в том виде, который описан в книгах и статьях. Вы можете внедрить зависимость, просто передав одну функцию в качестве аргумента для другой функции. А можно пойти ещё чуть дальше, и при помощи партиционирования передать не все аргументы в функцию сразу, а только нужные зависимости, и на выходе получить другую функцию, но уже с заранее «запечёнными» в неё некоторыми аргументами. В том же Python, хоть это и недоступно в виде синтаксических возможностей самого языка, но та же функция partial из стандартного модуля functools даёт эту возможность, чем я часто активно пользуюсь. Базовый пример:

from functools import partial

def add_numbers(x: int, y: int) -> int:
    return x + y

add_five = partial(add_numbers, 5)

print(add_five(3)) # 8

Шаблон синглтона обычно не имеет смысла в Python в чистом виде. Если нам нужен единственный экземпляр объекта в приложении, мы просто создаём экземпляр этого класса в модуле и напрямую используем его по всему приложению через импорт. Да и вообще, есть мнение, что синглтон — анти-паттерн, но это уже другая история. В целом, я ещё раз хотел бы отослать вас к YouTube-каналу ArjanCodes, где есть много полезных и интересных видео о паттернах применительно к Python. Мне даже как-то хотелось самому написать об этом статью или серию статей для Python и JavaScript, чтобы исчерпывающе и подробно разобрать по косточкам все классические паттерны в реалиях этих языков.

Микросервисы

Теперь хотелось бы поговорить о том, что сильно изменилось с тех пор, как были написаны многие классические обучающие материалы. Сейчас индустрия сместилась в сторону микросервисов. Мы пишем их, и они поднимаются десятками экземпляров в наших кластерах и так же быстро там исчезают. Очень многие компании уже не пишут гигантские монолитные системы, которые приходиться развивать и обслуживать совсем по-другому. Ваш идеальный микросервис — небольшой и узкоспециализированный. Всё по философии UNIX. Да, иногда микросервисы разрастаются и становятся далеко не микро. Но всё же они в большинстве своём всё-таки не становятся и гигантскими монолитами. А раз мы часто пишем что-то маленькое и компактное, то нам не стоит это переусложнять из коробки. Если совсем в экстремуме — не нужно из задачи FizzBuzz делать FizzBuzzEnterpriseEdition.

Результат генерации по запросу "Java EnterpriseEdition web-application", стиль: 4k
Результат генерации по запросу "Java EnterpriseEdition web-application", стиль: 4k

Давайте поднимемся дальше по архитектуре и рассмотрим базовую структуру как отправную точку для веб-серверов микросервисов.

app/
├── infrastructure/
│   ├── db.py
│   ├── log.py
│   └── settings.py
├── api/
│   ├── users.py
│   └── posts.py
├── domain/
│   ├── users.py
│   └── posts.py
├── persistence/
│   ├── users.py
│   └── posts.py
└── services/
    ├── users.py
    └── posts.py


Папка "infrastructure". Тут должна жить вся логика, связанная с вашим веб-фреймворком: система журналирования, коннекторы для баз данных, настройки вашего приложения и так далее. В общем, всё, что так или иначе отвечает за инфраструктуру, то есть техническое ядро, на котором строится ваш веб-сервер.

Папка "api". Тут будут существовать ваши обработчики — внешние входные точки приложения, куда будут приходить запросы. Тут будут запускаться конвейеры бизнес-логики — те самые верхнеуровневые функции, которые шаг за шагом должны пройти запущенный бизнес-сценарий. В идеале, согласно Railway Oriented Programming в эту же точку должен привести вас обратно успешный или неуспешный путь выполнения запущенной функции. Именно тут должен быть выбран ответ на запрос, который будет сделан с помощью вашего веб-фреймворка. В этом случае тот, кто придёт изучать ваше приложение, из этой точки сразу увидит достаточно полную картину: какой процесс мы запускаем, как мы отвечаем в случае успеха, какие штатные ошибки мы обрабатываем и как отвечаем на них. К тому же в этом случае логика вашего текущего фреймворка не протекает ниже, внутрь самой бизнес-логики исполнения конвейера.

Папка "domain". Тут должны жить описания ваших сущностей — доменные модели. В случае с Python это могут быть классы данных или pydantic-модели. Ими вы моделируете ваш бизнес-домен. Тут же, с помощью того же pydantic, вы можете определить правила и проверку создания ваших сущностей. На эту тему рекомендую почитать всё тот же "Domain Modeling Made Functional" и серию статей на fsharpforfunandprofit "The Designing with Types series". Просто воспринимайте ваши классы данных или pydantic-модели как аналог типов из F#.

from pydantic import BaseModel, EmailStr 

class User(BaseModel):
    user_name: str
    password: str
    email: EmailStr

Папка "persistence". Тут располагаются функции для работы с вашим хранилищем или хранилищами. Эти функции должны возвращать ваши доменные модели или коллекции доменных моделей. Откуда берутся данные этих функций под капотом, нам абсолютно не важно. Вы можете извлекать их из базы, по HTTP из другого сервиса. Главное, чтобы в бизнес-логику вы возвращали именно вашу доменную сущность.

async def get_users() -> list[User]:
    ...

Вы можете использовать для запросов ОРМ, query-builder или чистый SQL. Я предпочитаю последние два варианта. Подробнее остановлюсь на ОРМ и объясню, почему я её не использую и не очень люблю. Вопросы производительности и непрозрачности ОРМ рассматривать не будем, хотя они тоже есть. В первую очередь, для меня, как разработчика полного цикла, постижение всех подробностей конкретной ОРМ — это не очень рациональная трата времени. Приходится и так знать много разных вещей, вроде CSS, а изучение ОРМ — не очень переносимый навык. Например, SQL везде SQL. На моей практике мне доводилось чаще менять языки программирования, чем базы данных. Со знанием SQL я могу написать сервис на Python, JavaScript и так далее. Сейчас бэкенд я в основном пишу на Python, но вполне допускаю, что в обозримом будущем мне понадобится написать сервер на Go. Знания работы и всех нюансов SQLAlchemy мне там мало помогут. К тому же, я люблю читать текст запросов на чистом SQL, особенно если они чуть сложнее простого select. SQL очень выразительный и мощный язык запросов. Я бы всегда предпочёл читать его, а не какую-то дополнительную надстройку. К тому же могут быть ситуации, когда вы вообще не сможете использовать ОРМ. Так было до того, как ОРМ в Python научились работать с асинхронным кодом. А асинхронные сервера уже во всю писались и использовались в эксплуатации. Но если вам нравится ОРМ, можете спокойно ею пользоваться даже в рамках такого подхода к организации доступа к хранилищам. Я просто дал вам несколько идей для размышления.

Папка "services". Тут будут лежать все ваши функции, вся ваша бизнес-логика. Много маленьких функций, объединённых в конвейеры более крупных функций, которые вы будете собирать, как кубики Лего. Разложенные по модулям и пакетам. Возможно вы уже подумали о вопросах инкапсуляции и о том, что детали реализации должны быть скрыты. A пользователю можно применять лишь то, что ему можно применять. Просто вспомните о том, что в Python мы всегда жили по принципу «все мы взрослые люди». Называйте свои функции с классическим нижним подчеркиванием, если вы хотите показать, что нельзя их использовать отдельно от общего конвейера, в который они встроены. В конце концов, в Python желающие получить доступ к чему-то его получат, если очень захотят. Используйте протоколы для работы с вашими функциями. Посмотрите на возможности Overload. Изучите, как с помощью Callable вы можете определять сигнатуру ваших входящих параметров, которые вы ожидаете получить в качестве зависимостей. В общем, активно изучайте те возможности системы типов, которыми теперь обладает Python, чтобы писать код в таком стиле. Иногда, конечно, хочется ещё больше функциональных возможностей для ещё более выразительной работы с кодом. Недавно я заинтересовался библиотекой returns; пока не работал с ней в эксплуатации, но выглядит она интересно.

Результат генерации по запросу «микросервисы, микросервисы везде», стиль: artstation
Результат генерации по запросу «микросервисы, микросервисы везде», стиль: artstation

Ваш код может быть изначально проще, и вы не должны этого бояться, особенно в микросервисном мире. Сколько небольших микросервисов на пару тысяч строк кода работает в наших кластерах! Помните о KISS и не бойтесь не писать кровавый энтерпрайз-код, судорожно выискивая, какой паттерн и классический ООП-подход вы тут должны обязательно применить, где должна быть ваша очередная фабрика фабрик. Вообще, до определённого момента я думал, что направление, которому больше всего подходит что-то похожее на классическую ООП, это разработка игр. Много долгоживущих объектов в памяти, отражающих объекты игрового мира, с постоянно меняющимся состоянием. Но потом я увидел выступление Джона Кармака "John Carmack's keynote at Quakecon" и его рассуждения о том, как функциональные подходы к написанию кода, в том числе для игр, стали для него полезным открытием. Как он почерпнул многое для себя, изучая такие языки, как Haskell и Lisp, и как это повлияло на него при написании кода на C++.

В заключение

Я постарался показать, как мы привыкли писать код и как можно это делать по-другому. Особенно это важно, когда ты начинающий разработчик и впадаешь во фрустрацию от тех моментов, которые кажутся тебе нелогичными или неактуальными. Прочитав несколько толстых и умных книг, которые были написаны очень умными и авторитетными людьми, сложно представить себе, что написанное ими далеко не всегда верно. Что-то могло быть неверно изначально, как сама концепция наследования, о чём через много лет сам автор языка скажет в одном из интервью. Что-то может просто устареть и стать неактуальным с течением времени уже не потому, что тогда это было неверным подходом, а потому, что мы не стоим на месте, как не стоит на месте индустрия программирования.

Всегда стоит помнить о том, что большинство концепций написания правильного кода, описанных в таких знаковых книгах как «Чистая архитектура», «Чистый код», «Совершенный код», «Шаблоны корпоративных приложений» и так далее, всё это — личный опыт конкретных людей, пусть очень умных и опытных. Но, по большей части, это не научно доказанные истины, а, скорее, эмпирически полученные знания, которые они в какой-то момент своей жизни решили изложить на бумаге для всех остальных.

Тот же дядя Боб Мартин, написавший столько культовых вещей о том, как нужно писать код. Сейчас, уже в конце своей карьеры, он достаточно радикально пересмотрел многие свои взгляды. Теперь он пишет на Clojure и старательно переосмысливает весь свой прошлый опыт, находя многое написанное им же избыточно сложным. И на самом деле это абсолютно нормально. Более того, он работает над новой книгой о функциональном программировании с примерами на Clojure. О чем писал в своем Twitter:

Meanwhile I am writing a book on the pragmatics of functional programming. No monads. No monoids. No category theory. Just code and design principles; and comparative analyses.

Examples in Clojure, of course.

Тем временем я пишу книгу о прагматике функционального программирования. Без монад. Без моноидов. Без теории категорий. Просто код и принципы дизайна; и сравнительный анализ.

Естественно, с примерами на языке Clojure.

Когда вы будете читать очередную толстую книгу, статью, или даже смотреть выступление на конференции, не бойтесь быть в чём-то несогласными или сомневающимися. Это значит, в первую очередь, что вы не пытаетесь слепо впитывать всё подряд, что вам приносит океан окружающей информации. Вы пытаетесь анализировать и обдумывать получаемые знания. В таких ситуациях посмотрите по сторонам, найдите другие мнения по вызывающему у вас сомнения вопросу. И скорее всего, вы найдёте множество других мнений, которые могут быть практически полностью противоположными тому, которое вы изначально услышали. Кто-то описывает в своём труде паттерн, а кто-то уже через некоторое время называет это анти-паттерном и подробно объясняет, почему он так думает. Взгляните хотя бы на Алана Кея и на то, как он стал фактически родоначальником объектно-ориентированного программирования. ООП изначально не является строго доказанной научной теорией, как правильно писать код. Нет, это по большей части плод опыта и эмпирических поисков. И посмотрите на то, сколько было потом попыток и реализаций сделать то самое правильное ООП. И ни одна из них не стала единственной, эталонной научной реализацией ООП, которую признали бы все. Задумайтесь, почему наши взгляды на ООП во многом такие, какими их нам показала Java. В своё время Java стала прорывной технологией не потому, что заложенные в ней концепции кода стали эталонными и захватили тем самым умы разработчиков по всему миру. Нет. Славу и распространение принесла JVM, благодаря которой была во многом решена боль кроссплатформенной совместимости. А концепции, заложенные в Java, просто были приняты всеми по факту такими, какими они есть.

Ещё хотел бы посоветовать вам посмотреть выступление Мартина Фаулера "Software Design in the 21st Century". В нём есть очень интересный момент, когда Мартин рассказывает, что одним из технических рецензентов его книги «Рефакторинг» был всё тот же дядя Боб, и как он яростно разносил код, который написал Мартин, со словами «Ты никогда не должен называть это тут так!». Этот момент я запомнил как очень яркий пример той простой истины, что даже самые опытные и именитые разработчики могут смотреть на написание одного и того же кода совершенно по-разному. Главное, чтобы вы всегда в итоге находили тот подход, который станет золотой серединой и соглашением в вашей команде.

Результат генерации по запросу "Карл Саган. Бесконечная вселенная над нашей головой", стиль: artstation
Результат генерации по запросу «Карл Саган. Бесконечная вселенная над нашей головой», стиль: artstation

Закончить статью мне бы хотелось словами Карла Сагана, одного из моих самых любимых учёных и популяризаторов науки:

In the impetuous youth of humanity, we can make grave errors that can stunt our growth for a long time. This we will do if we say we have the answers now, so young and ignorant as we are. If we suppress all discussion, all criticism, proclaiming “This is the answer, my friends; man is saved!” we will doom humanity for a long time to the chains of authority, confined to the limits of our present imagination. It has been done so many times before.

It is our responsibility as scientists, knowing the great progress which comes from a satisfactory philosophy of ignorance, the great progress which is the fruit of freedom of thought, to proclaim the value of this freedom; to teach how doubt is not to be feared but welcomed and discussed; and to demand this freedom as our duty to all coming generations.

В бурной молодости человечества мы можем совершить серьёзные ошибки, которые могут надолго затормозить наш рост. Это произойдёт, если мы, такие молодые и невежественные, скажем, что у нас уже есть ответы. Если мы подавим все дискуссии, всю критику, провозгласив: "Вот ответ, друзья мои; человек спасён!", мы надолго обречём человечество на цепи авторитета, ограниченные рамками нашего нынешнего воображения. Так уже было много раз.

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

Ссылки

Tags:
Hubs:
Total votes 46: ↑41 and ↓5+41
Comments55

Articles

Information

Website
domclick.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия
Representative
Dangorche