JFinal个人学习笔记之源码分析1

3 篇文章 0 订阅
源码解读
启动项目
    /**
     * 建议使用 JFinal 手册推荐的方式启动项目
     * 运行此 main 方法可以启动项目,此main方法可以放置在任意的Class类定义中,不一定要放于此
     */
    public static void main(String[] args) {
        JFinal.start("WebRoot", 81, "/", 5);
    }

web.xml 文件

<filter>
        <filter-name>jfinal</filter-name>
        <filter-class>com.jfinal.core.JFinalFilter</filter-class>
<init-param>
    <param-name>configClass</param-name>
    <param-value>com.demo.common.config.DemoConfig</param-value>
        </init-param>
</filter>

    <filter-mapping>
        <filter-name>jfinal</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
启动项目后,
1)首先,它会根据web.xml。文件中的配置找到JFinal的过滤器
com.jfinal.core.JFinalFilter。
2)之后,就到了该文件中的init()初始化方法中。
createJFinalConfig(filterConfig.getInitParameter("configClass"));

        if (jfinal.init(jfinalConfig, filterConfig.getServletContext()) == false)
            throw new RuntimeException("JFinal init error!");

        handler = jfinal.getHandler();
        constants = Config.getConstants();
        encoding = constants.getEncoding();
        jfinalConfig.afterJFinalStart();

        String contextPath = filterConfig.getServletContext().getContextPath();
        contextPathLength = (contextPath == null || "/".equals(contextPath) ? 0 : contextPath.length());
createJFinalConfig方法会利用反射创建在web.xml配置中的自定义配
置类com.demo.common.config.DemoConfig的实例。
3)之后,调用`jfinal.init(jfinalConfig,filterConfig.getServletContext())`
该方法两个参数,一个是刚刚初始化的config实例,
另一个是过滤器`filterConfig的上下文`。

4)我们在进入jfinal.init方法里看看
boolean init(JFinalConfig jfinalConfig, ServletContext servletContext) {
        this.servletContext = servletContext;
        this.contextPath = servletContext.getContextPath();

        initPathUtil();

        Config.configJFinal(jfinalConfig);  // start plugin and init log factory in this method
        constants = Config.getConstants();

        initActionMapping();
        initHandler();
        initRender();
        initOreillyCos();
        initTokenManager();

        return true;
    }
这时我们就到了jfinal核心包中的jFinal类中init方法。在该类中我们(要特别注意,`private static final JFinal me = new JFinal();`
这里的me就是JFinal的实例。)init方法中:
①initPathUtil()
private void initPathUtil() {
    String path = servletContext.getRealPath("/");
    PathKit.setWebRootPath(path);
}
这里的path拿到的是`J:\javaproject\workspace\jfinal_demo\WebRoot`, 
也就是我们项目的根目录。PathKit.setWebRootPath(path);
就是设置项目的根路径(给PathKit.webRootPath赋值)并且是去掉末尾
的/。
5)Config.configJFinal(jfinalConfig);官方的解释是:`start plugin and init log factory in this method`。源码是
static void configJFinal(JFinalConfig jfinalConfig) {
        jfinalConfig.configConstant(constants);         
        initLogFactory();
        jfinalConfig.configRoute(routes);
        jfinalConfig.configPlugin(plugins);                     
        startPlugins(); // very important!!!
        jfinalConfig.configInterceptor(interceptors);
        jfinalConfig.configHandler(handlers);
    }
这里
①首先是初始化jfinal的常量:`jfinalConfig.configConstant(constants)`, 
这里要知道jfinalConfig就是web.xml中的DemoConfig类。 
也就是我们自定义的配置文件,该类继承了JFinalConfig。 
而这里调用的configCoonstant方法就是我们在DemoConfig.java类中的方法。 
在jfinal给的demo中的代码
        // 加载少量必要配置,随后可用PropKit.get(...)获取值
        PropKit.use("a_little_config.txt");
        me.setDevMode(PropKit.getBoolean("devMode", false));
这里PropKit.use()这个方法会加载a_little_config.txt这个文件。
它的底层代码是:
    public static Prop use(String fileName, String encoding) {
        Prop result = map.get(fileName);
        if (result == null) {
            result = new Prop(fileName, encoding);
            map.put(fileName, result);
            if (PropKit.prop == null)
                PropKit.prop = result;
        }
        return result;
    }
看到result = new Prop(fileName, encoding);
其中Prop()源码:
public Prop(String fileName, String encoding) {
    InputStream inputStream = null;
    try {
        inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName);     // properties.load(Prop.class.getResourceAsStream(fileName));
        if (inputStream == null)
            throw new IllegalArgumentException("Properties file not found in classpath: " + fileName);
        properties = new Properties();
        properties.load(new InputStreamReader(inputStream, encoding));
    } catch (IOException e) {
        throw new RuntimeException("Error loading properties file.", e);
    }
    finally {
        if (inputStream != null) try {inputStream.close();} catch (IOException e) {LogKit.error(e.getMessage(), e);}
    }
}
上面这段源码中使用了`Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName)`把a_little_config.txt文件加载进来!
其中`Thread.currentThread().getContextClassLoader()`是获得当前线程的类加载器ClassLoader。
这里要说下Thread.currentThread().getContextClassLoader() 和 Class.getClassLoader()区别

参考地址:

http://blog.csdn.net/AlbertFly/article/details/52162253

大概的意思是Class.getClassLoader得到的类加载器是静态的,表明类的载入者是谁。 
而另一个Classloader是动态的,谁执行(某个线程),就是那个执行者的Classloader。 
对于单例模式的类,静态类等,载入一次后,这个实例会被很多程序(线程)调用,对于这些类,载入的Classloader和执行线程的Classloader通常都不同。

回到正题:把txt文件加载进来为输入流后,再调用java的`properties.load(new InputStreamReader(inputStream, encoding));`来读取文件。
之后回到PropKit.use()方法中`map.put(fileName, result);`把结果保存到map集合中。
这里的map是ConcurrentHashMap。ConcurrentHashMap优势是有利于高并发并且线程安全,内部使用的是数组和链表。具体详见:

ConcurrentHashMap详解>
http://wiki.jikexueyuan.com/project/java-collection/concurrenthashmap.html

最后把result赋值给`PropKit.prop = result`,至此,配置文件就读
取完毕。
me.setDevMode(PropKit.getBoolean("devMode", false));
这句话,就是先从刚刚读取的配置文件a_little_config.txt中,获取devMode,默认false。
之后在设置给Constants对象。至此常量设置完成,并且完成了读取配置文件。
②调用initLogFactory方法初始化日志
private static void initLogFactory() {
        LogManager.me().init();
        log = Log.getLog(Config.class);
        JFinalFilter.initLog();
    }
    static void init() {
        if (defaultLogFactory == null) {
            try {
                Class.forName("org.apache.log4j.Logger");
                Class<?> log4jLogFactoryClass = Class.forName("com.jfinal.log.Log4jLogFactory");
                defaultLogFactory = (ILogFactory)log4jLogFactoryClass.newInstance();    // return new Log4jLogFactory();
            } catch (Exception e) {
                defaultLogFactory = new JdkLogFactory();
            }
        }
    }
虽然这里是jfinal自己封装的类,但是它自己底层依然是使用的是apache的log4j包。
`log = Log.getLog(Config.class);`获取config日志记录器,如果没有就创建一个,名字就为类名。
`JFinalFilter.initLog();`获取jfinal日志记录器。关于日志记录器参考链接:

http://scau-fly.iteye.com/blog/2128492

  • jfinalConfig.configRoute(routes);讲解
    这里回去调DemoConfig中的configRoute(Routes me)方法。
    其中参数routes是在Config类中new出来的:
private static final Routes routes = new Routes(){public void config() {}};

在configRoute方法中有两个方法
me.add("/", IndexController.class, "/index");
me.add("/blog", BlogController.class);

它们这里add的方法调用的是Routes中的,源码如下:

public Routes add(String controllerKey, Class<? extends Controller> controllerClass, String viewPath) {
        if (controllerKey == null)
            throw new IllegalArgumentException("The controllerKey can not be null");
        controllerKey = controllerKey.trim();
        if ("".equals(controllerKey))
            throw new IllegalArgumentException("The controllerKey can not be blank");
        if (controllerClass == null)
            throw new IllegalArgumentException("The controllerClass can not be null");
        if (!controllerKey.startsWith("/"))
            controllerKey = "/" + controllerKey;
        if (map.containsKey(controllerKey))
            throw new IllegalArgumentException("The controllerKey already exists: " + controllerKey);

        map.put(controllerKey, controllerClass);
        // view path is controllerKey by default
        if (viewPath == null || "".equals(viewPath.trim())) 
            viewPath = controllerKey;

        viewPath = viewPath.trim();
        // "/" added to prefix
        if (!viewPath.startsWith("/"))                  
            viewPath = "/" + viewPath;
        // "/" added to postfix
        if (!viewPath.endsWith("/"))                    
            viewPath = viewPath + "/";
        // support baseViewPath
        if (baseViewPath != null)                       
            viewPath = baseViewPath + viewPath;

        viewPathMap.put(controllerKey, viewPath);
        return this;
    }

上面这段源码的作用就是把访问资源的路径controllerKey和相应的controllerClass保存到map中去,该map是Routes类的私有变量。
并且泛型是Map<String, Class<? extends Controller>>
这里的?是因为不知道类的名称,只知道他继承了Controller类。
这个add方法还对资源路径进行了处理。假设我们在配置路由是没有 写/,如me.add("blog", BlogController.class);,它会自动帮你 加上,即保存时controllerKey为/blog,这里我们没有写第三个参数,它也会帮你处理,默认等于controllerKey,还会帮你加上/,也就是viewPath为/blog/,最终viewPathMap.put("/blog", "/blog/") map.put("/blog", "class com.demo.blog.BlogController");
这样Routes初始化完毕。注意:这里Routes定义了baseViewPath,也就是它允许你对访问路径前再加上一个基础路径。

  • jfinalConfig.configPlugin(plugins);详解

它会调用DemoConfig类中的configPlugin(Plugins me)方法。

public void configPlugin(Plugins me) {
    // 配置C3p0数据库连接池插件
    C3p0Plugin C3p0Plugin = createC3p0Plugin();
    me.add(C3p0Plugin);

    // 配置ActiveRecord插件
    ActiveRecordPlugin arp = new ActiveRecordPlugin(C3p0Plugin);
    me.add(arp);

    // 所有配置在 MappingKit 中搞定
    _MappingKit.mapping(arp);
    }

这里用到的成c3p0创建时jfinal集合的,createC3p0Plugin() 方法就是创建C3p0Plugin对象,并且初始化好了jdbcurl,user,password.该对象对应的类实现了IPlugin接口。me.add(C3p0Plugin); 该方法就是把刚刚创建好的C3p0Plugin对象放到一个pluginList数组里面去,该数组是Plugins类的一个私有属性。Plugins类时被final修饰的。不能被继承类似于String。
ActiveRecordPlugin arp = new ActiveRecordPlugin(C3p0Plugin); 底层嵌套掉好好几次。最终结果就是给ActiveRecordPlugin类的三个属性(configName、dataSourceProvider、transactionLevel)赋值。configName赋的默认值“main”,dataSourceProvider是传进来的c3p0,transactionLevel事务级别为4。
注意:transaction level define in java.sql.Connection 目前我自己有待研究;到了这里我们也就知道ActiveRecordPlugin是用来和数据库打交道的。me.add(arp); 会把ActiveRecordPlugin也加入到pluginList 数组中。
_MappingKit.mapping(arp); 该方法是在model包里写的方法。目的是为了建立数据库表字段与model的对应关系。而又由于jfinal使用ActiveRecordPlugin来和数据库打交道。所以传递的参数也是该类的实例。里面源码:

arp.addMapping("blog", "id", Blog.class);

可以看出,它有调用了ActiveRecordPlugin类的addMapping方法。继续看addMapping源码:

public ActiveRecordPlugin addMapping(String tableName, 
            String primaryKey, 
            Class<? extends Model<?>> modelClass) {

    tableList.add(new Table(tableName, 
                            primaryKey, 
                            modelClass));
        return this;
    }

可以看出先创建了一个table类并初始化了表名、主键、模型Class,关于table类,官方的解释是Table save the table meta info like column name and column type. 意思就是说,Table是存储数据库表的元信息,比如字段名和字段类型。
之后再把该Table添加到tableList里面。至此创建配置完毕。

  • startPlugins() 启用插件

startPlugins()方法里就是去遍历之前的pluginList。然后去调取他们的start()方法。其中如果是ActiveRecordPlugin插件,它会特别处理。
我们先来看看start方法。start方法是各个插件自己实现类的方法。比如c3p0插件的方法源码:

public boolean start() {
        if (isStarted)//默认为false
            return true;

        dataSource = new ComboPooledDataSource();
        dataSource.setJdbcUrl(jdbcUrl);
        dataSource.setUser(user);
        dataSource.setPassword(password);
        try {dataSource.setDriverClass(driverClass);}
        catch (PropertyVetoException e) {dataSource = null; System.err.println("C3p0Plugin start error"); throw new RuntimeException(e);} 
        dataSource.setMaxPoolSize(maxPoolSize);
        dataSource.setMinPoolSize(minPoolSize);
        dataSource.setInitialPoolSize(initialPoolSize);
        dataSource.setMaxIdleTime(maxIdleTime);
        dataSource.setAcquireIncrement(acquireIncrement);

        isStarted = true;
        return true;
    }

从源码中可以看出,插件默认是没有开启的。dataSource = new ComboPooledDataSource(); 拿到了数据库连接池的对象。dataSource.setJdbcUrl(jdbcUrl);
dataSource.setUser(user);
dataSource.setPassword(password);
给连接池配置jdbcurl和账号密码。dataSource.setDriverClass(driverClass);这里是配置连接数据库驱动,默认是com.mysql.jdbc.Driver。其他的一些配置dataSource.setMaxPoolSize(maxPoolSize);//最大连接数
dataSource.setMinPoolSize(minPoolSize);//最小连接数
dataSource.setInitialPoolSize(initialPoolSize);//初始化大小
dataSource.setMaxIdleTime(maxIdleTime);//连接存活时间
dataSource.setAcquireIncrement(acquireIncrement);//如果使用的连接数已经达到了maxPoolSize,c3p0会立即建立新的连接。

参考地址>http://hanqunfeng.iteye.com/blog/1671412
至此,插件启动完毕。我们也可以看出,所谓启动插件就是创建相应的对象,并且配置好基本参数,以供使用。
注意:在ActiveRecordPlugin 中官方有这么一段注释。ActiveRecord plugin not support mysql type year, you can use int instead of year. 意思是在使用mysql数据库时,建议字段不要使用year类型,因为 jdbc 对于 mysql 的 year 类型处理比较诡异。详见:
jfinal作者解释> http://www.oschina.net/question/924074_2176656

  • ActiveRecordPlugin的start()源码:
    public boolean start() {
        if (isStarted) {
            return true;
        }
        if (configName == null) {
            configName = DbKit.MAIN_CONFIG_NAME;
        }
        if (dataSource == null && dataSourceProvider != null) {
            dataSource = dataSourceProvider.getDataSource();
        }
        if (dataSource == null) {
            throw new RuntimeException("ActiveRecord start error: ActiveRecordPlugin need DataSource or DataSourceProvider");
        }
        if (config == null) {
            config = new Config(configName, dataSource);
        }

        if (dialect != null) {
            config.dialect = dialect;
        }
        if (showSql != null) {
            config.showSql = showSql;
        }
        if (devMode != null) {
            config.devMode = devMode;
        }
        if (transactionLevel != null) {
            config.transactionLevel = transactionLevel;
        }
        if (containerFactory != null) {
            config.containerFactory = containerFactory;
        }
        if (cache != null) {
            config.cache = cache;
        }

        new TableBuilder().build(tableList, config);
        DbKit.addConfig(config);
        Db.init();
        isStarted = true;
        return true;
    }
> 因为之前都配置好了configName、dataSource。这里我们直接看到`config = new Config(configName, dataSource);` 这个方法嵌套调用了很多方法。我们慢慢分析:①嵌套进入:
    public Config(String name, DataSource dataSource) {
        this(name, dataSource, new MysqlDialect());
    }

这里new 了一个MysqlDialect对象。是jfinal自己定义的。其中构造方法是java默认的构造方法,之后我们再进入this()方法:

public Config(String name, DataSource dataSource, Dialect dialect) {
        this(name, dataSource, dialect, false, false, DbKit.DEFAULT_TRANSACTION_LEVEL, IContainerFactory.defaultContainerFactory, new EhCache());
    }

这里除了之前的三个参数(configname,datasource,dialect),加入了事务隔离级别、容器工厂、缓存。这里缓存EhCache底层其实用的是redis中的java版jedis。最后他们调用的是Config的一个构造方法。用来初始化事务、容器工厂、缓存等参数。回到start方法。虽然config = new Config(configName, dataSource);之后大部分都为null,但是在new Config这一步已经把它们都初始化过了。它之所以写很多if判断,是出于我们可以人工配置,不使用默认的 这种情况考虑的。

  • new TableBuilder().build(tableList, config);
void build(List<Table> tableList, Config config) {
        if (tableList.size() == 0) {
            return ;
        }

        Table temp = null;
        Connection conn = null;
        try {
            conn = config.dataSource.getConnection();
            TableMapping tableMapping = TableMapping.me();
            for (Table table : tableList) {
                temp = table;
                doBuild(table, conn, config);
                tableMapping.putTable(table);
                DbKit.addModelToConfigMapping(table.getModelClass(), config);
            }
        } catch (Exception e) {
            if (temp != null) {
                System.err.println("Can not create Table object, maybe the table " + temp.getName() + " is not exists.");
            }
            throw new ActiveRecordException(e);
        }
        finally {
            config.close(conn);
        }
    }
这个方法第一个参数tableList的值 是model包下的_MappingKit中`arp.addMapping("blog", "id", Blog.class);`方法会给tableList进行赋值。回到build方法。这里回去遍历tableList。在for循环里面还有`doBuild(table, conn, config);` 方法。源码:
private void doBuild(Table table, Connection conn, Config config) throws SQLException {
        table.setColumnTypeMap(config.containerFactory.getAttrsMap());
        if (table.getPrimaryKey() == null) {
            table.setPrimaryKey(config.dialect.getDefaultPrimaryKey());
        }

        String sql = config.dialect.forTableBuilderDoBuild(table.getName());
        Statement stm = conn.createStatement();
        ResultSet rs = stm.executeQuery(sql);
        ResultSetMetaData rsmd = rs.getMetaData();

        for (int i=1; i<=rsmd.getColumnCount(); i++) {
            String colName = rsmd.getColumnName(i);
            String colClassName = rsmd.getColumnClassName(i);

            Class<?> clazz = javaType.getType(colClassName);
            if (clazz != null) {
                table.setColumnType(colName, clazz);
            }
            else {
                int type = rsmd.getColumnType(i);
                if (type == Types.BINARY || type == Types.VARBINARY || type == Types.BLOB) {
                    table.setColumnType(colName, byte[].class);
                }
                else if (type == Types.CLOB || type == Types.NCLOB) {
                    table.setColumnType(colName, String.class);
                }
                else {
                    table.setColumnType(colName, String.class);
                }
                // core.TypeConverter
                // throw new RuntimeException("You've got new type to mapping. Please add code in " + TableBuilder.class.getName() + ". The ColumnClassName can't be mapped: " + colClassName);
            }
        }

        rs.close();
        stm.close();
    }
}
这里首先获取到一个空map`table.setColumnTypeMap(config.containerFactory.getAttrsMap());`之后是设个表的主键,要是没有指定默认为id。
之后`config.dialect.forTableBuilderDoBuild(table.getName());` 是为了获取表结构,返回的是sql语句。里面的select语句:`"select * from `" + tableName + "` where 1 = 2";` 其中`where 1=2` 是人为的设置了一个假的条件,使得结果集为了。这条sql最后得到的就是表的元数据。也就是表的字段。
之后`Statement stm = conn.createStatement();
    ResultSet rs = stm.executeQuery(sql);` 
    这是jdbc的基础,先拿到声明在去执行,rs就是结果集。
之后`ResultSetMetaData rsmd = rs.getMetaData();` 
这是得到了得到结果集(rs)的结构信息,比如字段数、字段名等。

getMetaData> https://zhidao.baidu.com/question/25316052.html

接上面之后就是for循环,这个循环的目的就是为了使得表中字段与java类型进行对应,对应关系存储在map里面。其中`rsmd.getColumnCount()` 获得字段总数。`rsmd.getColumnName(i)`获得相应的字段名。`getColumnName` 获得相应的数据库里的字段类型。
这里完成的是table表字段名与java类型的对应关系。

我们现在回到build方法中的`tableMapping.putTable(table);`它的源码:
public void putTable(Table table) {
        modelToTableMap.put(table.getModelClass(), table);
    }
这里把java类中相应表的model的Class与表table对应关系保存在TableMapping中的modelToTableMap中。
该modelToTableMap结构为`Map<Class<? extends Model<?>>, Table>`。

接着回到build中`DbKit.addModelToConfigMapping(table.getModelClass(), config);` 源码:
static void addModelToConfigMapping(Class<? extends Model> modelClass, Config config) {
        modelToConfig.put(modelClass, config);
    }
这里是把java类中相应表的model的Class和相应的config配置关系保存
在DbKit中的modelToConfig中。`modelToConfig`的结果是`Map<Class<? extends Model>, Config>` 。

我们在回到build中,循环执行完毕后,会执行finally中的`config.close(conn);` 而源码:
public final void close(Connection conn) {
        // in transaction if conn in threadlocal
        if (threadLocal.get() == null)      
            if (conn != null)
                try {conn.close();} catch (SQLException e) {throw new ActiveRecordException(e);}
    }
这里我们注意到`if (threadLocal.get() == null)` 官方也写了注释。
意思是如果threadlocal在事务中还有conn连接。
至此,`new TableBuilder().build(tableList, config);`方法执行完毕。

我们再回到ActiveRecordPlugin中的start方法。`DbKit.addConfig(config);` 
该方法目的是为了把config与configName的对应关系保存到DbKit中的`configNameToConfig`。
其结构为`Map<String, Config>`

我们再回到ActiveRecordPlugin中的start方法。
Db.init();
源码是:
static void init() {
        dbPro = DbPro.use();
    }
其中use方法里面调用的是`use(DbKit.config.name);`而该方法就是把,configName与DbPro对应关系存入到map中。该map是DbPro中全局变量,结构为`Map<String, DbPro>`,而创建DbPro时,回去DbKit中去获取ActiveRecordPlugin的config,这样configName与activerecord的DbPro对应关系也联系起来了。
至此,ActiveRecordPlugin的start方法执行完毕。

我们再回到Config类中的startPlugins方法的for循环中,在pluginList数组中的最后一个ActiveRecordPlugin遍历完毕后,该方法也就执行完毕。
  • jfinalConfig.configInterceptor(interceptors);

    这里会调用DemoConfig.java配置文件中的configInterceptor方法。
    由于这里没有配置自定义的拦截器。所以暂不深究。
    
  • jfinalConfig.configHandler(handlers);

    这里会调用DemoConfig.java配置文件中的configInterceptor方法。
    由于这里没有配置自定义的处理器。所以暂不深究。
    

    至此,Config类中的configJFinal方法执行完毕。

    我们回到JFianl类中,接着执行constants = Config.getConstants(); 方法。
    该方法就是为了的之前初始化好的常量。

    接着看到下面代码:

        initActionMapping();
        initHandler();
        initRender();
        initOreillyCos();
        initTokenManager();
  • initActionMapping();方法源码:
private void initActionMapping() {
        actionMapping = new ActionMapping(Config.getRoutes(), Config.getInterceptors());
        actionMapping.buildActionMapping();
        Config.getRoutes().clear();
    }
上面代码中先`new`一个ActionMapping.执行的构造方法就是给`ActionMapping` 类的routes属性初始化。虽然也传入了`Interceptors`但是源码注释了相关的赋值语句。
接着执行`actionMapping.buildActionMapping();`方法。
由于篇幅原因,我另写一篇,接着写。
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

山鬼谣me

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

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

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

打赏作者

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

抵扣说明:

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

余额充值