JavaRush /Java блог /Random UA /Паттерни проектування в Java
Viacheslav
3 рівень

Паттерни проектування в Java

Стаття з групи Random UA
Паттерни або шаблони проектування - частина роботи розробника, яку часто недооцінюють, що призводить до того, що код стає важко підтримувати та адаптувати до нових вимог. Пропоную подивитися на те, що це взагалі таке і як це використовується у JDK. Звичайно, всі базові шаблони в тому чи іншому вигляді вже давно довкола нас. Давайте побачимо їх у рамках даного огляду.
Паттерни проектування в Java - 1
Зміст:

Шаблони

Одна з найпоширеніших вимог у вакансіях - "Знання патернів". Насамперед варто відповісти на просте запитання - "А що таке Паттерн проектування?". Паттерн перекладається з англійської як "шаблон". Тобто, це деякий зразок, за яким ми щось робимо. Так і у програмуванні. Є деякі вироблені кращі практики (best practice) і підходи до вирішення проблем, що часто зустрічаються. Кожен програміст – архітектор. Навіть коли Ви створюєте лише кілька класів або навіть один — від Вас залежить, наскільки код зможе виживати під зміною вимог, наскільки він зручний у використанні іншими. І ось тут і допоможе знання шаблонів, т.к. це дозволить швидше зрозуміти, як краще написати код так, щоб не переписувати його. Як відомо, програмісти - люди ліниві і простіше написати відразу добре, ще переробляти кілька разів) Ще патерни можуть здатися схожим на алгоритми. Але вони мають різниця. Алгоритм складається із конкретних кроків, що описують необхідні дії. Паттерни лише описують підхід, але з описують кроки реалізації. Паттерни бувають різні, т.к. вирішують різні проблеми. Зазвичай виділяють такі категорії:
  • Що породжують

    Ці патерни вирішують проблеми забезпечення гнучкості створення об'єктів.

  • Структурні

    Ці патерни вирішують проблеми ефективної побудови зв'язків між об'єктами.

  • Поведінкові

    Ці патерни вирішують проблеми ефективної взаємодії між об'єктами.

Для розгляду прикладів пропоную скористатися онлайн компілятором коду repl.it.
Паттерни проектування в Java - 2

Патерни, що породжують (creational patterns)

Почнемо з початку життєвого циклу об'єктів - зі створення об'єктів. Шаблони, що породжують, якраз і допомагають створювати об'єкти зручніше, забезпечити гнучкість цього процесу. Одним із найвідоміших є " Будівельник " (Builder). Цей патерн дозволяє створювати складні об'єкти покроково. У Java найвідоміший приклад - StringBuilder:
class Main {
  public static void main(String[] args) {
    StringBuilder builder = new StringBuilder();
    builder.append("Hello");
    builder.append(',');
    builder.append("World!");
    System.out.println(builder.toString());
  }
}
Іншим відомим підходом до створення об'єкта є винесення створення в окремий метод. Такий метод стає фабрикою об'єктів. Тому й шаблон називається " Фабричний метод " (Factory Method). У Java, наприклад, його дію можна побачити з прикладу класу java.util.Calendar. Сам клас Calendarабстрактний, а щоб його створювати використовується метод getInstance:
import java.util.*;
class Main {
  public static void main(String[] args) {
    Calendar calendar = Calendar.getInstance();
    System.out.println(calendar.getTime());
    System.out.println(calendar.getClass().getCanonicalName());
  }
}
Часто це зумовлено тим, що логіка створення об'єкта може бути непростою. Наприклад, у разі вище, ми звертаємося до базового класу Calendar, а створюється клас GregorianCalendar. Якщо ми глянемо в конструктор, то побачимо, що в залежності від умов створюються різні реалізації Calendar. Але іноді одного фабричного методу мало. Іноді потрібно створювати різні об'єкти так, щоб вони поєднувалися один з одним. У цьому нам допоможе інший шаблон - " Абстрактна фабрика " (Abstract factory). І тоді нам потрібно створювати різні заводи в одному місці. У цьому плюсом і те, що не важливі деталі реалізації, тобто. не важливо, яку саме фабрику ми отримаємо. Головне, щоби вона створювала правильні реалізації. Супер приклад:
Паттерни проектування в Java - 3
Тобто, залежно від оточення (від операційної системи) ми отримаємо певну фабрику, яка створить сумісні елементи. Як альтернатива підходу до створення через когось, ми можемо скористатися патерном " Прототип ". Суть його проста — нові об'єкти створюються за образом і подобою існуючих об'єктів, тобто. за їхнім прототипом. У Java з цим патерном стикався кожен - це використання інтерфейсу java.lang.Cloneable:
class Main {
  public static void main(String[] args) {
    class CloneObject implements Cloneable {
      @Override
      protected Object clone() throws CloneNotSupportedException {
        return new CloneObject();
      }
    }
    CloneObject obj = new CloneObject();
    try {
      CloneObject pattern = (CloneObject) obj.clone();
    } catch (CloneNotSupportedException e) {
      //Do something
    }
  }
}
Як видно, що викликає не знає, як влаштований метод clone. Тобто створення об'єкта за прототипом — обов'язок самого об'єкта. Це корисно тому, що не зав'язує об'єкта-шаблону, що використовує на реалізацію. Ну і останній у цьому списку - патерн "Одиночка" (Singleton). Мета його проста - забезпечити єдиний екземпляр об'єкта на весь додаток. Цей патерн цікавий тим, що на ньому часто показують проблеми багатопоточності. Для більш глибокого ознайомлення слід ознайомитись із цими статтями:
Паттерни проектування в Java - 4

Структурні патерни (structural patterns)

Зі створенням об'єктів стало зрозуміліше. І тепер саме час подивитися на структурні патерни. Їхня мета — побудова зручних у підтримці ієрархій класів та їх взаємозв'язків. Одним із перших і всім відомих патернів - " Заступник " (Proxy). Заступник має той самий інтерфейс, як і реальний об'єкт, тому клієнта немає різниці — працювати через заступника чи напряму. Найпростішим прикладом є java.lang.reflect.Proxy :
import java.util.*;
import java.lang.reflect.*;
class Main {
  public static void main(String[] arguments) {
    final Map<String, String> original = new HashMap<>();
    InvocationHandler proxy = (obj, method, args) -> {
      System.out.println("Invoked: " + method.getName());
      return method.invoke(original, args);
    };
    Map<String, String> proxyInstance = (Map) Proxy.newProxyInstance(
        original.getClass().getClassLoader(),
        original.getClass().getInterfaces(),
        proxy);
    proxyInstance.put("key", "value");
    System.out.println(proxyInstance.get("key"));
  }
}
Як видно, у прикладі у нас є original - це HashMap, що реалізує інтерфейс Map. Ми далі створюємо проксі, який замінює оригінальну HashMapдля клієнтської частини, яка викликає методи putі getдодаючи під час виклику свою логіку. Як бачимо, взаємодія в патерні йде через інтерфейси. Але іноді заступника недостатньо. І тоді можна використовувати патерн " Декоратор " (Decorator). Декоратор ще називають обгорткою або враппером (Wrapper). Проксі та декоратор дуже схожі, але якщо подивитися на приклад — буде видно різниця:
import java.util.*;
class Main {
  public static void main(String[] arguments) {
    List<String> list = new ArrayList<>();
    List<String> decorated = Collections.checkedList(list, String.class);
    decorated.add("2");
    list.add("3");
    System.out.println(decorated);
  }
}
На відміну від проксі декоратор обертається навколо чогось, що передали на вхід. Проксі може як приймати те, що потрібно проксировать, і сам управляти життям проксируемого об'єкта (наприклад, створювати об'єкт, що проксується). Є ще один цікавий патерн - " Адаптер " (adapter). Він схожий на декоратор - на вхід декоратор приймає один об'єкт і повертає обгортку над цим об'єктом. Відмінність у цьому, що мета цього зміна функціоналу, а адаптація одного інтерфейсу до іншого. У Java є дуже яскравий приклад щодо цього:
import java.util.*;
class Main {
  public static void main(String[] arguments) {
    String[] array = {"One", "Two", "Three"};
    List<String> strings = Arrays.asList(array);
    strings.set(0, "1");
    System.out.println(Arrays.toString(array));
  }
}
На вході маємо масив. Далі ми створюємо адаптер, що приводить масив до інтерфейсу List. Працюючи з ним, ми насправді працюємо з масивом. Тому, додавати елементи вийде, т.к. Початковий масив не змінити. І ми в цьому випадку отримаємо UnsupportedOperationException. Наступним цікавим підходом у створенні структури класів є патерн " Компонувальник " (Сomposite). Цікавий він тим, що деякий набір елементів, що використовують один інтерфейс, вишиковуються в деяку деревоподібну ієрархію. Викликаючи метод у батьківському елементі, ми отримуємо виклик цього методу за всіма необхідними дочірніми елементами. Яскравий приклад цього патерну - UI (чи то java.awt або JSF):
import java.awt.*;
class Main {
  public static void main(String[] arguments) {
    Container container = new Container();
    Component component = new java.awt.Component(){};
    System.out.println(component.getComponentOrientation().isLeftToRight());
    container.add(component);
    container.applyComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);
    System.out.println(component.getComponentOrientation().isLeftToRight());
  }
}
Як бачимо, ми додали в контейнер компонент. А потім попросабо контейнер застосувати нову орієнтацію компонентів. І контейнер, знаючи, з яких компонентів він складається, делегував виконання цієї команди всім дочірнім компонентам. Ще одним з цікавих патернів є патерн(Bridge). Називається він так, тому що описує з'єднання чи міст між двома різними ієрархіями класів. Одну з цих ієрархій вважають абстракцією, а іншу — реалізацією. Так виділено тому що абстракція сама не виконує дії, а делегує це виконання реалізації. патерн часто застосовують тоді, коли є класи "управління" і кілька видів класів "платформ" (наприклад, Windows, Linux і т. д.). і делегуватиме їм основну роботу, завдяки тому, що всі реалізації будуть дотримуватися спільного інтерфейсу, їх можна буде взаємозамінювати всередині абстракції java.awt.
Паттерни проектування в Java - 5
Докладніше див. статтю " Patterns in Java AWT ". Серед структурних патернів також хочеться відзначити патерн " Фасад " (facade). Суть його в тому, щоб за зручним та лаконічним інтерфейсом сховати складність використання бібліотек/фреймворків, що стоять за цим API. Наприклад, як приклад, можна навести JSF або EntityManager з JPA. Також є інший патерн, званий(Flyweight). Його суть полягає в тому, що якщо у різних об'єктів є однаковий стан, то його можна узагальнити і зберігати не в кожному об'єкті, а в одному місці. І тоді кожен об'єкт зможе посилатися на загальну частину, що дозволить скоротити витрати пам'яті на зберігання Часто робота даного патерну пов'язана з попереднім кешуванням або з підтриманням пулу об'єктів Цікаво, що цей патерн ми теж знаємо з самого початку:
Паттерни проектування в Java - 6
За тією самою аналогією сюди можна віднести пул рядків. На цю тему можна прочитати статтю: " Flyweight Design Pattern ".
Паттерни проектування в Java - 7

Поведінкові шаблони

Отже, ми розібралися, як можна створити об'єкти та як можна організувати зв'язок між класами. Залишилося найцікавіше забезпечити гнучкість у зміні поведінки об'єктів. І в цьому нам допоможуть поведінкові патерни. Одним із найчастіше згадуваних патернів є патерн " Стратегія ". З нього ж починається вивчення патернів у книзі " Head First. Паттерни проектування ". З допомогою патерну " Стратегія " ми можемо всередині об'єкта зберігати те, як ми виконуватимемо дію, тобто. об'єкт всередині зберігає стратегію, яка може бути змінена в тому числі під час виконання коду. Цей патерн ми часто використовуємо, коли застосовуємо компаратор:
import java.util.*;
class Main {
  public static void main(String[] args) {
    List<String> data = Arrays.asList("Moscow", "Paris", "NYC");
    Comparator<String> comparator = Comparator.comparingInt(String::length);
    Set dataSet = new TreeSet(comparator);
    dataSet.addAll(data);
    System.out.println("Dataset : " + dataSet);
  }
}
Перед нами - TreeSet. Він має поведінка — TreeSetпідтримує порядок елементів, тобто. сортує їх (оскільки він є SortedSet). Ця поведінка має стратегію, визначену за умовчанням, яку ми бачимо в JavaDoc: сортування в "natural ordering" (для рядків це лексикографічний порядок). Так відбувається, якщо використати конструктор без параметрів. Але якщо ми захочемо змінити стратегію, ми можемо передати в конструктор Comparator. У цьому прикладі ми можемо створити наш набір як new TreeSet(comparator)і тоді порядок зберігання елементів (стратегія зберігання) зміниться на той, який вказаний в компараторі. Цікаво, що є майже такий самий патерн із назвою " СтанПаттерн "Стан" говорить, що якщо у нас є у головного об'єкта деяка поведінка, залежна від стану цього об'єкта, то тоді можна описати сам стан у вигляді об'єкта і змінювати об'єкт стану. А виклики з головного об'єкта делегувати стану. Ще один патерн, відомий нам з вивчення самих основ мови Java - патерн " Комманда ". Цей патерн проектування говорить про те, що різні команди можна представляти у вигляді різних класів. Цей патерн дуже схожий на патерн "Стратегія". ми перевизначали те, як буде виконуватися конкретна дія (наприклад, сортування в TreeSet). У патерні "Комманда" ж ми перевизначаємо те, яка взагалі дія буде виконана. коли ми використовуємо потоки:
import java.util.*;
class Main {
  public static void main(String[] args) {
    Runnable command = () -> {
      System.out.println("Command action");
    };
    Thread th = new Thread(command);
    th.start();
  }
}
Як бачимо, command визначає дію чи команду, яка буде виконана у новому потоці. Також варто розглянути і патерн " Ланцюжок обов'язків " (Chain of responsibility). Цей патерн теж дуже просто. Цей патерн каже, що якщо щось треба обробити, можна зібрати обробники в ланцюжок. Наприклад, такий шаблон часто використовується у веб-серверах. На вході сервер має певний запит користувача. Далі цей запит проходить ланцюжок обробки. У цьому ланцюжку обробників є фільтри (наприклад, не приймати запити з чорного списку IP-адреса), обробники автентифікації (пускати лише дозволених користувачів), обробник заголовків запиту, обробник кешування і т.д. Але є в Java і простіший і зрозуміліший приклад — java.util.logging:
import java.util.logging.*;
class Main {
  public static void main(String[] args) {
    Logger logger = Logger.getLogger(Main.class.getName());
    ConsoleHandler consoleHandler = new ConsoleHandler(){
		@Override
            public void publish(LogRecord record) {
                System.out.println("LogRecord обработан");
            }
        };
    logger.addHandler(consoleHandler);
    logger.info("test");
  }
}
Як видно, обробники (Handlers) додаються до списку обробників логера. Коли логгер отримує повідомлення обробки, кожне таке повідомлення проходить через ланцюжок хендлеров (з logger.getHandlers) даного логгера. Ще один патерн, який ми бачимо щодня " Ітератор ". Суть його полягає в тому, щоб розділити колекцію об'єктів (бо клас, що представляє структуру даних. Наприклад, List) і обхід цієї колекції.
import java.util.*;
class Main {
  public static void main(String[] args) {
    List<String> data = Arrays.asList("Moscow", "Paris", "NYC");
    Iterator<String> iterator = data.iterator();
    while (iterator.hasNext()) {
      System.out.println(iterator.next());
    }
  }
}
Як видно, ітератор — не є частиною колекції, а представлений окремим класом, який оминає колекцію. Використовуючи ітератор навіть може знати про те, якою колекції він итерируется, тобто. яку колекцію він оминає. Варто розглянути і патерн .(Visitor). Паттерн відвідувач дуже схожий на ітератор. Даний патерн допомагає обходити структуру об'єктів і виконувати дії над цими об'єктами. Відрізняються вони скоріше концепцією. лише елементи з послідовності.Відвідувач саме про те, що є деяка ієрархія або структура об'єктів, які ми відвідуємо.Наприклад, ми можемо використовувати окрему обробку каталогів і окрему обробку файлів.В Java "з коробки" є реалізація цього патерну у вигляді java.nio.file.FileVisitor:
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.io.*;
class Main {
  public static void main(String[] args) {
    SimpleFileVisitor visitor = new SimpleFileVisitor() {
      @Override
      public FileVisitResult visitFile(Object file, BasicFileAttributes attrs) throws IOException {
        System.out.println("File:" + file.toString());
        return FileVisitResult.CONTINUE;
      }
    };
    Path pathSource = Paths.get(System.getProperty("java.io.tmpdir"));
    try {
      Files.walkFileTree(pathSource, visitor);
    } catch (AccessDeniedException e) {
      // skip
    } catch (IOException e) {
      // Do something
    }
  }
}
Іноді виникає необхідність одним об'єктам реагувати на зміни в інших об'єктах і тоді нам допоможе патерн " Спостерігач" ( Observer ). Найзручніший спосіб — це забезпечити механізм підписки, що дозволяє одним об'єктам стежити та реагувати на події, що відбуваються в інших об'єктах. Цей патерн часто застосовується у різних Listener'ах і Observer'ах, реагують різні події. Як найпростіший приклад можна згадати реалізацію цього патерну з JDK першої версії:
import java.util.*;
class Main {
  public static void main(String[] args) {
    Observer observer = (obj, arg) -> {
      System.out.println("Arg: " + arg);
    };
    Observable target = new Observable(){
      @Override
      public void notifyObservers(Object arg) {
        setChanged();
        super.notifyObservers(arg);
      }
    };
    target.addObserver(observer);
    target.notifyObservers("Hello, World!");
  }
}
Є ще один корисний поведінковий шаблон - " Посередник " (Mediator). Корисний він тим, що в складних системах допомагає прибрати зв'язок між різними об'єктами та делегувати всі взаємодії між об'єктами деякому об'єкту, який є посередником. Одним із найяскравіших застосувань даного патерну є Spring MVC, який використовує цей патерн. Докладніше про це можна прочитати тут: " Spring: Mediator Pattern ". Часто можна побачити в прикладах так само java.util.Timer:
import java.util.*;
class Main {
  public static void main(String[] args) {
    Timer mediator = new Timer("Mediator");
    TimerTask command = new TimerTask() {
      @Override
      public void run() {
        System.out.println("Command pattern");
        mediator.cancel();
      }
    };
    mediator.schedule(command, 1000);
  }
}
Приклад зовні швидше нагадує патерн команда. А суть патерна "Посередник" прихована у реалізації Timer'а. Усередині таймера є черга завдань TaskQueue, є потік TimerThread. Ми, як клієнти цього класу, не взаємодіємо з ними, а взаємодіємо з тим Timer, який на наш виклик його методів звертається до методів інших об'єктів, посередником яких є. Зовні може здатися дуже схожим на "Фасад". Але різниця в тому, що колись використовується Фасад — компоненти не знають, що фасад існує і звертаються один до одного. А коли використовується "Посередник", то компоненти знають та використовують посередника, але не звертаються один до одного безпосередньо. Варто розглянути і патерн .(Template Method) Зрозумілий вже за назвою шаблон. Суть полягає в тому, що код написаний так, що користувачам коду (розробникам) надається деякий шаблон алгоритму, кроки в якому дозволяється перевизначати. ​​Це дозволяє користувачам коду не писати весь алгоритм, а думати тільки над тим, як правильно виконати той чи інший крок цього алгоритму.Наприклад, у Java є абстрактний клас AbstractList, що визначає поведінку ітератора по .Однак, сам ітератор використовує методи Listаркуша, такі як: get, set, . в є шаблоном алгоритму ітерування по листу, а розробники конкретних реалізаційremoveAbstractListAbstractListAbstractListзмінюють поведінку цього ітерування, визначаючи поведінку конкретних кроків. Останній з розбирається нами патернів - патерн " Знімок " (Momento). Суть його полягає у збереженні деякого стану об'єкта з можливістю цей стан відновити. Найвідомішим прикладом із JDK є серіалізація об'єкта, тобто. java.io.Serializable. Давайте розглянемо приклад:
import java.io.*;
import java.util.*;
class Main {
  public static void main(String[] args) throws IOException {
    ArrayList<String> list = new ArrayList<>();
    list.add("test");
    // Save State
    ByteArrayOutputStream stream = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(stream)) {
      out.writeObject(list);
    }
    // Load state
    byte[] bytes = stream.toByteArray();
    InputStream inputStream = new ByteArrayInputStream(bytes);
    try (ObjectInputStream in = new ObjectInputStream(inputStream)) {
      List<String> listNew = (List<String>) in.readObject();
      System.out.println(listNew.get(0));
    } catch (ClassNotFoundException e) {
      // Do something. Can't find class fpr saved state
    }
  }
}
Паттерни проектування в Java - 8

Висновок

Як ми побачабо з огляду, патернів існує безліч. Кожен із них вирішує своє завдання. І знання цих патернів може допомогти Вам вчасно зрозуміти, як написати Вашу систему так, щоб вона була гнучка, підтримувана та стійка до змін. І насамкінець трохи посилань для глибшого занурення: #Viacheslav
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ