open atlas
↑ К треку
Паттерны и качество кода CP · 09 · 02

Цепочки сообщений и посредник

Цепочка сообщений связывает вызывающего со всем графом объектов; посредник — перегиб в обратную сторону, класс, который только делегирует. Лечат скрытием делегата, удалением посредника и «говори, не спрашивай», но Деметра тянет в обе стороны — senior-решение в балансе.

CP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Ты пишешь order.getCustomer().getAddress().getCity().getName(), и оно компилируется, уезжает в прод, работает. Полгода спустя кто-то сворачивает Address в Customer, и это одно выражение ломается в сорока файлах — каждый из которых тихо запомнил точную форму графа, которым он никогда не владел. Цепочка работала. Проблема была не в корректности; она была в том, что вызывающий знал внутренний маршрут через четыре объекта, чтобы добраться до одной строки.

Потом коллега «чинит» это, обернув всё подряд: у Order появляется getCity(), который зовёт customer.getCity(), который зовёт address.getCity(). Теперь ни одной цепочки не видно — а ты построил башню из классов, которые не делают ничего, кроме переадресации вызовов. Ты обменял один запах на его зеркальное отражение. Этот урок — про оба запаха, их лекарства и судейское решение, которое стоит между ними.

Цель

После этого урока ты можешь распознать цепочку сообщений (a.getB().getC().getD()) как структурную связанность с графом объектов; распознать посредника как класс, методы которого только делегируют; применить три лекарства — скрыть делегата, удалить посредника, говори, не спрашивай — к нужному из них; объяснить, почему закон Деметры тянет эти два запаха в противоположные стороны; и принять senior-решение о том, сколько делегирования — это здоровая инкапсуляция, а сколько — шум переадресации.

1

Цепочка сообщений связывает вызывающего со всем путём, а не только с конечной точкой. Когда код читается как a.getB().getC().getD(), вызывающий зашил форму графа: что A отдаёт B, что B отдаёт C, что C отдаёт D. Каждый промежуточный тип теперь часть контракта вызывающего на этапе компиляции, хотя вызывающему нужно было лишь конечное значение.

// Вызывающий «знает» Order → Customer → Address → string
const city = order.getCustomer().getAddress().getCity();

Радиус поражения равен длине цепочки. Поменяй любое звено — переименуй getAddress, сделай Customer.address обнуляемым, подними city на уровень Customer — и каждое место вызова, которое шло через него, ломается. Вызывающий заплатил за знание, которое ему было не нужно: он хотел город, а купил карту внутренностей трёх объектов.

2

Скрыть делегата: попроси у ближайшего объекта то, что тебе нужно, и пусть он сам пройдёт остаток цепочки. Лекарство от цепочки сообщений — обычно скрыть делегата: затолкать навигацию за объект, который ты уже держишь, чтобы вызывающий зависел только от своего непосредственного соседа.

class Order {
  constructor(private customer: Customer) {}
  // «единственный друг» вызывающего отдаёт конечную точку напрямую
  get shippingCity(): string {
    return this.customer.address.city; // Order владеет этим проходом, а вызывающий — нет
  }
}

// вызывающий теперь касается одного типа, а не трёх
const city = order.shippingCity;

Это закон Деметры («говори только со своими непосредственными друзьями»), применённый как рефакторинг: вызывающий говорит только с Order. Если Address позже свернётся в Customer, единственное ломающееся место — внутри Order.shippingCity — одна правка, а не сорок. Цепочка не исчезла; она переехала в объект, который законно владеет этой связью.

3

Посредник — это скрытие делегата, доведённое до перебора: класс, методы которого только переадресуют. Примени скрыть делегата ко всему — и получишь противоположный запах. Если половина методов Order — однострочные переадресации к customer, то Order стал посредником — налогом на проброс. Каждый новый метод на Customer теперь требует своего метода-близнеца, делегирующего на Order, и каждому читателю приходится прыгать через переадресацию, чтобы понять, где работа реально происходит.

class Order {
  constructor(private customer: Customer) {}
  // ничего из этого не делает ничего, кроме переадресации — чистый проброс
  get name() { return this.customer.name; }
  get email() { return this.customer.email; }
  get city() { return this.customer.address.city; }
  get phone() { return this.customer.phone; }
}

Лекарство — удалить посредника: удалить переадресующие методы и дать вызывающим говорить с настоящим объектом — order.customer.email. Обменять цепочку на пустую обёртку — не прогресс; ты добавил класс на сопровождение, который не несёт собственного поведения.

4

«Говори, не спрашивай» часто растворяет оба запаха разом. Цепочки и посредники нередко появляются потому, что вызывающий спрашивает данные, а затем действует на них где-то ещё — вытаскивает город, чтобы посчитать доставку вдали от данных. Перенеси поведение к данным: скажи объекту выполнить работу.

// СПРОСИ: вызывающий лезет внутрь, берёт данные, решает — цепочка + логика вдали от данных
const city = order.getCustomer().getAddress().getCity();
const fee = isRemote(city) ? 15 : 5;

// СКАЖИ: объект, владеющий данными, отвечает на настоящий вопрос
class Order {
  shippingFee(): number {
    return this.customer.isRemote() ? 15 : 5; // граф остаётся приватным
  }
}
const fee = order.shippingFee();

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

5

Суждение: баланс, а не фанатизм — Деметра, доведённая до предела, производит посредников. Вот напряжение, которое обнажают эти два запаха. Закон Деметры говорит «не тянись сквозь объекты», что толкает тебя к скрыть делегата. Но механически подчиняться этому при каждом доступе означает оборачивать каждого соседа соседа в переадресующий метод — а это ровно то, как ты создаёшь посредников. Два запаха тянут друг против друга: нулевое делегирование даёт хрупкие цепочки; тотальное делегирование даёт башни проброса.

Поэтому правило не «устранить все цепочки» и не «устранить всё делегирование». Часть делегирования — здоровая инкапсуляция: она прячет связь, о которой вызывающему незачем знать. Слишком много — шум: обёртки, которые ничего не прячут, потому что связь и так часть публичной модели (list.length, Promise.then, fluent-билдер — это цепочки, которые надо оставить в покое). Навык в том, чтобы читать, что есть что: прячет ли это делегирование что-то, что вероятно изменится, или просто ретранслирует то, что уже публично?

Разбор примера

Пройди цепочку до обёртки и обратно к балансу. Начни с цепочки в нотификаторе, которому незачем знать внутренности клиента:

// notifier.ts — связан с Order → Customer → Address → string
function sendShippedEmail(order: Order) {
  const to = order.getCustomer().getEmail();
  const city = order.getCustomer().getAddress().getCity();
  mailer.send(to, `Your order ships to ${city}`);
}

Две цепочки, обе идут через Customer. Наивный перегиб — обернуть каждый шаг на Order:

class Order {
  get customerEmail() { return this.customer.email; }
  get customerCity()  { return this.customer.address.city; }
  get customerName()  { return this.customer.name; }   // и так далее, и далее...
}

Order теперь посредник — эти геттеры не несут поведения, и каждое новое поле на Customer требует нового переадресатора. Лучше: оставить два делегирования, которые нотификатору реально нужны, потому что они прячут связь, которая может измениться (где живёт city), и остановиться на этом:

class Order {
  // прячет проход Customer→Address, о котором нотификатору знать не следует
  get shippingEmail(): string { return this.customer.email; }
  get shippingCity(): string  { return this.customer.address.city; }
}

function sendShippedEmail(order: Order) {
  mailer.send(order.shippingEmail, `Your order ships to ${order.shippingCity}`);
}

Нотификатор говорит с одним типом. Мы добавили ровно два делегата — те, что прячут вероятно-изменчивый граф, — и отказались от остального. Если продукт позже попросит «тема письма зависит от того, удалённый ли это регион», следующий шаг — говори, не спрашивай: order.isRemoteShipment(), держащий решение рядом с данными. Суть не в «никаких цепочек» и не в «оберни всё» — она в том, чтобы спрятать связи, которые сдвинутся, и не выставить ничего лишнего.

Почему это работает

Почему «говори только со своими непосредственными друзьями» формулируют как связанность, а не как этикет? Потому что каждая . в цепочке — это факт, от которого вызывающий теперь зависит. a.b.c.d кодирует четыре факта: A-имеет-B, B-имеет-C, C-имеет-D, D-это-то-что-мне-нужно. Три из этих фактов — про объекты, о которых вызывающий вовсе не хотел знать. Закон Деметры — это бюджет связанности: он ограничивает, на сколько внутренних связей системы любому одному вызывающему позволено опираться. Скрыть делегата тратит этот бюджет там, где он окупается — запирая вероятное изменение в одном владельце, — а не размазывая форму графа по каждому вызывающему, которому понадобилось листовое значение.

Частая ошибка

Режим отказа, который ловит senior’ов: удалить делегата, который был намеренным швом. Не каждый переадресующий метод — посредник. Делегат, существующий как стабильная граница абстракции — место, где ты позже подменишь подложечный объект, вставишь политику, добавишь кеширование или разорвёшь зависимость, — делает реальную работу, даже если его тело сегодня в одну строку. «Удалить посредника» предполагает, что обёртка ничего не прячет; но если она прячет границу, которую ты захочешь варьировать позже, её удаление заново свяжет каждого вызывающего с конкретным типом, от которого ты их держал в стороне. Прежде чем удалять однострочного делегата, спроси: это шум проброса или шов, который вот-вот понадобится? Тело выглядит одинаково; разница вся — в намерении.

Проверь себя
Викторина

Ревьюер помечает Order.shippingCity() — однострочный метод, возвращающий this.customer.address.city — как посредника и просит удалить его, чтобы вызывающие напрямую использовали order.customer.address.city. Когда ревьюер неправ?

Итог

Цепочка сообщений (a.getB().getC().getD()) связывает вызывающего со всем графом объектов: каждое звено становится фактом, от которого вызывающий зависит, поэтому радиус поражения равен длине цепочки. Скрыть делегата лечит её, заталкивая проход за ближайший объект, применяя закон Деметры — говори только с непосредственными друзьями. Но переусердствуй с этим — и получишь зеркальный запах: посредника, класс, методы которого только переадресуют, взимая налог на проброс без собственного поведения; удалить посредника или говори, не спрашивай растворяют его. Два запаха тянут друг против друга, поэтому senior-решение — баланс: спрятать связи, которые вероятно изменятся, и оставить публичные (list.length, fluent-билдеры) в покое. Режим отказа, которого надо бояться, — удалить делегата, который был намеренным швом: однострочную переадресацию, которую ты захочешь как границу абстракции позже. То же тело, противоположное намерение — умение различить, что́ из них ты держишь, и есть весь навык.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.