От переводчика
Мы — внедрители. Мы должны внедрять, а не фантазировать!
(Рина Зеленая, к/ф «Девушка без адреса»)
К переводу этой статьи меня побудили две причины: 1) желание лучше разобраться с фреймворком Spring, 2) небольшое количество источников по теме на русском языке.
Краеугольный камень ООП — «внедрение зависимостей». Если описание процесса «внедрения» в целом, удовлетворительно, то объяснение понятия «зависимость» обычно оставляют за скобками. На мой взгляд, это существенное упущение.
Чтобы не фантазировать, а внедрять, нужно сначала разобраться с тем, что мы внедряем. И в этом нам может помочь лаконичная статья Jakob Jenkov «Understanding Dependencies». Она будет полезна не только тем, кто пишет на Java, но и тем, кто пишет на других языках и следит за качеством проектирования приложений.
UPD: я перевел еще одну статью Jacob Jenkov. Смотрите на Хабре Dependency Injection которая по смыслу продолжает данную. В ней даются советы по практическому применению DI
Понимая зависимости
- Что такое зависимость
- Почему зависимости это плохо
- Типы зависимостей
- Дополнительные характеристики зависимостей:
- Стандартные vs кастомные зависимости класса/интерфейса
- Резюме
Вы не можете прочесть хорошую книгу по ООП, в которой не упоминаются зависимости, слабая связность и т.п., и для этого есть хорошая причина. Понимание зависимостей важно при объектно-ориентированном дизайне API и приложений. Тем не менее, по моему мнению, предмет может быть исследован куда глубже, чем это делают многие книги. В этом и состоит цель текста. Если Вы — опытный ОО разработчик, Вы уже можете знать многое из написанного здесь. Также я верю в то, что многие разработчики все же смогут почерпнуть что-то из текста.
Что такое зависимость?
Когда класс А использует класс или интерфейс B, тогда А зависит от B. А не может выполнить свою работу без B, и А не может быть переиспользован без переиспользования B. В таком случае класс А называют «зависимым», а класс или интерфейс B называют «зависимостью».
Два класса, которые используют друг друга, называют связанными. Связанность между классами может быть или слабой, или сильной, или чем-то средним. Степень связности не бинарна и не дискретна, она находится в континууме. Сильная связанность ведет к сильным зависимостям, и слабая связность ведет к слабым зависимостям или даже к отсутствию зависимостей в некоторых ситуациях.
Зависимости, или связи имеют направленность. То, что A зависит от B не значит, что B зависит от A.
Почему зависимости это плохо?
Зависимости плохи тем, что снижают переиспользование. Снижение переиспользования плохо по многим причинам. Обычно переиспользование оказывает позитивное влияние на скорость разработки, качество кода, читаемость кода и т.д.
Как зависимости могут навредить, наиболее хорошо показывает пример: представьте, что у Вас есть класс CalendarReader, который может читать события календаря из XML-файла. Реализация CalendarReader приведена ниже:
public class CalendarReader {
public List readCalendarEvents(File calendarEventFile){
//open InputStream from File and read calendar events.
}
}
Метод readCalendarEvents получает объект типа File в качестве параметра. Поэтому, этот метод зависит от класса File. Зависимость от класса File означает, что CalendarReader способен на чтение событий календаря только из локальных файлов в файловой системе. Он не может читать события календаря из сетевого соединения, базы данных, или из ресурсов по classpath. Можно сказать, что CalendarReader тесно связан c классом File и локальной файловой системой.
Менее связанной реализацией будет замена параметра типа File параметром типа InputStream, как в коде ниже:
public class CalendarReader {
public List readCalendarEvents(InputStream calendarEventFile){
//read calendar events from InputStream
}
}
Как Вы можете знать, InputStream может быть получен из объекта типа File, из сетевого Socket, класса URLConnection, объекта Class (Class.getResourceAsStream(String name)), колонки из БД через JDBC и т.п. Теперь CalendarReader больше не завязан на локальную файловую систему. Он может читать файлы событий календаря из многих источников.
С версией метода readCalendarEvents(), использующей InputStream, класс CalendarReader повысил возможности переиспользования. Тесная привязка к локальной файловой системе была удалена. Вместо этого, она была заменена на зависимость от класса InputStream. Зависимость от InputStream более гибка, чем зависимость от класса File, но не означает, что CalendarReader на 100% может быть переиспользован. Он все еще не может читать данные из канала NIO, например.
Типы зависимостей
Зависимости — это не просто «зависимости». Есть несколько типов зависимостей. Каждый из них ведет к большей или меньшей гибкости в коде. Типы зависимостей:
- зависимости классов
- зависимости интерфейсов
- зависимость метод/поле
Зависимости классов — это зависимости от классов. Например, метод в кодовом боксе ниже получает String как параметр. Таким образом, метод зависит от класса String.
public byte[] readFileContents(String fileName){
//open the file and return the contents as a byte array.
}
Зависимости интерфейсов — это зависимости от интерфейсов. Например, метод в кодовой вставке ниже получает CharSequence в качестве параметра. CharSequence — стандартный интерфейс Java (в пакете java.lang). Классы CharBuffer, String, StringBuffer и StringBuilder реализуют интерфейс CharSequence, поэтому экземпляры только этих классов могут быть использованы в качестве параметров этого метода.
public byte[] readFileContents(CharSequence fileName){
//open the file and return the contents as a byte array.
}
Зависимости методов или полей — это зависимости от конкретных методов или полей объекта. Не важно, каков класс объекта или какой интерфейс он реализует, пока он имеет метод или поле требуемого типа. Следующий пример иллюстрирует зависимость методов. Метод readFileContents зависит от метода, названного «getFileName» в классе объекта, переданного как параметр (fileNameContainer). Обратите внимание, что зависимость не видна из декларации метода!
public byte[] readFileContents(Object fileNameContainer){
Method method = fileNameContainer
.getClass()
.getMethod("getFileName", null);
String fileName = method.invoke(fileNameContainer, null);
//open the file and return the contents as a byte array.
}
Зависимости методов или переменных характерны для API, которые используют рефлексию. Например, Butterfly Persistence использует рефлексию для того, чтобы обнаружуить геттеры и сеттеры класса. Без геттеров и сеттеров Butterfly Persistence не может читать и записывать объекты класса из/в базу данных. Таким образом Butterfly Persistence зависит от геттеров и сеттеров. Hibernate (схожий ORM API) может как использовать геттеры и сеттеры, так и поля напрямую, так и через рефлексию. Таким образом, Hibernate также имеет зависимость либо от методов, либо от полей.
Зависимость методов или («функций») также может быть замечена в языках, поддерживающих указатели на функции или указатели на методы, которые должны быть переданы в качестве аргументов. Например, делегаты в C#.
Дополнительные характеристики зависимостей
Зависимости имеют и другие важные характеристики помимо типа. Зависимости могут быть зависимостями времени компиляции, времени исполнения, видимые, скрытые, прямые, непрямые, контекстуальные и т.п. Эти дополнительные характеристики будут раскрыты в следующих разделах.
Зависимости реализации интерфейса
Если класс A зависит от интерфейса I, тогда A не зависит от конкретной реализации I. Но A зависит от какой-то реализации I. A не может выполнять свою работу без некоторой реализации I. Таким образом, когда класс зависит от интерфейса, этот класс также зависит от реализации интерфейса.
Чем больше методов есть в интерфейсе, тем меньше шансов, что разработчики будут предоставлять собственные реализации, если у них этого не просят. Следовательно, чем больше методов есть в интерфейсе, тем больше возможность того, что разработчики «застрянут» на стандартной реализации этого интерфейса. Другими словами, чем более сложным и громоздким становится интерфейс, тем более тесно он связывается со своей дефолтной имплементацией.
Из-за зависимостей реализации интерфейса, Вы не должны добавлять функциональность в интерфейс слепо. Если функциональность может быть инкапсулирована в свой компонент, в свой отдельный интерфейс, нужно делать так.
Ниже — пример того, что это значит. Код примера показывает узел дерева для иерархической древовидной структуры.
public interface ITreeNode {
public void addChild(ITreeNode node);
public List<ITreeNode> getChildren();
public ITreeNode getParent();
}
Представьте, что Вы хотите иметь возможность подсчитать количество потомков конкретного узла. Сначала Вы можете поддаться искушению, и добавить метод countDescendents() в интерфейс ITreeNode. Тем не менее, если Вы так сделаете, каждый, кто захочет реализовать интерфейс ITreeNode, вынужден будет реализовывать и метод countDescendents().
Вместо этого Вы можете реализовать класс DescendentCounter, который может просматривать экземпляр ITreeNode и считать всех потомков этого экземпляра. DescendentCounter может быть переиспользован с другими реализациями интерфейса ITreeNode. Вы только что уберегли своих пользователей от проблемы реализации метода countDescendents(), даже если им нужнореализовать интерфейс ITreeNode!
Зависимости времени компиляции и времени исполнения
Зависимость, которая может быть разрешена во время компиляции, называется зависимостью времени компиляции. Зависимость, которая не может быть разрешена до начала исполнения — зависимость времени исполнения. Зависимости времени компиляции могут быть проще замечены, чем зависимости времени выполнения, однако, зависимости времени исполнения могут быть более гибкими. Например, Butterfly Persistence, находит геттеры и сеттеры класса во время исполнения и автоматически мапит их с таблицами БД. Это очень простой способ сопоставлять классы с таблицами БД. Тем не менее, чтобы делать это, Butterfly Persistence зависит от правильно названных геттеров и сеттеров.
Видимые и скрытые зависимости
Видимые зависимости — это зависимости, которые разработчики могут видеть из интерфейса класса. Если зависимости не могут быть обнаружены в интерфейсе класса, это — скрытые зависимости.
В примере, приведенном ранее, зависимости String и CharSequence метода readFileContents() — видимые зависимости. Они видимы в декларации метода, который является частью интерфейса класса. Зависимости метода readFileContents(), который получает Object в качестве параметра, невидимы. Вы не можете видеть из интерфейса, что метод readFileContents() вызывает fileNameContainer.toString(), чтобы получить имя файла, или как на самом деле происходит, вызывает метод getFileName().
Другой пример скрытой зависимости — зависимость от статического синглтона или статических методов внутри метода. Вы не можете видеть из интерфейса, что класс зависит от статического метода или статического синглтона.
Как вы можете представить, скрытые зависимости могут быть злом. Их трудно обнаружить разработчику. Их можно выявить только изучая код.
Это не то же самое, что говорить что не стоить никогда использовать скрытые зависимости. Скрытые зависимости часто являются результатом предоставления разумных значений по умолчанию (providing sensible defaults). В этом примере это может не быть проблемой.
public class MyComponent{
protected MyDependency dependency = null;
public MyComponent(){
this.dependency = new MyDefaultImpl();
}
public MyComponent(MyDependency dependency){
this.dependency = dependency;
}
}
MyComponent имеет скрытую зависимость от MyDefaultImpl как можно видеть в конструкторе. Но MyDefaultImpl не имеет опасных сайд-эффектов, поэтому в данном случае скрытая зависимость не опасна.
Прямые и непрямые зависимости
Зависимость может быть либо прямой, либо непрямой. Если класс A использует класс B, тогда класс A имеет прямую зависимость от класса B. Если A зависит от B, B зависит от C, тогда A имеет непрямую зависимость от C. Если вы не можете использовать A без B, и не можете использовать B без С, то вы не можете также использовать A без C.
Непрямые зависимости также называют сцепленными (цепными), или транзитивными (в «Better, Faster, Lighter Java» by Bruce A. Tate and Justin Gehtland).
Неоправданно обширные зависимости
Иногда компоненты зависят от большей информации, чем им нужно для работы. Например, представьте компонент логина в веб-приложении. Этому компоненту нужны только логин и пароль, и он вернет объект пользователя, если найдет такового. Интерфейс может выглядеть так:
public class LoginManager{
public User login(HttpServletRequest request){
String user = request.getParameter("user");
String password = request.getParameter("password");
//read user and return it.
}
}
Вызов компонента мог бы выглядеть так:
LoginManager loginManager = new LoginManager();
User user = loginManager.login(request);
Выглядит просто, не так ли? И даже если методу логина потребуется больше параметров, не нужно будет изменять вызывающий код.
Но сейчас метод логина имеет то, что я называю «неоправданно обширные зависимости» от интерфейса HttpServletRequest. Метод зависит от большего, чем ему требуется для работы. LoginManager требует только имя пользователя и пароль, чтобы найти пользователя, но получает HttpServletRequest как параметр в методе логина. HttpServletRequest содержит гораздо больше информации, чем нужно LoginManager.
Зависимость от интерфейса HttpServletRequest вызывает две проблемы:
- LoginManager не может быть переиспользован без объекта HttpServletRequest. Это может сделать труднее юнит-тестирование LoginManager. Вам нужно будет замокать объект HttpServletRequest, что требует большой работы.
- LoginManager требует, чтобы названия параметров пользовательского имени и пароля были «логин» и «пароль». Это также необязательная зависимость.
Намного лучший интерфейс для метода логина LoginManager будет:
public User login(String user, String password){
//read user and return it.
}
Но посмотрите, что случится с вызывающим кодом теперь:
LoginManager loginManager = new LoginManager();
User user = loginManager.login(
request.getParameter("user"),
request.getParameter("password"));
Он стал более сложным. Вот причина, по которой разработчики создают неоправданно широкие зависимости. Чтобы упростить вызывающий код.
Зависимости локальные и контекстные
При разработке приложений нормально разбивать приложения на компоненты. Некоторые из этих компонентов — компоненты общего назначения, которые могут быть использованы также в других приложениях. Другие компоненты специфичны для приложения и не будут использоваться за пределами приложения.
Для компонентов общего назначения, любые классы, принадлежащие к компоненту (или API), являются «локальными». Остальная часть приложения — это «контекст». Если компонент общего назначения зависит от специфичных для приложения классов, это называется «контекстная зависимость». Контекстные зависимости плохи тем, что делают невозможным использование компонента общего назначения вне приложения. Заманчиво думать, что только плохой ОО разработчик будет создавать контекстные зависимости, но это не так. Контекстные зависимости обычно возникают, когда разработчики стараются упростить создание своего приложения. Хороший пример здесь — приложения, обрабатывающие запросы, такие как приложения, соединенные с очередями сообщений или веб-приложения.
Представьте, что приложение, которое получает запрос в виде XML, обрабатывает запросы и получает в ответ XML. В обработке XML-запроса участвуют несколько отдельных компонентов. Каждому из этих компонентов нужна разная информация, некоторая информаця уже была обработана предыдущими компонентами. Очень соблазнительно собрать XML-файл и всю связанную обработку внутри объекта запроса некоторого вида, который отправляется всем компонентам, в последовательности обработки. Обрабатывающий компонент может считать информацию из этого объекта запроса и добавить информацию от себя для компонентов, стоящих далее в последовательности обработки. Принимая этот объект запроса как параметр, каждый из компонентов, обрабатывающих запрос, зависит от этого запроса. Объект запроса специфичен для приложения, это вызывает зависимость от контекста каждого компонента обработки запроса.
Стандартные vs кастомные зависимости класса/интерфейса
Во многих ситуациях для компонента лучше зависеть от класса или интерфейса из стандартных Java (или C#) пакетов. Эти классы или интерфейсы всегда доступны каждому, что упрощает удовлетворение этих зависимостей. Также эти классы с меньшей вероятностью могут измениться и вызвать падение компиляции вашего приложения.
Однако, в некоторых ситуациях зависеть от стандартных библиотек — не лучшая вещь. Например, методу нужно 4 строки для его конфигурации. Поэтому ваш метод принимает 4 строки как параметры. Например, это имя драйвера, url базы данных, имя пользователя и пароль для подключения к базе данных. Если все эти строки всегда используются вместе, для пользователя этого метода может быть понятнее, если вы сгруппируете эти 4 строки в класс и будете передавать его экземпляр, вместо 4 строк.
Резюме
Мы рассмотрели несколько разных типов и характеристик зависимостей. В общем, зависимости интерфейса предпочтительнее зависимостей классов. В некоторых ситуациях, вы можете обнаружить, что зависимость классов предпочтительнее зависимости интерфейсов. Зависимости методов и полей могут быть очень полезны, но помните, что они обычно — скрытые зависимости, и скрытые зависимости затрудняют пользователям ваших компонентов их нахождение, и удовлетворение их требований.
Зависимости реализации интерфейса встречаются чаще, чем вы думаете. Я видел их во многих приложениях и API. Постарайтесь ограничить их максимально, сохраняя интерфейсы небольшими. По крайней мере, те интерфейсы, которые реализует пользователь компонента. Переместите дополнительные функции (например, подсчет и т. д.) во внешние компоненты, которые принимают экземпляр рассматриваемого интерфейса в качестве параметра.
Лично я предпочитаю зависимости времени компиляции зависимостям времени исполнения, но в некоторых случаях зависимости времени исполнения более элегантны. Например, Mr. Persister использует зависимости времени выполнения от геттеров и сеттеров, что освобождает ваши pojo от реализации персистентного интерфейса. Зависимости времени исполнения таким образом, могут быть менее инвазивными, чем
зависимости времени компиляции.
Скрытые зависимости могут быть опасны, но поскольку зависимости времени выполнения иногда также скрытые зависимости, у вас может не всегда быть выбор.
Помните, что даже если компонент не имеет прямых зависимостей от другого компонента, он может все же иметь непрямую зависимость то него. Менее ограничивающие, но тем не менее, опосредованные зависимости — также зависимости.
Постарайтесь избегать неоправданно широких зависимостей. Держите в уме, что неоправданно широкие зависимости возникают тогда, когда вы группируете множество параметров в класс. Это общий рефакторинг, который проводится, чтобы сделать код более простым, но как вы можете видеть, может вести к неоправданно широким зависимостям.
Компонент, который предполагается использовать в различных контекстах, не должен иметь никаких контекстных зависимостей. То есть компонент не должен зависеть от других компонентов в контексте, в котором он изначально разработан и в том, в который он интегрирован.
Этот текст только описал зависимости. Он не говорит вам, что делать с ними. Другие тексты на этом тренинговом сайте погрузят вас в эту тему (прим. перев.: имеется ввиду личный сайт автора).
К началу
Комментарии (12)
VolCh
25.02.2018 15:29> Намного лучший интерфейс для метода логина LoginManager будет:
VolCh
25.02.2018 15:39В данном конкретном случае хорошо было бы создать какой-то UserCreds объект, конструктор/фабричный метод которого из http запроса вытягивает нужные поля, а login получает его единственным параметром. С одной стороны, мы сделаем минимально необходимую зависимость login, с другой сведём вызов в веб-контроллере или его аналоге к user.login(UserCreds:createFromHttpRequest(request), что заметно проще вызова со строковыми интерфейсами. С третьей стороны, мы закладываем фундамент и под другие способы логина, а не только логин/пароль, и под другие API логина, а не только веб, например через cli или событие в MQ.
VolCh
25.02.2018 15:41* loginManager.login
На мобильном приложении очень неудобно писать комменты и невозможно их редактировать.
Kiselioff Автор
25.02.2018 15:59Согласен, что создание объекта для пользовательской информации визуально упростит код. Однако, что-то мне подсказывает, что таким образом мы создадим еще одну сущность, которая будет зависеть от HttpServletRequest, получая все его данные. То есть мы встроим еще один слой абстракции, через который метод login буден по-прежнему зависеть от HttpServletRequest, но уже опосредованно.
Kiselioff Автор
25.02.2018 16:34Если число параметров метода login не будет изменяться в сторону повышения, думаю, лучше оставить строки, как есть. Однако, если число параметров вырастет за 4 (моя личная граница удобства), то объект лучше создать.
VolCh
25.02.2018 17:06Да, именованный конструктор UserCreds:createFromHttpRequest(HttpServletRequest request) не очень хорошее место по инкапсуляции знаний о способе преобразования HttpServletRequest в UserCred в общем случае. Лучше его помещать куда-то в слой, основная функция которого преобразовывать HttpServletRequest в DTO/ValueObject приложения или доменной области. Пускай даже без выделения в отдельный метод, пока не нарушается DRY. Итого у нас есть варианта клиентского кода:
LoginManager loginManager = new LoginManager(); User user = loginManager.login(request);
User user = loginManager.login( request.getParameter("user"), request.getParameter("password"));
LoginManager loginManager = new LoginManager(); UserCreds creds = new UserCreds(request.getParameter("user"), request.getParameter("password")); // @todo extract fabric into http layer User user = loginManager.login(creds);
По-моему, последний оптимален по читаемости и возможности изменения/расширения в будущем.
Kiselioff Автор
25.02.2018 17:23Так получше читается. Да, все равно придется работать с запросом, никуда не деться. Можно, действительно, и в другой слой перенести.
vdem
25.02.2018 19:26Краеугольный камень ООП — «внедрение зависимостей».
А я всю жизнь думал, что инкапсуляция, наследование и полиморфизм :) Внедрение зависимостей появилось (читать: начало активно использоваться) гораздо позже, по причине, неплохо описанной в данной статье. Взять тот же Turbo Vision — там даже интерфейсы не использовались (они появились позже), и тем не менее Turbo Vision — это ООП. Кстати, «Внедрение зависимостей», вероятно, стоило бы называть «внедрение независимостей», это лучше отражает суть.
Kiselioff Автор
25.02.2018 19:59Не будем отрицать: принципы ООП, это действительно инкапсуляция, наследование и полиморфизм:). Однако, можно сказать, что все они связаны с управлением зависимостями. Да, есть вариант разрешения зависимостей внутри класса, но это ведет к негативным последствиям, описанным в разделе «почему зависимости плохо». Де-факто такой подход устарел, и ему на смену пришло «внедрение зависимостей». На этой теме сфокусирована еще одна статья от этого автора. Над ее переводом я сейчас работаю. Приглашаю познакомиться с ней, думаю, что там Вы найдете еще интересные доводы в пользу DI.
VolCh
> Чем больше методов есть в интерфейсе, тем меньше шансов, что разработчики будут предоставлять собственные реализации, если у них этого не просят. Следовательно, чем больше методов есть в интерфейсе, тем больше возможность того, что разработчики «застрянут» на стандартной реализации этого интерфейса. Другими словами, чем более сложным и громоздким становится интерфейс, тем более тесно он связывается со своей дефолтной имплементацией.
Хорошее объяснение одного из практических плюсов «академического» ISP. Часто можно наблюдать, что люди пытаются формально соблюдать DIP, но никаких практических плюсов это не даёт, потому что выносят в один интерфейс все методы огромного класса, который почти гарантировано станет навсегда единственной реализацией из-за своей сложности.