打开多个文档(项目)的应用程序的体系结构

ezp*_*sso 16 c++ architecture qt conceptual

我正在研究基于Qt的CAD应用程序,我正在试图弄清楚应用程序的架构.该应用程序能够加载包含计划,部分等的多个项目,并在专用视图中显示这些图形.每个项目和全局配置.

应用程序由全局对象表示,派生自QApplication:

class CADApplication Q_DECL_FINAL: public QApplication {
    Q_OBJECT
public:
    explicit CADApplication(int &args, char **argv);
    virtual ~CADApplication();
    ...
    ProjectManager* projectManager() const;
    ConfigManager* configManager() const;
    UndoManager*  undoManager() const;

protected:
    const QScopedPointer<ProjectManager> m_projectManager;
    const QScopedPointer<ConfigManager> m_configManager;
    ...
};
Run Code Online (Sandbox Code Playgroud)

"管理器"是在CADApplication构造函数中创建的.他们负责与加载的项目(ProjectManager),全局配置选项(ConfigManager)等相关的功能.

还有项目视图,配置选项对话框和可能需要访问"管理器"的其他对象.

为了获得当前项目,SettingsDialog需要:

#include "CADApplication.h"
#include "ProjectManager.h"
...
SettingsDialog::SettingsDialog(QWidget *parent)
: QDialog(parent)
{
    ...
    Project* project = qApp->projectManager()->currentProject();
    ...
}
Run Code Online (Sandbox Code Playgroud)

我喜欢整个方法的是它遵循RAII范式."管理者"是在applcation的实例化/销毁时创建和销毁的.

我不喜欢的是它不容易出现循环引用,并且我需要在每个源文件中包含"CADApplication.h",其中需要任何"管理器"的实例.这就像CADApplication对象被用作这些"经理"的某种全球"持有者".

我做了一些研究.似乎还有其他一些方法暗示使用单身人士.OpenToonz使用TProjectManager单例:

class DVAPI TProjectManager {
...
public:
    static TProjectManager *instance();
    ...
};

TProjectManager* TProjectManager::instance() {
    static TProjectManager _instance;
    return &_instance;
}
Run Code Online (Sandbox Code Playgroud)

在每个文件中,他们需要访问项目经理:

#include "toonz/tproject.h"
...

TProjectManager *pm = TProjectManager::instance();
TProjectP sceneProject = pm->loadSceneProject(filePath);
Run Code Online (Sandbox Code Playgroud)

根据您的经验,为了追求良好的架构并使应用程序容易出错并简化单元测试,我应该坚持使用哪种方法?也许还有其他范例?

Dra*_*rgy 11

我在VFX工作,这与CAD略有不同,但至少在建模方面没有太大差异.在那里,我发现围绕命令模式旋转应用程序设计非常有用.当然,您不一定要为此制作一些ICommand界面.std::function例如,您可能只使用和lambdas.

消除/减少单个对象实例的中央依赖性

However, I do things a bit differently for application state in that I favor more of a "pull" paradigm instead of a "push" for the type of things you're doing (I'll try to explain this better below in terms of what I mean by "push/pull"*) so that instead of a boatload things in "the world" accessing these few central manager instances and telling them what to do, the few central managers sorta "access the world" (not directly) and figure out what to do.

  • Apologies in advance. I am not a very technically precise person and English is also not my native language (from Japan). If you can bear through my poor attempts at describing things, I think there will be some useful information there to some people. At least it was useful to me in ways I had to learn the hard way.

What I propose will not only eradicate singletons, but it will eradicate fan-in towards central application object instances in general which can make things a whole lot easier to reason about, make thread-safe, unit test, etc. Even if you use dependency injection over singletons, such application-wide objects and shared state, heavily depended upon, can make a lot of things more difficult from reasoning about side effects to the thread safety of your codebase.

What I mean is that instead of, say, every single command writing to an undo manager which would require everything that can write undoable state to be coupled to this undo system, I invert the flow of communication away from the central object instance. Instead of this (fanning in towards single object instance):

在此输入图像描述

我建议翻阅/反转通信(从一个中心实例扇出到多个实例):

在此输入图像描述

And the undo system is no longer being told what to do anymore by everyone else. It figures out what to do by accessing the scene (specifically the transaction components). At the broad conceptual level I think of it in terms of "push/pull" (though I've been accused of being a bit confusing with this terminology, but I haven't come up with any better way to describe or think about it -- funnily it was a colleague who originally described this as "pulling" data as opposed to "pushing it" in response to my poor attempts at describing how a system worked to the team and his description was so intuitive to me that it has stuck with me ever since). In terms of dependencies between object instances (not object types) it's kind of replacing fan-in with fan-out.

Minimizing Knowledge

When you do that, you no longer have to have this manager kind of thing being centrally depended upon by so many things, and no longer have to include its header file all over the place.

There is still a transaction component that everything undoable has to include the header file for, but it's much, much simpler than the full-blown undo system (it's actually just data), and it's not instantiated just once for the entire application (it gets instantiated locally for every single thing that needs to record undoable actions).

The allows "the world" to work with less knowledge. The world doesn't have to know about full-blown undo managers, and undo managers don't have to know about the entire world. Both now only have to know about these uber simple transaction components.

Undo Systems

Specifically for undo systems, I achieve this kind of "inversion of communication" by having each scene object ("Thing" or "Entity") write to its own local transaction component. The undo system then, periodically (ex: after a command is executed) traverses the scene and gathers all the transaction components from each object in the scene and consolidates it as a single user-undoable entry, like so (pseudocode):

void UndoSystem::process(Scene& scene)
{
    // Gather the transaction components in the scene
    // to a single undo entry.
    local undo_entry = {}

    // Our central system loop. We loop through the scene
    // and gather the information for the undo system to
    // to work instead of having everything in the scene
    // talk to the undo system. Most of the system can
    // be completely oblivious that this undo system even
    // exists.
    for each transaction in scene.get<TransactionComponent>():
    {
        undo_entry.push_back(transaction);

        // Clear the transaction component since we've recorded
        // it to the undo entry so that entities can work with
        // a fresh (empty) transaction.
        transaction.clear();
    }

    // Record the undo entry to the undo system's history
    // as a consolidated user-undoable action. I used 'this'
    // just to emphasize the 'history' is a member of our
    // undo system.
    this->history.push_back(undo_entry);
}
Run Code Online (Sandbox Code Playgroud)

This has the added bonus in that each entity in the scene can write to its own associated, local transaction component in its own separate thread without having to, say, deal with locks in critical execution paths trying to all write directly to a central undo system instance.

Also you can easily have more than one undo system instance using this approach. For example, it might be kind of confusing if a user is working in "Scene B" or "Project B" and hits undo only to undo a change in "Scene A/Project A" which they are not immediately working in. If they can't see what's happening, they might even inadvertently undo changes they wanted to keep. It'd be like me hitting undo in Visual Studio while working in Foo.cpp and accidentally undoing changes in Bar.cpp. As a result you often want more than one undo system/manager instance in software that allows multiple documents/projects/scenes. They should not necessarily be singletons let alone application-wide objects, and instead should often be document/project/scene-local objects. This approach will easily allow you to do that and also change your mind later, since it minimizes the amount of dependencies in the system to your undo manager (perhaps only a single command or two in your system needs to depend on it*).

  • 您甚至可以将撤消/重做命令与撤消系统解耦,例如,将一个UserUndoEventUserRedoEvent多个命令推送到命令系统和撤消系统都可以访问的中央队列.同样,这使得它们都依赖于这些事件和事件队列,但事件类型可能更简单(它可能只是一个存储预定义命名常量的值的整数).这是同样适用的策略.

避免扇入到中心对象实例

This is my preferred strategy whenever I find a case where you want many, many things to depend on one central object instance with major fan-in. And that can help all kinds of things from reducing build times, allowing changes to occur without rebuilding everything, more efficient multithreading, allowing design changes without having to rewrite a boatload of code, etc.

And that can likewise alleviate the temptation to use singletons. To me if dependency injection is a real PITA and there's a strong temptation to use singletons, I see that as a sign to consider a different approach to the design until you find one where dependency injection isn't a PITA anymore which is usually achieved through a lot of decoupling, specifically of a kind that avoids fan-in from a boatload of different places in code.

Seeking that is not the result of zealously trying to avoid singletons, but even pragmatically, the types of designs that tempt us to use singletons are often difficult to scale without their complexity becoming overwhelming at some point, since it implies a boatload of dependencies to central objects (and their states) all over the place, and those dependencies and interactions become really difficult to reason about in terms of what is actually going on after your codebase reaches a large enough scale. Dependency injection alone doesn't solve that issue necessarily, since we can still end up with a boatload of dependencies to application-wide objects injected all over the place. Instead what I've found to significantly mitigate this issue is to seek out designs that specifically make dependency injection no longer painful to do which means eliminating the bulk of those dependencies to application-wide instances of objects outright.

I do still have one central, very generalized, simple thing that many things depend on in my case, and what I propose will need at least one generalized data structure that's rather centralized. I use an entity-component system like this:

在此输入图像描述

... or this:

在此输入图像描述

So every single system in my case is dependent on the central ECS "database" instance and there I haven't been able to avoid fan-in. However, that's easy enough to inject since there are only a few dozen hefty systems that need dependency injection like a modeling system, physics system, rendering system, GUI system. Commands are also executed with the ECS "database" uniformly passed to them by parameter so that they can access whatever is needed in the scene without accessing a singleton.

Large-Scale Designs

Above all other things, when exploring various design methodologies, patterns, and SE metrics, I've found that the most effective way to allow systems to scale in complexity is to isolate hefty sections into "their own world", relying on the minimum amount of information to work. "Complete decoupling" of a kind that minimizes not only concrete information required for something to work, but also minimizes the abstract information as well, is, above all other things, the most effective way I've found to allow systems to scale without overwhelming the developers maintaining them. For me it's a huge warning sign when you have, say, a developer who knows NURBs surfaces inside out and can whip up impressive demos related to them, but is tripping over bugs in your system in his loft implementation not because his NURBs implementation is incorrect, but because he's dealing with central application-wide states incorrectly.

"Complete decoupling" breaks each hefty section of the codebase, like a rendering system, into its own isolated world. It barely needs information from the outside world to work. As a result the rendering developer can do his thing without knowing much about what's going on elsewhere. The alternative when you don't have these kinds of "isolated worlds" is a system whose centralized complexity spills into every single corner of the software, and the result is this "organic whole" which is very difficult to reason about and kind of requires every developer to know something about just about everything.

The somewhat (possibly) unusual thought I have is that abstractions are often considered the key way to decouple systems and objects, but an abstraction only reduces the amount of concrete information required for something to work. We may still find a system turning into this "organic whole" by having a boatload of abstract information that everything needs to work, and that can sometimes be just as difficult to reason about as the concrete alternative, even if it leaves more breathing room for changes. To really allow a system to scale without overwhelming our brains, the ultimate way I've found beneficial is to not make systems depend on a lot of abstract information, but to make them depend on as little information as possible of any form outright from the outside world.

It can be a useful exercise to just sit down with a part of a system and ask, "How much information from the outside world do I need to comprehend to implement/change this thing?"

在此输入图像描述

In a former codebase I worked in (not of my design), the physics system above had to know about scene hierarchies, the property system, abstract transformer interfaces, transformation stacks, keyframes, envelopes, undo systems, nodal graph evaluation, selections, selection modes, system-wide properties (configs, i.e.), different types of geometry abstractions, particle emitters, etc. etc., and just to get started applying gravity to something in the scene. These were all abstract dependencies but perhaps to over 50 different non-trivial abstract interfaces in the system to comprehend from the outside world before we can even begin comprehending what the physics system is doing.

And naturally a physics system has a conceptual dependency to some of these concepts, but not all, and the ones that are there shouldn't need to expose too much information in order for a physics system to do its thing. That system was very difficult to maintain and often required a developer to spend a couple of years in training and study before he could even start contributing anything really substantial while even the seasoned veterans were having a difficult time figuring out what was going on because the amount of central information being accessed and modified required them to be on top of what the physics system was doing to some degree even if they were working on things completely unrelated.

So I think it's worth stepping back and thinking about these things in just a very human, "How much do I need to know to implement/change this thing vs. how much should I be required to know?" kind of way and seek to minimize the information if it far exceeds the second thought, and one of the ways to do that is using this proposed strategy to avoid fanning in on central application object instances. I'm generally obsessed with the human aspects but have never been so good at the technical aspects. Perhaps I should have gone into psychology, though it doesn't help that I'm insane. :-D

"Managers" vs. "Systems"

Now I'm a terrible person when it comes to proper technical terminology (somehow in spite of having designed a number of architectures, I'm still awful at communicating things in a technically precise way). But I have noticed a tendency towards "managers" often being used with a "push" mindset almost unanimously by developers who create anything they refer to as such. We request/push changes to be done centrally by telling these "managers" what to do.

Instead I have found it useful to favor what I want to call "systems", just having picked that up from architectures popular in game development. Systems "pull" as I want to call it. Nobody tells them specifically what to do. They instead figure it out on their own by accessing the world, pulling data from it to figure out what to do and do it. The world doesn't access them. They loop through things and do something. You can potentially apply this strategy to many of your "managers" so that the entire world isn't trying to talk to them.

This kind of "system" mindset (apologies if my terminology is so poor and my distinctions so arbitrary) was so useful to me to break free from a lot of "managers". I now only have one such central "manager" which is that ECS database, but it's just a data structure for storage. Systems just use them to retrieve components and entities to process in their loops.

在此输入图像描述

Destruction Order

Another thing this type of strategy tackles is destruction order which can get quite hairy for complex systems. Even when you have one central "application object" storing all these managers and thereby controlling their initialization and destruction order, it can sometimes be easy, especially in a plugin architecture, to find some obscure case where, say, a plugin registered something to the system which, upon shutdown, wants to access something central after it has already been destroyed. This tends to happen easily if you have layers upon layers of abstractions and events going on during shutdown.

While the actual examples I've seen are much more subtle and varied than this, a very basic example I just made up so that we can keep the example revolving around undo systems is like an object in the scene wanting to write an undo event when it is destroyed so that it can be "restored" by the user on undo. Meanwhile that object type is registered through a plugin. When that plugin is unloaded, all instances that are still remaining of the object then get destroyed. We might then run into that hairy destruction order issue if the plugin manager unloads plugins after the undo system has already been destroyed.

It might be a non-issue for some people but my team used to struggle with this a lot in a former codebase with obscure shutdown crashes (and the reasons were often far more complex than the simple example above), and this makes it a non-issue because, for example, nothing is going to try to do anything with the undo system on shutdown, since nothing talks to the undo system.

Entity-Component Systems

What I've shown in some diagrams are entity-component systems (the undo system code also implies one) and that could be absolute overkill in your case. In my case I'm dealing with a software that is quite huge with a plugin architecture, embedded scripting, visual programming for things like particle systems and shaders, etc.

However, this general strategy of "inversion" to avoid depending on central object instances can be applied without going towards a full-blown entity component system. It's a very generalized strategy you can apply in many different ways.

As a very simple example, you might just create one central abstraction for scene objects like ISceneObject (or even an ABC) with a virtual transaction method. Then your undo system can loop through the polymorphic base pointers to ISceneObject* in your scene and call that virtual transaction method to retrieve a transaction object if one is available.

And of course you can apply this far beyond undo systems. You can apply this to your config manager and so forth, though you might need to keep something like a project manager if that's your "scene". Basically you need one central thing to access all the "scene" or "project" objects of interest in your software to apply this strategy.

With Singletons

I don't use singletons at all these days (came across designs where it's no longer even tempting to use them) but if you really feel the need, then I tended to find it helpful in past codebases to at least hide the gory details of accessing them and minimize unnecessary compile-time dependencies.

For example, instead of accessing the undo manager through CADApplication, you might do:

// In header:
// Returns the undo manager:
UndoManager& undoManager();

// Inside source file:
#include "CADApplication.h"

UndoManager& undoManager()
{
     return *CADApplication::instance()->undoManager();
}
Run Code Online (Sandbox Code Playgroud)

This might seem like a superfluous difference but it can at least slightly reduce the amount of compile-time dependencies you have since a part of your codebase might need access to your undo manager but not the full-blown application object, e.g. It's easier to start off depending on too little information and expand if needed than to start off depending on too much information and reduce in hindsight.

If you change your mind later on and want to move away from singletons and inject the proper things with access to the undo manager, e.g., then the header inclusions also tend to make it easier to figure out what needs what instead of everything including CADApplication.h and accessing whatever it needs from that.

Unit Testing

From your experience, which of these approaches should I stick to in order to pursue good architecture and to make application error prone and simplify unit testing?

For unit testing in general widely-depended upon object instances of a non-trivial kind (singletons or not) can be rather difficult to deal with since it's hard to reason about their states and whether you've thoroughly exhausted the edge cases for a particular interface you're testing (since the behavior of an object might vary if the central objects it depends on change in state).


p-a*_*l-o 3

给定一组在应用程序代码中的任何地方大量使用的类,我将诉诸代理到单例的解决方案。\n例如,作为您的经理之一,称为 Manager,我会给它一个私有实现类,并使其成为单例:

\n\n
class Status{ /* ... */ };\n\nclass ManagerPrivate\n{\npublic:\n    static ManagerPrivate & instance();\n    void doThis();\n    void doThat();\n    void doSomethingElse();\n\n    // etc ...\n\n    Status status() const;\nprivate:\n    Status _status;\n};\n
Run Code Online (Sandbox Code Playgroud)\n\n

该经理的代理可能是这样的:

\n\n
class Manager\n{\npublic:\n    Status doSomething()\n    {\n        ManagerPrivate::instance().doThis();\n        ManagerPrivate::instance().doThat();\n        return ManagerPrivate::instance().status();\n    }\n    //...\n};\n
Run Code Online (Sandbox Code Playgroud)\n\n

因此,我们有一个封装在无状态代理中的有状态单例,并且我们仍然可以依赖继承并拥有管理器层次结构,所有这些都封装了相同的单例:

\n\n
class BaseManager\n{\npublic:\n    virtual ~BaseManager() = default;\n    virtual Status doSomething() = 0;\n};\n\nclass ManagerA : public BaseManager\n{\npublic:\n    Status doSomething()\n    {\n        ManagerPrivate::instance().doThis();\n        ManagerPrivate::instance().doThat();\n        return ManagerPrivate::instance().status();\n    }\n};\n\nclass ManagerB : public BaseManager\n{\npublic:\n    Status doSomething()\n    {\n        ManagerPrivate::instance().doSomethingElse();\n        return ManagerPrivate::instance().status();\n    }\n};\n
Run Code Online (Sandbox Code Playgroud)\n\n

或者包装多个单例的单个 fa\xc3\xa7ade 类,等等。

\n\n

这样,每当需要管理器时,用户都可以包含其标头并在任何需要的地方使用新实例:

\n\n
void someFunction()\n{\n   //...\n\n   Status theManagerStatus = ManagerX().doSomething();\n\n   //...\n}\n
Run Code Online (Sandbox Code Playgroud)\n\n

控制反转仍然是一个可行的功能:

\n\n
BaseManager * theManagerToUse()\n{\n    if(configuration == A)\n    {\n        return new ManagerA();\n    }\n    else if(configuration == B)\n    {\n        return new ManagerB();\n    }\n    // etc ...\n}\n
Run Code Online (Sandbox Code Playgroud)\n