Pull to refresh
31
0
Антон Куранов @Throwable

Пользователь

Send message

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

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

То что это антипаттерн это только по вашему мнению.

Обращу ваше внимание на сей объективный факт, что это по большей части недостоверно, и например паттерны reactive создавались с целью немного улучшить ситуацию. Основной недостаток обзервера -- это неявная последовательность обработки событий разными обработчиками, которая зависит от порядка инициализации компонентов (а в IoC он всегда неявный), и которая в свою очередь является чуть ли не основной причиной всех глюков упомянутых вами UI. Кроме того, при проектировании паттерна вылезает еще куча неявных соглашений и гарантий, которые должны быть обязательно оговорены в каждом Observable-компоненте.

  1. События синронны или асинхронны?

  2. Гарантируется ли детерминированный порядок выполнения (и какой)?

  3. Гарантируется ли доставка сообщения, если removeListener() или addListener() вызван внутри обработчика? В вашем примере поведение недетерминировано.

  4. Если один из хендлеров кинул исключение, гарантируется ли доставка сообщения другим хендлерам?

  5. Уже упомянутый: гарантируется ли вызов onEnd(), если обработчик обработал onStart(), а другой выкинул исключение?

  6. Связанная проблема корректного shutdown-а: событие может послаться уже остановленному компоненту (проблема неразрешима, если есть циклические зависимости, которые может давать паттерн observer).

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

textField.onValueChange(e -> {
  if (e.getValue().endsWith(", "))
    textField.setValue(e.getValue() + "fck, ");
});

Представим код без использования наблюдателей, с TransactionManager у которого есть 4 метода

public void create(CreateOrderRequest request) {
  // практически реальный код -- вся магия внутри TM, принцип DRY соблюден
  // бизнес код защищен от некорректного использования и забывчивости/неумения программиста
  transactionManager.transactional(() -> {
    // some domain code
  });
}

Теперь рассмотрим тот же пример, но с наблюдателями

Предположим, что мы хотим навесить более одного наблюдателя -- а кто нам мешает, раз уж дизайн позволяет? Например я хочу еще залогировать сие событие в audit таблицу в базе данных. Тогда сразу же встает проблема последовательности инициализации (которая есть неявная): если контекст фреймворком был собран так, что первый обработчик -- это TM, тогда все нормально -- второй запишет в audit таблицу уже в транзакции. Если же нет -- первым вызовется audit и упадет с ошибкой transaction required.

Ладно, вы создаете отдельный AuditService, где делаете свои обзерверы и добавляете тот же обзервер с транзакцией. А теперь мне нужно отослать данные только что созданного заказа (и еще клиента) удаленному rest-логгеру. Я вешаю обработчик на onEnd() и опять проблема с последовательностью вызовов: может случиться, что транзакция уже как бы закрыта, а мне нужно еще читать данные клиента. Или в другой транзакции будем читать?

Теперь позвольте мне сделать review вашего кода:

class OrderService {
  public void create(CreateOrderRequest request) {
    // ремарка: это нужно будет копипейстить в любом компоненте, где требуется транзакция
    try {
      // если список обзерверов может меняетья динамически,
      // то нужно делать дефенсивную копию
      // new ArrayList<>(observers).forEach(...)
      observers.forEach(observer -> observer.onStart());
      
      //some domain code
      
      // для обеспечения детерминированности вызовы onEnd()
      // должны осуществляться в обратном порядке (LIFO)
      // тот, кто первым открыл транзакцию, должен закрыть ее последней
      observers.forEach(observer -> observer.onEnd());
    } catch (Exception e) {
      // абсолютно нелогично вызывать onCreationFailed() для обработчиков,
      // для которых не был вызван onStart() - они вообще ничего не должны знать о событии
      // более того, для некоторых уже успел вызваться onEnd(), зачем им посылать onCreationFailed()?
      // они уже освободили ресурсы и забыли про операцию
      // Забыл добавить: onCreationFailed() тоже может выкинуть исключение,
      // и в этом случае остальные обработчики не получат сообщение, что приведет
      // к утечке ресурсов. Здесь нужно вызывать хендлер в try - catch и продолжать в случае ошибки.
      // А после цепочки вызовов кинуть исключение с той ошибкой.
      observers.forEach(observer -> observer.onCreationFailed(e));
    }
  }
}

// Ваш обработчик нереентерабелен (не позволяет корректно работать внутри уже созданной транзакции)
// Как надо было:
class CreateOrderObserverImpl {
  // Нет гарантии, что это prototype, поэтому использует ThreadLocal
  ThreadLocal<Boolean> isManagedByCurrent = new ThreadLocal<>();
  
  public void onStart() {
    if (!transactionManager.hasActive()) {
      transactionManager.begin();
      isManagedByCurrent.set(true);
    }
  }
  
  public void onEnd() {
    // если транзакция создалась выше, мы ее и не должны коммитить
    if (isManagedByCurrent.get()) {
      try {
        transactionManager.commit();
      } finally {
        isManagedByCurrent.clear();
      }
    }
  }
  
  public void onCreationFailed(Exception e) {
    if (isManagedByCurrent.get()) {
      try {
        transactionManager.rollback();
      } finally {
        isManagedByCurrent.clear();
      }
    }
  }
}

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

Правильный TransactionManager обязательно включает метод rollback().

Вообще говоря, код из (2) нужно было бы написать в конструкции try-catch-finally, также при этом используя rollback в случае исключения, но напомню, что примеры намерено упрощены. А еще лучше было бы использовать аннотацию @Transactional из какого-либо фреймворка

Так и надо было делать, а еще проверить, не создана ли уже была транзакция ранее.

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

С этим согласен, но предлагаемое решение -- реально жесть и мусор. Транзакции -- контекстно зависимая вещь. Чтобы скрыть детали реализации самым лучшим способом будет поставить AOP interceptor вручную или при помощи аннотации фреймворка. Если нет фреймворка -- то в тыщу раз лучше оставить TM как явную зависимость в бизнес коде.

private final List<CreateOrderObserver> observers;

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

observers.forEach(observer -> observer.onStart(context));

Как вседа оптимистичное программирование для идеального мира, где по небу летают розовые пони, а код никогда не ломается. Если свалится первый обработчик -- другие что, не получат сообщения? А должны? Если свалится дальнейший код в теле метода, обработчики не получат end, а транзакция никогда не закроется? А если первый обработчик получил onStart(), а второй свалился, нужно ли будет первому послать onEnd()? И еще куча пессимистичных нюансов, которые превратят данный паттерн в настоящий геморрой. Так что на свалку данный велосипед.

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

Но проблема в том, что в современном фронтэнде сложно ограничиться только библиотекой стилей и виджетов -- так или иначе потребуется интеграция с мощной экосистемой JS. Это было основным и правильным решением для Vaadin Flow: теперь там можно достаточно просто интегрировать JS-библиотеки, при этом не теряя возможности серверного state-management-а.

Собственно SchedulerLock так и делает в базе. Вопрос был больше про его возможности -- может ли он вместо lock сделать подобие trylock. У меня для этого велосипед написан -- когда задание выполняется оно создает запись в б.д. с таймкодом. Если такая запись уже существует, то выполнение скипается.

Наконец, мы аннотируем наши запланированные задания, применив аннотацию @SchedulerLock

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

Блин, мне интуитивно казалось, что String.format() как-то связан с нативной сишной реализацией, а посему должен работать быстрее. Для сравнения можно было бы еще добавить форматирование с MessageFormat.

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

Там проблема не с монолитом, а с хранилищем. Чтобы создать масштабируемое хранилище, при том консистентное, потребуется механизм распределенных локов, а он отюдь не быстрый и не очень надежный. Распределенные системы хорошо работают, когда архитектура взаимодействия между микросервисами строго линейная или древовидная -- именно для нее по большей части и предложены вышеперечисленные методы. Но чуть сложнее data flow -- и уже не существует никакого универсального решения.

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

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

Не озвучен главный вывод: только старый добрый monolith и 2PC может гарантировать ACID (табличка нагло врет). Все остальные способы -- это велосипеды, которые в лучшем случае дают BASE и иллюзию транзакционности, при этом заставляя пользователя чуть менее чем полностью перелопатить определенным образом всю бизнес операцию, а также на порядок усложнить архитектуру. И ни в одном подобном анализе не озвучены гарантии и ограничения, которые предполагает каждый из способов. Кроме того, все предложенные "велосипеды" сфокусированы только на скоординированной записи, замалчивая такую важную вещь как консистентное чтение, где уже потребуется распределенный механизм блокировок.

Мне все эти саги и компенсации кажутся наивным способом переизобрести заново то, что уже было проанализированно и реализовано ранее, а именно: нужен координатор + поддержка 2PC для каждого endpoint-а. Без этого можно до бесконечности извращаться -- никогда не получите консистентной бизнес операции (за исключением множества частных случаев).

Из плюсов микросервисов

Я бы добавил:

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

Среди минусов не названы самые главные:

3) Гарантии для бизнес-транзакций между разными БД

А если учесть, что большинство коннектит свои микросервисы к одной и той же БД, напрочь теряя всякую возможность для ACID, то на мой взгляд это один из основных недостатков. Кто-то не заморачивается и тупо забивает на ACID, провоцируя неконсистентность в данных при отказе. А кто-то пытается городить велосипеды типа "сага", на смену того, что раньше решалось в две строчки, и которые к тому же не дают гарантий уровня ACID.

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

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

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

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

Концепт сильно не поменялся -- так было и раньше. Стоимость разработки и поддержки растет, все заняты работой: менеджеры раздают таски, архитекторы рисуют интерфейсы, девелоперы клепают микросервисы, а девопсеры специалисты CI/CD поднимают стопицот тулзов и контейнеров, чтобы создать "пайплайн" -- без него никак. Все это деплоится в клауд, потребляя тонны ресурсов, и там потихоньку ворочается.

А какие варианты?! То, что может быть null обязательно нужно проверять.

Не нужно, если вы, исходя из контекста выполнения (который в большинстве случаев не может быть вычислен компилятором статически), гарантируете, что здесь не null. И если оно все-таки по каким-то обстоятельствам null, то вы получите вполне нормальный NPE -- это ничем не хуже, нежели каждый раз делать дополнительную проверку и кидать исключение вручную.

"Замнем для ясности" пока про "невычислима в статике"... но зависимость от контекста в чем выражается?!

Форма пользовательского ввода. Nullability полей объекта формы уже может быть проверена выше по треду косвенно или сторонними средствами (валидаторы всякие), а компилятор об этом ничего не знает.

data class MyForm {
  ...
  @NotNull @Valid // JSR-303 annotations
  // наплевать, что NotNull, все-равно ВЕЗДЕ будем вставлять ненужные проверки
  var address : Address?
  ...
}
...
// Типа Address уже проверен, что не null.
// Зачем тут нужен "!!"? Чем он будет лучше простого NPE?
System.out.println(myForm.address!!.city)

?! В смысле, позволить выносить в run-time то, что только вот что перенесли в compile-time?

Именно. Не быть NPE-nazi и дать возможность пользователю выбирать между compile-time check и runtime check.

> Как вы используете null-safety что это прям лучшая фича в типах в Котлине?

Я вообще не совсем понимаю, как можно использовать null-safety? Использовать можно non-null типы. И с этим - вроде как - проблем быть не должно. Нет?

Ну да. Для non-null типов Котлин дает гарантию отсутствие NPE при работе с ними (по крайней мере для котлиновского кода). Но все остальные типы Котлин обязует жестко декларировать как nullable и проверять при каждом обращении. Отсюда лезут проблемы с отложенной инициализацией, которая чуть менее, чем всегда используется в большинстве фреймворков. То есть вроде как бы исходя из контекста нула быть не должно, но поле все-равно требуется объявить nullable (либо присвоить значение) и каждый раз бестолково проверять. А внедрение lateinit было лишь неудачным костылем.

А проблема в том, что nullability во большинстве случаяев невычислима в статике и является контекстно-зависимой. Поэтому лучшим решением было бы внедрить в Котлин послабление вроде Any!!, где null-safety полностью ложится на разработчика, к тому же такие типы существуют внутренне в компиляторе. Но разработчики посчитали не нужным "портить киллер-фичу".

Вы всерьез считаете подобный workflow адекватным для разработки? То есть вместо быстрого локального запуска предлагается куча медленных приседаний с напрочь отсутствующей возможностью поставить брейкпоинт и отдебажить в IDE? Это даже хуже чем сервера приложений в 2005.

В том-то и дело, что я же веду и разработку этих проектов. Необходимо сосредоточиться -- IDE открывает проект быстро, голова же нет. Плюс гиперответственность: необходимо проанализировать каждый реквест от клиента, сложную часть взять на себя, объяснить что и как делать, сделать code review.

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

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

Если говорить очень упрощенно, основная идея Calvin состоит в том, что узлы договариваются о плане выполнения транзакции до того, как поставят блокировки и начнут выполнять транзакцию.

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

В случае с Calvin и детерминированными системами, недетерминированное событие не отменяет транзакцию целиком.

Ну и хрен-то с ним! Скажите лучше как отменить транзакцию в случае детерминированного события? В качестве примера операция покупки товара:

  • DB 1 (счета): прочитать регистр счета клиента, проверить, есть ли в наличии необходимая сумма, вычесть цену товара и записать в регистр счета

  • DB2 (товары): прочитать регистр количество товара на стоке, проверить, есть ли в наличии необходимое количество, вычесть количество купленных, записать обратно в регистр стока.

Как Calvin будет организовывать атомарность данных операций и откат DB1, когда на складе нет достаточного количества?

На волне всеобщего хайпа микросервисы стали уже своего рода карго-культом в IT.

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

Все потому, что Spring -- это не столько DI, сколько набор @рецептов и @заклинаний, построенных поверх DI. Если вам нужен только DI, возьмите Guice -- наиболее органичная реализация паттерна. И да, в Guice прямым текстом говорится, что наиболее приоритетный случае -- это инъекция через конструкторы. По двум причинам:

  • Объект должен быть гарантированно инициалицирован со всеми своими зависимостями.

  • Объект не должен иметь зависимость от DI фреймворка.

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

Лично я всегда использую инициализации через конструктор, а для сокращения писанины использую Lombok с аннотациями:

@FieldDefaults(makeFinal = true)
@RequiredArgsConstructor(onConstructor = @__({@Inject}))

Поведение @Transactional было скопировано в свое время с JavaEE, в котором многие решения были просто ужасными. В часности, непонятно зачем было выставлять UserTransaction.setRollbackOnly() при выкидывании unchecked exception на каждом прокси (наверное, чтобы как можно раньше откатить и освободить "ценнейший" ресурс), вместо того, чтобы откатывать только на верхнем уровне, если exception не был обработан.

Например, мы можем добавить атрибут noRollbackFor в PersonValidateService.

Это только если экспшн возникает непосредственно в вашем коде, а не ниже по стеку. Стандартная ситуация: работаем с JPA, хотим поставить логику на определенные ошибки, (напр. EntityExistsException). Однако noRollbackFor=PersistenceException.class не сработает, ибо EntityManager уже заботливо выставил setRollbackOnly.

Круто! Года 3 года назад я тоже сделал бота для онлайн игры в шахматы: https://t.me/GameFactoryBot Многие решения реализованы похожим образом (даже Postgres), за исключением того, что бэкенд на джаве. Не понял только как реализована нотификация хода с сервера -- Heroku вроде бы не дает вебсокеты.

Не лучшие впечатления от Telegram Bot Gaming Platform: она не эволюционирует и выглядит заброшенной, функционал заточен под простые казуальные игры и не приспособлен для мультиплеера, в десктопной версии из чата не работают линки на t.me/bot, невозможность нормально "зацепить" игру в чате с партией на сервере, кроме как по inline_mesage_id, ограничение, что игру вызывает только первая кнопка.

Information

Rating
3,878-th
Location
Madrid, Испания
Registered
Activity