一个管理全局实例的python框架

一个管理全局实例的框架,利用元类(metaclass)和混合类(mixin) 。

1 代码实现

这段代码通过元类和混合类的机制提供了一个可以全局访问且线程安全的单例模式框架。这种设计模式在多线程应用程序中非常有用,尤其是在需要全局管理资源或实例的情况下。

# Copyright (c) OpenMMLab. All rights reserved.
import inspect
import threading
import warnings
from collections import OrderedDict
from typing import Type, TypeVar

_lock = threading.RLock()
T = TypeVar('T')


def _accquire_lock() -> None:
    """Acquire the module-level lock for serializing access to shared data.

    This should be released with _release_lock().
    """
    if _lock:
        _lock.acquire()


def _release_lock() -> None:
    """Release the module-level lock acquired by calling _accquire_lock()."""
    if _lock:
        _lock.release()


class ManagerMeta(type):
    """The metaclass for global accessible class.

    The subclasses inheriting from ``ManagerMeta`` will manage their
    own ``_instance_dict`` and root instances. The constructors of subclasses
    must contain the ``name`` argument.

    Examples:
        >>> class SubClass1(metaclass=ManagerMeta):
        >>>     def __init__(self, *args, **kwargs):
        >>>         pass
        AssertionError: <class '__main__.SubClass1'>.__init__ must have the
        name argument.
        >>> class SubClass2(metaclass=ManagerMeta):
        >>>     def __init__(self, name):
        >>>         pass
        >>> # valid format.
    """

    def __init__(cls, *args):
        cls._instance_dict = OrderedDict()
        params = inspect.getfullargspec(cls)
        params_names = params[0] if params[0] else []
        assert 'name' in params_names, f'{cls} must have the `name` argument'
        super().__init__(*args)


class ManagerMixin(metaclass=ManagerMeta):
    """``ManagerMixin`` is the base class for classes that have global access
    requirements.

    The subclasses inheriting from ``ManagerMixin`` can get their
    global instances.

    Examples:
        >>> class GlobalAccessible(ManagerMixin):
        >>>     def __init__(self, name=''):
        >>>         super().__init__(name)
        >>>
        >>> GlobalAccessible.get_instance('name')
        >>> instance_1 = GlobalAccessible.get_instance('name')
        >>> instance_2 = GlobalAccessible.get_instance('name')
        >>> assert id(instance_1) == id(instance_2)

    Args:
        name (str): Name of the instance. Defaults to ''.
    """

    def __init__(self, name: str = '', **kwargs):
        assert isinstance(name, str) and name, \
            'name argument must be an non-empty string.'
        self._instance_name = name

    @classmethod
    def get_instance(cls: Type[T], name: str, **kwargs) -> T:
        """Get subclass instance by name if the name exists.

        If corresponding name instance has not been created, ``get_instance``
        will create an instance, otherwise ``get_instance`` will return the
        corresponding instance.

        Examples
            >>> instance1 = GlobalAccessible.get_instance('name1')
            >>> # Create name1 instance.
            >>> instance.instance_name
            name1
            >>> instance2 = GlobalAccessible.get_instance('name1')
            >>> # Get name1 instance.
            >>> assert id(instance1) == id(instance2)

        Args:
            name (str): Name of instance. Defaults to ''.

        Returns:
            object: Corresponding name instance, the latest instance, or root
            instance.
        """
        _accquire_lock()
        assert isinstance(name, str), \
            f'type of name should be str, but got {type(cls)}'
        instance_dict = cls._instance_dict  # type: ignore
        # Get the instance by name.
        if name not in instance_dict:
            instance = cls(name=name, **kwargs)  # type: ignore
            instance_dict[name] = instance  # type: ignore
        elif kwargs:
            warnings.warn(
                f'{cls} instance named of {name} has been created, '
                'the method `get_instance` should not accept any other '
                'arguments')
        # Get latest instantiated instance or root instance.
        _release_lock()
        return instance_dict[name]

    @classmethod
    def get_current_instance(cls):
        """Get latest created instance.

        Before calling ``get_current_instance``, The subclass must have called
        ``get_instance(xxx)`` at least once.

        Examples
            >>> instance = GlobalAccessible.get_current_instance()
            AssertionError: At least one of name and current needs to be set
            >>> instance = GlobalAccessible.get_instance('name1')
            >>> instance.instance_name
            name1
            >>> instance = GlobalAccessible.get_current_instance()
            >>> instance.instance_name
            name1

        Returns:
            object: Latest created instance.
        """
        _accquire_lock()
        if not cls._instance_dict:
            raise RuntimeError(
                f'Before calling {cls.__name__}.get_current_instance(), you '
                'should call get_instance(name=xxx) at least once.')
        name = next(iter(reversed(cls._instance_dict)))
        _release_lock()
        return cls._instance_dict[name]

    @classmethod
    def check_instance_created(cls, name: str) -> bool:
        """Check whether the name corresponding instance exists.

        Args:
            name (str): Name of instance.

        Returns:
            bool: Whether the name corresponding instance exists.
        """
        return name in cls._instance_dict

    @property
    def instance_name(self) -> str:
        """Get the name of instance.

        Returns:
            str: Name of instance.
        """
        return self._instance_name

2 代码详细解析

2.1 导入依赖和初始化锁

import inspect
import threading
from collections import OrderedDict
from typing import Type, TypeVar

_lock = threading.RLock()
T = TypeVar('T')
  • inspect 用于获取类和函数的内部信息。
  • threadingRLock 用于同步多线程环境下对数据的访问,以保证线程安全。
  • OrderedDict 保持插入顺序的字典,用于存储实例。
  • TypeVarType 用于类型注解,提高代码的可读性和健壮性。

2.2 锁的获取与释放

def _accquire_lock() -> None:
    """Acquire the module-level lock for serializing access to shared data."""
    if _lock:
        _lock.acquire()

def _release_lock() -> None:
    """Release the module-level lock acquired by calling _accquire_lock()."""
    if _lock:
        _lock.release()

这两个函数用于在多线程环境中获取和释放锁,确保对共享数据的访问是线程安全的。

2.3 元类 ManagerMeta

class ManagerMeta(type):
    def __init__(cls, *args):
        cls._instance_dict = OrderedDict()
        params = inspect.getfullargspec(cls)
        params_names = params[0] if params[0] else []
        assert 'name' in params_names, f'{cls} must have the `name` argument'
        super().__init__(*args)
  • 这是一个元类,用于创建可以全局访问的类的子类。
  • cls._instance_dict 是一个有序字典,用于存储该类的所有实例。
  • 检查类构造函数中必须包含 name 参数,这是管理不同实例的关键字段。

2.4 混合类 ManagerMixin

class ManagerMixin(metaclass=ManagerMeta):
    def __init__(self, name: str = '', **kwargs):
        assert isinstance(name, str) and name, 'name argument must be an non-empty string.'
        self._instance_name = name

    @classmethod
    def get_instance(cls: Type[T], name: str, **kwargs) -> T:
        ...
    @classmethod
    def get_current_instance(cls):
        ...
    @classmethod
    def check_instance_created(cls, name: str) -> bool:
        ...
    @property
    def instance_name(self) -> str:
        ...
  • ManagerMixin 是一个使用 ManagerMeta 作为元类的基类,用于实现全局访问和实例管理。
  • __init__ 方法初始化实例,必须提供非空的 name 参数。
  • get_instance 方法根据 name 创建或获取实例。根据 name 检查实例是否存在。如果不存在,则创建一个新实例并添加到 _instance_dict。如果存在,忽略 kwargs 并返回现有实例。
  • get_current_instance 获取最近创建的实例。获取 _instance_dict 中最后创建的实例。
  • check_instance_created 检查是否已创建指定名称的实例。检查 _instance_dict 中是否存在名为 name 的实例。
  • instance_name 属性返回实例名称。

这段代码定义了一个可以全局访问的单例模式的框架,使用元类和混合类(mixin)。为了更好地理解如何使用这个框架,我将给出一个具体的应用示例。

3 示例场景1

假设我们需要创建一个全局配置管理器,这个管理器可以在程序的多个地方被访问和修改,同时确保全局只有一个实例。

3.1 定义配置管理器类

首先,我们利用 ManagerMixinManagerMeta 来定义一个 ConfigurationManager 类。

class ConfigurationManager(ManagerMixin):
    def __init__(self, name, settings={}):
        super().__init__(name)
        self.settings = settings

    def set(self, key, value):
        """Set a configuration value."""
        self.settings[key] = value

    def get(self, key):
        """Get a configuration value."""
        return self.settings.get(key, None)

3.2 使用全局配置管理器

接下来,我们将展示如何在不同部分的代码中创建和访问这个全局的配置管理器实例。在 init(self, name, settings={}) 方法中,name 参数起着关键的作用,它用于标识和区分不同的 ConfigurationManager 实例。这一设计是基于 ManagerMixin 和 ManagerMeta 提供的全局访问和单例管理机制。ManagerMixin 和 ManagerMeta 设计的目的是实现单例模式,即确保每个具有特定名称的类实例是唯一的。这通过内部的 _instance_dict 来管理每个实例的创建和存储实现,其中每个实例都与一个特定的 name 关联。
当你通过 get_instance 方法请求一个实例时,系统会检查是否已经存在一个与提供的 name 相关联的实例。如果存在,它将返回该实例;如果不存在,它将创建一个新实例。

在程序的初始化阶段或配置读取阶段,我们可以创建和初始化 ConfigurationManager 的实例。

# 创建全局配置管理器实例
config_manager = ConfigurationManager.get_instance('global_config', settings={'debug_mode': False})

# 修改设置
config_manager.set('api_key', '12345')

在程序的其他部分,我们可以通过名称访问这个已经创建的配置管理器实例,而不需要再次创建它。

# 在另一个文件或模块中访问全局配置管理器
config_manager = ConfigurationManager.get_instance('global_config')
api_key = config_manager.get('api_key')
print(f"API Key: {api_key}")

假设一个应用程序需要连接到两个不同的数据库
这里,production_db 和 development_db 作为 name 的值,标识了两个不同的数据库配置管理器实例。这使得它们可以存储和管理独立的设置,而不会相互干扰。这种方式在实际开发中非常实用,尤其是在大型或复杂的系统中。

db_config_prod = ConfigurationManager.get_instance('production_db', settings={'host': 'prod_host', 'port': 3306})
db_config_dev = ConfigurationManager.get_instance('development_db', settings={'host': 'dev_host', 'port': 3306})

3.3 说明

通过 ManagerMixinManagerMeta 的设计,ConfigurationManager 类保证了即使在复杂的多模块程序中,global_config 的实例也只被创建一次。此外,ManagerMixin 提供的 get_instance 方法使得访问这些全局实例变得简单和一致。

这种方式特别适用于需要跨多个模块共享单个实例的场景,如配置管理、数据库连接管理等。它提供了一种结构化和线程安全的方式来管理全局实例。

4 示例场景2

4.1 代码实现

class MMLogger(Logger, ManagerMixin):
    """Formatted logger used to record messages.

    ``MMLogger`` can create formatted logger to log message with different
    log levels and get instance in the same way as ``ManagerMixin``.
    ``MMLogger`` has the following features:

    - Distributed log storage, ``MMLogger`` can choose whether to save log of
      different ranks according to `log_file`.
    - Message with different log levels will have different colors and format
      when displayed on terminal.
   
    Examples:
        >>> logger = MMLogger.get_instance(name='MMLogger',
        >>>                                logger_name='Logger')
        >>> # Although logger has name attribute just like `logging.Logger`
        >>> # We cannot get logger instance by `logging.getLogger`.
        >>> assert logger.name == 'Logger'
        >>> assert logger.instance_name = 'MMLogger'
        >>> assert id(logger) != id(logging.getLogger('Logger'))
        >>> # Get logger that do not store logs.
        >>> logger1 = MMLogger.get_instance('logger1')
        >>> # Get logger only save rank0 logs.
        >>> logger2 = MMLogger.get_instance('logger2', log_file='out.log')
        >>> # Get logger only save multiple ranks logs.
        >>> logger3 = MMLogger.get_instance('logger3', log_file='out.log',
        >>>                                 distributed=True)

    Args:
        name (str): Global instance name.
        logger_name (str): ``name`` attribute of ``Logging.Logger`` instance.
            If `logger_name` is not defined, defaults to 'mmengine'.
        log_file (str, optional): The log filename. If specified, a
            ``FileHandler`` will be added to the logger. Defaults to None.
        log_level (str): The log level of the handler. Defaults to
            'INFO'. If log level is 'DEBUG', distributed logs will be saved
            during distributed training.
        file_mode (str): The file mode used to open log file. Defaults to 'w'.
        distributed (bool): Whether to save distributed logs, Defaults to
            false.
        file_handler_cfg (dict, optional): Configuration of file handler.
            Defaults to None. If ``file_handler_cfg`` is not specified,
            ``logging.FileHandler`` will be used by default. If it is
            specified, the ``type`` key should be set. It can be
            ``RotatingFileHandler``, ``TimedRotatingFileHandler``,
            ``WatchedFileHandler`` or other file handlers, and the remaining
            fields will be used to build the handler.

            Examples:
                >>> file_handler_cfg = dict(
                >>>    type='TimedRotatingFileHandler',
                >>>    when='MIDNIGHT',
                >>>    interval=1,
                >>>    backupCount=365)

            `New in version 0.9.0.`
    """

    def __init__(self,
                 name: str,
                 logger_name='mmengine',
                 log_file: Optional[str] = None,
                 log_level: Union[int, str] = 'INFO',
                 file_mode: str = 'w',
                 distributed=False,
                 file_handler_cfg: Optional[dict] = None):
        Logger.__init__(self, logger_name)
        ManagerMixin.__init__(self, name)
        # Get rank in DDP mode.
        if isinstance(log_level, str):
            log_level = logging._nameToLevel[log_level]
        global_rank = _get_rank()
        device_id = _get_device_id()

        # Config stream_handler. If `rank != 0`. stream_handler can only
        # export ERROR logs.
        stream_handler = logging.StreamHandler(stream=sys.stdout)
        # `StreamHandler` record month, day, hour, minute, and second
        # timestamp.
        stream_handler.setFormatter(
            MMFormatter(color=True, datefmt='%m/%d %H:%M:%S'))
        # Only rank0 `StreamHandler` will log messages below error level.
        if global_rank == 0:
            stream_handler.setLevel(log_level)
        else:
            stream_handler.setLevel(logging.ERROR)
        stream_handler.addFilter(FilterDuplicateWarning(logger_name))
        self.handlers.append(stream_handler)


    @classmethod
    def get_current_instance(cls) -> 'MMLogger':
        """Get latest created ``MMLogger`` instance.

        :obj:`MMLogger` can call :meth:`get_current_instance` before any
        instance has been created, and return a logger with the instance name
        "mmengine".

        Returns:
            MMLogger: Configured logger instance.
        """
        if not cls._instance_dict:
            cls.get_instance('mmengine')
        return super().get_current_instance()



业务代码中使用
if not MMLogger.check_instance_created('OpenCompass'):
        logger = MMLogger.get_instance('OpenCompass',
                                       logger_name='OpenCompass',
                                       log_level=log_level)
else:
    logger = MMLogger.get_instance('OpenCompass')

4.2 代码解释

这段代码定义了一个 MMLogger 类,继承自 Logger 类和 ManagerMixin 混合类。MMLogger 用于创建格式化的日志记录器,具有全局实例管理功能,并支持不同日志级别的消息记录。这个类增加了多个特性,包括分布式日志存储和终端彩色显示等。以下是详细的解释:

MMLogger 类定义与功能

  • MMLogger 结合了 Logger 的日志功能和 ManagerMixin 的全局实例管理能力。
  • 它支持不同的日志级别,并且可以根据配置将日志存储到不同的文件中。
  • 日志消息可以在终端以不同的颜色和格式显示,提高日志的可读性。

构造函数 (init)

  • 参数解释

    • name: 全局实例名称,用于通过 ManagerMixin 管理。
    • logger_name: Logger 实例的名称,默认为 “mmengine”。
    • log_file: 如果指定,日志将被写入到这个文件中。
    • log_level: 日志级别,默认为 “INFO”。
    • file_mode: 文件打开模式,默认为 ‘w’,表示写模式。
    • distributed: 是否保存分布式日志,默认为 False
    • file_handler_cfg: 文件处理配置,用于创建不同类型的 FileHandler
  • 实现细节

    • 首先,根据 logger_name 初始化 Logger 基类。
    • 调用 ManagerMixin 的初始化函数,设置实例名。
    • 根据 log_level 的字符串表示转换为对应的日志级别数值。
    • 获取当前设备的全局排名 (global_rank) 和设备 ID (device_id),用于分布式日志记录。
    • 根据 global_rank 配置 stream_handler,只有在全局排名为 0 的设备上,才会记录低于错误级别的日志。
    • 如果指定了 log_file,将根据是否是分布式环境来配置文件名,包括主机名和排名等信息。
    • 根据 file_handler_cfg 配置或创建默认的 FileHandler,并设置格式化器和日志级别。

类方法和属性

  • get_current_instance: 获取最近创建的 MMLogger 实例。如果没有实例被创建,它会首先通过默认名称 “mmengine” 创建一个实例。

用法示例

  • MMLogger.get_instance 方法用于获取或创建 MMLogger 的实例,确保每个实例名称对应唯一的日志记录器对象。
  • 使用 MMLogger 记录的日志不受第三方日志配置的影响,这通过实例管理和专用的获取方法来保证。
  • 日志文件可以根据是否是分布式训练来分别保存,支持复杂的文件处理配置。

cls._instance_dict的说明

instance_dict = cls._instance_dict这段代码的意思是从类 cls 中获取 _instance_dict 属性,并将其赋值给本地变量 instance_dict。这里的 _instance_dict 是一个类属性,通常用于存储与该类相关的一些实例数据。

ManagerMixin 类中,_instance_dict 被用作一个有序字典(OrderedDict),用来存储该类的所有实例。这允许类通过名字快速访问其任意实例,实现了一种全局访问和管理的模式。cls._instance_dict 通常在类方法中被访问或修改,如创建新实例、获取实例、检查实例是否已创建等。

ManagerMixin 的类方法 get_instance 中,cls._instance_dict 被用来检查一个具有特定名称的实例是否已存在。如果存在,则返回该实例;如果不存在,则创建一个新实例,将其添加到 _instance_dict 中,然后返回新创建的实例。这种方式确保了每个实例的唯一性,并允许全局管理。

get_instance 方法用于获取名为 name 的实例。如果这个名字的实例不存在,方法会创建一个新的实例并将其添加到 _instance_dict 中。

为何需要 MMLogger(Logger, ManagerMixin)

在Python中,logging.Logger 类本身并不是单例模式。虽然 logging.getLogger(name) 能保证同一个 name 返回相同的 Logger 实例,但这是通过内部维护一个字典实现的,而不是真正意义上的单例模式。Logger 实例可以多次创建并拥有不同的配置,这意味着不同的地方可能会创建具有不同行为的相同名称的 Logger 实例。

MMLogger 类继承自 Logger 并混合使用了 ManagerMixin,这样的设计是为了解决以下几个方面的需求:

  1. 增强的单例管理

    • ManagerMixin 提供了一个基于名称的实例管理机制,确保全局只有一个唯一的实例,即使是在不同的模块和组件中也是如此。
    • 这种管理方式使得 MMLogger 可以在整个应用程序中共享和访问,无论是在哪个模块中创建。
  2. 定制化和独立性

    • 使用 MMLogger 允许开发者对日志系统进行定制化配置,而不受默认 logging 模块的全局配置的影响。例如,可以实现高度定制的格式化器、处理器和过滤器。
    • 它可以避免第三方库或其他模块中的日志配置对自己应用的影响。
  3. 分布式日志功能

    • MMLogger 设计了分布式日志功能,能够根据运行环境(如不同的服务器或处理器)自动调整日志的存储和显示。这在大规模分布式应用程序中非常有用,特别是在机器学习或数据处理等领域。
  4. 日志的动态配置

    • MMLogger 通过 ManagerMixin 提供的 get_instance 方法允许动态创建或获取日志实例,并且可以为不同的日志实例设置不同的文件路径、日志级别等参数。
    • 这使得日志系统的扩展性和灵活性大大提高,能够满足不同场景下的需求。

总结

虽然 logging.getLogger(name) 提供了一种方便的方式来复用 Logger 实例,但它并没有实现真正意义上的单例模式,因为 Logger 的配置是可变的,且可以在不同的上下文中被修改,导致潜在的配置冲突和行为不一致。这种设计在灵活性方面有优势,允许对 Logger 进行细粒度的配置,但也需要开发者在大型应用中仔细管理日志配置,以避免意外的行为。假设在一个大型应用程序中,有两个模块都使用名为 “MyApp” 的 Logger。在模块A中,可能设置了将日志输出到控制台的处理器,而在模块B中,则可能添加了一个将日志写入文件的处理器。如果模块A先于模块B执行,那么在模块B执行时,“MyApp” 的 Logger 实例已经包含了一个输出到控制台的处理器,这可能不是模块B期望的行为。
MMLogger 的设计旨在提供更加强大和灵活的日志管理能力,通过 ManagerMixin 实现更严格的单例控制,以及通过继承和扩展 Logger 提供的功能来满足特定的业务需求。这种设计模式在需要高度定制化和在多个地方需要访问同一日志实例的应用中尤为重要。

  • 17
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
以下是一个基本的Python自动化测试框架,您可以根据需要进行修改。 1. 安装依赖库 使用pip安装以下依赖库: - pytest:测试框架 - selenium:自动化测试工具 - pytest-html:生成测试报告 ``` pip install pytest selenium pytest-html ``` 2. 创建项目结构 在您的项目目录下,创建以下目录和文件: ``` myproject/ ├── tests/ │ ├── pages/ │ │ ├── __init__.py │ │ ├── base_page.py │ │ └── home_page.py │ ├── __init__.py │ └── test_home_page.py ├── conftest.py ├── pytest.ini └── requirements.txt ``` - tests/pages:存放页面对象和基础页面类。 - tests/test_home_page.py:存放测试用例。 - conftest.py:存放全局配置信息。 - pytest.ini:存放pytest的配置信息。 - requirements.txt:存放项目依赖库信息。 3. 编写页面对象类 在tests/pages目录下,创建base_page.py和home_page.py文件。base_page.py文件是基础页面类,home_page.py文件是首页页面对象类。以下是示例代码: base_page.py: ```python from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By class BasePage: def __init__(self, driver): self.driver = driver def wait_for_element_visibility(self, locator, timeout=10): element = WebDriverWait(self.driver, timeout).until( EC.visibility_of_element_located((By.XPATH, locator)) ) return element def click_element(self, locator, timeout=10): self.wait_for_element_visibility(locator, timeout).click() def send_keys_to_element(self, locator, keys, timeout=10): self.wait_for_element_visibility(locator, timeout).send_keys(keys) ``` home_page.py: ```python from .base_page import BasePage class HomePage(BasePage): # 页面元素定位器 search_input_locator = "//input[@name='q']" search_button_locator = "//button[@type='submit']" def search(self, keyword): self.send_keys_to_element(self.search_input_locator, keyword) self.click_element(self.search_button_locator) ``` 4. 编写测试用例 在tests/test_home_page.py文件中,编写测试用例。以下是示例代码: ```python from pages.home_page import HomePage def test_search_in_google(driver): # 实例化首页页面对象 home_page = HomePage(driver) # 打开Google首页 home_page.driver.get("https://www.google.com") # 在搜索框中输入关键词并搜索 home_page.search("python") # 验证搜索结果页面是否包含关键词 assert "python" in home_page.driver.title ``` 5. 编写全局配置信息 在conftest.py文件中,编写全局配置信息。以下是示例代码: ```python from selenium import webdriver import pytest @pytest.fixture(scope="session") def driver(): # 实例化Chrome浏览器 chrome_options = webdriver.ChromeOptions() chrome_options.add_argument("--headless") driver = webdriver.Chrome(options=chrome_options) # 将浏览器窗口最大化 driver.maximize_window() # 设置隐式等待时间为10秒 driver.implicitly_wait(10) yield driver # 关闭浏览器 driver.quit() ``` 6. 编写pytest配置信息 在pytest.ini文件中,编写pytest配置信息。以下是示例代码: ```ini [pytest] addopts = --html=report.html ``` 7. 运行测试 运行以下命令运行测试: ``` pytest ``` 测试结果将生成在项目目录下的report.html文件中。 以上是一个基本的Python自动化测试框架,您可以根据需要进行修改和扩展。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值