Последнее изменение: 13 сентября 2010г.

Модульный дизайн или «что такое DIP, SRP, IoC, DI и т.п.»

Вступление

Когда в 1996 году язык Java только появился на свет, вокруг него было много шума. Оно и понятно – многие ключевые принципы, заложенные в язык, были новыми, требующими разъяснения. Одним из таких приципов был WORAWrite Once, Run Anywhere. Один раз написал – используешь везде. Прежде всего это относилось к байткоду. Однако этим дело не ограничивалось. Java позиционировался как язык, на котором можно писать повторно используемый код. Т.е. это самое Anywhere распространялось не только на другие платформы, но и на другие приложения. Переиспользование кода действительно было очень заманчивым, ибо резко снижало затраты на разработку.

Шло время. Java использовался все больше. И все больше раздавалось голосов о том, что переиспользуемый код – это миф. Что его неизбежно придется подправлять в каждом новом проекте. А если так – это все смысла не имеет. Аргументацию я при этом встречал весьма забавную. «Я пишу уже много лет, но переиспользовать так ничего и не получается. Все равно необходимы изменения.»

И это действительно было бы смешно, если бы не было грустно. И вот почему. Существует код, который используется в тысячах, если не в десятках тысяч проектов. И этот код не меняется. В смысле, он развивается, но при этом сохраняется обратная совместимость. Самый яркий пример – библиотеки. Возьмем, например, Log4J (http://www.skipy.ru/useful/logging.html#uls_l4j). Проекты, в которых она используется, посчитать сложно. Это де-факто стандартное средство для ведения логов в Java. При этом за последние лет пять количество изменений в этой библиотеке – около десятка. И в основном незначительные улучшения.

Log4J в этом плане не одинока – jakarta.apache.org содержит большое количество библиотек, которые очень широко используются. И это один из многих репозиториев.

Возникает противоречие. С одной стороны, хор отрицающих переиспользуемый код звучит всё громче. С другой стороны – вот они, примеры-то. Переиспользуют, и еще как. Вопрос. Как это получается?

Ответ на самом-то деле очень прост. Анализ показывает, что переиспользуемые модули имеют грамотно построенную архитектуру. Не претендуя на сокровенное знание, я хочу в этой статье дать описание некоторых приципов, соблюдение которых существенно приблизит ваш код к возможности его переиспользования. И начнем мы с рассмотрения того, как чаще всего развивается код.

Как это бывает в жизни

Рассмотрим простой пример. Путь мы разработали некий модуль. Что он делает – не суть важно. Этот модуль необходимо сконфигурировать. В качестве формата выбран XML.

Первая реализация

Итак, у нас есть изначальное требование – конфигурация модуля из файла. Модуль наш, файл можно назвать как угодно. Не мудрствуя лукаво, мы создаем код:

package ru.skipy.tests.architecture.module_design.v1;

import java.io.FileInputStream;
import java.io.IOException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.xml.sax.SAXException;

public class Module {

    public Module() throws IOException, ParserConfigurationException, SAXException {
        Document document = DocumentBuilderFactory.newInstance().
                newDocumentBuilder().parse(new FileInputStream("config.xml"));
        Configuration configuration = new Configuration();
        // working with document to get configuration
        init(configuration);
    }

    private void init(Configuration configuration) {
        // initialization code
    }

    private static class Configuration {
        // ... some fields
    }
}

Код прозрачен, работает. Используем:

public static void main(String[] args) {
    try {
        new Module();
    } catch (IOException ex) {
        // handle exception
    } catch (ParserConfigurationException ex) {
        // handle exception
    } catch (SAXException ex) {
        // handle exception
    }
}

И вот, понадобилось нам использовать этот модуль в другом приложении. А там – увы и ах – используется другой модуль, у которого конфигурационный файл тоже называется config.xml. Но модуль уже не наш и изменить имя файла мы не сможем. Значит, надо менять наш модуль.

Вторая реализация

Итак, надо обеспечить возможность создания модуля с любым именем конфигурационного файла. И при этом – не поломать существующий код. Можно так сделать? Легко. Передаем имя файла в конструктор:

package ru.skipy.tests.architecture.module_design.v2;

import java.io.FileInputStream;
import java.io.IOException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.xml.sax.SAXException;

public class Module {

public static final String DEFAULT_CONFIG = "config.xml"; public Module() throws IOException, ParserConfigurationException, SAXException { this(DEFAULT_CONFIG); } public Module(String name) throws IOException, ParserConfigurationException, SAXException { Document document = DocumentBuilderFactory.newInstance(). newDocumentBuilder().parse(new FileInputStream(name));
Configuration configuration = new Configuration(); // working with document to get configuration init(configuration); } private void init(Configuration configuration) { // initialization code } private static class Configuration { // ... some fields } }

Все просто, добавили новый конструктор, старый оставили для совместимости. И – ура! – мы можем использовать модуль с любым именем конфигурационного файла:

public static void main(String[] args) {
    try {
new Module("module-config.xml");
} catch (IOException ex) { // handle exception } catch (ParserConfigurationException ex) { // handle exception } catch (SAXException ex) { // handle exception } }

Вроде как всё замечательно. Но в один прекрасный день выясняется, что если приложение запустить не из той точки, где лежит конфигурационный файл – всё будет плохо. А именно так и происходит в третьем проекте! Надо исправлять.

Третья реализация

Решение напрашивается само – поместить конфигурационный файл в classpath. Но тогда его надо читать по-другому. Исправляем модуль еще раз:

package ru.skipy.tests.architecture.module_design.v3;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.xml.sax.SAXException;

public class Module {

    public static final String DEFAULT_CONFIG = "config.xml";

    public Module() throws IOException, ParserConfigurationException, SAXException {
        this(DEFAULT_CONFIG);
    }

public Module(String name) throws IOException, ParserConfigurationException, SAXException { this(new FileInputStream(name)); } public Module(InputStream is) throws IOException, ParserConfigurationException, SAXException { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is);
Configuration configuration = new Configuration(); // working with document to get configuration init(configuration); } private void init(Configuration configuration) { // initialization code } private static class Configuration { // ... some fields } }

Добавили конструктор, принимающий java.io.InputStream. Старые конструкторы оставили для совместимости. Теперь мы можем грузить конфигурацию как ресурс:

public static void main(String[] args) {
    try {
new Module(Module.class.getResourceAsStream("/module-config.xml"));
} catch (IOException ex) { // handle exception } catch (ParserConfigurationException ex) { // handle exception } catch (SAXException ex) { // handle exception } }

Ну... вроде как работает. Меж тем мы растем и замахиваемся на крупный проект. И вот оказывается, что конфигурационных файлов у нас – несколько десятков. И все мелкие. В такой конфигурации впору утонуть. И тонем. Надо исправлять.

Четвертая реализация

Решение в данном случае тоже напрашивается само – объединить все конфигурационные файлы в один. То есть наш модуль должен конфигурироваться по части дерева XML. Реализуем:

package ru.skipy.tests.architecture.module_design.v4;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Node;
import org.xml.sax.SAXException;

public class Module {

    public static final String DEFAULT_CONFIG = "config.xml";

    public Module() throws IOException, ParserConfigurationException, SAXException {
        this(DEFAULT_CONFIG);
    }

    public Module(String name) throws IOException, ParserConfigurationException, SAXException {
        this(new FileInputStream(name));
    }

public Module(InputStream is) throws IOException, ParserConfigurationException, SAXException { this(DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is)); } public Module(Node configNode) {
Configuration configuration = new Configuration();
// working with configNode to get configuration
init(configuration); } private void init(Configuration configuration) { // initialization code } private static class Configuration { // ... some fields } }

Добавили еще один конструктор, теперь уже принимающий org.w3c.dom.Node. Старые конструкторы – в режиме совместимости. Читаем общий файл и вырезаем нужный нам кусок XML, например, через XPath:

public static void main(String[] args) {
Node moduleConfigNode = null; try { Document document = DocumentBuilderFactory.newInstance(). newDocumentBuilder().parse(Module.class.getResourceAsStream("/application-config.xml")); moduleConfigNode = (Node) XPathFactory.newInstance().newXPath(). evaluate("/module-config", document, XPathConstants.NODE);
} catch (IOException ex) { // handle exception
} catch (XPathExpressionException ex) { // handle exception
} catch (ParserConfigurationException ex) { // handle exception } catch (SAXException ex) { // handle exception }
if (moduleConfigNode != null) { new Module(moduleConfigNode); }
}

И вот теперь – последний аккорд. Приложение необходимо разворачивать в кластере, конфигурация переносится в базу данных. Всё. Малой кровью тут не обойдешься. Хочешь, не хочешь – а придется переделывать сам механизм инициализации.

Итого...

Подведем итог этой маленькой, но очень поучительной истории. Перефразируя Коровьева-Фагота, перед вами, граждане, случай так называемого неправильного дизайна. Пример того, как делать не надо. Мы использовали модуль в пяти проектах и при этом один раз его писали, и четыре – модифицировали. И то, что нам пришлось менять только конструкторы – это неслыханное везение. Ну или следствие учебного примера. :) В реальной жизни изменения были бы гораздо более масштабными и неприятными.

А теперь скажите, только честно – сколько раз вы такое видели в жизни? А сколько делали сами?

Ладно, не будем грустить. Всё можно исправить. Надо только знать, как. Именно это мы сейчас и будем выяснять.

Общие принципы дизайна переиспользуемого кода

Итак, вопрос. Что нужно сделать, чтобы наш код стал переиспользуемым? Думаю, я никого не удивлю, если скажу, что думать об этом надо заранее. До того, как код будет писаться.

Первое и основное, что надо сделать, – определить, с какими объектами вашему коду придется иметь дело. Прежде всего – что ему нужно от его окружения. Сам-то модуль представляет собой черный ящик, вы можете менять его так, как сочтете нужным. Проблемы, как правило, возникают на стыке разных модулей (или модуля и окружения).

Поясню, что я имею в виду. Допустим, вы пишете модуль, который работает с базой данных. Что нужно для того, чтобы он работал? На самом общем уровне? Правильно, нужно соединение с базой. Вот вам и объект – «соединение с базой». Если вам нужно эти данные показать пользователю – еще один объект: «средство представления данных» Обратите внимание – это не таблица, не визуальная форма. Это самое общее определение. Что совершенно не мешает нам определить, какие операции нам нужны. Со стороны «соединения с базой» – умение выполнить запрос и вернуть его результаты. Со стороны «средства представления данных» – умение вывести данные в формате, необходимом пользователю, и в то место, куда укажет пользователь.

Думаю, суть ясна. Даже если не до конца – не беда, сейчас станет яснее.

Хорошо, абстрактные объекты определены. Как с ними работать? И вот тут мы вплотную подходим к первому из принципов проектирования, которые будут рассмотрены в этой статье.

Принцип «Dependency Inversion»

Dependency InversionИнвертирование зависимостей. Сразу акцентирую внимание – не путайте этот принцип с Dependency InjectionВнедрение зависимостей. О нем мы будем говорить в следующем разделе. Принцип инвертирования зависимостей можно сформулировать так:

1. Модули более высокого уровня не должны зависеть от модулей более низкого уровня. И те, и другие должны зависеть от абстракций.
2. Абстракция не должна зависеть от деталей реализации. Реализация должна зависеть от абстракции.

Под высоким и низким уровнем тут понимается следующее. Одни модули используются другими. Используемые модули расположены ниже, использующие – выше. В форме UML-диаграммы это можно представить так:

dependency inversion

Правда, она развернута. В общем, высокий уровень слева, низкий – справа.

Итак, что мы тут видим. Если некоторая абстракция – «интерфейс модуля». Есть код, который использует модуль – «клиент модуля». И есть реализация интерфейса модуля. Принцип инвертирования зависимостей определяет отношение этих трех сущностей так:

  • Клиент ничего не должен знать о реализации модуля. Он имеет дело только с интерфейсом.
  • Реализация модуля ничего не должна знать о клиенте. Она предоставляет функциональность и реализует определенный интерфейс.
  • Интерфейс не должен зависеть от реализации. Он вообще не должен о ней знать.
  • Реализация должна зависеть от интерфейса. Это естественно.

Казалось бы – все просто. Однако именно этот принцип в примере, с которого начиналась статья, не выполнялся на протяжении четырех реализаций! Судите сами:

  • Начальная реализация. Код модуля – «клиент» для конфигурации – «знает», что мы читаем: а) XML, б) из потока, в) файлового, г) из файла по имени config.xml. Итого – известно четыре факта о реализации.
  • Вторая версия. Код модуля «знает», что мы читаем: а) XML, б) из потока, в) файлового. Имя файла уже неизвестно. Но все равно – три факта о реализации.
  • Третья версия. Код модуля «знает», что мы читаем: а) XML, б) из потока. Тип потока неизвестен. Два факта о реализации.
  • Четвертая версия. Код модуля «знает», что мы читаем XML. Откуда – неизвестно. И вот этот один факт о реализации все равно вынуждает нас переписать чтение конфигурации в пятый раз, при переносе настроек в базу.

Обратите также внимание на следующий момент. Чем меньше фактов о реализации нам известно – тем больше у нас вариантов использования. Если в начале мы вообще ничего не можем поменять, будучи привязаны к файлу с определенным именем в определенном месте, то в четвертой реализации – все еще не совершенной! – мы XML можем читать уже откуда угодно. Хоть из файла на диске, хоть из ресурса, хоть из сети получить. Да хоть самим DOM построить! И на модуль это никак не повлияет!

Принцип инвертирования зависимостей является тем фундаментом, на котором держится переиспользование кода. Собственно, переиспользование как таковое означает, что абстрактный механизм остался тем же, но изменились его конкретные составляющие.

Вернемся к примеру модуля, читающего данные из базы, обрабатывающего и публикующего их. Возьмем соединение с базой данных. Ваш код изменится от того, как именно получено соединение? Нет. И не может! Вы используете интерфейс, а как он реализован – вас не волнует. Хоть напрямую создали, хоть из пула соединений получили, хоть из DataSource, развернутого в сервере приложений – до тех пор, пока используемая вами реализация соответствует контракту интерфейса, ваш код будет работать одинаково.

И то же самое с публикацией данных, полученных в вашем модуле. У вас есть интерфейс «средство представления данных», он же DataView. У него есть метод publishData(Data). Вы знаете только это. Но и этого достаточно – вы вызываете метод, и вас не волнует, куда ушли эти данные. В таблицу, по сети, в базу, в файл – для вашего модуля это неважно.

Фактически, мир вашего модуля ограничивается двумя интерфейсами – соединением с базой и публикацией данных. И его уже можно переиспользовать при любых комбинациях реализаций этого интерфейса.

Вы еще не заметили, что мы наступили на всё те же грабли???

Если нет – тогда вопрос на засыпку: а почему мы говорим именно о соединении с базой данных? Оно действительно является единственным возможным источником на все времена?

Бывает, что единственным. Но с большой вероятностью – нет. И если потребуется читать данные откуда-то из другого источника – нашу бизнес-логику придется серьезно перелопатить.

В чем ошибка? В неверном определении абстракции. Нам не нужно соединение с базой, хотя изначально мы рассчитываем именно на него. Нам нужен источник данных. Разницу чувствуете? Да, им может быть база данных. Но это уже – конкретика.

Этот пример показывает, насколько легко промахнуться с определением абстракции. И именно поэтому я сказал, что думать о переиспользовании надо заранее. Ибо каждая промашка и завязка на конкретику осложняет переиспользование вашего кода в дальнейшем.

Как же стоит действовать, чтобы промахиваться по минимуму (а в идеале – вообще не)? Однозначного ответа нет. Я бы сказал так: надо начинать проектирование с максимально примитивного уровня. В смысле, максимально абстрактного.

Берем лист бумаги и карандаш. Вот у нас есть наш модуль. Рисуем прямоугольник, пишем – «модуль». Он получает «данные» из «источника данных» и выдает результат – «выходные данные» в некий «приемник». Это еще четыре прямоугольника. Все. На самом верхнем уровне мы абстракцию определили.

Дальше, смотрим на абстракции. Что представляют собой входные данные? Скорее всего их структура фиксирована. Во всяком случае, ее можно зафиксировать. В самом деле, совсем абстрактные данные наш модуль не обработает, его вход обусловлен бизнес-логикой. То есть данные можно определить в виде одного класса известной структуры, InputDataUnit. Уже хорошо.

Теперь источник данных. Скорее всего – ничего определенного. В смысле – ничего определенного нельзя сказать о том, откуда данные возьмутся. Как мы их хотим получать – мы прекрасно можем определить по бизнес-логике. Если нам нужен, например, список записей по параметрам и отдельно запись по идентификатору – именно эти потребности и лягут в определение операций источника данных.

Точка назначения – приемник данных. Точно ничего определенного. Интерфейс с одной операцией, реализация – на совести потребителя данных.

Ну и последний момент – выходные данные. Мы их генерируем, мы и диктуем формат. Один класс, структура известна.

В итоге мы получаем вот такую картинку:

dependency inversion sample

И вот это уже действительно мир нашего модуля. Абстрагированный входной интерфейс, абстрагированный выходной интерфейс. В этих условиях наш модуль может переиспользоваться без каких-либо проблем. Нам остается только реализовывать внешние интерфейсы, если подходящих нет. Изменений в модуле это не вызовет.

Таким образом, наш модуль зависит только от абстракций. Он ничего не знает о том, как устроены реализации этих абстракций. Сами абстракции на реализацию тоже не завязаны. Да и реализацию можно сделать так, чтобы она ничего не знала о модуле, который ее будет использовать. Она будет опираться только на абстракцию. Все требования принципа dependency inversion выполнены.

Прошу еще заметить вот что. Я нигде ни слова не сказал о том, как устроен сам модуль. Если он внутри довольно сложный – а чаще всего это именно так – то при проектировании внутренней структуры применяется тот же прием. Выделяются абстракции, ... В итоге части, создаваемые внутри модуля, тоже можно переиспользовать. Причем практика показывает, что не только в нем, а в других модулях тоже. Сами эти части проектируются аналогично.

Ну, собственно, вот. Теперь, думаю, принцип dependency inversion уже должен быть понятен.

О чем стоит еще поговорить, так это о грани, на которой стоит остановиться. Абстракции можно выделять чуть ли не бесконечно. И все из них прятать за интерфейсами... Я видел такие проекты. Ничего хорошего. И вот тут мы плавно подходим к следующему принципу:

Принцип «Single Responsibility»

Принцип single responsibilityединственной обязанности – один из ключевых принципов объектно-ориентированного дизайна. Он говорит вот о чем:

Объект должен иметь одну единственную обязанность, и эта обязанность должна быть целиком инкапсулирована внутри объекта.

Если перевести на человеческий язык – каждый объект должен делать что-то одно, и его функциональность должна быть целиком спрятана внутри него.

Что это означает? А вот что: если объект делает несколько разных вещей – по сути это несколько объектов. И их надо разделять.

Иногда в формулировке принципа единственной обязанности вместо слова «объект» употребляется слово «класс». Однако я лично склонен рассматривать его как более общий принцип, применимый к дизайну как таковому, на любом уровне абстракции.

Существует еще одна интересная формулировка принципа единственной обязанности: должна быть только одна причина для модификации кода1. Эта формулировка хороша тем, что может легко применяться для проверки соблюдения самого принципа. Возьмем наш изначальный пример. Какие в нем могут быть причины для модификации кода? Первая – изменилась бизнес-логика. Вторая – изменился формат конфигурации. Вот вам и признак того, что конфигурацию надо отделять от бизнес-логики.

Возьмем второй пример, в окончательном варианте. Причина для изменения самого модуля всего одна – изменения бизнес-логики. Никакие изменения формата (не путайте с составом) и/или источника данных на работу модуля не повлияют. Для источника данных причиной изменения могло бы быть изменение формата данных, но данные мы уже отделили в отдельный объект. Реализация же выходного интерфейса нам вообще не видна. Т.е. тут принцип единственной обязанности соблюдается.

Прошу заметить – принцип соблюдается на том уровне абстракции, на котором мы сейчас находимся. Про внутреннюю структуру самого модуля мы ничего не можем сказать. Это черный ящик. Объект. Однако, если мы опустимся на уровень ниже, – и там тоже необходимо добиваться соблюдения принципа единственной обязанности. На новом уровне абстракции. И т.д. и т.п. В итоге мы придем к отдельным классам.

На самом деле грань между объектом на очередном уровне абстракции и отдельным классом достаточно условна. При желании практически каждый класс можно разбить на составляющие. Я бы сказал, что тут надо исходить из принципа разумной достаточности. Если вы считаете, что вот этот объект уже представляет собой неделимую единицу функциональности – пора остановиться. Разбивать объекты мельче уже нецелесообразно.

Может получиться так, что проработка аритектуры до самой глубины приведет к образованию множества практически тривиальных классов, интерфейсов и иже с ними. При этом часто возникает желание сделать классы побольше, пусть даже ценой укрупнения объектов и слияния классов. Допустимо это или нет – решать вам самим. Важно при этом понимать, чем вы рискуете, укрупняя классы.

Кроме того, существует такой фактор, как время. Дизайн до самого последнего уровня может занять очень много времени. Да и не всегда он вообще возможен – зачастую функциональность отдельных модулей можно до конца понять только в процессе активной разработки. Потому чаще всего изначально архитектура прорабатывается уровня на два, максимум три. Остальное – уже тогда, когда до этого доходит дело.

Ну а раз так – случаются и промахи. Скажем, считали функциональность неделимой, а потом понадобилось ее переиспользовать, да еще как. Сплошь и рядом такое происходит. Вот для этого и служат рефакторинги. Класс достаточно просто переработать, если он небольшой. И, естественно, при этой переработке необходимо соблюдать оба упомянутых принципа – dependency inversion и single responsibility.

* * *

Принципы – оно, конечно, замечательно. Однако, как известно, «в теории нет разницы между теорией и практикой, а на практике она есть». И если вы посмотрите на обе UML-диаграммы, то увидите, что в модуле есть поля типа внешних интерфейсов. И эти поля должны быть как-то инициализированы. Вопрос – как? Именно к этой теме мы сейчас и перейдем. И начнем с таких понятий, как...

Локаторы сервисов

Как проще всего получить объект? Создать его. :) Однако этого мы сделать не можем – для создания необходимо вызвать конструктор конкретного типа, т.е. модуль будет зависеть от конкретики. Это нарушение принципа dependency inversion.

Следующий по простоте способ – получить объект у кого-нибудь, у кого он уже есть. Представьте себе, что у нас есть какой-то репозиторий, в котором лежат нужные нам объекты. Например, такой:

public class ObjectRepository{

    private static final ObjectRepository instance = new ObjectRepository();

    private Map<Class<?>, Object> impls;

    public <T> T getImplementation(Class<T> objectClass){
        return objectClass.cast(impls.get(objectClass));
    }

    public static ObjectRepository getInstance(){
        return instance;
    }
}

Тогда мы можем получить экземпляр DataDestination, например, вызовом:

dataDestination = ObjectRepository.getInstance().getImplementation(DataDestination.class)

Собственно, всё. Наш экземпляр ObjectRepository и является локатором сервисов. Вообще, по определению,

Локатор сервисов представляет собой некий глобальный, известный всем объект, предоставляющий по запросу информацию, необходимую для выполнения задачи.

Примером локатора сервисов может служить, например, служба JNDI – Java Naming and Directory Interface, – возвращающая ссылку на объект по некоторому имени.

При всех достоинствах локатора сервисов, есть и недостатки. Основной – то, что об этом глобальном объеке должны знать все. И что он вообще должен быть. Если вы пишете какой-то модуль, который может использоваться в различном окружении – вы не всегда сможете обеспечить наличие локатора, да еще и нужного вам типа. Тут нужен другой подход. И такой подход есть.

«Inversion of Control» и «Dependency Injection»

Для того, чтобы понять, что такое Inversion of Control, представьте себе разработчика в первый день работы на новом месте. Вот, приходит он утром в офис, а ему и говорят:

– Вот тебе, дорогой друг, адрес склада. Пойди туда и возьми всё, что тебе нужно – стол, стул, компьютер, монитор, телефон, и т.д. и т.п.

И второй вариант. За день до выхода разработчик говорит работодателю:

– Для работы мне нужны: стол такого-то типа, стул такого-то типа, компьютер с такими-то характеристиками, монитор такой-то, телефон, и т.д. и т.п. Обеспечьте, пожалуйста!

Первый вариант – это уже известный нам локатор. Есть глобальный объект – склад, – где можно взять все необходимое.

Во втором случае действующим лицом разработчик уже не является. Все, что нужно поставляется ему извне. То есть присутствует некоторое изменение направления действия. Не разработчик делает, а для него делают. В первом случае он активное действующее лицо, во втором – пассивное.

Вот такое изменение направления действия и называется Inversion of Control.

Одной из разновидностей этого принципа является Dependency InjectionВнедрение зависимостей. Иногда между ними ставят знак равенства, хотя строго говоря, это не одно и то же – Inversion of Control является более общим принципом. Я предпочитаю использовать термин dependency injection, он более точно отражает суть. Итак,

Принцип Dependency Injectionвнедрения зависимостей – состоит в том, что необходимые объекты поставляются в требующий их объект извне, некой третьей стороной.

Первый вариант поставки объектов – вызовом устанавливающих методов, например, setDataSource(DataSource) и setDataDestination(DataDestination) у какого-то класса нашего модуля. Или же эти объекты могут передаваться в конструктор, это еще один вариант реализации. Каждый из них имеет свои плюсы и минусы, которые мы и рассмотрим в следующей части.

Выбор механизма

Итак, у нас есть как минимум три варианта получения нужных зависимостей – локатор сервисов, внедрение через конструктор, внедрение через методы. Что использовать?

Разумеется, однозначного ответа нет. У каждого варианта есть ситуации, где он работает лучше других. Более того, зачастую использование того или иного способа зависит от предпочтений и стиля программирования. А потому – советовать ничего не буду. Мы просто пройдемся по всем способам. Причем вперемешку.

Начнем, пожалуй, с локаторов. Преимущество этого способа в том, что локатор можно использовать в любой точке кода. Позвал – получил результат. Это особенно удобно, когда «протаскивание» необходимых объектов до этой самой точки затруднено. Все-таки для внешней системы наш модуль должен представлять черный ящик, так что точка внедрения зависимостей будет одна. И если использовать эту зависимость где-нибудь в методе на глубине 10-15-20 вызовов – ее через весь этот стек придется тащить в явном виде. А то и не одну. Так появляются контексты вызова, в которых хранятся необходимые объекты.

С другой стороны, ровно это же является и недостатком – локатор может незаметно для окружающих использоваться в любой точке кода. Когда это наш собственный модуль – это, возможно, удобно. Когда это чужой модуль – необходимость организации для него локатора может стать неожиданностью. Ибо документацию мы читать не любим.

Более того. Локатор как способ сильно менее очевиден, чем внедрение зависимости. Как мы узнаем, что локатор вообще используется? Какого он должен быть типа? Что предоставляет? Кто может сказать, что именно у этого локатора запрашивают? Ну ладно, если запрашивается реализация интерфейса, как в примере выше, то это еще полбеды. Можно проанализировать используемые интерфейсы. А если это JNDI? Если ключ всегда строка, причем определенной структуры? А если разработчики модуля ошиблись в документации? А ведь и такое бывает! А если ошиблись те, кто конфигурирует этот локатор? Создали DataSource в сервере приложений, но ошиблись в одной букве. Всё, код не находит нужного ресурса.

И сравните это с явным внедрением зависимости. Например, через set-метод. Уже только его сигнатура – setDataDestination(DataDestination) – говорит о его предназначении. Если этим методом по какой-то причине не воспользовались – это можно установить проверкой внутреннего состояния (установлены ли все нужные значения) при первом осмысленном действии и выдавать сообщение об ошибке. Таким образом, мы сильно облегчим жизнь тем, кто будет использовать наш код.

Ну а если использовать внедрение зависимостей через конструктор – мы даже не сможем создать объект без внедрения в него всех зависимостей. Т.е. мы либо сразу получаем цельный, сформированный объект без промежуточных состояний, когда установлена только часть зависимостей, либо не получаем его вообще. Кроме того, этот способ позволяет сделать зависимости неизменяемыми – ввиду наличия отсутствия set-методов.

Недостатки тоже есть, куда без них? Если одна зависимость – никаких проблем. Две – тоже ничего. А если их двадцать? Делам конструктор с двадцатью параметрами? Нас за это долго будут вспоминать незлым тихим словом! Двадцать set-методов сделать проще, но тогда у создаваемого объекта будет двадцать одно состояние, двадцать из которых нас не устроят.

Ну и общий недостаток внедрения зависимостей – кто-то это должен делать. Со всеми объектами, которым эти зависимости нужны. Т.е. – нужен некий сборщик. Как правило, эта роль возлагается на контейнер, в котором исполняется приложение. В случае сервера приложений зависимости – ссылки на EntityManager, бины и т.п. – внедряются самим контейнером. В случае, например, использования Spring Framework это делает сам фреймворк, принцип Inversion of Control является его фундаментом.

В общем, подводя итог. Локатор использовать удобно, но неочевидно. Внедрение зависимостей – очевидно, но не всегда удобно. И скорее всего, в реальном приложении необходим компромис. Например, в модуле, который поставляется для использования другими разработчиками, локаторы использовать скорее всего не стоит. Это может принести больше проблем, чем пользы.

Я сам видел различные варианты использования как каждого механизма по отдельности, так и вместе. Один из самых оптимальных – симбиоз. Т.е. использование одновременно и локатора, и внедрения зависимостей. Причем локатор играет роль и сборщика для внедрения зависимостей, а сами зависимости обозначены аннотациями.

Общая схема приблизительно такая. Класс, предоставляющий реализацию, которая для кого-то является необходимой зависимостью, помечается определенной аннотацией, например, @Provider. В параметрах аннотации указываются все данные, необходимые для выбора именно этой реализации. В классе, где требуется внедрение, содается поле нужного типа с аннотацией, скажем, @Injection. И там тоже указываются все параметры.

Работает это просто. Мы запрашиваем у локатора экземпляр класса. Локатор ищет описание (Class), создает экземпляр. Анализирует поля, и если находит аннотацию @Injection – ищет соответствующую типу поля реализацию зависимости среди всех классов, объявленных с аннотацией @Provider, после чего меняет значение поля, прописывая в него ссылку на эту реализацию. Таким образом, мы получаем удобство локатора, с одной стороны, и избавляемся от неудобств внедрения зависимостей, с другой.

Сам локатор в этом случае создается явно на старте приложения. Это необходимо потому, что процесс его инициализации достаточно трудоемок и может занять большее время, чем это допустимо при отложенной инициализации. А дальше – экземпляр локатора либо передается как внедренная зависимость (в конструктор или через set-метод), либо получается через какой-нибудь статический метод – тоже, фактически, локатор.

Описаный способ, разумеется, является одним из многих. И работает в определенных условиях и при определенных предпосылках. Так что, повторяю еще раз, – выбор метода работы с зависимыми и зависящими модулями обусловлен конкретной ситуацией. При этом необходимо учитывать те плюсы и минусы, о которых мы сейчас говорили.

* * *

Ну, вот, пожалуй, мы и добрались до конца. Остался последний вопрос. А как стоило бы реализовать самый первый пример?

Как это должно быть

Мы уже выяснили, что конфигурацию необходимо отделить от логики модуля. Соответственно, у нас появляется интерфейс Configuration:

package ru.skipy.tests.architecture.module_design.v5.config;

/**
 * Configuration interface
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public interface Configuration {

    public int getXXX();

    public boolean isYYY();

    public String getZZZ();
}

Как вы сами понимаете, названия методов условны. Да, поскольку классов, связанных с конфигурацией, у нас будет несколько – они все вынесены в подпакет config.

Дальше, окончательная структура конфигурации у нас одна, что в случае использования XML, что в случае базы данных. Различаются только способы инициализации. Потому – имеет смысл создать абстрактный класс, который будет содержать все нужные значения, но без логики их инициализации:

package ru.skipy.tests.architecture.module_design.v5.config;

/**
 * Abstract configuration - contains all the fields required
 * but not initialization logic
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public abstract class AbstractConfiguration implements Configuration{

    protected int xxx;
    protected boolean yyy;
    protected String zzz;

    protected AbstractConfiguration(){
    }

    @Override
    public int getXXX() {
        return xxx;
    }

    @Override
    public boolean isYYY() {
        return yyy;
    }

    @Override
    public String getZZZ() {
        return zzz;
    }
}

Следующим шагом будет реализация конфигурации на основе XML:

package ru.skipy.tests.architecture.module_design.v5.config;

import org.w3c.dom.Node;

import javax.xml.parsers.DocumentBuilderFactory;
import java.io.InputStream;

/**
 * XML-based configuration
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public class XMLConfiguration extends AbstractConfiguration {

    public XMLConfiguration(Node xmlNode) {
        init(xmlNode);
    }

    public XMLConfiguration(InputStream src) throws BadXMLConfigurationException {
        if (src == null)
            throw new IllegalArgumentException("Configuration input stream can't be null!");
        try {
            init(DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(src));
        } catch (Exception ex) {
            // SAXException
            // IOException
            throw new BadXMLConfigurationException(ex);
        }
    }

    private void init(Node xmlNode) {
        // ... initializing config
    }
}

В данном типе конфигурации имеются два конструктора – из потока и из узла XML. Они перекрывают все потребности, которые были у нас в реализациях от первой и до четвертой. Кроме того, один из конструкторов бросает наше специфическое исключение. Это опять-таки сокрытие конкретики – причиной этого исключения могут быть совершенно разные ошибки, связанные с чтением и разбором XML-потока. При необходимости информацию об исходной ошибке можно получить через метод Exception.getCause().

package ru.skipy.tests.architecture.module_design.v5.config;

/**
 * BadXMLConfigurationException
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public class BadXMLConfigurationException extends Exception{

    public BadXMLConfigurationException(Throwable cause) {
        super(cause);
    }
}

Также мы можем реализовать конфигурацию на основе БД:

package ru.skipy.tests.architecture.module_design.v5.config;

import java.sql.Connection;
import java.sql.SQLException;

/**
 * DB-based configuration
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public class DBConfiguration extends AbstractConfiguration{

    public static final String DEFAULT_TABLE_NAME = "XYZ_MODULE_CONFIG";

    public DBConfiguration (Connection dbConnection) throws SQLException {
        this(dbConnection, DEFAULT_TABLE_NAME);
    }

    public DBConfiguration (Connection dbConnection, String tableName) throws SQLException{
        init(dbConnection, tableName);
    }

    private void init(Connection connection, String tableName) throws SQLException{
        // reading configuration from DB
    }
}

Чтобы не наступить на те же грабли, что и с предопределенным именем файла конфигурации, оставляем возможность задать собственное имя таблицы в базе. Имя по умолчанию тоже присутствует. Конструкторы бросают исключение SQLException, так как только оно и может возникнуть. И тот, кто формирует конфигурацию на основе соединения с БД, вполне может это исключение обработать. Скажем так, оно тут оправдано.

Ну и наконец – сам модуль:

package ru.skipy.tests.architecture.module_design.v5;

import ru.skipy.tests.architecture.module_design.v5.config.Configuration;

/**
 * Module implementation
 *
 * @author Eugene Matyushkin aka Skipy
 * @version 2.0
 * @since 13.09.2010
 */
public class Module {

    public Module(Configuration configuration) {
        // working with configuration
        init(configuration);
    }

    private void init(Configuration configuration) {
        // initialization code
    }
}

Как видите, в нем ничего не известно о том, как именно реализована конфигурация. Она для него является зависимостью, внедряемой через конструктор.

Пример использования модуля:

Configuration config = null;
try{
    config = new XMLConfiguration(Module.class.getResourceAsStream("/module-config.xml"));
} catch (BadXMLConfigurationException ex){
    // handling exception
}
if (config != null){
    Module module = new Module(config);
    // working with module
}

Это фрагмент кода из сборщика, который создает конфигурацию и на ее основе – модуль.

Таким образом, мы полностью разделили конфигурацию модуля и его логику. Более того, мы оставили возможность для сторонней реализации конфигурации – интерфейс Configuration доступен всем. Желающие могут также использовать AbstractConfiguration, реализовывая ислючительно блок инициализации.

* * *

Вот, собственно, и всё. В смысле, всё, что я хотел сказать в этот раз о подходах к проектированию и реализации переиспользуемых модулей. Но это далеко не все, что можно сказать о принципах объектно-ориентированного дизайна как такового. Даже из пяти самых важных принципов мы затронули только два. А, следовательно – есть повод для дальнейшего разговора. Увидимся!


P.S. Как обычно, обсуждение в блоге: http://skipy-ru.livejournal.com/4528.html.


1. Автором этой формулировки является Роберт Мартин, один из мировых экспертов объектно-ориентированного дизайна, основатель компании Object Mentor Inc. (http://www.objectmentor.com/). См. также: http://en.wikipedia.org/wiki/Robert_Cecil_Martin