Pull to refresh

Архитектура программы на примере коммуникатора

Reading time7 min
Views11K
Хочу поделиться своим опытом в проектировании архитектуры программы. Архитектура — весьма важная вещь для проектов со сложной внутренней структурой и многочисленными внутренними связями. Ошибка в выборе способа решения может сильно аукнуться при дальнейшем развитии проекта, привести к лавинообразному росту сложностей и ошибок. Возможен даже момент, когда проще написать все с нуля, чем распутывать клубок взаимосвязей.
image
Для примера, возьму относительно простую архитектуру однопользовательского приложения. Например, коммуникатор — программу для сетевого общения, которая поддерживает множество разных протоколов, умеет менять внешний вид и должна обладать открытостью для добавления новых возможностей и дальнейшего развития.


С чего начать


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

1. «Говорящие» имена переменных, функций, классов и их методов. Чтобы по одному имени было понятно, что это такое. Исключение можно сделать для сугубо локальных переменных — счетчиков циклов, промежуточных значений.

2. Содержимое блоков (условия, циклы) с отступом, начало и конец блока должны быть на одном уровне.

3. Комментарий поясняет код, а не отражает настроение кодера.

В своих примерах я буду использовать псевдо-язык, похожий на JavaScript без привязки к реальному синтаксису, ибо цель его не дать готовый код, а показать идею. Код подсвечен с помощью Source Code Highlighter

Первая попытка


Попробуем сделать по-простому. Например, в программе есть:

* Главное окно
* Окно настроек

image
Казалось бы, все просто. В модуле главного окна напишем код для подключения к серверу, функцию отправки сообщений на сервер и разбора сообщений с сервера. Настройки возьмем из окна настроек. А в окне настроек сделаем запись-чтение настроек из файла. А теперь сделаем шаг дальше.

* Окно обмена сообщениями
* Окно списка контактов

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

Как быть


Попробуем уменьшить число взаимосвязей и упорядочить их.

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

image

Следует разделять интерфейс пользователя (UI), логику и данные. UI является отображением данных, а не хранилищем. Логика связывает UI и данные. Например, в окне настроек никаких данных нет, все настройки хранятся в конфиге, а окно настроек их только отображает.

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

image

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

Способов сохранения совместимости функций множество. Можно просто создавать новые функции, похожие на старые, а старые оставлять. Напрмер, ExecCommand(CmdText) и ExecCommand2(Cmd, Params). Можно передавать всего два параметра — указатель на структуру с параметрами и версию структуры. Можно использовать открытые структуры. Я лично остановился на варианте передачи параметров в виде командной строки.

Как это сделать


Общедоступные данные и объекты определяем в ядре. Например, конфиг, в котором хранятся настройки программы и который должен быть доступен из любого места. При этом конфиг следует делать открытым, чтобы мы могли свободно добавлять новые опции. Я использую конфиг в виде ассоциативного массива (имя-значение), где имена и значения в виде строк. Это дает 100% гарантию совместимости и безопасности ценой некоторой потери производительности (на поиск значения по имени и преобразование строки значения в требуемый тип). Использовать SQL тоже нужно через ядро, чтобы у компонентов не было привязки к конкретной реализации SQL-сервера и можно было бы менять SQL-сервер без переделывания всей системы. Как вариант, можно через ядро получить объекты для работы с произвольными таблицами и запросами.

// Модуль компонента
function SomeIPClient.Connect();
{
 // Берем имя пользователя и пароль из конфига, который находится в ядре
 // Класс конфига может быть реализован в отдельном модуле,
 // а в ядре описан только его прототип
 UserName = Core.Config['UserName'];
 Password = Core.Config['Password'];
 IPConnection.Open(Host, Port, UserName, Password);
}


Реализуем шаблон MVC (модель-представление-поведение). Например, есть поле ввода текста сообщения и кнопка «Отправить». Нажатие кнопки не должно запускать код отправки сразу — отправка может занять ощутимое время и все это время программа будет «висеть». А если код отправки будет брать данные из поля ввода, то может оказаться, что пользователь успел эти данные поменять или вообще закрыть окно. Поэтому все действия, совершаемые пользователем, нужно сводить к сообщениям или командам. Для этого можно предусмотреть в ядре функцию, которая будет принимать имя и параметры команды, отправителя и получателя. Или набор функций для разных элементарных действий — вызова окна настроек, сворачивания или закрытия программы, проигрывания звуков, итд… В простых программах можно обойтись без сообщений, только функциями. Но все равно, все общедоступные функции должны находиться в ядре, чтобы не было прямых взаимосвязей между разными компонентами программы.

// Модуль окна
function OnCloseWindowClick();
{
 Core.CloseWindow(CurrentWindowID);
}

function ExecCmd(CommandText);
{
 if (CommandText = 'WINDOW_CLOSE') CloseWindow();
}

// Модуль ядра
function Core.CloseWindow(WindowID);
{
 Core.CmdQueue.Add('WINDOW_CLOSE ' + WindowID);
}


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

Автономные компоненты программы, к примеру, модуль связи с сервером и парсер протокола связи могут взаимодействовать друг с другом не через ядро, а напрямую. Но пристыковывать их к ядру нужно последовательно. То есть к ядру стыкуется некий усредненный интерфейс модуля, который не зависит от особенностей его внутренней реализации. Это может быть, к примеру, наследник глобального класса, который принимает сообщения ядра и выполняет команды. А уже за этим классом может скрываться хоть целая отдельная программа любой степени сложности. Этим и достигается высокая масштабируемость и гибкость всей системы — можно добавлять новый функционал, и при этом не придется переписывать пол-программы, достаточно обеспечить совместимость нового функционала с ядром.

// Модуль ядра
СIPClient = class() // Символ "С" в имени означает, что это класс
{
 integer ID;
 string Host;
 string Port;
 virtual function Connect();
 virtual function Disconnect();
 virtual function SendData(Data);
}

// Модуль компонента
СSomeIPClient = class(Core.CIPClient)
{
 function Connect();
 function Disconnect();
 function SendData(Data);
 function ParseServerData(Data);
}


В этом примере компонент определяет наследника глобального класса Core.IPClient, в котором реализуется связь с сервером. И таких компоентов может быть множество, ядро будет различать их по идентификатору ID. Объекты компонентов хранятся в диспетчерах (менеджерах) — списках объектов с методами для доступа и управления хранимыми компонентами. Таким образом, мы можем добавлять все новые и новые компоненты без головной боли — о работе с ними позаботится диспетчер. Структура компонентов — отдельная большая тема.

// Модуль компонента
//
// Эта функция вызывается ядром из модуля компонента
// Создаются новый невизуальный объект и визуальное окно, которые добавляются
// в ядро. В дальнейшем любой другой модуль может передавать им команды.
function StartComponent()
{
 NewComponent = New CSomeComponent;
 Core.ComponentManager.Add(NewComponent); // объект компонента добавляется в ядро

 NewWindow = New CSomeComponentWindow;
 Core.WindowManager.Add(NewWindow); // окно компонента добавляется в ядро
}

// Модуль ядра
//
// Обработчик команд диспетчера окон.
// Например, передаем команду 'WINDOW_CLOSE 15';
function Core.WindowManager.ProccesCmd(CmdText)
{
 Cmd = GetParam(CmdText, 0); // Выделяем команду 'WINDOW_CLOSE'
 WindowID = GetParam(CmdText, 1); // выделяем первый параметр (ID окна) '15'
 Window = Self.GetWindowByID(WindowID); // Получаем объект окна по его ID
 Window.ExecCmd(Cmd); // Передаем команду окну
}


В результате у нас получается ядро, которое собирает в себя объекты компонентов и пересылает между ними сообщения и команды. При этом каждый компонент может работать в отдельном потоке, а если в компоненте произошла ошибка, то вся программа от этого не упадет — компонент может быть закрыт и перезапущен. Можно даже подключать-отключать и отлаживать компоненты на ходу. Конечно, у такой архитектуры есть и недостатки. В основном это непроизводительные потери машинного времени на обработку внутренних команд. Поэтому, для ускорения некоторых операций можно использовать или отдельную приоритетную очередь команд, либо прямой вызов функций. Или передать модулю прямую ссылку на объект другого модуля, чтобы он мог работать с ним напрямую. Это усложнит схему взаимосвязей и понизит надежность, но увеличит быстродействие.
Tags:
Hubs:
Total votes 33: ↑16 and ↓17-1
Comments63

Articles