[TUTORIAL] Dependency Injection в плагинах (при помощи Google Guice)

Reality_SC

Старожил
Пользователь
Баллы
123
Имя в Minecraft
Reality_SC
Спустя лет 5 решил стряхнуть пыль с майнкрафта и написать для себя простенький плагин. Но опыт кровавого энтерпрайза прочно уселся в голове и руках, и первая же попытка написать плагин без DI начала коробить. Пришлось целых 5 минут пользоваться гуглом и читать местами устаревшие туториалы, чтобы собрать что-то годное. К моему удивлению, на РуБакките не нашлось перевода данной статьи, но, в общем-то, оно настолько просто, что я решил самостоятельно поделиться этим с вами.

Зачем это нужно?

Простейший плагин — чаще всего один-два-три класса, сам класс MyPluginMain (extends JavaPlugin), и пусть будет ещё, например, MyCommandHandler (implements CommandExecutor) и MyListener (implements Listener). В таком случае чаще всего пользы от DI не будет видно — в MyPluginMain в методе onEnable создаются два других объекта, а в них есть ссылка на объект плагина. Либо, как вариант, в MyPluginMain есть статическая ссылка на него, а из других классов обращаются к ней (напрямую или через геттер).

Но стоит плагину немного начать разрастаться, хотя бы ещё на пару классов с логикой, и мы начинаем отчётливо видеть ад: MyCommandHandler и MyListener нужен не только плагин, но и два других класса. Двум другим классам нужен MyListener. Первому классу с логикой нужен второй, а второму — первый. А ещё в MyListener зачем-то понадобился MyCommandHandler ¯\_(ツ)_/¯. Ну, я постарался привести максимум треша для примера. Самое забавное — почти никто из них не нужен в основном классе плагина, но они все создаются в его onEnable, сохраняются в его полях или в локальных переменных метода, передаются друг другу в конструкторы, сеттеры, или, не дай бог, в публичные (и даже статические!) поля.

DI как раз решает эту проблему. У нас появляется контекст, в который кладутся все необходимые для работы плагина классы. Каждый класс объявляет, что ему нужно нужно, чтобы выполнять свою роль — свои зависимости. Объявляет он их через конструктор/сеттеры/поля, и DI сам ему их предоставит. Ещё раз, это важный момент — ни один класс не должен куда-то обращаться, чтобы получить необходимую зависимость — ему её предоставят. Когда вы напишете свой класс в таком ключе, он станет более независимым от других классов, и его проще будет перенести в другой проект. Вы возразите — Но ведь ему всё равно часто нужен MyPluginMain, а его нет в другом плагине! — Да, но от MyPluginMain нам чаще всего нужен только getLogger(), getServer(), getConfig() и прочее, что есть в базовом классе JavaPlugin.

Приступим!

Я буду пользоваться lombok, потому что я не люблю писать бойлерплейт-код сам.

Добавляю в pom.xml плагина зависимость Google Guice:
Код:
<dependency>
    <groupId>com.google.inject</groupId>
    <artifactId>guice</artifactId>
    <version>5.0.1</version>
</dependency>

Пишем основной класс плагина:
Код:
public final class MyPluginMain extends JavaPlugin {

    @Inject private MyListener myListener;
    @Inject private MyCommands myCommands;

    @Override
    public void onEnable() {
        // Dependency Injection.
        MyBinderModule module = new MyBinderModule(this);
        Injector injector = module.createInjector();
        injector.injectMembers(this);

        // Регистрируем обработчик команд.
        getServer().getCommand("my-plugin").setExecutor(myCommand);

        // Регистрируем обработчик событий.
        getServer().getPluginManager().registerEvents(myListener, this);
    }

    @Override
    public void onDisable() {
        getServer().getServicesManager().unregisterAll(this);
        getServer().getScheduler().cancelTasks(this);
    }

    public void reload() {
        PluginManager pluginManager = getServer().getPluginManager();
        pluginManager.disablePlugin(this);
        pluginManager.enablePlugin(this);
    }
}

Смотрим код магического класса MyBinderModule:
Код:
@RequiredArgsConstructor
public class MyBinderModule extends AbstractModule {

    private final MyPluginMain plugin;

    public Injector createInjector() {
        return Guice.createInjector(this);
    }

    @Override
    protected void configure() {
        // Всегда возвращать уже созданный экземпляр.
        bind(MyPluginMain.class) .toInstance(plugin);
    }
}

Ну тогда уж и двое остальных:
Код:
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class MyCommands implements CommandExecutor {

    private final MyPluginMain plugin;
    private final MyListener listener; // Он тут не нужен, но я привёл его для примера.

    @Override
    public boolean onCommand(
            @NotNull CommandSender sender,
            @NotNull Command command,
            @NotNull String label,
            @NotNull String[] args
    ) {
        try {
            // Как мы сюда вообще попали?
            if (!"my-plugin".equals(command.getName())) {
                return false;
            }

            // Ты кто такой, давай до свидания!
            if (!sender.hasPermission("my-plugin.reload")) {
                sender.sendMessage("Нет!");
                return true;
            }

            plugin.reload();
            sender.sendMessage("Плагин перезагружен.");
            return true;
        } catch (Exception ex) {
            plugin.getLogger().severe("Exception in command processing: " + ex.getMessage());
            return true;
        }
    }
}
Код:
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class MyListener implements Listener {

    private final MyCommand myCommand; // Не нужен, добавлен для примера.

    @EventHandler
    protected void onPlayerJoin(PlayerJoinEvent event) {
        event.getPlayer().sendMessage("Хай!");
    }
}

Разберёмся, что тут происходит.

На первых трёх строках в onEnable() мы создаём новый контекст. Сразу передаём в него this, чтобы все классы, которым в будущем нужен будет наш плагин, получили уже существующий объект.
Далее, мы просим некий "инжектор" проинжектировать в наш плагин зависимости — это поля, помеченные аннотацией @Inject. Он сам их найдёт и попытается создать нужные объекты, MyCommand и MyListener. Из-за того, что наш объект плагина был создан сервером, а не нами, мы не можем выполнить инжекцию через конструктор, поэтому пользуемся аннотированными полями (можно было использовать и сеттеры, но я не увидел смысла). Создавая объекты, которые нужно подложить нам в поля, Guice их одновременно кладёт в и контекст, откуда они будут возвращены при необходимости другим классам.

У остальных классов я указал @RAC с отдельным указанием поместить на сгенерированный конструктор аннотацию @Inject. Когда Guice требуется создать какой-то класс, он видим, что у него есть такой помеченный конструктор, и передаёт в него все нужные зависимости, попутно создавая и их тоже.

Дополнительная инфа.

В методе configure() класса MyBinderModule можно заранее "закинуть" в контекст некоторые объекты, которые в дальнейшем пригодится использовать (инжектировать в другие классы), например:
Код:
// Закинем в контекст Gson.
Gson gson = new GsonBuilder()
        .setPrettyPrinting()
        .create();
bind(Gson.class).toInstance(gson);

// Закинем в контекст HTTP-клиент.
OkHttpClient okHttpClient = new OkHttpClient.Builder()
        .connectTimeout(Duration.ofSeconds(2))
        .readTimeout(Duration.ofSeconds(5))
        .callTimeout(Duration.ofSeconds(10))
        .build();
bind(OkHttpClient.class).toInstance(okHttpClient);
Ссылка на полную документацию Google Guice.

P. S. Не забудьте, что Guice — рантаймовая зависимость, и его нужно либо указать в plugin.yml (см. ниже спойлер), либо зашейдить в .jar!
upload_2021-8-2_12-36-50.png

Вот, как-то так! Всем успешного плагино-писания!
 
Последнее редактирование:
К моему удивлению, на РуБакките не нашлось перевода данной статьи
По-моему, как раз ничего удивительного - на РуБакките и с обычным DI как паттерном частенько не дружат - нередко приходят с одной и той же проблемой, а-ля "как использовать X в другом классе".
Для туториала бы не использовать ломбок, ибо может вызвать конфуз у непросвещенных. Впрочем, любые туториалы здесь - любо.
 
Так-то ничего удивительного. Нормально использовать DI можно только если всё приложение реально находится в IoC контейнере, как spring например. А вкручивание контейнера в отдельный плагин всегда приводит к тому что на стыке всегда возникают какие-нибудь проблемы. В итоге проще генерировать все эти конструкторы в IDE а потом писать бойлерпейт инициализации.
 
Так-то ничего удивительного. Нормально использовать DI можно только если всё приложение реально находится в IoC контейнере, как spring например. А вкручивание контейнера в отдельный плагин всегда приводит к тому что на стыке всегда возникают какие-нибудь проблемы. В итоге проще генерировать все эти конструкторы в IDE а потом писать бойлерпейт инициализации.
Я так и начал, но на 5-м компоненте уже наплевался :)
Пока особенных проблем не встретил, кроме того, что Guice автоматически генерирует java.util.Logger для каждого класса, и пробросить "плагиновский" уже не получается — или через кваливаеры (не пробовал, к слову), или просто инжектить плагин и брать нужный логгер всегда из него.
 
Назад
Верх