前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >如何使用calcite rule做SQL重写(上)

如何使用calcite rule做SQL重写(上)

作者头像
麒思妙想
发布于 2023-08-28 07:37:49
发布于 2023-08-28 07:37:49
1.8K00
代码可运行
举报
文章被收录于专栏:麒思妙想麒思妙想
运行总次数:0
代码可运行

各位读者朋友,我想死你们了,今天我带着 calcite这个专题的第三篇文章来了,今天我们来说说sql重写,这可能也是大家都有需求的方面,我计划这个专题分为三篇来写:

  • 上篇介绍 calcite 自带的 rule 做sql重写
  • 下篇介绍如何自定义 rule 来实现rewrite sql
  • 第三篇作为番外,不限于calcite,泛化倒使用 AST + Vistor,来完成真正意义上的SQL语句重写。那么我们就开始吧!Let's go!!!

对于 rewrite sql 这个需求,大家都会有各自得需求,从我的角度来看,主要分为:

  • 对象改写 简单的例如对Sql对象的替换 select a.firstname || a.lastname from a 作为输入,实际查询 select concat(b.first,b.last) from b
  • 语法转换 同源语义,但是由于数据库方言限制,select top 10 * from a 转换成 select * from a limit 10
  • 性能优化 一般会伴随语义和语法的转换,这里我们做等价代换的时候,还是要从关系代数的角度来证明规则的成立。在这里可能伴随着Sql语句得优化,也可能是对执行计划的优化。

下面我们以SQL优化为例,来看看calcite如何做。

SQL 优化

基于规则优化(RBO)

基于规则的优化器(Rule-Based Optimizer,RBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会变成另外一个关系表达式,同时原有表达式会被裁剪掉,经过一系列转换后生成最终的执行计划。

RBO 中包含了一套有着严格顺序的优化规则,同样一条 SQL,无论读取的表中数据是怎么样的,最后生成的执行计划都是一样的。同时,在 RBO 中 SQL 写法的不同很有可能影响最终的执行计划,从而影响执行计划的性能。

基于成本优化(CBO)

基于代价的优化器(Cost-Based Optimizer,CBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会生成另外一个关系表达式,同时原有表达式也会保留,经过一系列转换后会生成多个执行计划,然后 CBO 会根据统计信息和代价模型 (Cost Model) 计算每个执行计划的 Cost,从中挑选 Cost 最小的执行计划。

CBO = RBO + Cost Model + Model Iteration

由上可知,CBO 中有两个依赖:统计信息和代价模型。统计信息的准确与否、代价模型的合理与否都会影响 CBO 选择最优计划。从上述描述可知,CBO 是优于 RBO 的,原因是 RBO 是一种只认规则,对数据不敏感的呆板的优化器,而在实际过程中,数据往往是有变化的,通过 RBO 生成的执行计划很有可能不是最优的。事实上目前各大数据库和大数据计算引擎都倾向于使用 CBO,但是对于流式计算引擎来说,使用 CBO 还是有很大难度的,因为并不能提前预知数据量等信息,这会极大地影响优化效果,CBO 主要还是应用在离线的场景。

优化规则

无论是 RBO,还是 CBO 都包含了一系列优化规则,这些优化规则可以对关系表达式进行等价转换,常见的优化规则包含:

  • 谓词下推 Predicate Pushdown
  • 常量折叠 Constant Folding
  • 列裁剪 Column Pruning

谓词下推:

我们可能已经理解了什么是谓词下推,基本的意思predicate pushdown 是将SQL语句中的部分语句( predicates 谓词部分) 可以被 “pushed” 下推到数据源或者靠近数据源的部分。

对于Join(Inner Join)、Full outer Join,条件写在on后面,还是where后面,性能上面没有区别;

  • 对于Left outer Join ,右侧的表写在on后面、左侧的表写在where后面,性能上有提高;
  • 对于Right outer Join,左侧的表写在on后面、右侧的表写在where后面,性能上有提高;
  • 当条件分散在两个表时,谓词下推可按上述结论2和3自由组合;
  • 所谓下推,即谓词过滤在map端执行;所谓不下推,即谓词过滤在reduce端执行 注意:如果在表达式中含有不确定函数,整个表达式的谓词将不会被pushed

常量折叠

常量折叠也是常见的优化策略,这个比较简单,例如,有一个常量表达式 10 + 30,如果不进行常量折叠,那么每行数据都需要进行计算,进行常量折叠后的结果如下图所示( 对应 Calcite 中的 ReduceExpressionsRule.PROJECT_INSTANCE Rule)

列裁剪

列裁剪也是一个经典的优化规则,例如,一次查询并不需要扫描它的所有列值,而只需要列值 id,所以在扫描表之后需要将其他列进行裁剪,只留下列 id。这个优化带来的好处很明显,大幅度减少了网络 IO、内存数据量的消耗。

Calcite优化器

Calcite提供了两类型的优化器,即上述所说的RBO优化器和CBO优化器,在Calcite中的具体实现类对应HepPlanner(RBO)和VolcanoPlanner(CBO)。

HepPlanner优化器

HepPlanner简单理解就是两个循环,第一个循环会遍历用户提供的rule,第二个循环会遍历SQL树的节点,每当rule匹配到对应树节点的时候,会重新进行一遍循环。这个比较好理解。

VolcanoPlanner优化器

VolcanoPlanner则相对复杂一些,它不是简单地应用rule,而是会使用动态规划算法,计算每种rule匹配后生成新的SQL树的Cost信息,与原先SQL树的Cost信息相比较,如果新的树的Cost比较低,那么才会真正应用对应的rule。

案例

代码解析

首先,我们根据上一节的内容,来构建一个带条件的查询

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
RelNode opTree = relBuilder
                .scan("consumers")
                .scan("orders")
                .join(JoinRelType.INNER,
                        relBuilder.call(SqlStdOperatorTable.EQUALS,
                                relBuilder.field("id"),
                                relBuilder.field("user_id")))
                .filter(
                        relBuilder.call(SqlStdOperatorTable.EQUALS,
                                relBuilder.field("lastname"),
                                relBuilder.literal("jacky")))
                .project(
                        relBuilder.field("id"),
                        relBuilder.field("goods"),
                        relBuilder.field("price"),
                        relBuilder.field("firstname"),
                        relBuilder.field("lastname"))
                .sortLimit(0, 5, relBuilder.field("id"))
                .build();

接下来,我们在优化器里加入条件下退规则,这里我们用到上文提到得 HepPlanner 也就是 RBO

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
        HepProgramBuilder hepProgramBuilder = HepProgram.builder();
        hepProgramBuilder.addRuleInstance(FilterJoinRule.FilterIntoJoinRule.FilterIntoJoinRuleConfig.DEFAULT.toRule());
        HepProgram program = hepProgramBuilder.build();

        HepPlanner hepPlanner = new HepPlanner(program);
        hepPlanner.setRoot(opTree);
        RelNode r = hepPlanner.findBestExp();
  • 添加规则
  • 初始化 HepProgram 对象;
  • 初始化 HepPlanner 对象,并通过 setRoot() 方法将 RelNode 树转换成 HepPlanner 内部使用的 Graph;
  • 通过 findBestExp() 找到最优的 plan,规则的匹配都是在这里进行。

这里我们需要提一下,addRuleInstance 这个方法

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  /**
   * Adds an instruction to attempt to match a specific rule object.
   *
   * <p>Note that when this method is used, it is NOT necessary to add the
   * rule to the planner via {@link RelOptPlanner#addRule}; the instance
   * supplied here will be used. However, adding the rule to the planner
   * redundantly is good form since other planners may require it.
   *
   * @param rule rule to fire
   */
  public HepProgramBuilder addRuleInstance(RelOptRule rule) {
    return addInstruction(new HepInstruction.RuleInstance(rule));
  }

在添加 RelOptRule 规则得时候,calcite 1.21 版本以后如何实例化规则,进行了修改,老版本使用 builder.addRuleInstance(FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN)

接下来打印一下执行计划,和查询结果就好了。完整DEMO代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制

package com.dafei1288;


import org.apache.calcite.adapter.csv.CsvSchema;
import org.apache.calcite.adapter.csv.CsvTable;
import org.apache.calcite.plan.*;
import org.apache.calcite.plan.hep.HepPlanner;
import org.apache.calcite.plan.hep.HepProgram;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.RelWriter;
import org.apache.calcite.rel.core.JoinRelType;
import org.apache.calcite.rel.externalize.RelWriterImpl;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
import org.apache.calcite.sql.parser.SqlParser;
import org.apache.calcite.tools.FrameworkConfig;
import org.apache.calcite.tools.Frameworks;
import org.apache.calcite.tools.RelBuilder;
import org.apache.calcite.rel.rules.FilterJoinRule;
import org.apache.calcite.tools.RelRunners;


import java.io.File;
import java.io.PrintWriter;
import java.sql.ResultSet;


public class CalciteSqlRewriteCase {
    public static void main(String[] args) throws Exception {

        SchemaPlus rootSchema = Frameworks.createRootSchema(true);
        String csvPath = "src\\main\\resources\\db";
        CsvSchema csvSchema = new CsvSchema(new File(csvPath), CsvTable.Flavor.SCANNABLE);
        rootSchema.add("consumers", csvSchema.getTable("consumers"));
        rootSchema.add("orders", csvSchema.getTable("orders"));

        FrameworkConfig frameworkConfig = Frameworks.newConfigBuilder()
                .parserConfig(SqlParser.Config.DEFAULT)
                .defaultSchema(rootSchema)
                .build();


        RelBuilder relBuilder = RelBuilder.create(frameworkConfig);

        RelNode cnode = relBuilder.scan("consumers").build();
        System.out.println("==> "+ RelOptUtil.toString(cnode));

        cnode = relBuilder.scan("consumers").project(relBuilder.field("firstname"),
                relBuilder.field("lastname")).build();
        System.out.println("==> "+RelOptUtil.toString(cnode));

        RelNode opTree = relBuilder
                .scan("consumers")
                .scan("orders")
                .join(JoinRelType.INNER,
                        relBuilder.call(SqlStdOperatorTable.EQUALS,
                                relBuilder.field("id"),
                                relBuilder.field("user_id")))
                .filter(
                        relBuilder.call(SqlStdOperatorTable.EQUALS,
                                relBuilder.field("lastname"),
                                relBuilder.literal("jacky")))
                .project(
                        relBuilder.field("id"),
                        relBuilder.field("goods"),
                        relBuilder.field("price"),
                        relBuilder.field("firstname"),
                        relBuilder.field("lastname"))
                .sortLimit(0, 5, relBuilder.field("id"))
                .build();

        RelWriter rw = new RelWriterImpl(new PrintWriter(System.out, true));
        opTree.explain(rw);


        System.out.println();
        System.out.println();
        System.out.println();


        HepProgramBuilder hepProgramBuilder = HepProgram.builder();
        hepProgramBuilder.addRuleInstance(FilterJoinRule.FilterIntoJoinRule.FilterIntoJoinRuleConfig.DEFAULT.toRule());
        HepProgram program = hepProgramBuilder.build();

        HepPlanner hepPlanner = new HepPlanner(program);
        hepPlanner.setRoot(opTree);
        RelNode r = hepPlanner.findBestExp();
        r.explain(rw);

        System.out.println();
        System.out.println();
        System.out.println();

        ResultSet result = RelRunners.run(r).executeQuery();
        int columns = result.getMetaData().getColumnCount();
        while (result.next()) {
            System.out.println(result.getString(1) + " " + result.getString(2));
        }
    }
}


结果展示

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
==> LogicalTableScan(table=[[consumers]])

==> LogicalProject(firstname=[$1], lastname=[$2])
  LogicalTableScan(table=[[consumers]])

8:LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
  7:LogicalProject(id=[$0], goods=[$5], price=[$6], firstname=[$1], lastname=[$2])
    6:LogicalFilter(condition=[=($2, 'jacky')])
      5:LogicalJoin(condition=[=($0, $4)], joinType=[inner])
        3:LogicalTableScan(table=[[consumers]])
        4:LogicalTableScan(table=[[orders]])



17:LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
  15:LogicalProject(id=[$0], goods=[$5], price=[$6], firstname=[$1], lastname=[$2])
    22:LogicalJoin(condition=[=($0, $4)], joinType=[inner])
      19:LogicalFilter(condition=[=($2, 'jacky')])
        3:LogicalTableScan(table=[[consumers]])
      4:LogicalTableScan(table=[[orders]])

1 book

Process finished with exit code 0

结果分析

可以看到我们的执行计划从

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
8:LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
  7:LogicalProject(id=[$0], goods=[$5], price=[$6], firstname=[$1], lastname=[$2])
    6:LogicalFilter(condition=[=($2, 'jacky')])
      5:LogicalJoin(condition=[=($0, $4)], joinType=[inner])
        3:LogicalTableScan(table=[[consumers]])
        4:LogicalTableScan(table=[[orders]])

变成了

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
17:LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
  15:LogicalProject(id=[$0], goods=[$5], price=[$6], firstname=[$1], lastname=[$2])
    22:LogicalJoin(condition=[=($0, $4)], joinType=[inner])
      19:LogicalFilter(condition=[=($2, 'jacky')])
        3:LogicalTableScan(table=[[consumers]])
      4:LogicalTableScan(table=[[orders]])

也就实现了条件下推。

好了,上半部分我们就讲到这里,下一篇,我们来尝试自定义calcite的rule,来rewrite sql。

参考资料

https://zhuanlan.zhihu.com/p/61661909

https://github.com/tzolov/calcite-sql-rewriter/tree/master

https://guimy.tech/calcite/2021/01/02/introduction-to-apache-calcite.html

http://matt33.com/2019/03/17/apache-calcite-planner/

https://zhuanlan.zhihu.com/p/397365617

历史文章导读

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-08-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 麒思妙想 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
Onboard,迷人的引导页样式制作库
本文介绍了一个用于制作引导页的样式库Onboard,包括两种样式:1.图片+蒙板样式;2.底部视频样式。Onboard的使用方法也很简单,只需要导入库并创建一个OnboardingViewController实例,设置背景图片或视频、蒙板上的文字以及按钮,即可生成引导页。同时,还可以定制化OnboardingViewController,设置淡入淡出效果、模糊效果、蒙板文字颜色等。
ios122
2018/01/02
9220
Onboard,迷人的引导页样式制作库
【技巧和案例分享】引导页如何设计,才能把用户顺利“引进门”?
现在社会,同类产品竞争越演越烈。用户对产品的第一映像往往决定了一切。而用户的第一印象又起始于引导页。也就是说,引导页的好坏,很大程度上直接影响用户是否最终使用或购买产品。所以,如若设计师能够一开始就添加引人入胜的引导页设计,产品吸引和说服用户使用和购买的机率就会更大。
奔跑的小鹿
2020/03/16
7540
【技巧和案例分享】引导页如何设计,才能把用户顺利“引进门”?
APP 引导页、欢迎页运用
在实际生活中我们使用的每一款App都会有一个引导页和欢迎页面,这两个页面主要是增加用户体验,引导页是在你第一次安装该APP的时候显示的,而欢迎页你你每次进入应用的时候出现的。先了解功能,再来实现逻辑方法,首先引导页是几张不同的图片,下面会有一个表示原点,指明当前是第几页。
晨曦_LLW
2020/09/25
1.5K0
DWIntrosPage 简单定制引导页
下面摘取部分代码 DWIntrosPageContentViewController
Dwyane
2018/09/30
8180
DWIntrosPage 简单定制引导页
Android开发之引导页的简单实现
当欢迎页面加载完成的时候(一般为动画),即欢迎页面动画加载完成的时候,从本地存储中取出记录是否是第一次进入,然后进入引导页或者主页,如果是第一次就进入引导页,否则进入主页。
程序员飞飞
2020/05/27
2.4K0
Android项目实战(三):实现第一次进入软件的引导页
最近做的APP接近尾声了,就是些优化工作了, 我们都知道现在的APP都会有引导页,就是安装之后第一次打开才显示的引导页面(介绍这个软件的几张可以切换的图) 自己做了一下,结合之前学过的 慕课网_ViewPager切换动画(3.0版本以上有效果) 思路很简单,APP的主界面还是作为主Activity,只要新添加一个类来判断是不是第一次打开APP 设主activity 名字为:MainActivity.java   判断是不是第一次打开APP且实现引导页面的类 LoginActivity ,另外还需要一个类 这
听着music睡
2018/05/18
1.3K0
Android技巧一:启动屏+功能引导页
前言 很长一段时间没写博客了,再不写点东西真说不过去,把工作上的一些有价值的东西整理出来分享,在当下还有点时效性,不然迟早会烂在肚子里的。还记得之前小巫有个开源计划是想实现一个星期开发app,现在把它拾起来,计划没有实行起来跟我那懒惰的身躯有关,任何伟大的事情都需要强大的执行力才能实现,慢一点没关系,能创造点东西就是值得的事情。 本篇博客先介绍一个app最常见的特性,就是新功能属性介绍和启动屏,一般会怎么实现呢,这不就打算告诉大家了么。 先说逻辑 先判断是否第一次启动app,如果是,则进入功能使用导航(最简
巫山老妖
2018/07/20
1.5K0
羊皮书APP(Android版)开发系列(五)APP引导页实现
APP开发中,引导页展示通常是必不可少的,用来展示产品。github上有一个引导页的库,个人感觉不错,就拿来使用,地址:AppIntro 导入AppIntro库的方法(两种): 方法一: 到github上下载AppIntro,解压,将library文件夹拷贝到自己项目的根目录下,重命名为app____intro____library,在settings.gradle文件中添加:include ':app',':app_____intro____library',在build.gradle文件中添加:
热心的程序员
2018/08/30
5490
羊皮书APP(Android版)开发系列(五)APP引导页实现
EAIntroView–高度可定制的iOS应用欢迎页通用解决方案
本文介绍了一种高度可定制的iOS应用欢迎页通用解决方案,该方案可高度定制,不要仅限于现有的demo。该欢迎页方案具有以下特点:1.基于EAIntroView实现;2.支持自定义页面和视图;3.支持自定义动画效果;4.支持通过IB搭建和可视化编程。使用该方案可以快速创建应用欢迎页,提高开发效率。
ios122
2018/01/02
8310
EAIntroView–高度可定制的iOS应用欢迎页通用解决方案
翻转视图ViewFlipper快速打造引导页和轮播图
前面两期学习了 ViewAnimator及其子类ViewSwitcher的使用,以及ViewSwitcher的子类ImageSwitcher和TextSwitcher的使用,你都掌握了吗?本期我们一起来学习ViewAnimator另一个子类 ViewFlipper组件的使用。 一、ViewFlipper概述 ViewFlipper组件继承了 ViewAnimator,它可调用addView(View v)添加多个组件,一旦向 ViewFlipper中添加了多个组件之后,ViewFlipper
分享达人秀
2018/02/05
1.5K0
翻转视图ViewFlipper快速打造引导页和轮播图
ViewPager实现启动引导页面(个人认为很详细)
效果如图: 启动页面是一张图片+延时效果,这里就不给出布局文件了。 WelcomeActivity分析:在启动页面检测是否是第一次运行程序,如果是,则先跳转到引导界面的Activity——AndyVi
用户1737026
2018/06/01
9190
IOS开发之记录用户登陆状态
  上一篇博客中提到了用CoreData来进行数据的持久化,CoreData的配置和使用步骤还是挺复杂的。但熟悉CoreData的使用流程后,CoreData还是蛮好用的。今天要说的是如何记录我们用户的登陆状态。例如微信,QQ等,在用户登陆后,关闭应用在打开就直接登陆了。那么我们在App开发中如何记录用户的登陆状态呢?之前在用PHP或者Java写B/S结构的东西的时候,我们用Session来存储用户的登陆信息,Session是存在服务器上仅在一次回话中有效,如果要记录用户的登陆状态,那么会用到一个叫Cook
lizelu
2018/01/11
1.6K0
IOS开发之记录用户登陆状态
IOS开发之TabBarItem&NavigationBarItem
  想必大家都用过微信,微信间的页面切换是如何做成的呢?接下来我们用storyboard结合着代码来模拟一下微信的视图控制模式。   "工欲善其事,必先利其器",下面主要是对storyboard来进行我们项目框架的搭建的,必要时,用代码实现我们的页面效果。在IOS开发中常用的多视图间的切换大致有TabBarController, NavigationBarController, 和模态窗口。第一次接触模态的概念是在Web前端的内容中接触的。下面将会结合一个实际的效果来简单的介绍一下TabBar和Naviga
lizelu
2018/01/11
1.5K0
IOS开发之TabBarItem&NavigationBarItem
APP启动引导页的制作,用ViewPager实现翻页动画
这次制作App的引导页,主要用到2个知识“SharedPreferences 和 ViewPager”
爱因斯坦福
2018/09/10
2K0
Hexo部署至服务器(续)——建立引导页及分站
之前的建立分站教程(Hexo建立分站 | 花猪のBlog (cnhuazhu.top))是利用了Hexo纯静态的优势,将不同主题渲染后的public中的内容放在主站的public文件夹中。(所以称之为“主站”和“分站”,但其实这个结构用“父站”和“子站”形容更贴切。如下图所示)
花猪
2022/02/23
6960
Hexo部署至服务器(续)——建立引导页及分站
【Hybrid开发高级系列】ReactNative(六) —— 与现有的应用程序集成(IOS)
        由于React并没有做出关于你其他的技术堆栈的假设——通常在 MVC 中简单的用 V 来表示——这很容易嵌 入到现有non-React Native应用程序中。事实上,它与另外的最佳实践社区工具集成了,如 CocoaPods。
江中散人_Jun
2023/10/16
3280
【Hybrid开发高级系列】ReactNative(六) —— 与现有的应用程序集成(IOS)
羊皮书APP(Android版)开发系列(三)APP引导页启动控制类
基本上一个完整的APP都会有一个引导页,在APP首次安装或APP更新后第一次打开时显示,这个逻辑是很通用的,所以写成一个工具类,方便使用。 APP启动页逻辑有三种情况: 当APP被首次安装后打开时显示引导页。 当APP更新版本后,第一次打开时显示引导页。 当APP再次启动时,跳过引导页。 工具类AppIntroUtil.java代码如下: package cn.studyou.parchment.utils; import android.content.Context; import android.t
热心的程序员
2018/08/30
5940
残月引导页-关闭网站通知源码
Bsutss
2024/09/01
2240
ViewPager快速实现引导页
在很多APP第一次启动时都会出现引导页,在一些APP里面还会包括一些左右滑动翻页和页面轮播切换的情况。在之前也已经学习了AdapterViewFlipper和ViewFlipper,都可以很好的实现,今天继续来学习一个功能更加强大的ViewPager组件。 一、ViewPager简介 ViewPager是android扩展包v4包中的类,这个类可以让用户左右滑动切换当前的view。ViewPager继承自ViewGroup,也就是ViewPager是一个容器类,可以包含其他的View类。
分享达人秀
2018/02/05
1.5K0
ViewPager快速实现引导页
推荐阅读
相关推荐
Onboard,迷人的引导页样式制作库
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验