JavaWeb日常开发必知

现在的java框架用起来很方便,但是很多人忽略了基础的东西,在基础薄弱的情况下,去使用框架会踩坑,包括编码中不注意,会留下隐患,所以个人总结整理了一下日常开发过程中必须知道的一些知识点,给大家分享下,如有不妥地方,欢迎指出,共同学习。

JavaWeb三层架构

什么是三层架构?

三层架构(3-tier architecture) 通常意义上的三层架构就是将整个业务应用划分为:界面层(User Interface layer)、业务逻辑层(Business Logic Layer)、数据访问层(Data access layer)。区分层次的目的即为了“高内聚低耦合”的思想。在软件体系架构设计中,分层式结构是最常见,也是最重要的一种结构。微软推荐的分层式结构一般分为三层,从下至上分别为:数据访问层(又称为持久层)、业务逻辑层(又或称为领域层)、表示层。

为什么要用三层架构呢?

MVC要实现的目标是将软件用户界面和业务逻辑分离以使代码可扩展性、可复用性、可维护性、灵活性加强。

1.团队开发,便于管理

三层架构使得合作开发成为可能,由于各层相互独立,一个小组只需负责一小块就可以。结构化的编程方法面对大型的项目会感到力不从心,因为结构化设计必定会使程序变的错综复杂。逻辑主要在BLL层,就使得UI层也就是客户端不承担太多的职责,即使更新业务逻辑,也无需修改客户端,不用重新部署。

2.解耦

上一层依赖于下一层,如果测试下一层没有问题,那么问题就只有可能发现在本层了,便于发现和改正BUG。体现了“高内聚,低耦合”的思想。比如楼房是分层的,我们要到哪一层楼非常方便,只需在电梯里按下那个楼层的层号即可。而三层架构就好比开发的软件“楼”,哪层出现Bug,哪层有问题,我们作为开发人员能够随时找到,并修正。 各个层次分工明确,将一个复杂问题简单拆分了。

3.代码的复用和劳动成本的减少
分层的根本在于代码的复用和劳动成本的减少。分层的最理想化的结果是实现层与层之间的互不依赖的内部实现,所谓的即插即用!

特点:上层依赖下层,依赖关系不跨层。 上层调用下层得到结果,取决于下层的实现;不能直接访问数据层。问题排查时,从上而下。

项目目录如下:
在这里插入图片描述
一个简单的模块相互调用关系图(此处省去controller层)
在这里插入图片描述
现在说一说在三层架构开发中重要的知识点:

1.事务

特性
事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。
原子性(atomicity):一个事务是一个不可分割的工作单位,事务中包括的操作要么都做,要么都不做。

一致性(consistency):事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。

隔离性(isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行 的各个事务之间不能互相干扰。

持久性(durability):持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来 的其他操作或故障不应该对其有任何影响。

目前所用spring框架中支持事务,编程式事务以及声明式事务两种方式。声明式事务用注解@Transactional表示。推荐使用声明式事
务。

1.1回滚规则
spring事务管理器回滚一个事务的推荐方法是在当前事务的上下文内抛出异常。
spring事务管理器会捕捉任何未处理的异常,然后依据规则决定是否回滚抛出异常的事务。
默认配置下,spring只有在抛出的异常为运行时unchecked异常时才回滚该事务,也就是抛出的异常为RuntimeException的子类
(Errors也会导致事务回滚),而抛出checked异常则不会导致事务回滚。可以明确的配置在抛出那些异常时回滚事务,包括checked异常。
也可以明确定义那些异常抛出时不回滚事务。还可以编程性的通过setRollbackOnly()方法来指示一个事务必须回滚,
在调用完setRollbackOnly()后你所能执行的唯一操作就是回滚。

下面说说我经常见到的4种事务不回滚的产生原因:
(1)@Transactional所注解的方法是否为public,因为只有@Transaction注解只有被其他方法调用才生效的,能被其他方法调用的方法,只能是public
(2)Service方法中,把异常给try catch了,但catch里面只是打印了异常信息,没有手动抛出RuntimeException异常
(3)Service方法中,抛出的异常不属于运行时异常(如IO异常),因为Spring默认情况下是捕获到运行时异常就回滚
(4)在同一个类中,一个方法调用另外一个有注解(比如@Async,@Transational)的方法。比如:
当从类外调用方法a()时,从spring容器获取到的TestServiceImpl对象实际是包装好的proxy对象,因此调用a()方法的对象是动态代理对象。而在类内部a()调用b()的过程中,实质执行的代码是this.b(),此处this对象是实际的serviceImpl对象而不是本该生成的代理对象,因此直接调用了b()方法。伪代码:

		/** 事务不会生效**/
       @Transactional(rollbackFor = Exception.class)  
       public boolean a() {      
           b();
           return true;
      }
       @Transactional(rollbackFor = Exception.class)  
       public boolean b() {      
           doDbSomeThing();
           return true;
      }

正常情况下,按照正确的编码是不会出现事务回滚失败的。下面说几点保证事务能回滚的方法:
(1)保证修饰的方法是为public
(2)如果Service层会抛出不属于运行时异常也要能回滚,那么可以将Spring默认的回滚时的异常修改为Exception,这样就可以保证碰到什 么异常都可以回滚。具体的设置方式也说下:
注解事务,直接在注解上面指定,代码如下

@Transactional(rollbackFor = Exception.class)

(3)只有非只读事务才能回滚的,只读事务是不会回滚的,注解默认是非只读。
(4)如果在Service层用了try catch,在catch里面再抛出一个 RuntimeException类型异常,这样出了异常才会回滚
(5)如果你不喜欢(4)的方式,你还可以直接在catch后面写一句回滚代码:

TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

来实现回滚,这样的话,就可以在抛异常后也能return 返回值;比较适合需要拿到Service层的返回值的场景。具体的用法可以参见考下面的伪代码:

/** TransactionAspectSupport手动回滚事务:*/
       @Transactional(rollbackFor = Exception.class)  
       public boolean test() {  
            try {  
               doDbSomeThing();    
            } catch (Exception e) {  
                 e.printStackTrace();     
                 //就是这一句了, 加上之后抛了异常就能回滚(有这句代码就不需要再手动抛出运行时异常了)
                 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();  
                 return false;
            }  
           return true;
      }

(6)在同类中调用带有@Async,@Transactional导致事务不生效解决方案:
1.放到不同的类中进行调用
2.将之前使用普通调用的方法,换成使用代理调用
((TestService)AopContext.currentProxy()).b();获取到TestService的代理类,再调用事务方法,强行经过代理类,激活事务切面。
使用mybatisPlus中ServiceImpl自带增删改方法,标记出了 没带事务 的方法,使用时候注意:

public class ServiceImpl<M extends BaseMapper<T>, T> implements IService<T> {
    protected Log log = LogFactory.getLog(this.getClass());
    @Autowired
    protected M baseMapper;

    public ServiceImpl() {
    }
    
 	// 此处略去其他不是增删改的方法。。。
 	
	/** 没带事务*/
    public boolean save(T entity) {
        return this.retBool(this.baseMapper.insert(entity));
    }

    @Transactional(
        rollbackFor = {Exception.class}
    )
    public boolean saveBatch(Collection<T> entityList, int batchSize) {
        String sqlStatement = this.sqlStatement(SqlMethod.INSERT_ONE);
        int size = entityList.size();
        this.executeBatch((sqlSession) -> {
            int i = 1;

            for(Iterator var6 = entityList.iterator(); var6.hasNext(); ++i) {
                T entity = var6.next();
                sqlSession.insert(sqlStatement, entity);
                if (i % batchSize == 0 || i == size) {
                    sqlSession.flushStatements();
                }
            }

        });
        return true;
    }

    @Transactional(
        rollbackFor = {Exception.class}
    )
    public boolean saveOrUpdate(T entity) {
        if (null == entity) {
            return false;
        } else {
            Class<?> cls = entity.getClass();
            TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
            Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!", new Object[0]);
            String keyProperty = tableInfo.getKeyProperty();
            Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!", new Object[0]);
            Object idVal = ReflectionKit.getMethodValue(cls, entity, tableInfo.getKeyProperty());
            return !StringUtils.checkValNull(idVal) && !Objects.isNull(this.getById((Serializable)idVal)) ? this.updateById(entity) : this.save(entity);
        }
    }

    @Transactional(
        rollbackFor = {Exception.class}
    )
    public boolean saveOrUpdateBatch(Collection<T> entityList, int batchSize) {
        Assert.notEmpty(entityList, "error: entityList must not be empty", new Object[0]);
        Class<?> cls = this.currentModelClass();
        TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
        Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!", new Object[0]);
        String keyProperty = tableInfo.getKeyProperty();
        Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!", new Object[0]);
        int size = entityList.size();
        this.executeBatch((sqlSession) -> {
            int i = 1;

            for(Iterator var8 = entityList.iterator(); var8.hasNext(); ++i) {
                T entity = var8.next();
                Object idVal = ReflectionKit.getMethodValue(cls, entity, keyProperty);
                if (!StringUtils.checkValNull(idVal) && !Objects.isNull(this.getById((Serializable)idVal))) {
                    ParamMap<T> param = new ParamMap();
                    param.put("et", entity);
                    sqlSession.update(this.sqlStatement(SqlMethod.UPDATE_BY_ID), param);
                } else {
                    sqlSession.insert(this.sqlStatement(SqlMethod.INSERT_ONE), entity);
                }

                if (i % batchSize == 0 || i == size) {
                    sqlSession.flushStatements();
                }
            }

        });
        return true;
    }
	/** 没带事务*/
    public boolean removeById(Serializable id) {
        return SqlHelper.retBool(this.baseMapper.deleteById(id));
    }
	/** 没带事务*/
    public boolean removeByMap(Map<String, Object> columnMap) {
        Assert.notEmpty(columnMap, "error: columnMap must not be empty", new Object[0]);
        return SqlHelper.retBool(this.baseMapper.deleteByMap(columnMap));
    }
	/** 没带事务*/
    public boolean remove(Wrapper<T> wrapper) {
        return SqlHelper.retBool(this.baseMapper.delete(wrapper));
    }
	/** 没带事务*/
    public boolean removeByIds(Collection<? extends Serializable> idList) {
        return CollectionUtils.isEmpty(idList) ? false : SqlHelper.retBool(this.baseMapper.deleteBatchIds(idList));
    }
	/** 没带事务*/
    public boolean updateById(T entity) {
        return this.retBool(this.baseMapper.updateById(entity));
    }
	/** 没带事务*/
    public boolean update(T entity, Wrapper<T> updateWrapper) {
        return this.retBool(this.baseMapper.update(entity, updateWrapper));
    }

    @Transactional(
        rollbackFor = {Exception.class}
    )
    public boolean updateBatchById(Collection<T> entityList, int batchSize) {
        Assert.notEmpty(entityList, "error: entityList must not be empty", new Object[0]);
        String sqlStatement = this.sqlStatement(SqlMethod.UPDATE_BY_ID);
        int size = entityList.size();
        this.executeBatch((sqlSession) -> {
            int i = 1;

            for(Iterator var6 = entityList.iterator(); var6.hasNext(); ++i) {
                T anEntityList = var6.next();
                ParamMap<T> param = new ParamMap();
                param.put("et", anEntityList);
                sqlSession.update(sqlStatement, param);
                if (i % batchSize == 0 || i == size) {
                    sqlSession.flushStatements();
                }
            }

        });
        return true;
    }

   // 下面略去各种查询方法...

    }
1.2传播机制

为什么mybatisPlus框架中有些方法带事务,有些不带呢,这就是因为spring有事务传播机制。

什么是事务传播:
事务传播行为用来描述由某一个事务传播行为修饰的方法被嵌套进另一个方法的时事务如何传播。
用伪代码说明:

 public void methodA(){
    methodB();
    //doSomething
 }
 
 @Transaction(Propagation=XXX,rollbackFor = {Exception.class})
 public void methodB(){
    //doSomething
 }

代码中methodA()方法嵌套调用了methodB()方法,methodB()的事务传播行为由@Transaction(Propagation=XXX)设置决定。这里需要注意的是methodA()并没有开启事务,某一个事务传播行为修饰的方法并不是必须要在开启事务的外围方法中调用。

在日常开发中,一个业务流程下来可能需要用到多个service的增删改方法,此时这种场景就属于一个事务,应当写入一个用来聚合的service中,去调用其他的service的增删改方法,在聚合的service上添加事务,该事务就会传播到被调用的service中!

spring支持七种事务传播行为

事务传播行为类型说明
PROPAGATION_REQUIRED如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择。也是Spring的默认设置
PROPAGATION_SUPPORTS支持当前事务,如果当前没有事务,就以非事务方式执行。
PROPAGATION_MANDATORY使用当前的事务,如果当前没有事务,就抛出异常。
PROPAGATION_REQUIRES_NEW新建事务,如果当前存在事务,把当前事务挂起。
PROPAGATION_NOT_SUPPORTED以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
PROPAGATION_NEVER以非事务方式执行,如果当前存在事务,则抛出异常。
PROPAGATION_NESTED如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。

默认的传播从源码中可以看出:
在这里插入图片描述
日常开发中,根据业务需要,选择对应的传播行为。

2.锁

3.多线程编程在javaweb项目中的实际应用

一个javaweb项目无论用什么框架。最根本还是由servlet + jdbc + tomcat(或其他web容器)完成,了解多线程编程之前我们先看看
3.1 什么是Servlet?

  • 是sun推出的用于在服务器端处理HTTP协议的组件。
  • 为java程序提供一个统一的web应用的规范。
  • 其实本质很简单,就是一个Java接口interface而已,通常来说,Servlet 是指所有实现了 Servlet 接口的类。
  • 处理请求和发送响应的过程是由一种叫做Servlet的程序来完成的,并且Servlet是为了解决实现动态页面而衍生的东西。

3.2 Servlet的特点

  • Servlet 主要用于处理客户端传来的 HTTP 请求,并返回一个响应,它能够处理的请求有 doGet() 和 doPost() 等。
  • Servlet 由 Servlet 容器提供,Servlet 容器是指提供了 Servlet 功能的服务器(如 Tomcat)。
  • Servlet 容器会将 Servlet 动态加载到服务器上,然后通过 HTTP 请求和 HTTP 应与客户端进行交互。

3.3 tomcat和servlet的关系
  Tomcat 是Web应用服务器,是一个Servlet/JSP容器. Tomcat 作为Servlet容器,负责处理客户请求,把请求传送给Servlet,并将Servlet的响应传送回给客户.而Servlet是一种运行在支持Java语言的服务器上的组件. Servlet最常见的用途是扩展Java Web服务器功能,提供非常安全的,可移植的,易于使用的CGI替代品.
  从http协议中的请求和响应可以得知,浏览器发出的请求是一个请求文本,而浏览器接收到的也应该是一个响应文本。我们只知道浏览器发送过来的请求也就是request,我们响应回去的就用response。忽略了其中的细节,现在就来探究一下。           
  ①:Tomcat将http请求文本接收并解析,然后封装成HttpServletRequest类型的request对象,所有的HTTP头数据读可以通过request对象调用对应的方法查询到。
  ②:Tomcat同时会要响应的信息封装为HttpServletResponse类型的response对象,通过设置response属性就可以控制要输出到浏览器的内容,然后将response交给tomcat,tomcat就会将其变成响应文本的格式发送给浏览器
  Java Servlet API 是Servlet容器(tomcat)和servlet之间的接口,它定义了serlvet的各种方法,还定义了Servlet容器传送给Servlet的对象类,其中最重要的就是ServletRequest和ServletResponse。所以说我们在编写servlet时,需要实现Servlet接口,按照其规范进行操作。

说到这里,我们再说说Java对象的普通方法的调用过程

程序运行开始,类中的方法本体等类信息、静态和常量存储于方法区。
当类被创建出实例对象后,方法的标记、成员变量随着实例对象存储于堆内存。
当对象调用该方法时,该方法从栈内存中通过堆内存,最终指向方法区本体,然后在栈内存中快速创建, 方法以栈帧的形式进栈,内部的局部变量开始被加载。
线程栈:
线程栈存储的信息是指某时刻线程中方法调度的信息,当前调用的方法总是位于栈顶。
当某个方法被调用时,此方法的相关信息压入栈顶。
由于栈内存不被线程共享。栈之间不可见,所以,我们才不必担心方法被多人请求调用时出现线程安全问题
Tomcat会维护一个线程池,每次http请求,会从线程池中取出一个空闲线程,

总结
因为一次请求是一个线程,我们可以理解此线程为主线程,会一步一步的调用我们的业务方法逻辑,而在一些场景中,我们需要快速高效的执行完一次请求流程,就可以在此主线程中另外开启一个或多个线程,我们称为子线程,也就是我们经常说的 异步操作,所以多线程编程我们可以理解为: 一个主线程加上该主线程的多个子线程共同完成一次业务逻辑处理。而高并发时,是指Tomcat里为http请求维护的线程池瞬间被耗光,所以当tomcat的线程池不足以支撑大量并发时,就应当考虑服务器集群。

3.HTTP调用超时、重试、并发

与执行本地方法不同,进行 HTTP 调用本质上是通过 HTTP 协议进行一次网络请求。网络请求必然有超时的可能性,因此我们必须考虑到这三点:

  • 首先,框架设置的默认超时是否合理;
  • 其次,考虑到网络的不稳定,超时后的请求重试是一个不错的选择,但需要考虑服务端接口的幂等性设计是否允许我们重试;
  • 最后,需要考虑框架是否会像浏览器那样限制并发连接数,以免在服务并发很大的情况下,HTTP 调用的并发数限制成为瓶颈。

Spring Cloud 是 Java 微服务架构的代表性框架。如果使用 Spring Cloud 进行微服务开发,就会使用 Feign 进行声明式的服务调用。如果不使用 Spring Cloud,而直接使用 Spring Boot 进行微服务开发的话,可能会直接使用 Java 中最常用的 HTTP 客户端 Apache HttpClient 进行服务调用。

接下来,我们就看看使用 Feign 和 Apache HttpClient 进行 HTTP 接口调用时,可能会遇到的超时、重试和并发方面的坑。

3.1配置连接超时和读取超时参数的学问配置连接超时和读取超时参数的学问

对于 HTTP 调用,虽然应用层走的是 HTTP 协议,但网络层面始终是 TCP/IP 协议。TCP/IP 是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数:连接超时参数 ConnectTimeout,让用户配置建连阶段的最长等待时间;读取超时参数 ReadTimeout,用来控制从 Socket 上读取数据的最长等待时间。这两个参数看似是网络层偏底层的配置参数,不足以引起开发同学的重视。但,正确理解和配置这两个参数,对业务应用特别重要,毕竟超时不是单方面的事情,需要客户端和服务端对超时有一致的估计,协同配合方能平衡吞吐量和错误率。

连接超时参数和连接超时的误区有这么两个:

  • 连接超时配置得特别长,比如 60 秒。一般来说,TCP 三次握手建立连接需要的时间非常短,通常在毫秒级最多到秒级,不可能需要十几秒甚至几十秒。如果很久都无法建连,很可能是网络或防火墙配置的问题。这种情况下,如果几秒连接不上,那么可能永远也连接不上。因此,设置特别长的连接超时意义不大,将其配置得短一些(比如 1~5 秒)即可。如果是纯内网调用的话,这个参数可以设置得更短,在下游服务离线无法连接的时候,可以快速失败。
  • 排查连接超时问题,却没理清连的是哪里。通常情况下,我们的服务会有多个节点,如果别的客户端通过客户端负载均衡技术来连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端的问题;而如果服务端通过类似 Nginx 的反向代理来负载均衡,客户端连接的其实是 Nginx,而不是服务端,此时出现连接超时应该排查 Nginx。

读取超时参数和读取超时则会有三个误区:
第一个误区:认为出现了读取超时,服务端的执行就会中断。
我们知道,类似 Tomcat 的 Web 服务器都是把服务端请求提交到线程池处理的,只要服务端收到了请求,网络层面的超时和断开便不会影响服务端的执行。因此,出现读取超时不能随意假设服务端的处理情况,需要根据业务状态考虑如何进行后续处理。

**第二个误区:**认为读取超时只是 Socket 网络层面的概念,是数据传输的最长耗时,故将其配置得非常短,比如 100 毫秒。其实,发生了读取超时,网络层面无法区分是服务端没有把数据返回给客户端,还是数据在网络上耗时较久或丢包。但,因为 TCP 是先建立连接后传输数据,对于网络情况不是特别糟糕的服务调用,通常可以认为出现连接超时是网络问题或服务不在线,而出现读取超时是服务处理超时。确切地说,读取超时指的是,向 Socket 写入数据后,我们等到 Socket 返回数据的超时时间,其中包含的时间或者说绝大部分的时间,是服务端处理业务逻辑的时间。

**第三个误区:**认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。进行 HTTP 请求一般是需要获得结果的,属于同步调用。如果超时时间很长,在等待服务端返回数据的同时,客户端线程(通常是 Tomcat 线程)也在等待,当下游服务出现大量超时的时候,程序可能也会受到拖累创建大量线程,最终崩溃。对定时任务或异步任务来说,读取超时配置得长些问题不大。但面向用户响应的请求或是微服务短平快的同步接口调用,并发量一般较大,我们应该设置一个较短的读取超时时间,以防止被下游服务拖慢,通常不会设置超过 30 秒的读取超时。你可能会说,如果把读取超时设置为 2 秒,服务端接口需要 3 秒,岂不是永远都拿不到执行结果了?的确是这样,因此设置读取超时一定要根据实际情况,过长可能会让下游抖动影响到自己,过短又可能影响成功率。甚至,有些时候我们还要根据下游服务的业务需求,为不同的服务端接口设置不同的客户端读取超时。

3.2 Feign 和 Ribbon 配合使用,配置超时

为 Feign 配置超时参数的复杂之处在于,Feign 自己有两个超时参数,它使用的负载均衡组件 Ribbon 本身还有相关配置。

默认情况下 Feign 的读取超时是 1 秒,坑点一。

如果要修改 Feign 客户端默认的两个全局超时时间,可以设置

feign:
  client:
    config:
      default:
        connect-timeout: 3000
        read-timeout: 3000

坑点二,如果要配置 Feign 的读取超时,就必须同时配置连接超时,才能生效。

除了可以配置 Feign,也可以配置 Ribbon 组件的参数来修改两个超时时间。这里的坑点是,参数首字母要大写,和 Feign 的配置不同。

ribbon:
  ReadTimeout: 4000
  ConnectTimeout: 4000

如同时配置 Feign 和 Ribbon 的超时,以 Feign 为准。

3.3 Ribbon 会自动重试请求

注意接口的幂等设计,有状态的 API 接口不应该定义为 Get。否则这种重试机制会超出我们的预期。

关闭重试是将 MaxAutoRetriesNextServer 参数配置为 0,禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可:

ribbon:
MaxAutoRetriesNextServer: 0
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IT界的奇葩

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值