Spring 6.2 сломал мой instanceof: три бага, которые я поймал, пока писал Spring Boot Starter
Пишем Spring Boot Starter: архитектурное решение и три неочевидных бага
TL;DR. Я написал cronctl — starter для HTTP-управления
@Scheduled-методами. В статье — как устроена auto-configuration изнутри, почему Spring Framework 6.2 сломал мойinstanceof, как CGLIB-проксирование тихо отравило матчинг задач, и почему@NullMarkedконфликтует с Aware-интерфейсами Spring.
Зачем ещё одна библиотека
В каждом enterprise-проекте рано или поздно появляются @Scheduled-методы. И в каждом проекте через какое-то время кто-то спрашивает: «А можно вручную запустить вот этот джоб, не дожидаясь расписания?»
Стандартный ответ — написать эндпоинт руками. Но это копипаст: каждый метод нужно явно прокинуть в контроллер, поддерживать список актуальным, думать о безопасности.
Перед тем как писать что-то новое, я посмотрел на существующие решения:
Инструмент | Что умеет | Почему не подошло |
|---|---|---|
| Показывает список задач | Read-only, запуска нет |
Quartz | Полноценный планировщик, управление через API | Своя модель задач — нужно мигрировать код с |
JobRunr, db-scheduler | Управление задачами с персистентностью | То же: своя модель, требует переписывания, нужна БД |
ShedLock | Распределённая блокировка для | Не управление, а координация — задачи другие |
Общая проблема всего тяжёлого: вы уже написали сто @Scheduled-методов, и теперь вам предлагают мигрировать код на другую модель задач. cronctl работает иначе: zero-code надстройка над существующими @Scheduled — подключаете зависимость, всё остальное происходит само.
Как использовать
Подключение
<dependency>
<groupId>ru.syntezis</groupId>
<artifactId>cronctl-spring-boot-starter</artifactId>
<version>0.0.3</version>
</dependency>
Требования: Java 17+, Spring Boot 3.4+. Библиотека собирается и тестируется на Spring Boot 3.4/3.5; более ранние версии не поддерживаются.
Аннотации
После подключения все @Scheduled-методы автоматически появляются в API. @CronctlTask — опциональная аннотация для метаданных и тонкой настройки:
@Component
public class MyScheduler {
// Полные метаданные
@CronctlTask(
label = "Sync Data",
description = "Pulls updates from the remote source",
group = "integration",
tags = {"sync", "critical"},
timeout = 30
)
@Scheduled(fixedRate = 60_000)
public void syncData() { ...}
// Без @CronctlTask — дефолты: label = "generateReport"
@Scheduled(cron = "0 0 3 * * *")
public void generateReport() { ...}
// Исключить из API полностью
@CronctlTask.Exclude
@Scheduled(fixedRate = 60_000)
public void internalJob() { ...}
}
По умолчанию попадают все @Scheduled (режим AUTO). Альтернативы: ANNOTATED — только помеченные @CronctlTask; PACKAGE — только из указанных пакетов. Подробнее — в README.
API в двух словах
# Список задач
curl http://localhost:8080/api/cronctl/tasks
{
"tasks": [
{
"label": "Sync Data",
"group": "integration",
"tags": [
"sync",
"critical"
],
"timeout_seconds": 30,
"next_execution_at": "2024-05-01T12:01:00Z",
"details": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method_name": "syncData",
"schedule": {
"fixed_rate": 60000,
"time_unit": "MILLISECONDS"
}
}
}
],
"total": 1
}
# Запустить вручную (синхронно — блокирует до завершения)
curl -X POST http://localhost:8080/api/cronctl/tasks/3fa85f64.../execute
{
"status": "SUCCEEDED",
"execution_start_mills": 1715000000000,
"execution_end_mills": 1715000000123,
"execution_duration_mills": 123,
"fail_details": null
}
Доступны также: получение времени следующего запуска, асинхронный запуск с polling статуса и отменой. Полное описание эндпоинтов — в README.
Безопасность
⚠️ Важно. По умолчанию
cronctl.api.public-access=true— эндпоинты доступны без аутентификации. Это значит, что любой, кто доберётся до порта приложения, сможет запустить бизнес-логику на исполнение. Actuator, для сравнения, закрыт по умолчанию. Дефолт cronctl выбран ради «нулевого трения» при первом запуске, но **для любого окружения кроме localhost его нужно менять **.
Минимально достаточная конфигурация для production:
cronctl:
api:
public-access: false
swagger:
public-access: false
Как устроен SecurityFilterChain. cronctl регистрирует собственный SecurityFilterChain с securityMatcher("{base-path}/**") и @Order(SecurityProperties.BASIC_AUTH_ORDER - 2). Он перехватывает только запросы на пути cronctl; остальные запросы обрабатываются другими chain-бинами в обычном порядке. Если у вас есть собственный SecurityFilterChain, убедитесь, что его @Order не совпадает с BASIC_AUTH_ORDER - 2 — Spring не позволяет двум chain-бинам иметь одинаковый порядок и упадёт на старте.
spring-boot-starter-security — обязательная транзитивная зависимость cronctl, она всегда присутствует в classpath. Если ваше приложение до этого не использовало Spring Security, её появление активирует Spring Boot security auto-configuration: в логах появится generated security password, а все эндпоинты приложения, не покрытые никаким SecurityFilterChain, окажутся под дефолтной защитой (basic auth). Эндпоинты cronctl при этом подчиняются только его собственному chain-у.
Анатомия Spring Boot Starter
Точка входа
Spring Boot обнаруживает стартеры через файл:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Класс-конфигурация помечается @AutoConfiguration:
@AutoConfiguration
@ConditionalOnProperty(value = "cronctl.enabled", matchIfMissing = true)
@EnableConfigurationProperties(CronctlProperties.class)
@Import({
CronctlSecurityConfiguration.class,
CronctlSwaggerConfiguration.class
})
public class CronctlAutoConfiguration {
// все бины здесь
}
matchIfMissing = true — работает без явного cronctl.enabled=true.
Роли бинов
Spring предлагает три роли (BeanDefinition.ROLE_*):
Роль | Константа | Для чего |
|---|---|---|
| 2 | BPP, BFPP, внутренние процессоры |
| 1 | Вспомогательные сервисы |
| 0 | Бизнес-логика, публичный API |
Роль INFRASTRUCTURE важна для BPP: она сигнализирует Spring-у, что бин должен быть создан в числе первых и не нуждается в стандартной пост-обработке. Без неё Spring выдаёт предупреждения о preinstantiation.
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public static ScheduleAnnotationBeanPostProcessor scheduleAnnotationBeanPostProcessor(...) {
return new ScheduleAnnotationBeanPostProcessor(...);
}
Метод static — принципиально: static-методы вызываются до создания экземпляра конфигурационного класса.
Архитектурное решение: BeanFactoryPostProcessor vs BeanPostProcessor
Пользователь может настраивать cronctl не только через application.yml, но и программно:
@Bean
public CronctlConfiguration cronctlConfiguration() {
return CronctlConfiguration.builder()
.basePath("/internal/scheduler")
.apiPublicAccess(false)
.executorThreadPoolSize(8)
.build();
}
Значения из этого бина должны попасть в Environment до создания остальных бинов cronctl — иначе security-конфигурация прочитает старые дефолты. BeanPostProcessor не подходит: он работает уже после создания бинов. Нужен BeanFactoryPostProcessor — он запускается до инстанцирования любых бинов:
public class CronctlConfigurationContributor implements BeanFactoryPostProcessor {
private final ConfigurableEnvironment environment;
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
String[] names = beanFactory.getBeanNamesForType(
CronctlConfiguration.class, false, false
);
if (names.length == 0) return;
CronctlConfiguration config = beanFactory.getBean(CronctlConfiguration.class);
Map<String, Object> props = toPropertyMap(config);
if (props.isEmpty()) return;
environment.getPropertySources()
.addFirst(new MapPropertySource("cronctlProgrammaticConfig", props));
}
}
addFirst гарантирует наивысший приоритет — программная конфигурация перебивает application.yml.
Два ограничения этого паттерна, о которых важно помнить:
Внутри BFPP можно вызывать
beanFactory.getBean()только для простых бинов без зависимостей. ЕслиCronctlConfigurationбудет требовать autowired-зависимостей, это сломает контекст.Бин, инстанцированный внутри BFPP через
getBean(), создаётся до проходаBeanPostProcessor’ов — он не получит AOP-обработку. Если пользователь повесит@TransactionalнаCronctlConfiguration, транзакционность не применится.
Баг 1: Spring Framework 6.2 и OutcomeTrackingRunnable
Задача
Для каждой задачи нужно вычислять время следующего запуска. Spring предоставляет ScheduledTaskHolder с набором ScheduledTask, у каждой есть nextExecution() → Instant.
Нужно сопоставить задачу из реестра cronctl с записью в ScheduledTaskHolder. Очевидный подход:
public @Nullable Instant computeNextExecutionAt(Task task) {
ScheduledMethodReference ref = task.getReference();
for (ScheduledTask scheduledTask : holder.getScheduledTasks()) {
Runnable runnable = scheduledTask.getTask().getRunnable();
if (runnable instanceof ScheduledMethodRunnable smr) {
if (smr.getTarget() == ref.getBean() && smr.getMethod().equals(ref.getMethod())) {
return scheduledTask.nextExecution();
}
}
}
return null;
}
Код выглядит разумно. В sample-приложении next_execution_at был null для всех задач — интеграционный тест на этот метод к тому моменту ещё не существовал, его написали уже после исправления.
Что пошло не так
В Spring Framework 6.2 (issue #34058) класс Task получил трекинг результатов выполнения. Конструктор Task(Runnable) теперь оборачивает переданный runnable:
// Spring Framework 6.2, Task.java (упрощённо, по материалам декомпиляции)
public class Task {
private final Runnable runnable;
public Task(Runnable runnable) {
// ScheduledMethodRunnable implements SchedulingAwareRunnable,
// поэтому попадает в специализированную обёртку
if (runnable instanceof SchedulingAwareRunnable sar) {
this.runnable = new OutcomeTrackingSchedulingAwareRunnable(sar);
} else {
this.runnable = new OutcomeTrackingRunnable(runnable);
}
}
private class OutcomeTrackingRunnable implements Runnable {
private final Runnable delegate;
OutcomeTrackingRunnable(Runnable delegate) {
this.delegate = delegate;
}
@Override
public String toString() {
return delegate.toString(); // делегирует toString вложенному runnable
}
// run() и прочее опущены
}
// OutcomeTrackingSchedulingAwareRunnable аналогично реализует
// SchedulingAwareRunnable; toString() тоже делегирует
}
Результат: scheduledTask.getTask().getRunnable() возвращает OutcomeTrackingSchedulingAwareRunnable, а не ScheduledMethodRunnable. Проверка instanceof ScheduledMethodRunnable всегда false. Матчинг не работает совсем.
Решение через toString()
OutcomeTrackingRunnable.toString() делегирует вложенному runnable. ScheduledMethodRunnable.toString() возвращает method.getDeclaringClass().getName() + "." + method.getName(). Матчинг по строке работает через любую обёртку:
// Из реального кода NextExecutionTimeResolver:
public @Nullable Instant computeNextExecutionAt(Task task) {
ScheduledTaskHolder holder = scheduledTaskHolderProvider.getIfAvailable();
if (holder != null) {
ScheduledMethodReference ref = task.getReference();
String expectedDesc = ref.getMethod().getDeclaringClass().getName() + "." + ref.getMethod().getName();
for (ScheduledTask scheduledTask : holder.getScheduledTasks()) {
if (!expectedDesc.equals(scheduledTask.getTask().getRunnable().toString())) continue;
Instant nextExecution = scheduledTask.nextExecution();
if (nextExecution != null) {
return nextExecution;
}
}
}
return ScheduleUtils.computeNextExecutionAt(task.getDetails().getSchedule());
}
Честно о хрупкости. Матчинг по toString() — зависимость от implementation detail Spring: если в следующей минорной версии toString() у обёртки изменится, матчинг сломается бесшумно. Мы рассматривали альтернативы:
Рефлективный unwrap поля
delegate: работает, но ещё более хрупко — зависимость от имени приватного поля.Перехват на этапе регистрации Task: потребовал бы глубокой интеграции со Spring Scheduling, несоразмерной задаче.
toString()-матчинг выбран как наименее инвазивный. Рефлективный unwrap — запасной план при поломке.
Известное ограничение. Если два разных бина одного класса регистрируют метод с одним именем, expectedDesc у них совпадёт — матчинг неоднозначен. Текущее поведение: возвращается nextExecution первого совпавшего не-null результата. На практике такая ситуация крайне редка, но честно отметим её как ограничение.
Подпункт: CGLIB и getDeclaringClass()
Пока разбирались с обёрткой, обнаружился второй камень. Первый инстинкт при построении expectedDesc — взять имя класса из бина:
// Неправильно — для CGLIB-проксированных бинов:
String desc = ref.getBean().getClass().getName() + "." + ref.getMethod().getName();
// → "com.example.MyService$SpringCGLIB$0.syncData"
Но ScheduledMethodRunnable.toString() использует method.getDeclaringClass():
// В Spring-е:
@Override
public String toString() {
return this.method.getDeclaringClass().getName() + "." + this.method.getName();
// → "com.example.MyService.syncData" (всегда реальный класс)
}
Для бинов с @Transactional, @Cacheable и любой другой AOP-обёрткой bean.getClass() возвращает имя прокси-класса. method.getDeclaringClass() — всегда исходный класс. Правило: для матчинга по имени класса всегда getDeclaringClass(), никогда — getClass() на бине.
Баг 2: ObjectProvider и порядок инициализации
NextExecutionTimeResolver нуждается в ScheduledTaskHolder. Первая реализация выглядела так:
public class NextExecutionTimeResolver {
private final ScheduledTaskHolder holder;
public NextExecutionTimeResolver(ObjectProvider<ScheduledTaskHolder> provider) {
this.holder = provider.getIfAvailable(); // ← резолвим при создании бина
}
}
ScheduledTaskHolder регистрируется внутри Spring’овского ScheduledAnnotationBeanPostProcessor — того самого BPP, который обрабатывает @Scheduled-методы. Порядок инициализации BPP-бинов в Spring определяется иерархией: PriorityOrdered → Ordered → остальные. Но в пределах одной категории бин не должен полагаться на готовность других бинов — они могут быть ещё не созданы. Именно это и происходило: при некоторых конфигурациях getIfAvailable() срабатывал до регистрации ScheduledTaskHolder и возвращал null.
holder == null, nextExecution == null для всех задач в prod — и это единственный из трёх багов, который воспроизводился только в реальном приложении. Тесты были зелёными, потому что в тестовом контексте порядок инициализации складывался удачно.
Решение очевидно, если вспомнить зачем нужен ObjectProvider:
public class NextExecutionTimeResolver {
private final ObjectProvider<ScheduledTaskHolder> holderProvider;
public NextExecutionTimeResolver(ObjectProvider<ScheduledTaskHolder> holderProvider) {
this.holderProvider = holderProvider; // храним ссылку на provider, не на бин
}
public @Nullable Instant computeNextExecutionAt(Task task) {
ScheduledTaskHolder holder = holderProvider.getIfAvailable(); // резолвим лениво
// ...
}
}
ObjectProvider — инструмент для ленивого и опционального доступа к бинам. Вызов getIfAvailable() в конструкторе убивает весь его смысл.
Баг 3: @NullMarked и lifecycle-коллбеки Spring
Проект использует JSpecify: все пакеты помечены @NullMarked, что делает все ссылочные типы неявно @NonNull.
ScheduleAnnotationBeanPostProcessor реализует EmbeddedValueResolverAware — Spring-интерфейс для получения резолвера ${...}-плейсхолдеров. Поле устанавливается не через конструктор, а через lifecycle-коллбек:
@NullMarked // ← на пакете: всё @NonNull по умолчанию
public class ScheduleAnnotationBeanPostProcessor
implements BeanPostProcessor, EmbeddedValueResolverAware {
private StringValueResolver embeddedValueResolver; // ← неявно @NonNull, но не в конструкторе
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.embeddedValueResolver = resolver;
}
}
Этот баг не runtime-проблема — его поймала Qodana статическим анализом. Но жалоба справедливая: поле @NonNull, конструктором не инициализировано, значит статический анализ не может гарантировать null-safety.
Это системный конфликт: Spring-коллбеки через Aware-интерфейсы (BeanNameAware, ApplicationContextAware, EmbeddedValueResolverAware и др.) принципиально несовместимы с @NullMarked — поля, заполняемые через lifecycle-сеттеры, не могут быть инициализированы в конструкторе.
Решение — явный @Nullable, и он должен пройти через всю цепочку вызовов:
// В BPP:
private @Nullable StringValueResolver embeddedValueResolver;
// В ScheduledBeanProcessor.process():
public Task process(Object bean, String beanName, Method method,
@Nullable StringValueResolver resolver) { ...}
// В ScheduleUtils.assembleScheduleDetails():
public static ScheduleDetails assembleScheduleDetails(Scheduled annotation,
@Nullable StringValueResolver resolver) {
// resolver == null → плейсхолдеры не разворачиваются
}
Это честное описание реальности: до вызова setEmbeddedValueResolver поле равно null, и весь нижележащий код должен это обрабатывать.
Ограничения
Несколько реплик
Ручной вызов POST /tasks/{id}/execute попадает на одну ноду через балансировщик. Распределённого запуска нет — задача выполняется только на той ноде, которая получила HTTP-запрос.
Конкурентность
BlockingTaskExecutor выполняет метод через method.invoke(bean) без какой-либо синхронизации. Ручной запуск не координируется с плановым расписанием — если задача уже выполняется по расписанию, ручной запрос запустит её параллельно второй раз. Если это недопустимо, добавьте собственную блокировку внутри метода.
Совместимость с ShedLock
cronctl хранит ссылку на бин из BeanPostProcessor.postProcessAfterInitialization() — это уже проксированный бин. При вызове method.invoke(proxy) для CGLIB-прокси вызов проходит через цепочку advice, поэтому @SchedulerLock от ShedLock должен отрабатывать. Поведение с JDK dynamic proxy не верифицировалось.
Итоги
Проблема | Урок |
|---|---|
Spring 6.2 | Не полагайтесь на |
| |
| Храните |
| Lifecycle-поля явно помечаются |
Три бага нашлись по-разному: OutcomeTrackingRunnable обнаружился при первой проверке в sample-приложении ( интеграционного теста на next_execution_at ещё не существовало); ObjectProvider — единственный, который воспроизводился только в реальном приложении при определённом порядке инициализации, тесты были зелёными; @NullMarked поймала Qodana статическим анализом, до runtime он не добирался. Хороший аргумент в пользу иметь и sample-приложение, и статический анализ в CI — каждый инструмент ловит своё.
Библиотека: github.com/syntezis-ru/cronctl