Разработка Java-серверов на Paper · 6 мин чтения

Асинхронность Paper: снимок данных, ожидание и актуальный результат

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

Редакция LonelyCraft · Обновлено

Схема: Снимок → Фон → Возврат
Снимок данных, ограниченное ожидание и применение только в актуальном игровом контексте. Авторская схема LonelyCraft.

Подготовьте файл и команду

Для опыта с чтением файла возьмите Gradle-проект из первого Paper-плагина: Paper 1.21.1, JDK 21, пакет ru.example.trailnotes. Код ниже заменяет весь TrailNotesPlugin.java; регистрацию команды /note добавьте в plugin.yml перед сборкой.

В src/main/resources/plugin.yml добавьте секцию commands: с note: и description: Прочитать заметку. На тестовом сервере после первого старта создайте plugins/TrailNotes/note.txt с одной короткой строкой UTF-8. Не подставляйте в пример URL: сетевой запрос дополнительно требует тайм-аута и лимита размера ответа. Правила потоков смотрите в Paper Scheduling.

Прочитайте файл и примените результат в основном потоке

Класс ниже целиком заменяет главный файл. UUID и номер запроса захватываются до фоновой задачи; Player ищется только внутри runTask. Счётчик удаляется при выходе, поэтому новый сеанс не получит прежний ответ. Файл ограничен 4 КиБ: большая загрузка не входит в учебный пример.

package ru.example.trailnotes;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;

public final class TrailNotesPlugin extends JavaPlugin implements Listener {
    private final Map<UUID, Long> requests = new HashMap<>();
    private long sequence;

    @Override public void onEnable() {
        getDataFolder().mkdirs();
        getServer().getPluginManager().registerEvents(this, this);
    }
    @EventHandler public void onQuit(PlayerQuitEvent e) {
        requests.remove(e.getPlayer().getUniqueId());
    }
    @Override public boolean onCommand(CommandSender sender, Command cmd,
                                        String label, String[] args) {
        if (!(sender instanceof Player player)) {
            sender.sendMessage("Нужен игрок.");
            return true;
        }
        UUID uuid = player.getUniqueId();
        long request = ++sequence;
        requests.put(uuid, request);
        Path path = getDataFolder().toPath().resolve("note.txt");
        getServer().getScheduler().runTaskAsynchronously(this, () -> {
            String message;
            try {
                if (Files.size(path) > 4096) throw new java.io.IOException("Слишком большой файл");
                message = Files.readString(path, StandardCharsets.UTF_8);
            } catch (Exception error) {
                message = "Не удалось прочитать note.txt";
            }
            String result = message;
            if (!isEnabled()) return;
            try {
                getServer().getScheduler().runTask(this, () -> {
                    if (!Long.valueOf(request).equals(requests.get(uuid))) return;
                    Player current = getServer().getPlayer(uuid);
                    requests.remove(uuid);
                    if (current != null) current.sendMessage(result);
                });
            } catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
                // Плагин отключился до постановки результата.
            }
        });
        return true;
    }
    @Override public void onDisable() {
        requests.clear();
        getServer().getScheduler().cancelTasks(this);
    }
}

Проверьте наблюдаемый результат

Соберите JAR и установите на остановленный тестовый Paper, как в первом проекте. Выполните /note: в чат должна прийти строка из файла. Удалите note.txt и повторите команду: появится короткий отказ, сервер продолжает работать. Два быстрых запроса оставляют только ответ актуального номера.

Для сценария выхода попробуйте /note и сразу отключитесь. Сохранение UUID не означает право писать старому объекту Player; при возвращении прежний номер уже удалён. При отключении плагина новые игровые действия не выполняются, даже если дисковая операция успела закончиться.

Ограничьте поток работ при расширении

Пример читает маленький локальный файл, поэтому для публичной команды добавьте кулдаун или один запрос на игрока и общий предел очереди. Не запускайте столько фоновых задач, сколько раз игрок способен нажать кнопку. SQL/HTTP требуют тайм-аутов, ограниченных соединений и обработки отказа.

Для игрового тика используйте runTaskLater/runTaskTimer с задержкой в тиках; для календарного срока сравнивайте реальные timestamps. Не вызывайте Thread.sleep в основном потоке и не используйте join для ожидания фонового ответа: оба действия возвращают блокировку, которую вы пытались убрать. Этот BukkitScheduler-пример относится к Paper, поддержку Folia нужно проектировать отдельно.

Сначала измерьте, что занимает игровой поток

Асинхронность полезна для ожидания внешнего ответа, но не является способом сделать любую операцию безопасной. Найдите длительный участок: SQL, HTTP, чтение большого файла или расчёт по уже снятым данным. Изменение блока и обращение к живой сущности оставляйте в документированном контексте API. Иначе устранение задержки создаст гонку состояния.

Нельзя просто обернуть весь обработчик клика в runTaskAsynchronously. Передача Player в лямбду сохраняет живой объект, который меняется и может исчезнуть. На игровом потоке сначала извлеките UUID, идентификатор меню, поколение сессии и нужные простые значения. Фоновая функция получает такой снимок и возвращает данные результата, а не управляет инвентарём.

Пример запроса статистики

Игрок открыл профиль, вы сохранили UUID и номер открытия 17. Фоновая задача читает показатели из БД. Когда ответ готов, вы планируете обработку в допустимом игровом контексте и проверяете: игрок онлайн, номер открытия всё ещё 17, это всё ещё нужное меню. Если человек уже открыл другой экран, ответ можно сохранить в кэше, но нельзя перерисовать им новое меню.

Если он вышел и вернулся, одного UUID недостаточно: новый вход может иметь другое поколение сессии. Проверка поколения защищает от ответа старого запроса. Она также помогает отменять логику при остановке плагина. Данные БД могут остаться полезными, однако действие над игроком допустимо только для актуального контекста.

Номер сессии и повторная проверка действия защищают GUI от запоздалого ответа. Актуальность открытого меню.

Ограничьте ожидание и число задач

Задайте таймаут HTTP, время получения соединения БД и предел очереди. Если внешний сервис молчит, задача должна завершить ожидание, а интерфейс — показать понятную недоступность. Бесконечное ожидание в фоне не блокирует тик напрямую, но постепенно занимает весь пул и делает следующие операции недоступными.

Не вызывайте join или get ожидающей future на игровом потоке: этим вы вернёте блокировку туда, откуда хотели её убрать. Используйте продолжение результата и отдельный планировщик для применения. При этом помните, что callback обычно выполняется в потоке завершения, а не автоматически в основном серверном потоке.

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

  1. Снимите неизменяемые данные и поколение операции.
  2. Проверьте лимит одновременных запросов.
  3. Выполните внешнюю работу с таймаутом.
  4. Верните результат в документированный игровой контекст.
  5. Повторно проверьте текущую сессию и право действия.
  6. Примените результат либо отбросьте устаревший ответ.

Два вида времени

Планировщик Paper измеряет задержки в тиках; при перегрузке реальная длительность увеличивается. Кулдаун внешнего запроса или срок подтверждения платежа часто должен использовать время отдельно от игровых тиков. Определите, какая семантика нужна: «через 100 обработанных тиков» и «через пять реальных секунд» не всегда совпадают.

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

Каждый callback должен относиться к определённой сессии и допустимому состоянию. Таймеры раунда.

Завершение плагина и тесты задержек

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

Проверьте медленный ответ, ошибку, тайм-аут, выход игрока, повторное открытие меню и выключение плагина. Для фоновой части удобны контролируемые future и тестовые данные. Для границы с игровым API нужен серверный сценарий, подтверждающий, что доступ к миру возвращён в правильный контекст. Совместимость с Folia проектируйте отдельно по её планировщикам.

Частые вопросы

Можно ли читать Player в любом фоне?

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

Почему CompletableFuture всё равно тормозит тик?

Если вы вызываете join или get на игровом потоке, он ждёт результат. Нужна неблокирующая цепочка и явное планирование применения ответа.

Достаточно проверить UUID после ответа?

Нет, тот же аккаунт мог выйти и войти заново. Сравнивайте поколение сессии или конкретного открытия интерфейса.

Почему обычный scheduler не гарантирует Folia?

Folia имеет другую модель владения игровым контекстом. Совместимость требует соответствующих планировщиков и отдельной проверки API.

Просмотры: … · Один просмотр с браузера за день

Было полезно?Общий счёт: …

Одна реакция с браузера. Нажмите повторно, чтобы снять её, или выберите другую.

Источники и документация

Minecraftразработка плагиновPaper

Читайте дальше