json-rpc icon indicating copy to clipboard operation
json-rpc copied to clipboard

Feature proposal: Remote Method Invocation dispatcher

Open HMaker opened this issue 4 years ago • 1 comments

Remote Method Invocation (RMI) is the object oriented's version of RPC, where methods are invoked on remote objects. It would allow people to employ object oriented programming in the architecture of JSON-RPC API's in a transparent manner, i. e. the client does not need to know whether they are calling a function (unbound procedure) or a method (bound procedure).

MVC variants are a common pattern in the architecture of many applications. For web services, generally a Controller object receive requests from clients and call methods on other objects, injected into the Controller through its constructor, to fulfill the request and return a response. In Django, for example, the View receives the request and return a response, its role is somewhat close to the Controller in other MVC variants. Django's views can be either a function or class-based.

Design

The methods available for RMI, the exposed methods, would be described and made available under a namespace, a string defined as a class attribute, by a RMIReceiver. Also those methods would be explicitly exposed by annotating it's definition with @exposed decorator.

A reference to a exposed method would be done by referencing its path, which would be a string in format<namespace>.<method-name>, where <method-name> is the name of the method annotated by @exposed and the dot is used to delimit the end of the namespace, which can have any character including dots (e.g. "foo.bar.method").

The RMIDispatcher would dispatch the calls by taking the method's path and returning the corresponding method's implementation bound to its receiver. The receivers would be registered in the dispatcher by passing its instances to RMIDispatcher.register_receivers(*receivers). iter(RMIDispatcher) returns an iterator that yield tuples (method-path, method) for all added receivers, internally it calls RMIReceiver.iter_methods() for all receivers. This dispatcher would be read-only and compatible with the __getitem__ and iterator protocols, that is enough for jsonrpc to dispatch calls, right?

Implementation

import typing


class _RMIReceiverConstructor(type):
    
    def __new__(cls, name, bases, clsdict):
        if len(bases) > 0:
            clsdict['_exposed_'] = {}
        return super().__new__(cls, name, bases, clsdict)


class RMIReceiver(metaclass=_RMIReceiverConstructor):
    
    def resolve_method(self, method_name: str):
        method = next((method for name, method in self._iter_methods() if name == method_name), None)
        if method is not None:
            return method
        else:
            raise NotImplementedError

    def iter_methods(self):
        for name, method in self._iter_methods():
            yield f"{self.namespace}.{name}", method

    def _iter_methods(self):
        for klass in self.__class__.__mro__:
            if hasattr(klass, '_exposed_'):
                for name, method in klass._exposed_.items():
                    yield name, method.__get__(self, klass)


class exposed:

    def __init__(self, method):
        self.method = method

    def __set_name__(self, owner, name: str):
        if not issubclass(owner, RMIReceiver):
            raise TypeError(
                f"The @exposed decorator works only in subclasses of RMIReceiver, but '{owner.__name__}' is not."
            )
        setattr(owner, name, self.method)
        owner._exposed_[name] = self.method


class RMIDispatcher:

    def __init__(self):
        self._receivers: typing.Dict[str, RMIReceiver] = {}

    def register_receivers(self, *receivers: typing.List[RMIReceiver]):
        """NOTE: Receivers in same namespace will override each other"""
        for receiver in receivers:
            self._receivers[receiver.namespace] = receiver

    def __getitem__(self, method_path: str):
        parts = method_path.rsplit('.', 1)
        if len(parts) != 2:
            raise KeyError(
                f"Invalid method path '{method_path}'. All method paths must be in format '<namespace>.<method-name>'"
            )
        try:
            receiver = self._receivers[parts[0]]
        except KeyError:
            raise KeyError(f"There is no receiver for namespace '{parts[0]}'")
        try:
            return receiver.resolve_method(parts[1])
        except NotImplementedError:
            raise KeyError(
                f"The method '{parts[1]}' from '{method_path}' is not implemented by the receiver '{receiver.__class__.__name__}'"
            )

    def __iter__(self):
        def _iter():
            for receiver in self._receivers.values():
                yield from receiver.iter_methods()
        return _iter()

The implementation could be located in the rmi module.

Usage

from jsonrpc.rmi import RMIReceiver, RMIDispatcher, exposed

class SuperHelloWorld(RMIReceiver):
    namespace = 'hello'

   @exposed
   def super_hello(self):
        print('Super Hello, World')

   def non_exposed_hello(self):
        print("I can't be called from dispatcher!")

class HelloWorld(SuperHelloWorld):

    @exposed
    def hello(self):
        print('Hello, World')

dispatcher = RMIDispatcher()
dispatcher.register_receivers(HelloWorld())
dispatcher['hello.super_hello']()
dispatcher['hello.hello']()
dispatcher['hello.non_exposed_hello']() # KeyError: "The method 'non_exposed_hello' from 'hello.non_exposed_hello' is not implemented by the receiver 'HelloWorld'"

To define an API, one could use Adapters for each exposed object, thus creating exposed interfaces:

class FooAPI(RMIReceiver, FooInterface):

    def __init__(self, implementation: FooInterface):
        self._implementation = implementation
    
    @exposed
    def my_public_method(self):
        return self._implementation.my_public_method()

Implementations can be changed just by changing the injected object, the interface itself, FooAPI, would remain intact.

Compatibility

RMIDispatcher once filled with receivers can:

  1. be converted into a Dispatcher:
from jsonrpc import Dispatcher, RMIDispatcher
# fill a rmi_dispatcher with receivers
dispatcher = Dispatcher(dict(iter(rmi_dispatcher)))
  1. extend an existing Dispatcher:
from jsonrpc import dispatcher
# fill a rmi_dispatcher with receivers
dispatcher.add_dict(dict(iter(rmi_dispatcher)))

A single RMIReceiver can be added to an existing Dispatcher:

from jsonrpc import dispatcher
# ...
hello_world_receiver = HelloWorld()
dispatcher.add_dict(dict(hello_world_receiver.iter_methods()))

From what I understand in JSONRPCResponseManager, it expects a dispatcher to implement __getitem__ and raise KeyError if the method does not exist, I believe my implementation fulfills that. The dispatcher interface used by jsonrpc should be explicit.

To do

  • [ ] There will be namespace conflicts when two subclasses uses the same namespace declared by their parent, currently the implementation does not try to detect that. Should every class explicitly declare its namespace? Should the namespace reflect the class hierarchy? Should the namespace be the class name?
  • [ ] Add to jsonrpc and write tests
  • [ ] Open pull request
  • [ ] Merge into jsonrpc's master branch

HMaker avatar Sep 05 '20 19:09 HMaker

Thank you for the proposal! Let me take a closer look at it.

pavlov99 avatar Sep 08 '20 07:09 pavlov99