前言
小编的精通mybatis之讲解就快结束了,希望大家坚持不懈坚持到底了。好了进入今天的正题,动态sql全流程。
动态Sql定义
定义:每次构建sql脚本时,根据预先编写的脚本以及参数动态构建可执行的sql。
动态SQL是MyBatis 强大功能之一,他免除了在JAVA代码中拼装SQL字符串麻烦,同时保留了我们对SQL的自主控制,更方便进行SQL性能优化改造。这也是大部分的编程伙伴喜欢用mybatis的原因。
对动态sql的使用大家应该很熟悉了吧,如果有需要大家可以看官网使用动态 SQL。首先小编带大家看下sql脚本元素:
如果你之前用过 JSTL 或任何基于类 XML 语言的文本处理器,你对动态 SQL 元素可能会感觉似曾相识。在 MyBatis 之前的版本中,需要花时间了解大量的元素。借助功能强大的基于 OGNL 的表达式,MyBatis 3 替换了之前的大部分元素,大大精简了元素种类,现在要学习的元素种类就上面这些了。其实官网里面还有bind和接口注解方式的script。
那小编带大家先了解一下OGNL的表达式吧。
OGNL表达式
OGNL全称是对象导航图语言(Object Graph Navigation Language)是一种JAVA表达示语言,可以方便的存取对象属和方法,已用于逻辑判断。其支持以下特性:
- 获取属性属性值,以及子属性值进行逻辑计算
id != null || autho.name != null - 表达示中可直接调用方法,(如果是无参方法,可以省略括号)
! comments.isEmpty && comments.get(0) != null - 通过下标访问数组或集合
comments[0].id != null
遍历集合
Iterable<?> comments = evaluator.evaluateIterable(“comments”, blog);
接下来小编用代码示例演示一下
这边使用了官网的示例,Blog ,commit,user三个对象
public class OgnlTest {
@Test
public void ognlExpressionTest() {
ExpressionEvaluator expressionEvaluator = new ExpressionEvaluator();
Blog blog = new Blog();
blog.setId(1);
User user = new User();
user.setId(2);
blog.setAuthor(user);
List<Comment> commentList = new ArrayList<>();
Comment comment = new Comment();
comment.setBody("123");
commentList.add(comment);
blog.setComments(commentList);
//这里author如果为空的话author.id肯定会报错
boolean hasAuthor = expressionEvaluator.evaluateBoolean("id !=null && author.id!=null", blog);
System.out.println(hasAuthor);
boolean hasComments = expressionEvaluator.evaluateBoolean("comments !=null && !comments.isEmpty", blog);
System.out.println(hasComments);
boolean hasCommentBody = expressionEvaluator.evaluateBoolean("comments !=null && comments.get(0).body!=null", blog);
System.out.println(hasCommentBody);
boolean hasCommentBody2 = expressionEvaluator.evaluateBoolean("comments !=null && comments[0].body!=null", blog);
System.out.println(hasCommentBody2);
Iterable<?> comments = expressionEvaluator.evaluateIterable("comments", blog);
for (Object o : comments) {
System.out.println(o);
}
}
}
测试结果:
true
true
true
true
org.coderead.mybatis.bean.Comment@5b1d2887
SqlSource解析过程
说完了OGNL表达式,咱们来说一下sql的解析过程,即从数据源到我们可以执行的sql。SqlSource是sql的数据源,他可以通过注解的方式或xml方式得到对应的源。右边的BoundSql就是可执行sql的所有东西。StatementHandler就是根据他去执行sql的(当然获取他的时候还包装了一层:MappedStatement),待会儿小编会带大家看他的源码,一看就明白了。
小编稍微对sqlSource的实现类讲解一下:
- ProviderSqlSource :第三方法SQL源,每次获取SQL都会基于参数动态创建静态数据源,然后在创建BoundSql
- DynamicSqlSource:动态SQL源包含了SQL脚本,每次获取SQL都会基于参数以及脚本,动态创建创建BoundSql
- RawSqlSource:不包含任何动态元素,原生文本的SQL。但这个SQL是不能直接执行的,需要转换成BoundSql
- StaticSqlSource:包含可执行的SQL,以及参数映射,可直接生成BoundSql。前面三个数据源都要先创建StaticSqlSource然后才创建BoundSql。
因为第三方很少涉及,一般我们只是使用静态或动态,静态很简单直接将#{}变成?,然后将参数值设置进去即可(这边在变成问号的时候,参数值映射也是一一对应的,有兴趣的小伙伴可以去看下源码)。如:select * from user where user_id =#{userId},所以小编着重讲一下动态sql源的解析过程。
动态Sql源解析
先看下动态sql源的解析流程。
看到这儿大家是不是很懵,这个小编是根据源码来写的,首先还是得让大家知道什么是SqlNode,以及我们的动态sql是如何和SqlNode建立起关系的。
SqlNode这里使用了解释器模式,小编这里简单的解释一下这个设计模式。
解释器模式(Interpreter Pattern)提供了评估语言的语法或表达式的方式,它属于行为型模式。这种模式实现了一个表达式接口,该接口解释一个特定的上下文。这种模式被用在 SQL 解析、符号处理引擎等。
简单介绍
意图:给定一个语言,定义它的文法表示,并定义一个解释器,这个解释器使用该标识来解释语言中的句子。
主要解决:对于一些固定文法构建一个解释句子的解释器。
何时使用:如果一种特定类型的问题发生的频率足够高,那么可能就值得将该问题的各个实例表述为一个简单语言中的句子。这样就可以构建一个解释器,该解释器通过解释这些句子来解决该问题。
如何解决:构建语法树,定义终结符与非终结符。
关键代码:构建环境类,包含解释器之外的一些全局信息,一般是 HashMap。
SqlNode主要是来解析Mybatis中的Sql脚本元素,之后将解析完毕sql添加到DynamicContext中去。小编简单说下各个SqlNode的作用:
- SqlNode是总接口只有一个方法:apply(DynamicContext context),作用如上面小编所讲,各个sqlNode处理完对应的逻辑然后将对应sql添加到DynamicContext
- MixedSqlNode包含多个子sqlNode,是个list然后循环调用子节点的逻辑
- ChooseSqlNode,IfSqlNode,ForEachSqlNode,TrimSqlNode这些节点就是来处理对应的sql脚本元素
- StaticTextSqlNode为静态脚本node,直接拼接的是静态脚本,如:select * from user
- TextSqlNode为文本脚本node,他主要是用来替换${}占位符的,直接替换成文本如:select * from ${table_name}
题外话:这边小编想起了一个mybatis的面试题,说${}与#{}替换符的区别,其实结论大家都知道,但是具体实现可能没有真正的看过,有兴趣的小伙伴可以看下。结论是很简单且正确的,但求证的却很少
这些SqlNode是怎样的数据结构才能解析我们的动态sql呢,看过解释器模式就知道他其实构建语法树,怎么构建的看下图:
脚本之间是呈现嵌套关系的。比如if元素中会包含一个MixedSqlNode ,而MixedSqlNode下又会包含1至1至多个其它节点。最后组成一课脚本语法树。如上图左边的SQL元素组成右边的语法树。在节点最底层一定是一个StaticTextNode或 TextNode。
这就是xm中的sqll脚本解析变成语法树的结构。这边小编不一一讲各种元素,这里挑选几个讲一下
if、where、foreach
模拟if,where解析过程:
@Test
public void sqlNodeTest(){
Company company = new Company();
company.setId(1L);
company.setCompanyName("伟大的公司");
DynamicContext dynamicContext = new DynamicContext(configuration,company);
StaticTextSqlNode staticTextSqlNode = new StaticTextSqlNode("select * from company");
staticTextSqlNode.apply(dynamicContext);
IfSqlNode ifIdSqlNode = new IfSqlNode(new StaticTextSqlNode("id = #{id}"),"id != null");
IfSqlNode ifNameSqlNode = new IfSqlNode(new StaticTextSqlNode(" and company_name = #{companyName}"),"companyName != null");
MixedSqlNode mixedSqlNode = new MixedSqlNode(Arrays.asList(ifIdSqlNode,ifNameSqlNode));
WhereSqlNode whereSqlNode= new WhereSqlNode(configuration,mixedSqlNode);
whereSqlNode.apply(dynamicContext);
System.out.println(dynamicContext.getSql());
}
测试结果:
这边大家有没有发现,if其实不需要MixedSqlNode,包装一个StaticTextSqlNode即可,为什么小编在上面的脚本树中包含的是MixedSqlNode,这是因为if里面还可以加if所以里面需要包装使用MixedSqlNode,在mybatis实际过程中也是如此。
froeach
@Test
public void foreachNodeTest(){
Map<String,Object> paramMap = new HashMap<>(1);
List<Long> idList = Arrays.asList(1L,2L,3L);
paramMap.put("idList",idList);
DynamicContext dynamicContext = new DynamicContext(configuration,paramMap);
StaticTextSqlNode staticTextSqlNode = new StaticTextSqlNode("select * from company where id in");
ForEachSqlNode forEachSqlNode = new ForEachSqlNode(configuration,
new MixedSqlNode(Arrays.asList(new StaticTextSqlNode("#{item}"))),"idList","index","item","(",")",",");
staticTextSqlNode.apply(dynamicContext);
forEachSqlNode.apply(dynamicContext);
System.out.println(dynamicContext.getSql());
}
测试结果:
看到这个参数的替换是不是不一样啊。这边小编提个问,为什么List的参数小编要转成map参数传进去?
如果不了解的话建议看下精通Mybatis之Jdbc处理器StatementHandler中的参数转换。
小结
其实真正的解析也差不多,小编只是将他们拆开来了,实际过程根据xml里面的sql语句分段然后使用SqlNode拼装成脚本结构树,顶层只有MixedSqlNode 就是根节点,然后在执行的时候根据结构树变成sql。稍微有点区别的是,sqlNode还会进行一次包装。
动态标签xml解析过程
上面是小编是在底层拆开揉碎了展开的,那从用户角度,咱们编写好xml他是如何解析的呢,其实不难,听小编慢慢道来,SqlSource 是基于XML解析而来,解析的底层是使用Dom4j 把XML解析成一个个子节点,在通过 XMLScriptBuilder 遍历这些子节点最后生成对应的Sql源。其解析流程如下图:
nodeHandler 类图:
源码阅读:
小编编写了稍微复杂的xml的sql然后进行debug调试,测试xml的sql如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mapper.EmployeeMapper">
<resultMap id="employMap" type="entity.Employee" autoMapping="true"/>
<select id="selectByIdListAndName" resultType="collection" resultMap="employMap">
select * from employee
<where>
<if test="idList != null and !idList.isEmpty">
id in
<foreach collection="idList" separator="," index="index" item="item" open="(" close=")">
#{item}
</foreach>
</if>
<if test="name != null">
name = #{name}
</if>
</where>
</select>
</mapper>
XMLScriptBuilder源码(太多了小编捡一些重点):
//构造方法,其中context 就是上面的<select>标签的所有内容,parameterType为属性值的class类型
public XMLScriptBuilder(Configuration configuration, XNode context, Class<?> parameterType) {
super(configuration);
this.context = context;
this.parameterType = parameterType;
initNodeHandlerMap();
}
//初始化NodeHandlerMap,处理各脚本元素的handler,各节点最后是相应的sqlNode
//然后添加到rootNode的contexts列表中(rootNode 类型是MixedSqlNode,
//而contexts为MixedSqlNode中的属性 类型为List<SqlNode>)
private void initNodeHandlerMap() {
nodeHandlerMap.put("trim", new TrimHandler());
nodeHandlerMap.put("where", new WhereHandler());
nodeHandlerMap.put("set", new SetHandler());
nodeHandlerMap.put("foreach", new ForEachHandler());
nodeHandlerMap.put("if", new IfHandler());
nodeHandlerMap.put("choose", new ChooseHandler());
nodeHandlerMap.put("when", new IfHandler());
nodeHandlerMap.put("otherwise", new OtherwiseHandler());
nodeHandlerMap.put("bind", new BindHandler());
}
//开始解析
public SqlSource parseScriptNode() {
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource;
if (isDynamic) {
//如果是动态的就包装成动态的sqlsource
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
//否则就是静态sqlSource
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
protected MixedSqlNode parseDynamicTags(XNode node) {
//创建sqlNode子节点树
List<SqlNode> contents = new ArrayList<>();
//拿到子节点
NodeList children = node.getNode().getChildNodes();
//遍历子节点
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
//拿到各个节点的数据,首先判断是否是文本数据
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
//判断是否是脚本元素 动态sql
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
contents.add(new StaticTextSqlNode(data));
}
//如果是元素node
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
//取出node的名称
String nodeName = child.getNode().getNodeName();
//更加元素node取出对应的NodeHandler
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
//根据对应的nodeHandler处理对应的node节点
handler.handleNode(child, contents);
isDynamic = true;
}
}
//根节点始终为MixedSqlNode
return new MixedSqlNode(contents);
}
NodeHandler源码:这是XMLScriptBuilder的内部接口和内部实现(下面比较简单,不停递归调用最后扔进相应的sqlNode即可)
private interface NodeHandler {
void handleNode(XNode nodeToHandle, List<SqlNode> targetContents);
}
private class BindHandler implements NodeHandler {
public BindHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
final String name = nodeToHandle.getStringAttribute("name");
final String expression = nodeToHandle.getStringAttribute("value");
final VarDeclSqlNode node = new VarDeclSqlNode(name, expression);
targetContents.add(node);
}
}
private class TrimHandler implements NodeHandler {
public TrimHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
String prefix = nodeToHandle.getStringAttribute("prefix");
String prefixOverrides = nodeToHandle.getStringAttribute("prefixOverrides");
String suffix = nodeToHandle.getStringAttribute("suffix");
String suffixOverrides = nodeToHandle.getStringAttribute("suffixOverrides");
TrimSqlNode trim = new TrimSqlNode(configuration, mixedSqlNode, prefix, prefixOverrides, suffix, suffixOverrides);
targetContents.add(trim);
}
}
private class WhereHandler implements NodeHandler {
public WhereHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
WhereSqlNode where = new WhereSqlNode(configuration, mixedSqlNode);
targetContents.add(where);
}
}
private class SetHandler implements NodeHandler {
public SetHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
SetSqlNode set = new SetSqlNode(configuration, mixedSqlNode);
targetContents.add(set);
}
}
private class ForEachHandler implements NodeHandler {
public ForEachHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
String collection = nodeToHandle.getStringAttribute("collection");
String item = nodeToHandle.getStringAttribute("item");
String index = nodeToHandle.getStringAttribute("index");
String open = nodeToHandle.getStringAttribute("open");
String close = nodeToHandle.getStringAttribute("close");
String separator = nodeToHandle.getStringAttribute("separator");
ForEachSqlNode forEachSqlNode = new ForEachSqlNode(configuration, mixedSqlNode, collection, index, item, open, close, separator);
targetContents.add(forEachSqlNode);
}
}
private class IfHandler implements NodeHandler {
public IfHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
String test = nodeToHandle.getStringAttribute("test");
IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
targetContents.add(ifSqlNode);
}
}
private class OtherwiseHandler implements NodeHandler {
public OtherwiseHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
targetContents.add(mixedSqlNode);
}
}
private class ChooseHandler implements NodeHandler {
public ChooseHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
List<SqlNode> whenSqlNodes = new ArrayList<>();
List<SqlNode> otherwiseSqlNodes = new ArrayList<>();
handleWhenOtherwiseNodes(nodeToHandle, whenSqlNodes, otherwiseSqlNodes);
SqlNode defaultSqlNode = getDefaultSqlNode(otherwiseSqlNodes);
ChooseSqlNode chooseSqlNode = new ChooseSqlNode(whenSqlNodes, defaultSqlNode);
targetContents.add(chooseSqlNode);
}
private void handleWhenOtherwiseNodes(XNode chooseSqlNode, List<SqlNode> ifSqlNodes, List<SqlNode> defaultSqlNodes) {
List<XNode> children = chooseSqlNode.getChildren();
for (XNode child : children) {
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler instanceof IfHandler) {
handler.handleNode(child, ifSqlNodes);
} else if (handler instanceof OtherwiseHandler) {
handler.handleNode(child, defaultSqlNodes);
}
}
}
private SqlNode getDefaultSqlNode(List<SqlNode> defaultSqlNodes) {
SqlNode defaultSqlNode = null;
if (defaultSqlNodes.size() == 1) {
defaultSqlNode = defaultSqlNodes.get(0);
} else if (defaultSqlNodes.size() > 1) {
throw new BuilderException("Too many default (otherwise) elements in choose statement.");
}
return defaultSqlNode;
}
}
这里小编将上面xml解析完的树结构展示一下:
where节点比较复杂
将xml解析成SqlNode之后然后交给DynamicSqlSource,在执行sql的时候就会使用getBoundSql方法,参数为设置的参数值:
@Override
public BoundSql getBoundSql(Object parameterObject) {
//DynamicContext 为sqlnode执行逻辑后sql的载体
DynamicContext context = new DynamicContext(configuration, parameterObject);
//执行对应的元素脚本逻辑即sqlnode逻辑
rootSqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
//最后结束就成为了一个staticTextSqlNode了并且将占位符替换好了 即#{}替换成了?
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
//获取boundSql只是new 了一个BoundSql
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
//设置AdditionalParameter
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
SqlNode的apply方法源码较多,小编挑一个看一下:
ForEachSqlNode:
@Override
public boolean apply(DynamicContext context) {
//获取绑定的参数映射,这边两个,大家着重看下idList为key值为list的
Map<String, Object> bindings = context.getBindings();
//通过ognl表达式遍历对应的参数值
final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
//没值直接返回true
if (!iterable.iterator().hasNext()) {
return true;
}
boolean first = true;
//open 就为(
applyOpen(context);
int i = 0;
//遍历
for (Object o : iterable) {
DynamicContext oldContext = context;
//如果是第一个不需要逗号分割否则就用逗号分割
if (first || separator == null) {
context = new PrefixedContext(context, "");
} else {
context = new PrefixedContext(context, separator);
}
int uniqueNumber = context.getUniqueNumber();
// Issue #709
//如果foreach为map则进入第一个判断,否则为第二个
//区别在于index的值,一个是map的key而list是数值
if (o instanceof Map.Entry) {
@SuppressWarnings("unchecked")
Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
applyIndex(context, mapEntry.getKey(), uniqueNumber);
applyItem(context, mapEntry.getValue(), uniqueNumber);
} else {
applyIndex(context, i, uniqueNumber);
applyItem(context, o, uniqueNumber);
}
//因为是循环所以每次先放进FilteredDynamicContext中,最后才完全拼接起来
contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
if (first) {
first = !((PrefixedContext) context).isPrefixApplied();
}
context = oldContext;
i++;
}
applyClose(context);
context.getBindings().remove(item);
context.getBindings().remove(index);
return true;
}
补充值设置过程
上面DynamicSqlSource的getBoundSql方法中有setAdditionalParameter方法,这个方法的作用主要是在设置参数的时候需要用了。小编又又又贴出源码:
@Override
public void setParameters(PreparedStatement ps) {
ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
//属性名称
String propertyName = parameterMapping.getProperty();
//如果有AdditionalParameter
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
//则取出的值是这个
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) {
jdbcType = configuration.getJdbcTypeForNull();
}
try {
typeHandler.setParameter(ps, i + 1, value, jdbcType);
} catch (TypeException | SQLException e) {
throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
}
}
}
}
}
上面不知道小编解释清楚没有,其实就是多个值的时候如id in (?,?,?)的时候,我们的属性值需要一一对应,且问号的属性名称对应一个值。这样我们的foreach标签就只需要一个值的配置,mybatis给我们做了循环且各自配置了属性名称和值。厉不厉害!
总结
今天小编也彻底讲完了动态sql的解析,其实没有大家想象的那么复杂,从OGNL表达式开始,到SqlSource通过流程解析到可以直接执行的BoundSql结构,然后是sqlsource是怎么来的,以及foreach的参数设置的特殊性通通讲了一遍。希望大家和小编一样,收获满满。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/13556.html