В моей практике часто возникают ситуации, когда в программе один объект должен определённым образом реагировать на другие объекты. То есть – решать, взаимодействовать с ними или игнорировать. Например, когда объект принимает сообщения от других объектов, и должен решать – отреагировать на конкретное сообщение или пропустить его. Понятно, что в простейшем случае достаточно это захардкодить: допустим, объект user реагирует на все сообщения про этого юзера. Но что делать, если ситуация сложнее, и реакции должны быть динамическими? (например, если ваши объекты порождаются фабрикой). Для этого у меня есть некий паттерн, который я называю voter injection.
Встречаем воутеры
Термин Voter взят из фреймворка Symfony, где он решает частную задачу прав доступа. Там есть довольно интересные стратегии, например право доступа может предоставляться, если хотя бы один voter из набора сказал “да”, или если строго все сказали “да”, или если большинство, или если кворум (не менее чем N из набора), и так далее. В некоторых случаях, воутер может воздерживаться от голосования (не говорить ни “да”, ни “нет”, если он не в силах принять решение) – но этот сложный случай мы оставим на рассмотрение читателю.
В своей ситуации я использую воутеры для того, чтобы динамически проинжектить в объект определение того, с какими объектами он должен взаимодействовать – или, давайте на примере для простоты, какие сообщения он должен принимать к сведению. Рассмотрим ситуацию, когда объект user должен принимать к сведению все сообщения, в которых type == ‘comment’, target_id равняется идентификатору пользователя, и атрибут body не пуст.
Конечно, мы можем элементарно захардкодить это: if type == ‘comment’ && target_id == self.id && body.length > 0 …
Однако, если уж вы дочитали до сюда, вам может быть интересно как это сделать динамически. Итак, встречаем объект Voter:
1. Воутер имеет ссылку на объект, в который он проинжектирован (это очевидно).
2. Воутер умеет делать строго одно “тупое” действие, например проверять что “A” равно “B”.
3. Воутер инициализируется с набором параметров, имеющих отношение к решаемой им задаче.
4. Воутер имеет метод, по которому его можно просить “проголосовать” за конкретное сообщение.
Начинаем решать нашу задачу
1. Напишем воутер, который проверяет эквивалентность значения атрибута – заданному значению. При инициализации мы ему ему передаём два параметра: {attribute: ‘type’, value: ‘comment’}. Что он должен делать? Он берёт переданное ему сообщение, и смотрит, действительно ли у сообщения атрибут attribute (то есть ‘type’) имеет значение value (то есть ‘comment’). Обратите внимание, что ни одно из этих слов не захардкожено!
2. Второй воутер чуть сложнее: он сравнивает значение указанного атрибута сообщения и относящегося к нему атрибута объекта. То есть, при инициализации мы передаём ему: {message_attribute: ‘target_id’, object_attribute: ‘id’}. Что он должен делать? Он берёт переданное ему сообщение, и смотрит, действительно ли у сообщения атрибут target_id эквивалентен атрибуту id объекта. И снова ни один из них не захардкожен.
3. Третий воутер снова очень прост, он умеет только вычислять длину указанного атрибута сообщения: при инициализации мы сообщаем ему, что нужно смотреть атрибут body, и требуемая минимальная длина – 1 (то есть, длина body должна быть больше нуля). Он отвечает “да” только если это условие выполняется. И конечно же, снова никакого хардкода.
Воутеры – “тупые” исполнители
Стоит ещё раз подчеркнуть, что воутеры никак не кастомизируются под конкретный объект. Всё, что они умеют, это выполнять базовые логические операции: сравнить два атрибута, вычислить длину строки, убедиться что атрибут не “пуст”, убедиться что атрибут заполнен целочисленным значением, и тому подобное. Они не могут влезть “внутрь” ни в объект, ни в сообщение, а могут лишь пользоваться их публичными атрибутами (методами). Каждый воутер – простой автомат: он умеет делать строго что-то одно. Например, если воутер сравнивает два атрибута – мы говорим ему, какие именно атрибуты сравнивать у объекта и сообщения, только в момент его (воутера) инициалиализации.
Таким образом, имея ряд “тупых” воутеров, которые могут выполнять лишь ряд элементарных операций, можно программировать сколь угодно сложную логику путём их чисто арифметической комбинации. Более того, воутеры можно включать и выключать “на ходу”, можно независимо логировать их решения, можно переиспользовать их в других частях проекта. Воутеры – просты, понятны и очевидны, они читаются гораздо легче, чем многоэтажные if-ы и кейсы. Мышление воутерами – дисциплинирует разработчика, фактически “вынуждая” его укладывать код в чёткую логическую последовательность, а значит – не хардкодить “частные случаи” и прочие источники проблем.
Я регулярно наблюдаю, как ту же задачу пытаются решить путём наследования или метапрограммирования, тем самым привнося весь груз сопутствующей сложности, когда классам и объектам магическим (неявным) путём добавляются сторонняя логика и методы. И более того, иногда даже класс объекта засоряется внутренними переменными, в которых хранится подобная информация – разумеется, бесконтрольно и не единообразно. Мне подобные пути кажутся избыточными и трудночитаемыми.
Недостатки воутеров
Конечно, у подобного паттерна есть и очевидные недостатки: а) трудновато для восприятия без опыта, и б) вычислительный оверхэд. Как было сказано в самом начале, в тех случаях когда поведение однозначно и не меняется в ходе выполнения программы, и его проще описать прямым кодом – следует так и поступать.