Пишем Spring Boot Starter: архитектурное решение и три неочевидных бага

TL;DR. Я написал cronctl — starter для HTTP-управления @Scheduled -методами. В статье — как устроена auto-configuration изнутри, почему Spring Framework 6.2 сломал мой instanceof, как CGLIB-проксирование тихо отравило матчинг задач, и почему @NullMarked конфликтует с Aware-интерфейсами Spring.


Зачем ещё одна библиотека

В каждом enterprise-проекте рано или поздно появляются @Scheduled-методы. И в каждом проекте через какое-то время кто-то спрашивает: «А можно вручную запустить вот этот джоб, не дожидаясь расписания?»

Стандартный ответ — написать эндпоинт руками. Но это копипаст: каждый метод нужно явно прокинуть в контроллер, поддерживать список актуальным, думать о безопасности.

Перед тем как писать что-то новое, я посмотрел на существующие решения:

Инструмент

Что умеет

Почему не подошло

/actuator/scheduledtasks

Показывает список задач

Read-only, запуска нет

Quartz

Полноценный планировщик, управление через API

Своя модель задач — нужно мигрировать код с @Scheduled

JobRunr, db-scheduler

Управление задачами с персистентностью

То же: своя модель, требует переписывания, нужна БД

ShedLock

Распределённая блокировка для @Scheduled

Не управление, а координация — задачи другие

Общая проблема всего тяжёлого: вы уже написали сто @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_*):

Роль

Константа

Для чего

INFRASTRUCTURE

2

BPP, BFPP, внутренние процессоры

SUPPORT

1

Вспомогательные сервисы

APPLICATION

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.

Два ограничения этого паттерна, о которых важно помнить:

  1. Внутри BFPP можно вызывать beanFactory.getBean() только для простых бинов без зависимостей. Если CronctlConfiguration будет требовать autowired-зависимостей, это сломает контекст.

  2. Бин, инстанцированный внутри 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 определяется иерархией: PriorityOrderedOrdered → остальные. Но в пределах одной категории бин не должен полагаться на готовность других бинов — они могут быть ещё не созданы. Именно это и происходило: при некоторых конфигурациях 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 OutcomeTrackingRunnable + CGLIB

Не полагайтесь на instanceof для Runnable внутри Spring Task — используйте

toString()-матчинг; для построения строки берите getDeclaringClass(), а не getClass() на бине

ObjectProvider.getIfAvailable() в конструкторе

Храните ObjectProvider, резолвируйте лениво — в момент вызова, не создания бина

@NullMarked + Aware-интерфейсы

Lifecycle-поля явно помечаются @Nullable с протяжкой аннотации через всю цепочку вызовов

Три бага нашлись по-разному: OutcomeTrackingRunnable обнаружился при первой проверке в sample-приложении ( интеграционного теста на next_execution_at ещё не существовало); ObjectProvider — единственный, который воспроизводился только в реальном приложении при определённом порядке инициализации, тесты были зелёными; @NullMarked поймала Qodana статическим анализом, до runtime он не добирался. Хороший аргумент в пользу иметь и sample-приложение, и статический анализ в CI — каждый инструмент ловит своё.

Библиотека: github.com/syntezis-ru/cronctl