Mybatis之一二级缓存和拦截器
1 Mybatis缓存
1.1 一级缓存
一级缓存Mybatis
的一级缓存是指SQLSession
,一级缓存的作用域是SQLSession, Mabits默认开启一级缓存
。
在同一个SqlSession
中,执行相同的SQL
查询时;第一次会去查询数据库,并写在缓存中,第二次会直接从缓存中取。
当执行SQL
时候两次查询中间发生了增删改
的操作,则SQLSession
的缓存会被清空。
每次查询会先去缓存中找,如果找不到,再去数据库查询,然后把结果写到缓存中。
Mybatis
的内部缓存使用一个HashMap
,key
为hashcode+statementId+sql
语句。Value
为查询出来的结果集映射成的java对象
SqlSession
执行insert
、update
、delete
等操作commit
后会清空该SQLSession
缓存
一级缓存只是相对于同一个SqlSession
而言。所以在参数和SQL
完全一样的情况下,我们使用同一个SqlSession
对象调用一个Mapper
方法,往往只执行一次SQL
,因为使用SelSession
第一次查询后,MyBatis
会将其放在缓存中,以后再查询的时候,如果没有声明需要刷新,并且缓存没有超时的情况下,SqlSession
都会取出当前缓存的数据,而不会再次发送SQL到数据库
1.1.1 一级缓存的生命周期
MyBatis
在开启一个数据库会话时,会 创建一个新的SqlSession
对象,SqlSession
对象中会有一个新的Executor
对象
Executor
对象中持有一个新的PerpetualCache
对象;当会话结束时,SqlSession
对象及其内部的Executor
对象还有PerpetualCache
对象也一并释放掉
如果SqlSession
调用了close()
方法,会释放掉一级缓存PerpetualCache
对象,一级缓存将不可用
如果SqlSession
调用了clearCache()
,会清空PerpetualCache
对象中的数据,但是该对象仍可使用
如果SqlSession
中执行了任何一个update
操作(update()、delete()、insert()
) ,都会清空PerpetualCache
对象的数据,但是该对象可以继续使用
一级缓存最多缓存 1024
条 SQL
1.1.2 怎么判断某两次查询是完全相同的查询
mybatis
认为,对于两次查询,如果以下条件都完全一样,那么就认为它们是完全相同的两次查询:
- 传入的
statementId
- 查询时要求的结果集中的结果范围
- 这次查询所产生的最终要传递给
JDBC java.sql.Preparedstatement的Sql
语句字符串(boundSql.getSql() ) - 传递给
java.sql.Statement
要设置的参数值
1.1.3 Springboot集成时一级缓存不生效问题
因为一级缓存是会话
级别的,要生效的话,必须要在同一个 SqlSession
中。但是与 springboot
集成的 mybatis
,默认每次执行 sql
语句时,都会创建一个新的 SqlSession
,所以一级缓存才没有生效。
当调用 mapper
的方法时,最终会执行到 SqlSessionUtils
的 getSqlSession
方法,在这个方法中会尝试在事务管理器中获取 SqlSession
,如果没有开启事务,那么就会 new 一个 DefaultSqlSession
所以可以猜测只要将方法开启事务,那么一级缓存就会生效,加上 @Transactional
注解
那么为什么加了@Transactional
注解就可以了呢,看源码解析:
看看源码中是什么时候将 SqlSession
设置到事务管理器中的。
SqlSessionUtils
中,在获取到 SqlSession
后,会调用 registerSessionHolder
方法注册 SessionHolder
到事务管理器:
具体是在 TransactionSynchronizationManager
的 bindResource
方法中操作的,将 SessionHolder
保存到线程本地变量(ThreadLocal) resources
中,这是每个线程独享的。
然后在下次查询时,就可以从这里取出此 SqlSession
,使用同一个 SqlSession
查询,一级缓存就生效了。
所以基本原理就是:如果当前线程存在事物,并且存在相关会话,就从 ThreadLocal
中取出。如果没有事务,就重新创建一个 SqlSession
并存储到 ThreadLocal
当中,共下次查询使用。
至于缓存查询数据的地方,是在 BaseExecutor
中的 queryFromDatabase
方法中。执行 doQuery
从数据库中查询数据后,会立马缓存到 localCache(PerpetualCache类型)
中:
1.2 二级缓存
1.2.1 基础
二级缓存是mapper级别
的,Mybatis
默认是没有开启二级缓存的,需要在setting全局参数中配置开启二级缓存
第一次调用mapper
下的SQL
去查询用户的信息,查询到的信息会存放到该mapper
对应的二级缓存区域。 第二次调用namespace
下的mapper
映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果
二级缓存是多个SqlSession
共享的,多个SqlSession
去操作同一个Mapper
的sql
语句,其作用域是mapper
的同一个namespace
,不同的sqlSession
两次执行相同namespace
下的sql语句
且向sql中传递参数也相同即最终执行相同的sql语句,第一次执行完毕会将数据库中查询的数据写到缓存(内存),第二次会从缓存中获取数据将不再从数据库查询,从而提高查询效率
为了更加清楚的描述二级缓存,先来看一个示意图:
sqlSessionFactory
层面上的二级缓存默认是不开启的,二级缓存的开启需要进行配置,实现二级缓存的时候,MyBatis要求返回的POJO必须是可序列化的。 也就是要求实现Serializable接口,配置方法很简单,只需要在映射XML文件配置就可以开启缓存了<cache/>
,如果配置了二级缓存就意味着:
- 映射语句文件中的所有select语句将会被缓存
- 映射语句文件中的所有insert、update和delete语句会刷新缓存
- 缓存会使用默认的Least Recently Used(LRU,最近最少使用的)算法来收回
- 根据时间表,比如No Flush Interval,(CNFI没有刷新间隔),缓存不会以任何时间顺序来刷新
- 缓存会存储列表集合或对象(无论查询方法返回什么)的1024个引用
- 缓存会被视为是read/write(可读/可写)的缓存,意味着对象检索不是共享的,而且可以安全的被调用者修改,不干扰其他调用者或线程所做的潜在修改
1.2.2 使用二级缓存
1.2.2.1 序列化
将po类
实现Serializable
接口
需要将要缓存的pojo
实现Serializable
接口,为了将缓存数据取出执行反序列化操作,因为二级缓存数据存储介质多种多样,不一定只存在内存中,有可能存在硬盘中,如果我们要再取这个缓存的话,就需要反序列化了。所以建议mybatis
中的pojo都去实现Serializable
接口
由于二级缓存的数据不一定都是存储到内存中,它的存储介质多种多样,所以需要给缓存的对象执行序列化
1.2.2.2 配置缓存
<cache
eviction="FIFO" <!-- 回收策略为先进先出 -->
flushInterval="60000" <!--自动刷新时间60s-->
size="512" <!--最多缓存512个引用对象-->
readOnly="true"/> <!--只读-->
在需要开启的namespace
下配置cache标签
,其中标签中的属性:
eviction:
代表的是缓存回收策略,目前MyBatis
提供以下策略:
LRU:最近最少使用的,一处最长时间不用的对象
FIFO:先进先出,按对象进入缓存的顺序来移除他们
SOFT:软引用,移除基于垃圾回收器状态和软引用规则的对象
WEAK:弱引用,更积极的移除基于垃圾收集器状态和弱引用规则的对象。这里采用的是LRU,移除最长时间不用的对形象
flushInterval
:刷新间隔时间,单位为毫秒,这里配置的是100
秒刷新,如果你不配置它,那么当SQL被执行的时候才会去刷新缓存size
:引用数目,一个正整数,代表缓存最多可以存储多少个对象,不宜设置过大。设置过大会导致内存溢出。这里配置的是1024个对象readOnly
:只读,意味着缓存数据只能读取而不能修改,这样设置的好处是我们可以快速读取缓存,缺点是我们没有办法修改缓存,他的默认值是false
,即不允许修改
在对应的sql
语句上增加属性useCache="true"
开启缓存,或者flushCache="true"
刷新缓存
namespace
中配置缓存demo:
<?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="com.yihaomen.mybatis.dao.StudentMapper">
<!--开启本mapper的namespace下的二级缓存-->
<cache eviction="LRU" flushInterval="100000" readOnly="true" size="1024"/>
<resultMap id="studentMap" type="Student">
<id property="id" column="id" />
<result property="name" column="name" />
<result property="age" column="age" />
<result property="gender" column="gender" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler" />
</resultMap>
<!--可以通过设置useCache来规定这个sql是否开启缓存,ture是开启,false是关闭-->
<select id="selectAllStudents" resultMap="studentMap" useCache="true">
SELECT id, name, age FROM student
</select>
<!--刷新二级缓存
<select id="selectAllStudents" resultMap="studentMap" flushCache="true">
SELECT id, name, age FROM student
</select>
-->
</mapper>
或者在全局配置中开启缓存开关,这样就把哪些不需要缓存的使用useCache="false"
,禁用缓存就可以了
<settings>
<!-- 打开延迟加载的开关 -->
<setting name = "lazyLoadingEnabled" value = "true"/>
<!-- 把积极加载修改为消极加载,即延迟加载 -->
<setting name = "aggressiveLazyLoading" value = "false"/>
<!-- 开启二级缓存 -->
<setting name = "cacheEnabled" value = "true"/>
</settings>
1.2.2.3 SpringBoot使用二级缓存
在 yaml 中配置 cache-enabled 为 true
mybatis:
configuration:
cache-enabled: true
Mapper
接口上添加 @CacheNamespace
注解:
@CacheNamespace
是 MyBatis
框架中的注解,用于指定命名空间的缓存配置。通过该注解,可以配置该命名空间下的缓存策略,包括缓存类型、缓存大小、缓存过期时间等。
使用 @CacheNamespace
注解可以提高 MyBatis
的查询效率,避免频繁地访问数据库,提高系统的性能。同时,也可以通过该注解来控制缓存的更新策略,保证数据的一致性。
需要注意的是,@CacheNamespace
注解只能用于命名空间级别的缓存配置,不能用于单个 SQL
语句的缓存配置。如果需要对单个 SQL
语句进行缓存配置,可以使用 @Options
注解或在 SQL
语句中使用 <cache>
标签来实现。
@CacheNamespace
和 @CacheNamespaceRef
区别:
@CacheNamespace
和@CacheNamespaceRef
都是MyBatis
中用于配置缓存的注解,但是它们的作用和使用方式略有不同。@CacheNamespace
注解用于标注一个Mapper
接口的缓存配置,可以配置该Mapper
接口中所有查询语句的缓存策略。使用方式如下:
@CacheNamespace(implementation = MybatisRedisCache.class, eviction = MybatisRedisCache.class, flushInterval = 60000, size = 1024)
public interface UserMapper {
// ...
}
其中,implementation
属性指定了缓存实现类,eviction
属性指定了缓存的清除策略,flushInterval
属性指定了缓存的刷新时间间隔,size 属性指定了缓存的最大容量。
@CacheNamespaceRef
注解用于引用另一个Mapper
接口的缓存配置,可以将该Mapper
接口的缓存配置与另一个Mapper
接口的缓存配置共享。使用方式如下:
@CacheNamespaceRef(UserMapper.class)
public interface OrderMapper {
// ...
}
其中,value 属性指定了被引用的 Mapper 接口。
需要注意的是,@CacheNamespace
和 @CacheNamespaceRef
注解都需要与缓存实现类一起使用,用于指定缓存的具体实现。同时,这两个注解也可以同时使用,用于实现更复杂的缓存配置。
1.2.3 二级缓存生效&清除条件
生效的条件:
- 当会话提交或关闭之后才会填充二级缓存
- 必须是同一个
mapper
,即同一个命名空间 - 必须是相同的
statement
,即同一个mapper
中的同一个方法 - 必须是相同的 SQL 语句和参数
- 如果
readWrite=true(默认就是true)
,实体对像必须实现Serializable
接口
缓存清除条件:
- 只有修改会话提交之后,才会执行清空操作
xml
中配置的update
不能清空@CacheNamespace
中的缓存数据- 任何一种增删改操作都会清空整个
namespace
中的缓存
1.2.4 源码中填充二级缓存
在生效条件中提到了,二级缓存必须要在会话提交或关闭之后,才能生效
在查询到结果后,会调用 SqlSession
的 commit
方法进行提交(如果开启事务的话,提交 SqlSession
走的不是这里了,但最终填充二级缓存的地方是一样的。):
在此方法中,最终会调用到 TransactionalCache
的 flushPendingEntries
方法中填充二级缓存:
注意
:springboot
集成 mybatis
的话,如果没有开启事务,每次执行查询,都会创建新的 SqlSession
,所以即使是在同一个方法中进行查询操作,那也是跨会话的。
1.2.5 查询时如何使用二级缓存
在查询的时候,最终会调用 MybatisCachingExecutor
的 query
方法,里面会从 TransactionalCacheManager
中尝试根据 key
获取二级缓存的内容。
这个 key 很长,由 mapper、调用的查询方法、SQL 等信息拼接而成,这也是为什么想要二级缓存生效,必须满足前面所说的条件。
如果能在二级缓存中查询到,就直接返回了,不需要访问数据库。
具体的调用层数实在太多,用到了装饰者模式,最终是在 PerpetualCache 中获取缓存的:
打印日志是在 LoggingCache
中:
1.2.6 为什么mybatis默认不开启二级缓存
二级缓存虽然能带来一定的好处,但是有很大的隐藏危害
它的缓存是以 namespace(mapper)
为单位的,不同 namespace
下的操作互不影响。且 insert/update/delete
操作会清空所在 namespace
下的全部缓存。
那么问题就出来了,假设现在有 ItemMapper
以及 XxxMapper
,在 XxxMapper
中做了表关联查询,且做了二级缓存。此时在 ItemMapper
中将 item
信息给删了,由于不同 namespace
下的操作互不影响,XxxMapper
的二级缓存不会变,那之后再次通过 XxxMapper
查询的数据就不对了,非常危险。
来看一个例子:
@Mapper
@Repository
@CacheNamespace
public interface XxxMapper {
@Select("select i.id itemId,i.name itemName,p.amount,p.unit_price unitPrice " +
"from item i JOIN payment p on i.id = p.item_id where i.id = #{id}")
List<PaymentVO> getPaymentVO(Long id);
}
@Autowired
private XxxMapper xxxMapper;
@Test
void test() {
System.out.println("==================== 查询PaymentVO ====================");
List<PaymentVO> voList = xxxMapper.getPaymentVO(1L);
System.out.println(JSON.toJSONString(voList.get(0)));
System.out.println("==================== 更新item表的name ==================== ");
Item item = itemMapper.selectById(1);
item.setName("java并发编程");
itemMapper.updateById(item);
System.out.println("==================== 重新查询PaymentVO ==================== ");
List<PaymentVO> voList2 = xxxMapper.getPaymentVO(1L);
System.out.println(JSON.toJSONString(voList2.get(0)));
}
上面的代码,test()
方法中前后两次调用了 xxxMapper.getPaymentVO
方法,因为没有加 @Transactional
注解,所以前后两次查询,是两个不同的会话,第一次查询完后,SqlSession
会自动 commit
,所以二级缓存能够生效;
然后在中间进行了 Item
表的更新操作,修改了下名称;
由于itemMapper
与 xxxMapper
不是同一个命名空间,所以 itemMapper
执行的更新操作不会影响到 xxxMapper
的二级缓存;
再次调用 xxxMapper.getPaymentVO
,发现取出的值是走缓存的,itemName
还是老的。但实际上 itemName
在上面已经被改了
1.3 使用Ehcache
点击此处了解Ehcache原理
Mybatis
本身是一个持久层框架,它不是专门的缓存框架,所以它对缓存的实现不够好,不能支持分布式。
Ehcache
是一个分布式的缓存框架。
1.3.1 mapper.xml文件中使用
设置映射文件中cache标签的type值为ehcache的实现类
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
<select id="findUserById" parameterType="java.lang.Long" resultType="com.sxt.model.User" flushCache="false">
SELECT * FROM tb_user WHERE id = #{id}
</select>
1.3.2 添加Ehcache的配置文件
在src/main/resources
下创建cache
文件夹,在文件夹下创建ehcache.xml
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../config/ehcache.xsd">
<diskStore path="java.io.tmpdir"/>
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
overflowToDisk="true"
maxElementsOnDisk="10000000"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"
/>
</ehcache>
defaultCache
标签中属性:
name
:缓存名称。maxElementsInMemory
:缓存最大个数。eternal
:对象是否永久有效,一但设置了,timeout将不起作用。timeToIdleSeconds
:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false
对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。timeToLiveSeconds
:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0
,也就是对象存活时间无穷大。overflowToDisk
:当内存中对象数量达到maxElementsInMemory
时,Ehcache
将会对象写到磁盘中。diskSpoolBufferSizeMB
:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。maxElementsOnDisk
:硬盘最大缓存个数。diskPersistent
:是否缓存虚拟机重启期数据 ,The default value is false.diskExpiryThreadIntervalSeconds
:磁盘失效线程运行时间间隔,默认是120秒memoryStoreEvictionPolicy
:当达到maxElementsInMemory
限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU
(最近最少使用)。你可以设置为FIFO
(先进先出)或是LFU(较少使用)。clearOnFlush
:内存数量最大时是否清除
1.4 Mybatis的Executor执行器
Mybatis
有三种基本的Executor
执行器,SimpleExecutor、ReuseExecutor、BatchExecutor
SimpleExecutor
:每执行一次update
或select
,就开启一个Statement
对象,用完立刻关闭Statement
对象。ReuseExecutor
:执行update
或select
,以sql
作为key
查找Statement
对象,存在就使用,不存在就创建,用完后,不关闭Statement
对象,而是放置于Map<String, Statement>
内,供下一次使用。简言之,就是重复使用Statement
对象。BatchExecutor
:执行update
(没有select
,JDBC
批处理不支持select
),将所有sql
都添加到批处理中(addBatch()
),等待统一执行(executeBatch()
),它缓存了多个Statement
对象,每个Statement
对象都是addBatch()
完毕后,等待逐一执行executeBatch()
批处理。与JDBC
批处理相同
2 Mybatis拦截器
2.1 拦截器介绍
2.1.1 简介
Mybatis
拦截器设计的初衷就是为了供用户在某些时候可以实现自己的逻辑而不必去动Mybatis
固有的逻辑。减少代码侵入
通过Mybatis
拦截器我们可以拦截某些方法的调用,我们可以选择在这些被拦截的方法执行前后加上某些逻辑,也可以在执行这些被拦截的方法时执行自己的逻辑而不再执行被拦截的方法。所以Mybatis
拦截器的使用范围是非常广泛的。
2.1.2 核心概念
MyBatis
拦截器工作时,核心组件是 Invocation
、Interceptor
、Method
和 Target
对象等:
Interceptor
:这是所有自定义拦截器的接口,MyBatis
会根据配置找到并调用实现该接口的类。Invocation
:封装了方法调用的对象,它包含了目标方法的信息以及方法的参数。通过Invocation
对象,我们可以对方法的执行进行控制。Method
:表示目标方法,它是通过反射来获取的。Target
:表示目标对象,它是被拦截的对象。例如,Executor、StatementHandler 等都是目标对象,拦截器会通过 Target 对象来访问和控制这些对象的行为。
2.1.3 目标对象和方法
Mybatis
拦截器并不是每个对象里面的方法都可以被拦截的。Mybatis拦截器只能拦截Executor、ParameterHandler、StatementHandler、ResultSetHandler
四个对象里面的方法
默认情况下,MyBatis
允许使用插件来拦截的方法调用包括:
- 拦截执行器方法:
Executor
(update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
执行 SQL 语句的核心对象,它的 update()、query() 等方法负责执行实际的增、删、改、查操作 - 拦截参数的处理:
ParameterHandler
(getParameterObject, setParameters)
处理 SQL 参数绑定的对象,它负责将参数设置到 SQL 语句中 - 拦截结果集的处理:
ResultSetHandler
(handleResultSets, handleOutputParameters)
处理 SQL 查询结果的对象,它负责将从数据库返回的ResultSet
转换为Java
对象。 - 拦截Sql语法构建的处理:
StatementHandler
(prepare, parameterize, batch, update, query)
处理 SQL 语句的对象,它负责将 SQL 语句和参数绑定,并将其传递给数据库。 - 不同拦截器顺序:Executor -> ParameterHandler -> StatementHandler -> ResultSetHandler
2.2 Mybatis拦截器接口
public interface Interceptor {
代理对象每次调用的方法,就是要进行拦截的时候要执行的方法。在这个方法里面做我们自定义的逻辑处理
Object intercept(Invocation invocation) throws Throwable;
plugin方法是拦截器用于封装目标对象的,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理,
当返回的是代理的时候我们可以对其中的方法进行拦截来调用intercept方法 -- Plugin.wrap(target, this),
当返回的是当前对象的时候 就不会调用intercept方法,相当于当前拦截器无效
Object plugin(Object target);
用于在Mybatis配置文件中指定一些属性的,注册当前拦截器的时候可以设置一些属性
void setProperties(Properties properties);
}
2.3 @Intercepts注解
Intercepts
注解需要一个Signature(拦截点)参数数组。通过Signature来指定拦截哪个对象里面的哪个方法
@Intercepts
注解定义如下
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Intercepts {
/**
* 定义拦截点
* 只有符合拦截点的条件才会进入到拦截器
*/
Signature[] value();
}
Signature
来指定咱们需要拦截那个类对象的哪个方法。定义如下:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Signature {
/**
* 定义拦截的类 Executor、ParameterHandler、StatementHandler、ResultSetHandler当中的一个
*/
Class<?> type();
/**
* 在定义拦截类的基础之上,在定义拦截的方法
*/
String method();
/**
* 在定义拦截方法的基础之上在定义拦截的方法对应的参数,
* JAVA里面方法可能重载,不指定参数,不晓得是那个方法
*/
Class<?>[] args();
}
我们举一个例子来说明,比如我们自定义一个MybatisInterceptor类,来拦截Executor类里面的两个方法。自定义拦截类MybatisInterceptor
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
),
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
)
})
public class MybatisInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// TODO: 自定义拦截逻辑
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 返回代理类
}
@Override
public void setProperties(Properties properties) {
}
}
把源码知道拦截的主要是如下方法:
public <E> List<E> selectList(String statement, Object parameter) {
return this.selectList(statement, parameter, RowBounds.DEFAULT);
}
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
//1.根据Statement Id,在mybatis 配置对象Configuration中查找和配置文件相对应的MappedStatement
MappedStatement ms = configuration.getMappedStatement(statement);
//2. 将查询任务委托给MyBatis 的执行器 Executor
List<E> result = executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
return result;
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了