Краткое введение во внедрение зависимостей: что это и когда это необходимо использовать
Nov 4, 2018 · 4 min read
Введение
В разработке программного обеспечения, внедрение зависимостей это такая техника, где посредством одного объекта (или статического метода) предоставляются зависимости другого объекта. Зависимость — это объект, который может быть использован (как сервис).
Это определение из Википедии (в версии на английском языке — прим. переводчика), и все же, его не так и просто понять. Давайте разберемся с этим получше.
Перед тем как понять, что же это означает в программировании, давайте сначала разберемся, что это означает в общем смысле. Таким образом это поможет нам лучше разобраться с этим понятием.
Зависимость или зависимое — означает полагаться на что-то. Это все равно что, если сказать, что мы слишком много полагаемся на мобильные телефоны — это означает, что мы зависим от них.
Поэтому, прежде чем перейти к понятию внедрение зависимостей, сначала давайте разберемся, что означает зависимость в программировании.
Когда класс A использует некоторую функциональность из класса B, тогда говорят, что класс A зависим от класса B.
В Java, прежде чем мы сможем использовать методы других классов, нам необходимо для начала создать экземпляры этого класса (то есть класс А должен создать экземпляр класса В).
Таким образом, передавая задачу создания объекта чему-то другому и прямое использование этой зависимости называется внедрением зависимостей.
Так почему следует использовать внедрение зависимостей?
Представьте, что у нас есть класс для описания автомобилей, также содержащий другие различные объекты, например, колеса, двигатели и прочее.
Перед вами класс Car, отвечающий за создание всех объектов зависимостей. Теперь, что если мы решим избавиться колес компании MRFWheels и хотим использовать колеса от Yokohama в будущем?
Нам нужно будет воссоздать объект класса Car с новой зависимостью от Yokohama. Но при использовании внедрении зависимостей мы можем изменить колеса во время выполнения программы (потому что зависимости можно внедрять во время выполнения, а не во время компиляции).
Вы можете думать о внедрении зависимостей как о посреднике в нашем коде, который выполняет всю работу по созданию предпочтительного объекта колеса и предоставлению его классу Автомобиль.
Это делает наш класс автомобилей независимым от создания объектов таких как колеса, аккумулятор и т.д.
Существует три основных типа внедрения зависимостей:
Внедрение зависимостей ответственно за:
Если есть какие-либо изменения в объектах, то DI смотрит на него, и он не должен относиться к классу с использованием этих объектов.
Таким образом, если объекты будут меняться в будущем, тогда ответственность DI заключается в предоставлении соответствующих объектов классу.
Инверсия управления — концепция, лежащая в основе внедрения зависимости
Это означает, что класс не должен конфигурировать свои зависимости статистически, а должен быть сконфигурирован другим классом извне.
Это пятый принцип S.O.L.I.D из пяти основных принципов объектно-ориентированного программирования и разработки от дяди Боба, в котором говорится, что класс должен зависеть от абстракции, а не от чего-то конкретного (простыми словами, жестко закодированного).
Согласно принципам, класс должен полностью сосредоточиться на выполнении своих обязанностей, а не на создании объектов, необходимых для выполнения этих обязанностей. И именно здесь начинается внедрение зависимостей: она предоставляет классу требуемые объекты.
Преимущества использования внедрения зависимостей
Недостатки использования внедрения зависимостей
Тем не менее вы вполне можете реализовать внедрение зависимостей самостоятельно без использования сторонних библиотек и фреймворков или используя их.
Библиотеки и фреймворки, реализующие внедрение зависимостей
Для того чтобы узнать больше о внедрении зависимостей, вы можете ознакомиться со списком дополнительных источников ниже:
Dependency injection
От переводчика
Представляемый вашему вниманию перевод открывает серию статей от Jakob Jenkov, посвященных внедрению зависимостей, или DI. Примечательна серия тем, что в ней автор, анализируя понятия и практическое применение таких понятий как «зависимость», «внедрение зависимостей», «контейнер для внедрения зависимостей», сравнивая паттерны создания объектов, анализируя недостатки конкретных реализаций DI-контейнеров (например, Spring), рассказывает, как пришел к написанию собственного DI-контейнера. Таким образом, читателю предлагается познакомиться с довольно цельным взглядом на вопрос управления зависимостями в приложениях.
В данной статье сравнивается подход к настройке объектов изнутри и извне (DI). По смыслу настоящая статья продолжает статью Jakob Jenkov Understanding Dependencies, в которой дается определение самому понятию «зависимости» и их типам.

Серия включает в себя следующие статьи
Внедрение зависимостей
«Внедрение зависимостей» — это выражение, впервые использованное в статье Мартина Фаулера Inversion of Control Containers and the Dependency Injection Pattern. Это хорошая статья, но она упускает из виду некоторые преимущества контейнеров внедрения зависимостей. Также я не согласен с выводами статьи, но об этом — в следующих текстах.
Объяснение внедрения зависимостей
Внедрение зависимостей — это стиль настройки объекта, при котором поля объекта задаются внешней сущностью. Другими словами, объекты настраиваются внешними объектами. DI — это альтернатива самонастройке объектов. Это может выглядеть несколько абстрактно, так что посмотрим пример:
UPD: после обсуждения представленных автором фрагментов кода с flatscode и fogone, я принял решение скорректировать спорные моменты в коде. Изначальный замысел был в том, чтобы не трогать код и давать его таким, каков он написан автором. Оригинальный авторский код в спорных местах закомментирован с указанием «в оригинале», ниже дается его исправленная версия. Также оригинальный код можно найти по ссылке в начале статьи.
Этот DAO (Data Access Object), MyDao нуждается в экземпляре javax.sql.DataSource для того, чтобы получить подключения к базе данных. Подключения к БД используются для чтения и записи в БД, например, объектов Person.
Заметьте, что класс MyDao создает экземпляр DataSourceImpl, так как нуждается в источнике данных. Тот факт, что MyDao нуждается в реализации DataSource, означает, что он зависит от него. Он не может выполнить свою работу без реализации DataSource. Следовательно, MyDao имеет «зависимость» от интерфейса DataSource и от какой-то его реализации.
Класс MyDao создает экземпляр DataSourceImpl как реализацию DataSource. Следовательно, класс MyDao сам «разрешает свои зависимости». Когда класс разрешает собственные зависимости, он автоматически также зависит от классов, для которых он разрешает зависимости. В данном случае MyDao завсист также от DataSourceImpl и от четырех жестко заданных строковых значений, передаваемых в конструктор DataSourceImpl. Вы не можете ни использовать другие значения для этих четырех строк, ни использовать другую реализацию интерфейса DataSource без изменения кода.
Как вы можете видеть, в том случае, когда класс разрешает собственные зависимости, он становится негибким в отношении к этим зависимостям. Это плохо. Это значит, что если вам нужно поменять зависимости, вам нужно поменять код. В данном примере это означает, что если вам нужно использовать другую базу данных, вам потребуется поменять класс MyDao. Если у вас много DAO-классов, реализованных таким образом, вам придется изменять их все. В добавок, вы не можете провести юнит-тестирование MyDao, замокав реализацию DataSource. Вы можете использовать только DataSourceImpl. Не требуется много ума, чтобы понять, что это плохая идея.
Давайте немного поменяем дизайн:
Заметьте, что создание экземпляра DataSourceImpl перемещено в конструктор. Конструктор принимает четыре параметра, это — четыре значения, необходимые для DataSourceImpl. Хотя класс MyDao все еще зависит от этих четырех значений, он больше не разрешает зависимости сам. Они предоставляются классом, создающим экземпляр MyDao. Зависимости «внедряются» в конструктор MyDao. Отсюда и термин «внедрение (прим. перев.: или иначе — инъекция) зависимостей». Теперь возможно сменить драйвер БД, URL, имя пользователя или пароль, используемый классом MyDao без его изменения.
Внедрение зависимостей не ограничено конструкторами. Можно внедрять зависимости также используя методы-сеттеры, либо прямо через публичные поля (прим. перев.: по поводу полей переводчик не согласен, это нарушает защиту данных класса).
Класс MyDao может быть более независимым. Сейчас он все еще зависит и от интерфейса DataSource, и от класса DataSourceImpl. Нет необходимости зависеть от чего-то, кроме интерфейса DataSource. Это может быть достигнуто инъекцией DataSource в конструктор вместо четырех параметров строкового типа. Вот как это выглядит:
Теперь класс MyDao больше не зависит от класса DataSourceImpl или от четырех строк, необходимых конструктору DataSourceImpl. Теперь можно использовать любую реализацию DataSource в конструкторе MyDao.
Цепное внедрение зависимостей
Пример из предыдущего раздела немного упрощен. Вы можете возразить, что зависимость теперь перемещена из класса MyDao к каждому клиенту, который использует класс MyDao. Клиентам теперь приходится знать о реализации DataSource, чтобы быть в состоянии поместить его в конструктор MyDao. Вот пример:
Как вы можете видеть, теперь MyBizComponent зависит от класса DataSourceImpl и четырех строк, необходимых его конструктору. Это еще хуже, чем зависимость MyDao от них, потому что MyBizComponent теперь зависит от классов и от информации, которую он сам даже не использует. Более того, реализация DataSourceImpl и параметры конструктора принадлежат к разным слоям абстракции. Слой ниже MyBizComponent — это слой DAO.
Решение — продолжить внедрение зависимости по всем слоям. MyBizComponent должен зависеть только от экземпляра MyDao. Вот как это выглядит:
Снова зависимость, MyDao, предоставляется через конструктор. Теперь MyBizComponent зависит только от класса MyDao. Если бы MyDao был интерфейсом, можно было бы менять реализацию без ведома MyBizComponent.
Такой паттерн внедрения зависимости должен продолжается через все слои приложения, с самого нижнего слоя (слоя доступа к данным) до пользовательского интерфейса (если он есть).
Наша проблема c зависимостями
На протяжении десятилетий повторное использование ПО чаще обсуждалось, чем реально имело место. Сегодня ситуация обратная: разработчики каждый день повторно используют чужие программы, в виде программных зависимостей, а сама проблема остаётся практически неизученной.
Мой собственный опыт включает десятилетие работы с внутренним репозиторием Google, где зависимости установлены как приоритетная концепция, а также разработку системы зависимостей для языка программирования Go.
Зависимости несут серьёзные риски, которые слишком часто упускаются из виду. Переход к простому повторному использованию малейших фрагментов ПО произошёл так быстро, что мы ещё не выработали лучшие практики для эффективного выбора и использования зависимостей. Даже для принятия решения, когда они уместны, а когда нет. Цель этой статьи — оценить риски и стимулировать поиск решений в этой области.
Что такое зависимость?
В современной разработке зависимость — это дополнительный код, который вызывается из программы. Добавление зависимости позволяет избежать повторения уже сделанной работы: проектирования, написания, тестирования, отладки и поддержки определенной единицы кода. Назовем эту единицу кода пакетом, хотя в некоторых системах вместо пакета используются другие термины, такие как библиотека или модуль.
Принятие внешних зависимостей — старая практика: большинство программистов загружали и устанавливали необходимую библиотеку, будь то PCRE или zlib из C, Boost или Qt из C++, JodaTime или Junit из Java. В этих пакетах высококачественный отлаженный код, для создания которого требуется значительный опыт. Если программа нуждается в функциональности такого пакета, гораздо проще вручную загрузить, установить и обновлять пакет, чем разработать эту функциональность с нуля. Но большие первоначальные затраты означают, что ручная реализация повторного использования дорого обходится: крошечные пакеты проще написать самому.
Менеджер зависимостей (иногда называемый диспетчером пакетов) автоматизирует загрузку и установку пакетов зависимостей. Поскольку менеджеры зависимостей упрощают загрузку и установку отдельных пакетов, снижение постоянных затрат делает небольшие пакеты экономичными для публикации и повторного использования.
До появления менеджеров зависимостей невозможно было представить публикацию восьмистрочной библиотеки: слишком много накладных расходов и слишком мало пользы. Но NPM свёл накладные расходы почти к нулю, в результате чего почти тривиальная функциональность может быть упакована и повторно использована. В конце января 2019 года зависимость escape-string-regexp встроена почти в тысячу других пакетов NPM, не говоря уже о всех пакетах, которые разработчики пишут для собственного использования и не публикуют в открытом доступе.
Теперь менеджеры зависимостей появились практически для каждого языка программирования. Maven Central (Java), Nuget (.NET), Packagist (PHP), PyPI (Python) и RubyGems (Ruby) — в каждом из них более 100 000 пакетов. Появление такого распространённого повторного использования мелких пакетов — одно из крупнейших изменений в разработке программного обеспечения за последние два десятилетия. И если мы не будем более осторожны, это приведёт к серьёзным проблемам.
Что может пойти не так?
В контексте данного обсуждения пакет — это код, загружаемый из интернета. Добавление зависимости поручает работу по разработке этого кода — проектирование, написание, тестирование, отладку и поддержку — кому-то другому в интернете, кого вы обычно не знаете. Используя этот код, вы подвергаете собственную программу воздействию всех сбоев и недостатков зависимости. Выполнение вашего софта теперь буквально зависит от кода незнакомца из интернета. Если сформулировать таким образом, всё звучит очень небезопасно. Зачем кому-то вообще соглашаться на такое?
Мы соглашаемся, потому что это легко, потому что всё вроде работает, потому что все остальные тоже так делают, и самое главное — потому что это кажется естественным продолжением вековой устоявшейся практики. Но есть важная разница, которую мы игнорируем.
Десятилетия назад большинство разработчиков тоже доверяли другим писать программы, от которых они зависели, такие как операционные системы и компиляторы. Это программное обеспечение покупалось из известных источников, часто с каким-то соглашением о поддержке. Тут ещё остаётся место для ошибок или откровенного вредительства. Но мы хотя бы знали, с кем имеем дело и, как правило, могли использовать коммерческие или правовые меры воздействия.
Феномен ПО с открытыми исходниками, которые распространяются бесплатно через интернет, во многом вытеснил старые практики закупки ПО. Когда повторное использование было ещё трудным, мало проектов внедрило такие зависимости. Хотя их лицензии обычно отказывались от каких-либо «гарантий коммерческой ценности и пригодности для конкретной цели», проекты построили себе хорошую репутацию. Пользователи в значительной степени учитывали эту репутацию в принятии своих решений. Вместо коммерческих и юридических мер воздействия пришла репутационная поддержка. Многие распространённые пакеты той эпохи по-прежнему пользуются хорошей репутацией: например, BLAS (опубликован в 1979), Netlib (1987), libjpeg (1991), LAPACK (1992), HP STL (1994) и zlib (1995).
Пакетные менеджеры свели модель повторного использования кода до предельной простоты: теперь разработчики могут совместно использовать код с точностью до отдельных функций в десятки строк. Это большое техническое достижение. Существует бесчисленное множество доступных пакетов, и проект может включать в себя большое их количество, но коммерческие, юридические или репутационные механизмы доверия к коду остались в прошлом. Мы доверяем большему количеству кода, хотя причин для доверия стало меньше.
Стоимость принятия плохой зависимости можно рассматривать как сумму всех возможных плохих исходов на серии из цены каждого плохого исхода, умноженной на его вероятность (риск).
Цена плохого исхода зависит от контекста, в котором используется зависимость. На одном конце спектра находится личный хобби-проект, где цена большинства плохих исходов близка к нулю: вы просто развлекаетесь, ошибки не имеют никакого реального влияния, кроме как чуть больше потраченного времени, а их отладка может даже приносить удовольствие. Таким образом, вероятность риска почти не имеет значения: она умножается на ноль. На другом конце спектра — производственный софт, который должен поддерживаться годами. Здесь стоимость зависимости может оказаться очень высокой: серверы могут упасть, конфиденциальные данные могут быть разглашены, клиенты могут пострадать, компании вообще могут обанкротиться. В продакшне гораздо важнее оценить и максимально снизить риск серьёзного отказа.
Независимо от ожидаемой цены, есть некоторые подходы для оценки и снижения рисков добавления зависимостей. Вполне вероятно, что пакетные менеджеры следует оптимизировать для снижения этих рисков, в то время как они до настоящего времени уделяли основное внимание сокращению затрат на загрузку и установку.
Проверка зависимости
Вы бы не наняли разработчика, о котором никогда не слышали и ничего не знаете. Сначала вы узнаете что-то о нём: проверите ссылки, проведёте собеседование и так далее. Прежде чем зависеть от пакета, который вы нашли в интернете, также разумно немного узнать об этом пакете.
Базовая проверка может дать представление о вероятности возникновения проблем при попытке использования этого кода. Если в ходе проверки обнаружатся незначительные проблемы, вы можете принять меры по их устранению. Если проверка выявит серьёзные проблемы, возможно, лучше не использовать пакет: возможно, вы найдёте более подходящий или, может, нужно разработать его самостоятельно. Помните, что пакеты с открытым исходным кодом публикуются авторами в надежде, что они будут полезны, но без гарантии удобства использования или поддержки. При сбое в продакшне именно вам придётся его отлаживать. Как предупреждала первая Стандартная общественная лицензия GNU, «весь риск, связанный с качеством и производительностью программы, лежит на вас. Если программа окажется дефектной, вы берёте на себя расходы на всё необходимое обслуживание, ремонт или исправление».
Дальше изложим некоторые соображения по проверке пакета и принятии решения о том, следует ли от него зависеть.
Дизайн
Документация пакета ясна? У API чёткий дизайн? Если авторы могут хорошо объяснить API и дизайн человеку, то это увеличивает вероятность, что они также хорошо объяснили реализацию компьютеру в исходном коде. Написание кода для чёткого, хорошо продуманного API проще, быстрее и, вероятно, менее подвержено ошибкам. Задокументировали ли авторы, чего они ожидают от клиентского кода, для совместимости с будущими обновлениями? (Среди примеров — документы совместимости C++ и Go).
Качество кода
Хорошо ли написан код? Прочтите некоторые фрагменты. Похоже ли, что авторы осторожны, добросовестны и последовательны? Похож ли он на код, который вы хотите отладить? Возможно, вам придётся это делать.
Не забывайте о методах разработки, с которыми вы, возможно, не знакомы. Например, библиотека SQLite поставляется как один файл с 200 000 кода и заголовком на 11 000 строк — в результате слияния множества файлов. Сам размер этих файлов сразу поднимает красный флаг, но более тщательное исследование приведёт к фактическому исходному коду разработки: традиционному файловому дереву с более чем сотней исходных файлов на C, тестами и скриптами поддержки. Получается, что однофайловый дистрибутив строится автоматически из оригинальных исходников: так проще для конечных пользователей, особенно тех, у кого нет менеджеров зависимостей. (Скомпилированный код также быстрее работает, поскольку компилятор видит больше возможностей оптимизации).
Тестирование
Есть ли в коде тесты? Вы можете ими управлять? Они проходят? Тесты устанавливают, что основная функциональность кода верна, и сигнализируют о том, что разработчик серьёзно старается сохранить её. Например, дерево разработки SQLite содержит невероятно подробный набор тестов с более чем 30 000 отдельных тестовых случаев. Есть документация для разработчиков, объясняющая стратегию тестирования. С другой стороны, если тестов мало или нет вообще, или если тесты не проходят, это серьёзный красный флаг: будущие изменения в пакете, вероятно, приведут к регрессиям, которые можно было легко обнаружить. Если вы настаиваете на тестах в своём коде (так ведь?), то должны обеспечить тесты для кода, который передаёте другим.
Предполагая, что тесты существуют, запускаются и проходят, то можете собрать дополнительную информацию, запустив инструменты для анализа покрытия кода, обнаружения состояний гонки, проверки выделения памяти и обнаружения утечек памяти.
Отладка
Найдите баг-трекер этого пакета. Много ли открытых сообщений об ошибках? Как давно они открыты? Сколько ошибок исправлено? Есть ли баги, исправленные в последнее время? Если там много открытых вопросов о настоящих багов, особенно не закрытых в течение длительного времени, это плохой знак. С другой стороны, если ошибки редко встречаются и быстро исправляются, это здорово.
Поддержка
Посмотрите на историю коммитов. Как долго код активно поддерживается? Сейчас он активно поддерживается? Пакеты, которые активно поддерживались в течение длительного периода времени, скорее всего, будут продолжать поддерживаться. Сколько человек работает над пакетом? Многие пакеты — это личные проекты, которые разработчики создают для развлечения в свободное время. Другие — результат тысяч часов работы группы платных разработчиков. В общем, у пакетов второго типа обычно быстрее исправляют ошибки, стабильно внедряют новые функции и в целом они лучше поддерживаются.
С другой стороны, некоторый код действительно «идеален». Например, escape-string-regexp из NPM, может, больше никогда не понадобится изменять.
Использование
Сколько пакетов зависит от этого кода? Пакетные менеджеры часто дают такую статистику, или можно посмотреть в интернете, как часто другие разработчики упоминают этот пакет. Большее количество пользователей означает хотя бы то, что у многих код работает достаточно хорошо, а ошибки в нём будут замечать оперативнее. Широкое использование также является частичной гарантией продолжения обслуживания: если широко используемый пакет теряет мейнтейнера, то очень вероятно, что его роль возьмёт на себя заинтересованный пользователь.
Например, невероятно широко используются библиотеки вроде PCRE, Boost или JUnit. Это делает более вероятным — хотя, конечно, не гарантирует — что ошибки, с которыми вы могли столкнуться, уже исправлены, потому что другие столкнулись с ними раньше вас.
Безопасность
Будет ли этот пакет работать с небезопасными входными данными? Если да, то насколько он устойчив к вредоносным данным? Есть ли у него баги, которые упоминаются в Национальной базе уязвимостей (NVD)?
Например, когда в 2006 году мы с Джеффом Дином начали работать над поиском по коду Google Code Search ( grep по публичным базам кода), очевидным выбором казалась популярная библиотека регулярных выражений PCRE. Однако в разговоре с командой безопасности Google мы узнали, что у PCRE есть длинная история проблем, таких как переполнение буфера, особенно в парсере. Мы сами убедились в этом, поискав PCRE в NVD. Это открытие не сразу заставило нас отказаться от PCRE, но заставило более тщательно подумать о тестировании и изоляции.
Лицензирование
Правильно ли лицензирован код? У него вообще есть лицензия? Приемлема ли лицензия для вашего проекта или компании? Удивительная часть проектов на GitHub не имеют чёткой лицензии. Ваш проект или компания могут наложить дополнительные ограничения на лицензии зависимостей. Например, Google запрещает использовать код под лицензиями типа AGPL (слишком жёсткие) и типа WTFPL (слишком расплывчатые).
Зависимости
Имеет ли у этого пакета собственные зависимости? Недостатки в косвенных зависимостях так же вредны, как и недостатки в прямых зависимостях. Пакетные менеджеры могут перечислить все транзитивные зависимости данного пакета, и каждый из них в идеале следует проверить, как описано в этом разделе. Пакет со многими зависимостями потребует немалой работы.
Тестирование зависимости
Процесс проверки должен включать запуск собственных тестов пакета. Если пакет прошел проверку и вы решили сделать свой проект зависимым от него, следующим шагом должно быть написание новых тестов, ориентированных на функциональность конкретно вашего приложения. Эти тесты часто начинаются как короткие автономные программы, чтобы убедиться, что вы можете понять API пакета и что он делает то, что вы думаете (если вы не можете понять или он не делает что надо, немедленно остановитесь!). Затем стоит приложить дополнительные усилия, чтобы превратить эти программы в автоматические тесты, которые будут запускаться с новыми версиями пакета. Если вы нашли ошибку и у вас есть потенциальное исправление, вы сможете легко перезапустить эти тесты для конкретного проекта и убедиться, что исправление не сломало ничего другого.
Абстрагирование зависимости
Зависимость от пакета — это решение, от которого вы в будущем можете отказаться. Возможно, обновления уведут пакет в новом направлении. Возможно, будут найдены серьёзные проблемы безопасности. Возможно, появится лучший вариант. По всем этим причинам стоит приложить усилия, чтобы упростить миграцию проекта на новую зависимость.
Если пакет вызывается из многих мест в исходном коде проекта, для перехода на новую зависимость потребуется внести изменения во все эти различные места. Хуже того, если пакет представлен в API вашего собственного проекта, то миграция на новую зависимость потребует внесения изменений в весь код, вызывающий ваш API, а это уже может оказаться вне вашего контроля. Чтобы избежать таких затрат, имеет смысл определить собственный интерфейс вместе с тонкой обёрткой, реализующей этот интерфейс с помощью зависимости. Обратите внимание, что обёртка должна включать только то, что требуется проекту от зависимости, а не всё, что предлагает зависимость. В идеале это позволяет позже заменить другую, одинаково подходящую зависимость, изменив только враппер. Миграция тестов для каждого проекта на использование нового интерфейса проверяет реализацию интерфейса и обёртки, а также упрощает тестирование любых потенциальных замен для зависимости.
Изоляция зависимости
Также может быть целесообразно изолировать зависимость во время выполнения, чтобы ограничить возможный ущерб, вызванный ошибками в ней. Например, Google Chrome позволяет пользователям добавлять в браузер зависимости — код расширений. Когда Chrome впервые запустили в 2008 году, он представил критическую функцию (теперь стандартную во всех браузерах) изоляции каждого расширения в песочнице, работающей в отдельном процессе операционной системы. Потенциальный эксплоит в плохо написанном расширении не имел автоматического доступа ко всей памяти самого браузера и не мог совершить неуместных системных вызовов. Для Code Search, пока мы не отбросили PCRE полностью, план состоял в том, чтобы по крайней мере изолировать парсер PCRE в аналогичной песочнице. Сегодня ещё одним вариантом будет лёгкая песочница на основе гипервизора, такая как gVisor. Изоляция зависимостей снижает связанные риски выполнения этого кода.
Даже с этими примерами и другими готовыми опциями изоляция подозрительного кода во время выполнения по-прежнему слишком сложна и редко выполняется. Истинная изоляция потребует полностью безопасного для памяти языка, без аварийного выхода в нетипизированный код. Это сложные не только в полностью небезопасных языках, таких как C и C++, но и в языках, которые обеспечивают ограничивают небезопасные операции, такие как Java при включении JNI, или как Go, Rust и Swift, при включении своих функций unsafe. Даже в таком безопасном для памяти языке, как JavaScript, код часто имеет доступ к гораздо большему, чем ему нужно. В ноябре 2018 г. оказалось, что последняя версия пакета npm event-stream (функциональный потоковый API для событий JavaScript) содержит запутанный вредоносный код, добавленный два с половиной месяца назад. Код собирал биткоин-кошельки у пользователей мобильного приложения Copay, получал доступ к системным ресурсам, совершенно не связанным с обработкой потоков событий. Одним из многих возможных способов защиты от такого рода проблем была бы лучшая изоляция зависимостей.
Отказ от зависимости
Если зависимость кажется слишком рискованной и вы не можете её изолировать, лучшим вариантом может быть полный отказ от неё или хотя бы исключение наиболее проблемных частей.
Например, когда мы лучше поняли риски PCRE, наш план для Google Code Search превратился из «использовать библиотеку PCRE напрямую» в «использовать PCRE, но поместить парсер в песочницу», потом в «написать новый парсер регулярных выражений, но сохранить движок PCRE», затем в «написать новый парсер и подключить его к другому, более эффективному движку с открытым исходным кодом». Позже мы с Джеффом Дином переписали также и движок, так что никаких зависимостей не осталось, и мы открыли результат: RE2.
Если вам нужна лишь небольшая часть зависимости, проще всего сделать копию того, что вам нужно (конечно, сохраняя соответствующие авторские права и другие юридические уведомления). Вы берёте на себя ответственность за исправление ошибок, техническое обслуживание и т. д., Но вы также полностью изолированы от более крупных рисков. В сообществе разработчиков Go есть поговорка на этот счёт: «Немного копирования лучше, чем немного зависимости».
Обновление зависимости
Долгое время общепринятой мудростью в программном обеспечении было: «Если работает, ничего не трогай». Обновление несёт риск введения новых ошибок; без соответствующей награды — если вам не нужна новая функция, зачем рисковать? Такой подход игнорирует два аспекта. Во-первых, это стоимость постепенного обновления. В программном обеспечении сложность внесения изменений в код не масштабируется линейно: десять маленьких изменений — это меньше работы и легче, чем одно соответствующее большое изменение. Во-вторых, трудность обнаружения уже исправленных ошибок. Особенно в контексте безопасности, где известные ошибки активно эксплуатируются, каждый день без обновления повышает риски, что злоумышленники могут воспользоваться багов в старом коде.
Например, рассмотрим историю компании Equifax от 2017 года, о которой подробно рассказали руководители в показаниях перед Конгрессом. 7 марта была раскрыта новая уязвимость в Apache Struts и выпущена исправленная версия. 8 марта Equifax получил уведомление US-CERT о необходимости обновления любых видов использования Apache Struts. Equifax запустила сканирование исходного кода и сети 9 и 15 марта соответственно; ни одно сканирование не обнаружило уязвимых веб-серверов, открытых в интернет. 13 мая злоумышленники нашла такие сервера, которые специалисты Equifax не обнаружили. Они использовали уязвимость Apache Struts для взлома сети Equifax, а в течение следующих двух месяцев украли подробную личную и финансовую информацию о 148 миллионах человек. Наконец, 29 июля Equifax заметила взлом и публично объявила о нём 4 сентября. К концу сентября исполнительный директор Equifax, а также CIO и CSO подали в отставку, и началось расследование в Конгрессе.
Опыт Equifax приводит к тому, что хотя менеджеры пакетов знают версии, которые они используют во время сборки, вам нужны другие механизмы для отслеживания этой информации в процессе развёртывания в продакшне. Для языка Go мы экспериментируем с автоматическим включением манифеста версии в каждый двоичный файл, чтобы процессы развёртывания могли сканировать двоичные файлы на наличие зависимостей, требующих обновления. Go также делает эту информацию доступной во время выполнения, так что серверы могут обращаться к базам данных известных ошибок и самостоятельно сообщать в систему мониторинга, когда они нуждаются в обновлении.
Быстрое обновление важно, но обновление означает добавление нового кода в проект, что должно означать обновление оценки рисков использования зависимости на основе новой версии. Как минимум, вы хотите просмотреть различия, показывающие изменения, вносимые из текущей версии в обновлённые версии, или, по крайней мере, прочитать заметки о выпуске, чтобы определить наиболее вероятные проблемные области в обновлённом коде. Если изменяется много кода, так что различия трудно понять, это также информация, которую можно включить в обновление оценки риска.
Кроме того, необходимо повторно выполнить тесты, написанные специально для проекта, чтобы убедиться, что обновлённый пакет по крайней мере так же подходит для проекта, как и более ранняя версия. Также имеет смысл повторно запустить собственные тесты пакета. Если у пакета собственные зависимости, вполне возможно, что конфигурация проекта использует другие версии этих зависимостей (более старые или более новые), чем те, которые используют авторы пакета. Выполнение собственных тестов пакета позволяет быстро выявить проблемы, характерные для конфигурации.
Опять же, обновления не должны быть полностью автоматическими. Перед развёртыванием обновленных версий необходимо убедиться, что они подходят для вашей среды.
Если процесс обновления включает повторное выполнение уже написанных интеграционных и квалификационных тестов, то в большинстве случаев задержка обновления более рискованна, чем быстрое обновление.
Следите за своими зависимостями
Даже после всего этого работа не закончена. Важно продолжать следить за зависимостями и в каких-то случаях даже отказаться от них.
Ползучие зависимости также могут влиять на размер проекта. Во время разработки Google Sawzall — языка JIT-обработки логов — авторы в разное время обнаружили, что основной бинарник интерпретатора содержит не только JIT Sawzall, но и (неиспользуемые) интерпретаторы PostScript, Python и JavaScript. Каждый раз виновником оказывались неиспользуемые зависимости, объявленные какой-то библиотекой Sawzall, в сочетании с тем, что система сборки Google полностью автоматически использовала новую зависимость. Вот почему компилятор Go выдаёт ошибку при импорте неиспользуемого пакета.
Обновление — естественное время для пересмотра решения об использовании изменяющейся зависимости. Также важно периодически пересматривать любую зависимость, которая не меняется. Кажется ли правдоподобным, что нет проблем с безопасностью или других ошибок для исправления? Проект заброшен? Возможно, пришло время планировать замену этой зависимости.
Ещё важно перепроверить журнал безопасности каждой зависимости. Например, Apache Struts раскрыл серьёзные уязвимости удалённого выполнения кода в 2016, 2017 и 2018 годах. Даже если у вас много серверов, которые его запускают и быстро обновляют, такой послужной список наводит на мысль, а стоит ли его вообще использовать.
Вывод
Эпоха повторного использования программного обеспечения, наконец, наступила, и я не хочу преуменьшать преимущества: это принесло чрезвычайно позитивную трансформацию разработчикам. Тем не менее, мы приняли эту трансформацию, не полностью продумав потенциальные последствия. Прежние причины доверять зависимостям теряют актуальность в то самое время, когда у нас больше зависимостей, чем когда-либо.
Критический анализ конкретных зависимостей, который я описал в этой статье, представляет собой значительный объём работы и остаётся скорее исключением, чем правилом. Но я сомневаюсь, что есть разработчики, которые действительно прилагают усилия, чтобы сделать это для каждой возможной новой зависимости. Я сделал только часть этой работы для части собственных зависимостей. В основном всё решение сводится к следующему: «давайте посмотрим, что произойдёт». Слишком часто что-то большее кажется слишком большим усилием.
Но атаки Copay и Equifax являются чёткими предупреждениями о реальных проблемах в том, как мы сегодня используем зависимости программного обеспечения. Мы не должны игнорировать предупреждения. Предлагаю три общие рекомендации.




