Модульное программирование в C++. Статические и динамические плагины


Плагины на С++

На тему модульности программ в С++, в Интернете, теоретических материалов много, а практических – мало. Поэтому, не найдя подходящего прототипа для собственного проекта, пришлось изобретать очередной «велосипед», из чего-то, ведь, исходить надо.

Сложность программирования, тестирования, отладки, поддержки и сопровождения программных продуктов заставляет искать пути борьбы с ней. Одним из традиционных способов является использование плагинов и сервисов с виртуальными интерфейсами. Однако доступные примеры предпочитают иметь дело с консолью, тогда как нам интересны окна и другие компоненты из арсенала GUI (графического интерфейса пользователя).

Часть первая. Результирующая


Введение


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

В нашем случае, удобно, разделить плагины на два вида: статические и динамические. К первым мы отнесем dll раннего связывания, т.е., бинарные модули, известные и доступные на этапе компиляции. А вторые это dll позднего связывания, загружаемые автоматически, из определенного каталога, во время работы основной программы.

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

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

Язык С++ очень удобен для решения задач подобного рода. Но, чтобы не усложнять демонстрационную программу различного рода фреймворками, мы ограничимся использованием только WinAPI.

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

Но сначала пара слов о модульности, как таковой.

Модульное структурирование кода


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

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

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

Результат работы программы без динамических плагинов


В нашем демо-проекте, мы использовали оба вида компиляции. Естественно, что результат работы у них один и тот же. Только, в динамическом проекте, можно видеть работу программы вообще без плагинов (рис. 1).

Рис. 1. Главное окно приложения при отсутствии плагинов.
Рис. 1. Главное окно приложения при отсутствии плагинов.

В проекте нам доступны два динамических плагина и три статических. Статические плагины присутствуют всегда, либо внутренним образом (при полной статической сборке проекта), либо в виде трех dll (расположенных рядом с exe-модулем):

– Common.dll (библиотека общего назначения, для упрощенной работы с ini-файлами, создания исходных каталогов и т.п.);
– DllLoader.dll (загрузчик динамических плагинов, который оказался достаточно сложным для включения его в библиотеку общего назначения);
– App.dll (основной код, создающий главное окно, организующий работу цикла сообщений и т.п.).

Работа этих статических плагинов (за исключением создания прототипа главного окна) на рисунке не видна, поскольку они предназначены, в основном, для обслуживания динамических плагинов.

Сами же динамические плагины представлены двумя файлами (в папке «Plugins»):

– NewWin.dll (создание множества различных дочерних MDI-окон, одного класса)
и
– About.dll (единственное MDI-окно, эмулирующее диалоговое окно «О программе»).

Заметим, что эти плагины имеют собственные внешние ресурсы в папке «Plugins\Res».

Результат работы программы с динамическими плагинами


Если добавить плагин «NewWin.dll» в папку «Plugins», то увидим следующие изменения (рис. 2).

Рис. 2. Главное окно приложения при наличии плагина «NewWin.dll».
Рис. 2. Главное окно приложения при наличии плагина «NewWin.dll».

Этот плагин создает несколько различных окон с цитатами Владимира Маяковского. При этом, первое окно центрируется, а последующие располагаются по диагонали, со смещением (рис. 3).

Рис. 3. Множество окон плагина «NewWin.dll».
Рис. 3. Множество окон плагина «NewWin.dll».

Если изменить размеры текущего окна, то следующее окно тоже изменится соответственно, только на отступы это не повлияет.

Хотя это и не принципиально, но мы ограничили количество одновременно открытых окон, при превышении которых будет выдано предупреждение (рис. 4).

Рис. 4. Максимальное количество окон плагина «NewWin.dll».
Рис. 4. Максимальное количество окон плагина «NewWin.dll».

Добавим теперь плагин «About.dll». Результат его работы виден на рис. 5.

Рис. 5. Единственное окно «О программе» плагина «About.dll».
Рис. 5. Единственное окно «О программе» плагина «About.dll».

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

Благодаря наличию внешних ресурсов у плагинов, все эти окна полностью настраиваются. Можно менять практически все, вплоть до пиктограмм, пунктов меню, «горячих» и Alt-клавиш для них. Не говоря уже о файлах изображений и тексте в окнах.

Заметим, что для Alt-клавиш мы использовали цифры, вместо символов. Это связано с раскладкой клавиатуры. Если, допустим, раскладка у нас латинская, а буквы русские либо наоборот, то Alt-клавиши действовать не будут. Цифры, в этом смысле, от раскладки не зависят. Однако латинские символы в файловых настройках указывать можно. О самих конфигурационных файлах речь ниже.

Принцип работы программы «AppPlugins»


Здесь можно отметить, что:

1. Приложение и плагины считывают исходные данные (размеры и наименование окон, пути меню, горячие и Alt-клавиши, текстовку и т.п.) и по ним строят окна и модифицируется меню. Если данных нет, то используются параметры по умолчанию. В качестве встроенного шрифта выбран «Arbat-Bold». Если его нет в вашей системе, то используется другой.

2. При выходе из программы, текущие данные сохраняются в упрощенных ini-файлах.

Отметим, что мы практически не пользуемся rc-файлами ресурсов вообще, поскольку все ресурсы у нас динамические. В том числе и пиктограммы окон и фоновый рисунок дочернего окна. Кстати, окна можно закрывать дополнительной горячей клавишей «Esc» (помимо стандартной комбинации «Ctrl+F4»). Однако эти ресурсы должны находиться в соответствующих каталогах, относительно исполняемого exe-файла. Исключение составляет иконка для exe-файла. Поскольку, просто непонятно, как ее прикрепить к результирующему файлу, во время компиляции проекта, без использования файла ресурсов. Все остальное можно загрузить в рантайме.

Проблемы модульности


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

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

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

Общие ресурсы


В общие ресурсы входят:

1. Общие переменные (внутренние, создаваемые в памяти, и внешние, загружаемые из файлов инициализации).

2. Общее меню.

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

4. Общие функции и интерфейсы.

Общие переменные можно представить в виде структур, которые можно передавать по ссылке либо через указатель из одного модуля в другой. В качестве глобальных переменных можно оставить только (статические) константы.

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

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

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

Часть вторая. Техническая


Общие цели


Задача, при создании нового проекта, была простой. Нужно было сразу организовать проект так, чтобы весь существенный код был вынесен во внешние бинарные модули. При этом, dll позднего связывания (динамические плагины) должны быть независимыми от приложения. Т.е., если они есть, то программа их «подхватывает» и использует, а если нет, то это никак не должно отражаться на работе основного приложения.

Кроме того, требуется, чтобы, заранее известные и необходимые модули (создание главного окна, загрузчик динамических плагинов, модуль общих функций и т.п.) организовать в виде dll раннего связывания (статические плагины). Они, конечно, являются обязательными (при полной динамической сборке проекта), но, способные заменяться на аналогичные, при наличии таковых.

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

Такой подход позволяет добиться распараллеливания сложного проекта на отдельные составляющие (как динамические, так и статические), что само по себе также является нашей целью.

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

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

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

1. Проект писать на C++ / WinAPI, как для 32-х, так и для 64-х разрядных систем Windows. При этом использовать бесплатную версию Visual Studio С++ 2017, Community Edition (требуется только регистрация). Хотя сама программа должна работать и в Visual Studio С++ 2010.

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

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

3.1. Первый плагин, который мы назовем «NewWin.dll» будет создавать множество дочерних MDI окон. Каждое из которых будет выводить некий логотип, рисовать какой-нибудь фон и писать определенный текст. Все параметры должны быть настраиваемыми, храниться в конфигурационном файле и использовать внешние ресурсы. Для определенности, мы выбрали, для логотипа – фото и подпись поэта Владимира Маяковского. Для текста – его стихотворные цитаты. Цветовая гамма для фона окна выбирается случайно, а изображение строится по некоторому произвольному алгоритму (рис. 2-3).

3.2. Второй плагин будет эмулировать стандартный диалог: «О программе». Это будет как бы упрощенный вариант первого плагина, позволяющий выводить только одно окно. А вместо программно генерируемого фона, там просто выводится внешний фоновый рисунок.

3.3. Каждый плагин добавляет свою запись в главное меню, «горячую» и Alt-клавишу для нее. Дополнительно (к команде «Ctrl+F4»), для MDI-окон должна действовать, как уже упоминалось выше, клавиша «Esc». Стандартная команда выхода из приложения: «Alt+F4» (ее программировать не надо). Но для нее будет доступен эквивалент в виде выбранной Alt-клавиши (рис. 1).

4. Для фиксированных dll раннего связывания (статических плагинов) будет реализован только минимально необходимый обслуживающий код.

Решение поставленной задачи методом «от обратного»


Посмотрим, каким может быть проект для главного модуля, в виде exe-файла, при полной динамической сборке (рис. 6). При этом два динамических плагина и три статических компилируются, в виде dll, независимо. Не нужно забывать об использовании необходимых lib-файлов (указанных в проектах). Последовательность компиляции, при полной динамической сборке, произвольная, но модуль «App.dll» компилируется предпоследним. Последним создается соответствующий исполнимый exe-файл. Для полной статической сборки создается только exe-модуль.

Рис. 6. Проект для главного модуля, при полной динамической сборке.
Рис. 6. Проект для главного модуля, при полной динамической сборке.

Отметим, что в папка «_BaseCode» является общей для всех проектов, независимо от способа их компиляции.

Как видим, здесь минимум кода (в файлах *.cpp). А «StdAfx.cpp» вообще пустой. Главными здесь являются файлы App.h и App.lib, которые связывают, на этапе компиляции, файлы Main.exe и App.dll.

Сам «Main.cpp» делает достаточно понятные вещи:

1. Получает указатель на интерфейс модуля приложения (либо из App.dll, либо из класса App.h / App.cpp).

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

3. Регистрация и создание главного окна приложения.

4. Запуск цикла сообщения главного окна.

Думаю, что здесь все просто и понятно. Вопрос только, что из себя представляет интерфейс модуля приложения?

Интерфейс модуля приложения


Откроем наш проект «App» (рис. 7).

Рис. 7. Проект «App», при полной динамической сборке модуля приложения.
Рис. 7. Проект «App», при полной динамической сборке модуля приложения.

Здесь вообще все просто. Код «Main.cpp» абсолютно стандартный для dll. Соответственно, главная содержательная часть находится в файлах «App.h» и «App.cpp». При этом данный модуль использует интерфейсы других dll: «Common.dll» и «DllLoader.dll», на что указывают файлы: «Common.h» / «Common.lib» и «DllLoader.h» / «DllLoader.lib». При этом все бинарные dll-модули компилируются независимо (как упоминалось выше, их у нас пять)

Посмотрим файл «App.h» из этого проекта. Там нас интересует класс «CAppInreface» (рис. 8-9).

Рис. 8. Заголовки и определения в файле «App.h».
Рис. 8. Заголовки и определения в файле «App.h».

Рис. 9. Класс «CAppInreface» в файле «App.h».
Рис. 9. Класс «CAppInreface» в файле «App.h».

Это типичное оформление для виртуального класса, который мы перегружаем в классе «CApp» (рис. 10).

Рис. 10. Перегруженный класс «CApp» в файле «App.h» для модуля приложения.
Рис.10. Перегруженный класс «CApp» в файле «App.h» для модуля приложения.

Здесь же можно найти и прототип экспортируемой функции «GetAppInterface()» (рис. 11).

Рис. 11. Прототип экспортируемой функции «GetAppInterface()».
Рис. 11. Прототип экспортируемой функции «GetAppInterface()».

А ее реализация находится в файле «App.cpp» (рис. 12):

Рис. 12. Реализация экспортируемой функции «GetAppInterface()».
Рис. 12. Реализация экспортируемой функции «GetAppInterface()».

Как видим, в принципе, все относительно просто. Сложны только нюансы реализации. Например, в нашем перегруженном классе «CApp» добавлены собственные функции и множество различных данных. Со всем этим надо работать, что требует много кода.

Однако, концептуально, виртуальные интерфейсы у нас одни и те же, как для статических плагинов, так и для динамических.

В любом случае, чтобы разобраться с проектом, надо с ним поработать. Как говориться: «Лучше один раз потрогать, чем сто раз увидеть» :).

Нюансы реализации


Их достаточно много, даже для этого небольшого проекта. Я бы обратил внимание на:

1. Логирование. Механизм здесь этот есть, но «замороженный». Я использовал его, когда надо было отследить маршруты оконных сообщений в циклах сообщений плагинов и главного окна.

2. Работа с ini-файлами. Оказалось, что нет особой необходимости работать с полноценными ini-файлами. Для этого надо много кода, который, для наших целей, вполне можно упростить. На рис. 13, показан пример файла «About.ini», генерируемого по умолчанию.

Рис. 13. Файл «About.ini», генерируемый по умолчанию.
Рис. 13. Файл «About.ini», генерируемый по умолчанию.

Структура его очень простая, но всегда фиксированная, для использования в конкретном модуле:

– Строка комментария.
– Одна или несколько строк данных.

Строка комментария всегда начинается с символа ';'. А строка / строки данных это все, что лежит после текущего комментария и до следующего комментария, либо конца файла.

Это позволило оформить достаточно просто громоздкие данные для плагина «NewWin.dll» (в файле «NewWin.ini» (рис. 14).

Рис. 14. Часть файла «NewWin.ini», генерируемого по умолчанию.
Рис. 14. Часть файла «NewWin.ini», генерируемого по умолчанию.

Если изменить здесь данные, без нарушения их структуры, то программа соответствующим образом отреагирует.

Заметим, что для отделения команды хоткея, используется символ табулятора '\t'. Кроме того, вместо процентов мы используем промилле (тысячную долю единицы), а вместо абсолютных размеров относительные. При желании это все можно поменять.

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

3. У нас используется достаточно много констант, для данных по умолчанию, определяемых в начале *.cpp файлов. Например, верхняя часть «App.cpp» показана на рис. 15-16.

Рис. 15. Константы и глобальное перечисление, для файла «App.cpp».
Рис. 15. Константы и глобальное перечисление, для файла «App.cpp».

Рис. 16. Массивы данных, по умолчанию, для файла «App.cpp».
Рис. 16. Массивы данных, по умолчанию, для файла «App.cpp».

Это далеко не все имеющиеся нюансы реализации, но обо всем упоминать довольно долго. Лучше, не полениться и изучить прилагаемый код.

Статическая сборка


Поскольку весь существенный код у нас находится в каталоге «_BaseCode», то для полной статической сборки достаточно его подключить и убрать флаг IS_DLL (рис. 17).

Рис.17. Проект для главного модуля, при полной статической сборке.
Рис.17. Проект для главного модуля, при полной статической сборке.

Никакого другого изменения кода в наших проектах мы не делаем. Но не надо забывать, что проекты для статической и динамической сборки у нас хотя и разные, однако у них общий файл «StdAfx.h». Поэтому всегда нужно проверять, какое значение параметра IS_DLL выставлено там.

Все, компилируем и получаем ехе-шники, для х86 и х64, независящие от наших dll. Результаты всей нашей работы можно посмотреть, например, в TotalCommander'e (рис. 18). Имена для exe-файлов можно указывать любые.

Рис.18. Каталоги проекта для динамической и статической сборки.
Рис.18. Каталоги проекта для динамической и статической сборки.

Где скачать проект


Файлы проекта можно скачать по адресу:
emery-emerald.narod.ru/Articles/AppPlugins/AppPlugins_src.zip

Бинарные файлы, полученные в результате компиляции (для 32-х и 64-х разрядных систем) можно получить здесь:
emery-emerald.narod.ru/Articles/AppPlugins/AppPlugins_bin.zip

Pdf-версию данной статьи можно найти по ссылке:
emery-emerald.narod.ru/Articles/AppPlugins/AppPlugins.pdf

Компиляция под разными версиями Visual Studio C++


Данный проект скомпилирован под Visual Studio С++ 2017, Community Edition и SDK 10.0 как для x86, так и x64. Но он может быть получен и на меньших версиях и SDK 8.1, если в файлах, типа, *.vcxproj, сделать соответствующую замену для строк «ToolsVersion=«15.0»» и «v141»:

– В версии VS C++ 2010: «ToolsVersion=«4.0»» и «v100».
– В версии VS C++ 2013: «ToolsVersion=«12.0»» и «v120».
– В версии VS C++ 2015: «ToolsVersion=«14.0»» и «v140».

Часть третья. Выводы


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