Как стать автором
Обновить
81.88
Тензор
Разработчик системы СБИС

Плагин для анализа планов PostgreSQL в IDE JetBrains и его разработка

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров7.8K
Intellij plugin
Intellij plugin

Для пользователей explain.tensor.ru - нашего сервиса визуализации PostgreSQL-планов, мы создали плагин "Explain PostgreSQL" для всех IDE от JetBrains, теперь есть возможность форматировать запросы и анализировать планы непосредственно в IDE.

Как использовать плагин и детали о его разработке читайте ниже.


Установка плагина

К сожалению, попытка опубликовать плагин в JetBrains Marketplace завершилась отказом:

На основании имеющейся у нас информации, Вы являетесь лицом, связанным с организацией, находящейся в Российской Федерации, и/или с Правительством Российской Федерации.

По этой причине, а также в связи с недавно опубликованной поправкой к санкционному законодательству ЕС (8-й пакет санкций ЕС), JetBrains не может предоставлять Вам услуги ИТ‑консалтинга, включая техническую поддержку или помощь. Проверки и публикация плагинов на Маркетплейсе JetBrains попадают под определение предоставления технической поддержки.

Поэтому установку плагина надо делать из локального файла - скачиваем его с сайта explain.tensor.ru (раздел Download / Plugins ) и в Settings > Plugins выбираем Install plugin from Disk .

Подключение к БД

создаем новый datasource:

создаем новый datasource
создаем новый datasource

скачиваем JDBC-драйвера PostgreSQL и подключаемся к БД:

подключаемся к БД
подключаемся к БД

Форматирование запроса

в консоли набираем текст запроса и нажимаем Ctrl-Q F или в контекстном меню SQL Format:

форматируем запрос
форматируем запрос

Анализ запроса

В контекстном меню выбираем Explain Plan > Explain Analyze (Tensor) , при этом выполнится запрос EXPLAIN (ANALYZE, BUFFERS)

анализ плана запроса
анализ плана запроса

полученный план отправится в сервис explain.tensor.ru через публичное API , результат откроется в новом окне:

визуализация плана запроса
визуализация плана запроса

Для IDE без плагина Database Tools

В WebStorm для работы с БД требуется установка плагина Database Tools and SQL for WebStorm . Если этот плагин не установлен или у вас community-версия IDE то открыть сайт explain.tensor.ru можно прямо в IDE:

explain.tensor.ru в IDE
explain.tensor.ru в IDE

Настройка плагина

Для тех кто развернул сервис локально, используя вариант self hosted, или использует сайт explain-postgresql.com , можно поменять сайт в настройках IDE в Tools > Explain PostgreSQL:

настройка плагина
настройка плагина

О разработке плагина для JetBrains IDE

создание нового проекта
создание нового проекта

Создаем новый проект "IDE plugin".
В окне есть ссылка на официальную документацию IntelliJ Platform SDK , в ней приведены основные сведения, используемые при разработке.

Создание Tool Window

  1. создаем класс, реализующий интерфейс ToolWindowFactory и переопределяем метод createToolWindowContent , который будет вызываться при нажатии на кнопку окна - здесь в окне создается новая вкладка с сайтом explain.tensor.ru и кнопка новой вкладки:

public class ExplainToolWindowFactory implements ToolWindowFactory, DumbAware {

	@Override
	public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) {
		createNewTab(project);
		ButtonNewTab newTabBtn = new ButtonNewTab("New Tab", null, AllIcons.Toolbar.AddSlot);
		((ToolWindowEx) toolWindow).setTabActions(newTabBtn);
	}

    public static void createNewTab(Project project) {
		ApplicationManager.getApplication().invokeLater((new Runnable() {
			@Override
			public void run() {
				ExplainBrowser browser = new ExplainBrowser(true);
				ToolWindow toolWindow = ToolWindowManager.getInstance(project).getToolWindow(explainWindow);
				Content content = ContentFactory.getInstance().createContent(browser.getComponent(), "Explain", false);
				content.setDisposer(browser);
				@NotNull ContentManager contentManager = toolWindow.getContentManager();
				contentManager.addContent(content);
				contentManager.setSelectedContent(content);
			}
		}));
	}
}
  1. кнопка создания новой вкладки - наследуем AnActionButton и переопределяем методы:

    actionPerformed - выполняется при нажатии на кнопку, здесь мы создаем новую вкладку или открываем новое окно браузера если нажали вместе с Ctrl

    updateButton - выполняется для определения доступности кнопки, в нашем случае если JDK с поддержкой JCEF браузера и открыто tool window

public class ButtonNewTab extends AnActionButton {

	public ButtonNewTab(String text, String descr, Icon icon) {
		super(text, descr, icon);
	}

	@Override
	public void actionPerformed(@NotNull AnActionEvent e) {
		if (e.getInputEvent().isControlDown()) {
			BrowserUtil.browse(AppSettingsState.getInstance().getExplainUrl());
		} else {
			ExplainToolWindowFactory.createNewTab(e.getProject());
		}
	}

	@Override
	public void updateButton(@NotNull AnActionEvent e) {
		super.updateButton(e);
		boolean enabled = ExplainBrowser.isSupported && ExplainToolWindowFactory.getToolWindow(e.getProject()).isVisible();
		e.getPresentation().setEnabled(enabled);
	}
}
  1. окно с JCEF-браузером

    JCEF - это реализация Chromium Embedded Framework в Java, определяем возможность его использования методом isSupported и если нет, то вместо компонента с окном браузера выдаем сообщение:

public class ExplainBrowser implements Disposable {
	public static final String EXPLAIN_URL = "https://explain.tensor.ru";
	public static final boolean isSupported = JBCefApp.isSupported();
	private JBCefBrowser browser = null;
	private JPanel panel = null;
	public ExplainBrowser(boolean loadOnStart) {
		if (isSupported) {
			browser = new JBCefBrowser(EXPLAIN_URL);
		} else {
			panel = new JPanel(new BorderLayout());
			JLabel label = new JLabel("JCEF browser is not supported");
			panel.add(label, BorderLayout.CENTER);
		}
	}

	public JComponent getComponent() {
		if (browser == null) {
			return panel;
		} else {
			return browser.getComponent();
		}
	}
}
  1. добавляем описание tool window в plugin.xml в разделе extensions, в параметре factoryClass указываем созданный в п.1 класс. IDE при старте прочитает plugin.xml и создаст наше окно снизу на панели инструментов

  <extensions defaultExtensionNs="com.intellij">
    <toolWindow factoryClass="com.mgorkov.toolwindow.ExplainToolWindowFactory"
                id="Explain PostgreSQL"
                anchor="bottom"
                secondary="true"
                canCloseContents="true"
                largeStripeAnchor="true"
                icon="AllIcons.Xml.Browsers.Chrome"
                doNotActivateOnStart="true"
    />
  </extensions>

Форматирование запроса

Создаем класс SQLFormatter и наследуем от AnAction , в нем также как и в AnActionButton надо переопределить методы actionPerformed и update.

В actionPerformed мы получаем текст документа и, используя API explain.tensor.ru , форматируем и заменяем исходный текст.

public class SQLFormatter extends AnAction {

    private Document document = null;
    private String formatted = null;

    @Override
    public void actionPerformed(@NotNull AnActionEvent e) {
        Editor editor = e.getData(CommonDataKeys.EDITOR);
        if (editor != null) {
            document = editor.getDocument();
            String text = document.getText();
            formatted = explainApi.beautifier(e, text).join();
            if (formatted != null) {
                WriteCommandAction.runWriteCommandAction(e.getProject(), () -> {
                  document.deleteString(0, document.getTextLength());
                  document.insertString(0, formatted);
                });
            }
        }
    }
}

Чтобы IDE узнала об этом действии нужно зарегистрировать его в plugin.xml в разделе actions , указав реализующий класс, место размещения и комбинацию клавиш. Здесь мы размещаем действие в EditorPopupMenu и оно доступно при нажатии Ctrl Q F

<actions>
  <action id="SQLFormat" class="com.mgorkov.actions.SQLFormatter" text="SQL Format">
    <add-to-group group-id="EditorPopupMenu" anchor="last"/>
    <keyboard-shortcut keymap="$default" first-keystroke="ctrl Q" second-keystroke="F"/>
  </action>
</actions>

При наличии синтаксических ошибок выдается предупреждение:

error hint
error hint

Это реализовано в методе showErrorMessage сервиса ExplainApiService:

private void showErrorMessage(@NotNull AnActionEvent e, String msg) {
    ApplicationManager.getApplication().invokeLater(() -> {
        HintManager.getInstance().showErrorHint(Objects.requireNonNull(e.getData(CommonDataKeys.EDITOR)), msg);
    });
}

Анализ запроса

Для выполнение запросов к базе используем плагин JetBrains Database Tools, он встроен во все IDE , кроме community edition и WebStorm (можно установить отдельно).

Здесь мы наследуем класс ExplainActionBase.Ui и в нем переопределяем метод explainStatement , в котором выполняем запрос и выводим окно браузера с результатом анализа в новой вкладке редактора:

FileEditor[] editors = FileEditorManager.getInstance(console.getProject()).openFile(file, true);
Arrays.stream(editors).filter(e -> e instanceof PgPlanEditor).forEach(e -> {
    PgPlanEditor editor = (PgPlanEditor) e;
    editor.getExplainWindow().updatePlan(mdl.getJson());
});

Далее регистрируем новые действия в контекстном меню JDBC-консоли. Чтобы плагин мог работать в IDE без Database Tools добавим в plugin.xml опциональную зависимость от него:

<depends optional="true" config-file="plugin-withdatabase.xml">com.intellij.database</depends>

и создадим plugin-withdatabase.xml :

<actions>
    <action id="com.mgorkov.actions.ExplainAnalyzeAction"
            class="com.mgorkov.actions.ExplainAnalyzeAction" text="Explain Analyze (Tensor)"
            icon="DatabaseIcons.ConsoleShowPlan">
        <add-to-group group-id="Console.Jdbc.ExplainGroup" anchor="last"/>
    </action>
    <action id="com.mgorkov.actions.ExplainAction" class="com.mgorkov.actions.ExplainAction"
            text="Explain Plan (Tensor)"
            icon="DatabaseIcons.ConsoleShowPlan">
        <add-to-group group-id="Console.Jdbc.ExplainGroup" anchor="last"/>
    </action>
</actions>

Настройка плагина

В IDE есть понятие сервисов, это компоненты плагина, которые загружаются по требованию при вызове getService() . При этом сервисы реализуют паттерн singleton, т.е. при вызове getService() всегда возвращается один и тот же компонент. Хранение настроек плагина реализуется сервисом типа applicationService (всего их три).

Реализация настроек выполнена по схеме MVC.

Роль модели выполняет класс AppSettingsState, реализующий PersistentStateComponent, при этом в аннотации надо указать имя параметра и файла, в котором сохраняются настройки:

@State(
        name = "com.mgorkov.settings.AppSettingsState",
        storages = @Storage("ExplainPostgreSQLSettingsPlugin.xml")
)
public final class AppSettingsState implements PersistentStateComponent<AppSettingsState> {

    private String ExplainUrl = "https://explain.tensor.ru";

    public static AppSettingsState getInstance() {
        return ApplicationManager.getApplication().getService(AppSettingsState.class);
    }

    public void setExplainUrl(String explainUrl) {
        ExplainUrl = explainUrl;
    }
}

Роль контроллера - класс AppSettingsConfigurable реализующий интерфейс Configurable , в котором переопределены методы:

getDisplayName - выдает название пункта меню Settings, используется при поиске

public class AppSettingsConfigurable implements Configurable {

    private AppSettingsComponent appSettingsComponent;

    @Override
    public @NlsContexts.ConfigurableName String getDisplayName() {
        return "Explain PostgreSQL";
    }

    @Override
    public @Nullable JComponent createComponent() {
        appSettingsComponent = new AppSettingsComponent();
        return appSettingsComponent.getPanel();
    }

    @Override
    public void apply() throws ConfigurationException {
        AppSettingsState settings = AppSettingsState.getInstance();
        String explainUrl = appSettingsComponent.getExplainUrl();
        try {
            new URL(explainUrl).openStream().close();
            settings.setExplainUrl(explainUrl);
        } catch (Exception e) {
            throw new ConfigurationException(e.getMessage());
        }
    }
}
настройка плагина
настройка плагина

apply - выполняет проверку на валидность URL и возможность подключиться к нему, в случае ошибок бросает исключение ConfigurationException , текст из которого выводится снизу на странице настроек.

createComponent - создает страницу настроек, о ней ниже.

Роль представления реализует класс AppSettingsComponent , в котором создается форма с текстовым полем и меткой:

public class AppSettingsComponent {
    private final JPanel panel;
    private final JBTextField explainUrlTextField = new JBTextField();

    public AppSettingsComponent() {
        panel = FormBuilder.createFormBuilder()
                .addLabeledComponent(new JBLabel("Explain PostgreSQL site: "), explainUrlTextField, 1, false)
                .addComponentFillVertically(new JPanel(), 0)
                .getPanel();
    }

    public JPanel getPanel() {
        return panel;
    }

    public String getExplainUrl() {
        return explainUrlTextField.getText();
    }

    public void setExplainUrl(@NotNull String url) {
        explainUrlTextField.setText(url);
    }
}

Регистрируем модель и контроллер в plugin.xml , для контроллера необходимо указать пункт меню Settings, в котором будут наши настройки, т.е. tools:

<extensions defaultExtensionNs="com.intellij">
  <applicationConfigurable parentId="tools"
                           instance="com.mgorkov.settings.AppSettingsConfigurable"
                           id="com.mgorkov.settings.AppSettingsConfigurable"
                           displayName="Explain PostgreSQL"
  />
  <applicationService serviceImplementation="com.mgorkov.settings.AppSettingsState"/>
</extensions>

Отладка плагина

Для вывода сообщений в лог используем Logger :

private static final Logger log = Logger.getInstance(ExplainApiService.class);
log.debug("POST JSON: " + jsonObject);

Лог хранится в файле idea.log , открыть который можно через меню Help -> Open Log in Editor

По умолчанию в лог пишутся все события уровня INFO.

Чтобы записать события с уровнем DEBUG надо добавить список имен в Help -> Diagnostic Tools -> Debug Log Settings :

настройка debug
настройка debug

Для включения внутренних команд в IDE надо добавить опцию idea.is.internal=true в Help -> Edit Custom Properties

Настройка DevTools
Настройка DevTools
DevTools menu
DevTools menu

После перезапуска появится дополнительный пункт меню Tools -> Internal Actions

просмотр элемента
просмотр элемента

Теперь при одновременном нажатии Ctrl-Alt-A и щелчке мыши на любом элементе можно посмотреть его состав, как в DevTools браузера

Сборка и публикация плагина

Сборка осуществляется Gradle, при скачивании зависимостей в защищенной корпоративной среде могут быть ошибки типа "not found", для решения надо добавить сертификат в хранилище, используя стандартный пароль "changeit", и перезапустить IDE:

keytool -import -trustcacerts -alias root -file <файл с сертификатом> -keystore <путь к IDE>/jbr/lib/security/cacerts

Конфигурация сборки задается в файле build.gradle.kts , при необходимости можно поменять параметры по умолчанию. Например для варианта сборки с библиотекой json версии 20231013 и тестового запуска плагина в IDE IntelliJ IDEA Ultimate Edition версии 2022.2.5 :

dependencies {
  implementation("org.json:json:20231013")
}

intellij {
  version.set("2022.2.5")
  type.set("IU") // Target IDE Platform

  plugins.set(listOf("com.intellij.database"))
}

Описание всех опций сборки можно почитать здесь.

Gradle создает набор задач, для которых можно поменять конфигурацию запуска, например чтобы при тестовом запуске IDE лог-файл idea.log отображался в консоли в отдельной вкладке надо поменять конфигурацию задачи runIde и добавить idea.log , путь к которому можно посмотреть в Help -> Show Log in Files:

меняем конфигурацию задачи runIde
меняем конфигурацию задачи runIde

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

настраиваем signPlugin
настраиваем signPlugin

Эти переменные используются при сборке и уже прописаны в build.gradle.kts :

signPlugin {
    certificateChain.set(System.getenv("CERTIFICATE_CHAIN"))
    privateKey.set(System.getenv("PRIVATE_KEY"))
    password.set(System.getenv("PRIVATE_KEY_PASSWORD"))
}

Процесс создания подписей есть в документации, но там нет описания для получения значений в виде одной строки, а это можно сделать так:

# строка для переменной PRIVATE_KEY
openssl genpkey -aes-256-cbc -algorithm RSA -pkeyopt rsa_keygen_bits:4096 | openssl rsa | tee private.pem | openssl base64 | tr -d '\n'

# строка для CERTIFICATE_CHAIN
openssl req -key private.pem -new -x509 -days 365 | openssl base64 | tr -d '\n'

Для проверки совместимости плагина с разными версиями версиями IDE можно использовать задачу verifyPlugin и добавить параметры для нее в конфиг сборки (build.gradle.kts) , например мы хотим проверить плагин в DataGrip версий 2023.3 и 3.1 :

runPluginVerifier {
  ideVersions.set(listOf(
          "DG-2023.3.1",
          "DG-2023.3"
  ))
}

По умолчанию верификация плагина происходит на сайте plugins.jetbrains.com при загрузке новой версии плагина, на каких версиях производится проверка задается в build.gradle.kts :

patchPluginXml {
  sinceBuild.set("222")
  untilBuild.set("233.*")
}

Можно также запустить проверку на сайте для конкретной версии, например для версий 221.* плагин не совместим из-за отсутствия методов:

проверка совместимости на сайте
проверка совместимости на сайте

Для публикации плагина первичная загрузка производится вручную, собранный файл находится в build/distributions (его кстати можно установить прямо с диска, для этого зайти в Settings -> Plugins -> Install Plugin from Disk)

В дальнейшем можно автоматизировать публикацию, для этого на сайте plugins.jetbrains.com в своем профиле во вкладке Tokens сгенерить токен и прописать его в переменной PUBLISH_TOKEN в конфигурации задачи publishToken.


В статье написал обо всех этапах создания плагина, пропустив детали реализации, не относящиеся непосредственно к теме разработки плагинов в IDE JetBrains.

Код плагина опубликован в github под лицензией MIT.

Дополнение: в комментариях к статье обнаружилась непонятная проблема с внешней библиотекой json на M1 MacOS 14 Sonoma - ошибка NoClassDefFoundError при создании

new JSONObject();

Проблему решили заменой библиотеки на Gson , которая входит в состав библиотек во всех IDE JetBrains.

Теги:
Хабы:
Всего голосов 17: ↑17 и ↓0+17
Комментарии21

Публикации

Информация

Сайт
sbis.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия