Разделение команд и запросов
Функция должна либо ЧТО-ТО ДЕЛАТЬ (команда, с эффектом), либо ЧТО-ТО ОТВЕЧАТЬ (запрос, без наблюдаемого побочного эффекта) — но не то и другое сразу; их разделение делает запросы безопасными для свободного вызова, а код — предсказуемым и тестируемым.
Коллега добавляет одну безобидную строку в обработчик: logger.debug('current user: ' + currentUser()). Staging падает. Выясняется, что currentUser() не просто возвращает пользователя — при первом вызове он ещё и ротирует токен сессии как побочный эффект. Строка лога вызвала функцию второй раз, токен ротировался снова, и пользователя разлогинило. Функция читалась как вопрос; втайне она была ещё и действием.
Баг не в строке лога. Баг — в функции, которая одновременно отвечает и делает. Как только одна функция несёт обе роли, ты уже никогда не сможешь вызвать её небрежно — каждое чтение становится записью, о которой надо рассуждать, а «просто добавить лог» превращается в прод-инцидент.
После этого урока ты можешь классифицировать любую функцию как команду (вызывает эффект) или запрос (возвращает значение без наблюдаемого эффекта); распознать запах запроса, который втайне мутирует, и объяснить порождаемые им баги двойного вызова и строки лога; применить разделение команд и запросов, чтобы запросы можно было свободно вызывать, а код стал проще тестировать и понимать; и назвать законные случаи, где явная совмещённая операция — честный дизайн, а не догматичное дробление ради дробления.
Функция должна либо ЧТО-ТО ДЕЛАТЬ, либо ЧТО-ТО ОТВЕЧАТЬ — но никогда не то и другое сразу. Разделение команд и запросов (CQS) Бертрана Мейера проводит одну черту через каждый метод, который ты пишешь. Команда меняет наблюдаемое состояние (мутирует поле, пишет строку в БД, шлёт запрос) и по соглашению возвращает void либо просто результат этого эффекта. Запрос возвращает значение и оставляет мир ровно таким, каким нашёл — вызови его ноль раз, один раз или сто раз, наблюдаемой разницы не будет.
// query: answers, changes nothing observable
getBalance(): number;
// command: does, changes state
deposit(amount: number): void;Ценность правила — в гарантии, которую оно покупает: видя запрос, ты знаешь, что его безопасно вызывать. Можно его залогировать, проверить в тесте, вызвать дважды, переставить местами — и ничего ниже по потоку не сдвинется. Именно на эту единственную гарантию опирается остаток урока.
Классическое нарушение — геттер, который мутирует, и он порождает «жуткие» баги двойного вызова. Функция, названная как вопрос (currentUser, peek, nextId, геттер свойства), которая при этом меняет состояние, — это ловушка, потому что каждый вызывающий резонно предполагает, что это запрос, и вызывает её свободно.
class IdGenerator {
private n = 0;
// looks like a query, is secretly a command
get current(): number {
return ++this.n; // mutates on every read
}
}
const gen = new IdGenerator();
console.log(gen.current); // 1
console.log(gen.current); // 2 — reading "current" twice gave different answersВ тот миг, когда «чтение» меняет состояние, ломаются две истины, на которые опирается программист: чтение дважды может вернуть разные ответы, а добавление чтения (строки лога, наблюдения в отладчике, проверки) меняет поведение программы. Теперь никто не может посмотреть на значение без риска. Исправление не тонкое — сделай чтение настоящим запросом, а мутации дай собственное имя команды.
Разделённые запросы достаточно ссылочно прозрачны, чтобы вызывать их свободно — а это и делает код предсказуемым и тестируемым. Как только у запроса нет побочного эффекта, ты можешь подставить его результат вместо вызова, не меняя поведения, вынести его из цикла, закэшировать или вычислить в проверке теста с нулевой подготовкой. Тестам больше не нужны хитрые леса «и проверить, что ничего больше не изменилось», потому что запрос не может изменить что-либо ещё.
// pure query: trivial to test, safe to call anywhere
function totalCents(items: LineItem[]): number {
return items.reduce((sum, i) => sum + i.cents, 0);
}
// command: test by checking the effect it caused, in isolation
function chargeCard(gateway: Gateway, cents: number): Promise<Receipt> {
return gateway.charge(cents);
}Асимметрия намеренна: запросы тестируют по их возвращаемому значению, команды — по их эффекту. Смешай их, и каждый тест обязан проверять и ответ, и изменение состояния — а ты лишился возможности использовать функцию в строке лога, в охраннике или в проверке без последствий.
CQS лежит в основе рассуждений об идемпотентности — а значит, и о безопасных повторах. Чистый запрос автоматически идемпотентен: повторный вызов ничего не стоит и ничего не меняет, поэтому повтор, обновление или дублирующий запрос бесплатны. Команды — это место, где идемпотентность приходится проектировать (ключи дедупликации, условные записи), и именно CQS позволяет точно увидеть, где это проектирование нужно. Когда чтения и записи переплетены, ты вообще не можешь рассуждать о том, «безопасно ли это повторить?», потому что каждый вызов, возможно, делает и то, и другое.
// safe to retry: a query does nothing the second time
const price = quotePrice(cart); // idempotent for free
// must be made idempotent on purpose: a command has an effect
await placeOrder(cart, idempotencyKey); // retry only safe with the keyЭто senior-выигрыш. В распределённых системах сети дублируют и повторяют запросы за тебя, хочешь ты того или нет. CQS даёт тебе словарь — это запрос, повтор бесплатен; это команда, повтору нужен ключ — так что рассуждение о дублирующей доставке становится механическим, а не паникой в каждом отдельном случае.
Запрос со скрытой мутацией и его CQS-исправление. Кэш выставляет наружу lookup, который выглядит как чистое чтение, но также инкрементит счётчик обращений для вытеснения:
class Cache<V> {
private store = new Map<string, V>();
private hits = new Map<string, number>();
// VIOLATION: named like a query, mutates on every call
lookup(key: string): V | undefined {
this.hits.set(key, (this.hits.get(key) ?? 0) + 1); // hidden write
return this.store.get(key);
}
}Теперь любой охранник if (cache.lookup(k)), любой console.log(cache.lookup(k)), любая проверка теста expect(cache.lookup(k)).toBe(v) втихую меняют состояние вытеснения. Запусти тест дважды — и порядок вытеснения отличается; добавь отладочный лог — и в проде вытесняется другой ключ. Запах в том, что внутри lookup прячется запись.
Вынеси команду из запроса:
class Cache<V> {
private store = new Map<string, V>();
private hits = new Map<string, number>();
// QUERY: pure read, safe to call anywhere, any number of times
peek(key: string): V | undefined {
return this.store.get(key);
}
// COMMAND: the mutation, named honestly
recordHit(key: string): void {
this.hits.set(key, (this.hits.get(key) ?? 0) + 1);
}
}
// the caller now states intent explicitly
const value = cache.peek(key);
if (value !== undefined) cache.recordHit(key); // I am reading AND I want this countedpeek теперь свободно вызывать в логах, охранниках и тестах; recordHit — единственное, что мутирует, и его легко протестировать в изоляции, проверив счётчик. Главное: теперь вызывающий решает, когда обращение считается, — а это и есть правильное место для такого решения, а не зарытое внутри чтения.
▸Почему это работает
Почему это важнее, чем выглядит? Потому что CQS — это то, что делает функцию заменяемой своим результатом. Запрос ты можешь заменить значением, которое он возвращает; его можно перемещать, кэшировать, логировать, повторять. В тот момент, когда чтение ещё и пишет, ты теряешь каждый из этих манёвров — функция приварена к своему единственному месту вызова и к точному числу вызовов. CQS — не стилистическая мелочь; это свойство, которое держит чтения дешёвыми для осмысления, а это большая часть того, что делает крупную кодовую базу управляемой. Это ещё и хребет идемпотентности: «безопасно ли это повторить?» имеет мгновенный ответ для чистого запроса и известную инженерную задачу для команды, а не безграничное расследование.
▸Частая ошибка
Догматичный провал: дробить операции, которые законно атомарны. stack.pop(), возвращающий снятое значение и удаляющий его, queue.tryDequeue(), возвращающий значение плюс флаг успеха и при этом потребляющий элемент, map.remove(key), возвращающий старое значение, атомарный compare-and-swap, INSERT ... RETURNING id — все они намеренно сплавляют запрос и команду, потому что чтение и запись должны произойти как один неделимый шаг. Навязать здесь CQS (peek(), а затем отдельный remove()) — значит вновь открыть гонку между двумя вызовами, и под конкуренцией это строго хуже. Senior-правило не «никогда не совмещать» — оно совмещай только тогда, когда этого требует атомарность, и тогда называй честно: pop, tryDequeue, getAndIncrement, fetchAndAdd — все они объявляют в имени, что и возвращают, и мутируют. Преступление в Шаге 2 было не в совмещении; оно было в совмещении под именем запроса.
Метод getNextToken(): string возвращает токен и при этом продвигает внутренний счётчик, так что следующий вызов вернёт другой токен. Ревьюер помечает его. Какой правильный CQS-вердикт?
Разделение команд и запросов гласит, что функция должна либо ДЕЛАТЬ что-то (команда: меняет наблюдаемое состояние, возвращает void или результат эффекта), либо ОТВЕЧАТЬ что-то (запрос: возвращает значение и не меняет ничего наблюдаемого) — но не то и другое в замаскированном виде. Классическое нарушение — функция в форме геттера, которая втайне мутирует, и она порождает жуткие баги, где чтение дважды даёт разные ответы, а добавление строки лога меняет поведение. Сохранение запросов чистыми делает их свободными для вызова — безопасными в логах, охранниках, тестах и повторах, — а это и делает код предсказуемым, тестируемым и даёт чистый словарь для идемпотентности: чистый запрос идемпотентен бесплатно, тогда как идемпотентность команды приходится проектировать. Провальный режим — догматизм: по-настоящему атомарные операции вроде pop, tryDequeue и getAndIncrement справедливо сплавляют чтение и запись, и насильственное их разделение вновь открывает гонки. Поэтому совмещай только тогда, когда этого требует атомарность, — и когда совмещаешь, называй честно, чтобы ни один вызывающий не принял команду за запрос.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.