DEV Community

Tetragius
Tetragius

Posted on

Variant of microservices on the web.

Now there are many articles about microservice architectures on JS on the web.

I will make a reservation right away, I am against the use of the term “microservices” to the frontend on the web, in my opinion it is more correct to say “modules”.

Architecture

Core

Core - provides functionality for loading modules, shared state storage (for example redux), common data bus based on RxJS, common services of modal windows and notifications. If you wish, you can expand the list.

The core loads modules at the request of the user or at the request of another module.

Module

The module is a regular web application that can be assembled as a UMD module and exported outside the entry point for connecting to the kernel.

In my example there will be three of them: The main react component for rendering, the Reducer that needs to be connected to the shared storage, and the common services and functions that the module is ready to share according to contracts.

Module can be not only a page, it can be some kind of widget or plugin, or just a set of auxiliary functions

Communication between modules

Modules communicate via the kernel by requesting shared services. Or via RxJS bus according to the protocol specified in the contracts.

Explanation in the code

Inside the module

Most likely in your application there is something like

...
import App from './containers/app';
...

ReactDOM.render(
  <Provider store={store}>
    <Router>
        <App/>
    </Router>
  </Provider>,
  document.getElementById('app'),
);

to create a module, you just need to create a new file (for example, main.tsx) with the next content

export { default as Main } from './containers/app';
export { default as reducer } from './redux/reducers';
export { default as shared } from './services/shared-service';

This will be the three entry points expected by our core.

where

...'./redux/reducers'

const reducers = combineReducers<IState>({
  requests: requestsReducer,
  tasks: maintenanceTaskReducer,
  main: mainReducer
});

export default reducers;
...

...'./services/shared-service'

interface ISharedService {
    mapper(type: string, item: any);
    openPlate(type: string, item: any);
    render(type: string, item: any);
}

class $SharedService implements ISharedService {
    task = new MaintenanceTask(null, null);
    maintenance_audit = new Tasks.MaintenanceAuditTask(null, null);
    maintenance_detach_gui = new Tasks.MaintenanceDetachGuiTask(null, null);
    maintenance_utp_request = new MaintenanceTask(null, null);
    request = new MaintenanceRequest(null, null);
    mapper = (type: string) => this[type] && this[type].mapper || TaskFactoryByTypeName(type);
    openPlate = (type: string) => this[type] && this[type].openPlate || TaskFactoryByTypeName(type);
    render = (type: string) => this[type] && this[type].render || TaskFactoryByTypeName(type);
}

const SharedService = new $SharedService();

export default SharedService;
...

Let's say a little about SharedService. This is an exported static service.
A third-party module may request something from the core.

Find the module - module_name, then find the common service and the openPlate method in it, then call this method to render maintenance_detach_gui

(getShared in core explanation section)

The last file to add is a stub so that the module can work inside and outside the core.

import * as PS from 'portal-service';

class WebBase {

  static sendNotify(notify: any, type: string, delay: number, closeable: boolean = false) {
    try {
      return PS && PS.notification.send(notify, type, delay, closeable);
    }
    catch (e) {
      return;
    }
  }

  static sendStream(message: { type: string, body: any }) {
    try {
      return PS && PS.stream.next(message);
    }
    catch (e) {
      return;
    }
  }
}

export default WebBase;

Pay attention to the portal-service import, we will talk about it later.

And add to the webpack assembly module

...
output: {
    path: paths.build,
    filename: 'index.min.js',
    library: 'Main',
    libraryTarget: 'umd',
},
externals: {
    'portal-service': 'portal-service',
...

At the output we have to get three files.

  • index.min.js
  • main.css
  • manifest.json

manifest.json - core needs to load the module

{
    "id": "D63E7031-DD51-42E3-979E-85107F4DB58F",
    "name": "maintenance",
    "version": "7.0.0"
}

Inside the core

In order for portal-service to be available in the module, the closure function over the requireJS is used. This allows us to intercept the require ('portal-service') from the module.

And at the moment of interception, we return to the module our object providing the common services of the kernel.

(after building webpack, import ... from is replaced by require)

It also opens up the possibility for modules to load their own dependencies separately from the kernel and other modules.

But to use such a closure, it is necessary that the kernel be compiled by gulp, since the webpack uses its own mechanism instead of requireJS.

I chose such a mechanism because I didn’t want to push dependencies into the module from the kernel, I wanted the module to determine where the fire itself was running

registerPageSync(page: any): boolean {
        if ($ExtModuleService.registredPage[page.name]) {
            return true;
        }

        var self = this;

        function reqListener(responseText) {
            try {

                let getPage = new Function('exports', 'module', responseText + " ;return module.exports;");

                //dependency injection start

                let injectPrepare = new Function('injector', 'page', `

                    var closure = (function(r){
                        var _require = r;
                        var _modules = {};
                        return {require: require, modules: _modules};
                    })(window.require);

                    window.require = function(o, u){
                        try{
                            return closure.require(o, u);
                        }
                        catch(e){
                            if(!closure.modules[o]){
                                console.log('inject : ' + o + ' by: ' + page.name);
                                closure.modules[o] = injector(o, page);
                                return closure.modules[o];
                            }
                            else{
                                return closure.modules[o];
                            }
                        }
                    }

                `);

                var fakeInjector = function (name, page: any) {
                    if (name === "portal-service") {
                        return self.injectPortalService();
                    }
                    else {
                        if (page.dependencies) {
                            for (var depName in page.dependencies) {
                                if (depName === name) {

                                    let dep = page.dependencies[depName];
                                    let oReq = new XMLHttpRequest();
                                    dep.path && oReq.open("GET", `${dep.path}/${dep.fileName}`, false);
                                    !dep.path && oReq.open("GET", `pages/${page.name}/dependencies/${depName}/${dep.fileName}`, false);
                                    oReq.send();

                                    if (oReq.status === 200) {
                                        return eval(oReq.responseText);
                                    }
                                    else {
                                        return false;
                                    }

                                }
                            }
                        }
                    }
                }

                injectPrepare(fakeInjector, page);

                //dependency injection end

                let _page = getPage({}, { exports: null });

                let o = {};
                o[page.name] = React.createElement(_page.Main);

                if (_page.reducer) {
                    injectAsyncReducer(page.name, _page.reducer);
                }

                _.assign($ExtModuleService.registredPage, o);
                return true;
            }
            catch (e) {
                console.log(e);
                return false;
            }
        }

        let fileref = document.createElement("link");
        fileref.setAttribute("rel", "stylesheet");
        fileref.setAttribute("type", "text/css");
        fileref.setAttribute("href", `pages/${page.name}/main.css?ver=${page.version}`);
        document.getElementsByTagName("head")[0].appendChild(fileref);

        if (page.externalLib) {
            let lib = document.createElement("script")
            lib.setAttribute("type", "text/javascript")
            lib.setAttribute("src", `pages/${page.name}/${page.externalLib}?ver=${page.version}`);
            document.getElementsByTagName("head")[0].appendChild(lib);
        }

        let oReq = new XMLHttpRequest();
        oReq.open("GET", `pages/${page.name}/index.min.js?ver=${page.version}`, false);
        oReq.send();

        if (oReq.status === 200) {
            return reqListener(oReq.responseText)
        }
        else {
            return false;
        }

This will be available to the module upon calling portal-service.

 injectPortalService() {
        return {
            auth: AuthService,
            stream: MainService.mainStream,
            notification: NotificationService,
            ws: wsService,
            store: store,
            history: history,
            getPermissions: (name) => AuthService.getEmployeePermissionsByModule(name),
            shared: SharedService.services,
            getShared: (module) => SharedService.getSharedSafity.call(SharedService, module),
            modals: (props) => new ModalService(props)
        }
    }

So the module reducer connects to the global storage.

export const injectAsyncReducer = (name: string, asyncReducer: any) => {
    (<any>store).asyncReducers[name] = asyncReducer;
    (<any>store).replaceReducer(createReducer((<any>store).asyncReducers));
}

In the place where we want to render our module

...
{this.props.pages.map((page) =>
    [
        <Route
            key={page.id}
            path={`/${page.name}`}
            render={(props) => <PageContainer key={page.id + '_requests'} page={page} {...props} />} />
    ])
}
...

inside the PageContainer we use getPageElementAsync and render our module

 ExtModuleService.getPageElementAsync(_page).then(
    page => {
        if (page) {
            let content = React.cloneElement<any, any>(page as any, { ...this.props })
            this.setState({ content: content });
        }
        else {
            this.setState({ error: true });
        }
    }
);

How closure works

UMD modules always contain a string like

!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t...

This allows the closure described earlier to work.


Conclusion

This approach allows the development of modules for independent teams based on contracts. And run the modules as independent applications or as part of the core.

The kernel defines the mechanisms for loading and interacting with modules by coordinating their work and providing common mechanisms for work such as requesting user rights or authentication.

Of course, this is only one of the options for the implementation of microservice architecture on the web. And perhaps not the best. But it successfully works on my working project with more than six modules and ten widgets. Including a module that consists only of calls to common methods and renders from other modules.

And of course, the CORS and backend infrastructure settings were left behind the scene, but that’s another story.

Thank!

If you like you can also read:

Top comments (0)