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

Давайте рассмотрим пример кода, который (в похожем стиле) часто попадается в реальных проектах. Программа решает следующую задачу: есть несколько устройств, которые умеют отправлять пакеты с телеметрией по 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. В книге описаны принципы построения гибкой и поддерживаемой архитектуры.