Как стать автором
Обновить

Комментарии 53

А вообще как сделать dependency injection на javascript? Есть решение какое-нибудь типы Spring? Чтобы руками зависимости в конструктор не передавать?
Насколько я знаю, в Angular 2 используется подход, очень похожий на Spring. Там как раз TypeScript, начиная с версии 1.5 он поддерживает ES6-декораторы, так что это даже выглядит похоже.

У нас в проекте используется собственная реализация паттерна Service Locator.
Хочется библиотеку, которая реализует DI и больше ничего не реализует. Неужели такой для джаваскрипта нет?
github.com/young-steveo/bottlejs

В копилку :)

А вообще не очень правильно поставленный вопрос: DI — это принцип, который можно реализовать множеством способов, среди которых так или иначе основные через конструкторы и через сеттеры/аддеры. Библиотеки типа DI-контейнеров, скрывающие от нас это, всё равно в большинстве своем их используют.
В языках подобных JS, где объект это всего лишь словарь (если смотреть снизу-вверх), я последнее время применяю такой подход:
click
// Предположим у нас есть прокси к серверной части, который работает с данными.
function dbProxy(tableName){
  ...
}

// А этот красс использует прокси и формирует виджет списка выбора (Select) для пользователя. Причем класс знает какой именно прокси ему нужно использовать:
function SelectWidget(){
}

SelectWidget.prototype.render = function(){
  // Если свойство заданно динамически, используем его как средство инъекции зависимостей, иначе используем дефолтный объект прокси.
  var db = (this.db !== undefined)? this.db : new dbProxy('users');
  db.getData(...);
  ...
}

// Тестируется просто:
var obj = new SelectWidget();
obj.db = mock('dbProxy')
obj.db.expects(':getData');
...

High Cohesion, или высокая связность говорит о том, что внутри модуля весь функционал согласован и сфокусирован на решении какой-то узкой проблемы. Это значит, что при правильном проектировании модули получаются компактными и понятными, не содержат «лишнего» кода и побочных эффектов.

Какой-то не очевидный вывод. Каким образом высокая связность вообще связана с побочными эффектами?
Если строго подходить к вопросу, то если ваш модуль имеет высокую связность, то все его функции(методы, классы) интенсивно используют друг друга. При этом компактность и понятность также не гарантируются.
Может, я не очень правильно выразился, но попробую пояснить:
Вот например, есть модуль, который выполняет авторизацию и возвращает токен сессии. А внутри себя он выполняет еще и сохранение сессии в local storage. По идее, получается, что функционал не очень связный, ведь для работы с хранилищем лучше выделить отдельный модуль. А в итоге мы получаем побочный эффект, если, например, не хотели эту сессию сохранять.
Вы совершенно правы, острой нужды в нем нет. Однако, например, это может быть полезно, если у IB есть несколько реализаций. В любом случае, хорошо, когда есть ровно тот интерфейс, который нам необходимо реализовать.
Часто это не хорошо, а говорит как раз о низкой связности внутри модуля. Наследование интерфейсов бывает полезно, но чаще оно прячет не очень удачную архитектуру.
Спасибо за рассмотр всех этих случаев… Везде есть свои плюсы и минусы…
Да разделение интерфейсов и вообще их дизайн важная тема!
Я в своей команде борюсь со старшим поколением которое вообще уверено что юнит-тесты это на практике ничего не дающая…
И моль только интеграционные тесты надо писать, а ТестПирамида не спустилась с небес и вообще мозехт быть ерунда :((
Так что спасибо за тему, как бальзам ;)
Прошу прощения за небольшой оффтоп, но мне больше нравится перевод терминов Low Coupling и High Cohesion в лоб, как: Слабая связанность и Высокая сплоченность. Как то это больше отражает их смысл.
Помоему, классический перевод — связанность и связность.
Возможно, но не очень понятно что там с чем связывается и связуется (не силен в русском языке, может не верно отглаголил).
Я всего лишь уточнил, какие термины больше нравятся мне, не более того )
Что мне не нравится в таких статьях, так это когда чуть ли не единственной причиной добавления интерфйесов в проект называют тестируемость кода. Это путает и пугает новичков. Сложно обьяснить нужность тестов, когда они за собой еще и интерфейсы и IoC всякие тащат. Нужна базовая часть, чтобы описать, что такой код(со слабой связанностью) и без тестов выигрышнее привычного им. Правда для хорошего примера нужен код в пару десятков сущностей и их взаимодействий. И это проблема…
Тестируемость — вполне себе неплохая метрика качества кода. Грубо говоря, хороший код и тестировать легко. И если тестировать модуль сложно, то, скорее всего, либо у него не loose coupling, либо не high cohesion.

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

Понятно, что на приведенных примерах показанные подходы кажутся излишним усложнением. Но когда у вас в фасаде подсистем по 20 методов, и каждому модулю нужна лишь какая-то их часть — разделение их на уровне интерфейсов кажется вполне разумным решением.
Думаю если у вас в модуле по 20 методов, стоит удивится, а почему так много?
А что тут думать. Интерфейсы нужны как точки расширения системы, например вон через USB можно подключать как мышь, так и клавиатуру и много чего еще изначально чего не планировалось в системе иметь.
Проблема тут в другом, что тестируя все модули и выделяя для каждого из них свой интерфейс может получиться настолько абстрактный монстр, что вместе с DI никто уже толком не сможет разобраться что в системе происходит и для чего она вообще создавалась.

Вообще добавление тестирования к системе это большой такой + к затратам и что более важно сложности системы.
большой такой + к затратам и что более важно сложности системы

А что усложняется то? Добавляется папочка tests?
В том то и дело что создать папочку тут не достаточно )
Взять тот же пример из статьи. У нас есть класс A, к-ый используется классами B, C, D. Причем каждый этот класс использует только 1 метод из класса А, допустим b, c и d соответственно.
Не парясь о тестируемости мы бы просто реализовали интерфейс IA состоящий из методов b,c,d (а скорее всего даже его и не реализовывали).
Но чтобы сделать его тестируемым нам нужно выделить еще 3 интерфейса: IAforB, IAforC, IAforD с соответствующими методами.
Тут уже можно увидеть закономерность, что кол-во сущностей у нас уже удвоилось.

А вот вам еще один случай, у нас появляется новый класс E, которому нужно 2 метода: b и с, или новый метод e. Что мы теперь будем делать?
Не парясь о тестируемости мы бы просто реализовали интерфейс IA состоящий из методов b,c,d

Но чтобы сделать его тестируемым нам нужно выделить еще 3 интерфейса: IAforB, IAforC, IAforD с соответствующими методами

Ужасное решение. Ниже я уже писал, что нужно уметь определять границу, после которой дробление интерфейса становится во зло.
А вот вам еще один случай, у нас появляется новый класс E, которому нужно 2 метода: b и с, или новый метод e. Что мы теперь будем делать?

Использовать реализацию IA, как и раньше.
Как правило, когда тесты начинают писать на уже готовом проекте, то или тесты становятся монструозными (особенно если четко следят, чтобы модульные не являлись интеграционными), или переписывается архитектура на лету, что обычно приводит к ошибкам. А чаще и то, и другое — изменения архитектуры на микроуровне, что лишь немного помогает сократить объём тестов.
или переписывается архитектура на лету, что обычно приводит к ошибкам

Моя практика показывает, что написание тестов и связанное с этим изменение архитектуры всегда ведут только к лучшему. Если вы что то тестируете и это приводит к ошибкам, значит вы делаете что то не то.
Чтобы один простой по сути тест не превращался в три экрана кода, оставаясь при этом модульным, очень часто нужно изменить метод до того, как будешь тест писать.
Идея тут в том, что если для написания теста под метод нужно три экрана кода, то что то не так с методом, а тест всего лишь лакмусовая бумажка.
Не, гораздо лучше написать тест, ужаснуться и потом уже рефакторить и метод и тест. И следить, чтобы тест не падал. Так проще.
Тест на 3 экрана писать?
Да, тест на 3 экрана. Который можно будет прогонять, чтобы убедиться, что всё работает и рефакторинг ничего не ломает. А потом в процессе рефакторинга добиться того, что бы этот тест стал меньше и красивей.
Если у вас написание модульного теста занимает 3 экрана — значит, что-то не так с самим тестируемым модулем. Либо его сложно использовать (тогда и тесты, и «настоящий» код, использующий этот модуль, будет громоздким), а значит надо рефакторить интерфейс, либо сложно выделить его зависимости, а это значит у него проблемы с coupling'ом, и опять-таки модуль надо рефакторить.

Есть одно исключение — сложно тестировать код, который не содержит логики, а содержит только связи (создание объектов, проброс событий, проксирование методов). Но ивыгода от тестирования такого кода сомнительна.
На готовом проекте, который писался без тестов, скорее всего будет что-то не так с модулем. Скорее и то, и другое, и что-нибудь ещё. А без тестов рефакторить такой модуль опасно и долго. А тесты писать долго и опасно (можно не разобраться в каком-то хаке, который тесты не покроют, но будет ложная уверенность). Замкнутый круг.

Единственное, что я нашёл приемлемым на практике — создавать новый модуль (чаще модули, поскольку у унаследованных очень редко одна ответственность), инжектить туда старый, проксировать старые методы, писать тесты на новые аналоги с ожидаемым поведением, реализовывать эти аналоги по уму, в том числе пригодными к тестированию, потом в прокси-методе делать вызовы и того, и другого, при расхождениях отдавать старый результат и логировать аргументы и оба результата, на каждое расхождение писать новые тесты на новые аналоги, озеленять их и повторять пока расхождений не будет.
Используя финансовый язык: введение абстракций, тестов и т.д. это вложения в качество кода. Окупятся эти вложения или нет — зависит уже немного от других факторов.
Если у вас есть одни большущий интерфейс, который используется разными модулями, каждый из которых нужно протестировать, можно еще не делить интерфейс на части, а реализовать пустую абстракцию, от которой будут наследоваться моки в каждом конкретном тесте. Эта абстракция создается только для тестов и никак не влияет на пользовательский код, что так же уменьшает связанность между тестами и не тестами.
Вполне жизнеспособный подход, согласен. Но есть одно «но»: разделение большого интерфейса на несколько, каждый из которых «заточен» под конкретный модуль, снижает сопряжение для такого модуля. То есть это разделение полезно не только для тестов, но и для самого кода.
Ну тут зависит от ситуации. Если разделение интерфейса создает две разные сущности, то это хорошо, но если разделение делается только ради тестов, а на деле исходный интерфейс достаточно High Cohesion, получаются спагетти.
Не снижает в общем случае для зависимых модулей, но увеличивает для того, от которого зависит. Рассмотрим ситуацию после введения общего интерфейса: три модуля зависит от интерфейса (один имплементирует, два используют) — ровно три связи, у каждого модуля по одной. Разбили (пускай без наследования для имплементирующего). Теперь один имплементирует два интерфейса, а два используют по одному — 4 связи, у имплментирующего их стало две. Объединили через наследование: вроде вернулись к трём связям у модулей, но по сути их стало пять: теперь у самого общего интерфейса их ещё две — теперь при изменении разбитых интерфейсов нужно будет продираться через цепочку наследования.

Было бы иначе, можно было бы просто каждый метод объявлять интерфейсом :) Как правило, разбиение интерфейса без планов либо разбиения его основной реализации, либо введение дополнительных имплементаций хотя бы одной части, только ухудшает код. Конечно, можно рассматривать создание моков в тестах как дополнительные имплементации, но обычно тесты не рассматриваются как полноценная часть приложения и при автоматизации тестирования по инициативе снизу аргумент «теперь так будет проще тестировать» выглядит неубедительно для верхов, которые скептически относились к инициативе, когда они увидят внезапно разросшийся код без всякого добавления функциональности.
Ну, о том, что порождается много интерфейсов, в которых надо как-то ориентироваться, я уже писал в самой статье. Да, такая проблема правда существует, и при наличии какого-то соглашения об их именовании это не доставляет большого дискомфорта.

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

Есть еще один профит от разбиения на подинтерфейсы по принципу «востребованности» модулями (обратите внимание, я уже ниже писал, что один и тот же метод может встречаться в разных интерфейсах). Оно документирует код. Представьте, допустим у вас есть код, который позволяет купить корзину товаров. Теперь, пусть возникло новое требование: пользователь может в один клик купить один товар, то есть товар теперь реализует интерфейс, необходимый для «покупаемости». Но если мы не разбивали интерфейсы, то выяснить, что же нужно для «покупаемости», мы можем только прочитав код реализации. А при разделении у нас для этого автоматически есть готовый интерфейс, ICartForPurchase.
Пример у вас получился неуниверсальный, надо заметить. Важно сделать акцент, что интерфейс должен быть законченным. Вот например, у нас есть некоторое хранилище данных, которое может хранить числа и строки:
public interface Storage {
  public Integer getInt(String key);
  public String getString(String key);
  public void setInt(String key, Integer value);
  public void setString(String key, String value);
};


Допустим, тестируемый класс использует единственный метод — getInt для получения каких-то данных из хранилища. Будем ради облегчения тестирования разбивать интерфейс с выделением этого куска? Нет, поскольку такой интерфейс не обладает целостностью. Никто не может гарантировать, что в будущем этому же классу не потребуется доступ к строковым значениям. Или что не будет введено хранение, например, дат или XML-документов.

Максимум на что можно разбить этот интерфейс — это выделить ReadableStorage, так как задача получения данных из хранилища без необходимости записи в него достаточно целостная и под это вполне можно выделить отдельный интерфейс.
public interface ReadableStorage {
  public Integer getInt(String key);
  public String getString(String key);
};

public interface Storage implements ReadableStorage {
  public void setInt(String key, Integer value);
  public void setString(String key, String value);
};


Продолжать разбивать эти интерфейсы дальше, ИМХО, не только бессмысленно, но и просто опасно.

Дробить интерфейсы можно и нужно, но нельзя делать это бездумно, по принципу «легче тестировать». Таким подходом можно ситуативно наплодить N интерфейсов, которые вместе образуют дикую кашу и сделают код основного приложения значительно более сложным для сопровождения, чем он был до разбиения.
Навскидку я бы разбил интерфейс по принципу строки и целые :)
Возможно в некоторых случаях имеет смысл делать и так, но нужно смотреть на предметную область. Если классы, которые используют этот интерфейс работают каждый со своим типом данных и добавление новых типов данных приведет к созданию нового класса (и соответственно интерфейса для него) — это одно. Если возможно использование разных типов данных — это другое.
Тут основной принцип — не чтобы было «легче тестировать», а выделить подинтерфейсы, необходимые для каждого клиента интерфейса подсистемы.
Логика примерно такая: нам нужно разделить интерфейс на части. По какому принципу это делать? Очевидно, давайте связанные по смыслу методы сгруппировывать в отдельные интерфейсы. Остается вопрос — что такое «связанные по смыслу»? В качестве меры «связанности по смыслу» вполне можно использовать «используются вместе». И тестирование, как видите, в этих рассуждениях совсем ни при чем.

Есть один маленький нюанс: разбиение на интерфейсы вида I{Module}For{OtherModule} не обязательно непересекающееся, то есть один и тот же метод может использоваться для разных подсистем. Язык позволяет сделать так при условии, что сигнатуры методов совпадают.
Принцип «используются вместе» порождает нецельные интерфейсы, поскольку клиент часто не использует все методы интерфейса одновременно. Но некоторые методы нельзя исключать из интерфейса по этому принципу, поскольку получим нецельный интерфейс, который будет затруднительно использовать при дальнейшем развитии системы, его придется дополнять. А изменение интерфейсов (по сути API класса/модуля) — это совсем не то с чем хочется иметь дело часто.
Что значит «нецельный интерфейс»? На мой взгляд, «цельность» интерфейса и есть «связанность методов в нем по смыслу». Так что тут вопрос в том, как эту «цельность» определять.

А изменение интерфейсов (по сути API класса/модуля) — это совсем не то с чем хочется иметь дело часто.

А вот тут не согласен. API от этого не меняется, фасадный интерфейс остается тем же. А вот то, что в клиентском модуле появилась новая зависимость от метода, как раз и отражается в изменении интерфейса.
«цельность» интерфейса и есть «связанность методов в нем по смыслу»

Правильно! По смыслу, а не «по используемости другими модулями»
«Смысл» может быть разный:)
Я писал об этом уже в этой же ветке:
В качестве меры «связанности по смыслу» вполне можно использовать «используются вместе».


Вам не кажется, что если какой-то модуль использует набор методов другого модуля — то они связаны по смыслу? Я к тому, что вряд ли при правильном разбиении на модули получится так, что от одного модуля в одном и том же месте нужны две ну совершенно не связанные между собой функции.

И еще, для вашего примера возможно два вполне логичных разбиения: по типу данных (int/string) и по типу аксессора (get/set). Вполне может сложиться такая ситуация, что модулю нужен getInt и setString. Что тогда делать, использовать сразу два интерфейса?
Вполне может сложиться такая ситуация, что модулю нужен getInt и setString. Что тогда делать, использовать сразу два интерфейса?

Проанализировать такой модуль, конечно же. А там — либо это оправданно (тогда передавать Storage), либо модуль надлежит переработать и разделить, так как у него недостаточно высокий cohesion
Ну то, что код надо анализировать, я ни в коем случае не отрицаю, это необходимо независимо от используемых методик и паттернов :)

А вот передавать весь Storage ради двух методов, на мой взгляд, довольно сомнительное решение (в плане coupling'а).
Вам не кажется, что если какой-то модуль использует набор методов другого модуля — то они связаны по смыслу?


Есть какой-то стандарт, в котором описаны разные типы связанности, их там под десяток, и «по смыслу» — лишь один из них. Навскидку может быть ещё «паразитная» связь по данным, например, в случае использования денормализованных и просто сложно связанных данных, когда есть несколько путей получения нужного значения, а выбирается путь не по смыслу, а по принципу «у этого модуля данные эти есть, нам они нужны, мы его уже используем в других целях, так зачем нам зависимость от другого (пускай и более подходящего по смыслу) модуля, возьмём от этого, несмотря на то, что там они предназначены для другого сценария использования».

И еще, для вашего примера возможно два вполне логичных разбиения: по типу данных (int/string) и по типу аксессора (get/set)


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

Что тогда делать, использовать сразу два интерфейса?

Да. Но оценить перед разбиением как часто такая ситуация будет встречаться и если однозначного ответа нет, то вообще подумать об использование других методов рефакторинга.
И тестирование, как видите, в этих рассуждениях совсем ни при чем.


Формально говоря, модульное тестирование — это один из видов использования. Другое дело, что во многих командах двух- или даже трёхкратное использование с обширной копипастой не является само по себе достаточным основанием для изменения существующих интерфейсов (в широком смысле слова) модулей даже в основном коде, не говоря о коде тестов, особенно введенных без энтузиазма.
Вообще, чем меньше в коде копипасты — тем более высокий в нем уровень абстракции, а значит его проще переиспользовать. Написание тестов во многом помогает выявить эту самую копипасту.
На мой взгляд, добавлять методы в интерфейс ради теста неприемлемо, тогда как дробить интерфейс на части мы вполне можем ради тестов, так как это (при адекватном подходе, естественно) положительно влияет на качество кода.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий