1. Введение
Недавно мы рассмотрели Creational Design Patterns и где их найти в JVM и других основных библиотеках. Теперь мы рассмотрим шаблоны поведенческого проектирования . Они сосредоточены на том, как наши объекты взаимодействуют друг с другом или как мы взаимодействуем с ними.
2. Цепочка ответственности
Шаблон цепочки ответственности позволяет объектам реализовывать общий интерфейс и для каждой реализации делегировать полномочия следующей, если это необходимо. Затем это позволяет нам построить цепочку реализаций, где каждая выполняет некоторые действия до или после вызова следующего элемента в цепочке :
interface ChainOfResponsibility {
void perform();
}
class LoggingChain {
private ChainOfResponsibility delegate;
public void perform() {
System.out.println("Starting chain");
delegate.perform();
System.out.println("Ending chain");
}
}
Здесь мы видим пример, в котором наша реализация выводит данные до и после вызова делегата.
Мы не обязаны вызывать делегата. Мы могли бы решить, что не должны этого делать, и вместо этого завершить цепочку досрочно. Например, если бы были какие-то входные параметры, мы могли бы проверить их и завершить раньше, если бы они были недействительны.
2.1. Примеры в JVM
Фильтры сервлетов — это пример из экосистемы JEE, который работает таким образом. Один экземпляр получает запрос и ответ сервлета, а экземпляр FilterChain
представляет всю цепочку фильтров. Затем каждый из них должен выполнить свою работу, а затем либо завершить цепочку, либо вызвать chain.doFilter()
, чтобы передать управление следующему фильтру :
public class AuthenticatingFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"MyAuthToken".equals(httpRequest.getHeader("X-Auth-Token")) {
return;
}
chain.doFilter(request, response);
}
}
3. Команда
Шаблон Command позволяет нам инкапсулировать некоторые конкретные действия — или команды — за общим интерфейсом, чтобы их можно было правильно запускать во время выполнения.
Обычно у нас есть интерфейс Command, экземпляр Receiver, который получает экземпляр команды, и Invoker, который отвечает за вызов правильного экземпляра команды. Затем мы можем определить разные экземпляры нашего командного интерфейса для выполнения различных действий с приемником :
interface DoorCommand {
perform(Door door);
}
class OpenDoorCommand implements DoorCommand {
public void perform(Door door) {
door.setState("open");
}
}
Здесь у нас есть реализация команды, которая примет дверь
в качестве получателя и заставит дверь стать «открытой». Наш вызывающий может затем вызвать эту команду, когда он хочет открыть данную дверь, и команда инкапсулирует, как это сделать.
В будущем нам может понадобиться изменить нашу команду OpenDoorCommand
, чтобы сначала проверить, не заперта ли дверь. Это изменение будет полностью внутри команды, и классы получателя и вызывающего не должны иметь никаких изменений.
3.1. Примеры в JVM
Очень распространенным примером этого шаблона является класс Action
в Swing:
Action saveAction = new SaveAction();
button = new JButton(saveAction)
Здесь SaveAction
— это команда, компонент Swing JButton
, который использует этот класс, является инициатором, а реализация Action
вызывается с ActionEvent
в качестве получателя.
4. Итератор
Шаблон Iterator позволяет нам работать с элементами в коллекции и взаимодействовать с каждым по очереди. Мы используем это для написания функций, использующих произвольный итератор для некоторых элементов, независимо от того, откуда они берутся . Источником может быть упорядоченный список, неупорядоченный набор или бесконечный поток:
void printAll<T>(Iterator<T> iter) {
while (iter.hasNext()) {
System.out.println(iter.next());
}
}
4.1. Примеры в JVM
Все стандартные коллекции JVM реализуют шаблон Iterator , предоставляя метод iterator()
, который возвращает Iterator<T>
для элементов коллекции. Потоки также реализуют тот же метод, за исключением того, что в этом случае это может быть бесконечный поток, поэтому итератор никогда не завершится.
5. Сувениры
Паттерн Memento позволяет нам создавать объекты, способные изменять состояние, а затем возвращаться в прежнее состояние. По сути, это функция «отмены» для состояния объекта.
Это можно реализовать относительно легко, сохраняя предыдущее состояние каждый раз, когда вызывается установщик:
class Undoable {
private String value;
private String previous;
public void setValue(String newValue) {
this.previous = this.value;
this.value = newValue;
}
public void restoreState() {
if (this.previous != null) {
this.value = this.previous;
this.previous = null;
}
}
}
Это дает возможность отменить последнее изменение, внесенное в объект.
Это часто реализуется путем помещения всего состояния объекта в один объект, известный как Memento. Это позволяет сохранять и восстанавливать все состояние за одно действие, вместо того, чтобы сохранять каждое поле по отдельности.
5.1. Примеры в JVM
JavaServer Faces предоставляет интерфейс StateHolder
, который позволяет разработчикам сохранять и восстанавливать свое состояние . Существует несколько стандартных компонентов, реализующих это, состоящих из отдельных компонентов, например, HtmlInputFile
, HtmlInputText
или HtmlSelectManyCheckbox
, а также составных компонентов, таких как HtmlForm
.
6. Наблюдатель
Паттерн Observer позволяет объекту указывать другим, что произошли изменения. Обычно у нас будет Субъект — объект, излучающий события, и ряд Наблюдателей — объекты, получающие эти события. Наблюдатели зарегистрируют у субъекта, что они хотят получать информацию об изменениях. Как только это произойдет, любые изменения, происходящие в субъекте, будут информировать наблюдателей :
class Observable {
private String state;
private Set<Consumer<String>> listeners = new HashSet<>;
public void addListener(Consumer<String> listener) {
this.listeners.add(listener);
}
public void setState(String newState) {
this.state = state;
for (Consumer<String> listener : listeners) {
listener.accept(newState);
}
}
}
Это берет набор прослушивателей событий и вызывает каждый из них каждый раз, когда состояние изменяется с новым значением состояния.
6.1. Примеры в JVM
В Java есть стандартная пара классов, которые позволяют нам делать именно это — java.beans.PropertyChangeSupport
и java.beans.PropertyChangeListener
.
PropertyChangeSupport
действует как класс, в который можно добавлять и удалять наблюдателей, а также уведомлять их обо всех изменениях состояния. Затем PropertyChangeListener
— это интерфейс, который наш код может реализовать для получения любых произошедших изменений:
PropertyChangeSupport observable = new PropertyChangeSupport();
// Add some observers to be notified when the value changes
observable.addPropertyChangeListener(evt -> System.out.println("Value changed: " + evt));
// Indicate that the value has changed and notify observers of the new value
observable.firePropertyChange("field", "old value", "new value");
Обратите внимание, что есть еще пара классов, которые кажутся более подходящими — java.util.Observer
и java.util.Observable
. Однако в Java 9 они устарели из-за негибкости и ненадежности.
7. Стратегия
Паттерн « Стратегия» позволяет нам писать общий код, а затем вставлять в него конкретные стратегии, чтобы получить конкретное поведение, необходимое для конкретных случаев.
Обычно это реализуется с помощью интерфейса, представляющего стратегию. Затем клиентский код может написать конкретные классы, реализующие этот интерфейс, как это необходимо для конкретных случаев . Например, у нас может быть система, в которой нам нужно уведомлять конечных пользователей и реализовывать механизмы уведомления в виде подключаемых стратегий:
interface NotificationStrategy {
void notify(User user, Message message);
}
class EmailNotificationStrategy implements NotificationStrategy {
....
}
class SMSNotificationStrategy implements NotificationStrategy {
....
}
Затем мы можем решить во время выполнения, какую именно из этих стратегий использовать для отправки этого сообщения этому пользователю. Мы также можем написать новые стратегии для использования с минимальным влиянием на остальную часть системы.
7.1. Примеры в JVM
Стандартные библиотеки Java широко используют этот шаблон, часто таким образом, который на первый взгляд может показаться неочевидным . Например, Streams API , представленный в Java 8, широко использует этот шаблон. Лямбды, предоставленные для map()
, filter()
и других методов, являются подключаемыми стратегиями, которые предоставляются универсальному методу.
Однако примеры восходят еще дальше. Интерфейс Comparator
, представленный в Java 1.2, представляет собой стратегию, которую можно использовать для сортировки элементов в коллекции по мере необходимости. Мы можем предоставить разные экземпляры Comparator
для сортировки одного и того же списка по-разному:
// Sort by name
Collections.sort(users, new UsersNameComparator());
// Sort by ID
Collections.sort(users, new UsersIdComparator());
8. Шаблонный метод
Шаблон метода шаблона используется, когда мы хотим организовать несколько различных методов, работающих вместе. Мы определим базовый класс с методом шаблона и набором из одного или нескольких абстрактных методов — либо нереализованных, либо реализованных с некоторым поведением по умолчанию. Затем метод шаблона вызывает эти абстрактные методы по фиксированному шаблону. Затем наш код реализует подкласс этого класса и реализует эти абстрактные методы по мере необходимости:
class Component {
public void render() {
doRender();
addEventListeners();
syncData();
}
protected abstract void doRender();
protected void addEventListeners() {}
protected void syncData() {}
}
Здесь у нас есть несколько произвольных компонентов пользовательского интерфейса. Наши подклассы будут реализовывать метод doRender()
для фактического рендеринга компонента. Мы также можем опционально реализовать методы addEventListeners()
и syncData()
. Когда наш UI-фреймворк визуализирует этот компонент, он гарантирует, что все три будут вызываться в правильном порядке.
8.1. Примеры в JVM
AbstractList
, AbstractSet
и AbstractMap
, используемые коллекциями Java, имеют много примеров этого шаблона . Например, оба метода indexOf()
и lastIndexOf()
работают в терминах метода listIterator()
, который имеет реализацию по умолчанию, но переопределяется в некоторых подклассах. В равной степени оба метода add(T)
и addAll(int, T)
работают с точки зрения метода add(int, T)
, который не имеет реализации по умолчанию и должен быть реализован подклассом.
Java IO также использует этот шаблон в InputStream
, OutputStream
, Reader
и Writer
. Например, класс InputStream
имеет несколько методов, которые работают с точки зрения read(byte[], int, int)
, для реализации которых требуется подкласс.
9. Посетитель
Шаблон Visitor позволяет нашему коду обрабатывать различные подклассы безопасным способом, не прибегая к проверкам instanceof .
У нас будет интерфейс посетителя с одним методом для каждого конкретного подкласса, который нам нужно поддерживать. Тогда наш базовый класс будет иметь метод accept(Visitor) .
Каждый из подклассов будет вызывать соответствующий метод для этого посетителя, передавая себя. Затем это позволяет нам реализовать конкретное поведение в каждом из этих методов, каждый из которых знает, что он будет работать с конкретным типом:
interface UserVisitor<T> {
T visitStandardUser(StandardUser user);
T visitAdminUser(AdminUser user);
T visitSuperuser(Superuser user);
}
class StandardUser {
public <T> T accept(UserVisitor<T> visitor) {
return visitor.visitStandardUser(this);
}
}
Здесь у нас есть интерфейс UserVisitor
с тремя различными методами посетителя. В нашем примере StandardUser
вызывает соответствующий метод, и то же самое будет сделано в AdminUser
и Superuser
. Затем мы можем написать нашим посетителям, чтобы они работали с ними по мере необходимости:
class AuthenticatingVisitor {
public Boolean visitStandardUser(StandardUser user) {
return false;
}
public Boolean visitAdminUser(AdminUser user) {
return user.hasPermission("write");
}
public Boolean visitSuperuser(Superuser user) {
return true;
}
}
Наш StandardUser
никогда не имеет разрешения, наш Superuser
всегда имеет разрешение, и наш AdminUser
может иметь разрешение, но это нужно искать в самом пользователе.
9.1. Примеры в JVM
Платформа Java NIO2 использует этот шаблон с Files.walkFileTree()
. Для этого требуется реализация FileVisitor
, которая имеет методы для обработки различных аспектов обхода дерева файлов. Затем наш код может использовать это для поиска файлов, распечатки соответствующих файлов, обработки множества файлов в каталоге или множества других вещей, которые должны работать в каталоге :
Files.walkFileTree(startingDir, new SimpleFileVisitor() {
public FileVisitResult visitFile(Path file, BasicFileAttributes attr) {
System.out.println("Found file: " + file);
}
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
System.out.println("Found directory: " + dir);
}
});
10. Заключение
В этой статье мы рассмотрели различные шаблоны проектирования, используемые для поведения объектов. Мы также рассмотрели примеры использования этих шаблонов в основной JVM, поэтому мы можем увидеть их использование таким образом, который уже приносит пользу многим приложениям.