Большинство разработчиков в мире JVM работают над различными веб-приложениями, большинство из которых основаны на таких фреймворках, как Spring или Micronaut. Однако некоторые люди утверждают, что фреймворки создают слишком большие накладные расходы. Я решил проверить, насколько справедливы такие утверждения и сколько работы необходимо, чтобы воспроизвести то, что фреймворки предоставляют нам «из коробки».

Эта статья не о том, возможно ли использовать фреймворк или когда его использовать. Речь идет о написании вашего фреймворка — переделка — лучший способ обучения!

Для простоты мы будем использовать код демо-приложения. Приложение состоит из

  • Уникальный сервис
  • Два репозитория
  • Два POJO

Нет рамки

Начальная точка приложения без фреймворка будет выглядеть как код ниже:

Как мы видим, метод main приложения отвечает за реализацию интерфейсов, от которых зависит ManualTransactionParticipationService. Разработчик должен знать, какая реализация PartitionService должна быть создана в основном методе. При использовании фреймворка программистам обычно не нужно создавать экземпляры и зависимости самостоятельно. Они полагаются на основную функцию фреймворков — внедрение зависимостей.

Итак, давайте взглянем на простую реализацию контейнера внедрения зависимостей, основанную на обработке аннотаций.

Что такое внедрение зависимостей?

Шаблон внедрения зависимостей

Внедрение зависимостей или DI — это шаблон для предоставления экземплярам класса его переменных экземпляра (его зависимостей).

Но как это делается? Шаблон отделяет ответственность за создание объекта от его использования. Необходимые объекты предоставляются («внедряются») во время выполнения, а реализация шаблона обрабатывает создание и жизненный цикл зависимостей.

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

ПРИМЕЧАНИЕ. Внедрение зависимостей — это реализация инверсии управления!

Доступные решения для внедрения зависимостей

В мире Java широко распространено как минимум несколько DI-фреймворков.

  • Весна — DI был начальной частью этого проекта и до сих пор является основной концепцией фреймворка.
  • Guice — фреймворк/библиотека DI от Google.
  • Кинжал — популярен в мире Android.
  • Микронавт — часть каркаса.
  • Кваркус — часть фреймворка.
  • Java/Jakarta CDI — стандартная среда внедрения зависимостей, возникшая в Java EE 6.

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

Фактически, DI настолько популярен, что для него был сделан запрос спецификации Java.

Обработка аннотаций

Обработка на основе времени выполнения

Spring, самый популярный фреймворк Java, обрабатывает аннотации во время выполнения. Решение в значительной степени основано на механизме отражения. Подход, основанный на отражении, является одним из возможных способов обработки аннотаций, и если вы хотите последовать этому примеру, обратитесь к Java Own Framework — шаг за шагом.

Обработка на основе компиляции

В дополнение к обработке во время выполнения существует еще один подход. Часть внедрения зависимостей может происходить во время обработки аннотаций — процесса, происходящего во время компиляции. В последнее время он стал популярным благодаря Micronaut и Quarkus, поскольку они используют этот подход.

Обработка аннотаций предназначена не только для внедрения зависимостей. Он входит в состав различных инструментов. Например, в библиотеках типа Lombok или MapStruct.

Обработка аннотаций и процессоры

Целью обработки аннотаций является создание, а не изменение файлов. Он также может выполнять некоторые проверки во время компиляции, например, гарантировать, что все поля класса являются окончательными. Если что-то не так, процессор может не выполнить компиляцию и предоставить программисту информацию об ошибке.

Процессоры аннотаций написаны на Java и используются javac во время компиляции. Однако программисты должны скомпилировать процессор перед его использованием. Он не может напрямую обрабатывать себя.

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

Как наблюдать за работой обработчиков аннотаций

Есть два флага компилятора -XprintProcessorInfo и -XprintRounds, которые представляют информацию о процессе компиляции и раундах компиляции.

Пример конфига для Gradle можно найти здесь.

Как написать обработчик аннотаций

Чтобы написать обработчик аннотаций, необходимо создать реализацию интерфейса Processor.

Processor определяет шесть методов, что очень сложно реализовать. К счастью, создатель инструмента подготовил AbstractProcessor для расширения и упрощения работы программиста. API AbstractProcessor немного отличается от Processor и предоставляет некоторые реализации методов по умолчанию.

Как только реализация будет готова, вы должны уведомить компилятор о необходимости использования вашего процессора. В javac есть несколько флагов для обработки аннотаций, но вы не должны так с ним работать. Чтобы уведомить компилятор о процессоре, необходимо указать его имя в файле META-INF/services/javax.annotation.processing.Processor. Имя должно быть полным, а файл может содержать более одного процессора. Последний подход работает с инструментами сборки. Никто не строит свой проект с использованием javac, верно?

Поддержка инструментов сборки

Инструменты сборки, такие как Maven или Gradle, поддерживают использование процессоров.

Создание собственного DI-фреймворка

Как упоминалось выше, в статье Java Own Framework — шаг за шагом рассказывается, как работает обработка аннотаций во время выполнения DI. В качестве коллеги я с удовольствием покажу базовый фреймворк времени компиляции. Этот подход имеет ряд преимуществ перед классическим. Подробнее об этом можно прочитать в примечаниях к выпуску Micronaut. Ни фреймворк, который мы создаем, ни Micronaut не свободны от отражений, но частично и в ограниченной степени зависят от них.

Примечание. Обработчик аннотаций — гибкий инструмент. Представленное решение вряд ли будет единственным вариантом.

А вот и главное блюдо хранилища. Мы собираемся построить нашу структуру DI вместе. Цель состоит в том, чтобы заставить код ниже работать.

Мы можем сделать некоторые предположения, основываясь на приведенном выше коде. Во-первых, нам нужна структура для предоставления аннотаций для указывающих классов. Я решил использовать для аннотаций стандартизированную библиотеку jakarta.inject.*. Точнее, просто jakarta.inject.Singleton. То же самое использует Micronaut.

Во-вторых, мы можем быть уверены, что нам нужен BeanProvider. Фреймворки любят ссылаться на него с помощью слова Context, например ApplicationContext.

Третья необходимая вещь — это процессор аннотаций, который будет обрабатывать упомянутые аннотации. Он должен создавать классы, позволяющие платформе предоставлять ожидаемые зависимости во время выполнения.

Фреймворк должен использовать механизм отражения как можно меньше.

Для простоты предположим, что структура:

  • обрабатывает конкретные классы, аннотированные с помощью @Singleton, которые имеют только один конструктор,
  • использует область действия singleton (каждый компонент будет иметь только один экземпляр для данного BeanProvider).

Как должен работать фреймворк?

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

На приведенной ниже диаграмме показана высокоуровневая архитектура желаемого решения.

Схема "классов" фреймворка

Как видите, нам нужен BeanProcessor для создания реализаций BeanDefinition для каждого компонента. Затем BeanDefinition выбираются BaseBeanProvider, который реализует BeanProvider (не на диаграмме). В коде приложения мы используем BaseBeanProvider, созданный для нас BeanProviderFactory. Мы также используем интерфейс ScopeProvider, который должен обрабатывать область действия бина. В примере, как уже упоминалось, нас интересует только одноэлементная область видимости.

Реализация структуры

Сам фреймворк находится в подпроекте Gradle под названием framework.

Основные интерфейсы

Начнем с интерфейса BeanDefinition.

Интерфейс имеет только два метода: type() для предоставления объекта Class для класса компонента и один для создания самого компонента. Метод create(…) принимает BeanProvider для получения его зависимостей, необходимых во время сборки, поскольку он не должен их создавать, отсюда и DI.

Фреймворку также понадобится интерфейс BeanProvider всего с двумя методами.

Метод provideAll(…) предоставляет все компоненты, соответствующие параметру Class‹T› beanType. Под соответствием я подразумеваю, что данный bean-компонент является подтипом или того же типа, что и данный beanType. Метод provide(…) почти такой же, но предоставляет только один соответствующий bean-компонент. Исключение выдается в случае отсутствия бинов или более одного бина.

Процессор аннотаций

Мы ожидаем, что обработчик аннотаций найдет классы, аннотированные с помощью @Singleton. Затем проверьте, допустимы ли они (без интерфейсов, абстрактных классов, только один конструктор). Последний шаг — создание реализации BeanDefinition для каждого аннотированного класса.

Итак, мы должны начать с его реализации, правильно?

Разработка через тестирование будет возражать. Вернемся к тестам позже. Теперь сосредоточимся на реализации.

Шаг 1 — определение процессора

Давайте определим наш процессор:

Наш процессор расширит предоставленный AbstractProcessor вместо полной реализации интерфейса Processor.

Реальная реализация отличается от того, что вы видите. Не волнуйся; он будет использован в полной мере на следующем шаге. Показанной здесь упрощенной версии достаточно для реальной работы DI.

Шаг 2 — добавить аннотации!?

Благодаря использованию AbstractProcess нам не нужно переопределять какие-либо методы. Вместо этого можно использовать аннотации:

  1. @SupportedAnnotationTypes соответствует Processor.getSupportedAnnotationTypes и используется для создания возвращаемого значения. Как определено, обработчик заботится только о @jakarta.inject.Singleton.
  2. @SupportedSourceVersion(SourceVersion.RELEASE_17) соответствует Processor.getSupportedSourceVersion и используется для создания возвращаемого значения. Процессор будет поддерживать язык до уровня Java 17.

Шаг 3. Переопределение метода обработки

Предположим, что приведенный ниже код включен в тело класса BeanProcessor.

  1. Параметр annotations предоставляет набор аннотаций, представленных в виде элементов. Аннотации представлены как минимум интерфейсом TypeElement. Это может показаться необычным, так как все привыкли к java.lang.Class или более широкому java.lang.reflect.Type, который является представлением времени выполнения.
    На с другой стороны, существует также представление времени компиляции. Позвольте мне представить интерфейс Element, общий интерфейс для всех языковых конструкций времени компиляции, таких как классы, модули, переменные и пакеты. Стоит отметить, что существуют подтипы, соответствующие таким конструкциям, как PackageElement или TypeElement.
    Код процессора будет часто использовать элементы Element.
  2. Поскольку процессор должен перехватывать любое исключение и регистрировать его, здесь мы будем использовать предложения try и catch. Метод BeanProcessor.processBeans обеспечивает фактическую обработку аннотаций.
  3. Инфраструктура процессора аннотаций предоставляет экземпляр Messager пользователю через поле processingEnv в AbstractProcessor. Messager — это способ сообщать обо всех ошибках, предупреждениях и т. д.
    Он определяет четыре перегруженных метода printMessage(…), и первый параметр этих методов — используется для определения типа сообщения с помощью Diagnostic.Kind enum. В коде есть пример сообщения об ошибке.
    Если процессор выдает исключение, компиляция завершится ошибкой без дополнительных диагностических данных.
  4. Нет необходимости запрашивать аннотации, поэтому метод возвращает false.

Шаг 4 – напишите фактическую обработку

  1. Во-первых, RoundEnvironment используется для предоставления всех элементов цикла компиляции, аннотированных с помощью @Singleton.
  2. Затем используется ElementFilter, чтобы получить только TypeElement из аннотированных. Было бы разумно потерпеть неудачу здесь, когда размер аннотации отличается от типа, но с помощью @Singleton можно аннотировать что угодно, и мы не хотим с этим сталкиваться. Поэтому нам не нужно ничего, кроме TypeElement. Они представляют элементы класса и интерфейса во время компиляции.
    ElementFilter — это служебный класс, который фильтрует Iterable‹? расширяет Element› или Set‹? расширяет Element›, чтобы получить элементы, соответствующие критериям, с типом, суженным до соответствия реализации Element.
  3. На следующем этапе мы создаем экземпляр TypeDependencyResolver, который является частью нашей структуры. Класс отвечает за получение элемента типа, проверку наличия только одного конструктора и параметров конструктора. Мы рассмотрим его код позже.
  4. Затем мы разрешаем наши зависимости с помощью TypeResolver, чтобы иметь возможность построить наш экземпляр BeanDefinition.
  5. Последнее, что нужно сделать, это написать файлы Java с определениями. Мы рассмотрим это на шаге 5.

Возвращаясь к TypeDefinitionResolver, приведенный ниже код показывает реализацию:

  1. ElementFilter, с которым мы уже знакомы, получает конструкторы элемента.
  2. Выполняется проверка, чтобы убедиться, что наш элемент имеет только один конструктор.
  3. Если есть один конструктор, мы следим за процессом.
  4. В случае, если их больше одного, компиляция завершается ошибкой. Вы можете увидеть реализацию метода failOnTooManyConstructors здесь.
    Единственный конструктор создает объект Dependency с элементом и его зависимостями. Он будет использоваться для написания фактического кода Java. Было бы полезно увидеть реализацию Dependency, поэтому взгляните:

Возможно, вы заметили странный тип TypeMirror. Он представляет тип на языке Java (буквально язык, так как это происходит во время компиляции).

Шаг 5. Написание определенийКак написать исходный код Java?

Для написания кода Java во время обработки аннотаций можно использовать практически что угодно. Все в порядке, если в итоге вы получите CharSequence/String/byte[].

В примерах в Интернете вы обнаружите, что использование StringBuffer популярно. Честно говоря, мне неудобно писать такой исходный код. Для нас есть лучшее решение.

JavaPoet — библиотека для написания исходного кода Java с использованием JavaAPI. Вы увидите его в действии в следующем разделе.

Отсутствует часть BeanProcessor

Вернемся к BeanProcessor. Некоторые части файла еще не были раскрыты. Вернемся к нему:

Написание выполняется в два этапа:

  1. DefinitionWriter создает BeanDefinition, и экземпляр JavaFile содержит его.
  2. Программист записывает реализацию в реальный файл, используя предоставленный через processingEnv экземпляр Filer. В случае сбоя записи компиляция завершится ошибкой, и компилятор выдаст сообщение об ошибке.

Filer – это интерфейс, поддерживающий создание файлов для процессора аннотаций. Место для хранения сгенерированных файлов настраивается с помощью флага -s javac. Однако в большинстве случаев инструменты сборки справляются с этим за вас. В этом случае файлы хранятся в каталоге, таком как build/generated/sources/annotationProcessor/java для Gradle или аналогичном для других инструментов.

Создание кода Java происходит в DefinitionWriter, и вы сразу увидите его реализацию. Однако вопрос в том, как выглядит такое определение. Я думаю, пример покажет это лучше всего.

Пример того, что должно быть написано

Для нижеприведенного компонента:

Определение должно выглядеть как код ниже:

Здесь четыре элемента:

  1. Неудобное имя, чтобы люди не могли использовать его напрямую. Класс должен реализовать BeanDefinition‹BeanType›.
  2. Поле типа ScopeProvider, отвечающее за создание экземпляра компонента и обеспечивающее его время жизни (область действия). Область Singleton — это единственная область действия, которую охватывает платформа, поэтому будет использоваться только метод ScopeProvider.singletonScope().
    Function‹BeanProvider, Bean› , используемый для создания экземпляра компонента, передается методу ScopeProvider.singletonScope.
    Я расскажу о реализации ScopeProvider позже. На данный момент достаточно знать, что это обеспечит только один экземпляр bean-компонента в нашем контексте DI.
    Однако, если вам интересно, исходный код доступен здесь.
  3. Фактический метод create использует провайдер и соединяет его с beanProvider через метод apply.
  4. Реализация метода типа — простая задача.

Пример показывает, что единственные вещи, специфичные для bean-компонента, — это тип, переданный в объявление BeanDefinition, новый вызов и поля/возвращаемые типы.

Реализация DefinitionWriter

Для краткости я опущу код приватных методов, конструктор и некоторые небольшие фрагменты. Давайте посмотрим обзор кода Java, который пишет код Java. Вот ссылка на полный код.

Фу, это много. Не бойся; это проще, чем кажется.

  1. Есть три поля экземпляра:
    - TypeElementdefinedClass - это наш bean-компонент,
    - List‹TypeMirror›structorParameterTypes содержит параметры для конструктора bean-компонента (кто бы мог подумать? , не так ли?),
    - Имя класса имя_определенного_класса – это объект JavaPoet, созданный на основе определенного класса. Он представляет собой полное имя для классов.
  2. TypeSpec — это класс JavaPoet, представляющий создание типов Java (классы и интерфейсы). Он создается с помощью статического метода classBuilder, в который мы передаем наше странное имя, созданное на основе фактического имени типа компонента.
  3. ParameterizedTypeName.get(ClassName.get(BeanDefinition.class),definedClassName) создает код, представляющий BeanDefinition‹BeanTypeName›, который применяется как суперинтерфейс нашего класса через метод addSuperinterface.
  4. Реализация метода create() не так сложна и не требует пояснений. Посмотрите на метод createMethodSpec() и его применение.
  5. То же самое относится к методу type(), что и к методу create().
  6. Метод scopeProvider() подобен предыдущим методам. Однако сложная часть заключается в вызове конструктора. singletonScopeInitializer() отвечает за создание вызова конструктора, заключенного в ScopeProvider.singletonScope(beanProvider -› …). Мы вызываем BeanProvider.provide для каждого параметра, чтобы получить зависимость и сохранить вызовы в порядке параметров конструктора.

Хорошо, BeanDefinition готовы. Теперь мы переходим к ScopeProvider.

Реализация ScopeProvider

  1. Вы можете увидеть запечатанное определение интерфейса, которое расширяет Function‹BeanProvider, T›. Таким образом, доступен метод Function.apply().
  2. Фабричный метод для SingletonProvider
  3. Реализация SingletonScope основана на любой реализации отложенных значений в Java. В синхронизированном методе применения мы создаем экземпляр нашего компонента, только если его нет. Поле значения помечено как изменчивое, чтобы предотвратить проблемы в многопоточной среде.

Теперь мы готовы. Настало время исполняющей части фреймворка.

Шаг 6 — подготовка bean-компонентов во время выполнения

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

BaseBeanProvider должен иметь доступ ко всем экземплярам BeanDefinition. Это связано с тем, что BaseBeanProvider не должен нести ответственность за создание и предоставление компонентов.

Фабрика BeanProviderFactory

В связи с упомянутым фактом BeanProviderFactory взял на себя ответственность через статический метод BeanProvider getInstance(String… packages). Где параметр packages определяет места для поиска BeanDefinition, присутствующих в пути к классам. Это код:

  1. Метод отвечает за получение экземпляра BeanProvider.
  2. Вот где становится интересно. Я определяю константу TYPE_QUERY с очень конкретным типом из Библиотеки отражений. Проект README.md определяет его как:
    Reflections сканирует и индексирует метаданные пути к классам вашего проекта, позволяя выполнять обратный транзитивный запрос системы типов во время выполнения.
    Я рекомендую вам читайте об этом подробнее, но я просто объясню, как это используется в коде. Определенная QueryFunction будет использоваться для сканирования пути к классам во время выполнения, чтобы найти все подтипы BeanDefinition.
  3. Конфигурация создается для объекта Reflections. Он будет использоваться в следующей части кода.
  4. Конфигурация определяется параметрами и фильтром пакетов, которые BeanProviderFactory будут сканировать пакет io.jd и переданные пакеты. Благодаря этому фреймворк предоставляет bean-компоненты только из ожидаемых пакетов.
  5. Объект Reflections создан. Он будет отвечать за выполнение нашего запроса позже в коде.
  6. Объект отражений выполняет TYPE_QUERY. Он создаст все экземпляры BeanDefinition с помощью статического BeanDefinition‹?› getInstance(Class‹?› e).
  7. Метод, создающий экземпляры BeanDefinition, использует отражение. При возникновении исключения код заключает его в пользовательское RuntimeException. Код пользовательского исключения находится здесь.
  8. Экземпляр интерфейса BeanProvider в виде экземпляра BaseBeanProvider, источник которого будет представлен в следующих нескольких абзацах.

BaseBeanProvider Итак, как реализован BaseBeanProvider? Это легко принять. Исходный код в репозитории очень похож, но (Осторожно, спойлер!) изменен на обработку @Transactional в следующей статье.

  1. provideAll(Class‹T› beanType) берет все BeanDefinition и находит все методы type(), которые возвращают Class‹? ›, который является подтипом или точно предоставленным beanType. Благодаря этому он может собрать все подходящие бобы.
  2. обеспечить(Class‹T› beanType) также просто. Он повторно использует метод provideAll , а затем берет все подходящие компоненты.
  3. Фрагмент кода проверяет, существует ли какой-либо bean-компонент, соответствующий beanType, и выдает исключение, если нет.
  4. Фрагмент кода проверяет, соответствует ли beanType более одного компонента, и выдает исключение, если да.
  5. Если есть только один соответствующий bean-компонент, он возвращается.

Вот оно!

Мы получили все части. Теперь мы должны проверить, работает ли код.

Мы что-то пропустили?

Не стоило ли начать с тестов процессора аннотаций? Как можно протестировать процессор аннотаций?

Тестирование процессора аннотаций

Процессор аннотаций довольно плохо подготовлен к тестированию. Один из способов проверить это — создать отдельный проект/подмодуль Gradle или Maven. Затем он будет использовать процессор аннотаций, и сбой компиляции будет означать, что что-то не так. Звучит не очень, верно?

Другой вариант — использовать библиотеку компиляция-тестирование, созданную Google. Это упрощает процесс тестирования, хотя инструмент не идеален. Пожалуйста, найдите руководство по его использованию здесь.

Я представил оба подхода в репозитории статьи. Модуль компиляции-тестирования использовался для «модульных тестов», а модуль integrationTest — для «интеграционных тестов».

Вы можете найти тестовую реализацию и конфигурацию в файлах подпроекта framework ниже:

  1. сборка.градле
  2. тестовый каталог
  3. интеграцияТестовый каталог

Шаг 7. Рабочая структура

Вначале было NoFrameworkApp:

Если main запущен, мы напечатаем три строки:

Это выглядит так с FrameworkApp:

Однако, чтобы заставить его работать, мы должны добавить @Singleton здесь и там. Пожалуйста, обратитесь к исходному коду в директории. Если мы запустим этот main, мы получим тот же результат:

Поэтому мы можем назвать это успехом. Платформа работает просто великолепно!

Что дальше?

Как только вы проверили результат выполнения кода из предыдущего абзаца, вы увидели дополнительные сообщения. Они касаются начала и совершения сделки.

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