绝对是深度好文!关于单元测试,这有一封信给开发同学

绝对是深度好文!关于单元测试,这有一封信给开发同学来这里找志同道合的小伙伴!


绝对是深度好文!关于单元测试,这有一封信给开发同学


正文
绝对是深度好文!关于单元测试,这有一封信给开发同学

这是写给开发同学系列文档中的一篇,主要讲单元测试。

写这个系列的原因是发现开发同学,尤其是偏业务的开发同学对于软件开发中的很多实践和理论理解的不够清楚。比如设计文档,代码评审,单元测试,集成测试和自动化测试,持续集成和持续发布这样一些耳熟能详的概念,说起来每个开发同学都听过,但很多人并没有深入考虑过为什么要引入这些实践,实践需要哪些手段,要达到什么目的,要坚持什么原则?所以这些实践落地的过程也是千差万别,效果往往也不甚理想。

通过这一系列文档,我会把我所了解的每个实践的来源、适用范围和价值用最简明的方式写出来,并结合具体的开发环境提供一些具体的操作手段,帮助同学们按照正确的路径快速了解和上手。

1

 理论基础

大数据就像青少年谈性,每个人都在说,但不知道谁做了。每个人认为另外人在做,所以每个人都声称自己在做。

这是大数据火的时候调侃大数据的段子,套在单元测试上也一样适用。说起来单元测试是开发应该了解的基础概念,然而实际并非如此。

我在若干团队(不限于淘宝)作过以下的调查,结果基本类似:

  • 有多少人写过单元测试? 60% ~ 80%,视团队而定

  • 每个单元测试里面都有至少一个assert语句,而非使用System.out.println? 20% ~ 30%

  • 单元测试的行覆盖率超过50%? < 10%

  • 每个单元测试平均每天会被执行10次以上? < 5%

  • 所有单元测试**一定不会因为断网失败**? < 1%

大多数开发都认为自己写了单元测试,但大家写的’单元测试’都不相同。实际上,能达到最后一点要求的单元测试才是有价值的单元测试,其它所谓的’单元测试’最多只能算是’测试代码’甚至’代码片段’。

‘单元测试’和’测试代码’的区别就像钻石和煤炭,虽然都由碳元素(程序代码)构成,但前者价值远远大于后者。随着时间的流逝,’钻石恒久远,一颗永流传’,而煤炭只会被烧掉,变成二氧化碳;同样,随着项目的不断演进,’单元测试’能一直存在并发挥价值,而’测试代码’只会被一行行注释掉,直到某个接手的同学把一个全被注释掉的测试文件从项目中删除。

要理解这一切发生的原因,首先我们需要了解软件开发中的几个定律:

错误率恒定定律

程序员的错误产出比是个常数

对某一个程序员来说,实现相同功能会犯的错误(BUG)是固定的,不受程序员自身意愿影响,不受绩效影响,也不受项目紧急程度影响。不考虑程序员水平的成长,错误产出比在很长一段时间(每个项目的间隔)内可以认为是个常数。

这个定律告诉我们,如果一个程序员感觉今天状态很好,比昨天多写了一倍代码并向QA声称bug数肯定比昨天还少,而QA测试的结果居然真的如此,那很大可能是QA测的不够仔细,而不是程序员的代码水平一夜之间突飞猛进。


这个定律还告诉我们,TL开会时要求程序员写代码尽量不要犯错没有任何意义,这种要求就像要求程序员明天长高5公分一样,不具有可操作性。

错误率恒定定律决定了错误数是一定的,但并不意味着这些错误产生的影响是一定的。恰恰相反,不同开发方式中,这些错误带来的影响差别非常巨大。菜鸟和老手完成相同功能发生的错误可能只差5倍,在具体项目实践中带来的影响却可能差了20倍、50倍甚至更多。原因就是以下的规模代价平方律:

规模代价平方定律


定位并修复一个BUG所需的代价正比于目标代码规模的平方

如果一个20行的函数刚写完时作者就能发现有BUG,找到并修复这个BUG可能只需要1分钟,并且不担心影响其它使用者。如果是在200行的一个类中,别人调用时发现有BUG,阅读代码并定位问题可能就需要一个小时,对这个问题的修复重新代码评审又要花一个小时。如果在系统和系统联调的时候才发现这个问题,前面扯皮就要半天,改完了重新回归又是半天。如果改这个BUG的人已经不是原作者的话,往往担心改了这个BUG又引入其它问题,于是修改方案就要拖一群人讨论半天,最终改完了要求QA作大范围的回归,结果还是还不放心。

规模代价平方律是很多软件工程实践的核心思想。根据平方律,为了减少错误修复的成本,要尽可能早的发现错误,在尽量小的范围内定位并修复错误。由于这是一个平方律而非线性率,所有这方面的努力都是非常划算的。比如以下实践很大程度上就是为了尽早发现错误,以后有机会我再逐一介绍这些实践。

  • 设计评审

  • 代码评审

  • 单元测试

  • 自动化测试

  • 结对编程

  • (Scrum)小迭代,迭代后期的成果演示

规模代价平方律对程序员的重要性可以和牛顿三定律在初等物理中的地位媲美 。遗憾的是很多程序员写了很多年都不知道这个定律律,往往低估了错误修复的时间。所以业界对程序员的自我评估有如下经典的吐槽。

当一个程序员宣称他已经完成了90%的工作时,他的意思是还需要相同的时间来完成剩下10%的工作。

2

单元测试的目的

错误率恒定律告诉我们错误是不可避免的,而规模代价平方律告诉我们要尽早发现错误。单元测试作为一个行之有效的工程实践,目的只有一个:

单元测试的目的是尽早在尽量小的范围内暴露错误

不同项目的单元测试方案各有不同,各种方案的选型往往也会有争议。这时候一定要记住单元测试的目的。凡是利于此目的,即使复杂一些的方案或有一定学习成本也可以采用,凡是不利于此目的的方案,即使看起来很美也没有采用的必要(本文最后有几个单测的误区具体说明这点)。

3

单测的要求

为了达到 尽早 和 尽量小的范围 以及 暴露错误,对单测有以下要求。 实践证明,只有满足这些要求的单测才能实现单测的目的。

单测要能报错

有些同学不喜欢用Assert,而喜欢在test case中写个System.out.println,人肉观察一下结果,确定结果是否正确。这种写法根本不是单测,原因是即使当时被测试代码是正确的,后续这些代码还有可能被修改,而一旦这些代码被改错了。println根本不会报错,测试正常通过只会带来虚假的自信心,这种所谓的’单测’连暴露错误的作用都起不到,根本就不应该存在。

单测要有强度

有些同学写的测试里面会有Assert,但用的很少,往往只是在最后用一个assertNotNull(result),这样的测试强度是不够的。举个例子,假设有以下的待测方法:

 public class User{

              public int id;

              public String name;

        }

        //待测试的方法,位于UserDAO中

        /**

        * 根据用户名模糊查找用户,双向模糊

        **/

        public List<User> findUserByFuzzyName(String fuzzyName){

              //实现

        }

以下的测试用例强度就太差了,这个用例虽然也用了Assert,但对测试的结果校验很弱,即没有校验结果中有多少User,也没有校验双向模糊逻辑是否正确实现了。实际上即使查询结果是空,返回的也是个empty list,测试用例还是不会报错。

 // 数据准备,假设测试库中已经有了 tom tommy jerry 三个用户

        @Test

        public void testFindUserByFuzzyName(){

            List<User> users= userDAO.findUserByFuzzyName(‘tom’);

            Assert.assertNotNull(users);

        }

单测要能反应函数的明确需求才算有强度。这样以后函数的实现一旦被改错了单测才能尽快报错,针对以上这个例子,单测至少要达到以下强度:

// 数据准备,假设测试库中已经有了 tom tommy jerry 三个用户

        @Test

        public void testFindUserByFuzzyName(){

            //左模糊

            List<User> users= userDAO.findUserByFuzzyName(‘tom’);

            Assert.assertEquals(2,users.size());

            Assert.assertEquals(‘tom’, users.get(0).name);

            Assert.assertEquals(‘tommy’, users.get(1).name);

            //右模糊

            List<User> users= userDAO.findUserByFuzzyName(‘y’);

            Assert.assertEquals(2,users.size());

            Assert.assertEquals(‘tommy’, users.get(0).name);

            Assert.assertEquals(‘jerry’, users.get(1).name);

        }

单测要有覆盖度


强度是指单元测试中对结果的验证要全面,覆盖度则是指测试用例本身的设计要覆盖被测试程序(SUT, Sysem Under Test)尽可能多的逻辑。只有覆盖度和强度都比较高才能较好的实现单测的目的。

按照测试理论,SUT的覆盖度分为方法覆盖度,行覆盖度,分支覆盖度和组合分支覆盖度几类。不同的系统对单测覆盖度的要求不尽相同,但这是有底线的。一般来说,程序配套的单测至少要达到>80%的方法覆盖以及>60%的行覆盖,才能起到’看门狗’的作用,也才是有维护价值的单测。

等价类划分可以帮助我们用更少的测试代码写出更高覆盖度的单测。单元测试是典型的白盒测试,等价类的划分以及单元测试的编写最好都由SUT的编写者自己去完成,这样整体效率最高。

单测粒度要小


和集成测试不同,单元测试的粒度一定要小,只有粒度小才能在出错时尽快定位到出错的地点。单测的粒度最大是类,一般是方法。**单测不负责检查跨类或者跨系统的交互逻辑** , 那都是集成测试的范围。

通俗的说,程序员写单测的目的是’擦好自己的屁股’,把自己的代码从实现中隔离出来,在集成测试前先保证自己的代码没有逻辑问题。至于集成测试乃至其它测试中暴露出来的接口理解不一致或者性能问题,那都不在单元测试的范围内。

单测要稳定


单元测试通常会被放到持续集成(CI)中,每次有代码check in时单元测试都会被执行。如果单测依赖有对外部环境(网络、服务、中间件)的依赖,任何一次网络抖动或者返回的变化都会造成单测失败进而造成持续集成的失败。这会造成整个持续集成有大量误报,进而导致持续集成机制的不可用。所以单测不能受到外界环境的影响

为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring或者guice这样的DI框架注入一个本地(内存)实现或者Mock实现。用这种方法保证在SUT出错时单测才会报错,持续集成才能更稳定,单测的失败也才更重要。

单测速度要快


作为’看门狗’,最好是在每次代码有修改时都运行单元测试,这样才能尽快的发现问题。这就要求单元测试的运行一定要快。一般要求单个测试的运行时间不超过3秒,而整个项目的单测时间控制在3分钟之内,这样才能在持续集成中尽快暴露问题。

单测不仅仅是给持续集成跑的,跑测试更多的是程序员本身,单测速度和程序员跑单测的意愿成反比,如果单测只要5秒,程序员会经常跑单测,去享受一下全绿灯的满足感,可如果单测要跑5分钟,能在提交前跑一下单测就不错了。

实际上,上一条要求将单测的外部依赖全部改成本地实现或者Mock,除了系统稳定性外,执行速度也是考量之一。改成本地实现或者Mock后,绝大多数单测运行的时间都非常快,基本上可以说是瞬间就能跑完。

4

单元测试的方案

明确了单测的目标之后,单测方案的选型也比较明确了。原则就是本地化,选型上也尽量以内存方案为主。

下面我们以Spring boot开发为例,给出一套解决方案,以下代码都是以Spring Annotation Configuration给出的,如果有必要也可以换成XML。

数据库测试


数据库测试多用在DAO中,DAO对数据库的操作依赖于mybatis的sql mapper 文件,这些sql mapper多是手工写的,在单测中验证所有sql mapper的正确性非常重要,在DAO层有足够的覆盖度和强度后,Service层的单测才能仅仅关注自身的业务逻辑。

为了验证sql mapper,我们需要一个能实际运行的数据库。为了提高速度和减少依赖,可以使用内存数据库。内存数据库和目标数据库(MySQL,TDDL)在具体函数上有微小差别,不过只要使用标准的SQL 92,两者都是兼容的。下面的方案中就使用H2 作为单测数据库。

数据库单测方案需要解决3个问题:

  • Schema的初始化和同步

  • 每个测试完成后的数据清除

  • 调试过程查看数据库内容

下面的方案中对这3个问题都给出了方法

  • 在pom中引入H2的依赖

<dependency>

    <groupId>com.h2database</groupId>

    <artifactId>h2</artifactId>

    <version>1.4.190</version>

    <scope>test</scope>

</dependency>

在测试资源目录 src/test/resource 下新建 your_module.sql,其中的内容是需要初始化的建表语句,这些建表语句可以从idb中导出。如果表结构发生了更改,需要人工重新导出。


create table something(

    id bigint unsigned  not null auto_increment comment ‘主键’,

    gmt_create datetime  not null comment ‘创建时间’,

    gmt_modified datetime  not null comment ‘修改时间’,

    primary key (id)

)  comment=’something’;


创建DAO接口,注意这里不用写实现。只要按照一定的规范,可以动态生成所有DAO的实现,自动映射到相应的sql mapper中:


package com.taobao.daren.service.qualification.server.domain.dao;

import com.taobao.daren.service.qualification.server.domain.entity.HSFDataSourceMeta;

/**

 * @author lotus.jzx

 */

public interface HSFDataSourceMetaDao {

    /**

     * 生成一个新的数据源元信息

     *

     * @param name    名称

     * @param group   HSF组别

     * @param version HSF版本

     *

     * @return ID

     */

    Long insertHSFDataSourceMeta(String name, String group, String version);

}


在程序的资源目录src/main/resources/sqlmapper中放置对应的sqlmapper文件,文件名最好和DAO对应,以便人工查找,比如这里对应的sqlmapper就叫HSFDataSourceMetaDao.xml。注意!为了实现自动映射, mapper的namespace一定要和DAO的类名相同。


<?xml version=’1.0′ encoding=’UTF-8′ ?>

<!DOCTYPE mapper PUBLIC ‘-//mybatis.org//DTD Mapper 3.0//EN’ ‘http://mybatis.org/dtd/mybatis-3-mapper.dtd’ >

<mapper namespace=’com.taobao.daren.service.qualification.server.domain.dao.HSFDataSourceMetaDao’>

<resultMap id=’HSFDataSourceMetaResultMap’

           type=’com.taobao.daren.service.qualification.server.domain.entity.HSFDataSourceMeta’>

    <id column=’id’ property=’id’/>

    <result column=’gmt_create’ property=’gmtCreate’/>

    <result column=’gmt_modified’ property=’gmtModified’/>

    <result column=’name’ property=’name’/>

    <result column=’is_active’ property=’active’/>

    <result column=’service_group’ property=’group’/>

    <result column=’service_version’ property=’version’/>

</resultMap>

<select id=’findHSFDataSourceMetaById’ parameterType=’Long’ resultMap=’HSFDataSourceMetaResultMap’>

    select

    id,

    name,

    gmt_create,

    gmt_modified,

    service_group,

    service_version,

    is_active

    from daren_qualification_hsf_datasource_meta

    where id=#{id}

</select>

</mapper>

在测试目录 src/test/java/com/taobao/…/your_module/config 下新建测试配置;

@Configuration

@ComponentScan({‘com.taobao.your_modle’})

public class BaseTestBeanConfig{

@Bean(name = QualificationBeanConfig.QUALIFICATION_DATASOURCE)

public DataSource dataSource() {

    EmbeddedDatabaseBuilder databaseBuilder = new EmbeddedDatabaseBuilder();

    EmbeddedDatabase db = databaseBuilder

            .setType(EmbeddedDatabaseType.H2)

                    //启动时初始化建表语句

            .addScript(‘classpath:schema/qualification.sql’)

            .build();

    return db;

}

 @Bean(name = ‘h2WebServer’, initMethod = ‘start’, destroyMethod = ‘stop’)

//启动一个H2的web server, 调试时可以通过localhost:8082访问到H2的内容

//JDBC URL: jdbc:h2:mem:testdb

//User Name: sa

//Password: 无

//注意如果使用断点,断点类型(Suspend Type)一定要设置成Thread而不能是All,否则web server无法正常访问!

public Server server() throws Exception {

    //在8082端口上启动一个web server

    Server server = Server.createWebServer(‘-web’, ‘-webAllowOthers’, ‘-webDaemon’, ‘-webPort’, ‘8082’);

    return server;

}

@Bean(name = QualificationBeanConfig.QUALIFICATION_SQL_SESSION_FACTORY_BEAN)

public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {

    final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();

    sessionFactory.setDataSource(dataSource);

    PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();

    //加载所有的sqlmapper文件

    Resource[] mapperLocations = resolver.getResources(‘classpath*:sqlmapper/**/*.xml’);

    sessionFactory.setMapperLocations(mapperLocations);

    return sessionFactory.getObject();

}

@Bean(name = QualificationBeanConfig.QUALIFICATION_MAPPER_SCANNER_CONFIGURER)

public MapperScannerConfigurer mapperScannerConfigurer() {

    //只需要写DAO接口,不用写实现类,运行时动态生成代理

    MapperScannerConfigurer configurer = new MapperScannerConfigurer();

    configurer.setBasePackage(‘com.taobao.daren.service.qualification.server.domain.dao’);

    configurer.setSqlSessionFactoryBeanName(QualificationBeanConfig.QUALIFICATION_SQL_SESSION_FACTORY_BEAN);

    return configurer;

}

}


写一个数据库单测的基类,后续的数据库单测继承这个基类即可;

@RunWith(SpringJUnit4ClassRunner.class)

@SpringApplicationConfiguration(BaseTestBeanConfig.class)

//每次跑完测试方法后清空数据库

@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)

public abstract class BaseTest {

    @Autowired

    protected DataSource dataSource;

    /**

     * Execute SQL directly, these SQL may be for test purpose only and should not be included in mybatis sqlMapper

     * configuration files.

     *

     * @param sql SQL statement to execute, variable binding is NOT supported for simplicity.

     */

    protected void execute(String sql) {

        try {

            dataSource.getConnection().createStatement().execute(sql);

        } catch (SQLException e) {

            throw new RuntimeException(e);

        }

    }

}

然后就可以写数据库单测了;

/**

* @author lotus.jzx

*/

public class QualificationDaoTest extends BaseTest {

@Autowired

private QualificationDao qualificationDao;

@Test

public void testFindQualificationItemMetaById() {

    String sql = ‘insert into daren_qualification(‘

            + ‘id’

            + ‘,gmt_create’

            + ‘,gmt_modified’

            + ‘,root_item_id’

            + ‘)’

            + ‘ values (‘

            + ‘1’

            + ‘,NOW()’

            + ‘,NOW()’

            + ‘,10’

            + ‘)’;

    execute(sql);

    Assert.assertNull(qualificationDao.findQualificationById(-1L));

    Qualification qualification = qualificationDao.findQualificationById(1L);

    Assert.assertEquals(1, qualification.getId().intValue());

    Assert.assertNotNull(qualification.getGmtCreate());

    Assert.assertNotNull(qualification.getGmtModified());

    Assert.assertEquals(10, qualification.getRootId().intValue());

    Assert.assertNull(qualification.getRoot());

}

}

到此为止搭建了一整套基于H2的数据库单测方案。这套方案基于内存,没有对外部的依赖,可以在单机上飞快的跑完 mvn clean test。

对于前文提到的三个问题,这个方案是这么解决的:

1、Schema的初始化和同步

通过离线的方式人工同步Schema至src/test/resources/sqlmapper目录。这步目前还不够自动化。如果idb能开放接口,这个步骤也可以通过脚本一键完成,当然目前还是比较人肉的。

2、每个测试完成后的数据清除

在BaseTest中@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)这行会自动完成数据清除,写test case时可以认为表都是空的。

而且我们采用的是内存表,非持久化,所以不存在持久化数据对后续单测的影响

3、调试过程查看数据库内容

在测试配置中有一个h2WebServer,在测试用例运行的过程,可以访问本地server,通过H2 web console用如下配置查看数据库中的数据。

JDBC URL: jdbc:h2:mem:testdb

User Name: sa

Password: 无

(ps:要注意的是,如果在测试用例中加了断点,一定要把断点类型从缺省的’All’改成为“Thread”,否则H2 web console会被阻塞。)

其它外部依赖


对于除数据库以外的依赖,包括Tair, Diamond,Nofity等中间件以及外部的HSF/HTTP服务,在单测中全部采用Mock进行解耦。具体的Mock框架采用 easymock,主要是考虑到它的语法相对自然,同时支持对class的mock。

是否使用mock的争议一直很大,反对者认为mock要额外写很多代码,同时mock通过并不能保证线上工作正常,而支持者认为mock速度快并且稳定,这就是最大的作用。而我们觉得在单元测试中mock外部依赖还是合理的。一方面单元测试的目的是’擦好自己的屁股’,对接口的理解错误应该在(自动化)集成测试而不是单元测试中去检测,另一方面mock的使用范围仅仅是边界上的外部依赖,其使用还是可控的。

下面以一个例子说明如何用easymock写单元测试

这是需要测试的代码

package com.jinlo.springbootdemo.demo;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;

/**

 * 根据黑名单和白名单确认用户是否能访问

 */

public class AccessControlService {

    private UICService UICService;

    private BlackListProvider blackListProvider;

    private WhiteListProvider whiteListProvider;

    @Autowired

    public void setUICService(UICService UICService) {

        this.UICService = UICService;

    }

    @Autowired

    public void setBlackListProvider(BlackListProvider blackListProvider) {

        this.blackListProvider = blackListProvider;

    }

    @Autowired

    public void setWhiteListProvider(WhiteListProvider whiteListProvider) {

        this.whiteListProvider = whiteListProvider;

    }

    /**

     * 返回指定用户是否能够访问

     * @param userId 用户ID

     *

     * @return

     */

    public boolean canAccess(Long userId) {

        List<Long> whiteListProviderList = whiteListProvider.provideUserIdWhiteList();

        if (whiteListProviderList.contains(userId)) {

            return true;

        } else {

            return !blackListProvider.provideUserNameBlackList().contains(UICService.findNameById(userId));

        }

    }

}

其中UICSerive定义如下,这是一个HSF的远程服务

public interface UICService {

    /**

     * 查找ID对应的名称

     * @param id

     * @return

     */

    String findNameById(Long id);

}

黑名单提供者有一个本地实现

/**

 * 黑名单提供者

 */

public interface BlackListProvider {

    /**

     * 提供黑名单

     * @return

     */

    List<String> provideUserNameBlackList();

}

/**

 * BlackListProvider的一个本地实现

 */

public class LocalBlackListProvider implements BlackListProvider {

    private List<String> blackList;

    public LocalBlackListProvider(List<String> blackList) {

        this.blackList = new ArrayList<>(blackList);

    }

    @Override

    public List<String> provideUserNameBlackList() {

        return Collections.unmodifiableList(blackList);

    }

}

白名单则需要通过HSF远程获取

/**

 * 白名单提供者

 */

public interface WhiteListProvider {

    /**

     * 提供白名单ID

     * @return

     */

    List<Long> provideUserIdWhiteList();

}

由于代码中有两个对外部服务的依赖,想在实际环境中去验证canAccess代价很大。一方面跨系统准备数据很麻烦;另一方面即使当时数据准备好了,过两天可能又没了。这个验证没法自动化就没法到持续集成,也就称不上单元测试。

通过使用Mock,可以让这两个外部依赖按照我们的想法返回数据,准备数据变得容易,单测的强度也就容易提升。

首先在pom中引入easymock

<dependency>

            <groupId>org.easymock</groupId>

            <artifactId>easymock</artifactId>

            <version>3.4</version>

            <scope>test</scope>

        </dependency>

(由于微信文章字数限制,更多精彩,请点击“阅读原文”!)

绝对是深度好文!关于单元测试,这有一封信给开发同学

本文为云栖社区文章,如需转载,请注明出处,并附上云栖社区微信公众号:yunqiinsight。

点击“阅读原文”可查看原文。


想和这群聪明人共事吗?加入阿里云云栖社区(全职/兼职):yqeditor@list.alibaba-inc.com


投稿或入驻云栖社区,请联系:yqeditor@list.alibaba-inc.com


2016,为了实现更多技术梦想,云栖社区与你携手并行。

绝对是深度好文!关于单元测试,这有一封信给开发同学

yunqiinsight

长按二维码,一网打尽所有深度技术文章

戳原文,更有料!

来源:软件工程之思,本文观点不代表自营销立场,网址:https://www.zyxiao.com/p/105350

发表评论

登录后才能评论
侵权联系
返回顶部