Система плагинов

Проектирование интерфейса плагина: чистые абстрактные классы и Q_DECLARE_INTERFACE

Введение

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

Концепция

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

Пример кода

// iexportplugin.h — базовый интерфейс, обязательный для всех export-плагинов
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>

class IExportPlugin
{
public:
    virtual ~IExportPlugin() = default;

    virtual QString formatName() const = 0;
    virtual QString fileExtension() const = 0;

    // Позволяет плагину отказаться от обработки конкретного документа
    // (например, формат не поддерживает векторную графику, а документ её содержит)
    virtual bool canExport(const QVariantMap &documentMetadata) const = 0;

    virtual bool exportToFile(const QString &filePath, const QByteArray &documentData) = 0;
};

#define IExportPlugin_iid "com.mycompany.myapp.IExportPlugin/1.0"
Q_DECLARE_INTERFACE(IExportPlugin, IExportPlugin_iid)

// Необязательный дополняющий интерфейс — не все плагины обязаны его реализовывать
class IExportProgressReporting
{
public:
    virtual ~IExportProgressReporting() = default;
    virtual void setProgressCallback(std::function<void(int)> callback) = 0;
};

#define IExportProgressReporting_iid "com.mycompany.myapp.IExportProgressReporting/1.0"
Q_DECLARE_INTERFACE(IExportProgressReporting, IExportProgressReporting_iid)
// pdfexportplugin.h — реализация, поддерживающая и базовый, и опциональный интерфейс
#pragma once
#include "iexportplugin.h"
#include <QObject>
#include <functional>

class PdfExportPlugin : public QObject, public IExportPlugin, public IExportProgressReporting
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID IExportPlugin_iid)
    Q_INTERFACES(IExportPlugin IExportProgressReporting)

public:
    QString formatName() const override { return "PDF документ"; }
    QString fileExtension() const override { return "pdf"; }

    bool canExport(const QVariantMap &documentMetadata) const override
    {
        // PDF поддерживает практически любой тип контента — почти всегда true
        return true;
    }

    bool exportToFile(const QString &filePath, const QByteArray &documentData) override
    {
        for (int progress = 0; progress <= 100; progress += 25) {
            if (m_progressCallback) m_progressCallback(progress);
        }
        return true; // реальная логика экспорта в PDF здесь
    }

    void setProgressCallback(std::function<void(int)> callback) override
    {
        m_progressCallback = std::move(callback);
    }

private:
    std::function<void(int)> m_progressCallback;
};
// Использование: основное приложение проверяет наличие опционального интерфейса
void exportDocument(QObject *pluginInstance, const QString &filePath, const QByteArray &data)
{
    auto *exporter = qobject_cast<IExportPlugin *>(pluginInstance);
    if (!exporter) return;

    if (auto *progressReporter = qobject_cast<IExportProgressReporting *>(pluginInstance)) {
        progressReporter->setProgressCallback([](int percent) {
            qDebug() << "Прогресс экспорта:" << percent << "%";
        });
    }

    exporter->exportToFile(filePath, data);
}

Пояснения к коду

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

В exportDocument() приложение сначала получает обязательный интерфейс IExportPlugin через qobject_cast, а затем отдельно, опционально, проверяет наличие IExportProgressReporting через тот же механизм — если конкретный плагин его не реализует, qobject_cast просто вернёт nullptr, и код корректно продолжит работу без обновления прогресса, не нарушая основной функциональности экспорта.

Подводные камни

  • Слишком ранняя фиксация интерфейса без запаса на будущее расширение. После того как сторонние разработчики начали писать плагины под конкретную версию интерфейса, добавление новых обязательных методов в существующий интерфейс ломает совместимость со всеми уже написанными плагинами — расширение функциональности должно идти через добавление новых опциональных дополняющих интерфейсов (как IExportProgressReporting), а не модификацию существующего обязательного контракта.
  • Слишком гранулярное разделение на множество мелких интерфейсов ради теоретической гибкости, когда реальной потребности в такой гранулярности нет, усложняет как реализацию плагинов (нужно помнить, какие из десяти возможных интерфейсов реализовывать для типичного случая), так и код, использующий плагины (множество последовательных проверок qobject_cast) — баланс между гибкостью и простотой нужно искать осознанно, ориентируясь на реальные сценарии использования, а не на абстрактную «полноту» архитектуры.
  • Версионирование IID интерфейса без явной стратегии при breaking changes. Суффикс /1.0 в строке IID — подсказка для будущего: при действительно breaking-изменении интерфейса (не просто добавлении опционального метода, а изменении сигнатуры существующего) стоит выпустить новый IID (/2.0) и поддерживать оба интерфейса параллельно на переходный период, чтобы старые плагины не сломались мгновенно при обновлении основного приложения.
  • Метод canExport(), возвращающий упрощённый ответ без полезной диагностики для отказа. Если плагин отказывается экспортировать конкретный документ (canExport() == false), но не сообщает причину, пользователь интерфейса приложения, выбирающий формат экспорта, не понимает, почему опция недоступна — для лучшего пользовательского опыта стоит предусмотреть метод, возвращающий не просто булево значение, а причину отказа в человекочитаемом виде.