Calcite 解析层详解

编程入门 行业动态 更新时间:2024-10-21 09:36:50

Calcite 解析层<a href=https://www.elefans.com/category/jswz/34/1770044.html style=详解"/>

Calcite 解析层详解

1、概述

用户的操作请求经过服务层的接收和封装被传递给calcite-core模块。

其中第一站就是解析层,它的作用主要是对SQL语句进行语法解析。

在这个过程中,初始的SQL字符串会被转化为Calcite内部的语法解析节点,为进一步的语法校验和优化做准备。

2、语法解析过程
1)概述

语法解析是利用词法分析器、语法分析器将输入的语句通过一些预定的规则解析为抽象语法树的过程。

2)语法解析的执行架构

其中主要分为3个阶段:

1.首先字符串处理器会将源语句中的字符串转换成字符流;

2.然后词法分析器会对字符流中的一些词法进行匹配,形成词组(Token)流;

3.最后由语法分析器将这些词组流进行语义逻辑的理解,转变为最终的抽象语法树。

在这个过程当中,还有两个维护组件,一个是负责维持词法和语法匹配逻辑的表格管理器,另一个是负责检查语法错误的异常监听器。

3、Calcite中的解析体系

对于数据管理系统,语法解析主要针对的是将SQL语句解析成抽象语法树的过程

1)抽象语法树的概念

语法解析的最终结果是一棵抽象语法树,它以树状的形式表现出语法结构,树上的每个节点都表示源码中的一种结构。

如果给计算机输入的指令是“(1+2)*3”,那么经过语法解析以后就会生成抽象语法树,其中圆形节点表示叶子节点,一般是参数,方形节点表示非叶子节点,一般是操作符。

抽象语法树将纯文本转换为一棵树,其中每个节点对应代码中的一种结构,例如上述的表达式转换为源码中的结构的形式。

同理,输入的一条SQL语句也会生成一棵抽象语法树,例如select id from table where id > 1。

这棵树的每个节点仅仅是对语法的抽象,并未对应到相应的源码结构当中。

因此为了能够匹配每个节点相应的源码结构,Calcite构建了它的SqlNode体系来完成这项任务。

2)SqlNode体系
1.概述

SqlNode是负责封装语义信息的基础类,是Calcite中非常重要的概念,不只是解析阶段,也和后续的校验、优化息息相关,它是所有解析节点的父类。

在Calcite中SqlNode的实现类有40多个,每个类都代表一个节点到源码结构的映射,其大致可以分为3类,即SqlLiteral、SqlIdentifier、SqlCall。

2.SqlLiteral

SqlLiteral类主要封装输入的常量,也被称作字面量。

它和它的子类一般用来封装具体的变量值,也可以通过调用getValue方法返回所需要的值。

为了实现其通用性,Calcite支持了很多数据类型,展示了当前版本SqlLiteral可以表示的常量类型。

3.SqlIdentifier

SqlIdentifier代表输入的标识符,例如在一条SQL语句中表的名称、字段名称,都可以封装成一个SqlIdentifier对象。

4.SqlCall

每一个操作都可以对应一个SqlCall,如查询是SqlSelect,插入是SqlInsert。

为了更加细粒度地介绍Calcite是如何使用SqlCall的子类来封装操作的,以负责查询的SqlSelect为例,介绍SqlCall内部具体是如何封装操作的。

a)SqlSelect中包含的属性以及常量
/*** 封装查询操作的SqlSelect节点*/
public class SqlSelect extends SqlCall {public static final int FROM_OPERAND = 2;public static final int WHERE_OPERAND = 3;public static final int HAVING_OPERAND = 5;SqlNodeList keywordList;// 查询字段列表@Nullable SqlNodeList selectList;// 数据源信息@Nullable SqlNode from;// 过滤条件信息@Nullable SqlNode where;// 分组信息@Nullable SqlNodeList groupBy;@Nullable SqlNode having;SqlNodeList windowDecls;@Nullable SqlNodeList orderBy;@Nullable SqlNode offset;@Nullable SqlNode fetch;@Nullable SqlNodeList hints;
}

通过观察SqlSelect的成员变量,可以发现在SqlSelect当中封装了数据源信息(FROM子句)、过滤条件信息(WHERE子句)、分组信息(GROUP BY子句)等查询信息。

当SQL语句是一条查询语句的时候,会生成一个SqlSelect节点,在这个节点下面封装了SQL语句当中每一个关键的参数。

同理,在负责插入数据的SqlInsert中,在SqlInsert中封装了目标表信息(targetTable)、源信息(source)、字段对应信息(columnList),基本上将插入数据时需要的信息都囊括了进来。

b)SqlInsert中包含的属性以及常量
public class SqlInsert extends SqlCall {public static final SqlSpecialOperator OPERATOR =new SqlSpecialOperator("INSERT", SqlKind.INSERT);SqlNodeList keywords;SqlNode targetTable;SqlNode source;@Nullable SqlNodeList columnList;
}
c)那么SqlNode中的各个类是如何工作的呢?

如下SQL包含字段的投影(id)、数据源的制定(t)、查询过滤条件(id>1)以及分组条件(id)。

selectid
from t
where id > 1 

经过Calcite的SqlNode规范化,最终形成SqlNode树。

4、JavaCC
1)JavaCC简介

JavaCC 代码生成器,它的作用就是生成在语法解析过程中的词法分析器和语法分析器。

通过模板文件(例如.jj文件、.jjt文件以及.jtb文件)来生成Java程序,Calcite利用这些Java程序来完成语法解析的工作。

2)JavaCC简单示例
1.示例

解析一条 select 1+1 查询语句,把它加起来并输出结果,select 1+1输出2,select 2+3输出5。

JavaCC 主要实现都在.jj文件中。

2.JavaCC中定义语法的模板
options {JavaCC的配置项
}
PARSER_BEGIN(解析器类名)package包名;
import库名;
public class解析器类名 {任意Java代码
}PARSER_END(解析器类名)
a)options

options是解析配置项,格式为键值对key=value。

比如在解析时忽略大小写:IGNORE_ CASE = true。

JavaCC配置模板

options {// 大小写配置成不敏感的状态IGNORE_CASE = true;// STATIC代表生成的解析器类是否为静态类,默认是true,我们需要它可以多次初始化,所以设为false。STATIC = false;
} 
b)PARSER声明

PARSER声明是PARSER_BEGIN和PARSER_END之间的部分,这部分完全是Java代码,同时只有一个类,这个类就是解析器类。

解析器类,代表解析的入口,其输入是要解析的内容,可以用程序允许的方式输入,比如字符串参数、文件或数据流,而输出则看实现情况,比如简单的计算器直接在解析完时就计算好了,复杂的SQL语句解析会生成一棵抽象语法树。

解析器类和普通类类似,不过在生成代码时JavaCC会自动为其生成一些构造方法,可以输入字符流和字节流,所以 SimpleSelectParser可以直接调用字符流构造方法。

解析器类一般作为被调用的入口类,传入要解析的字符内容,然后调用parse方法开始解析,我们声明的SQL属性用来保存传入的SQL。

JavaCC中的代码模板:

PARSER_BEGIN(SimpleSelectParser)package cn.ptpress.cdm.parser.select;import java.io.* ;public class SimpleSelectParser {private String sql;public void parse() throws ParseException {SelectExpr(sql);}public SimpleSelectParser(String expr) {this((Reader)(new StringReader(expr)));this.sql = expr;}
}PARSER_END(SimpleSelectParser)
c)解析逻辑

解析逻辑部分由代码和表达式构成,可以分为2种代码:纯Java代码和解析逻辑代码。

i)纯Java代码

纯 Java 代码以 JAVACODE 关键字开始,后面就是 Java 代码里方法的声明,内容也只限于 Java 代码,这些方法的作用就是供解析代码调用,比如匹配前缀。

这些解析逻辑是可选的,其本质是公共方法抽取,当然也可能整个语法文件都没有抽出一个方法。

JavaCC中纯Java代码:

JAVACODE boolean matchPrefix(String prefix, String[] arr) {for (String s : arr) {if (s.startsWith(prefix)) {return true;}}return false;
}
ii)解析逻辑代码

解析逻辑代码,其构成多了冒号和冒号后面的花括号然后才是方法体

程序的基本构成是变量、语句、分支结构、循环结构等,这几个简单元素组合起来就可以构成很复杂的程序。

JavaCC的解析逻辑代码和纯Java代码的最大区别是:可以嵌入JavaCC的语法。

首先,这些代码在结构上看起来像方法,不过其由两个花括号构成,第一个花括号里声明变量,第二个花括号里写逻辑,同时方法名后面还有一个冒号,以此和纯粹的方法区分开。

解析逻辑代码和纯 Java 代码类似,都有分支、循环,只是看起来不像代码,其结构类似正则表达式,还可以混入Java代码。

在循环结构中,用正则表达分支逻辑时,会用到圆括号和竖线

如(a|b|c),在 JavaCC 里,a、b、c可以换成解析逻辑:关键字+代码处理。

关键字就是后面要讲的常量字符定义,代码处理就是走到某个词语后执行什么操作,这里仅仅输出一句话。

关键字和代码处理,就是最小构成,整个结构可以无限递归。

JavaCC中循环逻辑代码:

// if - else
void ifElseExpr():
{}
{(<SELECT> {System.out.println("if else select");}|<UPDATE>  {System.out.println("if else update");}|<DELETE>  {System.out.println("if else delete");}|{System.out.println("other");})
}// while 0~n
void while1Expr():
{}
{(<SELECT>)*
}

关于查询表达式的简单示例:

// 入口
void SelectExpr(String sql) :
{int res; // 声明变量-结果变量
}
{<SELECT> // 以SELECT开始res = Expression() // 处理计算表达式会获得结果{System.out.println(sql + "=" + res); // 输出结果}
}
// 计算表达式
int Expression() :
{// 声明变量int res = 0;int v;
}
{res = Number() // 获得第一个数(<ADD> // 加号v = Number() // 获得第二个数{res += v;} // 计算结果
|<SUB> // 减号v = Number(){res -= v;})*{return res;} // 返回结果
}int Number() :
{Token t;
}
{t = <NUMBER> {return Integer.parseInt(t.image); // 将字符串转换成整数}
}
d)关键字定义

每一个关键字都由一个TOKEN构成,SKIP用于指定在解析时需要跳过的字符,每个TOKEN用尖括号标识,多个TOKEN之间用竖线分隔。

尖括号里用冒号分隔,冒号前面是变量名,冒号后面是定义该变量的正则表达式。

本节示例需要定义数字NUMBER,为了简单,示例中并未处理不能以0开始的数字,其余符号都只有一个单词,具体的定义方法下所示。

JavaCC 中关键字的定义方法:

SKIP:{ // 跳过制表符" "| "\t"| "\n"| "\r"| "\r\n"
}
TOKEN :
{< SELECT: "SELECT" >
|   < NUMBER: (["0"-"9"])+ >
|   < ADD: "+" >
|   < SUB: "-" >
}
e) JavaCC 编译生成解析代码

现在代码编写完成,需要借助 JavaCC 编译才能生成解析代码。

对于 Java 可以使用Maven插件,不用单独下载 JavaCC。

可以用 Maven 来加载相关的依赖,具体的坐标写法如下。

JavaCC代码生成的插件坐标:

<plugin><groupId>org.codehaus.mojo</groupId><artifactId>javacc-maven-plugin</artifactId><version>2.6</version><executions><execution><phase>generate-sources</phase><id>javacc</id><goals><goal>javacc</goal></goals><configuration><sourceDirectory>${basedir}/src/main/javacc</sourceDirectory><includes><include>**/*.jj</include></includes><outputDirectory>${basedir}/generated-sources/</outputDirectory></configuration></execution></executions>
</plugin>

Java CC编译命令:

mvn org.codehaus.mojo:javacc-maven-plugin:2.6:javacc

运行命令后会在 target/generated-sources/javacc 中生成包和代码,使用时,只需要调用解析器主类即可。

f)解析器主类调用
final SimpleSelectParser parser = new SimpleSelectParser("select 1+1+1");// select 1+1+1=3
parser.parse(); 
5、Calcite中 JavaCC 的使用方法

Calcite 默认采用 JavaCC 来生成词法分析器和语法分析器。

1)使用 JavaCC 解析器

Calcite中,JavaCC 的依赖已经被封装到 calcite-core 模块当中,如果使用 Maven 作为依赖管理工具,只需要添加对应的calcite-core模块坐标即可。

<dependency><groupId>org.apache.calcite</groupId><artifactId>calcite-core</artifactId><version>1.26.0</version>
</dependency>

在代码中,可以直接使用 Calcite 的 SqlParser 接口调用对应的语法解析流程,对相关的 SQL 语句进行解析。

解析流程:

// SQL语句
String sql = "select * from t_user where id = 1";// 解析配置
SqlParser.Config mysqlConfig = SqlParser.config().withLex(Lex.MYSQL);// 创建解析器
SqlParser parser = SqlParser.create(sql, mysqlConfig);// 解析SQL语句
SqlNode sqlNode = parser.parseQuery();System.out.println(sqlNode.toString());
2)自定义语法

有时需要扩展一些新的语法操作,以数仓的操作——Load作为例子,介绍如何自定义语法。

Load操作时将数据从一种数据源导入另一种数据源中,Load操作采用的语法模板如下。

LOAD sourceType:obj TO targetType:obj 
(fromCol toCol (,fromCol toCol)*) 
[SEPARATOR '\t']

其中,sourceType 和 targetType 表示数据源类型,obj表示这些数据源的数据对象,(fromCol toCol)表示字段名映射,文件里面的第一行是表头,分隔符默认是制表符。

Load语句示例:

LOAD hdfs:'/data/user.txt' TO mysql:'db.t_user' (name name,age age) SEPARATOR ',';

在真正实现时,有两种选择。

一种是直接修改Calcite的源码,在其本身的模板文件(Parser.jj)内部添加对应的语法逻辑,然后重新编译。

但是这种方式的弊端非常明显,即对Calcite本身的源码侵入性太强。

另一种利用模板引擎来扩展语法文件,模板引擎可将扩展的语法提取到模板文件外面,以达到程序解耦的目的。

在实现层面,Calcite用到了FreeMarker,它是一个模板引擎,按照FreeMarker定义的模板语法,可以通过其提供的 Java API 设置值来替换模板中的占位符。

如下展示了 Calcite 通过模板引擎添加语法逻辑相关的文件结构,其源码将 Parser.jj 这个语法文件定义为模板,将 includes 目录下的.ftl文件作为扩展文件,最后统一通过config.fmpp来配置。

具体添加语法的操作可以分为3个步骤

编写新的 JavaCC 语法文件;

修改config.fmpp文件,配置自定义语法;

编译模板文件和语法文件。

1.编写新的 JavaCC 语法文件

不需要修改Parser.jj文件,只需要修改includes目录下的.ftl文件,对于前文提出的Load操作,只需要在parserImpls.ftl文件里增加Load对应的语法。

在编写语法文件之前,先要从代码的角度,用面向对象的思想将最终结果定下来,也就是最后希望得到的一个SqlNode节点。

抽象Load语句内容并封装后,得到SqlLoad,继承SqlCall,表示一个操作,Load操作里的数据源和目标源是同样的结构,所以封装SqlLoadSource,而字段映射可以用一个列表来封装,SqlColMapping仅仅包含一堆列映射,SqlNodeList代表节点列表。

扩展SqlLoad的代码实现:

// 扩展SqlLoad的代码实现
public class SqlLoad extends SqlCall {// 来源信息private SqlLoadSource source;// 终点信息private SqlLoadSource target;// 列映射关系private SqlNodeList colMapping;// 分隔符private String separator;// 构造方法public SqlLoad(SqlParserPos pos) {super(pos);}// 扩展的构造方法public SqlLoad(SqlParserPos pos, SqlLoadSource source, SqlLoadSource target, SqlNodeList colMapping,String separator) {super(pos);this.source = source;this.target = target;this.colMapping = colMapping;this.separator = separator;}
}

由于Load操作涉及两个数据源,因此也需要对数据源进行定义。

Load语句中数据源的定义类:

/*** 定义Load语句中的数据源信息*/
@Data
@AllArgsConstructor
public class SqlLoadSource {private SqlIdentifier type;private String obj;
}

Load语句中出现的字段映射关系也需要定义。

对Load语句中的字段映射关系进行定义:

// 对Load语句中的字段映射关系进行定义
public class SqlColMapping extends SqlCall {// 操作类型protected static final SqlOperator OPERATOR =new SqlSpecialOperator("SqlColMapping", SqlKind.OTHER);private SqlIdentifier fromCol;private SqlIdentifier toCol;public SqlColMapping(SqlParserPos pos) {super(pos);}// 构造方法public SqlColMapping(SqlParserPos pos, SqlIdentifier fromCol, SqlIdentifier toCol) {super(pos);this.fromCol = fromCol;this.toCol = toCol;}
}

为了输出SQL语句,还需要重写unparse方法。

unparse方法定义:

/*** 定义unparse方法*/
@Override
public void unparse(SqlWriter writer, int leftPrec, int rightPrec) {writer.keyword("LOAD");source.getType().unparse(writer, leftPrec, rightPrec);writer.keyword(":");writer.print("'" + source.getObj() + "' ");writer.keyword("TO");target.getType().unparse(writer, leftPrec, rightPrec);writer.keyword(":");writer.print("'" + target.getObj() + "' ");final SqlWriter.Frame frame = writer.startList("(", ")");for (SqlNode n : colMapping.getList()) {writer.newlineAndIndent();writer.sep(",", false);n.unparse(writer, leftPrec, rightPrec);}writer.endList(frame);writer.keyword("SEPARATOR");writer.print("'" + separator + "'");
}

当需要的 SqlNode 节点类定义好后,就可以开始编写语法文件了,Load语法没有多余分支结构,只有列映射用到了循环,可能有多个列。

parserImpls.ftl文件中添加语法逻辑的代码示例:

// 节点定义,返回我们定义的节点
SqlNode SqlLoad() :
{SqlParserPos pos; // 解析定位SqlIdentifier sourceType; // 源类型用一个标识符节点表示String sourceObj; // 源路径表示为一个字符串,比如“/path/xxx”SqlIdentifier targetType;String targetObj;SqlParserPos mapPos;SqlNodeList colMapping;SqlColMapping colMap;String separator = "\t";
}
{
// LOAD语法没有多余分支结构,“一条线下去”,获取相应位置的内容并保存到变量中
<LOAD>{pos = getPos();}sourceType = CompoundIdentifier()<COLON> // 冒号和圆括号在Calcite原生的解析文件里已经定义,我们也能使用sourceObj = StringLiteralValue()
<TO>targetType = CompoundIdentifier()
<COLON>targetObj = StringLiteralValue(){mapPos = getPos();}
<LPAREN>{colMapping = new SqlNodeList(mapPos);colMapping.add(readOneColMapping());}(
<COMMA>{colMapping.add(readOneColMapping());})*<RPAREN>
[<SEPARATOR> separator=StringLiteralValue()]// 最后构造SqlLoad对象并返回{return new SqlLoad(pos, new SqlLoadSource(sourceType, sourceObj),new SqlLoadSource(targetType, targetObj), colMapping, separator);}
}// 提取出字符串节点的内容函数
JAVACODE String StringLiteralValue() {SqlNode sqlNode = StringLiteral();return ((NlsString) SqlLiteral.value(sqlNode)).getValue();
}SqlNode readOneColMapping():
{SqlIdentifier fromCol;SqlIdentifier toCol;SqlParserPos pos;
}
{{ pos = getPos();}fromCol = SimpleIdentifier()toCol = SimpleIdentifier(){return new SqlColMapping(pos, fromCol, toCol);}
}
2.修改config.fmpp文件,配置自定义语法

需要将 Calcite 源码中的 config.fmpp 文件复制到项目的 src/main/codegen 目录下,然后修改里面的内容,来声明扩展的部分。

config.fmpp文件的定义示例:

data: {parser: {# 生成的解析器包路径package: "cn.ptpress.cdm.parser.extend",# 解析器名称class: "CdmSqlParserImpl",# 引入的依赖类imports: ["cn.ptpress.cdm.parser.load.SqlLoad","cn.ptpress.cdm.parser.load.SqlLoadSource""cn.ptpress.cdm.parser.load.SqlColMapping"]# 新的关键字keywords: ["LOAD","SEPARATOR"]# 新增的语法解析方法statementParserMethods: ["SqlLoad()"]# 包含的扩展语法文件implementationFiles: ["parserImpls.ftl"]}
}
# 扩展文件的目录
freemarkerLinks: {includes: includes/
}
3.编译模板文件和语法文件

在这个过程当中,需要将模板Parser.jj文件编译成真正的Parser.jj文件,然后根据Parser.jj文件生成语法解析代码。

利用Maven插件来完成这个任务,具体操作可以分为2个阶段:初始化和编译。

初始化阶段通过resources插件将codegen目录加入编译资源,然后通过dependency插件把calcite-core包里的Parser.jj文件提取到构建目录中。

编译所需插件的配置方式:

<plugin><artifactId>maven-resources-plugin</artifactId><executions><execution><phase>initialize</phase><goals><goal>copy-resources</goal></goals></execution></executions><configuration><outputDirectory>${basedir}/target/codegen</outputDirectory><resources><resource><directory>src/main/codegen</directory><filtering>false</filtering></resource></resources></configuration>
</plugin><plugin><!--从calcite-core.jar提取解析器语法模板,并放入FreeMarker模板所在的目录--><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>2.8</version><executions><execution><id>unpack-parser-template</id><phase>initialize</phase><goals><goal>unpack</goal></goals><configuration><artifactItems><artifactItem><groupId>org.apache.calcite</groupId><artifactId>calcite-core</artifactId><version>1.26.0</version><type>jar</type><overWrite>true</overWrite><outputDirectory>${project.build.directory}/</outputDirectory><includes>**/Parser.jj</includes></artifactItem></artifactItems></configuration></execution></executions>
</plugin>

这2个插件可以通过“mvn initialize”命令进行测试。

运行成功后可以看到target目录下有了codegen目录,并且多了本没有编写的Parser.jj文件。

然后就是编译阶段,利用FreeMarker模板提供的插件,根据config.fmpp编译Parser.jj模板,声明config.fmpp文件路径模板和输出目录,在Maven的generate-resources阶段运行该插件。

FreeMarker在pom.xml文件中的配置方式:

<plugin><configuration><cfgFile>${project.build.directory}/codegen/config.fmpp</cfgFile><outputDirectory>target/generated-sources</outputDirectory><templateDirectory>${project.build.directory}/codegen/templates</templateDirectory></configuration><groupId>com.googlecode.fmpp-maven-plugin</groupId><artifactId>fmpp-maven-plugin</artifactId><version>1.0</version><dependencies><dependency><groupId>org.freemarker</groupId><artifactId>freemarker</artifactId><version>2.3.28</version></dependency></dependencies><executions><execution><id>generate-fmpp-sources</id><phase>generate-sources</phase><goals><goal>generate</goal></goals></execution></executions>
</plugin>

运行“mvn generate-resources”命令就可以生成真正的Parser.jj文件。

最后一步就是编译语法文件,使用JavaCC插件即可完成。

JavaCC插件配置方式:

<plugin><groupId>org.codehaus.mojo</groupId><artifactId>javacc-maven-plugin</artifactId><version>2.6</version><executions><execution><phase>generate-sources</phase><id>javacc</id><goals><goal>javacc</goal></goals><configuration><sourceDirectory>${basedir}/target/generated-sources/</sourceDirectory><includes><include>**/Parser.jj</include></includes><lookAhead>2</lookAhead><isStatic>false</isStatic><outputDirectory>${basedir}/src/main/java</outputDirectory></configuration></execution></executions>
</plugin>

注意这里的I/O目录,直接将生成的代码放在了项目里。

看起来上面每个阶段用了好几个命令,其实只需要一个Maven命令即可完成所有步骤,即“mvn generate-resources”,该命令包含以上2个操作,4个插件都会被执行。

完成编译后,就可以测试新语法,在测试代码里配置生成的解析器类,然后写一条简单的Load语句。

4.测试Load语句的示例代码
String sql = "LOAD hdfs:'/data/user.txt' TO mysql:'db.t_user' (c1 c2,c3 c4) SEPARATOR ','";// 解析配置
SqlParser.Config mysqlConfig = SqlParser.config()// 使用解析器类.withParserFactory(CdmSqlParserImpl.FACTORY).withLex(Lex.MYSQL);SqlParser parser = SqlParser.create(sql, mysqlConfig);SqlNode sqlNode = parser.parseQuery();System.out.println(sqlNode.toString());

输出的结果正是重写的unparse方法所输出的。

通过unparse方法输出的结果:

LOAD 'hdfs': '/data/user.txt' TO 'mysql': 'db.t_user' 
('c1' 'c2', 'c3' 'c4') 
SEPARATOR ','

更多推荐

Calcite 解析层详解

本文发布于:2023-12-03 09:24:44,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1653728.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:详解   Calcite

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!