mybatis插件原理和使用

 说明:把表名直接定义成 order 是要报错的

1. 逻辑分页和物理分页的区别

逻辑分页

 又称内存分页 :mapper 接口传入 RowBounds,SQL 没有任何分页标识,取表总满足条件的数据, 到内存中去分页,和 ArrayList的 ,subList(start,end)很像,只适合数据量小的场景,如果数据量大的话要用物理分页,所以他不能算是真正的分页,只能算逻辑上的分页

// offset,从第几行开始查询
int start = 10; 
// limit,查询多少条
int pageSize = 5; 
RowBounds rb = new RowBounds(start, pageSize);
List<Student> list = mapper.selectStudentList(rb);
for(Student s :list){
	System.out.println(s);
}

xml 里的sql

select  * from student 

 

RowBouds 的底层其实是对ResultSet 的处理。它会舍弃掉前面offset 条数据,然后再取剩下的数据的limit 条(所以RowBounds的实现逻辑是 先取全量数据,然后在内存中截取分页的数据)

DefaultResultSetHandler.java
private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap,
ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws
SQLException {
	DefaultResultContext<Object> resultContext = new DefaultResultContext();
	ResultSet resultSet = rsw.getResultSet();
	this.skipRows(resultSet, rowBounds);
	while(this.shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() &&
	resultSet.next()) {
		ResultMap discriminatedResultMap = this.resolveDiscriminatedResultMap(resultSet,
		resultMap, (String)null);
		Object rowValue = this.getRowValue(rsw, discriminatedResultMap, (String)null);
		this.storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
	}
}

物理分页(limit)

具体可以参考如下文章:
   https://blog.csdn.net/Leon_Jinhai_Sun/article/details/110732995

mybatis 中文官网:
    http://www.mybatis.cn/
    http://www.mybatis.cn/archives/789.html

1.看代码部分:
   为什么只能拦截 四大天王?Configuration 类里创建 四大天王的时候,把拦截器链里的内容给四大天王(rownum 599,Wrap.plugin(target,this)

2. 多个自定义拦截器的执行顺序如何处理的?

1.
  DefaultSQLSession 进行增删改查?
  Executor
  StatementHandler 封装对数据库的增删改查
  ParameterHandler 设置值的
  ResultSetHandler
  MappedProxy  找到映射器


1.插件的原理与自定义插件的编写

  问题:
         有哪些对象可以被代理,对象里的哪些方法可以被代理?四大天王:
             Executor 顶层的接口(update,query,closed: 判断一个会话是否结束......),注意用的update 代替insert
             ParameterHandler(getParameterObject,setParameters):SQL 语句组装,拼接参数
             StatementHandler(prepare,parameterize,batch,update,query)
             ResultSetHandler(handlerResultSets.,handlerOutputParameters)
         多个插件如何注册?(这个实际开发中遇到过,多个的话会报错)
         多个插件的话,代理能不能被代理?可以

          假如 对Executor 对象的某个方法进行代理,定义了三个拦截器 a ,b ,c (链接器链里存的是 a,b,c)
           调用目标方法时候,执行顺序是怎么样的? 先执行c的,然后是b,然后是a,最后是 Executor 对象的被代理方法
            
             (c(b(a(Executor)a)b)c)
           
         拦截器链如何形成,如何做到层层拦截的?
         
          
         怎么创建代理?Intercepter 接口 的plugin 方法(Wrap.plugin(target,this))代码待看??
         什么时候创建代理对象,启动,创建会话,执行SQL?

          被代理后,调用的是什么方法?怎么调用到被代理对象的方法?还是调用 statementHandler.query(or invocation.proceed) ,因为已经改写了要执行的          SQL了
           Intercepter 接口的 intercept 方法
①插件原理


②自定义插件编写(1.要打印会被执行的SQL,除了mybatisplugin还可以自己写个简单的插件,自己用)
  说明一个场景:现在基本用appo 配置,有的人直接将线上的applo 配置copy到本地,修改 日志级别为debug,但是发现 项目启动的时候
  特别慢,然后考虑用插件),sql 的执行时间 etc ;2.返回数据的脱敏
  常用案例:
  水平分表(因为 interceptor的intercept(Invocation invocation ) 
      可以通过invocation  获得 方法上面的注解,信息,,哪个表etc)
       invocation.getArgs();invocation.getTarget()

       可以参考链接 :https://www.cnblogs.com/mmzs/p/11174551.html
  权限控制:(eg: 拼接SQL,shiro和security 是直接从表里拿出所有用户权限数据,然后根据注解值去判断是否在用户权限列表中;在mybatis 插件来实现,直接过滤此用户有没有这个权限,有就返回数据,没有就返回null )
  修改 mybatis 原来的内存分页(RowBands) 变为 物理分页(limit)
  特殊情况下:打印SQL,统计SQL执行时间(这个开发阶段,调试用的多;查看慢查询(数据库级别的很浪费时间的))
  对从库里查出的数据脱敏(手机号,电话号码,身份证等,当然这个也可在SQL中实现)

 

拦截器相关的接口:

/**
 * @author Clinton Begin
 */
public interface Interceptor {

  
  //在此方法中实现自己需要的功能,最后执行invocation.proceed()方法,实际就是调method.invoke(target, args)方法,调用代理类
  Object intercept(Invocation invocation) throws Throwable;

  // target 可以是四个对象,也可以是代理四大对象的代理对象
  Object plugin(Object target);

  void setProperties(Properties properties);

}

 

拦截器链

/**
 * @author Clinton Begin
 */
public class InterceptorChain {

  private final List<Interceptor> interceptors = new ArrayList<Interceptor>();

  // 传入四大对象,用拦截器链里的每个拦截器去包装目标对象,生成代理对象,代理对象作为目标对象,再
  // 被其他拦截器 去代理(所以说目标对象和代理对象都是可以被代理的)
  public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      // 这儿也包括自定义拦截器(因为实现了 Interceptor 接口)
      // plugin 是 自定义拦截器的 plugin 方法,用来生成 代理对象的
      target = interceptor.plugin(target);
    }
    // 最后返回代理对象
    return target;
  }

  public void addInterceptor(Interceptor interceptor) {
    interceptors.add(interceptor);
  }
  
  public List<Interceptor> getInterceptors() {
    return Collections.unmodifiableList(interceptors);
  }

}

 

接下来看看哪些方法调用了 pluginAll  和 interceptor.plugin(target) 的逻辑

然后看下 interceptor.plugin(target)

plugin 是interceptor的方法

import java.util.Properties;

/**
 * @author Clinton Begin
 */
public interface Interceptor {

  Object intercept(Invocation invocation) throws Throwable;

  Object plugin(Object target);

  void setProperties(Properties properties);

}

 

选择一个实现类点进去:

 

看看 invokeHandler 的invoke方法怎么重写的?

如何知道哪个拦截器走完了,那个拦截器没有执行完呢?

 

Invocation


package org.apache.ibatis.plugin;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * @author Clinton Begin
 */
public class Invocation {

  private Object target;
  private Method method;
  private Object[] args;

  public Invocation(Object target, Method method, Object[] args) {
    this.target = target;
    this.method = method;
    this.args = args;
  }

  public Object getTarget() {
    return target;
  }

  public Method getMethod() {
    return method;
  }

  public Object[] getArgs() {
    return args;
  }


  //mybatis的Interceptor最终还是调用的method.invoke方法
  // eg 在自定义的拦截器要实现 Interceptor 接口,并重写 intercept方法 ,在intercept 写完要
  // 增强的逻辑后,还要 调用 invocation.proceed() 方法去调用真正的目标方法
  public Object proceed() throws InvocationTargetException, IllegalAccessException {
    return method.invoke(target, args);
  }

}

 

Plugin 类:

/**
 * @author Clinton Begin
 */
//这个类是Mybatis拦截器的核心,大家可以看到该类继承了InvocationHandler
//又是JDK动态代理机制
public class Plugin implements InvocationHandler {
 
  //目标对象
  private Object target;
  //拦截器
  private Interceptor interceptor;
  //记录需要被拦截的类与方法
  private Map<Class<?>, Set<Method>> signatureMap;
 
  private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
    this.target = target;
    this.interceptor = interceptor;
    this.signatureMap = signatureMap;
  }
 
  //一个静态方法,对一个目标对象进行包装,生成代理类。
  public static Object wrap(Object target, Interceptor interceptor) {
	//首先根据interceptor上面定义的注解 获取需要拦截的信息
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
	//如果长度为>0 则返回代理类 否则不做处理
    if (interfaces.length > 0) {
	  //创建JDK动态代理对象
      return Proxy.newProxyInstance(
          type.getClassLoader(),
          interfaces,
          new Plugin(target, interceptor, signatureMap));
    }
    return target;
  }
 
  //在执行Executor、ParameterHandler、ResultSetHandler和StatementHandler的实现类的方法时会调用这个方法
  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
	  //通过method参数定义的类 去signatureMap当中查询需要拦截的方法集合
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
	  //判断是否是需要拦截的方法,如果需要拦截的话就执行实现的Interceptor的intercept方法,执行完之后还是会执行method.invoke方法,不过是放到interceptor实现类中去实现了
      if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
      }
	  //不拦截 直接通过目标对象调用方法
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }
  //根据拦截器接口(Interceptor)实现类上面的注解获取相关信息
  private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
	//获取注解信息
    Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
    // issue #251
	//为空则抛出异常
    if (interceptsAnnotation == null) {
      throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());      
    }
	//获得Signature注解信息
    Signature[] sigs = interceptsAnnotation.value();
    Map<Class<?>, Set<Method>> signatureMap = new HashMap<Class<?>, Set<Method>>();
	//循环注解信息
    for (Signature sig : sigs) {
	  //根据Signature注解定义的type信息去signatureMap当中查询需要拦截方法的集合
      Set<Method> methods = signatureMap.get(sig.type());
	  //第一次肯定为null 就创建一个并放入signatureMap
      if (methods == null) {
        methods = new HashSet<Method>();
        signatureMap.put(sig.type(), methods);
      }
      try {
		//找到sig.type当中定义的方法 并加入到集合
        Method method = sig.type().getMethod(sig.method(), sig.args());
        methods.add(method);
      } catch (NoSuchMethodException e) {
        throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + e, e);
      }
    }
    return signatureMap;
  }
  //根据对象类型与signatureMap获取接口信息
  private static Class<?>[] getAllInterfaces(Class<?> type, Map<Class<?>, Set<Method>> signatureMap) {
    Set<Class<?>> interfaces = new HashSet<Class<?>>();
	//循环type类型的接口信息 如果该类型存在与signatureMap当中则加入到set当中去
    while (type != null) {
      for (Class<?> c : type.getInterfaces()) {
        if (signatureMap.containsKey(c)) {
          interfaces.add(c);
        }
      }
      type = type.getSuperclass();
    }
	//转换为数组返回
    return interfaces.toArray(new Class<?>[interfaces.size()]);
  }
 
}

 

给两个例子,通过mybatis 插件给insert方法和update 方法增加默认值(创建时间,创建人etc)

① 对  StatementHandler拦截

package com.example.demo.mybatisplugin;


import com.example.demo.entity.businessenum.CreatedInfoEnum;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import java.lang.reflect.Field;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.Properties;

/**
 * @program: 插入修改信息补全创建人创建时间,修改人修改时间
 * @description: StatementHandler  接口 update 方法的定义如下 (用方法定义完成 @Signature 里注解内容)
 *  int update(Statement statement) throws SQLException;
 * @author: guoyiguang
 * @create: 2021-02-27 16:18
 **/
//@Intercepts({
//        @Signature(type = StatementHandler.class, method = "update", args = {Statement.class})
//})
@Intercepts({
        @Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class}),
        @Signature(type = StatementHandler.class, method = "update", args = {Statement.class})

})
//@Component
public class InsertOrUpdatePlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("执行目标方法前 InsertOrUpdatePlugin  拦截的目标对象:"+invocation.getTarget()+"拦截的方法 "+ invocation.getMethod().toString() + " 拦截的参数: "+ invocation.getArgs().toString());

        /**
         ***********************************************************执行目标方法前的逻辑***********************************
         */
        if (invocation.getTarget() instanceof ParameterHandler) {
            return invokeSetParameter(invocation);
        }

        /**
        ***********************************************************执行目标方法***********************************
        */
        Object proceed = invocation.proceed();

        /**
         ***********************************************************执行目标方法后的逻辑***********************************
         */
        System.out.println("InsertOrUpdatePlugin 行目标方法后  逻辑  ");

        return proceed;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target,this);
    }

    @Override
    public void setProperties(Properties properties) {

    }
    private Object invokeSetParameter(Invocation invocation) throws Exception {

        ParameterHandler parameterHandler = (ParameterHandler) invocation.getTarget();
        PreparedStatement ps = (PreparedStatement) invocation.getArgs()[0];

        // 反射获取 参数对像
        // 虽然上面拿到的 是 ParameterHandler,但是 getClass后 的是 ParameterHandler 的实现类 DefaultParameterHandler,内部有 parameterObject 属性,
        // 通过反射获得某个 属性值(or 属性对象)(此处是Order 对象)
        Field parameterField = parameterHandler.getClass().getDeclaredField("parameterObject");
        parameterField.setAccessible(true);
        // 此处是Order对象
        Object parameterObject = parameterField.get(parameterHandler);

        // 改写参数
        parameterObject = processParam(parameterObject);

        // 改写的参数设置到原parameterHandler对象
        parameterField.set(parameterHandler, parameterObject);
        parameterHandler.setParameters(ps);
        return null;
    }

    private Object processParam(Object parameterObject) throws IllegalAccessException {
        // Order.class
        Class<?> aClass = parameterObject.getClass();
        Field[] declaredFields = aClass.getDeclaredFields();
        for (Field  field:declaredFields){
            field.setAccessible(true);
            if(field.getName().equals(CreatedInfoEnum.createdTime.toString()) || field.getName().equals(CreatedInfoEnum.updatedTime.toString())){
                LocalDateTime now = LocalDateTime.now();
                field.set(parameterObject,now);
            }
            // 从内存获取 用户信息
            String user = "123456783456789";
            if (field.getName().equals(CreatedInfoEnum.createdGUID.toString()) && null == field.get(parameterObject)){
                field.set(parameterObject,user);
            }
            if (field.getName().equals(CreatedInfoEnum.updatedGUID.toString()) && null == field.get(parameterObject)){
                field.set(parameterObject,user);
            }
        }

        return parameterObject;
    }



}

② 对 Executor 拦截:

package com.example.demo.mybatisplugin;


import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.sql.PreparedStatement;
import java.time.LocalDateTime;
import java.util.Properties;

/** 参考链接:https://www.cnblogs.com/qingshan-tang/p/13299701.html
 * @program: 插入修改信息补全创建人创建时间,修改人修改时间
 * @description: Executor  接口 update 方法的定义如下 (用方法定义完成 @Signature 里注解内容)
 *  iint update(MappedStatement ms, Object parameter) throws SQLException;
 *
 * 第一个参数 MappedStatement ; 第二个参数  Order对象
 * @author: guoyiguang
 * @create: 2021-02-27 16:18
 **/
@Intercepts({
        @Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class}),
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class,Object.class})
})
@Component
public class InsertOrUpdatePlugin2 implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("执行目标方法前 InsertOrUpdatePlugin2  拦截的目标对象:"+invocation.getTarget()+"拦截的方法 "+ invocation.getMethod().toString() + " 拦截的参数: "+ invocation.getArgs().toString());

        /**
         ***********************************************************执行目标方法前的逻辑***********************************
         */
        fillField(invocation);
        /**
        ***********************************************************执行目标方法***********************************
        */
        Object proceed = invocation.proceed();

        /**
         ***********************************************************执行目标方法后的逻辑***********************************
         */
        System.out.println("InsertOrUpdatePlugin2 行目标方法后  逻辑  ");

        return proceed;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target,this);
    }

    @Override
    public void setProperties(Properties properties) {

    }

    private void fillField(Invocation invocation) throws IllegalAccessException {
        Object[] args = invocation.getArgs();
        SqlCommandType sqlCommandType = null;
        for (int i = 0; i < args.length; i++) {
            Object arg = args[i];
            // 0 参数类型:org.apache.ibatis.mapping.MappedStatement
            // 1 参数类型:com.example.demo.entity.Order
            System.out.println(i+" 参数类型:" + arg.getClass().getName());
            //arg.getClass().getName() 和  arg.getClass().toString() 这两个方法等效
            //第一个参数 MappedStatement ,从他内部获取 是增加还是删除的操作
            // arg.getClass().toString() ------>  org.apache.ibatis.mapping.MappedStatement
            if (arg instanceof MappedStatement) {
                MappedStatement ms = (MappedStatement) arg;
                sqlCommandType = ms.getSqlCommandType();
                System.out.println("操作类型:" + sqlCommandType);
                //如果是“增加”或“更新”操作,则继续进行默认操作信息赋值。否则,则退出
                if (sqlCommandType == SqlCommandType.INSERT || sqlCommandType == SqlCommandType.UPDATE) {
                    //  continue:不再执行循环体中continue语句之后的代码,直接进行下一次循环
                    //  此处是 进入  第二个参数循环
                    continue;
                } else {
                    // break:直接跳出当前循环体(while、for、do while)或程序块(switch)
                    break;
                }
            }

            // 第二个参数才走这儿
            if (sqlCommandType == SqlCommandType.INSERT) {
                for (Field f : arg.getClass().getDeclaredFields()
                        ) {
                    f.setAccessible(true);
                    switch (f.getName()) {
                        case "createdGUID":
                            f.set(arg,"111");
                            break;
                        case "createdTime":
                            f.set(arg, LocalDateTime.now());
                            break;
                        case "updatedGUID":
                            f.set(arg, "111");
                            break;
                        case "updatedTime":
                            f.set(arg, LocalDateTime.now());
                            break;
                        case "delFlag":
                            f.set(arg, "0");
                            break;
                    }
                }
            } else if (sqlCommandType == SqlCommandType.UPDATE) {
                for (Field f : arg.getClass().getDeclaredFields()
                        ) {
                    f.setAccessible(true);
                    switch (f.getName()) {
                        case "updatedGUID":
                            f.set(arg, "111");
                            break;
                        case "updatedTime":
                            f.set(arg, LocalDateTime.now());
                            break;
                    }
                }
            }
        }
    }

}

③记录sql的执行时间

package com.example.demo.mybatisplugin;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.springframework.stereotype.Component;
import java.sql.Statement;
import java.util.Properties;

/**
 * @program: 拦截所以数据库sql执行时长超过1毫秒的方法,并记录sql
 * @description: @Intercepts以及@Signature配置需要拦截的对象,其中type是需要拦截的对象Class,method是对象里面的方法,args是方法参数类型。
 * @author: guoyiguang
 *
 * 遇到的问题: 之前也是 拦截的 StatementHandler 的prepare 方法,args: Statement prepare(Connection connection, Integer transactionTimeout)
 *              但是发现 了  和分页插件一起使用,limit没有出现,于是考虑 拼接完sql之后,访问数据库之前的 query 方法
 *
 *              query:
 *              在 Statement 接口的定义:
 *              <E> List<E> query(Statement statement, ResultHandler resultHandler)
 *              下图的 type 是 四大对象的一个,method 是四大对象接口里的  方法,args 是 方法对应的形参:
 *
 * @create: 2021-02-26 23:39
 **/

@Intercepts({
        @Signature(type = StatementHandler.class, method = "query", args = {Statement.class,ResultHandler.class})
})
@Component
public class RecordTimePlugin implements Interceptor {
    private long time;


    /**
     * 拦截的  前逻辑 +  执行目标方法 invocation.proceed() +  执行完目标方法后的逻辑
     *
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println(" RecordTimePlugin  拦截的目标对象:"+invocation.getTarget()+"拦截的方法 "+ invocation.getMethod().toString() + " 拦截的参数: "+ invocation.getArgs().toString());

        /**
         *****************************************************************  执行目标方法前的逻辑  **************************************************************************************
         *
         */
        //通过StatementHandler获取执行的sql
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String sql = boundSql.getSql();

        long start = System.currentTimeMillis();
        /**
         ***************************************************************** 调用  invocation.proceed()  执行目标方法 **************************************************************************************
         *
         */
        // 底层调用 method.invoke 执行目标方法
        Object proceed = invocation.proceed();
        /**
         *****************************************************************  执行完目标方法后的逻辑  **************************************************************************************
         *
         */
        // 执行完目标方法后的逻辑
        long end = System.currentTimeMillis();
        if ((end - start) > time) {
            System.out.println("本次数据库操作是慢查询,sql是:" + sql);
        }else{
            System.out.println("本次数据库操作是查询,sql是:" + sql);
        }
        return proceed;
    }

    /**
    * @Description: 传入目标对象或者一个代理对象的,返回新的代理对象
     * 底层JDK的动态代理
    */
    @Override
    public Object plugin(Object target) {
        if (target instanceof StatementHandler) {
            return Plugin.wrap(target, this);
        }
        return Plugin.wrap(target, this);
    }


    /**
     * @Description: 获取设置的阈值等参数
     */
    @Override
    public void setProperties(Properties properties) {
        this.time = Long.parseLong(properties.getProperty("time"));
    }



}

mybatis插件的注册有两种方法:

① 直接在 拦截器上加 @Component 注解

② 新增一个配置类:用  @Configuration  + @Bean

eg:

@Configuration
public class MyBatisConfig {

    @Bean
    public CustomePagePlugin customePagePlugin() {
        CustomePagePlugin customePagePlugin = new CustomePagePlugin();
        return customePagePlugin;
    }
 }

 

参考链接:

https://blog.csdn.net/chinabestchina/article/details/102559207

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值