Mybatis - 预编译的运用和原理
Mybatis - 预编译
- 一. 什么是预编译
- 1.1 Mybatis中预编译的运用
- 1.2 预编译的原理
- 1.2.1 动态SQL的分类
- 1.2.2 预编译的处理(占位符的替换)
- 1.2.3 执行的时候如何替换参数(参数赋值)
- 1.3 总结
一. 什么是预编译
首先我们来说下预编译的一个背景:我们知道一条SQL
语句到达Mysql
之后,Mysql
并不是会马上执行它,而是需要经过几个阶段性的动作(细节的可以查看Mysql复习计划(一)- 字符集、文件系统和SQL执行流程):
- 缓存的检查。
- 解析器解析。
- 优化器解析。
- 执行器执行。
那么这几个阶段肯定是需要一定的时间的。而有时候我们一条SQL
语句可能需要反复的执行,只不过里面的参数可能不一样,比如where
子句中的条件。如果每次都需要经过上面的几个步骤,那么效率就会下降。因此为了解决这种问题,就出现了预编译。
预编译语句就是将这类语句中的值用占位符替代,可以视为将SQL
语句模板化或者说参数化。一次编译、多次运行,省去了解析优化等过程。
1.1 Mybatis中预编译的运用
Mybatis
中,默认是开启预编译的功能的,我们来测试一下(用的SpringBoot
):
1.pom
依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.1.3</version>
</dependency>
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>1.2.1</version>
</dependency>
<dependency><groupId>org.mybatis</groupId><artifactId>mybatis</artifactId><version>3.4.6</version>
</dependency>
2.配置文件:application.yml
文件。
server:port: 8080spring:datasource:driver-class-name: com.mysql.jdbc.Driverurl: jdbc:mysql://IP地址:3306/数据库名称username: xxxpassword: xxx
# 别名,这样写Mapper配置文件的时候可以省略前缀
mybatis:type-aliases-package: com.application.bean
application.properties
文件:
#druid数据库连接池
type=com.alibaba.druid.pool.DruidDataSource
#配置mapper
mybatis.mapper-locations=classpath:mapper/*.xml
#配置日志输出
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
3.Mapper.xml
文件:
对应目录:
文件内容:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--关联UserMapper接口 ,下面的id要和接口中对应的方法名称一致,实现一对一的对应-->
<mapper namespace="com.application.mapper.UserMapper"><select id="getUserById" parameterType="String" resultType="user">select * from user where userName=#{userName}</select>
</mapper>
4.实体类:
public class User {private String userName;private int id;// get/set
}
5.mapper
接口:
@Mapper
@Repository
public interface UserMapper {User getUserById(String userName);
}
6.Service
类:
@Service
public class UserProcess {@Autowiredprivate UserMapper userMapper;public User getUser(String name){return userMapper.getUserById(name);}
}
7.Controller
类:
@RestController
public class MyController {@Autowiredprivate UserProcess userProcess;@PostMapping("/hello")public User hello(){return userProcess.getUser("tom");}
}
8.运行后的日志输出:
当你看到了占位符 ?
的时候,就说明预编译成功了,Mybatis
就是将#{}
这样的参数用占位符问号来代替。形成一个SQL
模板。以达到预编译的目的。
1.2 预编译的原理
Mybatis
中,对于SQL
的处理解析动作在于XMLStatementBuilder.parseStatementNode
这个类的函数中,我们来从这里为入口来看。我们先来看本文案例中的Mapper
文件:
<mapper namespace="com.application.mapper.UserMapper"><select id="getUserById" parameterType="String" resultType="user">select * from user where userName=#{userName}</select>
</mapper>
1.2.1 动态SQL的分类
再看下源码:
public void parseStatementNode() {// 命名空间中的唯一标识,一般可以用方法名String id = context.getStringAttribute("id");// 对应的数据库标识String databaseId = context.getStringAttribute("databaseId");if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {return;}// 驱动程序每次批量返回的结果行数Integer fetchSize = context.getIntAttribute("fetchSize");// 等待数据库返回结果的超时时间Integer timeout = context.getIntAttribute("timeout");String parameterMap = context.getStringAttribute("parameterMap");// 该SQL中,传入的参数的完全限定名或者别名。本文是userString parameterType = context.getStringAttribute("parameterType");Class<?> parameterTypeClass = resolveClass(parameterType);// Map结果集String resultMap = context.getStringAttribute("resultMap");// 该SQL返回类型String resultType = context.getStringAttribute("resultType");String lang = context.getStringAttribute("lang");LanguageDriver langDriver = getLanguageDriver(lang);Class<?> resultTypeClass = resolveClass(resultType);// 结果集的相关配置,下文给出详细的点String resultSetType = context.getStringAttribute("resultSetType");// 语句类型的处理,下文给出详细的点StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);String nodeName = context.getNode().getNodeName();// 得到SQL命令的类型:对应着增删改查,有这么几种枚举值:UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH;SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));boolean isSelect = sqlCommandType == SqlCommandType.SELECT;// 设置为true的时候,只要语句被调用,就会让本地缓存和二级缓存被清空。默认值为false(针对select)boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);// true代表将本次查询的语句通过二级缓存缓存起来,针对select,为true。boolean useCache = context.getBooleanAttribute("useCache", isSelect);boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);// Include Fragments before parsingXMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);includeParser.applyIncludes(context.getNode());// Parse selectKey after includes and remove them.processSelectKeyNodes(id, parameterTypeClass, langDriver);// 生成SQL的地方。SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);// ...省略
}
resultSetType
有哪些类型?
FORWARD_ONLY
:结果集的游标只能向下滚动。SCROLL_INSENSITIVE
:结果集的游标可以上下移动,当数据库变化时,当前结果集不变。SCROLL_SENSITIVE
:返回可滚动的结果集,当数据库变化时,当前结果集同步改变。
statementType
有哪些类型?
STATEMENT
:普通语句。PREPARED
:预处理。CALLABLE
:存储过程。
那么本文只关注于预编译的处理过程。我们继续看上述函数中的关键代码:
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
这段代码就是通过LanguageDriver
对SQL
语句进行解析,返回的结果中,就可能包含动态SQL
或者占位符。我们来看下这个函数的源码:LanguageDriver
的默认实现是XMLLanguageDriver
。
public class XMLLanguageDriver implements LanguageDriver {// 接着到这里@Overridepublic SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {// 创建一个XMLScriptBuilder对象。并通过它来解析SQL脚本XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);return builder.parseScriptNode();}↓↓↓↓↓↓↓public SqlSource parseScriptNode() {MixedSqlNode rootSqlNode = parseDynamicTags(context);SqlSource sqlSource = null;// 这里就是重点了,判断这个SQL是否是动态的if (isDynamic) {sqlSource = new DynamicSqlSource(configuration, rootSqlNode);} else {sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);}return sqlSource;}
}
从上面源码可以得出,最终SQL
构建起来,有两个分支:
- 动态的:通过
DynamicSqlSource
来构建。 - 非动态的:通过
RawSqlSource
来构建。
那么是否为动态的判断标准是啥呢?关键看下面这行代码:
MixedSqlNode rootSqlNode = parseDynamicTags(context);
↓↓↓↓↓↓↓
protected MixedSqlNode parseDynamicTags(XNode node) {List<SqlNode> contents = new ArrayList<SqlNode>();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 textSqlNode = new TextSqlNode(data);if (textSqlNode.isDynamic()) {contents.add(textSqlNode);isDynamic = true;} else {contents.add(new StaticTextSqlNode(data));}} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628String nodeName = child.getNode().getNodeName();NodeHandler handler = nodeHandlerMap.get(nodeName);if (handler == null) {throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");}handler.handleNode(child, contents);isDynamic = true;}}return new MixedSqlNode(contents);
}
↓↓↓↓↓↓↓
textSqlNode.isDynamic()
↓↓↓↓↓↓↓
public class TextSqlNode implements SqlNode {public boolean isDynamic() {DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();GenericTokenParser parser = createParser(checker);parser.parse(text);return checker.isDynamic();}↓↓↓↓↓↓↓private GenericTokenParser createParser(TokenHandler handler) {return new GenericTokenParser("${", "}", handler);}
}
到这里为止,我们可以观察到,在判断这个SQL
是否为动态的时候,底层会创建一个GenericTokenParser
类型的解析器。而这个解析器呢则是一个以 ${
为开始和以 }
为结尾的解析器。如果解析成功,说明该SQL
中包含了${}
,那么该SQL
就会被标记为动态SQL
标签。
那么反之,我们回到这段代码中:
对于#{}
这样的语句,就是一个非动态标签。总结下就是:
- 如果
SQL
中的参数是用${}
作为占位符的,那么该SQL
属于动态SQL
,封装为DynamicSqlSource
。 - 否则其他的都是非动态
SQL
,封装为RawSqlSource
。
1.2.2 预编译的处理(占位符的替换)
那么我们来看下RawSqlSource
的构造函数:
public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);Class<?> clazz = parameterType == null ? Object.class : parameterType;// SQL的解析sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<String, Object>());
}public class SqlSourceBuilder extends BaseBuilder {public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {// 主要的一个解析器ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);// 获取真实的可执行性的sql语句String sql = parser.parse(originalSql);// 包装成StaticSqlSource然后返回return new StaticSqlSource(configuration, sql, handler.getParameterMappings());}
}
这里我们可以看出来,对于非动态SQL
,会生成一个以 #{
为开头,}
为结尾的解析器。紧接着就会创建一个StaticSqlSource
类,在这里做个区分:
RawSqlSource
: 存储的是只有#{}
或者没有标签的纯文本SQL
信息DynamicSqlSource
: 存储的是写有${}
或者具有动态SQL
标签的SQL
信息StaticSqlSource
: 是DynamicSqlSource
和RawSqlSource
解析为BoundSql
的一个中间态对象类型。BoundSql
:用于生成我们最终执行的SQL
语句,属性包括参数值、映射关系、以及SQL
(带问号的)
接着我们看下解析器ParameterMappingTokenHandler
的处理,它是SqlSourceBuilder
的一个静态内部类:
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {@Overridepublic String handleToken(String content) {parameterMappings.add(buildParameterMapping(content));return "?";}
}
这段代码在哪里用到呢?就是在GenericTokenParser.parse()
这段代码中执行到的:
public class GenericTokenParser {public String parse(String text) {// ...while (start > -1) {if (start > 0 && src[start - 1] == '\\') {// this open token is escaped. remove the backslash and continue.builder.append(src, offset, start - offset - 1).append(openToken);offset = start + openToken.length();} else {// ...if (end == -1) {// close token was not found.builder.append(src, start, src.length - start);offset = src.length;} else {// 就是找到了#{}的结束标识},然后将中间的内容替换成?builder.append(handler.handleToken(expression.toString()));offset = end + closeToken.length();}}start = text.indexOf(openToken, offset);}if (offset < src.length) {builder.append(src, offset, src.length - offset);}return builder.toString();}
}
这段代码执行完毕之后,我们发现最后返回了一个StaticSqlSource
对象。
1.2.3 执行的时候如何替换参数(参数赋值)
那么在执行SQL
的时候,则会去根据BoundSql
来完成参数的赋值等操作。我们来看下RawSqlSource.getBoundSql
这个函数:
public class RawSqlSource implements SqlSource {private final SqlSource sqlSource;@Overridepublic BoundSql getBoundSql(Object parameterObject) {return sqlSource.getBoundSql(parameterObject);}
}
因为只有#{}
的SQL
语句,在上文中可以看到最后会生成一个StaticSqlSource
对象,而这个类中就重写了getBoundSql
函数,里面主要构造了一个BoundSql
对象。
public class StaticSqlSource implements SqlSource {@Overridepublic BoundSql getBoundSql(Object parameterObject) {return new BoundSql(configuration, sql, parameterMappings, parameterObject);}
}
Mybatis
在执行SQL
的时候,主要看的是SimpleExecutor.prepareStatement
这个入口:
public class SimpleExecutor extends BaseExecutor {private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {Statement stmt;// 获取JDBK的数据库连接Connection connection = getConnection(statementLog);// 准备工作,初始化Statement连接stmt = handler.prepare(connection, transaction.getTimeout());// 使用ParameterHandler处理入参handler.parameterize(stmt);return stmt;}
}
我们主要关注入参的处理函数上:
public class PreparedStatementHandler extends BaseStatementHandler {@Overridepublic void parameterize(Statement statement) throws SQLException {parameterHandler.setParameters((PreparedStatement) statement);}
}
↓↓↓↓↓
public class DefaultParameterHandler implements ParameterHandler {@Overridepublic void setParameters(PreparedStatement ps) {ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());// 从boundSql中拿到我们传入的参数List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();if (parameterMappings != null) {// 循环处理每一个参数,需要应用到类型转换器for (int i = 0; i < parameterMappings.size(); i++) {ParameterMapping parameterMapping = parameterMappings.get(i);// mode属性有三种:IN, OUT, INOUT。如果参数为 OUT 或 INOUT,参数对象属性的真实值将会被改变if (parameterMapping.getMode() != ParameterMode.OUT) {Object value;String propertyName = parameterMapping.getProperty();if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional paramsvalue = boundSql.getAdditionalParameter(propertyName);} else if (parameterObject == null) {value = null;} // 如果类型处理器里面有这个类型,直接赋值即可。else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {value = parameterObject;} // 否则转化为元数据处理,通过反射来完成get/set赋值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 {// 使用不同的类型处理器向jdbc中的PreparedStatement设置参数typeHandler.setParameter(ps, i + 1, value, jdbcType);} catch (TypeException e) {throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);} catch (SQLException e) {throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);}}}}}
}
前面一部分逻辑主要是对参数进行类型转换。最后则对SQL
进行参数的赋值和替换。那么我们主要关注最后的赋值代码:
typeHandler.setParameter(ps, i + 1, value, jdbcType);
↓↓↓↓↓
public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> {@Overridepublic void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {if (parameter == null) {if (jdbcType == null) {throw new TypeException("JDBC requires that the JdbcType must be specified for all nullable parameters.");}try {ps.setNull(i, jdbcType.TYPE_CODE);} catch (SQLException e) {throw new TypeException("Error setting null for parameter #" + i + " with JdbcType " + jdbcType + " . " +"Try setting a different JdbcType for this parameter or a different jdbcTypeForNull configuration property. " +"Cause: " + e, e);}} else {try {setNonNullParameter(ps, i, parameter, jdbcType);} catch (Exception e) {throw new TypeException("Error setting non null for parameter #" + i + " with JdbcType " + jdbcType + " . " +"Try setting a different JdbcType for this parameter or a different configuration property. " +"Cause: " + e, e);}}}
}
我们重点关注那些非null
值的赋值,看下Mybatis
是怎么替换SQL
中的 ?
的:
setNonNullParameter(ps, i, parameter, jdbcType);
这个函数是一个抽象函数,有很多具体的实现,对应的参数是什么类型的,就用对应类型的处理方式去完成,如图:
无论是哪种类型,本质上都是对 ?
占位符进行值的替换操作。
1.3 总结
因为本篇文章主要是将Mybatis
对于预处理的原理。因此整个Mybatis
的机制或者是二级缓存、一级缓存的知识点不在本文内容范围中(准备再写几篇内容补上)。本文主要讲了:什么是预编译。Mybatis
中对预编译的处理、如何生成模板SQL
、如何进行值的替换。
因此这里做个小总结:
首先预编译对于Mybatis
而言,相当于构建出了一条SQL
的模板,将#{}
对应的参数改为?
而已。届时只需要更改参数的值即可,无需再对SQL
进行语法解析等操作。
对于动态SQL
的判断,就是在于是否包含${}
占位符。如果包含了就通过DynamicSqlSource
来解析。而这里则影响到SQL
的解析:
DynamicSqlSource
:解析包含${}
的语句,其实也会解析#{}
的语句。RawSqlSource
:解析只包含#{}
的语句。
这两种类型到最后都会转化为StaticSqlSource
,然后由他创建一个BoundSql
对象。包括参数值、映射关系、以及转化好的SQL
。
最后是关于SQL
的执行,即如何将真实的参数赋值到我们上面生成的模板SQL
中。这部分逻辑发生在SQL
的执行过程中,其入口SimpleExecutor.prepareStatement
主要做了这么几件事。
- 根据我们上面生成的
BoundSql
对象。拿到我们传入的参数。 - 对每个参数进行解析,转化成对应的类型。
- 如果转化出的参数值为
null
,则直接赋值,否则,还要通过类型处理器来完成赋值操作。typeHandler.setParameter(ps, i + 1, value, jdbcType);
- 每种类型处理器,则会对对应的参数进行赋值。
备注,Mybatis支持的类型处理器可以看TypeHandlerRegistry这个类,相关处理器的注册在其构造函数中。这里贴出部分截图:
Mybatis - 预编译的运用和原理相关推荐
- mybatis 预编译
MyBatis预编译 <select id="getUserById" resultMap="UserMap" parameterType="j ...
- mybatis以及预编译如何防止SQL注入
SQL注入是一种代码注入技术,用于攻击数据驱动的应用,恶意的SQL语句被插入到执行的实体字段中(例如,为了转储数据库内容给攻击者).[摘自] SQL injection - Wikipedia SQL ...
- Javascript作用域原理---预编译
问题的提出 首先看一个例子: var name = 'laruence'; function echo() { alert(name); var name = 'eve'; alert(name); ...
- mybatis深入理解(一)之 # 与 $ 区别以及 sql 预编译
mybatis 中使用 sqlMap 进行 sql 查询时,经常需要动态传递参数,例如我们需要根据用户的姓名来筛选用户时,sql 如下: select * from user where name = ...
- .NET1.1中预编译ASP.NET页面实现原理浅析[1]自动预编译机制浅析
.NET1.1中预编译ASP.NET页面实现原理浅析[1]自动预编译机制浅析 .NET1.1中预编译ASP.NET页面实现原理浅析[1]自动预编译机制浅析 作者:&;nbsp来自:网络 htt ...
- 深入剖析ASP.NET的编译原理之二:预编译(Precompilation)
(转载)在本篇文章的第一部分:[原创]深入剖析ASP.NET的编译原理之一:动态编译(Dynamical Compilation),详细讨论了ASP.NET如何进行动态编译的,现在我们来谈谈另外一种重 ...
- JavaWeb篇之二------sql注入的原理和解决方法(预编译)
引言 在上一篇最末,我展示了sql注入现象,接下来我们来探究sql注入的本质原理 Sql注入及解决方法 我们打个断点,debug调试一下(不清楚代码的可以看上一篇) 我们可以看到"泊进之介& ...
- JavaScript中函数作用域之精辟,函数原理的浅入深出,及程序执行预编译之通天编译???
1.程序执行的前一刻会先将代码预编译一遍,如果有语法错误则直接终止程序运行 //预编译之通天编译 --> 在执行的前一刻,会把文件通天扫描一遍 /** //预编译 函数整体提升(即函数会放到程序 ...
- JavaScript作用域原理——预编译
JavaScript是一种脚本语言, 它的执行过程, 是一种翻译执行的过程.并且JavaScript是有预编译过程的,在执行每一段脚本代码之前, 都会首先处理var关键字和function定义式(函数 ...
最新文章
- 简析正则表达式的使用
- python人工智能方向面试准备_高薪直通车丨人工智能+Python面试经验分享(西安**思数据)...
- Git复习(十)之常见报错和疑问
- Palo Alto Networks全球化安全堡垒理念 提升企业防御能力
- C++unique函数应用举例
- 网站改title的后果到底有多惨?
- matlab createtask,Matlab批量与createjob
- python redis 集群_python与java中使用redis集群
- Missing Push Notification Entitlement警告-----以及解决方法
- 云存储云计算选择开源还是商业版
- LimeSDR常见问题及解决方法
- 智慧海洋task04 利用数据进行建模并调参
- 模型的参数verbose用法详解
- iOS m3u8本地缓存播放(控制下载并发、暂停恢复)
- 外部数据的合规引入助力银行用户营销系统冷启动
- b站视频详情数据抓取,自动打包并发送到指定邮箱(单个或者群发)
- 北京国际学校IB考试均分稳得一匹,IB考试结果揭秘
- funny_upload
- 弱引用(WeakReference)初识
- 【立创EDA开源推荐】001期 | 基于航顺HK32F030R8串行Flash烧录器
热门文章
- 这样清理运行内存,你的iphone就不会卡了
- 酷炫的个人动态引导页
- 有哪些好用的pdf修改器?思路提供
- Java8 Lambda表达式 ArrayList排序
- Installshield环境变量的追加与删除设置
- 《kafka问答100例 -4》 如果我手动在zk中添加/brokers/topics/{TopicName}节点会怎么样?
- Salesforce练习Case
- SP8093 JZPGYZ - Sevenk Love Oimaster(广义SAM)
- MLeaksFinder :腾讯开源的 iOS 内存泄漏检测工具
- 沈坤:中国餐饮严重缺乏创新意识