• cinderclient命令行源码解析


    一、简介

    openstack的各个模块中,都有相应的客户端模块实现,其作用是为用户访问具体模块提供了接口,并且也作为模块之间相互访问的途径。Cinder也一样,有着自己的cinder-client。

    二、argparse简单介绍

    argparse是python用于解析命令行参数和选项的标准模块,作为optparse的一个替代被添加到Python2.7。Cinder-client主要就是调用了argparse这个工具包。

    使用步骤:

    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument()

    parser.parse_args()

    首先导入该模块;然后创建一个解析对象;然后向该对象中添加你要关注的命令行参数和选项,每一个add_argument方法对应一个你要关注的参数或选项;最后调用parse_args()方法进行解析;解析成功之后即可使用。
    方法 ArgumentParser(prog=None, usage=None,description=None, epilog=None, parents=[],formatter_class=argparse.HelpFormatter, prefix_chars='-',fromfile_prefix_chars=None, argument_default=None,conflict_handler='error', add_help=True)

    这些参数都有默认值,当调用 parser.print_help()或者运行程序时,由于参数不正确(此时python解释器其实也是调用了pring_help()方法)时,会打印这些描述信息,一般只需要传递description参数

    方法add_argument(name or flags...[, action][, nargs][, const][, default][, type][, choices][, required][, help][, metavar][, dest])
    其中:
    name or flags:命令行参数名或者选项,如上面的address或者-p,--port.其中命令行参数如果没给定,且没有设置defualt,则出错。但是如果是选项的话,则设置为None。,parse_args()运行时,会用'-'来认证可选参数,剩下的即为位置参数
    nargs:命令行参数的个数,一般使用通配符表示,其中,'?'表示只用一个,'*'表示0到多个,'+'表示至少一个。nargs='*' 表示参数可设置零个或多个;nargs=' '+' 表示参数可设置一个或多个;nargs='?'表示参数可设置零个或一个

    default:默认值。

    type:参数的类型,默认是字符串string类型,还有float、int等类型。
    help:和ArgumentParser方法中的参数作用相似,出现的场合也一致。
    dest:如果提供dest,例如dest="a",那么可以通过args.a访问该参数
    action:参数出发的动作
    store:保存参数,默认
    store_const:保存一个被定义为参数规格一部分的值(常量),而不是一个来自参数解析而来的值。
    store_ture/store_false:保存相应的布尔值
    append:将值保存在一个列表中。
    append_const:将一个定义在参数规格中的值(常量)保存在一个列表中。
    count:参数出现的次数

    parser.add_argument("-v", "--verbosity", action="count", default=0, help="increase output verbosity")
    version:打印程序版本信息
    choice:允许的参数值

    三、cinderclient代码入口查找

    第一种方式:
    D:官网代码python-cinderclient-stable-pikesetup.cfg
    [entry_points]
    console_scripts =
        cinder = cinderclient.shell:main
    
    第二种方式:
    [root@test bin]# pwd
    /usr/bin
    [root@test bin]# ls |grep cinder
    cinder
    cinder-all
    cinder-api
    cinder-backup
    cinder-manage
    cinder-rootwrap
    cinder-rtstool
    cinder-scheduler
    cinder-volume
    cinder-volume-usage-audit
    [root@test bin]# cat cinder
    #!/usr/bin/python
    # PBR Generated from u'console_scripts'
    import sys
    from cinderclient.shell import main
    
    if __name__ == "__main__":
        sys.exit(main())
    [root@test bin]# 
    

    四、cinderclient代码分析

    D:官网代码python-cinderclient-stable-pikecinderclientshell.py
    def main():
        try:
            if sys.version_info >= (3, 0):-----sys.version获取python的版本,默认情况下, 使用系统自带的python版本,python2.6或者python 2.7
                """
    			>>> print sys.version_info
    			(2, 6, 6, 'final', 0)
    			>>> 
    			"""
    			OpenStackCinderShell().main(sys.argv[1:])---sys.argv[1:],输入的cinder命令行,sys.argv[0]表示程序本身,sys.argv[1:]表示 输入的参数
            else:
                OpenStackCinderShell().main([encodeutils.safe_decode(item)----走如下分支,步骤一
                                            for item in sys.argv[1:]])
        except KeyboardInterrupt:
            print("... terminating cinder client", file=sys.stderr)
            sys.exit(130)
        except Exception as e:
            logger.debug(e, exc_info=1)
            print("ERROR: %s" % six.text_type(e), file=sys.stderr)
            sys.exit(1)
    

    对步骤一进行详解

    from cinderclient import api_versions
    from cinderclient import client
    
    D:官网代码python-cinderclient-stable-pikecinderclientshell.py
    class OpenStackCinderShell(object):
    
        def __init__(self):
            self.ks_logger = None
            self.client_logger = None
       def main(self, argv):
            # Parse args once to find version and debug settings
    	    解析args参数一次,查找version和debug设置信息
            parser = self.get_base_parser()
    		"""
    		get_base_parser:获取基本的命令行解析器;调用add_argument方法实现添加具体命令行参数;
    		构造参数解析类ArgumentParser的实例parser,然后通过实例调用方法parser.add_argument增加一些固有的参数,比如:--debug,--help,
    		--os_auth_type等参数
    		"""
            (options, args) = parser.parse_known_args(argv)
    		"""
    		parse_known_args()方法的作用就是当仅获取到基本设置时,如果运行命令中传入了之后才会获取到的其他配置,不会报错;
    		而是将多出来的部分保存起来,留到后面使用,解析的参数按属性的方式存储到Namespace对象;
    		options的值为命名空间namespace的对象
    		"""
            self.setup_debugging(options.debug)----打开debug信息
            api_version_input = True
            self.options = options
    
            do_help = ('help' in argv) or (-----查看是不是需要对命令行进行help查询
                '--help' in argv) or ('-h' in argv) or not argv
    			
            #确定使用API的版本,默认情况下,是版本3
            if not options.os_volume_api_version:
                api_version = api_versions.get_api_version(
                    DEFAULT_MAJOR_OS_VOLUME_API_VERSION)
            else:
                api_version = api_versions.get_api_version(
                    options.os_volume_api_version)
    
            # build available subcommands based on version
    		#根据api版本号,去查找其对应的版本的扩展版本,其实本质上就是获取
    		D:官网代码python-cinderclient-stable-pikecinderclientv2contriblist_extensions.py模块中的类
            major_version_string = "%s" % api_version.ver_major
            self.extensions = client.discover_extensions(major_version_string)
            self._run_extension_hooks('__pre_parse_args__')
            
    		#基于版本api版本,创建对应的子命令解释器,同时根据对应的api_version版本,加载
    		D:官网代码python-cinderclient-stable-pikecinderclient不同版本的shell.py文件
    		D:官网代码python-cinderclient-stable-pikecinderclientv2shell.py模块
            subcommand_parser = self.get_subcommand_parser(api_version,
                                                           do_help, args)
            self.parser = subcommand_parser
    
            if options.help or not argv:---如果命令行后面跟的是help命令,那么就打印该命令的help信息,直接返回
                subcommand_parser.print_help()
                return 0
    
            argv = self._delimit_metadata_args(argv)
    		# 命令行参数的解析;
            args = subcommand_parser.parse_args(argv)
            self._run_extension_hooks('__post_parse_args__', args)
    
            # Short-circuit and deal with help right away.
            if args.func == self.do_help:
                self.do_help(args)
                return 0
            elif args.func == self.do_bash_completion:
                self.do_bash_completion(args)
                return 0
            #提取命令行参数中的基本的租户等信息存放到一个元祖里,为后面方法的调用做具体参数的准备
            (os_username, os_password, os_tenant_name, os_auth_url,
             os_region_name, os_tenant_id, endpoint_type,
             service_type, service_name, volume_service_name, os_endpoint,
             cacert, os_auth_type) = (
                 args.os_username, args.os_password,
                 args.os_tenant_name, args.os_auth_url,
                 args.os_region_name, args.os_tenant_id,
                 args.os_endpoint_type,
                 args.service_type, args.service_name,
                 args.volume_service_name,
                 args.os_endpoint, args.os_cacert,
                 args.os_auth_type)
            auth_session = None
            #对参数的认证权限的一些处理,比如是否提供租户、是否提供密码等
            if os_auth_type and os_auth_type != "keystone":
                auth_plugin = loading.load_auth_from_argparse_arguments(
                    self.options)
                auth_session = loading.load_session_from_argparse_arguments(
                    self.options, auth=auth_plugin)
            else:
                auth_plugin = None
    
            if not service_type:
                service_type = client.SERVICE_TYPES[major_version_string]
    
            # FIXME(usrleon): Here should be restrict for project id same as
            # for os_username or os_password but for compatibility it is not.
    
            # V3 stuff
            project_info_provided = ((self.options.os_tenant_name or
                                      self.options.os_tenant_id) or
                                     (self.options.os_project_name and
                                      (self.options.os_project_domain_name or
                                       self.options.os_project_domain_id)) or
                                     self.options.os_project_id)
    
            # NOTE(e0ne): if auth_session exists it means auth plugin created
            # session and we don't need to check for password and other
            # authentification-related things.
            if not utils.isunauthenticated(args.func) and not auth_session:
                if not os_password:
                    # No password, If we've got a tty, try prompting for it
                    if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty():
                        # Check for Ctl-D
                        try:
                            os_password = getpass.getpass('OS Password: ')
                            # Initialize options.os_password with password
                            # input from tty. It is used in _get_keystone_session.
                            options.os_password = os_password
                        except EOFError:
                            pass
                    # No password because we didn't have a tty or the
                    # user Ctl-D when prompted.
                    if not os_password:
                        raise exc.CommandError("You must provide a password "
                                               "through --os-password, "
                                               "env[OS_PASSWORD] "
                                               "or, prompted response.")
    
                if not project_info_provided:
                    raise exc.CommandError(_(
                        "You must provide a tenant_name, tenant_id, "
                        "project_id or project_name (with "
                        "project_domain_name or project_domain_id) via "
                        "  --os-tenant-name (env[OS_TENANT_NAME]),"
                        "  --os-tenant-id (env[OS_TENANT_ID]),"
                        "  --os-project-id (env[OS_PROJECT_ID])"
                        "  --os-project-name (env[OS_PROJECT_NAME]),"
                        "  --os-project-domain-id "
                        "(env[OS_PROJECT_DOMAIN_ID])"
                        "  --os-project-domain-name "
                        "(env[OS_PROJECT_DOMAIN_NAME])"
                    ))
    
                if not os_auth_url:
                    raise exc.CommandError(
                        "You must provide an authentication URL "
                        "through --os-auth-url or env[OS_AUTH_URL].")
    
            if not project_info_provided:
                raise exc.CommandError(_(
                    "You must provide a tenant_name, tenant_id, "
                    "project_id or project_name (with "
                    "project_domain_name or project_domain_id) via "
                    "  --os-tenant-name (env[OS_TENANT_NAME]),"
                    "  --os-tenant-id (env[OS_TENANT_ID]),"
                    "  --os-project-id (env[OS_PROJECT_ID])"
                    "  --os-project-name (env[OS_PROJECT_NAME]),"
                    "  --os-project-domain-id "
                    "(env[OS_PROJECT_DOMAIN_ID])"
                    "  --os-project-domain-name "
                    "(env[OS_PROJECT_DOMAIN_NAME])"
                ))
    
            if not os_auth_url and not auth_plugin:
                raise exc.CommandError(
                    "You must provide an authentication URL "
                    "through --os-auth-url or env[OS_AUTH_URL].")
            #没有提供认证会话的,那么与keystone建立认证会话
            if not auth_session:
                auth_session = self._get_keystone_session()
    
            insecure = self.options.insecure
    
            self.cs = client.Client(---------------步骤二,本质上是一个http请求
                api_version, os_username,
                os_password, os_tenant_name, os_auth_url,
                region_name=os_region_name,
                tenant_id=os_tenant_id,
                endpoint_type=endpoint_type,
                extensions=self.extensions,
                service_type=service_type,
                service_name=service_name,
                volume_service_name=volume_service_name,
                bypass_url=os_endpoint,
                retries=options.retries,
                http_log_debug=args.debug,
                insecure=insecure,
                cacert=cacert, auth_system=os_auth_type,
                auth_plugin=auth_plugin,
                session=auth_session,
                logger=self.ks_logger if auth_session else self.client_logger)
    
            try:
    		# 如果所要调用的方法没有标志为unauthenticated,则需要进行身份验证操作;
                if not utils.isunauthenticated(args.func):
                    self.cs.authenticate()
            except exc.Unauthorized:
                raise exc.CommandError("OpenStack credentials are not valid.")
            except exc.AuthorizationFailure:
                raise exc.CommandError("Unable to authorize user.")
    
            endpoint_api_version = None
            # Try to get the API version from the endpoint URL.  If that fails fall
            # back to trying to use what the user specified via
            # --os-volume-api-version or with the OS_VOLUME_API_VERSION environment
            # variable.  Fail safe is to use the default API setting.
            try:
                endpoint_api_version = 
                    self.cs.get_volume_api_version_from_endpoint()
            except exc.UnsupportedVersion:
                endpoint_api_version = options.os_volume_api_version
                if api_version_input:
                    logger.warning("Cannot determine the API version from "
                                   "the endpoint URL. Falling back to the "
                                   "user-specified version: %s",
                                   endpoint_api_version)
                else:
                    logger.warning("Cannot determine the API version from the "
                                   "endpoint URL or user input. Falling back "
                                   "to the default API version: %s",
                                   endpoint_api_version)
    
            profile = osprofiler_profiler and options.profile
            if profile:
                osprofiler_profiler.init(options.profile)
    
            try:
                args.func(self.cs, args)----实现根据解析的命令行参数调用具体的方法,假如使用的命令行为cinder list,该处args.func = do_list,
    说明这里调用的具体方法是do_list; finally: if profile: trace_id = osprofiler_profiler.get().get_base_id() print("Trace ID: %s" % trace_id) print("To display trace use next command: " "osprofiler trace show --html %s " % trace_id)

    对步骤二详解

    D:官网代码python-cinderclient-stable-pikecinderclientclient.py
    def Client(version, *args, **kwargs):
     """Initialize client object based on given version."""
         api_version, client_class = _get_client_class_and_version(version)----对步骤2.1 详解根据api版本version版本号,获取对应目录下的Client函数
         return client_class(api_version=api_version,*args, **kwargs)-------对步骤2.2的详解
    
    对步骤2.1详解
    D:官网代码python-cinderclient-stable-pikecinderclientclient.py
    def _get_client_class_and_version(version):
        if not isinstance(version, api_versions.APIVersion):
            version = api_versions.get_api_version(version)
        else:
            api_versions.check_major_version(version)
        if version.is_latest():
            raise exceptions.UnsupportedVersion(
                _("The version should be explicit, not latest."))
        return version, importutils.import_class(
            "cinderclient.v%s.client.Client" % version.ver_major)	 
    		
    对步骤2.2 的详解---假如使用的版本为v2版本
    D:官网代码python-cinderclient-stable-pikecinderclientv2client.py
    from cinderclient import client
    from cinderclient import api_versions
    from cinderclient.v2 import availability_zones
    from cinderclient.v2 import cgsnapshots
    from cinderclient.v2 import consistencygroups
    from cinderclient.v2 import capabilities
    from cinderclient.v2 import limits
    from cinderclient.v2 import pools
    from cinderclient.v2 import qos_specs
    from cinderclient.v2 import quota_classes
    from cinderclient.v2 import quotas
    from cinderclient.v2 import services
    from cinderclient.v2 import volumes
    from cinderclient.v2 import volume_snapshots
    from cinderclient.v2 import volume_types
    from cinderclient.v2 import volume_type_access
    from cinderclient.v2 import volume_encryption_types
    from cinderclient.v2 import volume_backups
    from cinderclient.v2 import volume_backups_restore
    from cinderclient.v2 import volume_transfers
    
    class Client(object):
    
        def __init__(self, username=None, api_key=None, project_id=None,
                     auth_url='', insecure=False, timeout=None, tenant_id=None,
                     proxy_tenant_id=None, proxy_token=None, region_name=None,
                     endpoint_type='publicURL', extensions=None,
                     service_type='volumev2', service_name=None,
                     volume_service_name=None, bypass_url=None, retries=0,
                     http_log_debug=False, cacert=None, auth_system='keystone',
                     auth_plugin=None, session=None, api_version=None,
                     logger=None, **kwargs):
            # FIXME(comstud): Rename the api_key argument above when we
            # know it's not being used as keyword argument
            password = api_key
            self.version = '2.0'
            self.limits = limits.LimitsManager(self)
    
            # extensions------引入统一目录下,不同资源的管理类
            self.volumes = volumes.VolumeManager(self)
            self.volume_snapshots = volume_snapshots.SnapshotManager(self)
            self.volume_types = volume_types.VolumeTypeManager(self)
            self.volume_type_access = 
                volume_type_access.VolumeTypeAccessManager(self)
            self.volume_encryption_types = 
                volume_encryption_types.VolumeEncryptionTypeManager(self)
            self.qos_specs = qos_specs.QoSSpecsManager(self)
            self.quota_classes = quota_classes.QuotaClassSetManager(self)
            self.quotas = quotas.QuotaSetManager(self)
            self.backups = volume_backups.VolumeBackupManager(self)
            self.restores = volume_backups_restore.VolumeBackupRestoreManager(self)
            self.transfers = volume_transfers.VolumeTransferManager(self)
            self.services = services.ServiceManager(self)
            self.consistencygroups = consistencygroups.
                ConsistencygroupManager(self)
            self.cgsnapshots = cgsnapshots.CgsnapshotManager(self)
            self.availability_zones = 
                availability_zones.AvailabilityZoneManager(self)
            self.pools = pools.PoolManager(self)
            self.capabilities = capabilities.CapabilitiesManager(self)
            self.api_version = api_version or api_versions.APIVersion(self.version)
    
            # Add in any extensions...
            if extensions:
                for extension in extensions:
                    if extension.manager_class:
                        setattr(self, extension.name,
                                extension.manager_class(self))
    
            if not logger:
                logger = logging.getLogger(__name__)
    
            self.client = client._construct_http_client(----本质上就是调用http模块的,建立session连接,同时拼接url的请求头,请求体
                username=username,
                password=password,
                project_id=project_id,
                auth_url=auth_url,
                insecure=insecure,
                timeout=timeout,
                tenant_id=tenant_id,
                proxy_tenant_id=tenant_id,
                proxy_token=proxy_token,
                region_name=region_name,
                endpoint_type=endpoint_type,
                service_type=service_type,
                service_name=service_name,
                volume_service_name=volume_service_name,
                bypass_url=bypass_url,
                retries=retries,
                http_log_debug=http_log_debug,
                cacert=cacert,
                auth_system=auth_system,
                auth_plugin=auth_plugin,
                session=session,
                api_version=self.api_version,
                logger=logger,
                **kwargs)
    
        def authenticate(self):
            """Authenticate against the server.
    
            Normally this is called automatically when you first access the API,
            but you can call this method to force authentication right now.
    
            Returns on success; raises :exc:`exceptions.Unauthorized` if the
            credentials are wrong.
            """
            self.client.authenticate()
    
        def get_volume_api_version_from_endpoint(self):
            return self.client.get_volume_api_version_from_endpoint()  

    五、添加一个新的命令行,这个命令行的功能为获取卷的连接信息,

    1)命令行设计的样式如下所示:

    cinder create-target volume_id hostname ip initiator
    usage: cinder create  [--platform <platform>]
    		      [--do_local_attach <do_local_attach>]
    		      [--os_type <os_type>]
    		      [--multipath <multipath>]
    		      [<volume_id>]
    		      [<host_ip >]
    		      [<host_name>]
    		      [<initiator>]
    Creates a volume target.	
    Positional arguments:
    <volume_id> volume uuid
    <host_ip >  host ip ,which attach volume
    <host_name> host name ,which attach volume
    <initiator> host iscsi client,which attach volume
    
    Optional arguments:		
    --platform <platform>   host architecture ,which attach volume.Default=x86_64
    --do_local_attach <do_local_attach>   if or not attach volume.Default=None
    --os_type <os_type>  host operation system,whiic attach volume.Default=linux2
    --multipath <multipath> if or not iscsi multipath.Default=None
    

     

    2)代码实现,在v2版本的shell.py文件中新增一个do_create_target函数

    D:官网代码python-cinderclient-stable-ocatacinderclientv2shell.py
    @utils.arg('volume',
               metavar='<volume_id>',
               help='volume uuid')
    @utils.arg('host_ip',
               metavar='<host_ip>',
               help='host ip ,which attach volume')
    @utils.arg('host_name',
               metavar='<host_name>',
               help='host name ,which attach volume')
    @utils.arg('initiator',
               metavar='<initiator>',
               help='host iscsi client,which attach volume')
    @utils.arg('--platform',
               metavar='<platform>',
               default='x86_64',
               help='host architecture ,which attach volume.Default=x86_64')
    @utils.arg('--do_local_attach',
               metavar='<do_local_attach>',
               default='false',
               help='if or not attach volume.Default=false')
    @utils.arg('--os_type',
               metavar='<os_type>',
               default='linux2',
               help='host operation system,whiic attach volume.Default=linux2')
    @utils.arg('--multipath',
               metavar='<multipath>',
               default='false',
               help='if or not iscsi multipath.Default=false')
    def do_create_target(cs,args):
        """create volume iscsi target."""
        volume_id=args.volume
        connector={}
        connector['ip']=args.host_ip
        connector['host']=args.host_name
        connector['initiator']=args.initiator
        connector['platform']=args.platform
        connector['do_local_attach']=args.do_local_attach
        connector['os_type']=args.os_type
        connector['multipath']=args.multipath
        info=cs.volumes.initialize_connection(volume_id,connector)
        utils.print_dict(info)

    输出内容如下:

    [root@test ~]# cinder help create-target
    usage: cinder create-target [--platform <platform>]
                                [--do_local_attach <do_local_attach>]
                                [--os_type <os_type>] [--multipath <multipath>]
                                <volume_id> <host_ip> <host_name> <initiator>
    
    create volume iscsi target.
    
    Positional arguments:
      <volume_id>           volume uuid
      <host_ip>             host ip ,which attach volume
      <host_name>           host name ,which attach volume
      <initiator>           host iscsi client,which attach volume
    
    Optional arguments:
      --platform <platform>
                            host architecture ,which attach volume.Default=x86_64
      --do_local_attach <do_local_attach>
                            if or not attach volume.Default=false
      --os_type <os_type>   host operation system,whiic attach
                            volume.Default=linux2
      --multipath <multipath>
                            if or not iscsi multipath.Default=false
    [root@test ~]# 
    

    3)验证命令行

    [root@test ~]# cinder --debug create-target a0ac29de-3a16-4b07-9aac-de63ccdf8fda 10.27.244.149 my03n010027244149.sncloud.com 
    iqn.1994-05.com.redhat:7329936b16d9 DEBUG:keystoneauth:REQ: curl -g -i -X POST http://10.27.241.34:8776/v2/d432ed8741cc427da398e4239f44deb4/volumes/a0ac29de-3a16-4b07-9aac-de63ccdf8fda/action -H "User-Agent: python-cinderclient" -H "Content-Type: application/json" -H "Accept: application/json" -H "X-Auth-Token: {SHA1}10e60c88cab7620ad3864cb1110d2b9c64a8170f" -d { "os-initialize_connection": { "connector": { "initiator": "iqn.1994-05.com.redhat:7329936b16d9", "ip": "10.27.244.149", "platform": "x86_64", "host": "my03n010027244149.sncloud.com", "do_local_attach": "false", "os_type": "linux2", "multipath": "false" } } } RESP BODY: { "connection_info": { "driver_volume_type": "iscsi", "data": { "target_luns": [0], "target_iqns": ["iqn.2010-10.org.openstack:volume-a0ac29de-3a16-4b07-9aac-de63ccdf8fda"], "auth_password": "24do7AqfnLDZ5DyB", "target_discovered": false, "encrypted": false, "qos_specs": null, "target_iqn": "iqn.2010-10.org.openstack:volume-a0ac29de-3a16-4b07-9aac-de63ccdf8fda", "target_portal": "10.27.244.144:3260", "volume_id": "a0ac29de-3a16-4b07-9aac-de63ccdf8fda", "target_lun": 0, "access_mode": "rw", "auth_username": "2y35wC68BsvU8M37tWCn", "auth_method": "CHAP", "target_portals": ["10.27.244.144:3260"] } } }

      

      

     

      

      

     

  • 相关阅读:
    mysql、mongodb、redis区别
    Redis面试题
    【开发提效】开发提效技巧
    【代码规范】16条军规
    mysql数据库连接字符串
    mysql数据库连接字符串
    好用的api接口
    node + express + vue
    跨源资源共享(CORS)
    fehelper
  • 原文地址:https://www.cnblogs.com/potato-chip/p/12924121.html
Copyright © 2020-2023  润新知