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

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

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

Ссылки

Комментарии (40)


  1. outlingo
    16.05.2023 08:54
    +4

    def get_unread_comments(pubication: Publicaion) -> list[Comment]

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


    1. CodeShaman Автор
      16.05.2023 08:54
      +2

      А можно, немного более полно раскрыть комментарий? Пока не очень понятно, о каком именно захаркоженном глобальном контексте идет речь и почему написание юнит-тестов превращается в боль.

      Если мы говорим о написании любого классического юнит-теста, то, тестируя функцию, нам в любом случае придется подготовить входные данные согласно сигнатуре этой функции. В данном случае нам нужно инициализировать экземпляр объекта Publication и передать его в get_unread_comments.

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


      1. outlingo
        16.05.2023 08:54

        Вот смотрите - предположим, вы используете ORM.

        Значит, прежде чем протестировать эту функцию, вам нужно:

        1. Настроить маппинги в ORM

        2. Создать базу данных

        3. Заполнить базу тестовыми данными

        4. Делать это для каждого теста

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


        1. CodeShaman Автор
          16.05.2023 08:54
          +3

          Не совсем понятно, для чего именно здесь нужна ORM. В данном случае вы создаете объект Publication и передаете его в get_unread_comments. Объект Publication не связан с ORM. Вам не обязательно его брать из базы. Вы можете создать его в коде теста перед вызовом функции.

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

          Допустим, в рамках деплоя через CI/CD на этапе тестирования вы можете просто поднять рядом в докер-контейнере экземпляр используемой вами базы. Да, для такого тестирования у вас должен быть механизм подготовки базы перед запуском теста, механизм отката базы в дефолтное состояние после завершения теста. Обычно сам механизм вы делаете и настраиваете один раз при начале работы с интеграционными тестами. А дальше дополняете или файл базовых миграций для подготовки вашего дефолтного экземпляра базы или как-то в самом интеграционном тесте, если вам нужно создать в базе что-то специфичное для конкретного случая.

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


      1. outlingo
        16.05.2023 08:54

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


        1. Coriolis
          16.05.2023 08:54
          +1

          Ну вы статью прочтите хоть. Там в разделе "Микросервисы" приведена структура приложения. Только в модуле persistence содержатся "функции которые ходят в базу", (от себя добавлю - их тестирование тривиально т.к. это паттерн "репозиторий" - просто вытащить и передать в нужной структуре наверх).


          1. outlingo
            16.05.2023 08:54

            Само наличие модуля persistence и есть проблема. Вот смотрите - вам нужно оттестировать функцию get_users_list. Какие есть варианты?

            Первый - создать тестовую базу, сконфигурировать ваш модуль так чтобы он на неё смотрел и прогнать тест. Минусы - а если база это «чуть больше чем sql” - например какая нибудь nosql, то вам нужно для теста сконфигурировать еще и сервис БД.

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

            А задача сделать несколько бакэндов, например sql и nosql превращается либо в пляски с бубном вокруг динамической загрузки модулей в рантайме, либо в натягивание статиков поверх синглетона реального бакэнда


            1. CodeShaman Автор
              16.05.2023 08:54

              Не очень понятно выходит тогда, а как предлагается решать такую проблему? Кажется, что наличие или отсутствие модуля persistence не убирает и не добавляет фундаментальную проблему наличия работы с источниками. И их интеграционного тестирования.


            1. 0xd34df00d
              16.05.2023 08:54

              Само наличие модуля persistence и есть проблема. Вот смотрите — вам нужно оттестировать функцию get_users_list. Какие есть варианты?

              Если вы о части модуля persistence, то её не нужно тестировать. В ней нет нетривиальной бизнес-логики, зачем там тесты?


              превращается либо в пляски с бубном вокруг динамической загрузки модулей в рантайме, либо в натягивание статиков поверх синглетона реального бакэнда

              Зачем?


              1. outlingo
                16.05.2023 08:54
                -1

                Если вы о части модуля persistence, то её не нужно тестировать. В ней нет нетривиальной бизнес-логики, зачем там тесты?

                У вас есть некий метод, который возвращает что-то.

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

                И стек вызова этого кода заходит в ваш модуль persistence.

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

                Это не обсуждается, ибо это просто базовое требование.

                А теперь просто говорим "наш продукт работает с базой в MSSQL и MongoDB". И всё - теперь вы для юнит-тестов должны притащить MSSQL и монгу. Можно конечно сказать "ой да мы это интеграционными тестами проверим" - но как правило каждый раз когда такое говорится это всё уходит в вечный техдолг.

                Зачем?

                Зачем что?

                Зачем натягивать функции поверх глобальных объектов?

                Ну затем, что у вас нет контекста приложения в явном виде.

                Где лежит ваша сессия к базе, чтобы её можно было проверить и при необходимости реконнектнуть? Ну конечно, в persistence._SESSION или чем-то подобном. Где лежат маппинги классов к таблицам? Ой да это мы вообще, скопировали пример из документации (но на самом деле тоже в какой-то глобальной переменной). И вот ваш код зависит от кучи глобальных объектов и набит сайдэффектами.

                Зачем натягивать поверх синглетонов?

                Ну вот вы написали "тривиальных функций" в persistence. А теперь поступило требование заказчика - не SQL'ный бакэнд (вот у него такая техническая политика, у него всё в тарантуле лежит). И теперь у вас две реализации persistence, одна через ORM а другая через API (ибо нет тарантула в вашем ORM). И как вы будете выбирать бакэнд?

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

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

                Вполне типовые грабли.


                1. 0xd34df00d
                  16.05.2023 08:54
                  +3

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

                  Кому должен?


                  Вот метод делает просто какой-то SELECT из БД. Зачем мне его тестировать? Его тесты — это по факту тесты библиотеки для работы с БД. Такие тесты не нужны.


                  юнит-теста и доказательства работы вашего кода

                  Рассказал бы я про доказательства, но не в посте про TS/Python/etc.


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

                  Ну хоть для приличия бы property-based-тесты бы использовали вместо вот этого карго-культа с юнит-тестами, которые якобы что-то там доказывают.


                  Это не обсуждается, ибо это просто базовое требование.

                  Программирование — это не религия.


                  Можно конечно сказать "ой да мы это интеграционными тестами проверим" — но как правило каждый раз когда такое говорится это всё уходит в вечный техдолг.

                  В чём техдолг?


                  Тесты на функцию


                  string myReadFile(string filename) {
                    return System.ReadFile(filename);
                  }

                  вы тоже писать будете?


                  Где лежит ваша сессия к базе, чтобы её можно было проверить и при необходимости реконнектнуть? Ну конечно, в persistence._SESSION или чем-то подобном.

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


                  data DbInterface m = DbInterface
                    { getUsers :: CompanyId -> m User
                    , addUser :: User -> m UserId
                    , ...
                    }

                  который может реализовываться хоть MySQL, хоть монгой, хоть хешмапой в памяти. Реконнект внутри, пусть getUsers/addUser за это отвечает — слой с логикой работы с пользователями всё равно не должен отвечать за логику реконнекта, и язык должен вас пинать за кривой дизайн, если вы вдруг начали это смешивать.


                  Где лежат маппинги классов к таблицам?

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


                  И как вы будете выбирать бакэнд?

                  В рантайме передам разные значения типа DbInterface.


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

                  Слишком сложно для хаскелистов, извините. Тяжело всё как-то в этом вашем питоне, сложный язык, непонятный. Репозитории какие-то, юниты.


  1. jdev
    16.05.2023 08:54
    +1

    Фантастика. В хорошем смысле слова.

    У меня есть пара практических вопросов.

    Я от ROP-а в чисто функциональном стиле отказался как раз из-за синтаксического мусора, который он генеряет в Kotlin. Вы вроде сказали, что в Python те же проблемы, но не рассказали как это в итоге делаете. Расскажете?

    Затем, функции в persistance. Они всё-таки по природе своей stateful (имеют источник подключения к БД условной). Вы их в итоге к глобальному окружению приколачиваете? Или через частичное применение в рантайме собираете? Или ещё как-то? Так же буду благодарен за подробности.

    И вообще, если можете скинуть ссылку на код в этом стиле, который ходит в базу, ходит по хттп и чё-нить в очередь публикует (желательно одновременно) - изучу с большим интересом:)

    А за State-driven и action-driven - отдельное спасибо. Я пару лет думал на эту тему в фоне и ни как не мог это лаконично сформулировать.


    1. jdev
      16.05.2023 08:54

      А если в рантайме собираете - чем это отличается от объекта?:) Я опять же отошёл от чистой функциональности и утешаю себя тем, что конструктор(p1, p2) + метод(p3) = функции(p1, p2, p3) :)


      1. CodeShaman Автор
        16.05.2023 08:54

        Вот тут немного не понял вопрос. :) Если можно, как-то развернуть его подробнее.


    1. CodeShaman Автор
      16.05.2023 08:54
      +2

      По поводу ROP в Python, к сожалению, хорошего ответа пока нет. Говоря о ROP, больше хотелось показать само наличие концепта и идей, в него заложенных, как пищу для размышлений и расширения кругозора. Сейчас очень интересует попробовать на практике возможности https://returns.readthedocs.io/en/latest/pages/railway.html# , но пока есть некоторые сомнения в лаконичности и естественности такого кода в Python. Это еще предстоит посмотреть и оценить, само крайне интересно, что получится. Вариант делать по Go-шному и всегда из функций возвращать значение и ошибку возможен, но в целом тоже не то, чтобы очень хорошо выглядел в Python. Пока как базовую хорошую практику взято за правило не использовать инфраструктуру фреймворка внутри бизнес-логики для работы с ошибками. То есть, после выброса ошибки, она должна быть поймана в именно хенделере, который вызвал конвеер, и выбор ответа должен произойти там же. Чтобы все заранее запланированные пути завершения работы были видны из точки вызова. Конечно, расстраивает то, что такая штука держится исключительно на конвенциях и дисциплине разработчиков. Ведь в случае с F# и ROP разработчика ударит по рукам компилятор, если какой-то из путей штатного завершения программы не был обработан. Тут мы, к сожалению, просто снова возвращаемся к фундаментальным проблемам работы с бизнес-логикой в языках, где нормальным является выброс исключений, и их отлов уже где-то на верхних уровнях в коде.

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

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


      1. jdev
        16.05.2023 08:54

        То есть, после выброса ошибки, она должна быть поймана в именно
        хенделере, который вызвал конвеер, и выбор ответа должен произойти там
        же.

        Угу, я так же извернулся.

        По поводу Persistence, если я правильно понял вопрос

        Кажется не правильно поняли:)

        Вот эта функция:

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

        Я так понял - это статическое определение именно функции, а не переменной функционального типа. Соответственно подключение к внешнеей системе (БД, например) она берёт из глобальной переменной.

        Отсюда я вижу два следствия:

        1. При её использовании, надо догадаться что эту глобальную переменную надо про инициализировать

        2. Нельзя иметь в программе два источника данных, которые будут брать юзеров из разных источников.

        Или у вас всё-таки что-то в таком духе:

        fun getUsers(ds: DataSource): List<User> = { ds.fetchUsers() }
        
        val db1Users = getUsers(DataSource(db1))
        val db2Users = getUsers(DataSource(db2))
        
        class Client(private val getUsers: () -> List<User>) {
          fun showUsers() {
            getUsers().forEach { println(it) }
          }
        }
        
        fun main() {
          val client = Client(db1Users)
          client.showUsers()
        }

        Ну т.е. как вы инжектите инфраструктуру в такие функции?

        Касательно второго, комментария, я утверждаю (самовнушаю ), что следующая запись является эквивалентом предыдущей:

        class Users(private val ds: DataSource) {}
            fun getUsers(): List<User> = ds.fetchUsers()
        }
        
        class Client(private val users: Useres)) {
          fun showUsers() {
            users.getUsers().forEach { println(it) }
          }
        }
        
        fun main() {
          val client = Client(Users(DataSource(db1)))
          client.showUsers()
        }

        То есть конструктор выполняет функцию частичного применения своих параметров к методам объекта. Забыл уже терминологию


        1. CodeShaman Автор
          16.05.2023 08:54
          +1

          С подключением из глобально инициализированного пула внутри get_users в большинстве случаев нет проблем по моему опыту. Инициализировать переменную окружения мы точно не забудем о чем нам скажет завалившийся деплой. Модуль settings просто не найдет инициализированную переменную и старт приложения завершится с ошибкой.

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

          Если источники очень разного характера, к примеру SQL и HTTP, то получение данных из них в любом случае лучше разнести по разным функциям. Если одна и та же сущность в одинаковом виде лежит в разных базах для одного и того же приложения, то это скорее повод подумать, все ли в порядке с хранением данных. Обычно данные об одной и той же сущности лежат в разных источниках, если это все же данные разного характера. В случае с Post мы можем получать данные о просмотрах из какого-нибудь кликстрима, вроде Кликхауса. В общем случае работа с разными источниками будет реализована через разные интерфейсы на уровне непосредственного получения данных. Ведь, собственно, за абстрагирование работы с этими разными интерфейсами и отвечает слой persistence. В него мы получаем что-то из слоя infrastructure, вроде коннекта к конкретной базе или HTTP сессию, а уже функция внутри реализует всю работу по извлечению данных через АПИ этого источника и оборачивание в доменную сущность, чтоб вернуть в ее бизнес логику.

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


          1. jdev
            16.05.2023 08:54
            +1

            Понял, спасибо.


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


  1. buldo
    16.05.2023 08:54
    +2

    И в конце структура папочек, которую предполагает стандартный шаблон asp.net core.

    Вообще статья странная. Я думал, что обычно с такого подхода и начинают. То есть сначала фигачим всё в процедурном стиле, потом разработчик узнает о паттернах и DDD и жизнь начинает играть новыми красками и фабриками, после этого приходит осознание, что не везде оно надо.

    То есть статья-то правильная и хорошая, но когда уже лет 5 пишешь в таком стиле, странно читать это как "смотрите, свежий взгляд на то, как можно писать микросервисы"

    Я, конечно со стороны C# говорю. Может питон как-то иными путями развивается.


    1. CodeShaman Автор
      16.05.2023 08:54
      +1

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


  1. dopusteam
    16.05.2023 08:54

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

    А что за метод Post? Я что то упустил немного

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

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


    1. CodeShaman Автор
      16.05.2023 08:54

      Да, благодарю за замечание, поправил. Конечно же по тексту верно:

      Мы знаем, что метод get_unread_comments работает с экземпляром класса Post

      По поводу:

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

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


      1. buldo
        16.05.2023 08:54

        Но ведь в любом случае get_unread_comments(post) для работы нужны какие-то данные типа строк подключения, конфигурации и тп. Этот метод их будет неявно брать из какого-то общего глобального контекста?


        1. CodeShaman Автор
          16.05.2023 08:54

          В данном случае нет. Объект Post содержит в одном из своих свойств массив комментариев. Функция возвращает нам массив не прочитанных комментариев. Конкретно эта функция является чистой и не имеет побочных эффектов и ее выходные данные зависят только от входных. Передавая один и тот же объект Post на вход мы всегда будем получать один и тот же результат на выход.


          1. buldo
            16.05.2023 08:54

            А в питоне нет функционала методов-расширений или default interface methods?


            1. CodeShaman Автор
              16.05.2023 08:54

              Так как я не работал с C#, то могу судить только из быстрого ознакомления с предметом по описанию.

              Если я все правильно понял, то default interface methods можно примерно похоже реализовать с помощью модуля abc https://docs.python.org/3/library/abc.html

              По поводу методов-расширений первое, что приходит на ум - это monkey patching.


  1. dph
    16.05.2023 08:54

    Не совсем понятно, а где тут отказ от ООП? Если у микросервиса есть персистанс - то он уже является объектом с точки зрения ООП (в изначальном смысле, объект - как стейт+список обрабатываемых событий, где стейт - это персистанс, а события - это entrypoints). Вот если от предлагаемой структуры микросервиса переходить к стилю Pipes&Filters - можно говорить об отказе от ОПП.
    Да и в предложенной структуре кода вместо класса просто используется namespace как объединение данных и методов, особой разницы (кроме сложностей в использовании) нет.
    Так что в чем смысл предложенного метода - не совсем понятно.


    1. CodeShaman Автор
      16.05.2023 08:54

      Отказа от ООП как такового нет, все верно. И об этом даже есть не большая цитата от Роберта Мартина на тему того, что ООП и ФП могут и даже должны хорошо уживаться в современных системах. Отказ от так называемого классического ООП, которое по большей части получило широкое распространение благодаря Java. Это самое ООП может быть очень разным, о чем тоже есть пример в статье - JavaScript.

      Никакого объединения данных и методов с помощью неймспейсов не декларируется. Как раз наоборот, данные существуют отдельно. То, что их обрабатывает существует отдельно. И фундаментально проблемы в этом нет, опять же о чем есть пара слов в статье. В ФП языках в принципе не стоит этот вопрос об объединении данных и логики в одну корзину. Что может работать с чем определяется сигнатурами функций и интерфейсами.


      1. dph
        16.05.2023 08:54

        Ну, так или иначе все равно нужна группировка разных функций, иначе "найти" нужное (тем более обеспечивать автоматический intellisense и прочие фишки) будет сложно.
        Классы+методы нужны, кроме прочего, и как метод структурирования кода - и тут никаких вариантов не предлагается, просто "группировка функций в неймспейсах" - недостаточная замена.
        А как только начнется группировка, то выяснится, что функции работы с, например, постами нужны вместе, вместе с необходимыми dataclass, и в неймспейсе вида Post, что приведет к модульному коду в духе старого Pascal, из которого Java и выросла. Ну, длинный путь чтобы дойти до ООП.
        Но для небольших проектов - да, вполне нормальный подход. Если еще и с ООА сочетать.


        1. CodeShaman Автор
          16.05.2023 08:54

          Все таки, мне кажется что основная задача наличия класса - это инкапсулирование работы с объектом по изначальной задумке. Найти функцию в модуле и найти метод в классе в целом задачи +- одинаковые по сложности. Сначала нужно найти нужный модуль или класс, потом найти в нем нужное поведение. Ну и доступ к функциям в модуле через IDE можно упростить сделав import functools и потом IDE выдаст через точку все содержимое модуля полностью аналогично с содержимым экземпляра класса. Можно даже написать алиас, если хочется меньше в коде писать символов import functools as ft. Тут главное, чтоб конвенция была для алиасов модулей. Впрочем, как и нужна конвенция для именования переменных и так далее.

          выяснится, что функции работы с, например, постами нужны вместе, вместе с необходимыми dataclass, и в неймспейсе вида Post

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

          Почему такая замена удобна на мой взгляд (группировка функций на уровне модулей, а не методов в классах) - так это обычно менее связанный и проще модифицируемый код. Такой подход к написанию сам тебя подталкивает к тому, чтобы делать меньше не явных преобразований и других сайд-эффектов в поведении. Ведь доступ ко всему поведению внутри метода через self открывает большое окно возможностей делать всякие дополнительные действия не явно внутри методов. С другой стороны функция, которая хочет использовать какое-то поведение явно декларирует это своей зависимостью в сигнатуре. То есть код получается более прозрачный для изучающего, как с ним работать. Опять же, чистые функции. Мы получаем на вход все наши данные и зависимости через аргументы и выдаем данные на выход.


  1. ilitaiksperta
    16.05.2023 08:54
    +1

    Да классическое ОПП вообще жеско переоцененная идея. Наследование — тут вообще всего 2 ходовых кейса, UI фреймворки и геймдев. Модификаторы доступа — их можно просто выкинуть, и жопа не отвалится. Интерфейсы с виртуальными функциями — полезная штука, но их в 80% случаев используют для оверинжиниринга.


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


    Чистая архитектура, паттерны проектирования, примеры реализации принципов SOLID, дядя Боб

    По традиции, не могу не кинуть козюлю в человека, который не считает вышеперечисленное вредным мусором


    Ну и добро пожаловать в эру косожопых картинок.

    Вам самим не стремно такое в статью сувать?


    image


    1. CodeShaman Автор
      16.05.2023 08:54

      На самом деле эта картинка намеренно такая "косожопая" вставлена, ведь она в тексте рядом с FizzBuzzEnterpriseEdition. Что как бы намекает, что такой подход порождает что-то подобное на этой картинке :)


      1. ilitaiksperta
        16.05.2023 08:54
        +1

        Я бы в это поверил, если бы остальные картинки были нормальными :)


  1. DmitryKoterov
    16.05.2023 08:54
    +1

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

    Классический пример тут - на клиентской стороны, Apollo Client. Он возвращает GraphQL-объекты как POJO и не дает навешивать на них методы. В итоге получается, что когда хочется добавить общую логику проверки на такие объекты (например, isEditable), то приходится, во-первых, называть эти функции postIsEditable (а не просто isEditable - иначе поиск по коду превращается в кошмар), а во-вторых, они начинают плодиться в разных местах с немного разным смыслом, как кролики (а кто-то начинает напрямую лезть в свойства объекта и проверять, не зная, что есть функция для этого).


    1. CodeShaman Автор
      16.05.2023 08:54

      Тут коротко просто отвечу той же мыслью, что высказана в статье. В Питоне нет по настоящему ничего приватного. "Все мы здесь взрослые, отвественые люди". Если кто-то захочет куролесить, изменяя объект так, как этого делать не следует, он всегда сможет это сделать. Такие вещи только код-ревью решаются, так или иначе. Ну и дисциплиной и ответственностью самих разработчиков, опять же так задуман язык его создателем. Тут оно, как есть :)


      1. outlingo
        16.05.2023 08:54

        В Питоне нет по настоящему ничего приватного

        Но есть name mangling через префикс двойного подчеркивания, типа __myattr


        1. CodeShaman Автор
          16.05.2023 08:54

          Да, но это больше конвенция, чем реальная преграда. Для класса MyClass атрибут __myattr будет напрямую доступен просто через _MyClass__myattr. Это не говоря о всех тех непотребствах, которые можно натворить прямо в рантайме с помощью того же манки патчинга :)


          1. outlingo
            16.05.2023 08:54

            Ну да, а еще можно добраться до локальных переменных вызывающего кода через stack frame, и вообще даже сдуру известно что сломать. Но это надо делать специально, и тут уж ССЗБ уровня "а еще я могу используя указатель на объект по смещению прочесть VMT и напрямую вызвать методы"


            1. CodeShaman Автор
              16.05.2023 08:54

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


  1. manyakRus
    16.05.2023 08:54

    Всё правильно написал автор :-)

    Я изучал Java целый год, понял что это всё хрень,
    и перешёл на golang :-) с ограниченным ООП