day28 SpringBootWeb(四)事务&AOP

目录

事务&AOP

1. 事务管理

1.1 事务回顾

1.2 案例

1.3 Spring事务管理

1.4 事务进阶

2. AOP基础

2.1 记录方法执行耗时

2.2 AOP快速入门

2.3 执行流程

2.4 AOP核心概念

3. AOP进阶

3.1 通知类型

3.2 通知顺序

3.3 切点表达式

3.4 连接点

4. AOP案例

4.1 需求

4.2 分析

4.3 步骤:


事务&AOP

1. 事务管理

1.1 事务回顾

在数据库阶段我们就介绍过事务:事务 是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。

事务操作:
 

  • 开启事务:begin /start transaction ; 一组操作开始前,开启事务
  • 提交事务: commit; 全部成功后提交事务
  • 回滚事务: rollback; 中间有任何一个子操作出现异常,回滚事务
     

1.2 案例

需求:解散部门-删除部门、同时删除部门下的员工。

我们需要完善之前删除部门的代码,最终代码实现如下:
 

1). DeptServiceImpl

@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;
    @Autowired
    private EmpMapper empMapper;

    @Override
    public void delete(Integer id) {
        //1. 删除部门
        deptMapper.delete(id);
        
        int i = 1/0;//出现异常
        
        //2. 根据部门id, 删除部门下的员工信息
        empMapper.deleteByDeptId(id);
    }
}

2). EmpMapper

//根据部门ID, 删除该部门下的员工数据
@Delete("delete from emp where dept_id = #{deptId}")
void deleteByDeptId(Integer deptId);

问题:即使程序运行抛出了异常,部门依然删除了,但是部门下的员工却没有删除,造成了数据的不一致。

解散部门,应该是一个事务,这一个事务中的一组操作,要么全部成功,要么全部失败。
 

1.3 Spring事务管理

  • 注解:@Transactional
  • 位置:业务(service)层的方法上、类上、接口上
  • 作用:将当前方法交给spring进行事务管理,方法执行前,开启事务;成功执行完毕,提交事务;出现异常,回滚事务
  • 可以通过如下配置,查看详细的事务管理日志:

1). 加在方法上

@Transactional
@Override
public void delete(Integer id) {
    //1. 删除部门
    deptMapper.delete(id);

    int i = 1/0;  //出现异常

    //2. 根据部门id, 删除部门下的员工信息
    empMapper.deleteByDeptId(id);
}

2). 加在接口上
 

@Transactional
public interface DeptService {

}

3). 加在类上
 

@Transactional
@Service
public class DeptServiceImpl implements DeptService {

}

我们会看到,当进行部门删除时,程序报出异常,而数据库数据呢,也已经回滚了。

1.4 事务进阶

1.4.1 rollbackFor

rollbackFor属性可以控制出现何种异常类型,回滚事务。默认情况下,只有出现 RuntimeException 才回滚异常。而如果出现编译时异常,则不回滚。

可以其出现任意异常都回滚事务
 

1). 方案一

而这样配置的话,使用比较麻烦,故而在实际开发中一般会进行异常转换
 

2). 方案二

如果业务代码有编译时异常,则将其转换为运行时异常,再抛出

这样即使用方便,又不至于事务失效。当然,如果全部抛出RuntimeException 不利于调错,故而可以自定义运行时异常,并抛出自定义异常。

public class CustomerException extends RuntimeException{
    public CustomerException() {
    }
    public CustomerException(String message) {
        super(message);
    }
}
@Transactional
@Override
public void delete(Integer id) throws Exception {
    //1. 删除部门
    deptMapper.delete(id);
    
    try {
        InputStream in = new FileInputStream("E:/1.txt");
    } catch (Exception e) {
        throw new Exception("出错了");
    }
	
    //2. 根据部门id, 删除部门下的员工信息
    empMapper.deleteByDeptId(id);
}

1.4.2 propagation
 

  • 事务传播行为:指的就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
  • 案例:
    在本例中 DeptServiceImpl中的delete方法,与 EmpServiceImpl中的deleteByDeptId方法, 两个方法都加上@Transactional 注解控制事务。
    由于没有配置事务传播行为,则默认事务传播行为 为 REQUIRED, 则表示 EmpServiceImpl.deleteByDeptId(),使用 DeptServiceImpl.delete() 刚才开启的事务,也就意味着这两个方法公用同一个事务,可以对其进行 统一提交和回滚操作。

1). DeptServiceImpl

@Transactional
@Override
public void delete(Integer id) throws Exception {
    //1. 删除部门
    deptMapper.delete(id);

    //2. 根据部门id, 删除部门下的员工信息
    empService.deleteByDeptId(id);
    
    //int i = 1/0;
}

2). EmpServiceImpl

@Transactional
@Override
public void deleteByDeptId(Integer deptId) {
	empMapper.deleteByDeptId(deptId);
}

  • 查看运行日志:

  • 使用 propagation 属性可以配置事务传播行为

属性值

含义

说明

REQUIRE

【默认值】需要事务,有则加入,无则创建新事务

-

REQUIRES_NEW

需要新事务,无论有无,总是创建新事务

-

SUPPORTS

支持事务,有则加入,无则在独立的连接中运行 SQL

结合 Hibernate、JPA 时有用,配在查询方法上

NOT_SUPPORTED

不支持事务,不加入,在独立的连接中运行 SQL

-

MANDATORY

必须有事务,否则抛异常

-

NEVER

必须没事务,否则抛异常

-

NESTED

嵌套事务

仅对 DataSourceTransactionManager 有效

我们主需要掌握前两个 : REQUIRED 以及 REQUIRES_NEW ,其他很少用到,无需掌握
 

接下来,我们再来测试一下 REQUIRES_NEW:

1). DeptServiceImpl

@Transactional
@Override
public void delete(Integer id) throws Exception {
    //1. 删除部门
    deptMapper.delete(id);

    //2. 根据部门id, 删除部门下的员工信息
    empService.deleteByDeptId(id);
    
    int i = 1/0; //抛出异常
}

2). EmpServiceImpl

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void deleteByDeptId(Integer deptId) {
    empMapper.deleteByDeptId(deptId);
}

  • 查看运行日志:

此时由于 EmpServiceImpl的deleteByDeptId方法 的事务传播行为为 REQUIRES_NEW 开启了一个新事务,则不会因为 DeptServiceImpl的delete方法 出现异常而回滚。
 

  • 作用:
    • REQUIRED :大部分情况下都是用该传播行为即可。
    • REQUIRES_NEW :当我们不希望事务之间相互影响时可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。

2. AOP基础

2.1 记录方法执行耗时

  • 需求:记录业务方法的执行耗时,并输出到控制台。
     

记录方法执行耗时,其实也非常简单,我们只需要在方法执行之前,获取一个开始时间戳。在方法执行完毕后,获取结束时间的时间戳。然后后者减去前者,就是方法的执行耗时。

  • 具体代码如下所示:
@Override
public List<Dept> list() {
    long begin = System.currentTimeMillis();
    
    List<Dept> deptList = deptMapper.list();
    
    long end = System.currentTimeMillis();
    log.debug("方法执行耗时 : {} ms", (end-begin));
    return deptList;
}

@Transactional
@Override
public void delete(Integer id) throws Exception {
    long begin = System.currentTimeMillis();
    
    //1. 删除部门
    deptMapper.delete(id);
    //2. 根据部门id, 删除部门下的员工信息
    empService.deleteByDeptId(id);
    //int i = 1/0;
    
    long end = System.currentTimeMillis();
    log.info("方法执行耗时 : {} ms", (end-begin));
}

@Override
public void save(Dept dept) {
    long begin = System.currentTimeMillis();

    dept.setCreateTime(LocalDateTime.now());
    dept.setUpdateTime(LocalDateTime.now());
    deptMapper.save(dept);
	
    long end = System.currentTimeMillis();
    log.info("方法执行耗时 : {} ms", (end-begin));
}

上述功能虽然实现了,但是我们会发现,所有的方法中,代码都是固定的,存在大量的重复代码:
 

A. 业务方法执行之前,记录开始时间:

long begin = System.currentTimeMillis();

B. 业务方法执行之后,记录结束时间:

long end = System.currentTimeMillis();
log.info("方法执行耗时 : {} ms", (end-begin));

2.2 AOP快速入门
 

  • AOP:Aspect Oriented Programming(面向切面编程),它的核心思想是将重复的逻辑剥离出来,在不修改原始逻辑的基础上对原始功能进行增强。
  • 优势:无侵入、减少重复代码、提高开发效率、维护方便
  • 我们可以通过AOP来完成上述代码的优化:

1). pom.xml 引入依赖
 

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2). 定义类抽取公共代码(执行耗时统计操作)

@Slf4j
public class TimeAspect {
	
    public void recordTime() throws Throwable {
        long begin = System.currentTimeMillis();
        
        //调用原始操作
        
        
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
    }
	
}

3). 标识当前类是一个AOP类,并被Spring容器管理

@Component
@Aspect
@Slf4j
public class TimeAspect {
	
    public void recordTime() throws Throwable {
        long begin = System.currentTimeMillis();
        
        //调用原始操作
        
        
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
    }
	
}

@Aspect:标识当前类是一个AOP类

@Component:声明该类是spring的IOC容器中的bean对象
 

4). 配置公共代码作用于哪些目标方法

@Component
@Aspect
@Slf4j
public class TimeAspect {
	
    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void recordTime() throws Throwable {
        long begin = System.currentTimeMillis();
        
        //调用原始操作
        
        
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
    }
	
}

@Around: 表示环绕通知,可以在目标方法执行前后执行一些公共代码

* 表示通配符,代表任意

.. 表示参数通配符,代表任意参数

5). 执行目标方法
 

@Component
@Aspect
@Slf4j
public class TimeAspect {
	
    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis();
        //调用原始操作
        Object result = joinPoint.proceed();
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
        return result;
    }
    
}

6). 测试运行

2.3 执行流程
 

AOP 的一种实现方式,是通过动态代理技术实现。
 

  • 当目标对象(此处为DeptServiceImpl)功能需要被增强时,并且我们使用AOP方式定义了增强逻辑(在Aspect类中)
  • Spring会为目标对象自动生成一个代理对象,并在代理对象对应方法中,结合我们定义的AOP增强逻辑完成功能增强

2.4 AOP核心概念

  • 连接点:JoinPoint,可以被AOP控制的方法执行(包含方法信息)
  • 通知:Advice ,重复逻辑代码
  • 切入点:PointCut ,匹配连接点的条件
  • 切面:Aspect,通知+切点

3. AOP进阶
 

3.1 通知类型
 

  • @Around:此注解标注的通知方法在目标方法前、后都被执行
  • @Before:此注解标注的通知方法在目标方法前被执行
  • @After :此注解标注的通知方法在目标方法后被执行,无论是否有异常
  • @AfterReturning : 此注解标注的通知方法在目标方法后被执行,有异常不会执行
  • @AfterThrowing : 此注解标注的通知方法发生异常后执行

@Around 需要自己调用 ProceedingJoinPoint.proceed() 来让目标方法执行,其他通知不需要考虑目标方法执行

  • 切面类
@Component
@Aspect
@Slf4j
public class TimeAspect {

    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis();
        //调用原始操作
        Object result = joinPoint.proceed();
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
        return result;
    }

    @Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void before(){
        log.info(" T before ....");
    }

    @After("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void after(){
        log.info(" T after ....");
    }

    @AfterReturning("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void afterReturning(){
        log.info("afterReturning ....");
    }

    @AfterThrowing("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void afterThrowing(){
        log.info("afterThrowing ....");
    }
}
  • 目标类
     
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        return deptList;
    }

    @Override
    public void delete(Integer id) {
        deptMapper.delete(id);
    }

    @Override
    public void save(Dept dept) {
        dept.setCreateTime(LocalDateTime.now());
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.save(dept);
    }
}
  • 测试1

程序正常运行的情况下,通知类型 @AfterThrowing 是不会运行的,但是@AfterReturning 是会运行的。

  • 测试2

程序运行出现异常的情况下,通知类型 @AfterReturning 是会运行的,但是@AfterThrowing 是不会运行的。

3.2 通知顺序
 

当有多个切面的切点都匹配目标时,多个通知方法都会被执行。之前介绍的 pjp.proceed() 在有多个通知方法匹配时,更准确的描述应该是这样的:

  • 如果还有下一个通知,则调用下一个通知
  • 如果没有下一个通知,则调用目标

那么它们的执行顺序是怎样的呢?

  • 默认按照 bean 的名称字母排序
  • 用 @Order(数字) 加在切面类上来控制顺序
    • 目标前的通知方法:数字小先执行
    • 目标后的通知方法:数字小后执行

1). 默认顺序

@Component
@Aspect
@Slf4j
public class TimeAspect {

    @Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void before(){
        log.info(" T before ....");
    }
    
}
@Component
@Aspect
@Slf4j
public class AimeAspect {

    @Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void before(){
        log.info("before ...." );
    }

}

定义TimeAspect 和 AimeAspect 切面类,测试执行顺序,默认按照切面类的名称字母排序。此例中AimeAspect 比 TimeAspect 字母顺序排名靠前,故此,AimeAspect 先执行。

所以调用之后执行顺序为:
 


 

2). @Order(数字) 排序

  • 目标前的通知方法:数字小先执行
  • 目标后的通知方法:数字小后执行
@Component
@Aspect
@Slf4j
@Order(1)
public class TimeAspect {

    @Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void before(){
        log.info(" T before ....");
    }
    
}

@Component
@Aspect
@Slf4j
@Order(2)
public class AimeAspect {

    @Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void before(){
        log.info("before ...." );
    }

}

测试结果如下:
 

3.3 切点表达式

切点表达式用来匹配【哪些】目标方法需要应用通知,常见的切点表达式如下

  • execution(返回值类型 包名.类名.方法名(参数类型))
    • * 可以通配任意返回值类型、包名、类名、方法名、或任意类型的一个参数
    • .. 可以通配任意层级的包、或任意类型、任意个数的参数
  • @annotation() 根据注解匹配
  • args() 根据方法参数匹配

3.3.1 execution

execution 主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:

execution(访问修饰符?  返回值  包名.类名?.方法名(方法参数) throws 异常?)

其中带 ? 的表示可以省略的部分
 

• 访问修饰符:可省略(没啥用,仅能匹配 public、protected、包级,private 不能增强)
 

• 包名.类名: 可省略

• throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)

3.3.2 annotation

切点表达式也支持匹配目标方法是否有注解。使用 @annotation

@annotation(com.itheima.anno.Log)

3.3.3 @PointCut

通过@PointCut注解,可以抽取一个切入点表达式,然后再其他的地方我们就可以通过类似于 方法调用 的形式来引用该切入点表达式。

   @Pointcut("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public void pt(){}


    @Around("pt()")
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis();
        //调用原始操作
        Object result = joinPoint.proceed();
        long end = System.currentTimeMillis();
        log.info("执行耗时 : {} ms", (end-begin));
        return result;
    }

3.4 连接点
 

连接点简单理解就是 目标方法,在Spring 中用 JoinPoint 抽象了连接点,用它可以获得方法执行时的相关信息,如方法名、方法参数类型、方法实际参数等等
 

  • 对于 @Around 通知,获取连接点信息只能使用 ProceedingJoinPoint
  • 对于其他四种通知,获取连接点信息只能使用 JoinPoint,它是 ProceedingJoinPoint 的父类型
     

那么如何获取这些信息呢?参考下面的代码

@Slf4j
@Aspect
@Component
public class MyAspect1 {

    @Pointcut("execution(* com.itheima.service.impl.*.*(..)) && @annotation(com.itheima.anno.Log)")
    public void pt(){}

    @Before("pt()")
    public void before(JoinPoint joinPoint){

        log.info("方法名: "+joinPoint.getSignature().getName());
        log.info("类名: "+joinPoint.getTarget().getClass().getName());
        log.info("参数: "+Arrays.asList(joinPoint.getArgs()).toString());

        log.info("before...1");
    }
}

4. AOP案例
 

4.1 需求
 

将对业务类中的增、删、改 方法操作日志保存到数据库。

  • 操作日志包括:
    • 操作人
    • 操作时间
    • 操作全类名
    • 操作方法名
    • 方法参数
    • 返回值
    • 方法执行耗时
       

4.2 分析
 

  • 需要对所有业务类中的增、删、改 方法添加统一功能,使用AOP技术最为方便
  • 由于增、删、改 方法名没有规律,可以自定义@Log注解完成目标方法选取

4.3 步骤:

1). 创建操作日志表

-- 操作日志表
create table operate_log(
    id int unsigned primary key auto_increment comment 'ID',
    operate_user int unsigned comment '操作人',
    operate_time datetime comment '操作时间',
    class_name varchar(100) comment '操作的类名',
    method_name varchar(100) comment '操作的方法名',
    method_params varchar(1000) comment '方法参数',
    return_value varchar(2000) comment '返回值',
    cost_time bigint comment '方法执行耗时, 单位:ms'
) comment '操作日志表';

2). 实体类

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
    private Integer id; //ID
    private Integer operateUser; //操作人
    private LocalDateTime operateTime; //操作时间
    private String className; //操作类名
    private String methodName; //操作方法名
    private String methodParams; //操作方法参数
    private String returnValue; //操作方法返回值
    private Long costTime; //操作耗时
}

3). 自定义注解

/**
 * 自定义Log注解
 */
@Target({ElementType.METHOD})
@Documented
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

4). 在需要记录日志的方法上加 @Log注解

    @Log
    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Integer id) throws Exception {
        deptService.delete(id);
        return Result.success();
    }

    @Log
    @PostMapping
    public Result save(@RequestBody Dept dept){
        deptService.save(dept);
        return Result.success();
    }


    @Log
    @GetMapping("/{id}")
    public Result getById(@PathVariable Integer id){
        Dept dept = deptService.getById(id);
        return Result.success(dept);
    }

5). 定义Mapper接口

@Mapper
public interface OperateLogMapper {
    //插入日志数据
    @Insert("insert into operate_log (operate_user, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
            "values (#{operateUser}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")
    public void insert(OperateLog log);

}

6). 定义切面类

@Component
@Aspect
public class LogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;
    @Autowired
    private HttpServletRequest request;

    @Around("@annotation(com.itheima.anno.Log)")
    public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis(); //开始时间

        String token = request.getHeader("token");
        Integer operateUser  = (Integer) JwtUtils.parseJWT(token).get("id");

        String className = joinPoint.getTarget().getClass().getName(); //操作类名
        String methodName = joinPoint.getSignature().getName(); //操作方法名

        Object[] args = joinPoint.getArgs();
        String methodParams = Arrays.toString(args); //操作方法参数

        //放行原始方式
        Object result = joinPoint.proceed();

        String returnValue = JSONObject.toJSONString(result);
        long end = System.currentTimeMillis(); //结束时间
        long costTime = end - begin;

        OperateLog log = new OperateLog(null,operateUser, LocalDateTime.now(),className,methodName,methodParams,returnValue,costTime);
        operateLogMapper.insert(log);
        return result;
    }

}

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在Spring Boot中,我们可以使用AOP(Aspect Oriented Programming)来实现在事务前切换数据源。 首先,我们需要定义两个数据源,例如DataSource1和DataSource2。然后,在应用程序中配置这些数据源,并使用@Transactional注解将需要使用不同数据源的方法标记为事务。 接下来,我们需要定义一个切面,使用@Aspect注解标记,来在事务前切换数据源。在切面中,我们可以使用@Before注解来指定在事务开始前执行的方法,并且在该方法中使用ThreadLocal来存储当前线程需要使用的数据源。 下面是一个示例代码: ```java @Aspect @Component public class DataSourceAspect { @Before("execution(* com.example.service.*.*(..)) && @annotation(transactional)") public void setDataSource(JoinPoint point, TargetDataSource transactional) { String dataSourceKey = transactional.value(); DataSourceContextHolder.setDataSource(dataSourceKey); } } ``` 在上面的代码中,@Before注解指定了要在Service层中所有被@Transactional注解标记的方法执行前切换数据源,并且@Transactional注解中的value属性用于指定要使用的数据源。在setDataSource方法中,我们使用DataSourceContextHolder类的setDataSource方法来将当前线程需要使用的数据源保存到ThreadLocal中。 最后,我们需要定义一个DataSourceContextHolder类来管理ThreadLocal对象,并在需要使用数据源时从ThreadLocal中获取当前线程需要使用的数据源。下面是一个示例代码: ```java public class DataSourceContextHolder { private static final ThreadLocal<String> dataSourceKey = new ThreadLocal<>(); public static void setDataSource(String key) { dataSourceKey.set(key); } public static String getDataSource() { return dataSourceKey.get(); } public static void clearDataSource() { dataSourceKey.remove(); } } ``` 在上面的代码中,我们使用ThreadLocal来保存当前线程需要使用的数据源,并提供了方法来获取当前线程需要使用的数据源和清除ThreadLocal对象。当事务执行完毕后,我们需要清除ThreadLocal对象以释放资源。 这样,我们就可以通过AOP切换数据源了。当我们需要在事务中使用不同的数据源时,只需要在@Transactional注解中指定要使用的数据源即可。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值