Как стать автором
Обновить

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

Время на прочтение24 мин
Количество просмотров18K
Всего голосов 51: ↑46 и ↓5+41
Комментарии55

Комментарии 55

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

НЛО прилетело и опубликовало эту надпись здесь

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

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

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

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

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

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

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

Зачем?

Зачем что?

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

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

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

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

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

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

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

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

НЛО прилетело и опубликовало эту надпись здесь

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

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

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

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

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

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

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

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

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

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

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

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

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

По поводу 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()
}

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

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

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

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

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

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


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

Боль слоя persistence и БД начинается с транзакциями, когда, например, 2 метода репозитория должны быть выполнены в транзакции, а третий опционально, а еще хуже, когда, при определенной бизнес логике, нужно выполнить rollback.

Подскажите пожалуйста, как с этим работаете ?

всегда считаете, что, грубо говоря, 1 http запрос есть атомарная операция и безусловно делаете commit (или rollback) ? или сессия проталкивается по всем слоям ?

В рамках работы нескольких последовательных операций с sql источником на самом деле все не так сложно. Несколько последовательных вызовов к базе группируются и работают с одним и тем же курсором в одной транзакции, если нужно обеспечить механизм rollback. Принимают его в качестве опционального параметра.

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

Есть варианты, к примеру, если мы работаем с серией вызовов к источнику sql и нужно сделать какой-то запрос по HTTP и в случае не успеха откатить все. Тогда все операции как раз можно обернуть в контекстный менеджер работы с базой и если HTTP часть завершится с ошибкой, то можно просто исключением прервать транзакцию и выйти из контекста инициировав в ней rollback. Соотвественно, если перед этим была какая-то логика отката по HTTP, то сначала выполняем ее.

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

Опять же, если стараться выдавливать операции I/O к границам, то для бизнес логики это все может оставаться так же абстрагированно.

В рамках работы нескольких последовательных операций с sql источником на самом деле все не так сложно. Несколько последовательных вызовов к базе группируются и работают с одним и тем же курсором в одной транзакции, если нужно обеспечить механизм rollback. Принимают его в качестве опционального параметра.

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

P.S. не придираюсь ни в коем случае, тема мне очень интересна. За сам пост большое спасибо, +- к этому и пришли.

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

Если мы говорим про последовательность шагов в рамках транзакции, то тут встает вопрос, а чего в ней больше - бизнес логики или инфраструктуры? По идее последовательность шагов в транзакции соответствует бизнес процессу. Что за чем сохраняем и как откатываем. Но в то же время мы используем низкоуровневую инфраструктуру для проведения транзакции. Тот же курсор. Так, что вероятнее лучше держать такие вещи на слое persistance, не перетягивая их в слой сервисов. Все-таки, хоть там и есть минимальная логика, но она только про работу с источниками и механизмами их отката. То есть по большей части связана с хранением данных, чем этот слой и занимается. Имя в таком случае лучше подбирать более сфокусировано на общем процессе :) update_post_and_add_comment_and_update_user_and_notify вполне может быть просто названа той причиной, почему эти действия объедены в одну транзакцию. К примеру handle_post_activity или process_post_interaction. Это так, навскидку. Для этого нужно в проекте выработать свою удобную всем конвенцию наименования. Собственно, я не называю этот слой именно репозиторием по двум причинам: во-первых, само определение, как должен быть реализован настоящий репозиторий часто вызывает некоторые холиварные споры, а во-вторых как раз по тому, что там может быть такая транзакционная работа.

Вообще, в Go можно уйти от возврата ошибок в сторону того же отправления всех ошибок в специальный канал.

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

Open source нет, ведь код пишется на работе

Чот прям грустно стало...

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

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

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

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

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

Да, по поводу папочек нелья не отметить алтернативный подход, когда у нас есть папка user и там уже в ней все про это - и инфраструктура, и персистентносиь и сервисы, и апи. А вообще в идеале у каждого файла кода могли бы быть теги. Тогда можно будет и по слоям группировать, и по сущности. В этом плане редакторы кода пока плохо развиваются. Жаль идея Билла Гейтса загнать всю файловую систему в бд не взлетела.

Да, делаю так иногда. Одна фича - одна папка. Особенно удобно для фич со "сроком годности". Единственное - пока не понял, что делать с кросс-функционалом, типа кода всяких мапперов, которые используются несколькими фичами

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

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

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

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

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

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

По поводу:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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


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

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


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

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


image

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

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

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

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

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

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

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

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

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

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

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

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

Интересно. В ответ хочу порекомендовать недавно вышедшую книгу "Паттерны разработки на Python". В ней рассказывается о том когда и как писать классы, чтобы получить как можно больше пользы от ООП.

Миллениалы изобрели процедурное программирование со всеми его недостатками.

Даже первый вопрос — «Зачем нам вообще нужны классы?» — неправильный, потому что разговор про ООП нужно начинать не с классов, а с интерфейсов. И, как ни странно, применительно к Питону, на этом же можно и заканчивать, потому что интерфейсов в чистом виде там нет, а без них ООП теряет значительную часть своей полезности. Поэтому разработчикам и остаётся писать конструкции c протоколами.
По сути, такой код:
from typing import Protocol

class ProtocolType(Protocol):
// ...
def my_func(x: ProtocolType):
это кривоватый способ написать:interface SomeType {

function my_func() {
в других языках. И не надо пытаться сделать вид, что такая реализация ООП — это какое-то достоинство Питона.

мы должны понять, как же организовать наши функции. Ответ в данном случае довольно прост: использовать пространства имён. Создавайте модули, в которых вы структурируете ваши функции согласно архитектуре приложения
Только смотрите, случайно не оберните эти функции в объявление класса, а то не дай бог получится нормальное ООП. По сути, вы предлагаете то же самое ООП, только методы почему-то нужно писать вне классов. Чем это будет удобнее? Объяснения нет.
JavaScript — это ООП-язык с самого начала, просто использующий прототипное наследование, и даже оно лучше классического Java-наследования.… JavaScript никогда не нуждался в классах, которые в него добавили в 2015 году.
Это всё вообще какие-то фантазии, вы уж простите. Любой, кто поработал хотя бы немного с ООП в JS, знает, что лучше бы там были обычное ООП и наследование.

Я не увидел в статье серьёзных аргументов в пользу отказа от ООП. Настолько, что, честно говоря, даже неясно, понимает ли автор то, о чём пишет, или просто где-то слышал, что ООП это не круто, а ФП круто. Все аргументы сводятся к «оказывается, код можно как-то написать и без классов» и вырванным из контекста цитатам из авторитетов (причём, если тот же авторитет пишет что-то с чем вы не согласны, нужно обязательно добавить «ну это же было давно и неправда»). Хотелось бы статью с примерами кода, из которых было бы понятно, как лучше.

Миллениалы изобрели процедурное программирование со всеми его недостатками.

Если в программе есть и процедурный и другие подходы - оно от этого так сильно хуже? Мы уже видели в виде классической Java через что приходится проходить и какие финты придумывать, чтобы существовать исключительно в чистом ОО мире. Когда нужно сделать какую-то не сложную вещь. К примеру, Go достаточно процедурный язык, что не делает его менее популярным в наше время. И его же за чем-то создали сейчас именно таким? При чем не глупые люди, которые явно знакомы со всеми парадигмами и подходами. Или набирающий популярность Rust, который тоже не является классическим ООП языком. Такие языки, как Питон - мультипарадигменные. Почему нужно боятся где-то написать процедурный код? Или ФП код? Кажется, что причин для этого нет, кроме предубеждений.

Поэтому разработчикам и остаётся писать конструкции c протоколами.

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

И не надо пытаться сделать вид, что такая реализация ООП — это какое-то достоинство Питона.

Вроде ни кто и не пытался, не знаю, где это подается в таком виде. Такой мысли в статье не было.

Чем это будет удобнее? Объяснения нет.

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

Любой, кто поработал хотя бы немного с ООП в JS, знает, что лучше бы там были обычное ООП и наследование.

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

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

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

Я считаю, что в Enterprise разработке нет места уверенности, в том, что "все мы взрослые люди". Очень многие ограничения в таких языках, как Java, помогают допустить как можно меньше ошибок, связанных с человеческим фактором

Тут хотелось бы спросить, а какие именно ограничения Java помогают совершать принципиально меньше ошибок? В случае с Haskell я бы мог понять такое утверждение, ведь не зря говорят "if a Haskell program compiles, it probably works". Его компилятор и система типов действительно делают нечто особенное по сравнению с большинством языков. Но Java?

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

Не только в Python, но и в Clojure, которые теперь активно продвигает дядя Боб. Тот же Javascript. Все это уже используется активно в энтерпрайз разработке. Ну и если человек видит, что что-то имеет явное указание по соглашению, что это нельзя трогать (то же нижнее подчеркивание в начале название), но берет и игнорирует это, то вероятно такой разработчик в любом случае набедокурит в другом месте, если не в этом. Обратится к чему-то приватному, что обозначено, как приватное, можно только осознано. Если ты хотя бы базово знаком с конвенцией языка, на котором пишешь.

К тому же есть другие способы сокрытия. Через замыкания внутри тех же функций.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий