Commons DbUtils源码阅读五

关于DbUtils,我们深入剖析了对ResultSet解析处理的两个核心类:BeanProcessor和BasicRowProcessor,可以说,这两个类,是对ResultSet的解析有了一个完整的支持。虽然真正做解析工作的是这两个类,但用户对ResultSet的解析是通过接口ResultSetHandler<T>的这个实现类来封装解决的。但要解析的是,我们也得通过SQL语句获取ResultSet对象呀,所以,看看DbUtils是怎么做的。

  一、QueryRunner类,利用可插拨的策略执行SQL查询来处理ResultSets,大致看了一下,该类的重载方法确实有够多。来一类一类的解决:

  1)构造器有多个重载方法,有必要说明解析一下,现列出部分代码:

 

Java代码   收藏代码
  1.  /** 
  2.   *QueryRunner 默认构造器 
  3.   */  
  4.  public QueryRunner() {  
  5.      super();  
  6.      ds = null;  
  7.  }  
  8.   
  9.  /** 
  10.   * 允许Oracle驱动程序的解决方案 
  11.   * @param pmdKnownBroken 如果是Oracle drivers,则不支持    ParameterMetaData.getParameterType(int)这个方法; 
  12.   * if pmdKnownBroken参数设置为true,则我们不做    
  13.   * ParameterMetaData.getParameterType(int)方法; 
  14.   * 如果为false,那将会尝试获取,如果不支持有异常抛出,则不再使用    
  15. */  
  16.  public QueryRunner(boolean pmdKnownBroken) {  
  17.      super();  
  18.      this.pmdKnownBroken = pmdKnownBroken;   
  19.      ds = null;  
  20.  }  
  21.    
  22.  /** 
  23.   * QueryRunner构造器,Oracle drivers的解决方案. 通过DataSource 
  24.   * 获取数据源连接 
  25.   * @param ds  数据源,用于获取数据连接Connection 
  26.   */  
  27.  public QueryRunner(DataSource ds) {  
  28.      super();  
  29.      this.ds = ds;  
  30.  }  
  31.    
  32.  /** 
  33.   *  QueryRunner构造器,Oracle drivers的解决方案. 通过DataSource 
  34.   * 获取数据源连接 
  35.   * @param ds  数据源,用于获取数据连接Connection. 
  36.   * @param pmdKnownBroken 如果是Oracle drivers,则不支持        ParameterMetaData.getParameterType(int)这个方法; 
  37.   * if pmdKnownBroken参数设置为true,则我们不做    
  38. * ParameterMetaData.getParameterType(int)方法; 
  39.   * 如果为false,那将会尝试获取,如果不支持有异常抛出,则不再使用    
  40.   */  
  41.  public QueryRunner(DataSource ds, boolean pmdKnownBroken) {  
  42.      super();  
  43.      this.pmdKnownBroken = pmdKnownBroken;  
  44.      this.ds = ds;  
  45.  }  

   本身这个构造器并没有什么,关键是boolean类型的pmdKnownBroken和DataSource类型的ds。DataSource呢,很明显,是通过它来获取数据库连接,如何程式获取DataSource对象,这得需要借助于commons里的dbcp和pool这两个组件。具体的如何获取,可以参考DBCP组件的官方示例程序,具体网址如下:

   http://svn.apache.org/viewvc/commons/proper/dbcp/trunk/doc/BasicDataSourceExample.java?view=markup  

  这个类中的静态方法setupDataSource就是用来获取数据源的,说个题外话,DBCP和pool这两个组件对于数据源的管理,可谓是鼎鼎大名啊,Spring的数据源管理也是基于该组件,当然了还有另外一个数据源C3P0。关于数据源这一知识点,各位有兴趣的朋友可以参考在下写的“Spring 数据源不同配置 ”,扯远了啊,呵~咱接着说pmdKnownBroken这个变量,虽然现在说起来可能感觉有些抽象。源码的解释是这样的:Oracle的驱动程序不支持ParameterMetaData.getParameterType方法,如果pmdKnownBroken设置为true,则我们甚至不进行尝试处理,而false,我们则会尝试着使用ParameterMetaData.getParameterType方法,如果有异常抛出,则不再使用。

  2)一般来说数据库操作的时候,总是会顺口溜似的:增删改查,所以我们先从QueryRunner类的SQL增加操作说起,看了一下这个类的大概实现,实际上呢,update方法它不仅充当了SQL增加操作,同时也充当了更新和删除的操作,所以,一并了解了吧:

 

Java代码   收藏代码
  1. /** 
  2.     * 执行一个没有参数的SQL插入、更新或者删除操作 
  3.     * Execute an SQL INSERT, UPDATE, or DELETE query without replacement 
  4.     * parameters. 
  5.     *  
  6.     * @param conn 数据连接The connection to use to run the query. 
  7.     * @param sql 要执行的SQL语句The SQL to execute. 
  8.     * @return 更新的行数The number of rows updated. 
  9.     * @throws SQLException 数据库访问异常if a database access error occurs 
  10.     */  
  11.    public int update(Connection conn, String sql) throws SQLException {  
  12.        return this.update(conn, sql, (Object[]) null);  
  13.    }  
  14.   
  15.    /** 
  16.     * 执行只有一个参数的SQL插入、修改或者删除操作 
  17.     * @param conn 执行查询的数据库连接 
  18.     * @param sql 要执行的SQL语句 
  19.     * @return 更新的行数 
  20.     * @throws SQLException 数据库访问异常 
  21.     */  
  22.    public int update(Connection conn, String sql, Object param)  
  23.        throws SQLException {  
  24.   
  25.        return this.update(conn, sql, new Object[] { param });  
  26.    }  
  27.   
  28.    /** 
  29.     * 执行指定没有参数的插入、修改或者删除的SQL语句。 
  30.     * 数据连接通过DataSource(在构造器中指定)获取。 
  31.     * 此连接必须在自动提交模式,否则会导致更新操作不会保存。 
  32.  
  33.     * @param sql 要执行的SQL语句 
  34.     * @throws SQLException 数据库访问异常 
  35.     * @return 更新的行数 
  36.     */  
  37.    public int update(String sql) throws SQLException {  
  38.        return this.update(sql, (Object[]) null);  
  39.    }  
  40.   
  41.    /** 
  42.     * 执行指定只有一个参数的插入、修改或者删除的SQL语句。 
  43.     * 数据连接通过DataSource(在构造器中指定)获取。 
  44.     * 此连接必须在自动提交模式,否则会导致更新操作不会保存。  
  45.     *  
  46.     * @param sql 要执行的SQL语句 
  47.     * @param param 参数 
  48.     * @throws SQLException  数据库访问异常 
  49.     * @return 更新的行数 
  50.     */  
  51.    public int update(String sql, Object param) throws SQLException {  
  52.        return this.update(sql, new Object[] { param });  
  53.    }  
  54.   
  55.    /** 
  56.     * 执行指定的插入、修改或者删除的SQL语句。 
  57.     * 数据连接通过DataSource(在构造器中指定)获取。 
  58.     * 此连接必须在自动提交模式,否则会导致更新操作不会保存。  
  59.     *  
  60.     * @param sql 要执行的SQL语句 
  61.     * @param params 初始化PreparedStatement参数 
  62.     * @throws SQLException 数据库访问异常 
  63.     * @return 更新的行数 
  64.     */  
  65.    public int update(String sql, Object... params) throws SQLException {  
  66.        Connection conn = this.prepareConnection();  
  67.   
  68.        try {  
  69.            return this.update(conn, sql, params);  
  70.        } finally {  
  71.            close(conn);  
  72.        }  
  73.    }  

   真是巨多啊!我在每个方法上,都将源码上面的一些说明解释成了中文,各位有兴趣的可以看看。

   挑两个具有代表性的方法来读一下:

      2-1)获取数据库连接,这个呢,源码上面的说明也说了,是通过DataSource来获取的,具体看看prepareConnection()这个方法:

 

Java代码   收藏代码
  1. protected Connection prepareConnection() throws SQLException {  
  2.     if(this.getDataSource() == null) {  
  3.         throw new SQLException("QueryRunner requires a DataSource to be " +  
  4.             "invoked in this way, or a Connection should be passed in");  
  5.     }  
  6.     return this.getDataSource().getConnection();  
  7. }  

     这个方法比较的简单,首先是获取数据源实例,如果数据源为空,则抛出异常:必须要有一个DataSource,然后呢,就会获取一个Connection实例返回。这个DataSource实例呢,是在实例化的时候指定的,当然了,我们也可以子类重写这个prepareConnection方法,来实现一个指定的获取数据库连接的方法。

     2-2)

 

Java代码   收藏代码
  1. /** 
  2.    * 执行一个SQL插入、更新或者删除操作 
  3. * @param conn 执行查询的数据库连接 
  4. * @param sql 要执行的SQL语句 
  5. * @return 更新的行数 
  6. * @throws SQLException 数据库访问异常 
  7. */  
  8. ublic int update(Connection conn, String sql, Object... params)  
  9.    throws SQLException {  
  10.   
  11.    PreparedStatement stmt = null;  
  12.    int rows = 0;  
  13.   
  14.    try {  
  15.        stmt = this.prepareStatement(conn, sql);//通过Connection和sql获取PreparedStatement实例  
  16.        this.fillStatement(stmt, params);  
  17.        rows = stmt.executeUpdate();  
  18.   
  19.    } catch (SQLException e) {  
  20.        this.rethrow(e, sql, params);  
  21.   
  22.    } finally {  
  23.        close(stmt);  
  24.    }  
  25.   
  26.    return rows;  

 

   来,一步一步的执行这个核心方法,首先,通过prepareStatement这个方法,传入数据库连接和SQL这两个参数

得到一个PreparedStatement对象实例;然后通过fillStatement方法填充参数值,看看具体实现:

 

Java代码   收藏代码
  1. /** 
  2.    * 通过指定对象填充PreparedStatement的代替参数。 
  3.  * @param stmt PreparedStatement to fill 
  4.    * @param params 查询替代参数; null也是有效的参数。 
  5.  * @throws SQLException 数据库访问异常 
  6.  */  
  7. public void fillStatement(PreparedStatement stmt, Object... params)  
  8.     throws SQLException {  
  9.   
  10.     if (params == null) {//参数为空,则返回  
  11.         return;  
  12.     }  
  13.      
  14.     ParameterMetaData pmd = null;  
  15.     if (!pmdKnownBroken){//false,we try it  
  16.         pmd = stmt.getParameterMetaData();//获取关于PreparedStatement 对象中参数的类型和属性信息的对象  
  17.         if (pmd.getParameterCount() < params.length) {//如果PreparedStatement需要的参数数量少于指定参数数量,则抛出数量不匹配异常  
  18.             throw new SQLException("Too many parameters: expected "  
  19.                     + pmd.getParameterCount() + ", was given " + params.length);  
  20.         }  
  21.     }  
  22.     //循环参数  
  23.     for (int i = 0; i < params.length; i++) {  
  24.         if (params[i] != null) {//如果指定的参数不为空,则指定参数值  
  25.             stmt.setObject(i + 1, params[i]);  
  26.         } else {  
  27.             // VARCHAR类型可以与许多的驱动工作,而不管真实的列类型.  
  28.             // 奇怪的是,NULL和OTHER与Oracle的驱动不能工作.  
  29.             // VARCHAR works with many drivers regardless  
  30.             // of the actual column type.  Oddly, NULL and   
  31.             // OTHER don't work with Oracle's drivers.  
  32.             int sqlType = Types.VARCHAR;  
  33.             if (!pmdKnownBroken) {//false  
  34.                 try {  
  35.                     sqlType = pmd.getParameterType(i + 1);//获取特定的参数类型  
  36.                 } catch (SQLException e) {  
  37.                     pmdKnownBroken = true;//如果不支持getParameterType方法,则不再尝试使用  
  38.                 }  
  39.             }  
  40.             stmt.setNull(i + 1, sqlType);//为特定类型赋空值  
  41.         }  
  42.     }  
  43. }  

   我已经对这个方法做了一些必要的说明,实际上呢,最需要强调的,就是pmdKnownBroken参数以及Oracle驱动的关系,pmdKnownBroken这个参数呢,我们已经在构造器那一块说过了,它实际上用于区别Oracle驱动, 说是Oracle驱动不支持getParameterType方法,我是不清楚了,没有使用过,所以没有发言权,但我想这个问题应该会有所解决.另一个批量查询方法batch,主要方法是与update方法类似的,故不再解析。现在呢,主要的方法体功能已经了解完了。

  3)接下来呢,理论上应该是SQL的查询方法解析了,但我看了一下query方法,需要说明的,我们都已经在之前的update方法里拜读过了,唯一不同的是就是多了一个ResultSetHandler<T>参数,之前呢,我有说过ResultSetHandler这个接口,它通过调用handler方法处理ResultSet结果集完成指定类型的转换,本身dbUtils组件呢,提供了众多的ResultSetHandler实现类,它们都位于org.apache.commons.dbutils.handlers的包下,我会在以后的章节中具体解析。

  4)具体来说明一下QueryRunner这个类中的fillStatementWithBean这个方法,在整个组件中暂未用到,但,我想在面向对象的Java开发中,通过指定的bean实例,为SQL语句参数指定bean变量值肯定是会广泛应用的,也就是JavaBean与特定数据表的映射了。Hibernate、JPA等框架能够自动完成对象与关系型数据库的映射,底层的实现也诸如此类吧!

 

Java代码   收藏代码
  1. /** 
  2.  * 根据bean的属性值填充PerparedStatement的参数 
  3.  * @param stmt 
  4.  *            待填充值的PreparedStatement 
  5.  * @param bean 
  6.  *            JavaBean对象 
  7.  * @param propertyNames 
  8.  *            有序的属性名称数组(这些名字应该有 
  9.  *            getters/setters方法匹配);这个属性数组顺序与statement的插入参数顺序匹配 
  10.  * @throws SQLException  
  11.  *             数据访问异常 
  12.  */  
  13. public void fillStatementWithBean(PreparedStatement stmt, Object bean,  
  14.         String... propertyNames) throws SQLException {  
  15.     PropertyDescriptor[] descriptors;  
  16.     try {  
  17.         descriptors = Introspector.getBeanInfo(bean.getClass())  
  18.                 .getPropertyDescriptors(); //4-1  
  19.     }catch(IntrospectionException e){  
  20.         throw new RuntimeException("Couldn't introspect bean " + bean.getClass().toString(), e);  
  21.     }  
  22.     PropertyDescriptor[] sorted = new PropertyDescriptor[propertyNames.length];//4-2  
  23.     //参数名与Bean的属性进行比较,确保属性的完整性  
  24.     //确保为每个属性名找到在bean中对应的PropertyDescriptor实例  
  25.     for (int i = 0; i < propertyNames.length; i++) {  
  26.         String propertyName = propertyNames[i];  
  27.         if (propertyName == null) {//属性列表里的属性不能为空  
  28.             throw new NullPointerException("propertyName can't be null: " + i);  
  29.         }  
  30.         boolean found = false;  
  31.         for (int j = 0; j < descriptors.length; j++) {//4-3  
  32.             PropertyDescriptor descriptor = descriptors[j];  
  33.             if (propertyName.equals(descriptor.getName())) {  
  34.                 sorted[i] = descriptor;//此属性在bean中存在,赋于PropertyDescriptor实例  
  35.          found = true;  
  36.                 break;  
  37.             }  
  38.         }  
  39.         if (!found) {  
  40.             throw new RuntimeException("Couldn't find bean property: "  
  41.                     + bean.getClass() + " " + propertyName);  
  42.         }  
  43.     }  
  44.     fillStatementWithBean(stmt, bean, sorted);//4-4  
  45. }  

    这个方法做的事情是这样的,就是通过指定一个Javabean实例和一个PreparedStatement的参数名数组为PreparedStatement填充对应的参数值。当然了,这些参数名都是JavaBean实例里的属性了。来看看具体的实现过程:

    4-1)通过内省机制获取指定bean实例的PropertyDescriptor[]数组,这样呢,就有了对bean属性的直接操作能力了;

    4-2)根据指定的参数名数组propertyNames,实例化一个PropertyDescriptor[]数组,这个数组,主要的呢,就是存储PreparedStatement指定的参数名对应bean实例中的属性的PropertyDescriptor对象;

    4-3)循环4-1)中Bean实例的PropertyDescriptor数组,如果通过PropertyDescriptor实例获取的属性名与指定的propertyNames相同,则将对应的PeropertyDescriptor实例赋给4-2)中声明的数组;

    4-4)4-1~4-3,这个应该算是真正实现PreparedStatement赋值的前期初始化工作吧,这个fillStatementWithBean的重载方法通过指定stmt中参数的bean实例对应的属性描述数组,下面来看看具体的实现代码吧:

 

Java代码   收藏代码
  1. /** 
  2.  *  
  3.  * 根据bean的属性值填充PerparedStatement的参数 
  4.  * @param stmt 
  5.  *            待填充值的PreparedStatement 
  6.  * @param bean 
  7.  *            JavaBean对象 
  8.  * @param properties 
  9.  *            指定顺序数组;与PreparedStatement的参数顺序一致 
  10.  * @throws SQLException 
  11.  *            数据访问异常 
  12.  */  
  13. public void fillStatementWithBean(PreparedStatement stmt, Object bean,  
  14.         PropertyDescriptor[] properties) throws SQLException {  
  15.     Object[] params = new Object[properties.length];//属性值的数组  
  16.     for (int i = 0; i < properties.length; i++) {  
  17.         PropertyDescriptor property = properties[i];  
  18.         Object value = null;  
  19.         Method method = property.getReadMethod();//获取属性对应的getter方法  
  20.         if (method == null) {  
  21.             throw new RuntimeException("No read method for bean property "  
  22.                     + bean.getClass() + " " + property.getName());  
  23.         }  
  24.         try {  
  25.             value = method.invoke(bean, new Object[0]);//通过反射调用获取属性值  
  26.         } catch (InvocationTargetException e) {  
  27.             throw new RuntimeException("Couldn't invoke method: " + method, e);  
  28.         } catch (IllegalArgumentException e) {  
  29.             throw new RuntimeException("Couldn't invoke method with 0 arguments: " + method, e);  
  30.         } catch (IllegalAccessException e) {  
  31.             throw new RuntimeException("Couldn't invoke method: " + method, e);  
  32.         }   
  33.         params[i] = value;//设置属性值  
  34.     }  
  35.     fillStatement(stmt, params);  
  36. }  

   这个方法我还真的有点懒得再说明下去了,浪费太多面板罗,OK,这个类的解析到此为此吧。

二、QueryLoader类,是一个从一个文件加载查询到一个Map的简单的类。然后,当需要的时候,你从Map中选择一些查询。当然了,这个方法的实现是比较简单的。现看看文件载入的源代码:

 

Java代码   收藏代码
  1. /** 
  2.  * 载入一个查询命名和SQL值映射的Map集合. 
  3.  * 此Map被缓存以便以后相同路径的请求可以返回被缓存的Map 
  4.  * Loads a Map of query names to SQL values.  The Maps are cached so a  
  5.  * subsequent request to load queries from the same path will return 
  6.  * the cached Map. 
  7.  *  
  8.  * @param path The path that the ClassLoader will use to find the file.  
  9.  * ClassLoader通过path查找文件 
  10.  *  
  11.  * This is <strong>not</strong> a file system path.  If you had a jarred 
  12.  * Queries.properties file in the com.yourcorp.app.jdbc package you would  
  13.  * pass "/com/yourcorp/app/jdbc/Queries.properties" to this method. 
  14.  * 这不是一个文件系统路径。如果你有一个Queries.properties文件在com.yourcorp.app.jdbc这个包下, 
  15.  * 那么你应该传递"/com/yourcorp/app/jdbc/Queries.properties"参数到这个方法. 
  16.  *  
  17.  * @throws IOException if a file access error occurs 
  18.  * @throws IllegalArgumentException if the ClassLoader can't find a file at 
  19.  * the given path. 
  20.  * @return Map of query names to SQL values 
  21.  */  
  22. public synchronized Map<String,String> load(String path) throws IOException {  
  23.   
  24.     Map<String,String> queryMap = (Map<String,String>) this.queries.get(path);  
  25.   
  26.     if (queryMap == null) {  
  27.         queryMap = this.loadQueries(path);  
  28.         this.queries.put(path, queryMap);  
  29.     }  
  30.   
  31.     return queryMap;  
  32. }  

    这个方法加入了同步锁机制,所以是线程安全的,这个方法需要注意的一点就是传入的路径,因为是通过ClassLoader载入,所以,传入的路径是绝对路径名。首先呢,先从本地的Map集合queries拿到路径名里对应的集合,如果为空,则说明没有缓存对不对,OK,没有就加呗,来loadQueries方法:

 

Java代码   收藏代码
  1. /** 
  2.  * Loads a set of named queries into a Map object.  This implementation 
  3.  * reads a properties file at the given path. 
  4.  *  
  5.  * 加载命名查询集到一个Map对象中. 
  6.  * 这个实现用于读取给定路径的Properties文件 
  7.  *  
  8.  * @param path The path that the ClassLoader will use to find the file. 
  9.  *      ClassLoader使用指定的path去查找file 
  10.  * @throws IOException file访问异常 
  11.  * @throws IllegalArgumentException ClassLoader查找不到指定路径的文件 
  12.  * @since DbUtils 1.1 
  13.  * @return Map of query names to SQL values 查询名称到SQL值的映射集合 
  14.  */  
  15. @SuppressWarnings("unchecked")  
  16. protected Map<String,String> loadQueries(String path) throws IOException {  
  17.     // Findbugs flags getClass().getResource as a bad practice; maybe we should change the API?  
  18.       
  19.     InputStream in = getClass().getResourceAsStream(path);//获取指定文件的流对象  
  20.   
  21.     if (in == null) {  
  22.         throw new IllegalArgumentException(path + " not found.");  
  23.     }  
  24.   
  25.     Properties props = new Properties();  
  26.     props.load(in);  
  27.   
  28.     // Copy to HashMap for better performance  
  29.     return new HashMap(props);  
  30. }  

    哝,有没有?!通过ClassLoader载入指定文件流对象,如果为空,则会抛出找不到文件的异常。否则呢,载入Properties文件并以HashMap返回。

   载入了未缓存的properties文件,那么,存一个:

 

Java代码   收藏代码
  1. this.queries.put(path, queryMap);  

   完成了载入、缓存,缓存这个东西呢是要占内存的,所以呢,不要缓存太多或者大的对象,除非有必要,建议各位用完就remove掉:

 

Java代码   收藏代码
  1. /** 
  2.  * Removes the queries for the given path from the cache. 
  3.  *  从缓存中删除指定的路径 
  4.  * @param path The path that the queries were loaded from. 
  5.  *   queries载入的路径 
  6.  */  
  7. public synchronized void unload(String path){  
  8.     this.queries.remove(path);  
  9. }  

   好了,DbUtils这两个类的解析就到此为此吧,回头看看,发现DbUtils组件的一些主要的类都已经解析完成了,继续努力!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
ThreadLocal是Java中的一个线程局部变量,它可以为每个线程提供独立的变量副本,使得每个线程都可以独立地修改自己所拥有的变量副本,而不会影响其他线程的副本。DBUtils是一个开源的数据库操作工具类库,它封装了JDBC的操作细节,简化了数据库操作的代码。 当ThreadLocal与DBUtils结合使用时,可以实现在多线程环境下,每个线程都拥有独立的数据库连接,避免了线程间的资源竞争和并发访问的问题。 下面是一个ThreadLocal与DBUtils结合使用的示例: ```java import org.apache.commons.dbutils.QueryRunner; import org.apache.commons.dbutils.handlers.BeanHandler; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtilsExample { private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>(); public static void main(String[] args) { // 初始化数据源 DataSource dataSource = ...; // 创建查询器 QueryRunner queryRunner = new QueryRunner(dataSource); try { // 获取数据库连接 Connection connection = dataSource.getConnection(); // 将连接保存到ThreadLocal中 connectionHolder.set(connection); // 在当前线程中执行数据库操作 User user = queryRunner.query("SELECT * FROM user WHERE id = ?", new BeanHandler<>(User.class), 1); System.out.println(user); } catch (SQLException e) { e.printStackTrace(); } finally { // 关闭数据库连接 Connection connection = connectionHolder.get(); if (connection != null) { try { connection.close(); } catch (SQLException e) { e.printStackTrace(); } } // 清除ThreadLocal中的连接 connectionHolder.remove(); } } } ``` 在上述示例中,我们通过ThreadLocal将数据库连接保存在每个线程的独立副本中。这样,在每个线程中执行数据库操作时,都可以从ThreadLocal中获取到独立的数据库连接,而不会受到其他线程的影响。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值