Один из самых распространенных способов параллельной (конкурентной) разработки нескольких функций – применение при контроле версий самостоятельных функциональных веток. В каждой из таких веток конкретная функция разрабатывается отдельно от других, а потом интегрируется в основной рабочий поток, когда разработка данной функции будет завершена. Такой паттерн называется «Feature Branch» (ветвление функций), см. тут
При применении такого подхода возникает проблема: дело в том, что функциональные ветки кода, разрабатываемые в изоляции друг от друга, сравнительно редко интегрируются. При этом упускаются все преимущества, связанные с непрерывной интеграцией. Чем дольше ветка отделена от основного цикла разработки, тем более значительный риск накапливается из-за откладывания интеграции. Обычно такие проблемы проявляются при попытке интегрировать функциональную ветку в основной цикл разработки. Действительно, не кажется ли насмешкой ситуация, когда приходится сразу интегрировать все наработки, сделанные за пару месяцев?
Упрощенное решение, позволяющее снизить сложность интеграции при достаточно долгом самостоятельном развитии одной ветки – регулярно интегрировать в функциональную ветку все изменения, возникающие в основном рабочем потоке. Но такой подход рано или поздно отказывает. Специалисты, занятые разработкой основного потока, практически не наблюдают или вообще не наблюдают той разработки, которая ведется в функциональной ветке. Если в основном потоке будет проведен рефакторинг, то его придется заносить в функциональные ветки вручную, даже притом, что такой рефакторинг зачастую проводится совершенно без учета событий, происходящих в функциональных ветках.
Если специалисты, занятые разработкой функциональной ветки, будут делать рефакторинг прямо в ней, это будет лишь осложнять будущие слияния и приводить к ситуациям, когда на интеграцию ветки уходит больше времени, чем на повторную разработку всего ее функционала с нуля. В результате, возникает нежелание вносить изменения, чтобы не провоцировать сложных конфликтов слияния (merge conflict). Рефакторинг необходим, но он откладывается или вообще отменяется. Вас заваливает технический долг.
Другой подход, который в последнее время активно обсуждается – разрабатывать в основной линии сразу все функции приложения. Такой подход известен под названием «стволовая разработка» (trunk-based development), подробнее см. тут. Этот подход очень хорош, если работа над всеми незавершенными функциями может быть закончена до даты следующего релиза. Но если завершить разработку всех функций до ближайшего релиза невозможно, то незавершенные функции приходится отключать – а значит, они будут невидимы для пользователей. Здесь мы подходим к обсуждения паттерна, который называется «Ротация функций» (Feature Toggle). См. тут.
Простая ротация функций
Чтобы реализовать самую простую форму ротации функций, мы можем просто отображать и скрывать в пользовательском интерфейсе входную точку для использования данной функции. Для этого задействуем в шаблоне простой условный оператор.
<c:if test="${featureFoo}"> <a href="/foo">Foo</a> </c:if>
Что-то подобное для обеспечения простых изменений можно сделать и в логике приложения.
public void doSomething() { if (featureFoo) { «foo specific logic» } «regular logic» }
В случае внесения более сложных изменений, такой подход, пожалуй, в итоге приведет к накоплению целого клубка условных операторов, которые просочатся в каждый уголок базы кода. Более того, если эти условия сохранятся в базе кода в течение долгого времени после того, как обслуживаемые ими функции прошли релиз, либо останутся в коде просто на всякий случай, то рано или поздно приложение просто утонет в условных операторах. Такой код будет решительно невозможно поддерживать или читать.
Поддержка ротации функций
При необходимости более глубоких изменений следует использовать наследование или композицию и дополнять имеющийся код специфичной функциональностью. В случае необходимости нужно производить рефакторинг, чтобы в коде были хорошо заметны точки расширения.
Так, добавим точку расширения, которую можно эффективно использовать при помощи наследования.
public interface Processor { void process(Bar bar); } public class CoreProcessor implements Processor { public void process(Bar bar) { doSomething(bar); handleFoo(bar); doSomethingElse(bar); } protected void handleFoo(Bar bar) { } } public class FooProcessor extends CoreProcessor { protected void handleFoo(Bar bar) { doSomethingFooSpecific(bar); } }
Ту же задачу можно решить и при помощи композиции.
public interface FeatureHandler { void handle(Bar bar); } public class Processor { FeatureHandler handler; public Processor(FeatureHandler handler) { this.handler = handler; } public void process(Bar bar) { doSomething(); handler.handle(bar); doSomethingElse(); } } public class CoreHandler implements Handler { public void handle(Bar bar) { } } public class FooHandler implements Handler { public void handle(Bar bar) { doSomethingCompletelyDifferent(bar); } }
Внедрение зависимостей
Теперь мы можем задействовать наш контейнер для внедрения зависимостей (см. http://martinfowler.com/articles/injection.html), чтобы выполнить с его помощью большую часть требуемой в нашем случае функционально-специфичной конфигурации. Рассмотрим, как это можно сделать в Spring MVC.
Один вариант заключается в том, чтобы выделить в отдельную категорию файлы applicationContext-${feature}.xml с определениями функционально-специфичных управляемых компонентов (бинов). Но в некоторых ситуациях нам придется иметь дело со списками таких компонентов – например, со списками перехватчиков. Если продублировать такой список в функционально-специфичном контекстном файле, то возникнут сложности с поддержкой кода.
Лучше оставить такой список в базовом файле applicationContext.xml. Если понадобится добавить к функции перехватчик, то мы сможем занести данную функцию в основной список, определить компонент в функционально-специфичном контекстном файле, а потом определить нулевую реализацию (см. wiki) в основном контекстном файле.
Аннотации
Мы также можем задействовать аннотации-маркеры (они же — аннотации без элементов) для наших функций. Таким образом, нам не придется повторяться в XML-файлах. Мы будем использовать значение аннотации для указания того, нужен ли нам аннотированный компонент при включенной или при отключенной функции.
FeatureTogglesInPractice/annotation/Foo.java @Retention(RetentionPolicy.RUNTIME) public @interface Foo { boolean value() default true; }
Еще нам понадобится создать специальный (custom) фильтр TypeFilter, который будет использовать информацию о переключении функций и нашу аннотацию. Все это делается для включения в код корректной реализации…
FeatureTogglesInPractice/annotation/FeatureIncludeFilter.java public class FeatureIncludeFilter implements TypeFilter { private final TypeFilter fooFilter = new AnnotationTypeFilter(Foo.class, true); public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException { if (fooFilter.match(metadataReader, metadataReaderFactory)) { boolean value = getAnnotationValue(metadataReader, Foo.class); if (FeatureToggles.isFooEnabled()) { return value; } else { return !value; } } return false; } private boolean getAnnotationValue(MetadataReader metadataReader, Class annotationClass) { return (Boolean) metadataReader. getAnnotationMetadata(). getAnnotationAttributes(annotationClass.getName()). get("value"); } }
… после чего добавим наш фильтр типов в конфигурацию сканера spring-компонентов.
<context:component-scan base-package="com.example.features"> <context:include-filter type="custom" expression="com.example.features.FeatureIncludeFilter" /> </context:component-scan>
Теперь можем продолжить и правильно аннотировать наши реализации, а остальную работу за нас выполнит Spring
public interface Processor { << >> } @Foo(false) public class CoreProcessor implements Processor { << >> } @Foo public class FooProcessor extends CoreProcessor { << >> }
Ситуация становится гораздо интереснее, когда мы добавляем в нашу смесь все больше функций. Возможно, нам понадобится дополнить фильтр типов, чтобы обрабатывать более сложные комбинации функций.
Отделение статических ресурсов
В предыдущем разделе мы рассмотрели несколько способов переключения функциональных веток на серверной стороне. Но что делать со статическими ресурсами, такими, как JavaScript и CSS?
Некоторые статические ресурсы можно превратить в шаблоны, отображаемые на серверной стороне. Так мы можем добавить логику для изменения их специфичным для конкретных функций образом. Но при таком подходе статические ресурсы, которые можно было бы разместить в сети доставки контента (Content Delivery Network, см. wiki), переносятся обратно на сервер приложений, что может быть неприемлемо в некоторых ситуациях.
Кроме того, мы можем превратить наши статические ресурсы в шаблоны, отображаемые во время сборки. Такой подход также может быть проблематичен, поскольку возникает ситуация, в которой мы можем переключать функциональные ветки только методом повторного развертывания.
Гораздо рациональнее было бы оставить статические ресурсы в виде статических файлов и создавать функционально-специфичные версии статического контента. Эти версии мы сможем в условном порядке подключать из наших динамических шаблонов. Так, можно взять shopping_cart.css и создать файл shopping_cart_foo.css, в котором мы поместим специфическое оформление для корзины покупок, применяемое при включенной функции Foo.
При работе JavaScript мы также можем использовать специфичные условные операторы в функциях JavaScript, как мы поступали с серверным кодом. Но у этого подхода есть один серьезный недостаток. Он допускает утечки информации о функциях, которые пока находятся в разработке, но еще не готовы для выдачи конечному пользователю. Порой такое случайное раскрытие погоды не делает, но бывает и так, что преждевременное раскрытие информации о недоработанных функциях оборачивается катастрофой. Давайте поговорим о том, как избежать такого случайного раскрытия.
Предотвращение случайного раскрытия
Если работа требует конфиденциальности, то, конечно, ее удобнее вести в отдельной ветке. С достаточной уверенностью можно утверждать, что код, разрабатываемый в ветке, останется конфиденциальным до тех пор, пока ветка не будет интегрирована в основную линию. Преодоление страха случайной утечки информации о еще не выпущенных функций – один из самых серьезных шагов барьеров, которые необходимо преодолеть для замены функциональных веток на ротацию функций.
Легко предположить, что утечка информации о недоработанных функциях может произойти из-за того, что функция недостаточно хорошо «обернута» в механизм ротации. Если вам повезет, такая утечка вскроется в виде какой-нибудь очевидной ошибки. Но чаще ошибка проявляется гораздо менее явно: в каком-то сообщении окажется неверный текст, отобразится дополнительное поле и т.д.
При тщательном тестировании вручную вы, в итоге, заметите такую непоследовательность. Можно попытаться использовать и автоматические функциональные тесты, но тестировать систему на отсутствие определенной функциональности или элементов пользовательского интерфейса очень непросто.
Самый неявный канал, через который возможна утечка еще не выпущенных функций – это потенциально невидимые артефакты. Ими могут быть неиспользуемые классы CSS, код JavaScript и даже комментарии в HTML. Случайное раскрытие такого рода очень сложно заметить.
Так или иначе, самый надежный способ избежать утечек – соблюдать аккуратность и дисциплину при разработке. Любые наработки по определенной функции должны обертываться в код для ротации функций. Изменения статических ресурсов необходимо выполнять в отдельных функционально-специфичных файлах, эти файлы должны быть обернуты в код для ротации функций. Если всегда отделять функционально-специфичные статические файлы, то мы сможем даже не включать их в разработку – просто для обеспечения безопасности.
Ротация функций во время исполнения
Если удастся обеспечить возможность включения и отключения функций во время исполнения, то мы сможем тестировать различные функции на предмет работы в едином развернутом приложении. Это можно будет делать как вручную, так и с помощью набора автоматических тестов. Кроме того, мы сможем в таком случае быстро отключать определенные функции, если в процессе производства возникнут какие-либо ошибки.
Первый вопрос, на который необходимо ответить при переключении функций во время исполнения, таков: будут ли пользователи, занятые текущей сессией работы с приложением, видеть переключение функций сразу же, как оно произойдет, или же неизмененные настройки будут сохраняться до конца текущей сессии?
Если сохранять настройки на протяжении всей сессии, то пользователь сможет составить полное и непротиворечивое впечатление о сайте. Вариант с немедленным проявлением ротации функций имеет недостаток: пользователь может запутаться, если функционал сайта изменится прямо у него на глазах. В результате возможны и ошибки на уровне приложения – так как не выполнятся определенные ожидания. Правда, возможность немедленного отключения функций позволит нам оперативно среагировать, чтобы глобально деактивировать такую функцию, которая вдруг станет работать неправильно.
Гибкая система ротации функций могла бы позволить переключать функции как немедленно, так и по завершении сессии – в зависимости от того, насколько неотложной является такая потребность.
Кроме того, нужно подумать, как мы распространим ротацию функций на множество серверов приложений. Можно выполнять все переключения функций в централизованной внешней системе – например, в базе данных – или в файлах, которые периодически сканируются заново.
Мы также можем предоставить специальный управляющий интерфейс. Примером такого интерфейса является JMX (см. wiki). В таком случае, все изменения можно будет применять непосредственно к работающему серверу приложений. В таком случае, мы, конечно, сможем осуществлять изменения мгновенно, но потребуется обеспечить дополнительную координацию, обеспечивающую взаимно непротиворечивое добавление функций на всем множестве серверов. Если мы собираемся применять изменения непосредственно к работающему серверу приложений, то необходимо позаботиться и о том, чтобы такое переключение оставалось в силе и после перезапуска приложения.
Ротация во время сборки
Ротация функций также возможна во время сборки. Это не только удобно делать в случае, когда дело уже доходит до паранойи – только бы в релиз попал лишь нужный код. Более того, ротация функций во время сборки применяется и в случае, когда при изменении подключаемой функции изменяется зависимость. Ведь из-за изменения зависимости может возникнуть ситуация, несовместимая с API.
Несовместимые зависимости
Часто возникают сложности с усовершенствованием зависимостей до новых версий, не имеющих полной обратной совместимости. Это зависит от глубины изменений в каждом конкретном случае. При ротации функций эта проблема еще более усложняется, так как мы оказываемся в ситуации, когда нам нужны сразу обе версии зависимости. Итак, что делать, если нам требуется применить две версии класса, в которых могут присутствовать разные конструкторы, методы с различными сигнатурами или вообще разные методы?
Мы можем использовать в качестве резервного варианта «рефлексию». В данном случае, мы сумеем динамически активировать нужную версию – при этом, нам удастся обхитрить механизм статической проверки типов и успешно скомпилировать приложение. Но так мы увеличим сложность системы и снизим производительность.
Вместо этого можно создать обертки для таких классов, которые похожи, но имеют различия. Можно начать с создания общего интерфейса, предоставляющего все необходимые элементы для обеих функций, а потом создать функционально-специфичные варианты реализации, выполняющие делегирование к подходящей версии нашей зависимости. После этого мы сможем выделить функционально-специфичные реализации в самостоятельные модули кода и компилировать во время сборки только тот модуль, который нам нужен. Именно поэтому ротация функций во время сборки не только полезна, но и необходима.
Тестирование ротации функций
Одна из потенциальных проблем, связанных с ротацией функций – возможность возникновения своеобразной «гремучей смеси». Нужно специально тестировать систему, чтобы избегать таких комбинаций. Действительно, теоретически, число таких комбинаций должно расти экспоненциально по мере увеличения количества функций. Но на практике подобные гремучие смеси возникают редко. Поэтому, целесообразно тестировать только такие комбинации, которые, на наш взгляд, действительно придется вводить в эксплуатацию.
Оказывается, что фактическое количество комбинаций, которые нам потребуется протестировать, будет таким же, как если бы у нас были функциональные ветки. Но тестирование значительно упрощается, так как это можно будет делать в единой базе кода. Если мы переключаем функции во время исполнения, то вполне можем обойтись единственной сборкой. В ней мы будем включать и отключать функции в зависимости от постановки задачи в конкретном тесте.
Если мы применяем ротацию функций во время сборки, то можем создавать отдельные конвейеры сборки для каждой комбинации, с разными вариантами «дыма» и регрессионными наборами для каждого конвейера.
Каждая отправка кода будет инициировать сборку на всех конвейерах. Конечный результат, в контексте непрерывной интеграции, получается таким же, как если бы мы собирали отдельные ветки.
Но, в отличие от ситуации с отдельными ветками, при вышеописанной постановке задачи возможна ситуация, в которой сборка для функции A не позволяет пройти тест или выполнить сборку для функции B. Честно говоря, эта проблема сохраняется и в случае с отдельными ветками. Однако, при работе в отдельных ветках мы откладываем данную проблему вплоть до окончательной интеграции. Ротация функций позволяет обнаруживать подобные проблемы при каждой отправке кода, что, на мой взгляд, более предпочтительно.
Удаление переключений в готовых функциях
Еще одно значительное препятствие, мешающее внедрять ротацию функций, заключается в следующем опасении: возможно, со временем код будет излишне засорен уже не нужными операторами переключения. Поэтому, необходимо удалять те переключатели функций, которые уже не нужны. После того, как функция готова и развернута для использования, нам может потребоваться сохранить переключатель еще на пару дней или недель, пока мы не будем уверены, что функция действительно работает правильно. Но после этого мы можем смело удалить переключатели для данной функции, сократив код до основной версии приложения.
Чтобы было проще избавляться от уже не нужных переключателей, их можно сделать статически типизированными. Это означает, что у нас должен быть метод FeatureToggles.isFooEnabled(), а не FeatureToggles.isFooEnabled(“foo”). Таким образом, мы сможем задействовать компилятор для простого удаления ненужных переключателей из кода. Ведь как только мы удалим метод isFooEnabled(), любой код, по-прежнему использующий его, не скомпилируется.
Кроме того, мы сможем задействовать нашу интегрированную среду разработки (IDE) для поиска примеров использования заданного метода и, соответственно, нахождения мест, где этот метод также должен быть удален из шаблонов – если наша IDE поддерживает такие операции.
Если разрабатываемая функция замораживается либо на неопределенное время, либо на достаточно долгий срок, то может быть соблазнительно оставить функцию в коде в имеющемся виде, снабдив ее для страховки переключателями. Но я считаю, что это изначально плохая идея. Поскольку код скрывается за переключателями, которые в течение некоторого времени использоваться не будут, такая ситуация легко может привести к проблемам с поддержкой кода. На самом деле, следует решительно избавляться и от ненужного кода, и от соответствующих переключателей. Старая версия всегда будет доступна для справки в системе контроля версий.
Заключение
Иногда инструментальные функции (такие, как ветвление при контроле версий) можно использовать такими способами, которые вредны для других видов инженерной практики. Позволяя разработчикам писать код в изолированных ветках, мы сталкиваемся с проблемами при интеграции и слиянии. Ротация функций допускает откладывание решений, касающихся конкретных аспектов, не нарушая при этом процесса разработки, связанного с непрерывной интеграцией кода.
Ротация кода, конечно, не должна приводить к перенасыщению программы разнообразными функциями. Если вы окончательно решили не отказываться от конкретной функции – уберите переключатели. Держите ваш код чистым.