Java SE 练习篇 P2 ORM工具(反射机制,XML解析)

1 构建映射关系

1.1 ORM工具的作用

ORM(Object Relative Mapping):表和类,记录和对象之间存在着对应关系,如果能得到这些映射关系,就可以根据这个类,自动创建并执行一个SQL语句,最终直接生成对应类的对象,也就是将由类得到SQL语句,由SQL执行结果得到对象的过程完全自动化

使用过MVC后,很清晰地得知想要访问数据库,对表中的内容进行增删改查,需要一个与表对应的实体类,如表sys_device对应实体类Device,表中的每一个列都对应着实体类的每一个成员,这样可以轻松构建出符合表结构的对象,来进行对数据的访问

在这里插入图片描述
所以可以得出数据库表和实体类有着明确的映射关系,且表的列和类的成员具有一一对应的关系,接下来通过配置好的XML文件来取得这种映射关系,最终目的是:通过更改配置文件,ORM能为我们自动地写好SQL语句

配置好的XML文件如下:
规定了类和表的映射,成员和列的映射,明确了主键
在这里插入图片描述

1.2 构建成员与列的映射关系

实体类的成员和表的列存在着一一对应的关系,通过符合规范的XML文件可以得到成员和列的映射关系类:

PropertyColumnDefinition类
在这里插入图片描述
这个[成员->列]映射类很简单,一个该类对象存储一对[成员->列]映射关系,需要注意的是接下来会需要反射机制,所以成员需要是Field类型

1.3 构建类与表的映射关系

有了[成员->列]映射关系,接下来就需要构建[类->表]映射关系类
[类->表]映射关系类不仅要有类对应的表名还需要包含刚才创建的[成员->列]映射关系类,且成员和列大多数情况不止一个,那么就需要一个列表来存储[成员->列]映射关系类

ClassTableDefinition类

/**
 * @author 雫
 * @date 2021/1/19 - 17:50
 * @function 类和表的映射关系
 * 类不确定采用泛型,表名采用String即可
 * A类对应A1表,A1表有多个列,A1表的每个列对应A类中的一个成员
 */
public class ClassTableDefinition {
    private Class<?> klass;
    private String table;

    //存放所有有效[成员->列]映射关系的列表
    private List<PropertyColumnDefinition> propertyColumnDefinitions;

    //[成员->列]映射类中成员的下标
    private int fieldIndex;

    //表中的主键映射关系
    private PropertyColumnDefinition key;

    public ClassTableDefinition() {
        this.propertyColumnDefinitions = new ArrayList<>();
        this.fieldIndex = 0;
    }


    public Class<?> getKlass() {
        return klass;
    }

    /**
     * @Author 雫
     * @Description 根据类名称获取对应的Class
     * 这里默认了成员和列的名字相同
     * @Date 2021/1/20 11:33
     * @Param [className]
     * @return void
     **/
    public void setKlass(String className) {

        try {
            this.klass = Class.forName(className);
            Field[] fields = this.klass.getDeclaredFields();

            for(Field field : fields) {
                PropertyColumnDefinition propertyColumnDefinition = new PropertyColumnDefinition();

                /*通过检测成员是否有get方法来检测成员是否有效*/
                String fieldName = field.getName();
                String getterName = "get";

                /*如果成员是boolean类型的需要单独处理,它的get方法是isXXX*/
                if(field.getType().equals(boolean.class)) {
                    getterName = "is";
                }

                /*获取成员的get方法,通过反射机制检测该get方法是否存在,存在证明是有效成员,不存在进异常处理*/
                getterName += fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
                Method method = this.klass.getDeclaredMethod(getterName, new Class[] {});

                /*将有效的成员的[成员->列]映射对象加入映射列表中*/
                propertyColumnDefinition.setProperty(field);
                propertyColumnDefinition.setColumn(fieldName);
                this.propertyColumnDefinitions.add(propertyColumnDefinition);
            }
        } catch (ClassNotFoundException e) {
            System.out.println("未找到对应的类");
        } catch (NoSuchMethodException e) {
            System.out.println("有成员不存在对应的get方法");
        }

    }

    public String getTable() {
        return table;
    }

    public void setTable(String table) {
        this.table = table;
    }

    /**
     * @Author 雫
     * @Description 根据给定的主键名来设置主键映射
     * @Date 2021/1/20 12:48
     * @Param [keyName]
     * @return void
     **/
    public void setkey(String keyName) {
        for(PropertyColumnDefinition propertyColumnDefinition : this.propertyColumnDefinitions) {
            if(propertyColumnDefinition.getProperty().getName().equals(keyName)) {
                this.key = propertyColumnDefinition;
                return;
            }
        }
    }

    /**
     * @Author 雫
     * @Description 获取主键映射
     * @Date 2021/1/20 12:51
     * @Param []
     * @return com.coisini.core.PropertyColumnDefinition
     **/
    public PropertyColumnDefinition getKey() {
        return this.key;
    }

    /**
     * @Author 雫
     * @Description 根据给定的成员名和列名来设置[成员->列]映射关系中的列名
     * 先通过setKlass()进行一次默认设置,再经过setColumnName()的逐个比对设置,让[成员->列]映射达到一致
     * @Date 2021/1/20 12:39
     * @Param [propertyName, columnName]
     * @return void
     **/
    public void setColumnName(String propertyName, String columnName) {
        for(PropertyColumnDefinition propertyColumnDefinition : this.propertyColumnDefinitions) {
            if(propertyColumnDefinition.getProperty().getName().equals(propertyName)) {
                propertyColumnDefinition.setColumn(columnName);
                return;
            }
        }
    }

    /**
     * @Author 雫
     * @Description 根据成员获取表中的列名称
     * 遍历准备好的[成员-列]映射列表,检测每个[成员->列]映射的成员
     * 和目标成员是否一致,一致则返回该成员的列名
     * @Date 2021/1/20 11:49
     * @Param [property]
     * @return java.lang.String
     **/
    public String getColumn(Field property) {
        for(PropertyColumnDefinition propertyColumnDefinition : this.propertyColumnDefinitions) {
            if(propertyColumnDefinition.getProperty().equals(property)) {
                return propertyColumnDefinition.getColumn();
            }
        }
        return null;
    }

    /**
     * @Author 雫
     * @Description 根据fieldIndex查找[成员->列]映射列表中还有没有下一个
     * 如果fieldIndex的值小于[成员->列]的容量则证明还有下一个
     * 搭配next使用
     * @Date 2021/1/20 11:54
     * @Param []
     * @return boolean
     **/
    public boolean hasNext() {
        if(this.fieldIndex < this.propertyColumnDefinitions.size()) {
            return true;
        }
        this.fieldIndex = 0;
        return false;
    }

    /**
     * @Author 雫
     * @Description 根据当前的fieldIndex的值取得对应的[成员->列]映射
     * 搭配hasNext()使用
     * @Date 2021/1/20 11:58
     * @Param []
     * @return java.lang.reflect.Field
     **/
    public PropertyColumnDefinition next() {
        PropertyColumnDefinition propertyColumnDefinition = this.propertyColumnDefinitions.get(this.fieldIndex);
        this.fieldIndex++;

        return propertyColumnDefinition;
    }

}

这个类的目的就是给一个类,通过反射机制取得其中的所有有效成员,将这些有效成员和它们对应的列名存放到[成员->列]映射列表中

(1) setKlass()默认映射和setColumnName()校正映射

setKlass
在这里插入图片描述
在这里插入图片描述

setColumnName
在这里插入图片描述

在这里插入图片描述

(2) 使用hasNext()和next()从[成员->列]映射列表中取得需要的映射

hasNext
在这里插入图片描述
next
在这里插入图片描述
hasNext()和next()的使用方式:
在这里插入图片描述

(3) setKey()和getKey()设置主键

由于要操作数据库,增删查改是必须需要主键的,主键作为一种特殊的映射,作为单独的[成员->列]映射关系存在,主键名称来自于XML文件

setKey
在这里插入图片描述

getKey
在这里插入图片描述

1.4 构建类表映射工厂

有了存储[类->表]映射关系的类,接下来就需要解析XML文件,取得这些映射关系,但先思考这样的一个问题:当我每次想要根据XML文件取得一个类的[类->表]映射关系时,是不是每次都要去解析一次XML文件?

不需要,XML文件是存在于磁盘的,访问内存的速度远超于访问磁盘的速度,那么我们就一次解析完,把所有的[类->表]映射关系存储到一个Map中,把它们从磁盘移动到内存,之后再使用时,直接调用内存中的Map即可

/**
 * @author 雫
 * @date 2021/1/20 - 12:55
 * @function
 * 这个存储键值对的类作用是一旦开始XML文件的解析,我们将解析的结果一次从外存装入到内存
 * 这样下一次解析的时候就避免了再次访问外存,直接访问内存中已经存在的类表池即可,大大加快了程序的速率
 */
public class ClassTableFactory {

    /*键是类名,值是[类->表]映射对象*/
    private static final Map<String, ClassTableDefinition> classTablePool;

    static {
        classTablePool = new HashMap<>();
    }

    public ClassTableFactory() {}

    /**
     * @Author 雫
     * @Description 用来解析XML文件,获取所有的[类->表]映射关系
     * @Date 2021/1/20 15:13
     * @Param [xmlPath]
     * @return void
     **/
    public static void scanClassTableMapping(String xmlPath)
            throws Throwable {

        Document document = AbstractXMLParser.getOneDocument(xmlPath);

        new AbstractXMLParser() {
            @Override
            public void dealElement(Element element, int i) throws Throwable {
                /*解析第一层标签,取得类名和表名,创建一个类表映射,
                通过setKlass()取得[成员->列]映射
                加入[成员->列]映射列表中*/
                String className = element.getAttribute("name");
                String tableName = element.getAttribute("table");

                ClassTableDefinition classTableDefinition = new ClassTableDefinition();
                classTableDefinition.setKlass(className);
                classTableDefinition.setTable(tableName);

                /*解析第二层标签,
                获取成员名和列名,通过setColumnName()校正[成员->列]
                映射中表的列名,避免实体类成员和表的列名名字不一致*/
                new AbstractXMLParser() {
                    @Override
                    public void dealElement(Element element, int i) throws Throwable {
                        String propertyName = element.getAttribute("name");
                        String columnName = element.getAttribute("column");

                        classTableDefinition.setColumnName(propertyName, columnName);
                    }
                }.parseTag(element, "property");


                /*解析第三层标签,
                获取主键名,并设置成[成员->列]映射的主键*/
                new AbstractXMLParser() {
                    @Override
                    public void dealElement(Element element, int i) throws Throwable {
                        String keyName = element.getAttribute("name");

                        classTableDefinition.setkey(keyName);
                    }
                }.parseTag(element, "key");

                //每解析完一个class大标签,将类名和类表映射关系存入映射池
                classTablePool.put(className, classTableDefinition);

            }
        }.parseTag(document, "class");

    }

    /**
     * @Author 雫
     * @Description 根据类名从映射池中取得对应的[类->表]映射
     * @Date 2021/1/20 15:38
     * @Param [className]
     * @return com.coisini.core.ClassTableDefinition
     **/
    public static ClassTableDefinition getClassTable(String className) {
        return classTablePool.get(className);
    }


    /**
     * @Author 雫
     * @Description 重载方法,根据类来获取对应的[类->表]映射
     * @Date 2021/1/20 15:40
     * @Param [klass]
     * @return com.coisini.core.ClassTableDefinition
     **/
    public static ClassTableDefinition getClassTable(Class<?> klass) {
        return classTablePool.get(klass.getName());
    }

}

在这里插入图片描述

1.5 映射测试

通过上面的过程,完成了[类->表]映射池,现在做一个测试,看看能否取得正确的映射关系:
在这里插入图片描述

2 实现ORM工具

2.1 根据properties文件配置用户信息

由于ORM是用于数据库的,对数据库的增删改查必然需要不同的用户及,每个用户有不同的权限,为了自动化获取与数据库的连接,决定解析properties文件来配置用户信息

properties文件:
在这里插入图片描述
自动解析properties文件获取连接的过程:
在这里插入图片描述
通过调用静态的getConnnection方法,就可以建立与MySQL的连接,且获取的连接由properties配置文件决定,可以提前通过GRANT给各个用户赋予不同的权限

2.2 根据[类->表]映射关系设计泛用的数据库访问方法

设计ORM的目的就是为了自动地,省时不费力地生成想要地SQL语句并执行,那回到ORM的目的:根据某个类,自动创建并执行一个SQL语句,最终直接生成对应类的对象,也就是将由类得到SQL语句,由SQL执行结果得到对象的过程完全自动化

也就是说我们要面对很多的不同的类,这些类在数据库中有对应的表,想要对不同的表进行增删改查只需要一个方法就能完成,那么就需要设计泛用的增删改查方法

为了得到这个泛用的数据库访问方法,首先要考虑参数和返回值

由于需要处理的类是不会提前知道的,所以需要采用不确定的方式来处理,即使用泛型和Object

2.3 插入自动化

(1) 泛用INSERT INTO语句设计

在这里插入图片描述

(2) 插入处理过程

以插入一条记录为例:

	 /**
     * @Author 雫
     * @Description 自动向表中插入object对象的方法
     * @Date 2021/1/21 10:21
     * @Param [object]
     * @return int
     **/
    public <T> int save(T object) throws SQLException, ClassNotFoundException {
        Class<?> klass = object.getClass();
        ClassTableDefinition ctd = ClassTableFactory.getClassTable(klass);

        /*构建插入的SQL语句*/
        String tableName =ctd.getTable();

        StringBuffer sql = new StringBuffer();
        sql.append("INSERT INTO ").append(tableName)
                .append("(");

        int columnCount = 0;
        while (ctd.hasNext()) {
            PropertyColumnDefinition pcd = ctd.next();
            sql.append(columnCount == 0 ? "" : ", ");
            sql.append(tableName).append(".").append(pcd.getColumn());
            columnCount++;
        }
        sql.append(") VALUE(");

        for(int i = 0; i < columnCount; i++) {
            sql.append(i == 0 ? "" : ", ")
                    .append("?");
        }
        sql.append(")");
        PreparedStatement preparedStatement = getConnection().prepareStatement(sql.toString());

        //寻找sql语句中的 "?" 以便之后找到值后用值替换 "?"
        int index = 1;

        /*通过传进来的参数对象object,通过get方法找到它的各成员的值,并将这些值替换掉SQL语句中
        * 待定的值并执行SQL语句,返回更改的记录的条数*/
        while(ctd.hasNext()) {
            /*依次取得[成员->列]映射对象,得到Field类型的成员*/
            PropertyColumnDefinition pcd = ctd.next();
            Field property = pcd.getProperty();

            /*构建get方法,有了Field类型的成员就可以通过field.getType()来获取该成员的类型*/
            String fieldName = property.getName();
            String methodName = (property.getType().equals(boolean.class) ? "is" : "get") +
                                    fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
            try {
                /*找到对应的无参get方法,通过执行get方法得到参数对象object中成员的值*/
                Method method = klass.getDeclaredMethod(methodName, new Class[] {});
                Object propertyValue = method.invoke(object);

                /*通过下标将sql语句中的?替换成列对应的值*/
                preparedStatement.setObject(index, propertyValue);
                index++;
            } catch (NoSuchMethodException | IllegalAccessException e) {
            } catch (InvocationTargetException e) {
            }
        }

        return preparedStatement.executeUpdate();
    }

2.4 删除自动化

	/**
     * @Author 雫
     * @Description 根据给的实体类和想要删除的行的主键值删除表中指定行
     * @Date 2021/1/31 16:16
     * @Param [klass, id]
     * @return int
     **/
    public <T> int remove(Class<?> klass, T id) throws SQLException, ClassNotFoundException {
        ClassTableDefinition ctd = ClassTableFactory.getOneCTD(klass);

        StringBuffer sql = new StringBuffer();
        sql.append("DELETE FROM ").append(ctd.getTable())
                .append(" WHERE ").append(ctd.getKey().getColumn()).append(" = ").append("?");

        PreparedStatement preparedStatement = getConnection().prepareStatement(String.valueOf(sql));
        preparedStatement.setObject(1, id);

        return preparedStatement.executeUpdate();
    }

2.5 更新自动化

	/**
     * @Author 雫
     * @Description 根据给定的实体类和想要更新的行的新对象以及主键来修改表中指定行
     * @Date 2021/1/31 17:04
     * @Param [klass, object, id]
     * @return int
     **/
    public <T> int update(Class<?> klass, T object, T id)
            throws NoSuchMethodException, SQLException,
            ClassNotFoundException, InvocationTargetException, IllegalAccessException {

        ClassTableDefinition ctd = ClassTableFactory.getOneCTD(klass);

        /*构建sql语句需要把握列的个数*/
        int columnCount = 0;
        while (ctd.hasNextPCD()) {
            PropertyColumnDefinition pcd = ctd.nextPCD();
            columnCount++;
        }

        StringBuffer sql = new StringBuffer();
        sql.append("UPDATE ").append(ctd.getTable()).append(" SET ");

        int count = 1;
        while (ctd.hasNextPCD()) {
            PropertyColumnDefinition pcd = ctd.nextPCD();
            if(count < columnCount) {
                sql.append(pcd.getColumn() + " = ").append("?").append(", ");
            } else {
                sql.append(pcd.getColumn() + " = ").append("?");
            }
            count++;
        }
        sql.append(" WHERE ").append(ctd.getKey().getColumn()).append(" = ").append(id);
        PreparedStatement preparedStatement = getConnection().prepareStatement(String.valueOf(sql));

        /*找到每个成员的get方法并执行,将结果依次替换sql语句中的?*/
        int t = 1;
        while(ctd.hasNextPCD()) {
            PropertyColumnDefinition pcd = ctd.nextPCD();
            Field field = pcd.getProperty();

            String getterHead = "";
            if(field.getType() == boolean.class) {
                getterHead = "is";
            } else {
                getterHead = "get";
            }

            String methodName = getterHead
                    + field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1);

            Method method = klass.getDeclaredMethod(methodName, new Class<?>[] {});
            Object value = method.invoke(object, new Object[] {});
            preparedStatement.setObject(t++, value);
        }

        return preparedStatement.executeUpdate();
    }

2.6 查询自动化

(1) 泛用SELECT语句设计

在这里插入图片描述

(2) 查询一条记录处理过程

这里以查询一条记录为例:

	/**
     * @Author 雫
     * @Description 自动查询主键为id记录的方法
     * 根据提供的Class,根据XML文件中配置的信息找到它的[类->表]映射关系,根据主键id来自动创建SELECT语句
     * 为了保证它的通用性,返回值选择了Object
     * @Date 2021/1/20 22:16
     * @Param [klass, id]
     * @return T
     **/
    public <T> T get(Class<?> klass, Object id) {
        Object result = null;

        /*根据klass获取类名,从而通过[类->表]映射池找到对应的[类->表]映射*/
        String className = klass.getName();
        ClassTableDefinition ctd = ClassTableFactory.getClassTable(className);

        /*根据[类->表]映射取得表名和主键名*/
        String tableName = ctd.getTable();
        String keyName = tableName + "." + ctd.getKey().getColumn();

        /*通过[类->表]映射取得该表的所有列,注意第除了第一个列外剩下每个列后面跟一个","*/
        StringBuffer columnList = new StringBuffer();
        boolean first = true;
        while (ctd.hasNext()) {
            PropertyColumnDefinition pcd = ctd.next();
            columnList.append(first ? "" : ", ");
            columnList.append(tableName).append(".").append(pcd.getColumn());
            first = false;
        }

        /*通过所取得的映射值自动生成SQL语句*/
        StringBuffer sql = new StringBuffer();
        sql.append("SELECT ").append(columnList)
                .append(" FROM ").append(tableName)
                .append(" WHERE ").append(keyName).append(" = ?");
       

        try {
            /*执行自动生成的SQL语句,这里只查询一条记录则只生成一个只含只含一个元素的结果集*/
            PreparedStatement preparedStatement = getConnection().prepareStatement(sql.toString());

            //setObject()方法将sql语句中的?替换成id,且自动转换成符合sql语句规范的格式
            preparedStatement.setObject(1, id);
            ResultSet resultSet = preparedStatement.executeQuery();

            /*遍历结果集,通过反射机制生成最终返回的<T>类型对象*/
            if(resultSet.next()) {

                //先生成一个无参构造生成的目标类对象
                result = klass.newInstance();

                /*遍历[类->表]映射中的[成员->列]映射列表,取出所有列,通过列名取得结果集中对应的成员*/
                while(ctd.hasNext()) {
                    PropertyColumnDefinition pc = ctd.next();
                    String column = pc.getColumn();
                    //根据列名来获取结果集中该列的值并得到这个值准备set给类的成员,
                    //但由于列对应的成员的类型不明,所以采用Object接收
                    Object property = resultSet.getObject(column);

                    /*根据[成员->列]映射关系取得真正的成员,通过set方法给该成员赋值
                    这里先找成员名,通过成员名找到该成员在元数据中的单参set方法,
                    找set方法需要方法名和参数个数,参数类型,
                    set是单参的方法,这里可以通过成员的类型来作为set方法需要的单参类型数组*/
                    Field field = pc.getProperty();
                    String fieldName = field.getName();

                    String methodName = "set" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
                    Method method = klass.getDeclaredMethod(methodName, new Class<?>[] {field.getType()});
                    method.invoke(result, new Object[] {property});
                }
            }
        } catch (SQLException | ClassNotFoundException |
                IllegalAccessException | InstantiationException |
                InvocationTargetException | NoSuchMethodException throwables) {
        }

        return (T) result;
    }

(3) 查询表中所有记录处理过程

/**
     * @Author 雫
     * @Description 根据所给的类查询对应表中所有行,并以列表的形式返回
     * @Date 2021/1/31 18:19
     * @Param [klass]
     * @return java.util.List<T>
     **/
    public <T> List<T> getAll(Class<?> klass) throws SQLException, ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
        ClassTableDefinition ctd = ClassTableFactory.getOneCTD(klass);

        StringBuffer sql = new StringBuffer();
        sql.append("SELECT ").append("*").append(" FROM ").append(ctd.getTable());

        PreparedStatement preparedStatement = getConnection().prepareStatement(String.valueOf(sql));
        ResultSet rs = preparedStatement.executeQuery();

        List<Object> resultList = new ArrayList<>();

        /*遍历结果集,每行定义一个对象,通过找到每个PCD的成员的set方法
         * 并从结果集中取出列名对应的值,将值set到每个行预先定义好的对象中
         * 每次set完后,将该对象加入列表中*/
        while (rs.next()) {
            Object result = klass.newInstance();
            /*一行对应一个对象,一张表如果是3行7列,那么这里需要生产3个对象,
            * 每个对象需要执行7次set方法, 之后才能将该对象加入队列中并返回*/
            while(ctd.hasNextPCD()) {
                PropertyColumnDefinition pcd = ctd.nextPCD();
                Field field = pcd.getProperty();

                String column = pcd.getColumn();
                Object value = rs.getObject(column);

                String methodName = "set"
                        + field.getName().substring(0, 1).toUpperCase() + field.getName().substring(1);
                Method method = klass.getDeclaredMethod(methodName, new Class<?>[] {field.getType()});

                method.invoke(result, new Object[] {value});
            }
            resultList.add(result);
        }

        return (List<T>) resultList;
    }

2.7 关于参数和返回值选择泛型还是Object

(1) Object和泛型的比较

自动插入一条记录:
public <T> int save(T object);
自动查询一条记录:
public <T> T get(Class<?> klass, Object id);

可以看到这两个通用的“面向未来”的方法每一个参数和返回值都是不明确的,不明确的自然要不明确的解决,即使用泛型或Object

但使用泛型和使用Object的区别是什么?
在JSE1.5之前,并没有出现泛型,当时只有Object,通过Object来作为实参类型或返回值类型来完善不确定的方法,使用Object,需要开发者准确地知道类型,以便之后处理时进行强制类型转换,对于强制类型转换带来的错误,编译器可能不提示错误,运行时才报错,这是非常大的安全隐患

使用泛型的好处是在编译的时候就进行安全检查,并且所有的强制类型转换都是自动和隐式的,即你不需要自己进行强制类型转换

(2) 测试Object和泛型接收对象

一个提供了两个方法的类,一个方法接收Object类对象返回Object类对象,一个方法接收泛型对象,返回泛型对象
在这里插入图片描述
对这两个方法的引用:
在这里插入图片描述
更改后:
在这里插入图片描述

结论:
Object必须使用强制类型转换,需要手动将结果转换成接收对象的类型

泛型不需要手动进行强制类型转换,因为泛型已经帮你检查过了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值