30.11.2019

Fuga — маленький универсальный фреймворк для Java

Fuga — универсальный Java фреймворк приложений. Вообще говоря, здесь на хабре уже была моя статья с подобным заголовком, в которой рассказывалось о веб-фреймворке для Java с идиентичным названием. Хоть я и являюсь создателем обоих фреймворков, но «вторая» версия имеет мало общего с оригиналом и является чем-то вроде переосмыслением прежних принципов построения приложений.

В Fuga имеется более продвинутый IoC-контейнер, который дает возможность разбить один монолит с жесткой навязываемой архитектурой на отдельные компоненты. Это позволяет как минимум использовать этот фреймворк не только для классических веб-приложений, но и для создания более сложных бекендов, микросервисов, десктопных и других приложений и библиотек. Дополнительно в состав фреймворка будут входить несколько тесно интегрированных вспомогательных компонентов, упрощающие процесс настройки, логирования, взаимодействия с другими приложениями и возможно что-то еще.

Пойдем по порядку и рассмотрим каждую часть фреймворка подробно.


IoC-контейнер

Основой всех будущих приложений, использующих данный фреймворк, является IoC-контейнер. Эта штука решает одну очень важную задачу — управляет жизненным циклом наших компонентов и устанавливает связи (зависимости) между ними. Иначе говоря, это освобождает нас от использования оператора new. Однако в данной реализации контейнера есть одна очень важная особенность — все компоненты приложения должны быть явно указаны.

Одна их основных идей фреймворка — никакой «магии». При разработке приложения не должно быть сюрпризов, неочевидного или неопределенного поведения. Все должно быть явно и интуитивно понятно. Отсюда вытекает и отрицательная сторона этого принципа. Если нет «магии», тогда нужно делать все самому. Таким образом, при создании приложения разработчику нужно явно указывать, из чего состоит его приложение и какого поведения он хочет добиться. Грубо говоря, для классического «Hello world!» приложения может понадобится гораздо больше строчек кода, чем если бы мы делали это используя другие популярные фреймворки. Однако мы же не "хэллоуворлды" пишем, верно?

Итак, в чем же соль этого контейнера? На самом деле все просто. Существует интерфейс Injector, являющийся реальным воплощением IoC-контейнера. Для его реализации и наполнения компонентами необходимо реализовать другой интерфейс, который и будет содержать всю нашу «начинку»:

public interface Unit {
  void setup(Configuration c);
}

Реализуя этот интерфейс, мы с помощью Configuration указываем все используемые компоненты. Это делается вызовом метода bind():

c.bind(ServiceA.class).auto();
c.bind(ServiceB.class).auto();

Юниты могут «устанавливать» в себя другие юниты:

c.install(new ServicesUnit());
c.install(new ApiUnit());

Если мы используем в приложении интерфейсы, то можно добавить нужный биндинг (связь) с помощью метода to():

c.bind(ServiceA.class).to(ServiceAImpl.class);
c.bind(ServiceB.class).to(ServiceBImpl.class);

c.bind(ServiceAImpl.class).auto();
c.bind(ServiceBImpl.class).auto();

В приложениях редко когда встретишь «чистые» компоненты, которые никогда ни от чего не зависят. В нашем случае зависимости указываются в конструкторе нужного нам класса:

class ServiceAImpl {
  @Inject public ServiceAImpl(ServiceB serviceB, ServiceC serviceC) {
    // constructor body
  }
}

Здесь мы пометили наш конструктор аннотацией @Inject, который указывает контейнеру, что смотреть зависимости и создавать экземпляр класса нужно используя этот конструктор.

Скорее всего вы задались вопросом, что это за «магический» метод auto() и для чего он нужен? Ответ простой. Эта команда попытается создать необходимый биндинг автоматически исходя из типа класса и его аннотаций. Вот как раз эта самая команда и избавляет нас от «магии» в том смысле, что мы обязаны явно указывать какой класс содержится в приложении и каким способом нужно создавать его экземпляры. Самый простой и распространенный путь, это аннотация @Inject у соответствующего конструктора. Но существуют и другие способы, такие как аннотации @ProvidedBy и @ComposedBy, а также использование мета-аннотаций для упрощения реализации и создания однотипных интерфейсов. Эти конструкции были в первую очередь созданы для внутренних модулей фреймворка, но возможно они могут пригодиться для других особых случаев, например, автогенерация RPC.

Кроме auto() можно использовать другие методы для создания «более явных» биндингов. Метод bind() возвращает экземпляр BindingBuilder, который имеет много полезных методов для создания биндингов. Вот краткий список некоторых из них:

auto();
to(Class<? extends T> target);
toInstance(T instance);
toConstructor(Constructor<T> constructor);
toProvider(Provider<? extends T> provider);
toProvider(Class<? extends Provider<? extends T>> provider);
toComposer(Composer composer);
toComposer(Class<? extends Composer> composer);

C auto() и to() мы разобрались. Метод toInstance() создает биндинг на заранее созданный экземпляр класса, а toConstructor() на конструктор класса, по которому нужно учитывать зависимости и создавать экземпляр необходимого класса. Следующая четверка методов используют интерфейсы Provider и Composer. Первый является стандартным провайдером, имеющий простой интерфейс с одним методом T get(), а второй является более расширенным вариантом. Composer для создания экземпляра класса требует тип запрашивателя и тип запрашиваемого класса, а именно:

public interface Composer {
  <T> T get(Key<T> requester, Key<T> requiredClass) throws ProvisionException;
}

Такая штука является менее типобезопасной, но открывает возможность динамической реализации интерфейсов, например, с помощью java.lang.reflect.Proxy.

По умолчанию при запросе зависимости инжектор будет каждый раз создавать новый экземпляр класса. Это поведение можно настроить с помощью областей видимости (Scopes). При написании биндингов можно явно указать, в какой области видимости он будет доступен. В состав Fuga идет только одна реализация — Singleton. Из названия сразу понятно, что такая реализация представляет одну глобальную область видимости, которая при соответствующем запросе заставит инжектор создать экземпляр класса только один раз. Указать нужную область видимости можно методом in()

c.bind(ServiceA.class).auto().in(Singleton.class);

Если есть необходимость создать свою область видимости, то необходимо создать аннотацию:

@Target(value = {CONSTRUCTOR})
@Retention(value = RUNTIME)
@ScopeAnnotation
public @interface Custom {
}

реализовать соответствующий интерфейс:

class CustomScope implements Scope {
  <T> Provider<T> scope(Key<T> key, Provider<T> provider) { ... }
}

и зарегистрировать его в родительском инжекторе:

c.bindScope(Custom.class, new CustomScope());

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

Создать инжектор-потомка можно с помощью соответствующего метода:

var childInjector = injector.createChildInjector(units);

Запуск приложения

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

FugaBoot.start(c -> {
  // unit body
});

Внутри метода start() происходит создание контекста приложения, конфигурирование юнитов, вызов всех обработчиков событий и создание конечного инжектора.

При необходимости, контекст можно создать вручную:

  ApplicationContext.fromUnits(units);

Если требуется создать только инжектор, то можно воспользоваться соответствующим билдером:

var injector = new InjectorBuilder()
  .withUnits(units)
  .build();

Интерфейс Injector имеет необходимые методы для получения биндингов и экземпляров классов. В их числе:

getBinding(Class<T> type);
getInstance(Class<T> type);

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

class App {
  public static void main(String[] args) {
        new App().start();
  }

  public void start() {
    FugaBoot.start(this::mainUnit);
  }

  private void mainUnit(Configuration c) {
    c.install(this::settingsUnit)
    c.install(this::servicesUnit)
    c.install(this::apiServerUnit)
  }

  private void settingsUnit(Configuration c) {
    // bindings here
  }

  private void servicesUnit(Configuration c) {
    // bindings here
  }

  private void apiServerUnit(Configuration c) {
    // bindings here
  }
}

Настройки

Любое серьезное приложение требует настройки с помощью внешних файлов и всегда появляются большие проблемы, когда эти настройки нужно загрузить, проверить и распространить по всему приложению. Fuga предлагает простой и удобный способ решения этой проблемы. Наличие в стандартной библиотеке Java класса Proxy позволяет на лету создавать реализацию интерфейса прямо в рантайме. Такой подход не новинка, но в данном случае он плотно интегрирован в IoC-контейнер, что позволяет облегчить разработчику жизнь и не думать о NullPointerException.

Рассмотрим пример. Допустим нам нужны настройки для простейшего HTTP сервера:

host: 'example.com'
port: 80

Все что мы должны, это написать интерфейс-представитель и пометить его аннотацией @Settings:

@Settings("http")
public HttpServerSettings {
  String host();
  int port();
}

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

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

c.install(new SettingsUnitBuilder()
  .withSource(new LocalFilesSettingsSource("./"))
  .build());

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

c.bind(HttpServerSettings.class).auto();

Готово! Теперь наше приложение будет при старте загружать настройки из файла settings.yaml, которые можно безопасно использовать в виде обычной зависимости в любом месте и в любое время.

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

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


Логирование

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

Fuga использует популярную библиотеку SLF4J в качестве фасада для внутренних систем протоколирования. Это позволяет не ограничиваться одним фреймворком и использовать любой другой, который имеет нужный бридж под SLF4J.

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

c.install(new LoggingUnitBuilder()
  .build());

Теперь мы в любом классе в качестве зависимости можем указать Logger и он будет автоматически создан используя LoggerFactory с соответстувующим именем:

class TestService {
  @Inject public TestService(Logger logger) {
    logger.info("Hello world!");
  }
}

Планы

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


  • В юниты планируется добавить метод extend(). В отличии от install(), он будет создавать новый инжектор. Это упростит создание переиспользуемых юнитов, которые будут иметь в составе классы с неудовлетворенными зависимостями. Таким образом, если в юните имеется, допустим, сервис требующий интерфейс ServiceA, то эту зависимость мы можем покрыть вышестоящим инжектором, а сам юнит распростронять в виде библиотеки.
  • Для модуля настроек есть необходимость добавить кроме возможности загружать настройки из локальных файлов в формате Yaml и Json, загружать в других популярных форматах и из других источников (HTTP, Git, Consul, Etcd и др.). Также из-за использования древовидных структур требуется серьезное тестирование.
  • Логгирование необходимо сделать более универсальным для тех случаев, когда какая-то библиотека или подсистема использует другой популярный логгер.
  • Доработать контекст приложения и систему событий. На данный момент она совершенно непригодна к использованию.
  • Пополнить состав фреймворка другими полезными компонентами: HTTP/Websocket, Classic MVC, RPC и др.

Заключение

Fuga — мой очередной пет-проект, который я использую исключительно для себя, а эта статья является неким чекпоинтом и возможностью получить фидбек. Конечно, хоть это и велосипед, но также очень хочется найти заинтересованных людей.

Один из моих проектов, который использует этот фреймворк и я могу представить как пример, это сервер автоматизации стримов и чатбот OverStream. Это позволило испытать свой велосипед на себе и получить огромный опыт.

Кстати, скорее всего многие при прочтении раздела «IoC-контейнер» подумали «да это же вылитый Guice!» и будет прав. Вдохновение пришло именно после использования этого DI-контейнера, но реализация контейнера Fuga совершенно своя и имеет свои особенности. К тому же, изучение исходников такого большого проекта как Guice оказалось увлекательным занятием, которое принесло много полезного опыта.


Let's block ads! (Why?)



Комментарии