51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 2745|回复: 6
打印 上一主题 下一主题

[资料] 我对自动化测试的一些认识

[复制链接]
  • TA的每日心情
    擦汗
    前天 09:04
  • 签到天数: 1047 天

    连续签到: 5 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2017-9-15 14:02:26 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    前言
      从2017年初开始,到现在差不多半年多的时间,我这边投入了一部分精力用于项目的自动化测试建设工作。目前来看收益还是比较明显的,在这个过程中也加深了对自动化测试的理解,这边就总结下自己对自动化测试的认识。
      首先我想说下在开展自动化前后,我的工作状况的对比:
      去年2016年Q3、Q4,基本上天天处于996甚至997的状态。大部分时间都花在了功能测试保障、回归测试和上线验证。因为项目在线上有多达7,8个不同的集群,每一次版本上线为了保险起见,开发会一个集群一个集群小心翼翼地上线,所以基本上天天都处于上线的状态。每次上线,手工测试时间顺利的话在半小时左右,如果遇到问题跟开发联调定位,会达到数个小时。除了时间上开销很大之外,每次上线带来的精神上的压力其实更严重,非常害怕哪个用户半夜跳出来反馈说调度有Bug。
      在开展了自动化测试之后,现在我给“日常版本迭代测试”只预估了25%的工作量。任意集群上线,我只要负责点一下Jenkins的“开始构建”按钮,即可完成验收。如果有出现用例失败,会自动发送邮件告知开发。
      显然,目前的工作状态要好很多,是因为项目工作量减少了吗?显然不是。其实今年以来整个项目组在研发的投入要比去年更多,工作量只会比以前更重,还要兼顾多个私有化部署的客户的验收和日常测试保障工作,工作量肯定是只增不减的。之所以能有更多的时间空余出来做其它更多维度的事情,这一切都得益于“自动化测试”的帮助,它极大地解放了我的手工测试时间,同时更加提升了上线的信心。
    1. 需求和目标
      在我开展自动化测试之前,其实该项目以前的测试人员也已经写了很多的接口测试用例,但是大多数用例处于“半瘫痪”状态,在CI上无人维护(听说起初是有人维护的,但是后来用例多了,维护的人每次花很长时间去定位问题,结果却发现大部分的问题都是环境问题导致,花了半天时间定位却没什么收益,久而久之便不想去维护)。看起来,自动化似乎并没有什么收益,反而维护用例会造成额外的工作负担。
      我觉得,其实自动化测试跟其它任何一种测试类型(比如异常测试、稳定性测试、性能测试等)都是类似的,它也是一种测试类型而已。在开展测试之前,我们首先必须要明确自动化测试的需求是什么,要解决什么样的问题。
    1.1 让“自动化”代替“手动”
      在我看来,初期的自动化测试,我的目标很明确,我就是要让“自动化”代替“手动”,让自动化真正地跑起来,凡是“自动化”跑过的内容,我绝不再去手工重复执行一遍。这样至少我有一个很明确的收益:每完成一条自动化用例,我减少了一条手工用例的执行时间。
      必须要提醒的是,让“自动化”完全替代“手动”,其实对自动化用例的稳定性、容错都有一定的要求。你要花一定时间去思考用例执行过程中的异常场景,是否足以充分替代手工测试。因此,我在增加用例的时候都会非常谨慎,确保用例集是稳定100%通过的前提下才会增加新的用例。
      对于正常情况下(排除环境、开发代码的问题)有时100%通过,有时90%通过的自动化用例集,我觉得它的作用和参考价值为0。正常的用例集就应该是100%通过的。
      1.2 让“回归”自动化
      上节说了让“自动化”替代“手动”,每完成一条自动化用例都是有明显收益的。那如何让收益最大化呢,当然是让每次回归或上线验证“不得不”执行的用例优先自动化。如果完成了回归用例集的全部自动化,那我就可以用它来替代我的日常回归,和上线回归工作,极大地释放我的手工验证时间。
      这里必须要指出的是,我跟的项目其实是一个对系统稳定性的要求要高于新功能的引入的一个后台项目,所以它的核心功能是比较固定的,其实大多数后台项目也是类似的,核心功能聚合、对系统的稳定性要求高。这就需要保障系统的核心功能完善。所以我们可以先将“核心功能”的验证完成自动化。
      1.3 不要让环境成为瓶颈
      前面说了,旧的用例集在维护的过程中给测试人员增加了很多额外的负担,到最后发现很多都是环境的问题。当时的情形就是专门搭建了另一套测试环境专门用于自动化测试,而大数据的后台环境搭建和维护非常的复杂,如果同时维护多套环境,难免会在一些组件升级的过程中出现遗漏,导致环境不同步。因此,我们的自动化测试用例前期完全可以直接在功能测试环境执行,因为功能测试环境肯定是会一直随着版本的迭代向前不断更新的。
      2. 技术选型
      在明确了目标后,要开始技术选型。常见的自动化测试类型,包括
      ●接口自动化
      ●UI自动化
      ●基于shell交互命令执行的自动化
      此外,不属于测试范畴,但是也可以实现自动化、释放手工时间的还有
      ●数据准备自动化
      ●环境编译、部署、打包自动化
      ●稳定性测试/性能测试结果指标获取、校验自动化
      ●机器资源监控、报警自动化
      ●其它所有手工重复执行的操作
      在开始自动化之前,首先要分析项目的架构和状况。对于一个后端的服务,它如果是纯粹以接口的形式提供给其它组件去调用,那可以采取“接口自动化”;对于一个Web产品,如果前后端都在测试的保障范围,而且前端页面相对比较稳定,可以考虑采用“UI自动化”(此时接口自动化其实已经不足以保障产品的端到端功能);对于更后端的组件,如果想测试组件自身的基础核心功能,可以采用“基于shell交互命令执行的自动化”,通过自动化脚本的方式封装shell命令的调用。
      此外,有些人可能还会执着于编程语言的选择,是用Java还是Python还是Shell,或者其它语言等等。这个我觉得其实没有定论,可以根据自己对语言的偏好和熟练程度,但是必须要考虑团队成员的普遍技术栈,因为后期可能其他人来接手这个项目时需要代替你去维护测试工程。通常来说,测试框架的选择(不管是接口自动化、UI自动化)推荐使用Java的TestNG框架;对于简单的基于命令行执行的自动化脚本的编写推荐使用Shell(Shell非常地强大);对于稍复杂的一些自动化的脚本的编写,推荐使用Python,在Python中可以非常方便地封装Shell命令,同时Python区别于Shell的一个特性就是它支持面向对象的封装,可以将一些对象封装在特定的类中,增加程序的可读性和健壮性。
      这里再插一段题外话:有些人可能会疑惑,现在其实有很多接口测试平台,测试人员可以直接在平台上完成接口测试,在选型时怎么抉择?——这里我不评价哪种方式更好,只想说下自己的看法:我觉得两种其实各有各的好处:
      ●编写代码的方式:
      优点:提升自己的编码能力,问题定位能力,具备更高的灵活性和可操作性。 缺点:结果展示不直观,不易于协作。其他人维护代码困难,难以推动开发执行。
      ●接口平台的方式:
      优点:简便,上手容易,可以在项目组间很好的协作和维护,测试记录和结果一目了然。 缺点:离开了平台,可能又要回归手动。
      对于测试人员而言,如果有精力和时间的话,我建议是两种都要掌握,甚至是自己去开发接口测试平台的能力。
      3. 自动化实施过程
      目前我跟的项目里已经实现自动化的内容包括:基于接口的场景回归自动化测试、编译部署过程自动化、Jacoco覆盖率统计并接入CR平台(代码变更分析平台)的自动化、对外/上线打包发布的自动化、稳定性测试结果校验的自动化。
      下面着重介绍下项目的接口自动化框架的搭建和设计过程。
      3.1 准备工作
      老生常谈,开始自动化前,我仍然想再次强调一定要明确自己的需求是什么。在我的项目里,我的需求主要有以下几点:
      同一份代码可以在多个集群执行
      各个集群的测试数据相互独立,不会互相影响
      可以方便地与数据库进行交互
      当用例执行出错时,有详细的日志帮助定位
      较好的可维护性和集群扩展性。
      3.2 框架搭建
      3.2.1 环境搭建
      环境搭建时,主要用了以下工具:
      Git:管理代码工程
      TestNG:作为测试框架
      Maven:管理依赖包
      Log4j:管理日志
      Hibernate:实现数据库交互
      HttpClient:实现请求发送
      之所以没有用MyBatis,觉得相对来说,MyBatis是一个半ORM的框架,它需要自己额外维护一份sql映射文件,而Hibernate是全ORM的,可以省去这一步。关于它俩的比较,大家可以参考下知乎的一篇文章:MyBatis和Hibernate的对比。对于JDBC的方式,当然它也可以访问数据库,只不过相对来说,使用ORM框架可以更贴近面向对象的编程方式。
      3.2.2 不同集群配置管理
      在实现过程中,因为不同的集群会有不同的配置,比如webserver host、登陆后台webserver的用户名/密码、公共账号信息、数据库信息等等。为了让一份代码可以在不同集群去共用,就必须把这些配置信息从代码中剥离出来。可以用配置文件的形式来统一管理集群的配置信息,如图所示:

    每个文件代表一个集群的配置。在代码中可以通过java.util.Properties类读取配置文件的方式载入各项配置信息:
    1. /**
    2.    * 根据指定的配置文件名,初始化配置
    3.    * @param configFile
    4.    * @throws IOException
    5.    */
    6.   public PropertiesUtil(String configFile) throws IOException{
    7.       this.configFile =DEFAUL_CONFIG_FILE_DIRECTORY + configFile;
    8.       InputStream fis = new FileInputStream(this.configFile);
    9.       props = new Properties();
    10.       props.load(fis);
    11.       //关闭资源
    12.       fis.close();
    13.   }
    14.   /**
    15.    * 根据key值读取配置的值
    16.    * @param key key值
    17.    * [url=home.php?mod=space&uid=26358]@return[/url] key 键对应的值
    18.    * @throws IOException
    19.    */
    20.   public String readValue(String key){
    21.       return  props.getProperty(key);
    22.   }
    23.   /**
    24.    * 读取properties的全部信息
    25.    * @throws FileNotFoundException 配置文件没有找到
    26.    * @throws IOException 关闭资源文件,或者加载配置文件错误
    27.    *
    28.    */
    29.   public Map<String,String> readAllProperties(){
    30.       //保存所有的键值
    31.       Map<String,String> map=new HashMap<String,String>();
    32.       Enumeration<?> en = props.propertyNames();
    33.       while (en.hasMoreElements()) {
    34.           String key = (String) en.nextElement();
    35.           String property = props.getProperty(key);
    36.           map.put(key, property);
    37.       }
    38.       return map;
    39.   }
    复制代码



    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏2
    回复

    使用道具 举报

  • TA的每日心情
    擦汗
    前天 09:04
  • 签到天数: 1047 天

    连续签到: 5 天

    [LV.10]测试总司令

    2#
     楼主| 发表于 2017-9-15 14:04:56 | 只看该作者
    到这里,解决了配置读取的问题,还需要解决代码运行时如何让它自己去选择正确的集群配置文件的问题。我是将选择配置文件的逻辑全部封装到了一个工厂类BaseConfigFactory.java中,在实际测试使用时,我只需要通过工厂类的静态方法BaseConfigFactory.getInstance()去获取想要的配置信息,而不需要关心它到底是如何去选择正确的配置文件的。工厂类的实现可以参考:
    1. public class BaseConfigFactory {
    2.       private static final String testEnv= System.getenv("TEST_ENV") == null ? "null" : System.getenv("TEST_ENV");
    3.       private static Logger logger = Logger.getLogger(BaseConfigFactory.class);
    4.       private static BaseConfig baseConfig;
    5.       private static HashMap<String, String> clusterConfigMap;
    6.       public static synchronized BaseConfig getInstance(){
    7.           if (null == baseConfig){
    8.               PropertyConfigurator.configure("log4j.properties");
    9.               initMap();
    10.               setupConfig();
    11.           }
    12.           return baseConfig;
    13.       }
    14.      
    15.      
    16.       public static void initMap(){
    17.           clusterConfigMap = new HashMap<>();
    18.           clusterConfigMap.put("TEST-BJ", "test-bj.properties");
    19.           clusterConfigMap.put("ONLINE-BJ", "online-bj.properties");
    20.           clusterConfigMap.put("ONLINE-XS", "online-xs.properties");
    21.           clusterConfigMap.put("ONLINE-LT", "online-lt.properties");
    22.           clusterConfigMap.put("ONLINE-BEIJING", "online-beijing.properties");
    23.           clusterConfigMap.put("ONLINE-HD", "online-hd.properties");
    24.           clusterConfigMap.put("null", "test-local.properties");
    25.       }
    26.      
    27.       public static void setupConfig(){
    28.           logger.info("TEST ENV: " + testEnv);
    29.           String propertyFile = clusterConfigMap.get(testEnv);
    30.           logger.info("Using '" + propertyFile + "' as property file.");
    31.           baseConfig = new BaseConfig(propertyFile);      
    32.       }
    33.   }
    复制代码
    即,将所有的集群的配置放入到一个Map中,然后通过读取环境变量TEST_ENV的值来选取具体的集群配置文件clusterConfigMap.get(testEnv)。
      3.2.3 log4j日志管理
      良好的日志输出是帮助定位问题的关键环节,尤其是定位服务器上执行时出现的问题。这边贴一个log4j的配置:

    1.  ### set log levels ###
    2.   log4j.rootLogger = debug, stdout, D, E
    3.   ### 输出到控制台 ###
    4.   log4j.appender.stdout = org.apache.log4j.ConsoleAppender
    5.   log4j.appender.stdout.Target = System.out
    6.   log4j.appender.stdout.layout = org.apache.log4j.PatternLayout
    7.   log4j.appender.stdout.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss.SSS Z} %p [%c{1}] [Thread-%t] %m%n
    8.   ### 输出到日志文件 ###
    9.   log4j.appender.D = org.apache.log4j.DailyRollingFileAppender
    10.   log4j.appender.D.File = logs/console.log
    11.   log4j.appender.D.Append = true
    12.   ##输出Debug级别以上的日志##
    13.   log4j.appender.D.Threshold = INFO  
    14.   log4j.appender.D.layout = org.apache.log4j.PatternLayout
    15.   log4j.appender.D.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss.SSS Z} %p [%c{1}] [Thread-%t] %m%n
    16.   ### 保存异常信息到单独文件 ###
    17.   log4j.appender.E = org.apache.log4j.DailyRollingFileAppender
    18.   ##异常日志文件名##
    19.   log4j.appender.E.File = logs/error.log
    20.   log4j.appender.E.Append = true
    21.   ##只输出ERROR级别以上的日志##
    22.   log4j.appender.E.Threshold = ERROR
    23.   log4j.appender.E.layout = org.apache.log4j.PatternLayout
    24.   log4j.appender.E.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss.SSS Z} %p [%c{1}] [Thread-%t] %m%n
    25.   ##Hibernate日志级别设置
    26.   log4j.logger.org.hibernate.ps.PreparedStatementCache=WARN
    27.   log4j.logger.org.hibernate=ERROR
    28.   # Changing the log level to DEBUG will result in Hibernate generated
    29.   # SQL to be logged.
    30.   log4j.logger.org.hibernate.SQL=ERROR
    31.   # Changing the log level to DEBUG will result in the PreparedStatement
    32.   # bound variable values to be logged.
    33.   log4j.logger.org.hibernate.type=ERROR
    复制代码
    该配置将INFO级别和ERROR级别的日志分别定位输出到不同的文件,且日志文件会按照日期进行自动归档,输出的格式包含了日志的日期、级别、类信息、线程信息、日志内容等。
      一般情况下,对于接口测试,当接口测试用例失败时,我们要打印的日志包括:请求的url、参数、方法、实际响应、期望响应等等。
      3.3 分层设计、解耦

    首先看一下项目的工程目录:


     可以看到,项目中包含了多个package,各个package的作用已经在图片中标示了。以前好多测试人员的习惯是将api代码的调用、测试方法的编写、data Provider的编写、测试数据的构造全部写在一个类文件中,这样做其实会有几个问题:
      ●可读性差
      ●代码复用性低
      ●维护性差
      ●难以调试
      ●耦合带来的其它各类问题
      此外,如果不同集群的测试数据不同,会有大量的if判断,结果是灾难性的。
      下面以一个用例为例,展示代码的结构:
    1. 测试api:
    2.   public class ScheduleApi extends BaseAzkabanApi{
    3.       ...
    4.       ...
    5.       /**
    6.        * 使用默认公共账号、email、失败策略、sla报警邮箱新增正常调度。
    7.        * @param projectName
    8.        * @param flow
    9.        * @param projectId
    10.        * @param scheduleTime
    11.        * @param scheduleDate
    12.        * @param period
    13.        * @return
    14.        */
    15.       public ResponseCode addNormSched(String projectName, String flow, String projectId, String scheduleTime, String scheduleDate,String period){
    16.           return scheduleFlow(projectName, flow, projectId, scheduleTime, scheduleDate, defaultProxyUser, defaultProxyEmail, period,  defaultSlaEmail);
    17.       }
    18.       ...
    19.       ...
    20.   }
    复制代码
     测试代码test:
    1. @Test(singleThreaded=true)
    2.   public class ScheduleTest{
    3.       ...
    4.       ...
    5.       /**
    6.        * 新增正常调度
    7.        * @param projectName
    8.        * @param flow
    9.        */
    10.       @Test(priority=1, dataProvider="addNormSched", dataProviderClass=ScheduleDataProvider.class, testName="1410356")
    11.       public void addNormSched(String projectName, String flow, String expectedStatus, String hasScheduleId, String message){
    12.           ResponseCode rc= scheduleApi.addNormSched(projectName, flow);
    13.           Assert.assertEquals(rc.getStatus(), expectedStatus, message+rc.getDebugInfo("返回结果中的状态status对应值"));
    14.           Assert.assertEquals(rc.hasProperty("scheduleId"), Boolean.parseBoolean(hasScheduleId), message+rc.getDebugInfo("返回结果中是否包含scheduleId"));
    15.       }
    16.       ...
    17.       ...
    18.   }
    复制代码
    测试用例dataProvider:
    1. public class ScheduleDataProvider {
    2.       @DataProvider(name = "addNormSched", parallel=true)
    3.       public static Object [][] addNormSched(){
    4.           return new Object[][]{
    5.               ScheduleTestData.validNormSchedule,
    6.               ScheduleTestData.notExistedProject,
    7.               ScheduleTestData.notExistedFlow
    8.           };
    9.       }
    10.       ...
    11.       ...
    12.   }
    复制代码
    测试数据testdata:
    1. public class ScheduleTestData extends BaseTestData{
    2.       ...
    3.       ...
    4.       //Testdata for addNormSched
    5.       public static Object[] validNormSchedule={VALID_PROJECT_NAME, VALID_NORMAL_SCHEDULE_FLOW, "success", "true", "设置有效的正常调度"};
    6.       public static Object[] notExistedProject={NOT_EXIST_PROJECT_NAME, VALID_NORMAL_SCHEDULE_FLOW, "error", "false", "不存在的project"};
    7.       public static Object[] notExistedFlow={VALID_PROJECT_NAME, NOT_EXIST_FLOW_NAME, "error", "fasle", "不存在的flow"};
    8.       ...
    9.       ...
    10.   }
    复制代码




    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    擦汗
    前天 09:04
  • 签到天数: 1047 天

    连续签到: 5 天

    [LV.10]测试总司令

    3#
     楼主| 发表于 2017-9-15 14:05:54 | 只看该作者
    可以看到,用例的测试代码test类是非常简洁的,只要调用api类封装的接口,然后进行assert判断即可。
      关于测试数据,将dataprovider与testdata进行分离,也是为了后续可能会灵活地调整下架用例,只需要去除dataprovider类中的用例行即可,而testdata中的数据仍然可以留着复用。
      另外,前面提到了不同集群测试数据的管理。再介绍下我这边的实现方式:
      不同测试类使用的公共数据,存放于BaseTestData基类中,让其它testdata类继承于基类
      不同集群可以共用的数据,尽量共用,以常量的方式存储于testdata类中
      不同集群无法共用的数据,统一存放于特定的json文件管理
      关于json文件管理数据,其实跟配置文件的管理类似,如下图所示:

    History.json:

    1. {           
    2.       "validTotalFetch":{
    3.           "key":"",
    4.           "beginTime":"2017-06-30%2015:30",
    5.           "endTime":"2017-06-30%2015:50",
    6.           "expectedTotal":"7"
    7.       },
    8.      
    9.       "validImmediatelyFetch":{
    10.           "key":"instant_execute_job",
    11.           "beginTime":"2017-06-30%2013:30",
    12.           "endTime":"2017-06-30%2013:40",
    13.           "expectedTotal":"1"
    14.       },
    15.      
    16.       "validScheduledFetch":{
    17.           "key":"online_schedule_job",
    18.           "beginTime":"2017-06-30%2014:30",
    19.           "endTime":"2017-06-30%2014:40",
    20.           "expectedTotal":"2"
    21.       }
    22.   }
    复制代码
    3.4 改进与提升
      在自动化的实施过程中,还遇到了一些问题可能对其它项目也会有一定的借鉴意义。这边罗列下几个我觉得比较有意思的问题。
      3.4.1 webserver高可用的支持
      我们的后台webserver是支持高可用的,所以每次运维上线后webserver的host可能会发生变化,以及在服务运行过程中也可能会发生webserver切换。如果每次去手动调整自动化用例的配置信息,是一件非常麻烦的事情。
      解决的方式就是在配置文件中,将主从webserver的host都填写进去,在测试过程中,如果发生请求失败,则允许切换一次host。
      3.4.2 用例并发执行
      由于我们的一部分用例是异步的场景用例,需要执行一个数据开发的任务,然后等待其执行完成。这些用例的执行比较费时,如果顺序执行的话会消耗非常多的时间。因此可以通过并发执行测试的方式,解决用例耗时的问题。
      3.4.3 单例模式解决session问题和host重复切换问题
      问题1: Azkaban的每个接口,都需要一个必传参数seesion。这个session可以通过/login接口获取。如果每个接口在执行的时候都去调用一次/login接口重新获取session,就会显得很冗余,也可能导致旧的session失效。
      问题2: 上述提到的对webserver高可用的支持,当多条用例并行执行时如果同时去切换host,可能会造成host切换回原来的不可用host。
      对于问题1,可以将session作为单例的方式进行存储。
      对于问题2,可以借鉴单例模式的“双重检查”思想,对切换host的代码进行部分同步,在防止host重复切换的同时,不会降低httpclient请求的并发性。
      3.4.4 “变”与“不变”
      其实这也是所有设计模式的基本思想,即区分自动化测试中的“可变因素”和“不变因素”。我觉得ycwdaaaa大神(飞哥)有两句话是非常棒的:
      封装"一切"可能的可控的变化因素
      为了稳定使尽"一切"手段


    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    慵懒
    2017-6-21 11:02
  • 签到天数: 2 天

    连续签到: 1 天

    [LV.1]测试小兵

    4#
    发表于 2017-9-15 17:11:29 | 只看该作者
    感谢楼主对自动化认识的分享
    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    郁闷
    2018-1-31 11:57
  • 签到天数: 38 天

    连续签到: 1 天

    [LV.5]测试团长

    5#
    发表于 2017-9-18 18:27:08 | 只看该作者
    感谢楼主详细的分享
    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    郁闷
    2018-1-31 11:57
  • 签到天数: 38 天

    连续签到: 1 天

    [LV.5]测试团长

    6#
    发表于 2017-9-18 18:27:39 | 只看该作者
    感谢楼主详细的分析
    回复 支持 反对

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-11-17 13:37 , Processed in 0.069996 second(s), 22 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表