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

Imagine how simple it is to manage your features

Очень удобно иметь возможность в любой момент изменить поведение сервиса без перезагрузки серверов, отключить неработающий функционал или провести AB-тестирование. Для этого разработчики обычно используют переключатели функций. Если в вашем стартапе не гигант, а небольшая группа энтузиастов — вам придется решать эту проблему доступными инструментами. В этой статье я покажу вам простой способ реализации переключения функций с помощью общей базы данных SQL.

Описание типичной системы

Сколько бы усилий ни вкладывалось в разработку отказоустойчивых самодостаточных систем, ручное вмешательство неизбежно:

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

Если проблему можно легко исправить, разработчик может настроить данные непосредственно в базе данных или написать bash/python/любой скрипт. Обычно мы избегаем прямого доступа к данным, но обстоятельства могут заставить вас это сделать. Но есть более надежный способ - приложение внутреннего пользования - Панель администрирования.

It could be your admin panel UI

Преимущества панели администратора:

* авторизованный доступ; * протоколирование и аудит всех действий пользователя; * его код обычно пересматривается и тестируется; * доступен не только пользователям, но и техподдержке.

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

Запачкайте руки кодом

Простой переключатель

Самый простой вариант использования — это двухпозиционный переключатель: либо включить для всех, либо отключить для всех.

private final FeatureToggleService featureToggleService;

public void businessLogic() {
   if (featureToggleService.isFeatureEnabled(IMPORTANT_FEATURE)) {
       doImportantFeatureStuff();
   } else {
       log.info("Feature disabled {}", IMPORTANT_FEATURE);
   }
}

Если бы я реализовал это в памяти, я бы проверил, есть ли ключ в наборе хэшей. Если есть - функция включена, иначе - отключена. В базе данных SQL может быть таблица с именами признаков — это все, что нам здесь нужно. Мы можем запросить ключи, и вот как это может выглядеть:

public class FeatureToggleService {

   private static final String STATE_QUERY = "SELECT id FROM features WHERE name = (?)";

   private final JdbcOperations jdbcOperations;

   private final LoadingCache<String, Boolean> toggleStateCache = CacheBuilder.newBuilder()
           .expireAfterWrite(60, TimeUnit.SECONDS)
           .build(new CacheLoader<>() {
               public Boolean load(String featureName) {
                   return jdbcOperations.queryForList(STATE_QUERY, String.class, featureName)
                              .size() > 0;
               }
           });

   public boolean isFeatureEnabled(String featureName) {
       boolean state = toggleStateCache.getUnchecked(featureName);
       return Boolean.TRUE.equals(state);
   }
}

В примере выше я использовал кеш от Google Guava. Это простой локальный кеш, но в 99% случаев он наверняка удовлетворит ваши требования. Функции переключаются нечасто, нет смысла делать запросы часто. В качестве примера я выбрал 60 секунд, но это хороший компромисс, не слишком много запросов и не слишком долгое переключение.

Переключатель пользователя

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

которые считаются тестировщиками.

private final FeatureToggleService featureToggleService;

public void businessLogic(LoggedUser loggedUser) {
   if (featureToggleService.isFeatureEnabled(IMPORTANT_FEATURE, loggedUser.id())) {
       doImportantFeatureStuff();
   } else {
       log.info("Feature disabled {}", IMPORTANT_FEATURE);
   }
}

Появилось третье состояние фичи — тестирование. Итак, состояния:

  1. Включено для всех
  2. Отключено для всех
  3. Включено для тестировщиков

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

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

И реализация может быть:

public class FeatureToggleService {

   private static final String STATE_QUERY =
            "SELECT enabled_for FROM features WHERE name = (?)";
   private static final String TESTERS_QUERY = "SELECT id FROM test_users";
   private final JdbcOperations jdbcOperations;

   private final Supplier<Set<Long>> testUsersSupplier =
           memoizeWithExpiration(this::extractTesters, 60, TimeUnit.SECONDS);

   private final LoadingCache<String, String> toggleStateCache = CacheBuilder.newBuilder()
           .expireAfterWrite(60, TimeUnit.SECONDS)
           .build(new CacheLoader<>() {
               public String load(String featureName) {
                   return jdbcOperations.queryForList(STATE_QUERY, String.class, featureName)
                           .stream().findFirst().orElse("NONE");
               }
           });

   public boolean isFeatureEnabled(String featureName, long userId) {
       String state = toggleStateCache.getUnchecked(featureName);
       return "ALL".equals(state) ||
               ("TEST".equals(state) && testUsersSupplier.get().contains(userId));
   }

   private Set<Long> extractTesters() {
       return Set.copyOf(jdbcOperations.queryForList(TESTERS_QUERY, Long.class));
   }
}

Сначала мы получаем состояние фичи по ее имени (либо из базы данных, либо из кеша), затем, если она ALL, считаем ее включенной для всех, или если она TEST — рассматриваем только ее включен для тестировщиков. Если это так, мы получаем список тестировщиков (либо из базы данных, либо из кеша) и проверяем, есть ли текущий пользователь в списке. Если ничего из вышеперечисленного — считайте, что функция отключена для всех.

Что может пойти не так в приведенных выше реализациях

Есть одна неочевидная проблема с локальным кешем, о которой вам следует знать:

  1. 09:45:27 функция включена для всех
  2. 09:45:47 запрос 1 поступил на сервер 1, и функция работала как включенная
  3. 09:45:51 запрос 2 поступил на сервер 2, и функция не работала, так как отключена

Почему это так? Локальные кэши ничего не знают о других серверах, в большинстве случаев это вообще не может быть проблемой. В противном случае вам следует выполнить одно из следующих действий:

  • продолжить использовать локальный кеш, но удалить его с помощью уведомления (используя подсистему публикации или широковещательные сообщения);
  • использовать распределенный кеш (eCache или аналогичный) и удалять его при каждом изменении;
  • использовать базы данных в оперативной памяти (Redis, Zookeeper или аналогичные) для хранения функций и предоставления недорогого доступа к ним;
  • воспользоваться платным сервисом, предоставляющим этот функционал (их несколько, здесь нет дополнений и настроек);
  • придумайте свою гениальную идею, их может быть много :)

Заключение

Многие разработчики считают, что использовать причудливую структуру — это здорово, но вам нужно оценить, действительно ли это оправдано. Часто бывает проще написать простой модуль, удовлетворяющий специфические потребности вашего приложения, и разработать его по-своему. Но тут нужно чувствовать грань — писать свой HashMap с нуля нет никакого смысла.