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

Предпосылки
Первая статья про анализатор PVS-Studio для Java вышла ещё в 2018 году. Говоря иначе, прошло уже 7 лет. Это были времена, когда ныне не отпускающая легаси Java 8 только-только вышла.
С тех пор полностью сменилась команда разработки Java анализатора. Был промежуток времени, когда активной работы над ним не велось. Были исправления ошибок, зависания, правки в плагины.
Результатом всего этого стало нарушение стандартов разработки на Java. Код оброс:
тонной
static
методов;методами с названиями-предложениями (
doThisAndThisBeforeThat
);глобальными состояниями;
кучей Singleton-ов;
комментариями размером больше, чем код.
Помимо этого, code style оказался некоторой смесью C# и C++. Стандартизированным он по итогу не оказался.
Безусловно, одной из первых операций по всей кодовой базе оказалось приведение в порядок code style. Мы зафорсировали Google Java Style с мелкими (и в целом стандартными для индустрии) изменениями, в том числе, конечно же, заменив использование двух пробелов на четыре.
Это была несложная задача благодаря многим инструментам автоматического форматирования. А вот что уже оказалось непосильным — переписывание архитектуры. Это решили исправлять инкрементально и по мере необходимости.
Такая необходимость появилась. Мы начали разработку taint-модуля, которому необходим механизм аннотаций (пользовательских, но об этом ниже).
Что за аннотации? В нашем случае аннотации — это набор разметки для статического анализа. @NotNull
и @Nullable
являются распространённым вариантом таких аннотаций. Но нам также необходим более расширенный функционал.
Приведу пример наших аннотаций из первой статьи про статический анализатор Java:
Class("java.lang.Math")
- Function("max", Type::Int32, Type::Int32)
.Pure()
.Set(FunctionClassification::NoDiscard)
.Requires(NotEquals(Arg1, Arg2)
.Returns(Arg1, Arg2, [](const Int &v1, const Int &v2)
{ return v1.Max(v2); })
Эта аннотация описывает Math.max
, а именно:
отсутствуют побочные эффекты;
возвращаемое значение метода должно быть использовано;
первый и второй аргументы не должны повторяться.
Возможно, по этому коду не совсем заметно, но этот механизм в Java анализаторе был написан на C++. При этом аннотации тесно связаны с механизмом data flow, который также поставляется C++ модулем. Связано это с тем, что Java анализатор разрабатывался с использованием общей внутренней библиотеки из C++ анализатора. Далее я обозначу всю эту C++ часть как "нативную".
Возвращаемся к taint. Мы хотим размечать различные методы и их параметры как источники или стоки заражённых данных. С таким небольшим расширением механизма аннотаций мы справились. Но не без проблем. Возникали ошибки, непонятное поведение. Дебажить нативную часть несколько трудно, приходится бегать между дебаггером Java и C++, так при этом мы и языка не знаем.
Но в дальнейшем нас опять ожидала необходимость расширения этого механизма. Механизм поиска заражений в коде обязал нас добавить возможность пользователю проставлять собственные аннотации на методы, которые, например, используются в его внутренних библиотеках. Появилась задача разработать пользовательские аннотации в формате JSON. Здесь стало очевидно, что время браться за это серьёзно, заменять нативный модуль и писать новый уже на Java.
Мы также решили, что наконец пришло время внедрять DI (Dependency Injection) фреймворк и писать новый модуль на нём. Соответственно, и подключать его с помощью DI к основному, который также необходимо переписывать на использование DI. И вот уже здесь начинается история в двух частях: переписывание старого модуля и написание нового.
Но для начала необходимо разобраться, почему мы хотим DI, и какой фреймворк для этого выбрать.
Guice (pronounced 'juice')
Dependency Injection
DI-контейнеры распространены в Java, в том числе благодаря широкой распространённости Spring Framework. А вошли в Jakarta ещё в Java EE 8 (и затем Jakarta EE 8).
Spring — это великолепно, однако мы разрабатываем не веб-приложение, а CLI. Он излишне тяжеловесен и тянет слишком много ненужных зависимостей. А также для перехода на Spring необходимо переписывать всё приложение. Задача интересная, но, будем откровенны, лишена смысла, ведь мы заинтересованы лишь в отдельном компоненте Spring: его IoC-контейнере.
Хорошей альтернативой здесь становится Guice — DI-фреймворк (и не более) от Google. Он популярен и стабилен.
Dagger
Здесь я, конечно же, обязан упомянуть Dagger, мы о нём знаем. Однако нам необходимо разбирать зависимости в рантайме. А поскольку приложение десктопное, то и использование рефлексии не вызывает проблем.
Как выглядит декларация зависимостей в Guice? Несколько отличается от привычного Spring и требует чуть больше кода, но тем не менее достаточно просто.
Знакомство с гусем
Далее я опишу базовые принципы работы. Подробное руководство по Guice можно прочитать здесь. А дальнейшее описания просто для быстрого ознакомления, чтобы понимать, с чем работаем.
Начальной точкой описания зависимостей является класс AbstractModule
. Для создания модуля нам необходимо наследовать его и написать метод configure
:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
}
}
Чтобы определить зависимость в контейнере, используем метод bind
с аргументом класса зависимости:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
bind(AnnotationProcessor.class);
....
}
}
После чего вызов Guice.createInjector
позволит нам загрузить приложение (в нашем случае —просто модуль):
var injector = Guice.createInjector(new AnnotationModule());
var processor = injector.getInstance(AnnotationProcessor.class);
Если AnnotationProcessor
содержит no-args
конструктор, то объект будет создан с помощью него. Если же у него есть зависимость, то следует разметить этот конструктор с помощью @Inject
:
public class AnnotationProcessor {
private final Dependency dependency;
@Inject
Public AnnotationProcessor(Dependency dependency) {
this.dependency = dependency;
}
}
В этом случае при исполнении кода мы упадём с исключением, так как Dependency
нет в графе зависимостей. Потому её также необходимо задекларировать:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
bind(AnnotationProcessor.class);
bind(Dependency.class);
}
}
Вот теперь вызов injector.getInstance(AnnotationProcessor.class)
вернёт нам объект с инициализированным полем dependency
.
Если же нам необходимо добавить в граф зависимостей какую-либо реализацию интерфейса (что чаще и требуется), то bind
выглядит следующим образом:
bind(MyInterface.class).to(MyInterfaceImpl.class);
Вместо использования bind
в модуле можно также использовать @Provides
и создавать зависимость в методе:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
bind(Dependency.class);
}
// Вместо bind(AnnotationProcessor.class)
@Provides
public AnnotationProcessor provideProcessor(Dependency dependency) {
return new AnnotationProcessor(dependency);
}
}
Этот вариант эквивалентен предыдущему с вызовом bind
, но позволяет описать более комплексную логику инициализации. Google рекомендуют использовать @Provides
вместо DSL. А вот эта статья рекомендует ровно обратное — DSL вместо @Provides
. Боюсь, предпочтительный вариант придётся определять броском монеты.
В путь
На этом ознакомление окончено, новый друг был выбран. После теоретического изучения возможностей фреймворка началось погружение в рефакторинг кода. Это произошло в двух актах:
Архитектурное планирование и написание нового модуля;
Попытка сохранить здравомыслие при адаптации старого.
Акт 1. Новый модуль
Easy difficulty. Easy (а возможно, и peaceful), ибо мы заранее думаем о том, как компоненты должны взаимодействовать с собой, и как всё это затем выглядит в Guice. Во втором акте будет сложнее.
Возвращаемся к проектированию статического анализатора. Мы хотим ходить по модели анализируемого исходного кода, для каждого элемента (например, вызов метода) проставлять аннотацию согласно определённым правилам.
Итак, нам нужен новый модуль, который содержит следующие классы:
Провайдеры аннотаций: знают, какие элементы и как размечать;
Процессоры аннотаций: ходят по модели и производят разметку;
Сервис для взаимодействия: интерфейс для получения итоговой разметки на модели.
В реализациях провайдеров мы получаем два дополнительных модуля. О них поговорим ниже.
Парсер аннотаций из JSON
Парсинг JSON нам необходим для пользовательских аннотаций. Какими задачами ограничиваем модуль:
Парсинг файла;
Валидация;
Преобразование.
Некоторый внутренний DSL, где мы декларативно пишем их на Java
Ключевой особенностью здесь (и тем, чего нам не хватает в простой JSON-разметке) являются лямбда выражения, которые уже на языке программирования (C++) описывают модификацию виртуального значения.
На текущем этапе мы не переписываем data flow, а лишь поддерживаем taint. Потому изначальный пример с Math.max
использовать смысла нет. А посмотрим мы что-то проще, например, как можно разметить Statement
:
ofClass(java.sql.Statement.class).includeSubtypes()
.ofMethod("execute").anyParameters().flags(SQL_INJECTION_SINK)
.ofMethod("executeUpdate").anyParameters().flags(SQL_INJECTION_SINK)
.ofMethod("executeQuery").anyParameters().flags(SQL_INJECTION_SINK)
.ofMethod("executeLargeUpdate").anyParameters().flags(SQL_INJECTION_SINK)
.ofMethod("addBatch").anyParameters().flags(SQL_INJECTION_SINK)
.finish()
Эта аннотация размечает все вызовы методов execute
, executeUpdate
, executeQuery
, executeLargeUpdate
и addBatch
на типе java.sql.Statement
как возможные стоки заражённых данных.
Проектирование такого API — это отдельная тема, и мне даже пришлось узнать, что такое Curiously Recurring Template Pattern (CRTP), хотя он и используется обычно в C++. Здесь он был необходим, чтобы гарантировать набор операций в цепи вызовов без возможности случайно создать неверный объект. О нём и в целом о процессе написания Fluent API, возможно, я расскажу когда-нибудь в будущем.
Связываем
Теперь два этих модуля необходимо связать. Здесь нам потребуется Multibinder
из Guice.
Для чего? Для создания списка провайдеров аннотаций. Я не хотел делать обычную зависимость со списком, на котором потом в любом месте приложения можно было вызвать add
, ибо это стало бы очередным глобальным состоянием.
Например, так появилась бы возможность проаннотировать модель и затем расширить список провайдеров аннотаций, что точно не походило бы на хороший дизайн. Multibinder
здесь помогает нам зафиксировать список на этапе создания модуля, при этом сохраняя возможность его расширять.
В модуле аннотаций это выглядит следующим образом:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
Multibinder.newSetBinder(
binder(),
new TypeLiteral<AbstractProcessor<? extends CtElement>>() {}
);
}
}
Обозначив такую зависимость в общем модуле (AnnotationModule
), можно создать процессор аннотаций, который зависит от этого списка:
public class AnnotationProcessor {
private final Set<Processor<? extends CtElement>> processors
@Inject
public AnnotationProcessor(
Set<AbstractProcessor<? extends CtElement>> processors) {
this.processors = Set.copyOf(processors);
}
}
Этот процессор не знает, какие аннотации он будет использовать. Он и не знает, откуда они приходят. Его и не должно это волновать.
Комбинируем и получаем:
public class AnnotationModule extends AbstractModule {
@Override
public void configure() {
bind(AnnotationProcessor.class).in(Singleton.class);
Multibinder.newSetBinder(
binder(),
new TypeLiteral<AbstractProcessor<? extends CtElement>>() {}
);
}
}
Как модифицировать список провайдеров аннотаций? Создать соответствующего провайдера:
@ProvidesIntoSet
public AbstractProcessor<? extends CtElement> provideAnnotations() { .... }
Получается архитектура, где каждый модуль таким образом декларирует провайдеры, и они окажутся в общей коллекции, которая затем попадёт в AnnotationProcessor
. При этом после настройки главного модуля, который запустит AnnotationProcessor
, внести изменения в этот список нельзя.
Таким образом, если у нас вдруг появится третий источник аннотаций, его поддержка будет вопросом создания нового модуля или даже просто провайдера, который просто вернёт ещё один объект.
Такой вариант также позволяет легко тестировать отдельные компоненты. Это выражается в следующем: в нашем новом модуле нет провайдера пользовательских аннотаций, есть только инструменты для работы с ними. Да и аннотации из DSL также находятся в отдельном модуле.
Получается следующая схема:

Здесь получается структура, где UserAnnotationProcessingModule
выступает в виде плагина.
AnalyzerModule
сам решает, какие конкретные форматы аннотаций ему поддерживать.Связь
Multibinder
отражает модули, которые используют упомянутый@ProvidesIntoSet
и таким образом расширяютAnnotationModule
, хотя сами об этом напрямую не знают.Здесь также получается интересный момент, где
AnnotationModule
можно заменить на совершенно другой, который также создастMultiBinder
, и это будет работать.
Дальнейшее расширение функционала элементарно. Пофантазируем о загрузке аннотаций с сетевого ресурса через NetworkAnnotationModule
и получим:

Где-то в этом воображаемом модуле мы можем независимо получить данные о том, откуда загружать аннотации и как их обрабатывать.
Почему наши плагины устанавливаются именно в AnalyzerModule
, я аргументирую в следующем блоке, где мы возвращаемся к основному модулю.
Акт 2. Когда-то и меня вела дорога приключений...
Немного отвлечёмся от схем, ведь после всей этой прекрасной жизни пришла пора вернуться в основной модуль. Проектирование здесь не задумывалось с использованием DI-контейнеров. Где-то есть длинные цепи передачи зависимостей. Очень длинные. А где-то используется просто Singleton. Соответственно, дело начинается с того, что для себя же приходится нарисовать схему этих компонентов, чтобы понять, как и что можно разделять.
Такая схема действительно была нарисована, но показывать её я не буду. В процессе разбора этой структуры были внесены различные изменения.
Во-первых, очевидно, что благодаря DI нам не нужно тянуть через аргументы конструкторов какие-либо зависимости, если самим компонентам они не нужны.
Во-вторых, некоторые классы уже по факту выполняли работу Provider
из Guice. Например, динамическая загрузка правил: она собирает всех наследников определённого класса в определённом пакете. С заменой таких классов дела обстоят особенно хорошо, делается это просто:
@Provides
@Named("rules")
private List<PvsStudioRule> provideRules() {
var rules = new ArrayList<PvsStudioRule>();
String packageName = PvsStudioRule.class.getPackage().getName();
var reflections = new Reflections(packageName);
var ruleClasses = reflections.getSubTypesOf(PvsStudioRule.class);
....
return rules;
}
Всё это достаточно простые (и по итогу приятные) изменения. Ругать старый код и писать новый (инвестиция, на что ругаться в будущем) можно долго, и это не слишком интересно. Лучше посмотреть, какой новый функционал необходимо привнести сюда.
Для этого возвращаемся к Multibinder
, поскольку пора делать механизм загрузки пользовательских аннотаций. Это отдельный модуль, ведь мы работаем с пользовательскими данными и обработку ошибок (если они есть) необходимо делать уже здесь. Именно поэтому модуль аннотаций должен быть столь абстрагирован от реальных событий.
Ему не следует знать, кто и откуда его зовёт. Он и не должен. Он может каким-либо образом сообщить о проблемах, но не должен принимать решений по их разрешению.
Помимо этого, именно ядро знает CLI-аргументы до файлов аннотаций, либо определяет логику автоматической загрузки всех .annotations.json
файлов.
То есть уже здесь мы делаем упомянутый ранее Guice модуль UserAnnotationProcessingModule
, отвечающий за предоставление нового процессора через @ProvidesIntoSet
: добавление зависимости в сет, созданный через Multibinder
. Этот модуль уже имеет следующие входные данные:
Конфигурация анализатора;
Контекст проекта.
Конфигурация анализатора (которая в том числе включает в себя данные из аргументов CLI) используется, чтобы посмотреть, указал ли пользователь пути до файлов аннотаций.
Контекст проекта используется, чтобы получить директорию .PVS-Studio
и оттуда достать все файлы .annotations.json
.
И поскольку это основной модуль, что позвал механизм загрузки этих аннотаций, он же и будет обрабатывать ошибки. Конкретной реализацией обработки этих ошибок здесь является генерация предупреждений V019.
Именно поэтому важно разделять ответственность, ведь модуль аннотаций не может знать о том, как создавать предупреждения анализатора, что с ними делать, чтобы они появились в отчёте или в графическом интерфейсе. Такая модульность позволяет нам так же тривиально заменить генерацию предупреждений на, допустим, обычный вывод предупреждения в stdout
.
Результат
Жить стало проще и лучше, а ядро пополнилось зависимостями Enterprise-стандартами.
Или серьёзней: крупная внутренняя работа была проведена, чтобы грамотно спроектировать новый модуль. Конечно же, мы могли потратить на это меньше времени и продолжать писать код как есть, получив рабочий вариант. К сожалению, как показывает практика, любая попытка дальнейшего расширения на случаи "такой сценарий не рассматривался" приводит к появлению новых ошибок, а время, которое приходится тратить на каждую фичу, возрастает экспоненциально.
На этом всё! Мы сделали анализатор лучше, а попробовать его можно здесь.