π·πΊRussian version
A lightweight Inversion of Control library implementing the Service Locator pattern for Python.
The package is available on the PyPi:
pip install galo_ioc
A plugin system is well suited for creating flexible and extensible applications. In such a system, plugins are responsible for creating and binding application objects to each other (for example, various implementations of services or repositories). To do this, you need to have a storage of all objects. The Service Locator pattern copes with this role perfectly, and the Galo-IOC project is an easy-to-use and lightweight implementation of it.
The plugin system together with Galo-IOC will help if necessary:
- conveniently enable/disable parts of the application functionality;
- install the application to several clients, some of whom must have some functionality individually;
- extend the application by installing third-party packages.
- In standard implementation of the Service Locator pattern, a single instance of each class is stored. In this library, instead of an instance, a factory is stored, which allows you to flexibly manage the creation of objects.
- To get objects of the same type, you can register several factory implementations and choose between them.
- Support for passing parameters when calling the factory.
- Support for static code analysis. Thanks to this, the IDE suggests the names and types of parameters, as well as the type of the returned result when calling factories, which greatly simplifies writing code and avoids stupid mistakes.
- Support for decorators for factories, with which you can influence the creation of objects. For example, add logging for all created objects or add caching for objects of some type.
It is worth noting that Service Locator is an antipattern. Its disadvantages include hiding dependencies. The examples below show a way to use this library, reducing the disadvantages of the Service Locator pattern to a minimum: Service Locator is used only in plugins, but not in the application classes. In the application classes, dependencies are explicitly specified in the constructors.
To demonstrate the capabilities of the library, consider the following example. An IT company is developing a product that allows their customers β other companies β to congratulate employees on birthday via a messenger. Among the customers there are companies from different countries, and the list of messengers used includes WhatsApp, Telegram and internal corporate messengers of companies.
This library works well with any plugin system, in which plugins allow you to create and bind application objects (services, repositories) with each other. This library does not provide an implementation of the plugin system, because it is not its responsibility. To use this library, you will have to take a ready-made implementation of the plugin system or implement it yourself. For these examples, we implement the plugin system ourselves.
This implementation of the plugin system will be very simple, but at the same time functional enough to demonstrate all the features of the Galo-IOC library. In this plugin system, the configuration file will contain the names of the modules. Each such module will contain a load
function, which will be responsible for creating and registering application objects in the Service Locator. When the application starts, these modules will be imported, and then the load
function will be called for each of them.
The project will have the following structure:
.
βββ module_names.txt
βββ setup.py
βββ src
βββ congratulations_app
βββ __init__.py
βββ __main__.py
βββ congratulations_services
β βββ __init__.py
β βββ english.py
β βββ russian.py
βββ messengers
βββ __init__.py
βββ telegram.py
βββ whatsapp.py
File module_names.txt
is the configuration file that lists the modules. These modules will be imported and the load
function will be called for each of them. Example of file contents module_names.txt
:
congratulations_app.messengers.telegram
congratulations_app.congratulations_services.russian
As you can see, Telegram is used as a messenger. If there is a need to replace the Telegram messenger with WhatsApp, for example, when installing an application to another customer with such a requirement, it will be enough to replace the line congratulations_app.messengers.telegram
with congratulations_app.messengers.whatsapp
in the configuration file of the new customer. In this way, you can replace any application object with any other without having to modify the code.
The file src/congratulations_app/messengers/__init__.py
contains the messenger interface β Messenger
and the messenger factory interface β MessengerFactory
. The factory interface is needed to specify the contract that other modules will use to get this object.
# src/congratulations_app/messengers/__init__.py
__all__ = [
"Messenger",
"MessengerFactory",
]
class Messenger:
def send_message(self, name: str, message: str) -> None:
raise NotImplementedError()
class MessengerFactory:
def __call__(self) -> Messenger:
raise NotImplementedError()
Let's consider one of the implementations of the messenger β Telegram, which is contained in the module src/congratulations_app/messengers/telegram.py
. This module contains the implementation of the Messenger
interface β TelegramMessenger
and the load
function. This function will be called when the application is initialized if this module is specified in the configuration file module_names.txt
. The function creates an instance of the TelegramMessenger
class and the factory TelegramMessengerFactory
that returns the messenger instance. This factory is then registered in the Service Locator using the add_factory
function from the Galo-IOC library. After that, using this factory, it will be possible to get an instance of the Messenger
class in another module. The module contained the WhatsApp messenger is implemented in a similar way β src/congratulations_app/messengers/whatsapp.py
.
# src/congratulations_app/messengers/telegram.py
from galo_ioc import add_factory
from congratulations_app.messengers import Messenger, MessengerFactory
__all__ = [
"TelegramMessenger",
"load",
]
class TelegramMessenger(Messenger):
def send_message(self, name: str, message: str) -> None:
print(f"Message {message!r} sent to {name!r} via Telegram.")
def load() -> None:
class TelegramMessengerFactory(MessengerFactory):
def __call__(self) -> Messenger:
return messenger
messenger = TelegramMessenger()
add_factory(MessengerFactory, TelegramMessengerFactory())
Now let's move on to one of the implementations of the congratulations service, which is contained in the module src/congratulations_app/congratulation_services/russian.py
The load
function in this module is responsible for creating an object of the RussianCongratulationsService
type and registering its factory in the Services Locator. To get the messenger
dependency, the get_factory
function is used. It allows you to access the MessengerFactory
, which is currently registered in the Services Locator. It can be TelegramMessengerFactory
, WhatsAppMessengerFactory
or any other. Then an instance of the Messenger
class is gotten by calling this factory. After that, it is passed to the constructor of the RussianCongratulationsService
class to create it.
# src/congratulations_app/congratulation_services/russian.py
from galo_ioc import add_factory, get_factory
from congratulations_app.messengers import Messenger, MessengerFactory
from congratulations_app.congratulations_services import CongratulationsService, CongratulationsServiceFactory
__all__ = [
"RussianCongratulationsService",
"load",
]
class RussianCongratulationsService(CongratulationsService):
def __init__(self, messenger: Messenger) -> None:
self.__messenger = messenger
def happy_birthday(self, name: str) -> None:
self.__messenger.send_message(name, f"Π‘ Π΄Π½Π΅ΠΌ ΡΠΎΠΆΠ΄Π΅Π½ΠΈΡ, {name}!")
def load() -> None:
class RussianCongratulationsServiceFactory(CongratulationsServiceFactory):
def __call__(self) -> CongratulationsService:
return service
messenger_factory = get_factory(MessengerFactory)
messenger = messenger_factory()
service = RussianCongratulationsService(messenger)
add_factory(CongratulationsServiceFactory, RussianCongratulationsServiceFactory())
Thanks to the use of factory interfaces, static code analysis and autocompletion are supported.
In the application startup function, the configuration file is read and modules are loaded. The get_factory
function (similar to set_factory
) accesses the container of factories in the current context. To add a container of factories to the current context, use the expression with FactoryContainerImpl():
.
from galo_ioc import FactoryContainerImpl, get_factory
from congratulations_app.startup_utils import get_module_names_path, read_module_names, load_plugins
from congratulations_app.congratulations_services import CongratulationsServiceFactory
def main() -> None:
module_names_path = get_module_names_path()
module_names = read_module_names(module_names_path)
with FactoryContainerImpl():
load_plugins(module_names)
congratulations_service_factory = get_factory(CongratulationsServiceFactory)
congratulations_service = congratulations_service_factory()
congratulations_service.happy_birthday("Maria")
if __name__ == "__main__":
main()
With the contents of the file module_names.txt
:
congratulations_app.messengers.telegram
congratulations_app.congratulations_services.russian
The output will be:
Message 'Π‘ Π΄Π½Π΅ΠΌ ΡΠΎΠΆΠ΄Π΅Π½ΠΈΡ, Maria!' sent to 'Maria' via Telegram.
But if you change the contents of the file module_names.txt
on:
congratulations_app.messengers.whatsapp
congratulations_app.congratulations_services.english
You get the output:
Message 'Happy birthday, Maria!' sent to 'Maria' via WhatsApp.
Now let's look at the integration of third-party plugins into the application. For example, a new customer wants to use an application to congratulate employees on birthday, but it does not want to use any of the already implemented messengers, but instead wants to use its internal corporate messenger. At the same time, this customer is against including the implementation of its corporate messenger in the code base of the application. Even this case is not a problem for the Galo-IOC library together with the plugin system. To solve this problem, you need to create a separate project.
The project structure will look like this:
.
βββ setup.py
βββ src
βββ secret_corporation_plugin
βββ __init__.py
βββ messengers
βββ __init__.py
βββ secret_corporation.py
Consider the implementation of the module src/secret_corporation_plugin/messengers/secret_corporation.py
. As you can see, it does not fundamentally differ from the implementation of the other two messengers: Telegram and WhatsApp, included in the code base of the application.
# src/secret_corporation_plugin/messengers/secret_corporation.py
from galo_ioc import add_factory
from congratulations_app.messengers import Messenger, MessengerFactory
__all__ = [
"SecretCorporationMessenger",
"load",
]
class SecretCorporationMessenger(Messenger):
def send_message(self, name: str, message: str) -> None:
print(f"Message {message!r} sent to {name!r} via Secret Corporation Messenger.")
def load() -> None:
class SecretCorporationMessengerFactory(MessengerFactory):
def __call__(self) -> Messenger:
return messenger
messenger = SecretCorporationMessenger()
add_factory(MessengerFactory, SecretCorporationMessengerFactory())
To use the implementation of the internal corporate messenger in the application instead of Telegram
or WhatsApp
, you need to install the secret_corporation_plugin
package using the command python setup.py install .
in the root directory of the project with this messenger. Further, in the configuration file module_names.txt
specify secret_corporation_plugin.messengers.secret_corporation
as a module with a messenger. As a result, the contents of the file module_names.txt
may look like this:
secret_corporation_plugin.messengers.secret_corporation
congratulations_app.congratulations_services.russian
And when running an application with such configuration file content we get the following output:
Message 'Π‘ Π΄Π½Π΅ΠΌ ΡΠΎΠΆΠ΄Π΅Π½ΠΈΡ, Maria!' sent to 'Maria' via Secret Corporation Messenger.
As you can see, SecretCorporationMessenger
is used as a messenger. To achieve this, it was not necessary to change the application code, but it was enough just to add another implementation of the messenger in another project and change the configuration file.
The full version of the example can be found at link.
More examples can be found at link.
-
loggers contains an example of a factory with input arguments.
-
congratulations_service_audit contains an example of using a decorator. The decorator is used for logging of input arguments for the
CongratulationsService
. -
fastapi_integration contains an example of integration with the FastAPI web framework. This example implements:
- two error handlers: text and json;
- two user repositories: Memory and PostgreSQL;
- two authentication methods: Basic authentication and OAuth 2;
- and other functionality.
You can select the used implementations of the error handler, the user repository, and the authentication method in the configuration file
module_names.txt
.