Введение, или что было раньше

Представьте, что вы работаете над кодом магазина, который живёт уже много лет. Бизнес доволен, продажи растут, но есть одна проблема — модуль обращения к весам превратился в чёрный ящик. Когда-то давно он работал прекрасно, но со временем разросся. Многопоточность, низкоуровневый код и бизнес-логика сплелись в один клубок, каждое изменение могло что-то сломать. И так дошло до того, что появились несколько фич, которые не реализовывались уже полгода… Стало ясно, что требуется не столько рефакторинг, сколько новая, удобная для работы абстракция.

Приветствую! Меня зовут Иван Матвеев, я разработчик в компании X5 Tech, и сегодня я расскажу, как мы начали рефакторить наши весы.

Исследование и анализ

Посмотрим более внимательно на текущую реализацию:

много кода...
class ScaleUploadJob {

// константы
// 44 зависимости в приватных полях
// 9 мутабельных состояний

//конструкторы, конструкторы для тестов

  public void run() {
    TransportClient client = null;
    DataHandler handler;

    try {
      while (shouldContinue()) {
        try (DbSession session = getSession()) {
          while (hasWork()) {
            DeviceInfo info = loadDeviceInfo(session);

            if (shouldAbort(info)) {
              break;
            }

            pump(info);

            if (shouldBulkLoad(info)) {
              processData(session, handler, info);
            } else if (shouldSyncMetadata(info)) {
              handleSyncMetadata(info, handler, session);
            } else if (shouldSyncImages(info)) {
              handleSyncImages(info, handler, session);
            } else {
              handleApplyUpdates(session, handler, info);
            }
          }
        } catch (Exception ex) {
          handleException(client, ex);
        } finally {
          closeClient(client);
        }
      }
    } finally {
      cleanupResources();
    }
  }

  /**
  * основная логика работы на 200+ строк
  */
  protected void processData(Session session, DataHandler handler, DeviceInfo device)
      throws DataException, NetworkException, ProtocolException, InterruptedException {

    ItemsCursor cursor = null;
    ItemsMetadata meta = null;
    ErrorReason reason = null;

    initRemovalSet();
    uploadEmblem(handler);
    meta = loadItemsMetadata(session, device.getId());

    try {
      setupRemovalRanges(session, device);
      long syncId = getMaxSyncId(session);
      GroupRegistry groups = loadGroupRegistry(session, device.getId());

      cursor = itemRepository.openCursor(session, buildSchema(), getBatchSize());
      syncGroups(device, session, handler, groups);

      List<ProductData> buffer = new LinkedList<>();

      while (hasMoreItems(session, cursor)) {
        processNextBatch(session, cursor, groups, device, buffer);
        if (buffer.size() >= getBatchSize()) {
          flushBuffer(handler, buffer, null);
          buffer.clear();
        }
      }

      if (!buffer.isEmpty()) {
        finalizeBatch(buffer, device, session);
      }

      purgeItems(handler, device, removalSet);
      updateSyncStatus(session, device, syncId);

    } catch (Exception ex) {
      reason = this.getReason(ex);
      handleFailure(session, device, reason, ex);
    } finally {
      cleanup(session, device, cursor, meta, reason);
    }
  }

  private void purgeItems() {

  }

  //прочие приватные методы
// в общей сложности - 1700 строк кода
}

// интерфейс отправки данных в весы
// вызывается в одном из приватных методов
public interface DataUploader {
  void submitProducts(List<ProductRecord> items) throws Exception;
  void submitProducts(List<ProductRecord> items, CallbackHandler<UploadContext, UploadResult> handler) throws Exception;
  // 18 прочих методов, из которых 8 - также дублируются
}

// реализация для одного устройства
public class FirstDeviceModelDataUploader implements DataUploader {
  @Override
  public void submitProducts(List<ProductRecord> items) {
    throw new UnsupportedOperationException();
  }
  @Override
  public void submitProducts(List<ProductRecord> items, CallbackHandler<UploadContext, UploadResult> handler) {
    throw new UnsupportedOperationException();
  }
  //...
}

// реализация для другого устройства
public class SecondDeviceModelDataLoader implements DataUploader {
  @Override
  public void submitProducts(List<ProductRecord> items) {
    submitProducts(items, null);
  }

  @Override
  public void submitProducts(List<ProductRecord> items, CallbackHandler<UploadContext, UploadResult> handler) {
    List<Integer> invalid = new ArrayList<>();
    int position = 0;
    int batchSize = loaderConfig.getImageBatchSize(config);

    for (ProductRecord product : items) {
      if (isValid(product)) {
        position++;
        CallbackRecorder recorder = createCallback(handler, product);

        //следующие вызовы - отправка данных на устройства
        syncBasicData(product, recorder);
        syncAttributes(product, recorder);
        syncLabels(product, recorder);
        syncMessages(product, recorder);
        syncImage(product, batchSize, recorder);

        position = applyThrottling(position, config);
        finalizeCallback(recorder);
      } else {
        invalid.add(product.getId());
      }
    }

    if (!invalid.isEmpty()) {
      purgeInvalidRecords(invalid);
    }
  }
}

Если посмотреть на код с помощью git blame, то можно увидеть, что со временем из аккуратного решения он эволюционировал до объёмного кода и размыл своё ядро: код обращения к весам, подключения, конкурентность и бизнес-логика стали «слишком близки», а абстракции, определённые для некоторых моделей устройств, начали «протекать». На это явно указывали дефолтные методы в интерфейсах с пробросом ошибок. Даже не включая Sonar было видно, что у данного кода очень высокая когнитивная сложность. Слишком много  if, try и других подобных конструкций, слишком глубокие переходы в приватные методы и вложенные сервисы (до 9 переходов), что является немалой нагрузкой для чтения. Видно, что уже предпринимались неоднократные попытки привести этот код к единой модели, но до идеала текущей реализации ещё, к сожалению, далеко.

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

Было рассмотрено два разных варианта.

«Классическое решение» — использовать «толстый» интерфейс для модели устройства и поместить в него все методы отправки разных сообщений.

В качестве плюса — понятная для пользователя, простая группировка.

Минус — жёсткая структура «толстых» интерфейсов: для любых новых типов сообщений будут добавляться новые методы, что ещё сильнее «раздует» интерфейс (нарушение ISP).

«Модульное решение» — использовать разные типы сообщений (паттерн Команда) + разные типы протоколов (паттерн Стратегия) + «сборку» весов с помощью DI-фреймворка.

Его плюсы:

  • миниатюрные реализации, узкие интерфейсы, большой потенциал к расширению (OCP);

  • гибкость конфигурации — новые устройства можно собирать даже динамически;

  • максимальная переиспользуемость компонентов;

  • лёгкость тестирования.

А минус — сложность архитектуры и конфигурации.

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

Моделирование и разработка решения

Определив план, мы перешли к выделению основной логики и абстракций и обозначили следующие области: работа устройств, подключения к устройствам и маппинг из бизнес-логики в DTO весов.

Основные интерфейсы

API устройства

Для начала определимся с API самих устройств. Во-первых, есть информация об устройстве (далее DeviceConfig): host, port, модель, версия модели, а также ID, который обеспечивает уникальность. Запросы к устройствам из бизнес-логики (Message) и редкие ответы от устройств (MessageResponse) оформлены в виде маркерных интерфейсов из-за их большой вариативности. Если вы уже увидели паттерн Команда(Command), то да, это он.

API устройства
API устройства

Для удобства пользователя решили выдавать устройства по их информации. Для этого лучше всего подошёл паттерн Фабрика(Factory). Она проверяет, может ли использовать DeviceConfig, и если может, выдаёт само устройство (далее Device). А отдельный менеджер (далее DeviceFactoryManager) инкапсулирует в себе все фабрики и служит общим доступом к весам для пользователя.

API фабрики устройств
API фабрики устройств

Подключения к устройствам

С другой стороны модуля находится более низкоуровневый API для обращения к устройствам. Информация о подключении (далее NetworkConfig) содержит host, port и timeout, который используется и для подключения, и для ожидания ответа. Аналогично API устройства, низкоуровневые запросы к весам очень отличаются, поэтому для них также используются маркерные интерфейсы.

API подключений
API подключений

По аналогии c API устройства выбран паттерн Фабрика(Factory) для получения подключений.

API фабрики подключений
API фабрики подключений

Трансляция между устройством и его подключением

Каждое сообщение из бизнес-логики может по-разному конвертироваться в запрос в зависимости от модели или версии устройства, а в некоторых случаях один и тот же способ отправки может использоваться для нескольких устройств. Чтобы соблюсти эту гибкость и по аналогии с предыдущими решениями, мы взяли конвертер (далее MessageMapper). Он сам определяет, для какого DeviceConfig он доступен, и конвертирует запрос из бизнес-логики в список запросов к API устройств вместе с соответствующими ответами. А менеджер подобных конвертеров позволяет скрыть реализацию от пользователя.

API конвертеров
API конвертеров

Базовая реализация

С абстракциями определились, теперь перейдём к базовым реализациям.

Network

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

Device

Device
// Наиболее общий Device содержит DeviceConfig, одно подключение и отображение типов сообщений к конвертерам.
public class BaseDevice<I> implements Device<I> {

    protected final DeviceConfig<I> info;
    private final Network network;
    private final Map<Class<? extends Message>, MessageMapper<I, ? extends Message>> mapperMap;
    // constructors, getters, setters

    @Override
    public void initialize() throws NetworkException {
        network.connect();
    }

    @Override
    public void shutdown() {
        network.close();
    }

    @Override
    public MessageResponse send(Message message) throws MessageFormatException, MessageSendingException {
        MessageMapper<I, ? extends Message> mapper = mapperMap.get(message.getClass());
        if (mapper == null) {
            throw new MessageFormatException(
                    "Весы (" + this.info + ") не поддерживают отправку данного сообщения: " + message,
                    message
            );
        }

        Collection<Input> inputs = mapper.convert(message);

        Collection<Output> outputs = new ArrayList<>();
        for (Input input : inputs) {
            Output output;
            try {
                output = network.send(input);
            } catch (DataSendingException e) {
                throw new MessageSendingException(message, e);
            }
            outputs.add(output);
        }

        return mapper.convert(outputs);
    }
}
DeviceFactory
public abstract class BaseDeviceFactory<I> implements DeviceFactory<I> {

    protected final NetworkFactoryManager networkFactory;
    private final MessageMapperManager<I> mapperFactory;

    // constructors

    /**
    * потомок обязуется получать информацию о подключению из информации об устройстве
    */
    protected abstract NetworkConfig extractNetworkConfig(DeviceConfig<I> info);

    @Override
    public Device<I> createDevice(DeviceConfig<I> info) {
        return new BaseDevice<>(
                info,
                this.networkFactory.getNetwork(this.extractNetworkConfig(info)),
                this.mapperFactory.getMappers(info)
        );
    }
}
DeviceFactoryManager
public class DeviceFactoryManager<I> {
    private final Collection<DeviceFactory<I>> factories;
    // constructor

    public Device<I> createDevice(DeviceConfig<I> info) throws UnknowDeviceConfigException {
        for (DeviceFactory<I> factory : factories) {
            if (factory.canHandle(info)) {
                return factory.createDevice(info);
            }
        }
        throw new UnknownDeviceConfigException(info);
    }
}
MessageMapperManager
public class MessageMapperManager<I> {
    private final Collection<MessageMapper<I, ? extends Message>> mappers;
    // constructor

    public Collection<MessageMapper<I, ? extends Message>> getMappers(DeviceConfig<I> info) {
        return mappers.stream()
                .filter(mapper -> mapper.canHandle(info))
                .toList();
    }
}

С высоты DI-фреймворка

Далее пользователю нужно лишь реализовать такие типы, как DeviceFactory, NetworkFactory и MessageMapper (с соответствующими методами и конкретными типами сообщений), и добавить их в контекст DI-фреймворка, а затем подключить библиотеку. Так как на проекте используется Spring, то была реализована вот такая автоконфигурация:

DeviceAutoConfiguration
@AutoConfiguration
@EnableConfigurationProperties(DeviceProperties.class)
@ConditionalOnBooleanProperty(prefix = "spring", name = "device.enabled")
public class DeviceAutoConfiguration {

    @Bean
    @ConditionalOnBooleanProperty(prefix = "spring.device.network.default", value = "ftp.enabled", matchIfMissing = true)
    @ConditionalOnMissingBean(FtpNetworkFactory.class)
    public NetworkFactory ftpNetworkFactory(DeviceProperties properties) {
        return new DefaultFtpNetworkFactory();
    }

    @Bean
    @ConditionalOnBooleanProperty(prefix = "spring.device.network.default", value = "socket.enabled", matchIfMissing = true)
    @ConditionalOnMissingBean(SocketNetworkFactory.class)
    public NetworkFactory socketNetworkFactory(DeviceProperties properties) {
        return new DefaultSocketNetworkFactory();
    }

    @Bean
    @ConditionalOnBooleanProperty(prefix = "spring.device.network.default", value = "http.enabled", matchIfMissing = true)
    @ConditionalOnMissingBean(HttpNetworkFactory.class)
    public NetworkFactory httpNetworkFactory(DeviceProperties properties) {
        return new DefaultHttpNetworkFactory();
    }

    /**
     * @param mappers все, определённые пользователем конвертеры для всех моделей весов
     */
    @Bean
    private <I> MessageMapperManager<I> messageMapperFactory(Collection<MessageMapper<I, ? extends Message>> mappers) {
        return new MessageMapperManager<>(mappers);
    }

    /**
     * @param factories фабрики соединений (default и пользовательские)
     */
    @Bean
    public NetworkFactoryManager networkFactoryManager(Collection<NetworkFactory> factories) {
              return new NetworkFactoryManager(factories);
    }

    /**
     * @param factories фабрики весов (default и пользовательские)
     */
    @Bean
    public <I> DeviceFactoryManager<I> DeviceFactoryManager(Collection<DeviceFactory<I>> factories) {
        return new DeviceFactoryManager<>(factories);
    }

Отправка сообщений на устройства

После всех этих манипуляций использование устройств в коде выглядит так (try-catch-finally блоки опущены для лучшей читаемости):

DeviceConfig<Long> deviceConfig = this.getDeviceConfigFromDb(someDbId);
Device<Long> device = DeviceFactoryManager.createDevice(deviceConfig);
device.initialize();
device.send(new ShopStaticInfoMessage(shopName, shopAddress));
device.shutdown();

Теперь пользователю больше не нужно знать, что это за весы, какая у них модель и как передаются данные.

На этом часть кода с «библиотекой-драйвером устройств» завершена. Но я планирую написать продолжение о «приоритетной очереди синхронно-асинхронных задач» для вызова устройств и управления порядком отправки сообщений.

Выводы

И вот что вышло в итоге из этой многослойной архитектуры для модуля. У разработчиков появился удобный фреймворк для работы с весами:

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

  • изменять реализацию устройств очень просто через Декоратор, можно даже в runtime;

  • реализации отправки разных сообщений разделены друг от друга, чтоб не было соблазна смешать их вновь;

  • интерфейсы небольшие, абстракции не протекают.

Бизнес получил возможность сократить время разработки фич для своих задач.

И наконец, после проделанной работы и успешной презентации нового фреймворка коллегам осталось только наполнить его кодом обращения к устройствам, ради которого он и изобретался. Да, это ещё не всё: код пишется и тестируется, но это честная работа большой скачок для данной части бизнес-логики.

Это немного, но это честная работа
Это немного, но это честная работа

Комментарии (0)