Skip to content

Instantly share code, notes, and snippets.

@yanli0303
Last active August 31, 2020 18:07
Show Gist options
  • Save yanli0303/53379426aab525e313ee67e7eaee70d9 to your computer and use it in GitHub Desktop.
Save yanli0303/53379426aab525e313ee67e7eaee70d9 to your computer and use it in GitHub Desktop.
Micro Module Services Programming Model

Micro Module Services Programming Model

"Micro Module Services" is a loosely coupled programming model that aims to solve below problems of centralized design patterns such as MVC:

  • Controllers tend to get bigger and bigger over time (and multiple developers), and overly complex.
  • Big controllers would become God objects when they take responsibility for handling all kinds of requests. They end up with lots of code and do all kinds of things, they know and have access to almost everything in an application.
  • Big controllers hurt the isolation of concerns, make it hard to refactor or delete code, require more and more efforts to maintain.
  • Libraries designed in centralized patterns aren't reusable, because it's hard to separate one of the functionalities from the central points such as controllers.
  • Unit testing is difficult as test cases need to mock dependencies.

The Micro Module Services programming model borrows concepts from microservices, it structures an application as a collection of module services that are

  • Highly maintainable and testable.
  • Loosely coupled.
  • Highly reusable.
  • Organized around business capabilities.
  • Owned by a small team.

Similar to microservices, the Micro Module Services programming model enables the rapid, frequent and reliable delivery of the applications. It also saves troubles for the teams to evolve their technology stacks.

The Micro Module Services programming model is a loosely coupled architectural style, it is a conceptional market that has an only message channel. The modules pull and push information from/to the market through the message channel using serializable messages, but they don't know the existence of other modules, they can't communicate with each other directly. Upon the initialization, the modules register themselves in the market and extend the market by providing a set of services - a list of message types they can process, or in another word, a list of requests they can respond; when any module needs information from the market, they put a contract (request message) on the market, and one of the other modules will take and fulfill it. In the Micro Module Services programming model, there isn't a centralized state or centralized controllers, the modules don't share state but maintain their own internal states. The modules are loosely coupled by the market, they hide their internal logic and have complete autonomy, they can be developed with different frameworks and tools and are highly reusable.

Micro Module Services Messaging Channel

The market maintains a services registry for message delivery but it doesn't contain any business logics, it defines the messaging protocol and provides a communication channel but it doesn't understand the message details.

Messages are serializable to support IPC

The communication is through serializable messages for the sake of Inter-Process-Communications or Inter-Server-Communications support, in which cases the APIs must be asynchronous.

Micro Module Services Messaging Protocol

The Micro Module Services will follow below messaging protocol.

  • All messages - incoming and outgoing - must be encoded in JSON using UTF-8 character encoding.
  • All message field names are in camelCase starts with a lowercase character.
  • The messaging channel validates every message, it raises errors and refuses to deliver the messages that don't conform to the protocol.
  • There are three kinds of messages: request messages, response messages, and notifications.

Request Messages

A request message will be delivered to a single recipient, and the requestor expects a response.

interface RequestMessage {
  /**
   * The message type indicates what API to consume.
   * Should be lowercase English words connected by hyphen, max length = 120 characters.
   */
  type: string, // e.g. 'get-user-profile'

  /**
   * An unique client or module identifier assigned by the messaging channel,
   */
  from: string, // e.g. 'cache-manager-a1b2c3d4'

  /**
   * Usually a number or a string assigned by a client or a module.
   * It should be unique in the assignor's domain so that the assignor can identify the
   * message later after it is sent.
   */
  messageID?: number | string,

  /**
   * The message content, such as API call parameters.
   */
  body?: any
}

Response Messages

A response is a reply to a request, it shall be delivered to the requestor.

interface ResponseMessage {
  /**
   * The response message type shall be the same as the request message type.
   */
  type: string, // e.g. 'get-user-profile'

  /**
   * An unique client or module identifier assigned by the messaging channel,
   * indicates whom sent the response.
   * Before sending to the client, this field shall be removed.
   */
  from?: string, // e.g. 'cache-manager-a1b2c3d4'

  /**
   * Indicates where the message should be delivered, usually same as the `from`
   * of the request message.
   * Before sending to the client, this field shall be removed.
   */
  to?: string, // e.g. 'client',

  /**
   * The `messageID` of the request message.
   */
  requestMessageID?: number | string
}

interface SuccessResponseMessage extends ResponseMessage {
  /**
   * The response message content.
   * Specify `null` to indicate an empty response.
   */
  body: any,
}

interface ErrorResponseMessage extends ReponseMessage {
  /**
   * The error occurred.
   */
  error: {
    /**
     * Unique human readable error code
     * Should be upper case English words separated by underscore, max length = 120 characters.
     */
    code: string, // e.g. 'FILE_CREATE_PERMISSION_DENIED'

    /**
     * A basic description about the error code, it is static, and defined in development time.
     */
    title: string, // e.g. 'Permission denied to create a file at specified path.',

    /**
     * A description of why the request resulted a failure, may include a bit of request details.
     * It is dynamically generated at run time.
     */
    message: string, // e.g. 'It was failed to create file at /root/b/a.txt, permission denied.',

    /**
     * The original request message.
     * Include the original request message in some error cases such as message type wasn't recognized.
     */
    request?: RequestMessage,

    /**
     * The response message.
     * Include this field in some error cases such as a response has an unreachable delivery address.
     */
    response?: ResponseMessage,

    /**
     * The inner error occurred, such as HTTP, Database, or File I/O errors.
     */
    innerError?: any
  }
}

Notifications

Notification messages shall be broadcasted to all modules and clients (no need to differentiate internal notifications and notifications for clients).

interface NotificationMessage {
  /**
   * The notification type.
   * Should be lowercase English words connected by hyphen, max length = 120 characters.
   */
  type: string, // e.g. 'user-authenticated',

  /**
   * An unique client or module identifier assigned by the messaging channel,
   * indicates whom posted the notification.
   */
  from: string, // e.g. 'cache-manager-a1b2c3d4',

  /**
   * Additional headers of the notification.
   */
  headers?: {
    [name: string]: any;
  }
}

Clients are special modules that don't provide any services

Clients (when the application is a server) are special modules of Micro Module Services:

  • They might post notifications to the market.
  • They consume most of the services provided by the market.
  • But they don't provide any services.

Micro Module Services is different from microservices

Micro Module Services borrows the decoupling concept from microservices but they aren't exactly the same. The Micro Module Services solves problems at a lower level than microservices, it tries to separate the tightly coupled modules in an application and improve module autonomy, but the modules still can cooperate.

Micro Module Services makes it hard for developers to add dependencies, it encourages developers to isolate concerns and decompose a problem to small ones and assign them to modules to solve.

microservices Micro Module Services
Decouple big applications to small services Decouple small applications to smaller module services
Services run independently of each other Services can't run independently, but the dependency relationship is indirect
Services can be deployed independently of each other Services can't be independently deployed
Services can fail/crash/shutdown/startup independent of other services Services can't fail/crash/shutdown/startup independent of other services
Loosely coupled Loosely coupled
Services make use of unified communication protocols such as HTTP Services make use of unified communication protocols such as HTTP
Highly maintainable, testable, and reusable Highly maintainable, testable, and reusable

Initialization

The main entry point of the Micro Module Services is a class whose constructor doesn't require any parameters.

After an instance of the class is created, it requires an asynchronous initialization process to prepare its internal state before it can accept any API calls, like below pseudo-code:

class MicroModuleServices {
  /**
   * The constructor doesn't require any parameters.
   */
  constructor() {
    this.isInitialized = false;
  }

  /**
   * Initialize.
   * @param {Object} config configurations.
   */
  async init(config) {
    const modules = []; // TODO: import modules
    const moduleInitPromises = modules.map((module) => module.init(config))

    // Wait for all modules are initialized.
    await Promise.all(moduleInitPromises)
    this.isInitialized = true;
  }
}

Why the initialization process is asynchronous?

Some modules requires considerable time to prepare their internal states, such as enumerating a directory on disk (file IO), decrypting previously saved data (heavy computing), and consuming RESTful APIs (network). The asynchronous initialization gives the call site an opportunity to work on other things while the Micro Module Services is initializing.

Connect to the Micro Module Services

An application or SDK that has Micro Module Services programming model applied has an only messaging channel, a client connects to it by connecting to the messaging channel. There is an input port named postMessage, a standard output port/event named message, and an error output port/event named error.

type MessageListener = (message: ResponseMessage | Notification) => void;
type ErrorListener = (error: Error) => void;

interface MicroModuleServices {
  /**
   * Sends a message - a JSON object - to the inner scope.
   * @param message A request message or a notification.
   */
  postMessage(message: RequestMessage | Notification) => void;

  /**
   * Subscribe to the standard output port.
   * @param event The event type `message`.
   * @param listener The message listener.
   */
  on(event: 'message', listener: MessageListener) => void;

  /**
   * Subscribe to the error output port.
   * @param event The event type `error`.
   * @param listener The error listener.
   */
  on(event: 'error', listener: ErrorListener) => void;
}

The Aggregator

Vocabulary

Environment: an user account in a particular server. Different user accounts in the same server represents different environments.

An instance of an application or SDK that has Micro Module Services programming model applied can provide services regarding a particular environment, the Aggregator is introduced to enable multi-environment support.

The Aggregator shares the same interface with Micro Module Services entry point class, it has one input port (postMessage) and two output ports (message and error events). The aggregator also shares the same messaging protocol, it forwards or broadcasts the client sent messages to the application/SDK instances depends on whether there is an environment field in the message.

Single Environment Oriented Messages

The aggregator forwards it to the corresponding application/SDK instance if the client posted a message regarding a particular environment specified by the environment field.

Aggregator Forwards Single Environment Oriented Messages

Aggregator Broadcasts non-single-environment-oriented Messages

The aggregator broadcasts non-single-environment-oriented messages (no environment field) to all application/SDK instances, in this case, there are two ways to aggregate the results. The client has to choose the response mode to use by specifying the combineResponses in the messages they post.

const exampleRequest = {
  type: 'get-user-profile',
  messageID: 123.456,
  /**
   * Indicates whether a single response is expected.
   * Specify `true` if you want to combine results from all
   * environments and receive a single response.
   * Specify `false` to receive responses from all environments
   * one by one.
   *
   * @remarks
   * This field will be ignored if `environment` is specified.
   */
  combineResponses: true,
  body: 'contents'
}

1. SINGLE RESPONSE MODE

In this mode, the aggregator waits for all application/SDK instances to finish processing the same request, and groups the results by environment-user, then returns a single response to the client. If any error occurred in an environment, the result for that environment would be an error in the aggregated results.

Aggregator Single Response Mode

2. MULTIPLE RESPONSES MODE

In this mode, the aggregator forwards a response (error or success) to the client as soon as an application/SDK instance finished processing the request. When all application/SDK instances have finished processing the request, it adds a flag to the last response to let the client know there won't be more responses to the original request.

Aggregator Multiple Responses Mode

Last updated Aug 31, 2020 Yan Li

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment