The architecture of the program on the example of the communicator

    I want to share my experience in designing the architecture of the program. Architecture is a very important thing for projects with a complex internal structure and numerous internal connections. An error in the choice of a solution method can strongly come up with further development of the project, leading to an avalanche-like growth of difficulties and errors. There may even be a moment when it is easier to write everything from scratch than to unravel the tangle of relationships.
    image
    For an example, I will take rather simple architecture of the single-user application. For example, a communicator is a program for network communication that supports many different protocols, can change its appearance and should be open to adding new features and further development.


    Where to begin


    For starters, I recommend that you follow the rules for code design. This is also very important, since accurate code is easier to read, and many design errors are easier to find and fix. A good example can be seen here . Let me remind you the main thing:

    1. "Talking" names of variables, functions, classes and their methods. To one name it was clear what it is. An exception can be made for purely local variables - cycle counters, intermediate values.

    2. The contents of the blocks (conditions, cycles) indented, the beginning and end of the block should be at the same level.

    3. The comment explains the code, but does not reflect the mood of the encoder.

    In my examples, I will use a pseudo-language similar to JavaScript without binding to the real syntax, forits purpose is not to give ready-made code, but to show an idea . Code highlighted with Source Code Highlighter

    First try


    Let's try to make it simple. For example, the program has:

    * The main window
    * The settings window

    image
    It would seem that everything is simple. In the module of the main window, we write the code for connecting to the server, the function of sending messages to the server and parsing messages from the server. Take the settings from the settings window. And in the settings window, write-read the settings from the file. Now let's take a step further.

    * Messaging
    window * Contact list window

    image
    At the same time, the contacts list windows can be nested both in the main window and in each other. This is also solvable, you can link it all together. But even with the slightest change in one element, it is necessary to track all its relationships. And the more program elements there are, the more and more confusing the relationships will be. And if you add support for plugins, different protocols and languages, then the whole project will become cancer.

    How to be


    Let's try to reduce the number of relationships and organize them.

    We build relationships not directly, but in the form of a tree. At the root of the tree is the core of the program, all other relationships will pass through it. Direct relationships between different branches of a tree should be avoided. If we need new functionality, add its definition to the kernel. Implementation will be in a separate branch.

    image

    Separate user interface (UI), logic, and data. UI is a data mapping, not a repository. Logic connects UI and data. For example, there is no data in the settings window, all settings are stored in the config, and the settings window only displays them.

    It is advisable to avoid direct code invocation (we call the kernel function, which calls the module function), and use messages (we create a message that will be picked up by the addressee) or the command queue (we put the command in the queue, the kernel will process it). This will make the program multitask and more stable with internal errors and crashes.

    image

    We maintain openness and backward compatibility of interfaces as much as possible. If we have a certain global function with a fixed set of parameters, then changing its definition will entail a rework of all the modules that use it. This is not critical inside the program; modern IDEs have good tools for this. And if we change the plugin interface, then all the old developments will stop working.

    There are many ways to maintain compatibility of functions. You can simply create new functions similar to the old ones, and leave the old ones. For example, ExecCommand (CmdText) and ExecCommand2 (Cmd, Params). You can pass only two parameters - a pointer to a structure with parameters and a version of the structure. You can use open structures. I personally settled on the option of passing parameters in the form of a command line.

    How to do it


    Public data and objects are defined in the kernel. For example, a config in which the program settings are stored and which should be accessible from anywhere. In this case, the config should be made open so that we can freely add new options. I use the config in the form of an associative array (name-value), where the names and values ​​are in the form of strings. This gives a 100% guarantee of compatibility and security at the cost of some performance loss (for finding a value by name and converting a string of values ​​to the required type). You also need to use SQL through the kernel, so that the components do not have a binding to a specific implementation of the SQL server and it would be possible to change the SQL server without redoing the entire system. Alternatively, you can get objects through the kernel for working with arbitrary tables and queries.

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


    We implement the MVC pattern (model-view-behavior). For example, there is a text message input field and a “Send” button. Pressing the button should not start the sending code immediately - sending may take a noticeable time and the program will “hang” all this time. And if the sending code will take data from the input field, it may turn out that the user managed to change this data or even close the window. Therefore, all actions performed by the user must be reduced to messages or commands. To do this, you can provide a function in the kernel that will take the name and parameters of the command, sender and receiver. Or a set of functions for different elementary actions - opening the settings window, minimizing or closing a program, playing sounds, etc. ... In simple programs, you can do without messages, only functions. But still,

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

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

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


    In this example, when we press the close button, the window does not close immediately, but a command is sent to the kernel with the window identifier. By this, by the way, the execution of the code chain ends, another command within the program can process the command itself. In this case, the kernel can send a message to all (or only interested) modules that a window is closing, to perform some general actions related to closing the window. And only then tells the window to close. Full flexibility and control, at the cost of some overhead when processing commands.

    Autonomous program components, for example, a server communication module and a communication protocol parser, can interact with each other not directly through the kernel, but directly. But you need to dock them to the core sequentially. That is, a certain averaged interface of the module is docked to the kernel, which does not depend on the features of its internal implementation. This may be, for example, the heir to the global class that receives kernel messages and executes commands. And even this entire class can hide even a separate program of any degree of complexity. This achieves high scalability and flexibility of the entire system - you can add new functionality, and you do not have to rewrite half the program, it is enough to ensure compatibility of the new functionality with the kernel.

    // Модуль ядра
    С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);
    }


    In this example, the component defines the inheritor of the global class Core.IPClient, in which communication with the server is implemented. And there can be many such components, the kernel will distinguish them by ID. Component objects are stored in dispatchers (managers) - lists of objects with methods for accessing and managing stored components. Thus, we can add more and more new components without a headache - the dispatcher will take care of working with them. Component structure is a separate big topic.

    // Модуль компонента
    //
    // Эта функция вызывается ядром из модуля компонента
    // Создаются новый невизуальный объект и визуальное окно, которые добавляются
    // в ядро. В дальнейшем любой другой модуль может передавать им команды.
    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); // Передаем команду окну
    }


    As a result, we get a kernel that collects component objects and forwards messages and commands between them. In addition, each component can work in a separate thread, and if an error occurs in the component, the entire program will not fall from this - the component can be closed and restarted. You can even plug-disconnect and debug components on the go. Of course, such an architecture has its drawbacks. Basically, these are unproductive losses of computer time for processing internal commands. Therefore, to speed up some operations, you can use either a separate priority queue of commands or a direct call to functions. Or pass a module a direct link to an object of another module so that it can work directly with it. This will complicate the relationship scheme and reduce reliability, but increase performance.

    Also popular now: