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

Результат генерации по запросу «Классическое объектно-ориентированное программирование», стиль: 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):
        ...


И вот теперь нам уже намного проще. Мы знаем, что метод Post работает с экземпляром класса 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

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

Итак, мы поняли, что классы нам по факту не очень нужны. Писать будем функции. А что с данными? Как мы их будем структурировать, как будем придавать им «нужную» форму, которая не развалится по ходу приложения? На примере того же 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

Классы в 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

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

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

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

© Habrahabr.ru