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

В объектно-ориентированном языке использование объектов и их свойств как инкапсуляция, наследование и полиморфизм являются самым органичным способом борьбы со сложностью. Также и в функциональных языках есть свои подходы и конструкции. Но если выбран объектно-ориентированный язык, то написать поддерживаемую, расширяемую и тестируемую программу легче всего применяя подходы и архитектуру, основанную на использовании объектов и их свойств (странно, да? ?).

Как обычно бывает

Давайте рассмотрим пример кода, который (в похожем стиле) часто попадается в реальных проектах. Программа решает следующую задачу: есть несколько устройств, которые умеют отправлять пакеты с телеметрией по HTTP протоколу, формат пакетов разный и он указан в кастомном HTTP заголовке. Есть приемник телеметрии, он умеет принимать пакеты в своем едином формате. В дальнейшем планируется расширение номенклатуры устройств. Для реализации был выбран фреймворк Spring Cloud Gateway (скажем, разработчики имели соответствующий опыт).

Код, который просто сели и написали
public class ValidationService {

    private final SerializationService serializationService;

    public ValidationService(SerializationService serializationService) {
        this.serializationService = serializationService;
    }

    public Flux<DataBuffer> validate(ServerWebExchange exchange, Map<String, String> measures) {
        if (exchange.getRequest().getHeaders().get("X-Device-Class").get(0).equals("CLASS_A")) {
            // Код валидации устройства типа А.

            // Код приведения данных устройства типа А к общему виду.
        }

        if (exchange.getRequest().getHeaders().get("X-Device-Class").get(0).equals("CLASS_B")) {
            // Код валидации устройства типа B.

            // Код приведения данных устройства типа B к общему виду.
        }

        return serializationService.serialize(exchange, measures);
    }

}

public class SerializationService {

    public Flux<DataBuffer> serialize(ServerWebExchange exchange, Map<String, String> measures) {
        byte[] result;

        // Код сериализации.

        final var factory = exchange.getResponse().bufferFactory();
        return Flux.just(factory.wrap(result));
    }

}

@Component
public class TelemetryPackageFilter implements GlobalFilter {

    private final ValidationService validationService;

    public TelemetryPackageFilter(ValidationService validationService) {
        this.validationService = validationService;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return DataBufferUtils.join(exchange.getRequest().getBody())
                .map(this::readBody)
                .map(data -> handleBody(exchange, data))
                .flatMap(request -> chain.filter(exchange.mutate().request(request).build()));
    }

    private byte[] readBody(DataBuffer buffer) {
        final var data = new byte[buffer.readableByteCount()];

        buffer.read(data);
        DataBufferUtils.release(buffer);

        return data;
    }

    private ServerHttpRequestDecorator handleBody(ServerWebExchange exchange, byte[] data) {
        Map<String, String> measures = new HashMap<>();

        if (exchange.getRequest().getHeaders().get("X-Device-Class").get(0).equals("CLASS_A")) {
            // Парсинг бинарного пакета устройства типа А.
        }

        if (exchange.getRequest().getHeaders().get("X-Device-Class").get(0).equals("CLASS_B")) {
            // Парсинг бинарного пакета устройства типа B.
        }

        final var result = validationService.validate(exchange, measures);

        return new ServerHttpRequestDecorator(exchange.getRequest()) {

            @Override
            public @NonNull Flux<DataBuffer> getBody() {
                return result;
            }

        };
    }

}

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

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

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

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

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

Как можно исправить

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

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

Код, который сначала спроектировали, потом написали
// "Бизнес-логика"

public interface Device {

    TelemetryPackage parse(byte[] data);

    void validate(TelemetryPackage telemetryPackage);

    TelemetryPackage standardize(TelemetryPackage telemetryPackage);

}

public class DeviceClassA implements Device {

    @Override
    public TelemetryPackage parse(byte[] data) {
        // Получение данных устройства типа А из бинарного формата для последующей обработки.

        return new TelemetryPackage(...);
    }

    @Override
    public void validate(TelemetryPackage telemetryPackage) {
        // Код валидации данных устройства типа А.
    }

    @Override
    public TelemetryPackage standardize(TelemetryPackage telemetryPackage) {
        // Код приведения данных устройства типа А к общему виду.

        return telemetryPackage;
    }

}

public class DeviceClassB implements Device {

    @Override
    public TelemetryPackage parse(byte[] data) {
        // Получение данных устройства типа B из бинарного формата для последующей обработки.

        return new TelemetryPackage(...);
    }

    @Override
    public void validate(TelemetryPackage telemetryPackage) {
        // Код валидации данных устройства типа B.
    }

    @Override
    public TelemetryPackage standardize(TelemetryPackage telemetryPackage) {
        // Код приведения данных устройства типа B к общему виду.

        return telemetryPackage;
    }

}

public class TelemetryPackage {

    private final Map<String, String> measures;

    public TelemetryPackage(Map<String, String> measures) {
        this.measures = measures;
    }

    public Map<String, String> getMeasures() {
        return measures;
    }

}

@Component
public class TelemetryPackageHandler {

    public TelemetryPackage handle(Device device, TelemetryPackage telemetryPackage) {
        // Проверка данных, в зависимости от типа устройства.
        device.validate(telemetryPackage); 
        // Приведение данных конкретного устройства к общему виду.
        return device.standardize(telemetryPackage);
    }

}

@Component
public class TelemetryPackageSerializer {

    public byte[] serialize(TelemetryPackage telemetryPackage) {
        // Преобразование данных устройства в бинарный формат приемника.

        return ...;
    }

}

// Медиатор для связи "бизнес-логики" с инфраструктурным кодом

public enum DeviceSelector {


    CLASS_A {
        @Override
        public Device get() {
            return deviceClassA;
        }
    },

    CLASS_B {
        @Override
        public Device get() {
            return deviceClassB;
        }
    };

    private final static Device deviceClassA = new DeviceClassA();

    private final static Device deviceClassB = new DeviceClassB();

    public abstract Device get();

    public static Device select(ServerHttpRequest request) {
        // Определение типа устройства по специальному заголовку.
        return DeviceSelector.valueOf(
                Objects.requireNonNull(request.getHeaders().get("X-Device-Class")).get(0)
        ).get();
    }

}

// Вызов сценария "бизнес-логики"

@Component
public class TelemetryPackageFilter implements GlobalFilter {

    private final TelemetryPackageSerializer telemetryPackageSerializer;

    private final TelemetryPackageHandler telemetryPackageHandler;

    public TelemetryPackageFilter(
            TelemetryPackageSerializer telemetryPackageSerializer,
            TelemetryPackageHandler telemetryPackageHandler
    ) {
        this.telemetryPackageSerializer = telemetryPackageSerializer;
        this.telemetryPackageHandler = telemetryPackageHandler;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return DataBufferUtils.join(exchange.getRequest().getBody())
                .map(this::readBody)
                .map(data -> decorate(exchange, handleBody(exchange, data)))
                .flatMap(request -> chain.filter(exchange.mutate().request(request).build()));
    }

    private byte[] readBody(DataBuffer buffer) {
        final var data = new byte[buffer.readableByteCount()];

        buffer.read(data);
        DataBufferUtils.release(buffer);

        return data;
    }

    private byte[] handleBody(ServerWebExchange exchange, byte[] data) {
        final var device = DeviceSelector.select(exchange.getRequest());
        final var originalTelemetryPackage = device.parse(data);
        final var handledTelemetryPackage = telemetryPackageHandler.handle(device, originalTelemetryPackage);

        return telemetryPackageSerializer.serialize(handledTelemetryPackage);
    }

    private ServerHttpRequest decorate(ServerWebExchange exchange, byte[] data) {
        return new ServerHttpRequestDecorator(exchange.getRequest()) {

            @Override
            public @NonNull Flux<DataBuffer> getBody() {
                final var factory = exchange.getResponse().bufferFactory();
                return Flux.just(factory.wrap(data));
            }

        };
    }

}

Что получилось улучшить:

  • Инфраструктурный код отделен от бизнес-логики: тесты будут написаны именно на бизнес-логику, без борьбы с особенностями фреймворка.

  • Создана точка расширения для добавления новых устройств со своим форматом: достаточно зарегистрировать новое устройство в классе DeviceSelector.

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

Начинать проектирование кода нужно с классификации задачи: будет ли это приложение на основе контроллеров и CRUD операций, потоковая обработка, преобразование данных, сложные вычисления, что-то другое. В зависимости от этого предлагается выбрать архитектурный стиль, например Clean Architecture, Command Query Responsibility Segregation, Event-Driven Architecture, Pipeline Architecture (это не исчерпывающий список). Следование выбранному архитектурному стилю задаст участникам понятный (либо объясняемый) вектор, по которому будет идти решение задачи.

Далее нужно определить точки расширения и дальнейшего развития кода. Вот тут очень важно найти баланс и не придумывать какие-то варианты развития которые лежат в отдаленном будущем, а ориентироваться на решаемую в данный момент задачу и ближайшую перспективу. Как раз этом этапе часто рождаются монструозные конструкции с фабриками создающие фабрики и прочий ООП ужас. Необходимо отсекать все решения, которые придуманы ради решения проблем в туманном будущем (в конце концов есть рефакторинг). Точки расширения часто реализуются через конструкции "абстрактный класс - конкретный класс", либо с помощью интерфейсов.

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

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

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

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

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

Полезные практики

В заключение опишу некоторые эффективные практики для создания хорошей архитектуры кода:

  • Если программа простая, то не нужно усложнять ее дополнительными сущностями. Но если предполагается развитие в ближайшее время, то лучше спроектировать возможность этого развития.

  • Наиболее полезный принцип для принятия решения, какие функции будет иметь тот или иной объект, это Singe Responcibility Principle. Если класс реализует не связанные друг с другом задачи, то лучше разделить этот класс на два или более; то же касается и самих методов класса.

  • Осмысленное и оправданное применение паттернов сильно улучшает читабельность и повышает качество кода. Например, повторяющиеся проверки условий, одинаковых в разных частях программы, можно заменить на паттерн Стратегия. Но не нужно использовать паттерны ради паттернов.

  • Создание развесистых иерархий обычно не несет пользы. Наиболее полезно выделять общий код в абстрактный класс и уникальный код помещать в наследник и на этом останавливаться. Либо вообще применять композицию. Интерфейсы лучше использовать как обозначение, что объект обладает той или иной функциональностью для выполнения общих алгоритмов.

  • Код можно самоописывать, называя классы и методы по тому, зачем они существуют и что делают.

  • Когда хочется создать очередной Great1Service, лучше остановиться и подумать, может быть стоит преобразовать его логику в классы, подобные Scenario (который выполняет какой то бизнес сценарий), Handler (реакция на какое либо событие), Entity (если логика уникальна для какой либо сущности).

Материалы

  • "A Philosophy of Software Design", John Ousterhout. Книга о глубоком понимании сложности кода и способах её уменьшения, почему код становится сложным и как этого избежать, используя правильные проектные решения.

  • "Code That Fits in Your Head: Heuristics for Software Engineering", Mark Seemann. Эта книга посвящена понятности и читаемости кода. Автор объясняет, как писать код так, чтобы он легко воспринимался другими разработчиками. Код — это не просто инструкции для компьютера, а способ общения с другими людьми.

  • "Refactoring: Improving the Design of Existing Code", Martin Fowler. Практика рефакторинга, работа с кодом и его эволюцией.

  • "Clean Architecture", Robert C. Martin. В книге описаны принципы построения гибкой и поддерживаемой архитектуры.

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