Skip to content

Архитектура

Вы собрали и загрузили дополнение в разделе Начало работы. Эта страница — карта. Она объясняет, что на самом деле произошло: что внутри этого одного jar (jar — это просто zip-архив скомпилированного кода и ресурсов, вы можете открыть его любым архиватором), что Pano делает при его загрузке и где оказывается каждый написанный вами файл, пока дополнение работает.

К концу вы сможете объяснить полный жизненный цикл загрузки, поймёте, почему нельзя добавлять svelte как зависимость, и сможете указать, где живёт каждый файл репозитория, когда дополнение работает. Попутно блоки «Убедитесь сами» дают вам что-то распаковать, открыть или кликнуть, чтобы идеи были не просто словами.

Один jar, три среды выполнения

Дополнение поставляется как единственный jar, но код внутри него выполняется в трёх разных местах. Вот вся картина одним взглядом:

text
                        ┌──────────────────────┐
                        │  your addon = 1 jar   │
                        └───────────┬──────────┘
          ┌─────────────────────────┼─────────────────────────┐
          ▼                         ▼                         ▼
     Browser                  Node (theme + panel)       JVM (Pano server)
     client bundle            server bundle              Kotlin backend
     the UI users click       renders your UI to HTML    endpoints, DB, events
  1. Бэкенд на Kotlin — загружается PF4J, загрузчиком плагинов, который использует Pano: он находит ваш jar в папке plugins и загружает его классы в работающий сервер Pano (JVM). Это ваши эндпоинты, таблицы базы данных, разрешения и слушатели событий. Он выполняется в том же процессе, что и сам Pano.
  2. Клиентский бандл Svelte — бандл здесь просто означает ваши .svelte-файлы, скомпилированные в один .mjs-файл. Он выполняется в браузере посетителя, внутри и панели, и активной темы. Это UI, по которому пользователь реально кликает.
  3. Серверный бандл Svelte — тот же UI, скомпилированный так, чтобы тема и панель (которые являются процессами Node) могли отрисовать ваши компоненты в HTML до того, как браузер вообще их увидит, — это отрисовка на стороне сервера (SSR). Затем браузер «гидрирует» этот HTML: он повторно запускает те же компоненты и присоединяется к разметке, которая уже на странице. Чтобы гидрация работала, обе стороны должны выполнять точно один и тот же Svelte — запомните это, это причина большого правила ближе к концу страницы.

Важная часть: существует один входной файл UI, src/main.js, и он обслуживает и панель, и тему. Вы не пишете два UI — вы пишете один и разветвляетесь по тому, где он выполняется.

Это метод класса, который вы экспортируете по умолчанию из src/main.js (тот же класс, что вы видели в разделе «Начало работы»). Pano вызывает его за вас, когда UI загружается — смотрите Что происходит при загрузке UI ниже — и передаёт ему объект pano с флагом isPanel:

js
onLoad() {
  const { pano } = this;
  if (pano.isPanel) {
    // panel registrations
  } else {
    // theme registrations
  }
}

Итак, jar несёт бэкенд, который выполняется в JVM Pano, и UI, который выполняется в двух контекстах Node/браузера, — всё собрано из одной папки исходных файлов. Остальная часть страницы прослеживает каждый из них до места, где он живёт во время выполнения.

Где оказывается каждый файл

Вот репозиторий дополнения Shoutbox с пометками о том, чем каждая часть становится во время выполнения. Названия папок — это соглашения, но разделение между src/ (UI) и src/main/ (бэкенд + ресурсы) фиксировано.

text
pano-plugin-shoutbox/
├─ build.gradle.kts             # Gradle build
├─ gradle.properties            # the addon's metadata (explained below)
├─ package.json                 # UI dependencies (never lists svelte — see below)
├─ rollup.config.js             # builds the Svelte UI
├─ src/
│  ├─ main.js                   # the single UI entry (panel + theme)
│  ├─ panel/                    # Svelte components shown in the panel
│  │  └─ ShoutboxSettings.svelte
│  ├─ theme/                    # Svelte components shown in the theme
│  │  └─ ShoutboxWidget.svelte
│  └─ main/
│     ├─ kotlin/com/panomc/plugins/shoutbox/
│     │  ├─ ShoutboxPlugin.kt   # your PanoPlugin subclass — the entry point
│     │  ├─ config/             # ShoutboxConfig
│     │  ├─ db/
│     │  │  ├─ dao/             # ShoutDao (abstract): one class per table, declares its queries
│     │  │  ├─ impl/            # ShoutDaoImpl — carries the @Dao annotation, runs the dao's queries
│     │  │  ├─ model/           # Shout entity
│     │  │  └─ migration/       # DatabaseMigration classes (see note under the tree)
│     │  ├─ routes/
│     │  │  ├─ api/             # public endpoints (GetShoutsAPI)
│     │  │  └─ panel/           # panel endpoints (PanelApi)
│     │  ├─ event/              # event listeners
│     │  ├─ permission/         # ManageShoutboxPermission
│     │  └─ log/                # CreatedShoutLog
│     └─ resources/
│        ├─ config.conf         # default config
│        ├─ logo.png            # addon icon shown in the panel
│        ├─ locales/            # en-US.json, tr.json, ru.json
│        └─ plugin-ui/          # generated by the build — never edit by hand (gitignored)

Два названия папок выше заслуживают простого пояснения:

  • Разделение между dao/ и impl/: класс dao/ абстрактный и только объявляет каждый запрос к базе данных; соответствующий класс impl/ — конкретный: это класс, который несёт аннотацию @Dao и содержит код, который реально выполняет запросы.
  • migration/ — миграция это небольшой класс, который обновляет таблицы или конфигурацию существующей установки, когда новая версия вашего дополнения меняет их форму (например, добавляет столбец). Она выполняется один раз, только на установках, которые отстают.

Два правила для чтения остальной части дерева:

  • Всё под src/main/kotlin и src/main/resources — это содержимое бэкенд-jar. Kotlin компилируется в классы; ресурсы (конфигурация, изображения, локализации) копируются без изменений; и то, и другое попадает в jar.
  • Всё под src/, но вне src/main/ (main.js, panel/, theme/) — это исходники UI. При сборке компилятор превращает их в файлы, которые попадают внутрь ваших бэкенд-ресурсов:
Исходники UI (пишете вы)Скомпилированный вывод (пишет сборка)
src/main.js, src/panel/, src/theme/src/main/resources/plugin-ui/

Да — сборка записывает свой вывод обратно в ваше дерево исходников, внутрь src/main/resources/. Так и задумано: размещение скомпилированного UI под resources/ — это именно то, что заставляет его быть заархивированным и поставленным внутри того же jar. (Это также причина, почему plugin-ui/ в gitignore — он перегенерируется при каждой сборке, так что коммитить нечего.)

Каждая папка здесь необязательна, кроме входного класса

config/, db/, event/, permission/, log/ — это просто места, куда соглашение помещает вещи. Pano находит ваши классы по их аннотациям — аннотация это тег @Something, написанный над классом, и Pano сканирует ваши скомпилированные классы на эти теги — а не по их папке (об этом далее). У дополнения только с конфигурацией вроде pano-plugin-cookies почти нет этих папок; у полноценного CRUD-дополнения (create/read/update/delete — то есть у него есть таблицы базы данных и эндпоинты) вроде pano-plugin-announcement есть все они.

Убедитесь сами

Скопируйте ваш собранный *.jar и переименуйте копию в *.zip, затем откройте её (jar — это просто zip). Внутри вы должны найти plugin-ui.zip рядом с папкой locales/ и logo.png — этот один plugin-ui.zip — весь ваш скомпилированный UI, упакованный внутрь.

Что происходит при загрузке бэкенда

Если вы раньше писали плагины для Minecraft, обратите внимание, что здесь нет plugin.yml. В любом случае все метаданные — id, главный класс, требуемая версия Pano — живут в MANIFEST.MF jar-а (небольшом текстовом файле внутри каждого jar, описывающем его), написанном за вас из gradle.properties во время сборки. Смотрите Конфигурация манифеста, какой именно ключ соответствует какому атрибуту.

Убедитесь сами

Снова откройте собранный jar как zip и прочтите META-INF/MANIFEST.MF в текстовом редакторе — вы должны увидеть в нём id и главный класс вашего дополнения.

Когда Pano загружает ваш jar, PF4J находит главный класс, указанный в манифесте, создаёт его экземпляр и вызывает ваши хуки жизненного цикла по порядку:

text
jar load → onCreate() → onEnable() → onStart() → … running … → onStop() → onDisable() → onUninstall()
  • Все хуки — это suspend-функции (suspend — это маркер асинхронности в Kotlin — на практике он означает, что вы можете вызывать функции базы данных и сети Pano прямо внутри этих хуков) и все по умолчанию ничего не делают — вы переопределяете только нужные.
  • onStart() — место, где большинство дополнений выполняет свою настройку (инициализирует базу данных, загружает конфигурацию). Руководство Разработка бэкенда проходит канонический паттерн.
  • onUninstall() вызывается только когда владелец сайта удаляет дополнение в панели — не когда он просто отключает его. Здесь pano-plugin-shoutbox удалил бы свою таблицу shout из базы данных.

Убедитесь сами

Поместите строку logger.info(...) внутрь onStart(), пересоберите и перезагрузите дополнение — строка появится в консоли Pano. Это простейший способ увидеть, как жизненный цикл реально срабатывает.

Ваши классы находятся автоматически

Когда ваше дополнение загружается, Pano проходит по каждому классу в вашем пакете — com.panomc.plugins.shoutbox и всём, что под ним. Любой класс, несущий один из тегов из таблицы ниже (аннотации @Something), создаётся за вас автоматически; вы никогда не конструируете их сами.

Более того, Pano заполняет то, что нужно каждому классу. Если вашему эндпоинту нужен ShoutDao для чтения базы данных, вы просто указываете ShoutDao как параметр конструктора, и Pano передаёт готовый экземпляр — вы никогда не пишете ShoutDaoImpl() сами. Передача классу того, от чего он зависит, вместо того чтобы заставлять его строить это, называется внедрением зависимостей.

АннотацияЧто она регистрирует
@EndpointHTTP-маршрут — становится активным в момент загрузки дополнения
@Daoобъект доступа к данным для одной из ваших таблиц — ставится на конкретный класс impl, не на абстрактный DAO
@Migrationвыполняется один раз на установку, чтобы привести старую базу данных или конфигурацию к вашей новой версии
@EventListenerслушатель событий платформы (настройка завершена, игрок удалён, …)
@PermissionDefinitionузел разрешения, который панель может выдать

Вы никогда не вызываете метод «зарегистрировать этот эндпоинт» — аннотирование класса и есть регистрация. Маршруты, объявленные классами @Endpoint, поднимаются при загрузке дополнения и снова удаляются при его выгрузке, так что включение и отключение дополнения чисто добавляет и убирает его API.

Фреймворк, делающий всё это, — Spring. Каждое дополнение получает собственный контекст приложения Spring (изолированный контейнер, содержащий объекты, созданные Spring), и Spring сканирует компоненты вашего пакета — это сканирование и есть шаг «пройти по каждому классу» выше. Слово Spring для объекта, который он создаёт и которым управляет, — bean, так что «ваши bean-ы находятся автоматически» — просто причудливый способ сказать «ваши аннотированные классы создаются за вас».

Доступ к собственным сервисам Pano

Коротко: ваши собственные классы появляются в конструкторах автоматически; чтобы использовать один из встроенных сервисов Pano, вы получаете его одной строкой ниже. К этим сервисам относятся DatabaseManager, AuthProvider, SetupManager, PluginDatabaseManager и другие — вы обращаетесь к ним, когда вашему коду нужно что-то, чем владеет сам Pano (например, спросить SetupManager, завершена ли первоначальная настройка).

Две вещи о строке ниже: applicationContext наследуется от PanoPlugin (ваш входной класс расширяет его), а by lazy означает, что bean извлекается в первый раз, когда вы реально используете setupManager, а не при запуске. (SetupManager::class.java — это просто способ Kotlin назвать нужный вам тип.)

kotlin
private val setupManager by lazy {
    applicationContext.getBean(SetupManager::class.java)
}

Вы обращаетесь к каждому сервису хоста именно так — applicationContext.getBean(...) с типом сервиса.

Продвинутое, если вам интересно, почему нужно извлечение: bean-ы вашего дополнения живут в контексте pluginBeanContext, а сервисы Pano — в отдельном контексте хоста (applicationContext). Сканирование компонентов видит только ваш пакет, поэтому сервисы хоста не могут быть внедрены в ваши конструкторы — вы должны запрашивать их явно, как показано выше.

Что происходит при загрузке UI

UI никогда не поставляется как отдельные файлы. Когда вы запускаете релизную сборку, скомпилированные папки plugin-ui/{client,server} архивируются в единый ресурс jar-а, plugin-ui.zip. Оттуда:

  1. При загрузке Pano вычисляет хеш UI этого zip и объявляет { version, uiHash } для вашего дополнения через API информации о сайте (/api/siteInfo), который тема и панель уже вызывают.
  2. Тема (браузер) импортирует ваш клиентский бандл с запросом, сбрасывающим кэш — client.mjs?v=<uiHash> — так что новая сборка инвалидирует старую кэшированную копию. Процесс Node импортирует серверный бандл, server/server.mjs, для SSR.
  3. После импорта Pano конструирует ваш экспортированный по умолчанию класс и вызывает его onLoad() — тот же onLoad(), что в начале этой страницы. Это единственная точка входа, где вы регистрируете всё, что добавляет UI: хуки, страницы, ссылки навигации.

Убедитесь сами

Откройте инструменты разработчика браузера, перейдите на вкладку Network и загрузите страницу, использующую ваше дополнение. Найдите запрос client.mjs?v=<hash> — этот <hash> и есть хеш UI, который Pano только что передал браузеру.

Хеш UI важен в основном для релизных дополнений, и в разработке он ведёт себя иначе:

  • Релиз: новая версия поставляет новый zip, поэтому хеш меняется, и запрос, сбрасывающий кэш, вытягивает свежий бандл.
  • Режим разработки: при включённом режиме разработки Pano пересобирает zip вашего UI с диска на каждый запрос. Хеш тогда не является настоящим хешем содержимого — это фиксированное значение-заглушка dev-build — и тема повторно запрашивает бандл на каждый запрос вместо кэширования. Именно это заставляет изменение bun run dev появляться по F5.

Руководство Разработка фронтенда охватывает то, что вы делаете внутри onLoad().

Общая среда выполнения Svelte

Это самое важное, что нужно понять об UI, и это объясняет правило, которое всех удивляет.

Ваш клиентский бандл не содержит Svelte, svelte-i18n или @panomc/sdk. Сборка rollup намеренно оставляет эти импорты внешними: собранный файл всё ещё буквально говорит import ... from 'svelte', и ничего не было скопировано внутрь — так что что-то во время выполнения должно ответить на этот импорт.

Это что-то — хост (тема или панель). Он предоставляет карту импортов — небольшой кусочек JSON, который браузер читает, чтобы узнать, откуда на самом деле должно загружаться голое имя вроде 'svelte' — и эта карта указывает каждый из этих импортов на URL /runtime/.... Каждый URL /runtime просто реэкспортирует собственную живую копию модуля хоста.

Результат в том, что Pano и все его дополнения используют одну среду выполнения Svelte и один экземпляр SDK. На практике это означает, что ваши компоненты и компоненты темы разделяют состояние и перерисовываются вместе, как будто они всегда были одним приложением. Если бы ваше дополнение упаковало собственную частную копию Svelte или SDK, у этой копии было бы своё отдельное состояние, и гидрация (из начала этой страницы — повторное присоединение браузера к отрисованному на сервере HTML) сломалась бы.

Убедитесь сами

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

Есть жёсткое следствие: поскольку скомпилированный вывод Svelte гарантированно работает только с точно той же версией Svelte, на которой он будет выполняться, версия компилятора вашей сборки должна точно совпадать с версией хоста. Именно поэтому вы никогда не фиксируете Svelte сами — @panomc/sdk фиксирует правильную версию, а ваш rollup.config.js отказывается собираться при несовпадении.

Никогда не добавляйте svelte в ваш package.json

Версия Svelte берётся из фиксации @panomc/sdk, а не от вас. Если вы укажете svelte сами, bun может установить версию, отличную от той, что зафиксировал @panomc/sdk, и сборка защищается именно от этого: при несовпадении версий rollup.config.js печатает ошибку и сборка падает (код выхода 1) — намеренно. Расхождение версий ломает гидрацию способами, которые мучительно отлаживать, поэтому защита останавливает вас до отгрузки. Удалите любую запись svelte и переустановите.

Ещё одна вещь, которую это объясняет: единственные голые импорты, остающиеся внешними, — это Svelte, svelte-i18n и @panomc/sdk (и их подпути). «Голый» импорт — это импорт, записанный по имени пакета — import x from 'chart.js' — в отличие от пути вроде ./file.js. Всё остальное, что вы импортируете, — сторонний пакет вроде библиотеки графиков — не имеет шима хоста, поэтому должно быть упаковано в вашу сборку. Эта часть автоматическая: просто bun add его и импортируйте, и сборка сама скопирует его внутрь — особыми являются только эти три общих имени. Справочник API фронтенда перечисляет точный набор разрешённых голых спецификаторов.

Где живут ваши данные во время выполнения

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

МестоЧто хранитКто записывает
plugins/<pluginId>/ (директория данных)config.conf, загруженные файлы, всё, что ваше дополнение сохраняет на дисксоздаётся автоматически при первой загрузке; записываете вы (например, через PluginConfigManager, показанный в разделе «Разработка бэкенда»)
Внутри jar (ресурсы)locales/*.json, logo.png, plugin-ui.zipвпечены во время сборки — только для чтения во время выполнения

Директория данных названа по вашему pluginId (для Shoutbox — plugins/pano-plugin-shoutbox/) и переживает перезапуски — это место для состояния конкретной установки. В релизном jar эти ресурсы фиксируются в момент сборки jar. Однако во время разработки Pano обслуживает их вживую из вашего дерева исходников: при включённом режиме разработки он читает locales/*.json прямо с диска и пересобирает zip вашего UI с диска на каждый запрос — что и заставляет цикл «правка-обновление» работать. Код Kotlin — единственное, что никогда не перезагружается горячо: изменения Kotlin требуют пересборки и перезапуска Pano. (В разделе «Начало работы» есть полная таблица «горячее против пересборки».)

Куда дальше

Теперь, когда у вас есть модель мышления, выберите сторону для сборки:

Хотите почитать реальный код дополнений? Два примера, упомянутых выше, — это pano-plugin-cookies (только конфигурация, почти нет бэкенд-папок) и pano-plugin-announcement (полноценное CRUD-дополнение, использующее их все).