Flexible modular architecture on Yii2 - Part 1: module connection, routing and events

When we develop complicated applications that will be supported for a long time, very important to achieve a flexible and easily changing architecture. Modularity - is one of the main principles which help us do that. All system is splitted apart isolated modules with low connectivity.

Many people think that they cannot write complicated applications in Yii2. But this is a mistake. In this series of articles I want to demonstrate effective method for development of a modular architecture on Yii2, which helps build flexible and easily changing systems.

In the begin I want to note some moments:

  • Our team uses this method in RESTful application. Therefore we will not talk about working with views. Likely this architecture will work correctly and for classic sites. But there can be pitfalls.
  • Only general architecture will be considered here. All questions about performance optimization and customization are out of this article.

The source code with the test data you can find here (Note that link specifies to not last commit).

Problem statement

So, what demands we make to our modular application?

  • Possibility to easily connect and disconnect any modules. Application must work correctly with any set of modules. But the modules should also be able to communicate with each other: exchange messages and commands.
  • Each module can have any number of versions. The versions also can replace each other. It help us to achieve more flexibility. We just develop two versions of the module and connect that which is needed for the current user.
  • Modules must be all-sufficient. It is a result of the previous two demands. Module must contain all that needed for its working and be absolutely independent of other modules, if it can be connected/disconnected in any moment.
  • Possibility to change set of the active modules and their versions without editing the source code. For example, to this could be done from an admin panel.

Cursory view on the architecture

In any application there is logic general for all system. Therefore our system consists of kernel and connectable modules. The file structure is not particularly different from the file structure in all Yii2 applications.

On initialization application iterates by the set of the active modules and connects them. It is important to remember that Yii2 loads modules only at the first access to them. For reasons of economy, we need to connect modules without their loading. But we need immediately add their routes and event handlers to the general list.

Module connection

First of all let's look to the file structure of the modules:

+ app
  + modules
    + example_billing
    | + events
    | + modules
    |   + v1
    |   | + // Standard files of the module
    |   | + V1.php
    |   + v2
    |   | + // Standard files of the module
    |   | + V2.php
    |   + ExampleBilling.php
    + example_tracker
      + events
      + modules
        + v1
        | + // Standard files of the module
        | + V1.php
        + v2
        | + // Standard files of the module
        | + V2.php
        + ExampleTracker.php

Here we look two modules (created for example): example_billing and example_tracker. Both of them have two versions: v1 and v2. All the logic is described in the modules-versions. The root modules contain classes used by all versions (general event objects for example).

Storage of the list of modules and their versions

Available modules and their versions are stored in the database in tables module and module_version. In migrations directory you can view migrations for their creating and filling. Theis models look like this:

namespace app\models\entities;

/**
 * @property int $id It is not AI field. It is id of the module in the application. Compliant with its namespace
 * @property string $name Pretty name (for admin panel)
 * @property bool $is_active If module is inactive, it will not be connected
 * @property int $version_id Id of the active version
 * @property string $source Full class name of the module
 * @property ModuleVersion[] $versions List with all versions of this module (for admin panel)
 * @property ModuleVersion|null $activeVersion Active version
 */
class Module extends ActiveRecord
{
    /** @return ModuleVersionQuery */
    public function getVersions()
    {
        return $this->hasMany(ModuleVersion::class, ['module_id' => 'id']);
    }

    /** @return ModuleVersionQuery */
    public function getActiveVersion()
    {
        return $this->hasOne(ModuleVersion::class, [
            'id' => 'version_id',
            'module_id' => 'id',
        ]);
    }
    // ...
}

/**
 * @property int $id Id подмодуля. It is not AI field. It is id of the module in the application. Compliant with its namespace in the root module
 * @property string $name Pretty name (for admin panel)
 * @property string $source ull class name of the module
 * @property int $module_id Id of the parent module
 */
class ModuleVersion extends ActiveRecord
{
    // ...
}

You can store the list of modules and their versions where you want (in config files for example). But working with database is more comfortably.

The list is generated manually by migrations. In the one hand, a manually add modules and versions to the list is less comfortable. But how often do you add new modules? It happening relatively seldom and add two-lines migration for this is not a big deal. In the other hand, this method gives one very important advantage: the ability to write modules that will not connect directly. For example, if you have several versions which slightly differ from each other, you can move a general logic into a separate version, but not add it to the list of available versions. It will be complicated to do that using an automatic generating list.

Application initialization

On application initialization we need to do two things: get from the modules routing rules and event handlers. It is done by a call of the static methods of the active module-version class. Using static methods allow us to dispense with full module initialization.

Modules-versions extend from \app\components\VersionModule. This class contains two methods:

namespace app\components;

abstract class VersionModule extends Module
{
    /**
     * Returns a list with routing rules.
     *
     * @return array
     */
    public static function getUrlRules()
    {
        return [];
    }

    /**
     * Returns a list with event handlers.
     *
     * @return array [
     *   eventName => [
     *     handler 1,
     *     handler 2,
     *     ...
     *   ]
     * ]
     */
    public static function getEventHandlers()
    {
        return [];
    }
    // ...
}

Connection of the modules looks like this:

namespace app\components;

use app\models\entities\Module;
use yii\web\Application;

class WebApplication extends Application
{
    /** @inheritdoc */
    public function init()
    {
        parent::init();
        // ...
        $this->enableModules();
    }

    /**
     * Connects active version of the modules
     */
    protected function enableModules()
    {
        $modules = Module::find()->active()->with('activeVersion');
        foreach ($modules->each() as $module) {
            if (!$module->activeVersion) {
                continue;
            }
            $this->setModule($module->id, $module->activeVersion->source); // Add the module to the application
            $this->urlManager->registerModuleRules($module->activeVersion); // Registrate routing rules
            $this->eventManager->registerModuleHandlers($module->activeVersion); // Registrate event handlers
        }
    }
    // ...
}

All is simple with the routing rules. UrlManager gets a list of the routes through VersionModule::getUrlRules():

namespace app\components;

use app\models\entities\ModuleVersion;

class UrlManager extends \yii\web\UrlManager
{
    /**
     * Registrates routes of the module.
     *
     * @param ModuleVersion $module
     */
    public function registerModuleRules($module)
    {
        $class = $module->source;
        $this->addRules($class::getUrlRules());
    }
    // ...
}

An event registration we will consider later. And now look to the module connection to the application.

Note that a module-version is added. But it is added with name of its root-module. It is done to ensure that any active version always be available via single name. If module example_billing has active version v1, the call to \Yii::$app->getModule('example_billing') will return \app\modules\example_billing\modules\v1\V1 class. Just because of this we not need clearly specify a module version in urls. It rid clien application of one more dependency.

Work with events

Event-driven model is good for systems with low connectivity. We just fire event when something happening in the module. And the lack of modules that handle this event will not affect the work of the current module. But there is one importent thing.

Event names better to keep in constants. It save us from typos and ease code navigation. Question is where to keep this names? It would be logical to keep them in the module-version. So we can see, what events can module fire. But in this case we get a new dependency: from the one module we directly refer to the constant from the other module. This is unacceptable!

To escape this problem we can keep event names somewhere in single place in the kernel. It is a working method. But then we have to specify the name of the module in the name of the constant. Plus we will get a huge list of the names in single place. There is another method - keep the event names in the root-module class. Which of these methods to use - decide for yourself. I like the second one.

If some versions are not much different from each other, some events will be common for these versions. And there is no need to declare them twice. Here is how event declaring looks in ExampleBilling module:

namespace app\modules\example_billing;

use app\components\MainModule;

class ExampleBilling extends MainModule
{
    const EVENT_EXAMPLE_INVOICE_CREATE = 'example_billing.invoice.create'; // The event that is common to all versions
    const EVENT_V1_EXAMPLE_INVOICE_MODIFY = 'example_billing.v1.invoice.modify'; // The event used in v1 version only
    // ...
}

Events of the kernel can be keeping in the class of the application:

namespace app\components;

use yii\web\Application;

class WebApplication extends Application
{
    const EVENT_EXAMPLE_USER_CREATE = 'core.exampleUser.create';
    // ...
}

Event objects stored in the event directory in the kernel or in modules. And they can be common for some module-versions too.

Event handling

For working with events we use standard Yii2 way, through the application object. For abstraction, we do it through EventManager component (connected in the config):

namespace app\components;

use app\models\entities\ModuleVersion;
use yii\base\Event;

class EventManager
{
    /**
     * Throws an event
     *
     * @param string $name Event name
     * @param Event $event = null
     */
    public function fire($name, $event = null)
    {
        \Yii::$app->trigger($name, $event);
    }

    /**
     * Registration of handlers
     * @param array $handlers
     */
    public function registerHandlers($handlers)
    {
        foreach ($handlers as $event => $callbacks) {
            if (!is_array($callbacks)) {
                $callbacks = [$callbacks];
            }
            foreach ($callbacks as $callback) {
                \Yii::$app->on($event, $callback);
            }
        }
    }

    /**
     * Registration of module handlers
     *
     * @param ModuleVersion $module
     */
    public function registerModuleHandlers($module)
    {
            $class = $module->source;
            $handlers = $class::getEventHandlers();
            $this->registerHandlers($handlers);
    }
    // ...
}

The module or the application returns a list of the event handlers. Any number of handlers can be setted for each event. Any callable type can be as handler. In my opinion, it is more comfortable to keep them in single place. So I use EventHandler classes with static methods for this. But it is only my opinion.

Look how ExampleBilling module returns the event handlers list:

namespace app\modules\example_billing\modules\v1;

use app\components\WebApplication;
use app\components\VersionModule;
use app\modules\example_billing\modules\v1\components\EventHandler;

class V1 extends VersionModule
{
    /** @inheritdoc */
    public static function getEventHandlers()
    {
        return [
            WebApplication::EVENT_EXAMPLE_USER_CREATE => [
                /** @see \app\modules\example_billing\modules\v1\components\EventHandler::userCreateHandler() */
                [EventHandler::class, 'userCreateHandler'],
                /** @see \app\modules\example_billing\modules\v1\components\EventHandler::userCreateOtherHandler() */
                [EventHandler::class, 'userCreateOtherHandler'],
            ],
        ];
    }
    // ...
}

Two handlers for the kernel events are added here.

By we can set handlers for events from other modules (or from current module) in the same way.

Conclusion

In this article, we looked at the base. This is already working system. Next, we will talk about how to facilitate the writing of the low connectivity versions. And also about how to separate migrations and dictionaries into modules.

You can read this article in Russian here.

Comments

Use Markdown
Thanks for your comment!
It will be published after approval.