橘子学Mybatis09之Mybatis关于二级缓存的使用

前面我们说了一级缓存,但是实际上我们说那玩意其实不咋实用。于是既然设计了缓存体系,就不可能弄个不实用的给人们。所以这里就引出二级全局缓存。
全局缓存就是无视sqlSession,你可以理解为一个分布式的缓存。作为全局的访问。

一、二级缓存

1、开启方式

二级缓存默认是不开启的,所以他需要你手动去开启。开启方式需要满足下面四个条件。

1、需要在核心配置文件,我的是sqlMapConfig.xml中指定,在<setting 中的配置。其实最新版mybatis这个条件其实可以不用,因为在configuration中的cache默认就是true。
2、需要在mapper.xml中引进二级缓存Cache的标签。
3、需要在查询标签<select中引入useCache属性打开。,其实这个也是可以不用配置的,我们这里就先配置上。
4、需要有事务的存在。

第一步和第三步可以不写。
于是我们就来操作验证一下,我们首先在UserMapper.xml中配置第二步和第三步。

<mapper namespace="com.yx.dao.IUserDao"><cache></cache><select id="findAll" resultType="com.yx.domain.User" useCache="true">SELECT * FROM `user`</select>
</mapper>

然后在代码中开启事务的提交。

@Test
public void test3() throws IOException {// 读取配置文件转化为流InputStream inputStream = Resources.getResourceAsStream("sqlMapConfig.xml");SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);SqlSession sqlSession = factory.openSession();SqlSession sqlSession2 = factory.openSession();//这⾥不调⽤SqlSession的api,⽽是获得了接⼝对象,调⽤接⼝中的⽅法。使用JDK动态代理产生代理对象IUserDao userDao = sqlSession.getMapper(IUserDao.class);IUserDao userDao2 = sqlSession2.getMapper(IUserDao.class);// 第一次执行查询List<User> userList = userDao.findAll();userList.forEach(user -> {System.out.println(user.toString());});// 事务提交sqlSession.commit();System.out.println("*************************************************");// 第二次执行相同的查询List<User> userList2 = userDao2.findAll();userList2.forEach(user -> {System.out.println(user.toString());});// 事务提交sqlSession2.commit();
}

我们看下输出:

Opening JDBC Connection
Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
Created connection 203819996.
Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@c260bdc]
==>  Preparing: SELECT * FROM `user`
==> Parameters: 
<==    Columns: id, name
<==        Row: 1, 张飞
<==        Row: 2, 关羽
<==        Row: 3, 孙权
<==      Total: 3
User{id=1, username='张飞'}
User{id=2, username='关羽'}
User{id=3, username='孙权'}
*************************************************
As you are using functionality that deserializes object streams, it is recommended to define the JEP-290 serial filter. Please refer to https://docs.oracle.com/pls/topic/lookup?ctx=javase15&id=GUID-8296D8E8-2B93-4B9A-856E-0A65AF9B8C66
Cache Hit Ratio [com.yx.dao.IUserDao]: 0.5// 缓存命中
User{id=1, username='张飞'}
User{id=2, username='关羽'}
User{id=3, username='孙权'}

我们看到缓存被命中了,第二次新的sqlsession中并没有执行sql,也没有创建连接,可见mybatis内部是维护着二级缓存的。而且我们看到命中率是0.5,其实就是我们查了两次,只有第二次命中了,就是百分之五十。

二、源码设计

1、装饰器的使用

我们前面说过装饰器模式,https://blog.csdn.net/liuwenqiang1314/article/details/135583787?spm=1001.2014.3001.5501
在这个二级缓存这里可以看到一个类起主要作用,就是CachingExecutor,他是Executor接口的实现类。而他和其他的SimpleExecutor,BatchExecutor有啥区别呢。
我们上一章知道BatchExecutor和SimpleExecutor通过继承BaseExecutor这个适配器来进行操作数据库。
而CachingExecutor是直接实现了Executor,实际上他是一个装饰器,他用来装饰其他的Executor。怎么证明呢。我们来想想Executor是在哪里创建呢,我们前面说Configuration的时候,知道Configuration不仅仅包罗万象,而且创建了很多基本操作类,其中就包括Exector。我们可以看到他的源码位于。
org.apache.ibatis.session.Configuration#newExecutor

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {executorType = executorType == null ? defaultExecutorType : executorType;executorType = executorType == null ? ExecutorType.SIMPLE : executorType;Executor executor;// 根据参数类型创建不同的executor ,默认是SimpleExecutorif (ExecutorType.BATCH == executorType) {executor = new BatchExecutor(this, transaction);} else if (ExecutorType.REUSE == executorType) {executor = new ReuseExecutor(this, transaction);} else {executor = new SimpleExecutor(this, transaction);}/*** 我们看到这里有个配置就是当开启这个cache的时候,就去创建缓存的 CachingExecutor* 这里我们说cacheEnabled默认值就是true,protected boolean cacheEnabled = true;* 这个变量其实就是mybatis核心配置文件里面的那个setting里面配置的缓存开启,* 最后封装在Configuration的cacheEnabled里面,不过默认为true,不用配就开了*/if (cacheEnabled) {executor = new CachingExecutor(executor);}executor = (Executor) interceptorChain.pluginAll(executor);return executor;}

我们前面说过装饰器的时候,我们说就是套娃,这里可以看到他把上面创建的BatchExecutor,或者是SimpleExecutor或者是ReuseExecutor。为了方便说下面我们统一叫做SimpleExecutor。所以这里就套娃了,他把SimpleExecutor套入CachingExecutor做装饰器的增强。
于是我们就跟入这个增强类也就是CachingExecutor来看一下这个增强发生的地方。

public class CachingExecutor implements Executor {private final Executor delegate;private final TransactionalCacheManager tcm = new TransactionalCacheManager();// 套娃就发生在这里,我们看到他把SimpleExecutor交给了CachingExecutor 的delegatepublic CachingExecutor(Executor delegate) {this.delegate = delegate;delegate.setExecutorWrapper(this);}

我们看到他把SimpleExecutor交给了CachingExecutor 的delegate,那么增强到底发生在哪里呢,既然缓存是针对查询的,那么我们就去看CachingExecutor的查询方法。也就是org.apache.ibatis.executor.CachingExecutor#query

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)throws SQLException {// 去MappedStatement中获取缓存,开启了缓存,其实就是UserMapper.xml中的<cache></cache>这个配置开启了没有// 因为MappedStatement封装的就是mapper文件,所以这个变量就是封装在MappedStatement中的Cache cache = ms.getCache();// 如果存在,也就是开启了if (cache != null) {// 如果你在sql中配置了flushCache="true",那就会来刷新缓存,实时查询,保证一致性。但是一般不配置,不然缓存其实就没用了,但是有些查询可能需要这个实时性,那就需要在这类sql上配置。而且update操作就会刷新缓存。避免脏数据,而且清空的就是这个MappedStatement ,其他的并不清空。flushCacheIfRequired(ms);// 如果你的sql标签上指定了useCache=true,现在也是默认的了,不用管了if (ms.isUseCache() && resultHandler == null) {ensureNoOutParams(ms, boundSql);@SuppressWarnings("unchecked")// 此时有缓存,直接就拿了,tcm就是装饰器增强的业务,list是为了尊重sql的排序,sql查出来啥顺序就是啥顺序,set和map可能会导致顺序和sql的不一致List<E> list = (List<E>) tcm.getObject(cache, key);if (list == null) {// 这里是你开启了<cache></cache>,然后发现没缓存,其实就是第一次,那也来这里查一次库list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);// 设置第一次查的结果去缓存tcm.putObject(cache, key, list); // issue #578 and #116}return list;}}// 如果没开启<cache></cache>,那就去查SimpleExecutor这个装饰器被装饰的Executor中的查询方法,return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

2、何时创建cache

我们说我们配置了cache标签才会使用到,所以那一定是在解析这个标签的时候才触发的。但是我们又知道在核心配置文件中的那个已经没用了,我们主要是在mapper中配置的那个cache才是有用的,于是我们就应该去找到解析mapper封装MappedStatement 的地方去找。路子就是
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);中的build方法,往下
org.apache.ibatis.builder.xml.XMLConfigBuilder#parseConfiguration
然后其中的mapperElement(root.evalNode(“mappers”));是解析mapper文件的,我们点进去。其中的mapperParser.parse();一看就是解析的,点进去。
configurationElement(parser.evalNode(“/mapper”));这句一看就是找到根目录开始解析。点进去。
org.apache.ibatis.builder.xml.XMLMapperBuilder#configurationElement

private void configurationElement(XNode context) {try {String namespace = context.getStringAttribute("namespace");if (namespace == null || namespace.isEmpty()) {throw new BuilderException("Mapper's namespace cannot be empty");}builderAssistant.setCurrentNamespace(namespace);cacheRefElement(context.evalNode("cache-ref"));// 处理缓存的,点进去cacheElement(context.evalNode("cache"));parameterMapElement(context.evalNodes("/mapper/parameterMap"));resultMapElements(context.evalNodes("/mapper/resultMap"));sqlElement(context.evalNodes("/mapper/sql"));buildStatementFromContext(context.evalNodes("select|insert|update|delete"));} catch (Exception e) {throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);}}

cacheElement(context.evalNode(“cache”));就是判断这个缓存标签的,我们就知道这里是处理的,点进去看看。

private void cacheElement(XNode context) {if (context != null) {String type = context.getStringAttribute("type", "PERPETUAL");Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);String eviction = context.getStringAttribute("eviction", "LRU");Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);Long flushInterval = context.getLongAttribute("flushInterval");Integer size = context.getIntAttribute("size");boolean readWrite = !context.getBooleanAttribute("readOnly", false);boolean blocking = context.getBooleanAttribute("blocking", false);Properties props = context.getChildrenAsProperties();// 我们看到这里开始使用新的缓存,其实就是这里创建的。builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);}}

org.apache.ibatis.builder.MapperBuilderAssistant#useNewCache

public Cache useNewCache(Class<? extends Cache> typeClass,Class<? extends Cache> evictionClass,Long flushInterval,Integer size,boolean readWrite,boolean blocking,Properties props) {// 这里是个建造者模式Cache cache = new CacheBuilder(currentNamespace).implementation(valueOrDefault(typeClass, PerpetualCache.class)).addDecorator(valueOrDefault(evictionClass, LruCache.class)).clearInterval(flushInterval).size(size).readWrite(readWrite).blocking(blocking).properties(props).build();configuration.addCache(cache);currentCache = cache;return cache;}

此时就创建出来缓存对象了。也就是我们前面说的装饰器模式的那个Cache,不过后面你会套娃,给他不断增强。虽然默认的也没有。我们来看下他怎么build的,我们点进去build的实现。

public Cache build() {setDefaultImplementations();// 如果你自己实现了cache,这里就会执行你的那个缓存方式,创建你的cache,比如你用redis,就会创建redis方式的cache,至于怎么创建,后面我们再看// 底层就是反射,你要是没实现,那就还是他自己的PerpetualCacheCache cache = newBaseCacheInstance(implementation, id);// cache的seeting有多个配置,这里设置进去你自己配置的多个,后面我们来看,类似这样/*<cache><property name="" value=""/><property name="" value=""/></cache>*/setCacheProperties(cache);// issue #352, do not apply decorators to custom caches// 你没自定义扩展,这里就是默认的PerpetualCache就进这里if (PerpetualCache.class.equals(cache.getClass())) {// 针对你自己的decorators,来进行增强装饰,你也可以自己配置for (Class<? extends Cache> decorator : decorators) {cache = newCacheDecoratorInstance(decorator, cache);setCacheProperties(cache);}// 没有你自己的,那就直接进行装饰,装饰的方法也是根据你的配置eviction="FIFO"就是把默认的换成FIFO/***  <cache size="" flushInterval="" blocking="" readOnly="">*     <property name="" value=""/>*     <property name="" value=""/>*   </cache>*//**MetaObject metaCache = SystemMetaObject.forObject(cache);if (size != null && metaCache.hasSetter("size")) {metaCache.setValue("size", size);}// 配的定时清理就是任务的装饰器,下面有各自的,可以多配,顺序套娃即可,你加了才会给你配置,不加就是默认那两个LoggingCache,SynchronizedCacheif (clearInterval != null) {cache = new ScheduledCache(cache);((ScheduledCache) cache).setClearInterval(clearInterval);}if (readWrite) {cache = new SerializedCache(cache);}// 这两个是都有的cache = new LoggingCache(cache);cache = new SynchronizedCache(cache);if (blocking) {// 你赔了这个就是走的阻塞队列装饰增强cache = new BlockingCache(cache);}return cache;*/cache = setStandardDecorators(cache);}// 如果不是默认的,那就给你加强一个装饰器,就是日志装饰器的套娃,其实就是一旦你不默认打日志出来让你看看else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {cache = new LoggingCache(cache);}return cache;}

所以你能看到他会整合你的配置,进行对应的增强,创建出新的cache对象(可能是redis这种),后面再通过装饰器模式,进行功能的增强。这样,cache对象就创建出来了,最终放到mappstatement里面。于是我们就知道CachingExecutor 在使用缓存的时候,需要通过MappedStatement 来获取缓存。Cache cache = ms.getCache();
也就是org.apache.ibatis.executor.CachingExecutor#query

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql){// 去MappedStatement中获取缓存,开启了缓存,其实就是UserMapper.xml中的<cache></cache>这个配置开启了没有// 因为MappedStatement封装的就是mapper文件,所以这个变量就是封装在MappedStatement中的Cache cache = ms.getCache();

三、一级二级缓存的时机

我们现在知道一级缓存和二级缓存了,那么问题来了,当我开启二级缓存的时候,是先触发一级缓存呢还是二级缓存呢。我们来看下创建Exector的地方。
org.apache.ibatis.session.Configuration#newExecutor

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {executorType = executorType == null ? defaultExecutorType : executorType;executorType = executorType == null ? ExecutorType.SIMPLE : executorType;Executor executor;if (ExecutorType.BATCH == executorType) {executor = new BatchExecutor(this, transaction);} else if (ExecutorType.REUSE == executorType) {executor = new ReuseExecutor(this, transaction);} else {executor = new SimpleExecutor(this, transaction);}/*** 我们看到这里有个配置就是当开启这个cache的时候,就去创建缓存的 CachingExecutor* 这里我们说cacheEnabled默认值就是true,protected boolean cacheEnabled = true;* 这个变量其实就是mybatis核心配置文件里面的那个setting里面配置的缓存开启,* 最后封装在Configuration的cacheEnabled里面,不过默认为true,不用配就开了*/if (cacheEnabled) {executor = new CachingExecutor(executor);}executor = (Executor) interceptorChain.pluginAll(executor);return executor;}

我们看到最后其实当你开启二级缓存的时候他会给你创建出来的是executor = new CachingExecutor(executor);就是CachingExecutor。
CachingExecutor包装了(套娃了)SimpleExecutor,而SimpleExecutor的查询功能是放在适配器BaseExecutor中的。所以他的处理链路是。
CachingExecutor->BaseExecutor->SimpleExecutor
CachingExecutor是去查二级缓存的,BaseExecutor是去查一级缓存的,所以其实是先二级再一级。可以debug看一下。

1、debug验证

断点跟这个方法就看到了。今天乙流,就不弄了,很简单的。
org.apache.ibatis.executor.CachingExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/646813.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

爬虫工作量由小到大的思维转变---<第三十九章 Scrapy-redis 常用的那个RetryMiddleware>

前言: 为什么要讲这个RetryMiddleware呢?因为他很重要~ 至少在你装配代理ip或者一切关于重试的时候需要用到!----最关键的是:大部分的教学视频里面,没有提及这个!!!! 正文: 源代码分析 这个RetryMiddleware是来自: from scrapy.downloadermiddlewares.retry import Retry…

Transfomer相关最新研究

文章目录 LogTrans * (有代码&#xff09;TFT &#xff08;有代码&#xff09;InfluTran &#xff08;有代码&#xff09;Informer *&#xff08;有代码&#xff09;&#xff08;长时间&#xff09;ProTranAutoformer ***&#xff08;有代码&#xff09;AliformerPyraformer &a…

JRT的无源码发布

之前介绍过JRT最大的特点就是业务脚本化。老javaer就会说你业务代码都在发布环境放着&#xff0c;那怎么代码保密&#xff0c;在发布环境别人随便改了启不是不安全&#xff0c;或者一些代码我就是不想让人看源码呢。 其实JRT的业务脚本化只是特性&#xff0c;不是代表就必须要…

选择排序(堆排序和topK问题)

选择排序 每一次从待排序的数据元素中选出最小&#xff08;或最大&#xff09;的一个元素&#xff0c;存放在序列的起始位置&#xff0c;直到全部待排序的数据元素排完 。 如果我们用扑克牌来举例&#xff0c;那么选择排序就像是提前已经把所有牌都摸完了&#xff0c;而再进行牌…

git commit 描述如何修改

git Commit 描述写错了&#xff0c;如何修改_git提交描述错误怎么修改-CSDN博客 1.git commit --amend 2.按一下 i 键&#xff0c;进入插入模式 3.修改成描述 4.按 esc 键退出&#xff0c;然后按shift:&#xff0c;然后输入 wq 就完成修改了

ROS1工作空间内多个包先后编译顺序、包内编译顺序

在ros工作空间里有packageA和packageB两个包&#xff0c;其中第二个包依赖第一个包。除了packageB的CMakeLists.txt中的find_package要加入第一个包外&#xff0c;还需要修改package.xml&#xff0c;保证catkin_make的编译顺序&#xff1a; packageB的package.xml&#xff1a;…

Java中文乱码浅析及解决方案

Java中文乱码浅析及解决方案 一、GBK和UTF-8编码方式二、idea和eclipse的默认编码方式三、解码和编码方法四、代码实现编码解码 五、额外知识扩展 一、GBK和UTF-8编码方式 如果采用的是UTF-8的编码方式&#xff0c;那么1个英文字母 占 1个字节&#xff0c;1个中文占3个字节如果…

list的介绍及其模拟实现

今天我们了解list&#xff0c;list在python中是列表的意思 &#xff0c;但是在C中它是一个带头双向循环链表&#xff1a; list的介绍 list是可以在常数范围内在任意位置进行插入和删除的序列式容器&#xff0c;并且该容器可以前后双向迭代。list的底层是双向链表结构&#xf…

springboot项目快速引入knife4j

引入依赖 <dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-spring-boot-starter</artifactId><version>3.0.3</version> </dependency>knife4j配置文件 basePackage改为自己存放接口的包名 /*** Kn…

【网络安全 | 漏洞挖掘 】Firefox长达21年的 “陈年老bug”,终于被修复了!

Firefox 的工单记录页面显示&#xff0c;一个在 21 年前发现的 bug 终于被修复了。 根据描述&#xff0c;具体错误是表格单元格无法正确处理内容 “溢出” 的情况&#xff0c;不支持 ‘hidden’、‘auto’ 和’scroll’ 属性。 如下图所示&#xff1a; 开发者在评论中指出&a…

如何使用Stable Diffusion的ReActor换脸插件

ReActor插件是从roop插件分叉而来的一个更轻便、安装更简单的换脸插件。操作简单&#xff0c;非常容易上手&#xff0c;下面我们就介绍一下&#xff0c;如何将ReActor作为stable diffusion的插件进行安装和使用。 一&#xff1a;安装ReActor插件 项目地址&#xff1a;https:/…

计算机网络——网络层(1)

计算机网络——网络层(1&#xff09; 小程一言专栏链接: [link](http://t.csdnimg.cn/ZUTXU) 网络层&#xff1a;数据平面网络层概述核心功能协议总结 路由器工作原理路由器的工作步骤总结 网际协议IPv4主要特点不足IPv6主要特点现状 通用转发和SDN通用转发SDN&#xff08;软件…

C++从零开始的打怪升级之路(day21)

这是关于一个普通双非本科大一学生的C的学习记录贴 在此前&#xff0c;我学了一点点C语言还有简单的数据结构&#xff0c;如果有小伙伴想和我一起学习的&#xff0c;可以私信我交流分享学习资料 那么开启正题 今天分享的是关于vector的题目 1.删除有序数组中的重复项 26. …

前端[新手引导动画]效果:intro.js

目录 一、安装 二、配置 三、编写需要引导动画的页面 四、添加引导效果 一、安装 npm i intro.js 二、配置 详细配置可以参考&#xff0c;官网&#xff1a; Intro.js Documentation | Intro.js Docs https://introjs.com/docs 新建一个intro.js的文件&#xff1a; 三、…

扎哇面试准备

1.你是谁&#xff1f; 我是李四&#xff0c;24 届学生&#xff0c;目前就读于西安电子科技大学&#xff0c;硕士学历&#xff0c;就读的专业是软件工程&#xff08;非软件相关专业就不要介绍你的专业了&#xff09;&#xff0c;很荣幸参加贵公司的面试 2.你会啥&#xff1f; …

06.Elasticsearch应用(六)

Elasticsearch应用&#xff08;六&#xff09; 1.什么是分词器 ES文档的数据拆分成一个个有完整含义的关键词&#xff0c;并将关键词与文档对应&#xff0c;这样就可以通过关键词查询文档。要想正确的分词&#xff0c;需要选择合适的分词器 2.ES中的默认分词器 fingerprint…

MySQL的`FOR UPDATE`详解

MySQL的FOR UPDATE详解 欢迎阅读本博客&#xff0c;今天我们将深入探讨MySQL中的FOR UPDATE语句&#xff0c;它用于在事务中锁定选择的数据行&#xff0c;确保在事务结束前其他事务无法修改这些数据。 1. FOR UPDATE基础 FOR UPDATE是用于SELECT语句的一种选项&#xff0c;它…

15- OpenCV:模板匹配(cv::matchTemplate)

目录 1、模板匹配介绍 2、cv::matchTemplate 3、模板匹配的方法&#xff08;算法&#xff09; 4、代码演示 1、模板匹配介绍 模板匹配就是在整个图像区域发现与给定子图像匹配的小块区域。 它可以在一幅图像中寻找与给定模板最相似的部分。 模板匹配的步骤&#xff1a; &a…

C++提高编程——STL:常用算法

本专栏记录C学习过程包括C基础以及数据结构和算法&#xff0c;其中第一部分计划时间一个月&#xff0c;主要跟着黑马视频教程&#xff0c;学习路线如下&#xff0c;不定时更新&#xff0c;欢迎关注。 当前章节处于&#xff1a; ---------第1阶段-C基础入门 ---------第2阶段实战…

Unity中URP下计算额外灯的方向

文章目录 前言一、为什么额外灯的方向&#xff0c;不像主平行灯一样直接获取&#xff1f;1、主平行灯2、额外灯中&#xff0c;包含 点光源、聚光灯 和 平行灯 二、获得模型顶点指向额外灯的单位向量三、Unity中的实现 前言 在上一篇文章中&#xff0c;我们获取了URP下额外灯的…