1. Введение
Сегодня мы начинаем смотреть на программы как на систему, где «что-то произошло» важнее, чем «кто-то кого-то вызвал». Мы разберёмся, как заменить прямые вызовы между частями кода публикацией события, как появляются роли источника событий и слушателей, и почему заранее оговоренный контракт события спасает проект от хаоса. А дальше, в следующих лекциях, мы постепенно сделаем события удобными, безопасными и пригодными для корутинного мира.
Когда код начинает «липнуть»
Если вы пишете учебную консольную программу на 50 строк — вам может казаться, что архитектура придумана для скучных людей в галстуках. Но есть плохая новость: как только вы добавляете второй-третий «побочный эффект» (логирование, статистику, сохранение в файл, уведомление), код начинает вести себя как жвачка под столом: вроде держится, но трогать неприятно.
Давайте представим наш практический проект — условный консольный трекер расходов BudgetBuddy. У нас есть команда add, которая добавляет расход. Наивный вариант: «раз добавили — значит, тут же логируем, тут же пересчитываем отчёт, тут же сохраняем, тут же печатаем сообщение».
data class Expense(val id: Int, val title: String, val amount: Int)
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
println("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
println("Audit: expense added id=${e.id}") // Audit: expense added id=1
println("Stats: need to recalc totals") // Stats: need to recalc totals
}
Код работает. И даже кажется милым. А потом вы добавляете «ещё одну маленькую реакцию» — например, авто‑сохранение в файл — и функция addExpense превращается в комбайн.
Проблема не в том, что там много строк. Проблема в том, что addExpense начинает знать слишком много: кто такой аудит, что такое статистика, когда сохранять, в каком формате логировать. Это уже не «добавить расход», это «добавить расход и сделать ещё сто вещей, потому что жизнь боль».
Событие — факт, а не команда
Когда в коде появляется слово event/«событие», новички часто воспринимают это как «какая-то магия, которая летает по воздуху». На самом деле всё проще и приземлённее: событие — это сообщение о факте, а не приказ конкретному модулю. Разница тонкая, но она меняет архитектуру радикально.
Сравните две формулировки:
- «Отправь письмо пользователю» — это команда, вы уже выбрали кто и что должен сделать.
- «Пользователь зарегистрирован» — это факт; дальше разные части системы могут реагировать по-своему.
В нашем BudgetBuddy «команда» выглядела бы так: sendEmailToAccountant(expense) (да, бухгалтер тоже человек). А «факт» будет звучать как: expenseAdded(expense).
Это и есть слабая связанность: часть кода, которая добавляет расход, не обязана знать, что где-то есть аудит, отчёты или авто‑сохранение. Она сообщает факт: «расход добавлен». А дальше — кто услышал, тот и молодец.
2. Модель «один → много»: источник и слушатели
В событийной модели почти всегда есть одна и та же картинка: есть источник события (publisher/emitter), который публикует факт, и есть слушатели (listeners/observers), которые на этот факт реагируют. Причём слушателей может быть один, два или двадцать — и источник от этого не должен нервно икать.
Минимальная реализация на коллекции обработчиков
Чтобы почувствовать идею руками, не нужно никаких библиотек и фреймворков. Достаточно коллекции обработчиков. Коллекции в Kotlin как раз для этого и существуют: хранить набор элементов и позволять добавлять/удалять их. У MutableCollection есть операции изменения содержимого (например, add/remove), и это фундамент для «списка подписчиков».
Минимальная модель «один → много» может выглядеть так:
data class Expense(val id: Int, val title: String, val amount: Int)
val expenseAddedListeners = mutableListOf<(Expense) -> Unit>()
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
for (listener in expenseAddedListeners) listener(e)
}
Это ещё не «идеальная» реализация событий (мы пока не обсуждаем безопасность, отписку, ошибки обработчиков). Но как учебная картинка — отлично: добавили расход, пробежались по слушателям, каждый сделал свою реакцию.
Важно: мы намеренно используем mutableListOf, потому что список подписчиков меняется: кто-то подписался, кто-то отключился. Kotlin нормально относится к тому, что мутабельную коллекцию можно хранить в val: ссылка не меняется, а содержимое — да. Это как «коробка всегда одна, но конфеты внутри то появляются, то исчезают».
Подключаем реакции отдельно
Давайте проговорим, что именно становится лучше, когда мы перестаём «дергать соседей за рукав» и начинаем публиковать события.
Представим, что у нас есть три реакции на добавление расхода: печать в консоль, аудит и пересчёт статистики. В прямом подходе addExpense должна знать обо всех трёх. В событийном подходе addExpense знает только о факте добавления, а реакции живут отдельно.
fun printOnAdded(e: Expense) {
println("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
}
fun auditOnAdded(e: Expense) {
println("Audit: expense id=${e.id}") // Audit: expense id=1
}
fun statsOnAdded(e: Expense) {
println("Stats: recalc after ${e.title}") // Stats: recalc after Coffee
}
Подключение слушателей выглядит так:
fun main() {
expenseAddedListeners += ::printOnAdded
expenseAddedListeners += ::auditOnAdded
expenseAddedListeners += ::statsOnAdded
}
И вот тут появляется приятная вещь: чтобы добавить новую реакцию, нам не нужно лезть в addExpense. Мы добавляем новую функцию‑слушатель и подписываем её. Программа растёт, а «центр» системы не превращается в монстра, который знает всё обо всём.
3. Контракт события и правила игры
Когда люди впервые слышат про события, у них часто возникает ощущение: «О, круто, теперь можно просто “эмитить” что угодно, куда угодно, и всё само заработает». Это опасная иллюзия. События работают хорошо только тогда, когда у них есть контракт — то есть набор заранее оговоренных правил, по которым все играют.
Контракт — это не обязательно формальный документ на 20 страниц. В учебном проекте это может быть просто ясная договорённость в голове и в коде. Но вопросы нужно решить заранее, потому что иначе разные части программы начнут ожидать разное поведение.
Что входит в контракт
Ниже — таблица «о чём договориться», без углубления в реализацию (реализацию мы будем делать в следующих лекциях).
| Часть контракта | Вопрос | Почему это важно |
|---|---|---|
| Данные события (payload) | Что передаём слушателям: строку, число, объект? | Если передавать «волшебные строки», легко ошибиться. Отдельный тип данных делает смысл явным. |
| Синхронность | Слушатели вызываются «прямо сейчас» или «когда-нибудь потом»? | Если это синхронно, слушатель может замедлить основную операцию. Если асинхронно — появляются вопросы порядка и жизненного цикла. |
| Ошибки слушателей | Если один слушатель упал, остальные должны выполниться? | Без договорённости вы получите случайные падения «то работает, то нет». |
| Порядок вызова | Есть ли гарантированный порядок слушателей? | Если порядок важен, вы фактически строите конвейер, а это уже другой стиль. |
| Управление подписками | Можно ли подписываться/отписываться во время работы? | Иначе вы рискуете «вечными слушателями», которые вызываются, когда уже не надо. |
| Поведение при отсутствии слушателей | Что делает событие, если никто не подписан? | Нормально, если это «ничего». Это делает код предсказуемым. |
Заметьте: мы пока не говорим «как именно» реализовать отписку или безопасность рассылки. Мы лишь фиксируем: это часть контракта, и без неё событийная система быстро превращается в мистику, где всё работает только по пятницам.
Как называть события
С названием события происходит забавная вещь: оно влияет на мышление команды сильнее, чем кажется. Если вы назовёте событие sendEmail, вы уже зашили в архитектуру, что «событие = письмо». Это ломает идею слабой связанности, потому что источник как будто намекает: «Я ожидаю, что ты отправишь письмо».
Лучше называть событие как факт, обычно в прошедшем времени или в форме «что случилось»:
- expenseAdded / ExpenseAdded
- expenseRemoved
- reportGenerated
И полезно думать так: «Событие должно быть правдой». Если событие называется expenseAdded, то в момент публикации расход действительно уже добавлен в список/хранилище. Если вы публикуете событие до изменения состояния — слушатели могут увидеть «мир до изменений» и начнут принимать решения на основе устаревших данных. Это не баг, это классическая ловушка проектирования.
Мини‑пример правильного порядка: сначала меняем состояние, потом публикуем факт.
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
for (listener in expenseAddedListeners) listener(e)
}
Выделяем точку факта в коде
Сейчас сделаем очень важный шаг, который выглядит почти смешно простым: отделим «добавление расхода» от «всех реакций». Не через полноценный Event<T> (это будет позже), а через явную функцию‑«точку события». Это полезный промежуточный стиль: вы уже дисциплинируете код, даже если у вас пока нет инфраструктуры подписок.
fun emitExpenseAdded(e: Expense) {
println("Event: expenseAdded(id=${e.id})") // Event: expenseAdded(id=1)
}
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
emitExpenseAdded(e)
}
Почему это вообще стоит делать, если пока «никто не слушает»? Потому что вы фиксируете архитектурный смысл: вот здесь произошёл факт. Позже, когда появятся слушатели, вы замените тело emitExpenseAdded на рассылку подписчикам, а остальной код не придётся перепахивать.
И это ровно тот момент, когда система начинает «разжиматься»: addExpense перестаёт быть местом, куда добавляют «ещё одну реакцию». Она становится местом, где случается доменное действие (добавили расход) и публикуется факт.
4. Компромиссы: цена гибкости и где события не нужны
События не бесплатны. Они дают гибкость, но забирают часть простоты. И если об этом не сказать вслух, то в какой-то момент вы начнёте подозревать, что «события — это зло», потому что всё стало неочевидно. На самом деле вы просто не заплатили по чеку: не определили контракт и не дисциплинировали использование.
Главная цена — в том, что исполнение становится менее линейным. При прямом вызове вы читаете код сверху вниз и примерно понимаете: «сначала добавили, потом пересчитали, потом сохранили». При событиях вы читаете: «добавили, а дальше… где-то кто-то отреагировал». Это нормально, но требует привычки: теперь поведение определяется не одним местом, а набором подписок.
Вторая цена — управление жизненным циклом слушателей. Если подписки живут дольше, чем нужно, вы получаете неожиданные повторные реакции. Если слушатель упал с исключением, вы должны понимать, что будет с остальными. Если слушатель подписался/отписался во время рассылки, нужно заранее решить, как система должна себя вести. Мы пока не решаем эти задачи, но важно увидеть их как реальные инженерные вопросы, а не как «ну это потом как-нибудь».
И ещё одно ограничение, которое полезно держать в голове: события прекрасны для расширяемых реакций «один → много», где слушатели могут появляться и исчезать. Но если вам нужен простой результат в стиле «посчитай и верни», события будут только мешать.
5. Типичные ошибки
Ошибка №1: путать событие с командой.
Когда событие называют sendEmail, архитектура начинает «просить» источник события знать, что именно должно произойти. Это ломает слабую связанность: источник снова начинает зависеть от конкретных действий. Если хочется событий — называйте их как факты (userRegistered, expenseAdded) и держите в голове мысль «событие должно быть правдой».
Ошибка №2: делать “центральную функцию”, которая всё равно знает обо всех реакциях.
Иногда разработчик говорит: «Ок, у нас будет событие», но затем пишет emitExpenseAdded, внутри которого вручную вызывает audit(), recalcStats(), saveToFile(). Это просто переезд той же связности в другое место. Событийная модель полезна именно тогда, когда реакции подключаются снаружи, а источник не знает, кто они.
Ошибка №3: не проговорить контракт поведения при ошибках.
Если один обработчик бросает исключение, а вы это не предусмотрели, то результат будет «рандомный»: иногда всё отработало, иногда программа упала, иногда часть реакций не случилась. Даже в учебном проекте стоит заранее определиться: «один упал — остальных всё равно вызываем» или «ошибка критична, всё прерываем». В следующих лекциях мы будем делать рассылку устойчивой, но мысль должна появиться уже сейчас.
Ошибка №4: строить логику, где порядок слушателей критичен, но это нигде не зафиксировано.
Если вы молча ожидаете, что «сначала статистика, потом сохранение», а кто-то поменял порядок подписок — вы получаете баг, который невозможно понять по коду addExpense. Если порядок действительно важен, это должно стать частью контракта (и тогда это уже ближе к конвейеру/пайплайну, а не к «свободным реакциям»).
Ошибка №5: пытаться заменить событиями вообще все вызовы функций.
События прекрасны для расширяемых реакций «один → много», где слушатели могут появляться и исчезать. Но если вам нужен простой результат «посчитай и верни», то события будут только мешать. Хорошая архитектура — это не «везде события», а «события там, где они реально снижают связанность и делают расширение дешевле».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ