Python 的代理模式与全局配置

1. 为什么要用代理模式

代理模式,即使用变量代表函数运行的结果:

class A:
    def __init__():
        self.a = []
        self.b = FactoryProxy(lambda: len(self.a))

例如这里使用了 self.b 代表 self.a 数组中的元素个数, 而且如果改变了 self.a (例如向 self.a 中 append 了内容), self.b 也会同步变化.

另一个使用场景是作为变量容器。如果我们需要在模块级定义一个非可变(例如数组、字典)类型的值,并需要不断更新,我们通常需要使用字典包裹:

module_var = {'': 1}

def a(x):
    module_var[''] = x
    print(module_var[''])

使用代理模式可以实现更加 Pythonic、可读更高的版本:

module_var = Proxy(1)

def a(x):
    module_var.set(x)
    print(module_var)

接下来,提供代理模式最常见的使用场景:实现动态全局配置。

2. 动态全局配置的实现

首先,新建一个 config.py:

import os
from pathlib import Path
from typing import TYPE_CHECKING, Union

from box import BoxKeyError, ConfigBox
from loguru import logger

from .utils import ProxyBase

DEFAULT_CONF = {
    "host": os.environ.get("_WEB_HOST", "127.0.0.1"),
    "port": 2008,
    "prefix": "/",
    "db": "./githubstar.db",
}


class ConfigError(BoxKeyError):
    pass


if TYPE_CHECKING:
    Base = ConfigBox
else:
    Base = object


class Config(ProxyBase, Base):
    __noproxy__ = ("_conf_file", "_cache", "__getitem__")

    def __init__(self, conf_file=None):
        self._conf_file = conf_file
        self._cache = None

    @property
    def __subject__(self):
        if not self._cache:
            self.reload_conf(conf_file=self._conf_file)
        return self._cache

    def reset(self):
        self._cache = None

    def reload_conf(self, conf_file=None, box=None):
        """Load config from provided file or config.toml at cwd."""
        if not box:
            box = ConfigBox(DEFAULT_CONF, box_dots=True)
        default_conf = Path("./config.toml")
        if conf_file:
            conf_file = Path(conf_file)
        elif self._conf_file:
            conf_file = Path(self._conf_file)
        elif default_conf.is_file():
            conf_file = default_conf
        else:
            logger.debug(f"No config found from provided file or ./{default_conf}.")
        if conf_file:
            if conf_file.suffix.lower() == ".toml":
                box.merge_update(ConfigBox.from_toml(filename=conf_file))
            elif conf_file.suffix.lower() in (".yaml", ".yml"):
                box.merge_update(ConfigBox.from_yaml(filename=conf_file))
            else:
                logger.warning(f'Can not load config file "{conf_file}", a yaml/toml file is required.')
        self._conf_file = conf_file
        self._cache = box
        if conf_file:
            logger.debug(f'Now using config file at "{conf_file.absolute()}".')

    def __getitem__(self, key):
        try:
            return self.__subject__[key]
        except BoxKeyError:
            msg = f'can not find config key "{key}", please check your config file.'
            raise ConfigError(msg) from None


config: Union[ConfigBox, Config] = Config()

(ProxyBase 的实现参见第三部分)

这样,我们只需要通过以下代码,即可实现从文件读取 config,然后使用 config 变量访问全局配置:

import typer

from .config import config

cli = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)

@cli.command()
def server(
    config_file: Path = typer.Argument(
        None,
        dir_okay=False,
        allow_dash=True,
        help="Config toml file",
    )
):
    if config_file:
        config.reload_conf(config_file)

    print('Host:', config.get('host'))

使用 reload_conf 读取配置文件后,任何模块中都可以通过以下代码导入 config 动态变量:

from .config import config

然后通过 get() 来获取配置:

config.get('host', '<Unknown>')

同时我们也可以增加配置文件修改时自动更新的方法,这里我们使用 watchdog 模块实现文件修改监控:

import functools
from pathlib import Path
from threading import Thread, Event
import time
from typing import Union

from box import BoxError, ConfigBox
from loguru import logger
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent

from .utils import ProxyBase

DEFAULT_CONF = {
    "host": os.environ.get("_WEB_HOST", "127.0.0.1"),
    "port": 2008,
    "prefix": "/",
    "db": "./githubstar.db",
}

class ConfigError(BoxError):
    pass

class ConfigChangeHandler(FileSystemEventHandler):
    def __init__(self, *args, func=None, **kw):
        super().__init__(*args, **kw)
        self.func = func
    
    def on_modified(self, event: FileSystemEvent):
        logger.info(f'Config file has changed, reloading.')
        try:
            self.func()
        except Exception as e:
            logger.warning(f'Can not reload config file: {e}.')

class Config(ProxyBase):
    __noproxy__ = ("_conf_file", "_cache", "_observer", "__getitem__")

    def __init__(self, conf_file=None):
        self._conf_file = conf_file
        self._cache = None
        self._observer = None

    @property
    def __subject__(self):
        if not self._cache:
            self.reload_conf(conf_file=self._conf_file)
        return self._cache

    def reset(self):
        self._cache = None

    def start_observer(self, conf_file, box):
        if self._observer:
            self._observer.stop()
        self._observer = obs = Observer()
        func = functools.partial(self.reload_conf, box = box)
        obs.schedule(ConfigChangeHandler(func=func), conf_file)
        obs.start()
        
    def reload_conf(self, conf_file=None, box=None):
        """Load config from provided file or config.toml at cwd."""
        if not box:
            box = ConfigBox(DEFAULT_CONF, box_dots=True)
        default_conf = Path("./config.toml")
        if conf_file:
            conf_file = Path(conf_file)
        elif self._conf_file:
            conf_file = Path(self._conf_file)
        elif default_conf.is_file():
            conf_file = default_conf
        else:
            logger.debug(f"No config found from provided file or ./{default_conf}.")
        if conf_file:
            if conf_file.suffix.lower() == ".toml":
                box.merge_update(ConfigBox.from_toml(filename=conf_file))
            elif conf_file.suffix.lower() in (".yaml", ".yml"):
                box.merge_update(ConfigBox.from_yaml(filename=conf_file))
            else:
                logger.warning(f'Can not load config file "{conf_file}", a yaml/toml file is required.')
        logger.debug(f'Now using config file at "{conf_file.absolute()}".')
        self._conf_file = conf_file
        self._cache = box
        self.start_observer(conf_file, box)

    def __getitem__(self, key):
        try:
            return self.__subject__[key]
        except BoxError:
            msg = f'can not find config key "{key}", please check your config file or env var.'
            raise ConfigError(msg) from None

config: Union[ConfigBox, Config] = Config()

除此之外,我们还可以增加配置文件自动检查 (使用 schema 模块) 等功能,这里不赘述。

3. 代理模式的实现

新建一个 utils.py:

class ProxyBase:
    """
    A proxy class that make accesses just like direct access to __subject__ if not overwriten in the class.
    Attributes defined in __noproxy__ will not be proxied to __subject__. Functions and properties will not
    be proxied by default.
    
    Example:
        ```python
        class Config(ProxyBase):
            __noproxy__ = ("conf_file",)

            def __init__(self, conf_file=None):
                self.conf_file = conf_file

            @property
            def __subject__(self):
                return self.conf_file.read()
        ```
    """

    __slots__ = ()

    def __call__(self, *args, **kw):
        return self.__subject__(*args, **kw)

    def hasattr(self, attr):
        try:
            object.__getattribute__(self, attr)
            return True
        except AttributeError:
            return False

    def __getattribute__(self, attr, oga=object.__getattribute__):
        if attr.startswith("__") and attr not in oga(self, "_noproxy"):
            try:
                subject = oga(self, "__subject__")
            except AttributeError as e:
                exc = ProxyAttributeError(e)
                exc.with_traceback(e.__traceback__)
                raise exc from None
            if attr == "__subject__":
                return subject
            return getattr(subject, attr)
        return oga(self, attr)

    def __getattr__(self, attr, oga=object.__getattribute__):
        if attr == "hasattr" or self.hasattr(attr):
            return oga(self, attr)
        else:
            return getattr(oga(self, "__subject__"), attr)

    @property
    def _noproxy(self, oga=object.__getattribute__):
        base = oga(self, "__class__")
        for cls in inspect.getmro(base):
            if hasattr(cls, "__noproxy__"):
                yield from cls.__noproxy__

    def __setattr__(self, attr, val, osa=object.__setattr__):
        if attr == "__subject__" or attr in self._noproxy:
            return osa(self, attr, val)
        return setattr(self.__subject__, attr, val)

    def __delattr__(self, attr, oda=object.__delattr__):
        if attr == "__subject__" or hasattr(type(self), attr) and not attr.startswith("__"):
            oda(self, attr)
        else:
            delattr(self.__subject__, attr)

    def __bool__(self):
        return bool(self.__subject__)

    def __getitem__(self, arg):
        return self.__subject__[arg]

    def __setitem__(self, arg, val):
        self.__subject__[arg] = val

    def __delitem__(self, arg):
        del self.__subject__[arg]

    def __getslice__(self, i, j):
        return self.__subject__[i:j]

    def __setslice__(self, i, j, val):
        self.__subject__[i:j] = val

    def __delslice__(self, i, j):
        del self.__subject__[i:j]

    def __contains__(self, ob):
        return ob in self.__subject__

    for name in "repr str hash len abs complex int long float iter".split():
        exec("def __%s__(self): return %s(self.__subject__)" % (name, name))

    for name in "cmp", "coerce", "divmod":
        exec("def __%s__(self, ob): return %s(self.__subject__, ob)" % (name, name))

    for name, op in [
        ("lt", "<"),
        ("gt", ">"),
        ("le", "<="),
        ("ge", ">="),
        ("eq", " == "),
        ("ne", "!="),
    ]:
        exec("def __%s__(self, ob): return self.__subject__ %s ob" % (name, op))

    for name, op in [("neg", "-"), ("pos", "+"), ("invert", "~")]:
        exec("def __%s__(self): return %s self.__subject__" % (name, op))

    for name, op in [
        ("or", "|"),
        ("and", "&"),
        ("xor", "^"),
        ("lshift", "<<"),
        ("rshift", ">>"),
        ("add", "+"),
        ("sub", "-"),
        ("mul", "*"),
        ("div", "/"),
        ("mod", "%"),
        ("truediv", "/"),
        ("floordiv", "//"),
    ]:
        exec(
            (
                "def __%(name)s__(self, ob):\n"
                "    return self.__subject__ %(op)s ob\n"
                "\n"
                "def __r%(name)s__(self, ob):\n"
                "    return ob %(op)s self.__subject__\n"
                "\n"
                "def __i%(name)s__(self, ob):\n"
                "    self.__subject__ %(op)s=ob\n"
                "    return self\n"
            )
            % locals()
        )

    del name, op

    def __index__(self):
        return self.__subject__.__index__()

    def __rdivmod__(self, ob):
        return divmod(ob, self.__subject__)

    def __pow__(self, *args):
        return pow(self.__subject__, *args)

    def __ipow__(self, ob):
        self.__subject__ **= ob
        return self

    def __rpow__(self, ob):
        return pow(ob, self.__subject__)


class Proxy(ProxyBase):
    '''
    A variable container for passing references.
    
    Example 1:
        ```python
        a = Proxy(1)
        assert a == 1
        ```
        
    Example 2:
        ```python
        plus_1 = lambda x: x = x + 1
        a = Proxy(1)
        plus_1(a)
        assert a == 2
        ```
    '''
    
    def __init__(self, val):
        self.set(val)

    def set(self, val):
        self.__subject__ = val

class Delayed:
    '''Used to store a function and all its arguments for later calls.'''
    
    def __init__(self, func: Union[Callable, Delayed] = None):
        if isinstance(func, Delayed):
            self.func = func.func
            self.args = func.args
            self.kw = func.kw
        else:
            self.func = func
            self.args = ()
            self.kw = {}

    def __call__(self, *args, **kw):
        self.args = args
        self.kw = kw
        return self

    def trigger(self, **kw):
        if self:
            return self.func(*self.args, **{**self.kw, **kw})

    def __bool__(self):
        return bool(self.func)

    def __repr__(self):
        return f"<{self.__class__.__name__} definition for {self.func.__name__} at {hex(id(self))}>"

class FactoryProxy(Delayed, ProxyBase):
    '''
    Stores a function representation. When the value is retrieved, the function will be executed
    and its return value will be returned.
    
    Example:
        ```python
        l = [1]
        listlen = FactoryProxy(lambda: len(l))
        assert listlen == 1
        l.append(1)
        assert listlen == 2
        ```
    '''
    
    __noproxy__ = ("func", "args", "kw")

    @property
    def __subject__(self):
        return super().trigger()

以上代码实现了四个类:ProxyBase,Proxy,Delayed,FactoryProxy。

ProxyBase 是代理模式的基础类,实现的功能是:任何访问实例的操作都等同于直接访问实例__subject__(包括 isinstance)。

Proxy 是最简单的代理类,相当于一个变量容器,可以使之等同于 set 到容器的任何变量,同时保持变量的可变性。

Delayed 是一个函数操作的定义类,存储了函数、args、kwargs,以待之后执行,可以通过以下代码定义:

def func(a, b=1):
    print(a, b)

c = Delayed(func)(5, b=10)

FactoryProxy 是一个基于函数的代理类,与 Delayed 是相同的格式,区别在于实例函数直接等同于函数的结果。函数将在变量被取值时执行。


感谢阅读,如果本文对你有帮助,可以订阅本系列:代码怪兽的前端技术分享,我将继续分享前后端全栈开发的相关实用经验。祝你开发愉快。

公益项目 GithubStar.Pro 的后端也使用了这种模式,因此这篇文章是开发的实际经验,这里也推荐使用 GithubStar.Pro 推广您的项目~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值