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

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

Статья из группы Random
Паттерны или шаблоны проектирования — часть работы разработчика, которую часто недооценивают, что приводит к тому, что код становится трудно поддерживать и адаптировать под новые требования. Предлагаю посмотреть на то, что это вообще такое и как это используется в 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 яркие пример этому — 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), и тогда порядок хранения элементов (стратегия хранения) поменяется на тот, который указан в компараторе. Интересно, что есть почти такой же паттерн с названием "Состояние" (State). Паттерн "Состояние" говорит, что если у нас есть у главного объекта некоторое поведение, зависимое от состояние этого объекта, то тогда можно описать само состояние в виде объекта и менять объект состояния. А вызовы из главного объекта делегировать состоянию. Ещё один паттерн, известный нам с изучения самых основ языка 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). Cамый удобный способ — это обеспечить механизм подписки, позволяющий одним объектам следить и реагировать на события, происходящие в других объектах. Данный паттерн часто применяется в различных 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, remove. Поведение этих методов определяет разработчик наследников AbstractList. Таким образом итератор в AbstractList — является шаблоном алгоритма итерирования по листу. А разработчики конкретных реализаций AbstractList меняют поведение этого итерирования, определяя поведение конкретных шагов. Последний из разбираемых нами паттернов — паттерн "Снимок" (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
Комментарии (15)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Roma Ко Уровень 17
26 декабря 2023
Одним из самых известных является "Строитель" (Builder). Данный паттерн позволяет создавать сложные объекты пошагово. В Java самый известный пример — StringBuilder:-----разве Билдер?
Vadim Zhalnin Уровень 17
28 января 2022
Прочесть некоторые картинки очень трудно.
N1k0 Уровень 22
5 июня 2021
Приглашаю всех интересующихся паттернами на Java заглянуть в мой репозиторий FantasyPattern В нем реализованы все популярные паттерны с описаниями, в простой игровой форме Буду рад Вашим PullRequest и конструктивной критике!
virex Уровень 25
10 декабря 2019
Шаблонов много, но что-бы их понять, лучше пояснить для чего они нужны. Вот как я понял: Строитель - для "ручного" создания объекта (вызывая нужные методы) Фабрика - обычно статический метод создания сложных классов Прототип - клонирование живого объекта Заместитель - добавление своей логики в процесс (например логирование) Декоратор - добавление своей логики без изменения логики работы старого или очень критичного класса Адаптер - для соединения разных классов, адаптация одного класса для другого Компоновщик - для управления дочерними классами одним методом Мост - позволяет абстрактным методам (открыть, записать, закрыть и т.д.) работать в разных реализациях (windows, linux) Фасад - позволяет одной командой проделать кучу специфической работы Легковес - хранит в себе какие-то данные множества однотипных объектов, вместо того что-бы они сами хранили данные, тем самым экономит оперативную память Стратегия - в зависимости от признака выполняет нужную стратегию Команда - в зависимости от признака выполняет нужное действие Цепочка обязанностей - добавление в процесс множества промежуточных действий (например фильтров) Итератор - для обхода каждого элемента в списке Посетитель - тоже что и Итератор но позволяет обработать каждый элемент Наблюдатель - подписываемся на какое-нибудь событие происходящее с определенным объектом, для реагирования Посредник - класс через который удобно работать с объектами в сложной системе Шаблонный метод - абстрактный класс в котором заключены все возможные методы, реализуется в классе-потомке где можно переопределить методы Снимок - текущее состояние объекта для сохранения/загрузки
8 декабря 2019
Вопрос про курс https://www.coursera.org/learn/design-patterns - на курсе на каком языке программирования разбираются паттерны?
Даниил Синицын Уровень 8
27 августа 2019
Статья полезная, но для новичков очень плотная подача информации и сложновата для понимания. Вернусь видимо к ней позднее.
11 мая 2019
Вот предположим, что я нифига этого не знаю. Статья об этом рассказывает. Вроде бы. Вот честно, заголовки говорят, что паттерны деляться на.. и называются вот так. И все. остальные буквы не понятны. примеры не понятны. Это как объяснять японские иероглифы китайским языком русскоговорящему. Для профи-гурманов статья зашла как я вижу. Или притворяются, что все поняли и так чопорно хвалят, чтобы не палиться, что тоже ничего полезного не вынесли. Но автор же старался, переводил. Нет. Статья обзорная, но ничему не учит. вот абсолютно. Чтобы понять очередной паттерн, велком в статью- оригинал или плейлист. Гениально, а что так можно было? Вывалить перевод и слать всех лесом. А ведь можно было постараться и пример привести более понятный и объяснить соими словами, а не переводом с аглицкого. Вот пройду весь курс, прочитаю пару книг и посмотрю тематические плейлисты и вернусь сюда и тоже чопорно похвалю автора за старания. Статья бесполезная.
Виталий Уровень 41
7 мая 2019
Очень много ошибок в тексте.
Andrei Уровень 41
1 мая 2019
@Viacheslav, поделитесь секретом, когда вы успеваете все это читать? Да еще и спецификации ))). Ну и да, очередно спасибо, за очередную хорошую статью. Так держать.
hidden #2039138 Уровень 35
1 мая 2019
Блин. Очень полезная статья. Автор, пиши еще!)