• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

从swift-init main start 开始看swift代码

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

从swift-init main start 开始看swift代码

<本人此前发表在新浪轻博客的原创文章,转载请标注原作者> 

swift作为openstack的对象存储系统,在openstack生态环境中扮演如AmazonS3的功能。虽然本人之前学习过ruby之类的脚本语言,但是也是从看swift代码的过程中逐渐学习python的特性,其中必然会有种种错误,请看官斧正。

 

环境:

OS:Ubuntu12.04 LTS (10.04LTS更新python2.6.5之后unittest会failed)

Swift-install:按照swiftdev文档中SAIO的方式部署

Swift-version:  1.4.6(相当于openstack E版)

PythonVersion: 2.7.3

开始:

swift是一个对象存储,官方文档对比文件系统时提到其主要是没有文件系统文件(夹)嵌套的概念。其实swift这里是简化了处理多层文件的问题,通过container和account来进行逻辑上的区分,形成一个相对较为flat的结构。这里从Swift的RESTAPI也可以看出来。基本的结构如下:

METHOD http(s)://host:port/<api_version>/<account>/<container>/<object>

这种目录式的API结构尤其是将version的加入,在InfoQ上许多介绍RESTAPI设计准则上都有提及。

 

可能是由于中毒于Java代码中的“过度设计”,本人阅读swift的代码时感觉中可以优化的地方确实很多,比如几乎所有server中做check的方法,完全可以提取成一个单独的内部方法;再比如说这里用于操作数据库的swift/common/db.py里的AccountBroker和ContainerBroker为什么不单独放在各自的包里,而是放在common下操作数据库的入口这里。也可能是pythonist的习惯使然,但是在一个py文件中写好几个类导致近2000行代码,还是有点让人有些阅读障碍。

 

在SAIO的文档中,提到需要自己添加启动脚本startmain,实际功能就是执行了 swift-initmain start,我们就从这行命令开始入手研究swift代码。

 

swift-init是{swift_locate}/bin目录下的一个python脚本,主要功能就是启动swift的基本服务,并且指定了commandline下可以选择配置的参数,其中命令的格式为

swift-init<server> [<server> ...] <command> [options]

具体执行的功能是在{swift_locate}/swift/common/manager.py中完成的。manager.py中定义了支持的command,包括[\'status\',\'start\',\'no_wait\',\'no_daemon\', \'once\',\'stop\', \'shutdown\',\'restart\', \'reload\',\'force_reload\'],并且将swift的服务分为两大类:

ALL_SERVERS= [\'account-auditor\',\'account-server\',\'container-auditor\',

\'container-replicator\',\'container-server\',\'container-sync\',

\'container-updater\',\'object-auditor\',\'object-server\',\'object-expirer\',

\'object-replicator\',\'object-updater\',\'proxy-server\',

\'account-replicator\',\'account-reaper\']

MAIN_SERVERS= [\'proxy-server\',\'account-server\',\'container-server\',

\'object-server\']

在mainstart命令中启动了proxy-server,account-server, container-server和object-server,这些是swift提供的基本服务,在官方文档exampleinstallation architecture章节里,可以看到,在集群环境下proxy-server,proxynode中只需运行proxy-server,storagenode中则需要运行剩下的3个server。在Manager#start方法中,在执行完基本的setup_env之后,将每一个MAIN_SERVERS里的server包装成Server对象(Server包含server,type,cmd和procs四个属性,type会在下文用于查找配置文件,cmd用来启动server实例),然后执行执行Server#launch。这里会分别读取server的configuration文件,configuration文件是按照utils#search_tree的方法查找得到,即在SWIFT_DIR下查找文件名符合{server_type}-server*.conf的文件或符合{server_type}-server*的文件夹,若为文件夹,则在此文件夹中查找后缀名为.conf的文件,其中SWIFT_DIR默认为/etc/swift,server_type为\'account\',\'container\', \'object\',\'proxy\'之类。然后执行Server.cmd命令启动服务。这里server.cmd实际也是根据server的名字组成的字符串,对应bin目录下的脚本命令。由于每一个服务对应一个conf文件,所以,如果是在集群环境下会有多个进程在不同的storagenode上执行,每一个进程代表一个server。由于实验环境是ALLIN ONE的方式,所以可以在ps中看到多个accout/container/objectserver进程在执行。与此相一致的是,swift的内部组件之间的交互完全是采用RESTAPI调用的方式,这样在简化交互方式的同时也降低了耦合。

 

Swift的Http实现利用了Eventlet和paste.deploy,具体实现在wsgi.py中,其中wsgi.server所需的app参数是利用paste.deploy风格的config文件产生的,这也是为什么上文提到的.conf文件中包含形如

[app:account-server]

use= egg:swift#account

的信息了。另外,在*/server.py中的app_factory方法,正是返回了app的实例。接下来,分别介绍一下proxy/server.py,accout/server.py, container/server.py,obj/server.py。proxy/server.py用于处理和转发对swift的所有请求,所有对swift的操作都由proxy/server.py中Application#handle_request处理,并最终由另外3种server的实例分别提供对account,container, object的RESTAPI访问。

 

proxy-server

首先,从用户API请求入手,研究proxy/server.py中对于http请求的处理。

从exampleinstallation architecture中可以看出,proxy-server的作用是捕获所有对swift的HTTP请求,然后将处理的请求发送至storagenode进行处理。proxy/server.py中定义了proxy-server的Application类,和处理请求的*Controller类,类图如下:

proxy/server.py的app_factory方法返回的是Application的实例,相比其父类BaseApplication,Application在handle_request()方法中来处理发送来的HTTP请求。过程如下图所示:

当一个Userrequest发送至proxy-server后,首先handle的是Application#handle_request(图中A),其实就是为request添加当前时间作为start_time,然后调用父类方法的handle_request方法(图中a),在BaseApplication#handle_request中(图中B),会根据request的url(/<account>/<container>/<object>)调用BaseApplication#get_controller,根据URL的目录层级返回相应的X-Controller(AccountController/ContainerController/ObjectController)。然后通过

controller= controller(self, **path_parts)

新建X-Controller实例,即X-Controller.app=Application,path_parts是从url解析出的dict类型字典,包含version,account_name,container_name,object_name。有了处理请求的的Class,再根据请求的HTTPmethod选择调用的class的方法,handler= getattr(controller, req.method)。之后对request进行authorize,如果权限满足,调用选择的X-Controller的HTTP同名的方法(图中b)。

在图中C过程中,X-Controller会根据request的HTTP方法对请求进行处理,这里值得注意的是,GET和HEAD请求的处理方法是一样的,代码里可以看出GET和HEAD方法都被转送到GETorHead方法了。除了GETorHead方法被Controller#GETorHead_base处理之外,其他的方法都在各自的方法体中先从ring中得到node信息(通过self.app.xxx_ring.get_nodes),增加或修改一些head信息,然后调用父类Controller#make_requests方法(图中D)对storagenode上的swift服务发出HTTP请求(图中d)。这里对Object的COPY请求会被转换成PUT请求发送。

关于Ring的code层面的知识我会在进一步研究后分享,从文档和概念上的了解来看是一个基于一致性哈希原理的存储负载均衡手段的实现。Ring#get_nodes方法会返回的信息是一个二元组(partition,list of node dicts),每一个nodedict会至少包含id,weight,zone,ip,port,device,meta信息,代码中的描述如下:

值得注意的是当使用make_requests调用storagenodes的RESTAPI时,URL发生了改变,新的API格式为:METHODhttp://node_ip:node_port/node_device/partition/<account>/<container>/<object>,其中node_ip:node_port/node_device/partition的值均来自Ring#get_nodes,account,container,object的name均来自用户的http请求。当storagenodes的response回复给proxy后,Controller#best_response方法会从中选择合适的并加以处理和log回传给X-Controller进行进一步的处理后返回给用户(图中E,F)。

这样,来自用户的请求就通过proxy-server转发给storagenodes上的swift服务了。

account-server, container-server, object-server:

下面开始深入了解运行在storagenode中的account-server,container-server和object-server。

swift使用了sqlite轻量级数据库,用于存储account/container的状态信息和metadata,同时object存储需要用到文件的扩展属性来标识metadata,因此需要注意部署时的文件系统。这里对数据库的操作是通过swift/common/db.py中的X-Broker来处理的,object的元数据信息则是利用xattr提供的方法实现读写,序列化操作是利用cPickle包中的功能实现。其中的关系如下图:

接下来,我们逐一介绍每个controller。

 

1.AccountController

从类图里可以很清晰的看到用于处理HTTP请求的方法,其中POST/PUT/GET/DELETE/HEAD用于新建/更新(若不存在则新建)/查询/删除Account的RESTAPI,HEAD和GET的区别在于HEAD只返回GET请求HTTP头的元数据信息,另外的REPLICATE是Swift内部用于维护和冗余备份的API。属性root,mount_check, auto_create_account_prefix的值分别来源于conf文件里key为\'devices\',\'mount_check\',\'auto_create_account_prefix\'的value,如果为空,则默认值分别为\'/srv/node\',\'true\',\'.\'。从属性名可以看出,root是devices所在根目录 ,mount_check是是否进行mount检查,\'auto_create_account_prefix\'是自动创建account的前缀。另外的replicator_rpc和logger主要是提供replicator和日志功能,与此相关的replicator下次会详细介绍。

方法_get_account_broker是一个内部方法,功能是返回一个AccountBroker的实例,用于代理对sqlite数据库的操作。sqlite是个嵌入式数据库,对于一个特定的account,其sqlitedb文件的绝对文件路径为:

db_file={account.root}/{device}/{DATADIR}/{partition}/{hash_path[-3:]}/{hash_path}/{hash_path}.db

hash_path=hash(account_name)

这样,对于一个特定的account其相关的database就对应一个特定的文件,{DATADIR}=\'accounts\',{account.root}为AccountController的root属性,{dirve},{partition},{account_name}分别来自HTTP请求中的URL,util#hash函数是对name和配置文件/etc/swift/swift.conf中的HASH_PATH_SUFFIX连接后取md5摘要。

知道了db文件,就可以利用sqlite3命令对数据库进行访问了,可以查看account数据库的schema,这些信息也可以在DatabaseBroker#initialize,AccountBroker#create_container_table和AccountBroker#create_account_stat_table这三个方法中看到,信息如下:

从之前介绍针对不同的account会有不同的db_file路径,而且account和container只是逻辑上的概念,所以account_stat只会有一行数据,用来存储当前路径所属account的相关状态信息,metadata用dict结构存储account相关元数据,结构是<key,(value,timestamps)>;container表则是存储当前account的container的基本统计信息,并且在代码中可以看到在对container进行insert/update/delete操作时会有TRIGGER对account_stat表进行更新。

 

1.DELETE

DELETE请求会删除当前account,但是这里的删除是逻辑删除,只是标记account为删除状态,并不会真正删除account和相关资源。流程如下

     1.checkrequest url是否合法,mount_checkdb 所在文件目录是否存在,检查head是否包含特定信息\'x-timestamp\',如果均满足,转入2

 

     2.检查db_file.pending文件,取出未同步的数据更新到数据库中(AccountBroker#_commit_puts),转入3

 

     3.检查所请求的account是否为DELETED状态,如果是转入4

 

     4.如果满足上述要求,调用DatabaseBroker#delete_db,这里清除metadata的数据并且调用AccountBroker#_delete_db将status置为DELETED,同时更新相关timestamp。

 

2.PUT

PUT请求会handle两种类型的HTTP请求,当没有url中没有<container>时,会更新account的metadata信息,流程如下:

     1.checkrequest url是否合法,mount_checkdb所需的文件目录是否存在,检查head中的\'x-timestamp\',如果满足,转入2

 

     2.若db_file不存在,则新建数据库,created=True,否则updateaccount_statu的put_timestamp字段(AccountBroker#update_put_timestamp),转入3

 

     3.根据requesthead中的以\'x-account-meta-\'开始的key的值更新到metadata,并且更新数据库的metadata字段(AccountBroker#update_metadata),转入4

 

     4.如果created==True,返回201Created,否则返回202Accepted。

 

在代码中可以看到步骤2和步骤3是分两次commit的,这里如果步骤3失败步骤2不会发生回滚,个人觉得存在问题。

若url有<container>时,Account-Server则会新建/更新container的信息,这里不会直接更新至数据库,而是将序列化到db_file.pending文件(AccountBroker#put_container)。

 

 

3.HEAD

HEAD请求返回account的基本信息(元数据信息),并以key-value的形式保存在HTTPHEAD中返回,通过AccountBroker#get_info获得account的基本信息(account,created_at, put_timestamp, delete_timestamp, container_count,object_count, bytes_used, hash,id),如果url中包含container,则返回信息中还会包含利用AccountBroker#get_container_timestamp得到的container的put_timestamp(最近更新时间)。

 

4.GET

在HEAD请求的基础上,在responesebody中会包含container的list(AccountBroker#list_containers_iter),这里会根据请求中需要的返回格式返回相关信息,支持的格式包括json,xml,plain。有近50行代码解决将数据用字符串拼出json/xml的格式,同时在ContainerController也有类似方法,个人认为可以抽象到utils方法中去。

 

5.REPLICATE

这个请求主要用于replicator的同步请求,保持系统的一致性(ReplicatorRpc#dispatch)。具体细节会在分析replicator服务的时候详述。

 

6.POST

用于根据requesthead中的以\'x-account-meta-\'开头的key的值更新account_stat表中的metadata字段的值。流程也是先检查url和目录存在与否,以及account是否为DELETED状态,然后从head中取出特定要求的metadata更新至数据库(AccountBroker#update_metadata)。相对简单,这里不再赘述。

 

2.ContainerController

Container与Account相类似,都是在逻辑结构,不同的是Account的子对象是Container,而Container的子对象是Object。从类图中可以看出ContainerController和AccountController在很多地方有相似之处。众多属性中,root,mount_check,node_timeout,conn_timeout, allowed_sync_hosts,auto_create_account_prefix的值均来自于container的conf文件,replicator_rpc是ReplicatorRpc的实例用于冗余处理,save_heads是请求头中需要包含键值对的键的数组。

ContainerController#_get_container_broker返回一个ContainerBroker实例,用于代理其数据库访问的操作。其db_file的绝对路径为:

db_file={container.root}/{device}/{DATADIR}/{partition}/{hash_path[-3:]}/{hash_path}/{hash_path}.db

hash_path=hash(account_name+container_name)

这里我们可以通过代码或sqlite3命令查看container数据库的schema,如下图:

ContainerController#account_update用于在对container做删除/修改操作时通知其所属account做同步修改,主要部分就是向account所在server_ip发送PUT请求,URL格式为:

PUThttp://{account_ip}:{account_port}/{account_device}/{account_partition}/{account}/{container}

还记得AccountController#PUT吗?提供带container的PUT请求的处理机制,就是为了在这里更新container信息时同时更新account。

1.DELETE

输入的URL格式为host:port/device/partition/account/container/<object>

其中<object>可有可无。如果没有object字段,说明是删除container,过程和Account的DELETE操作一样,先进行一系列检查,然后根据db_file.pengding文件刷新数据库到最新状态并检查是否已经删除,如果status字段不为DELETED,清空数据库中的metadata字段,更新delete_timestamp然后置status字段为DELETED,最后调用account_update通知其所属account更新状态。

如果URL中包含object字段,则是为了在对其所包含的Object进行操作后同步更新container,这里会调用ContainerBroker#delete_object,同样也是将删除信息序列化后写入db_file.pending文件,待下次对该container操作时更新进数据库。

 

2.PUT

输入的URL格式为host:port/device/partition/account/container/<object>,其中<object>可有可无。这里的处理流程同AccountController#PUT,如果包含object信息,则序列化后写入db_file.pending文件,否则根据request.head中的key-value更新container_statu数据库的metadata,并且调用account_update通知account-server更新状态。

3.HEAD,GET,POST,REPLICATE的流程同AccountController,只是操作的对象是container。这里不再详细介绍。

 

3.ObjectController

ObjectController直接操作的是文件系统中的文件,用来检索/删除/修改/新建对象系统中存储的对象,而非account/container那样的逻辑层,因此这里没有用到数据库来存储object的元数据,而是将其直接与文件系统中文件的扩展属性相关联。Swift中用DiskFile类来表示存储的文件对象,ObjectController对文件实体的操作都是通过DiskFile代理的。类图如下:

这里详细介绍一下这两个类的属性和方法.

 

 

ObjectController:

        1.DATADIR=\'objects\',常量,用来组成具体文件和目录;

        2.devices,来自配置文件中\'devices\'的值,默认\'/srv/node/\',功能同AccountController,ContainerController中的root属性;

        3.Logger,用于记录log信息,根据配置文件生成的LogAdapter对象;

        4.mount_check,用于确定是否检查文件挂载情况,来自配置文件中\'mount_check\'的值,默认true;

        5.node_timeout,conn_timeout,用于配置超时时间,分别来自配置文件中的\'node_timeout\'和\'conn_timeout\',默认分别为3,0.5;

        6.disk_chunk_size,network_chunk_size,文件系统和网络中文件的块的小,分别来自配置文件中\'disk_chunk_size\'和\'network_chunk_size\',默认为65536;

        7.log_requests,是否记录requests信息,来自配置文件中\'log_requests\',默认为true

        8.max_upload_time,最大上传时间,默认86400;

        9.slow,用于控制请求间隔时间,来自配置文件中\'slow\',默认为0;

        10.bytes_per_sync,当文件较大时,当buffer_cache多大时写入一次文件系统,取值来自于配置文件\'mb_per_sync\'(单位为MB),默认为512M

        11.allowed_headers,可以处理的http请求header的keys

        12.expiring_objects_account,expiring_objects_container_divisor在delete_at_update中用于同步更新object的container相关的信息。

        13.async_update()用于当object发生变化时,发送HTTP请求至所属container,更新container的数据,如果请求失败,则将更新序列化写入async_dir的dest文件中,具体路径如下:

ASYNCDIR=\'async_pending\'

async_dir=self.devices/objdevice/<ASYNCDIR>

hash_path=hash(account,container, obj)

dest=<async_dir>/<hash_path>[-3:]/<hash_path>-<timestamp>

         14.container_update()用于当object更新时更新所属container,具体请求是通过async_update发送的

         15.delete_at_update()用于当object被删除时更新所属container,具体请求是通过async_update发送的

         16.POST/PUT/GET/HEAD/DELETE/REPLICATE用于处理HTTP请求,详细过程会在下文详细介绍,这里先不详述。

DiskFile:

          1.传入参数path,device, partition, account, container, obj, logger,keep_data_fp=False,disk_chunk_size=65536,其中deivce,partition, account, container,obj的值来自REST请求,其他来自ObjectController的属性(path=ObjectController.devices)

          2.disk_chunk_size,每次操作文件的块大小

          3.name,DiskFile的名字标识,值为/<account>/<container>/<obj>

          4.datadir,表示object信息文件所在目录,

DATADIR=\'objects\'

hash_path=hash(account,container, obj)

datadir=<path>/<device>/DATADIR/<partition>/<hash_path>[-3:]/<hash_path>/

          5.device_path,值为<path>/<device>

          6.tmpdir,临时文件目录,值为device_path/tmp

          7.logger,传入的logger对象

          8.metadata,文件的元数据信息

          9.meta_file,存放文件元数据信息的文件路径

          10.data_file,存放object的文件路径

          11.fp,data_file的文件对象

          12.iter_etag,started_at_0,read_to_eof,quarantined_dir,keep_cache为操作DiskFile的细节设置。

构造函数中有meta_file和data_file的初始化过程:

     1.files为datadir下所有文件文件名的list

     2.如果files中有文件名后缀为\'.ts\',表示该对象已经标记为删除,则meta_file和data_file均为None

     3.否则后缀名为\'.meta\'的文件为meta_file,后缀名为\'.data\'的文件为data-file。

构造函数中有metadata的初始化过程,其值来自于data_file的xattr和meta_file

           13.app_iter_range()读取data_file中的制定段内容。由于DiskFile重写了__iter__,所以可以以iterator的方式对其data_file读写

           14._handle_close_quarantine(),检查data_file是否被损坏

           15.close(),关闭data_file所代表的文件

           16.is_deleted(),通过判断data_file是否存在和metadata是否有\'deleted\'项,检查DiskFile是否被删除

           17.mkstemp(),生成一个临时文件

           18.put(),将文件写入到磁盘中,并且将临时文件覆盖.data或.meta或.ts文件,格式为<file.datadir>/<timestamp>.<extension>

           19.unlinkold(),删除object的老版本文件,根据传入参数timestamp进行版本比较

           20.drop_cache(),清空文件缓存

           21.quarantine(),如果data_file被损坏,将文件移至quarantined_dir(device_path/quarantined/objects/basename(data_file))

           22.get_data_file_size(),返回data_file的大小,如果data_file在OS中的大小与metadata不一致,会抛出异常DiskFileError。

了解了每个方法的功能,下面来详细了解下object-server是如果处理来自proxy的REST请求。

1.POST

更新object的元数据信息,流程如下

     1.从requesturl中提取device,partition, account, container, obj,检查requestheads中的\'x-timestamp\'是否存在,检查mount情况

     2.根据请求信息新建DiskFile对象file,检查是否存在(包括检查metadata中的\'X-Delete-At\',调用file#is_deleted()和检查file.data_size)

     3.如果检查都通过,则根据request.heads中的元素更新metadata

     4.从request.heads中提取\'X-Delete-At\'并与file.metadata中的相同字段比较,根据较新的值调用file#delete_at_update(),通知更新container的信息

     5.调用file#put()方法将metadata写入到.meta文件和data_file的扩展属性中

 

2.PUT

新建/更新一个object对象,流程如下

     1.从requesturl中提取需要新建/更新的object信息,检查request.heads中的必要K-V,检查mount情况,并根据url中的信息实例化一个DiskFile对象file

     2.通过file#mkstemp新建临时文件temp,按照request.heads中的\'content-length\'申请temp的空间,按照network_chunk_size接收来自network的chunk,并且检查上传文件的大小,并更新信息摘要etag(md5)如果大于等于bytes_per_sync会执行一次os.fdatasync并且调用drop_buffer_cache清空缓存

     3.如果接收到的文件大小和request.head中声明的一致,并且etag也与heads中的\'etag\'一致时,说明文件接收成功

     4.根据request.heads中的值新建/更新file.metadata,通过file#put方法写入磁盘(包括用temp文件改名.data文件和写入metadata)

     5.通过file#unlinkold删除较早版本object文件

     6.调用container_update通知container更新信息

 

3.GET

检索一个object对象,在response.heads中返回metadata,在response.body中返回objectdata,流程如下

     1.根据url中的信息新建DiskFile对象file,检查request.heads中的必要K-V,检查mount情况

     2.如果file#is_deleted或者file.metadata中\'X-Delete-At\'小于当前时间(表示已标记为准备删除)或者通过file#get_data_file_size查看文件是否异常,如果已经删除或存在异常,返回404HTTPNotFound

     3.检查request.heads里的\'If-match\'和\'If-none-match\',前者检查file.metadata中的\'ETag\'是否与其一致确定所检索的文件,后者确定如果没有匹配的是否返回file的etag信息

     4.确定了需要操作的file,利用file的iterator,将其绑定response的构造函数参数app_iter,并且将file.metadata写入response.heads中,并返回response

 

4.HEAD

检索返回一个object的metadata,同GET请求的处理方法几乎一致,唯一不同的是不在body中返回file

 

5.DELETE

删除一个object,流程如下

     1.检查url,mount,必要的request.heads,并根据request信息产生DiskFile实例file

检查文件是否已被删除(file#is_deleted),如果没有删除,则更新metadata,将其\'deleted\'字段设为True

     2.通知container-server更新object‘X-Delete-At’时间,新建temp_file,并调用file#put将metadata写入temp_file的xttar并更新meta_file,并将temp_file改名为<file.datadir>/<timestamp>.ts

     3.通过file#unlinkold删除较早版本object文件,并通知container-server文件删除

 

6.REPLICATE

通过调用obj.replicator#tpooled_get_hashes获取hash值来验证object是否发生变化,具体细节会在未来介绍replicator时介绍。

结束

至此,介绍了swift存储的基本过程和细节,没有涉及Ring,Replicator,Auditor,Updaters等组件,待进一步研究之后再做总结。

 


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
Swift小代码发布时间:2022-07-13
下一篇:
OC Swift中检查代码行数发布时间:2022-07-13
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap