20 客户端服务订阅的事件机制剖析
Nacos客户端服务订阅的事件机制剖析
我们已经分析了Nacos客户端订阅的核心流程:Nacos客户端通过一个定时任务,每6秒从注册中心获取实例列表,当发现实例发生变化时,发布变更事件,订阅者进行业务处理,然后更新内存中和本地的缓存中的实例。
我们来分析,定时任务获取到最新实例列表之后,整个事件机制是如何处理的,首先我们先回顾整体流程
在第一步调用subscribe方法时,会订阅一个EventListener事件。而在定时任务UpdateTask定时获取实例列表之后,会调用ServiceInfoHolder.processServiceInfo方法对ServiceInfo进行本地处理,这其中就包括和事件处理。
监听事件的注册
在subscribe方法中,通过了下面的源码进行了监听事件的注册:
@Override
public void subscribe(String serviceName, String groupName, List<String> clusters, EventListener listener)throws NacosException {if (null == listener) {return;}String clusterString = StringUtils.join(clusters, ",");changeNotifier.registerListener(groupName, serviceName, clusterString, listener);clientProxy.subscribe(serviceName, groupName, clusterString);
}
在这其中我们主要要关注的就是changeNotifier.registerListener,此监听就是进行具体事件注册逻辑的,我们来看一下源码:
可以看出,事件的注册便是将EventListener存储在InstancesChangeNotifier的listenerMap属性当中了。同时这里的数据结构为ConcurrentHashMap,key为服务实例的信息的拼接,value为监听事件的集合。
public void registerListener(String groupName, String serviceName, String clusters, EventListener listener) {String key = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);ConcurrentHashSet<EventListener> eventListeners = listenerMap.get(key);if (eventListeners == null) {synchronized (lock) {eventListeners = listenerMap.get(key);if (eventListeners == null) {eventListeners = new ConcurrentHashSet<EventListener>();//将EventListener缓存到listenerMaplistenerMap.put(key, eventListeners);}}}eventListeners.add(listener);
}
ServiceInfo处理
上面的源码中已经完成了事件的注册,现在就来追溯触发事件的来源,UpdateTask中获取到最新的实例会进行本地化处理,部分源码如下:
// ServiceInfoUpdateService>UpdateTask>run()
ServiceInfo serviceObj = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
if (serviceObj == null) {serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);// 本地缓存处理serviceInfoHolder.processServiceInfo(serviceObj);lastRefTime = serviceObj.getLastRefTime();return;
}
这个run方法的详细逻辑昨天已经给大家分析过了,今天我们主要来看其中本地缓存处理的方法serviceInfoHolder.processServiceInfo,我们先来分析流程:
这个逻辑简单来说:判断新的ServiceInfo数据是否正确,是否发生了变化。如果数据格式正确,且发生变化,那就发布一个InstancesChangeEvent事件,同时将ServiceInfo写入本地缓存。
public ServiceInfo processServiceInfo(ServiceInfo serviceInfo) {String serviceKey = serviceInfo.getKey();if (serviceKey == null) {return null;}ServiceInfo oldService = serviceInfoMap.get(serviceInfo.getKey());if (isEmptyOrErrorPush(serviceInfo)) {//empty or error push, just ignorereturn oldService;}// 缓存服务信息serviceInfoMap.put(serviceInfo.getKey(), serviceInfo);// 判断注册的实例信息是否已变更boolean changed = isChangedServiceInfo(oldService, serviceInfo);if (StringUtils.isBlank(serviceInfo.getJsonFromServer())) {serviceInfo.setJsonFromServer(JacksonUtils.toJson(serviceInfo));}// 监控服务监控缓存Map的大小MetricsMonitor.getServiceInfoMapSizeMonitor().set(serviceInfoMap.size());// 服务实例以更变if (changed) {NAMING_LOGGER.info("current ips:({}) service: {} -> {}", serviceInfo.ipCount(), serviceInfo.getKey(),JacksonUtils.toJson(serviceInfo.getHosts()));// 添加实例变更事件,会被订阅者执行NotifyCenter.publishEvent(new InstancesChangeEvent(serviceInfo.getName(), serviceInfo.getGroupName(),serviceInfo.getClusters(), serviceInfo.getHosts()));// 记录Service本地文件DiskCache.write(serviceInfo, cacheDir);}return serviceInfo;
}
分析到这里我们发现其实这个重点应该在服务信息变更之后,发布的InstancesChangeEvent事件,这个事件是NotifyCenter进行发布的,我们来追踪一下源码
事件追踪
NotifyCenter通知中心的核心流程如下:
NotifyCenter中进行事件发布,发布的核心逻辑是:
1. 根据InstancesChangeEvent事件类型,获得对应的CanonicalName2. 将CanonicalName作为key,从NotifyCenter.publisherMap中获取对应的事件发布者(EventPublisher)3. EventPublisher将InstancesChangeEvent事件进行发布
核心代码如下:
private static boolean publishEvent(final Class<? extends Event> eventType, final Event event) {if (ClassUtils.isAssignableFrom(SlowEvent.class, eventType)) {return INSTANCE.sharePublisher.publish(event);}// 根据InstancesChangeEvent事件类型,获得对应的CanonicalNamefinal String topic = ClassUtils.getCanonicalName(eventType);// 将CanonicalName作为Key,从NotifyCenter#publisherMap中获取对应的事件发布者(EventPublisher)EventPublisher publisher = INSTANCE.publisherMap.get(topic);if (publisher != null) {// 事件发布者publisher发布事件(InstancesChangeEvent)return publisher.publish(event);}LOGGER.warn("There are no [{}] publishers for this event, please register", topic);return false;
}
在这个源码中,其实INSTANCE为NotifyCenter的单例实现,那么这里的publisherMap中key(CanonicalName)和value(EventPublisher)之间的关系是什么时候建立的?
其实是在NacosNamingService实例化时调用init初始化方法中进行绑定的:
// Publisher的注册过程在于建立InstancesChangeEvent.class与EventPublisher的关系。
NotifyCenter.registerToPublisher(InstancesChangeEvent.class, 16384);
这里再继续跟踪registerToPublisher方法就会发现默认采用了DEFAULT_PUBLISHER_FACTORY(默认发布者工厂)来进行构建,我们再继续跟踪会发现,在NotifyCenter中静态代码块,会发现DEFAULT_PUBLISHER_FACTORY默认构建的EventPublisher为DefaultPublisher。
//NotifyCenter
public static EventPublisher registerToPublisher(final Class<? extends Event> eventType, final int queueMaxSize) {return registerToPublisher(eventType, DEFAULT_PUBLISHER_FACTORY, queueMaxSize);
//NotifyCenter>static中部分代码
DEFAULT_PUBLISHER_FACTORY = (cls, buffer) -> {try {EventPublisher publisher = clazz.newInstance();publisher.init(cls, buffer);return publisher;} catch (Throwable ex) {LOGGER.error("Service class newInstance has error : ", ex);throw new NacosRuntimeException(SERVER_ERROR, ex);}
};
所以我们得出结论NotifyCenter中它维护了事件名称和事件发布者的关系,而默认的事件发布者为DefaultPublisher。
DefaultPublisher的事件发布
我们现在来看一下默认事件发布者的源码,查看以后我们会发现它继承自Thread,也就是说它是一个线程类,同时,它又实现了EventPublisher,也就是发布者
public class DefaultPublisher extends Thread implements EventPublisher
接下来我们来看它的init初始化方法,从这里我们可以看出当DefaultPublisher被初始化时,是以守护线程的方式运作的,其中还初始化了一个阻塞队列。
@Override
public void init(Class<? extends Event> type, int bufferSize) {// 守护线程setDaemon(true);// 设置线程名字setName("nacos.publisher-" + type.getName());this.eventType = type;this.queueMaxSize = bufferSize;// 阻塞队列初始化this.queue = new ArrayBlockingQueue<>(bufferSize);start();
}
最后调用了start()方法:在这其中调用了super.start()启动线程
@Override
public synchronized void start() {if (!initialized) {// start just called oncesuper.start();if (queueMaxSize == -1) {queueMaxSize = ringBufferSize;}initialized = true;}
}
run()方法调用openEventHandler()方法
这里写了两个死循环,第一个死循环可以理解为延时效果,也就是说线程启动时最大延时60秒,在这60秒中每隔1秒判断一下当前线程是否关闭,是否有订阅者,是否超过60秒。如果满足一个条件,就可以提前跳出死循环。
而第二个死循环才是真正的业务逻辑处理,会从阻塞队列中取出一个事件,然后通过receiveEvent方法进行执行。
@Override
public void run() {openEventHandler();
}void openEventHandler() {try {// This variable is defined to resolve the problem which message overstock in the queue.int waitTimes = 60;// To ensure that messages are not lost, enable EventHandler when// waiting for the first Subscriber to register// 死循环延迟,线程启动最大延时60秒,这个主要是为了解决消息积压的问题。for (; ; ) {if (shutdown || hasSubscriber() || waitTimes <= 0) {break;}ThreadUtils.sleep(1000L);waitTimes--;}// 死循环不断的从队列中取出Event,并通知订阅者Subscriber执行Eventfor (; ; ) {if (shutdown) {break;}// 从队列中取出Eventfinal Event event = queue.take();receiveEvent(event);UPDATER.compareAndSet(this, lastEventSequence, Math.max(lastEventSequence, event.sequence()));}} catch (Throwable ex) {LOGGER.error("Event listener exception : ", ex);}
}
队列中的事件哪里来的?其实就是DefaultPublisher的发布事件方法被调用了publish往阻塞队列中存入事件,如果存入失败,会直接调用receiveEvent。
可以理解为,如果向队列中存入失败,则立即执行,不走队列了。
@Override
public boolean publish(Event event) {checkIsStart();// 向队列中插入事件元素boolean success = this.queue.offer(event);// 判断是否成功插入if (!success) {LOGGER.warn("Unable to plug in due to interruption, synchronize sending time, event : {}", event);// 失败直接执行receiveEvent(event);return true;}return true;
}
最后再来看receiveEvent方法的实现:这里其实就是遍历DefaultPublisher的subscribers(订阅者集合),然后执行通知订阅者的方法。
void receiveEvent(Event event) {final long currentEventSequence = event.sequence();if (!hasSubscriber()) {LOGGER.warn("[NotifyCenter] the {} is lost, because there is no subscriber.", event);return;}// Notification single event listener// 通知订阅者执行Eventfor (Subscriber subscriber : subscribers) {// Whether to ignore expiration eventsif (subscriber.ignoreExpireEvent() && lastEventSequence > currentEventSequence) {LOGGER.debug("[NotifyCenter] the {} is unacceptable to this subscriber, because had expire",event.getClass());continue;}// Because unifying smartSubscriber and subscriber, so here need to think of compatibility.// Remove original judge part of codes.notifySubscriber(subscriber, event);}
}
但是这里还有一个疑问,就是subscribers中订阅者哪里来的,这个还要回到NacosNamingService的init方法中:
// 将Subscribe注册到Publisher
NotifyCenter.registerSubscriber(changeNotifier);
registerSubscriber方法最终会调用NotifyCenter的addSubscriber方法:核心逻辑就是将订阅事件、发布者、订阅者三者进行绑定。而发布者与事件通过Map进行维护、发布者与订阅者通过关联关系进行维护。
private static void addSubscriber(final Subscriber consumer, Class<? extends Event> subscribeType,EventPublisherFactory factory) {final String topic = ClassUtils.getCanonicalName(subscribeType);synchronized (NotifyCenter.class) {// MapUtils.computeIfAbsent is a unsafe method.MapUtil.computeIfAbsent(INSTANCE.publisherMap, topic, factory, subscribeType, ringBufferSize);}// 获取事件对应的PublisherEventPublisher publisher = INSTANCE.publisherMap.get(topic);if (publisher instanceof ShardedEventPublisher) {((ShardedEventPublisher) publisher).addSubscriber(consumer, subscribeType);} else {// 添加到subscribers集合publisher.addSubscriber(consumer);}
}
关系都已经梳理明确了,事件也有了,最后我们看一下DefaulePublisher中的notifySubscriber方法,这里就是真正的订阅者执行事件了。
@Override
public void notifySubscriber(final Subscriber subscriber, final Event event) {LOGGER.debug("[NotifyCenter] the {} will received by {}", event, subscriber);//执行订阅者事件final Runnable job = () -> subscriber.onEvent(event);// 执行者final Executor executor = subscriber.executor();if (executor != null) {executor.execute(job);} else {try {job.run();} catch (Throwable e) {LOGGER.error("Event callback exception: ", e);}}
}
总结
整体服务订阅的事件机制还是比较复杂的,因为用到了事件的形式,逻辑比较绕,并且其中还有守护线程,死循环,阻塞队列等。
需要重点理解NotifyCenter对事件发布者、事件订阅者和事件之间关系的维护,而这一关系的维护的入口就位于NacosNamingService的init方法当中。
核心流程梳理
ServiceInfoHolder中通过NotifyCenter发布了InstancesChangeEvent事件
NotifyCenter中进行事件发布,发布的核心逻辑是:
- 根据InstancesChangeEvent事件类型,获得对应的CanonicalName
- 将CanonicalName作为Key,从NotifyCenter.publisherMap中获取对应的事件发布者(EventPublisher)
- EventPublisher将InstancesChangeEvent事件进行发布
InstancesChangeEvent事件发布:
- 通过EventPublisher的实现类DefaultPublisher进行InstancesChangeEvent事件发布
- DefaultPublisher本身以守护线程的方式运作,在执行业务逻辑前,先判断该线程是否启动
- 如果启动,则将事件添加到BlockingQueue中,队列默认大小为16384
- 添加到BlockingQueue成功,则整个发布过程完成
- 如果添加失败,则直接调用DefaultPublisher.receiveEvent方法,接收事件并通知订阅者
- 通知订阅者时创建一个Runnable对象,执行订阅者的Event
- Event事件便是执行订阅时传入的事件
如果添加到BlockingQueue成功,则走另外一个业务逻辑:
- DefaultPublisher初始化时会创建一个阻塞(BlockingQueue)队列,并标记线程启动
- DefaultPublisher本身是一个Thread,当执行super.start方法时,会调用它的run方法
- run方法的核心业务逻辑是通过openEventHandler方法处理的
- openEventHandler方法通过两个for循环,从阻塞队列中获取时间信息
- 第一个for循环用于让线程启动时在60s内检查执行条件
- 第二个for循环为死循环,从阻塞队列中获取Event,并调用DefaultPublisher#receiveEvent方法,接收事件并通知订阅者
- Event事件便是执行订阅时传入的事件
相关文章:

20 客户端服务订阅的事件机制剖析
Nacos客户端服务订阅的事件机制剖析 我们已经分析了Nacos客户端订阅的核心流程:Nacos客户端通过一个定时任务,每6秒从注册中心获取实例列表,当发现实例发生变化时,发布变更事件,订阅者进行业务处理,然后更…...
ThreadPoolExecutor中的addWorker方法
在看线程池源码的时候看到了这么一段代码 private boolean addWorker(Runnable firstTask, boolean core) {retry:for (int c ctl.get();;) {// Check if queue empty only if necessary.if (xxx)return false;for (;;) {if (xxx)return false;if (xxx)break retry;if (xxx)c…...
9 有线网络的封装
概述 IPC设备一般都带有网口,支持以有线网络方式接入NVR和其他平台。有线网络的使用比较简单,主要操作有:设置IP地址、子网掩码、网关、DHCP等。在封装有线网络前,我们需要先封装DHCP客户端管理类,用于管理各种网络的DHCP功能。 DHCP客户端管理类 DHCP客户端管理类的头文件…...

Linux----网络基础(2)--应用层的序列化与反序列化--守护进程--0226
文章中有使用封装好的头文件,可以在下面连接处查询。 Linux相关博文中使用的头文件_Gosolo!的博客-CSDN博客 1. 应用层 我们程序员写的一个个解决我们实际问题, 满足我们日常需求的网络程序, 都是在应用层 1.2 协议 我们在之前的套接字编程中使用的是…...

uipath实现滑动验证码登录
现实需求 在进行RPA流程设计过程中,遇到登录系统需要滑动验证的情况,如图所示: 此时需要在RPA流程设计中,借助现有的活动完成模拟人工操作,完成验证登录操作。 设计思路 这个功能流程的设计思路大体如下: …...

openai-chatGPT的API调用异常处理
因为目前openai对地区限制的原因,即使设置了全局代理使用API调用时,还是会出现科学上网代理的错误问题。openai库 0.26.5【错误提示】:raise error.APIConnectionError(openai.error.APIConnectionError: Error communicating with OpenAI: …...

css实现音乐播放器页面 · 笔记
效果 源码 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible" content"IEedge"><meta name"viewport" content"widthdevice-width, …...

buu [NPUCTF2020]这是什么觅 1
题目描述: 就一个这种文件,用记事本打开后: 题目分析: 打开后就一串看不懂的东西,想想这个东西曾经在 010editor 或 winhex中出现过(右端)既然如此那么我们就用它打开,得到&#…...
Restful API 设计规范
1. 简介 目前 "互联网软件"从用客户端/服务端模式,建立在分布式体系上,通过互联网通讯,具有高延时、高开发等特点。但是软件开发和网络是两个不同的领域,交集很少。要使得两个融合,就要考虑如何在互联网环境…...
sigwaittest测试超标的调试过程
1,问题描述硬件环境:飞腾S2500(64核)OS:kylinOS, linux preempt rt, 4.19.90测试命令:sigwaittest -p 90 -i 1000 -a 1测试结果:信号混洗值最大超过了80us,与飞腾其他CPU…...

Python进阶-----面对对象4.0(面对对象三大特征之--继承)
目录 前言: Python的继承简介 1.什么是继承 2.继承的好处 3.object类 继承的相关用法 1.继承的定义与法则 2.对继承的重写 3.(单继承)多层继承 4.多继承 5.多继承重写时调用父类方法 前言: 在讲之前,我想说说中…...

九龙证券|利好政策密集发布,机构扎堆看好的高增长公司曝光
新能源轿车销量和保有量快速增长,带来了充电桩商场的微弱需求。 日前,商务部部长王文涛表明,本年将在落实好方针的一起,活跃出台新方针办法,比方辅导当地展开新能源轿车下乡活动,优化充电等使用环境&#x…...

stm32CubeIDE FMC 驱动LCD(8080)
一,TFT屏硬件接口16位,80并口。二,FMC介绍。FSMC(Flexible Static Memory Controller),译为灵活的静态存储控制器。STM32F1 系列芯片使用 FSMC 外设来管理扩展的存储器,它可以用于驱动包括 SRAM…...

Java 数据类型
数据类型用于对数据归类,以便开发者理解和操作。 基本数据类型 Java 确定了每种基本数据类型所占存储空间的大小,不会像其它语言那样随机器硬件架构的变化而变化,这使 Java 程序更具可移植性。 Java 中定义了如下的基本数据类型。 byte …...

Prometheus 监控云Mysql和自建Mysql(多实例)
本文您将了解到 Prometheus如何配置才能监控云Mysql(包括阿里云、腾讯云、华为云)和自建Mysql。 Prometheus 提供了很多种Exporter,用于监控第三方系统指标,如果没有提供也可以根据Exporter规范自定义Exporter。 本文将通过MySQL server exporter 来监控…...

Vue3中的h函数
文章目录简介简单使用参数使用计数器进阶使用函数组件插槽专栏目录请点击 简介 众所周知,vue内部构建的其实是虚拟DOM,而虚拟DOM是由虚拟节点生成的,实质上虚拟节点也就是一个js对象事实上,我们在vue中写的template,最终也是经过…...
阿尔法开发板 IMX6ULL 说明
一. IMX6ULL开发板 IMX6ULL开发板即正点原子的阿尔法(ALPHA)开发板,采用恩智浦芯片,cortex-A7架构的。 二. IM6ULL开发板说明 1. IO说明 对于IMX6ULL芯片,一个IO对应两个寄存器,第一个寄存器负责配置其复用功能,…...

Altium Designer19 #学习笔记# | 基础应用技巧汇总
全文目录一.元件符号库二.元件封装库1.AD09 集成元件库/封装库三.电路原理图1. 巧用查找"相似对象功能"1.1 查找相同元件1.2. 查找相同文本1.3. 查找相同网络 :E - S - C四.PCB原理图【AD PCB模式下的常用快捷键】PCB视图放大/缩小PCB视图左/右移动PCB切换…...

Python 元类编程实现一个简单的 ORM
概述 什么是ORM? ORM全称“Object Relational Mapping”,即对象-关系映射,就是把关系数据库的一行映射为一个对象,也就是一个类对应一个表,这样,写代码更简单,不用直接操作SQL语句。 现在我们就要实…...
《C++ Primer Plus》第18章:探讨 C++ 新标准(7)
C11 新增的其他功能 C11 增加了很多功能,本书无法全面介绍;另外,本书编写期间,其中很多功能还未得到广泛实现。然而,有些功能有必要简要地介绍一下。 并行编程 当前,为提高计算机性能,增加处…...
论文解读:交大港大上海AI Lab开源论文 | 宇树机器人多姿态起立控制强化学习框架(二)
HoST框架核心实现方法详解 - 论文深度解读(第二部分) 《Learning Humanoid Standing-up Control across Diverse Postures》 系列文章: 论文深度解读 + 算法与代码分析(二) 作者机构: 上海AI Lab, 上海交通大学, 香港大学, 浙江大学, 香港中文大学 论文主题: 人形机器人…...

51c自动驾驶~合集58
我自己的原文哦~ https://blog.51cto.com/whaosoft/13967107 #CCA-Attention 全局池化局部保留,CCA-Attention为LLM长文本建模带来突破性进展 琶洲实验室、华南理工大学联合推出关键上下文感知注意力机制(CCA-Attention),…...

基于uniapp+WebSocket实现聊天对话、消息监听、消息推送、聊天室等功能,多端兼容
基于 UniApp + WebSocket实现多端兼容的实时通讯系统,涵盖WebSocket连接建立、消息收发机制、多端兼容性配置、消息实时监听等功能,适配微信小程序、H5、Android、iOS等终端 目录 技术选型分析WebSocket协议优势UniApp跨平台特性WebSocket 基础实现连接管理消息收发连接…...
工程地质软件市场:发展现状、趋势与策略建议
一、引言 在工程建设领域,准确把握地质条件是确保项目顺利推进和安全运营的关键。工程地质软件作为处理、分析、模拟和展示工程地质数据的重要工具,正发挥着日益重要的作用。它凭借强大的数据处理能力、三维建模功能、空间分析工具和可视化展示手段&…...
将对透视变换后的图像使用Otsu进行阈值化,来分离黑色和白色像素。这句话中的Otsu是什么意思?
Otsu 是一种自动阈值化方法,用于将图像分割为前景和背景。它通过最小化图像的类内方差或等价地最大化类间方差来选择最佳阈值。这种方法特别适用于图像的二值化处理,能够自动确定一个阈值,将图像中的像素分为黑色和白色两类。 Otsu 方法的原…...

学校时钟系统,标准考场时钟系统,AI亮相2025高考,赛思时钟系统为教育公平筑起“精准防线”
2025年#高考 将在近日拉开帷幕,#AI 监考一度冲上热搜。当AI深度融入高考,#时间同步 不再是辅助功能,而是决定AI监考系统成败的“生命线”。 AI亮相2025高考,40种异常行为0.5秒精准识别 2025年高考即将拉开帷幕,江西、…...
虚拟电厂发展三大趋势:市场化、技术主导、车网互联
市场化:从政策驱动到多元盈利 政策全面赋能 2025年4月,国家发改委、能源局发布《关于加快推进虚拟电厂发展的指导意见》,首次明确虚拟电厂为“独立市场主体”,提出硬性目标:2027年全国调节能力≥2000万千瓦࿰…...
作为测试我们应该关注redis哪些方面
1、功能测试 数据结构操作:验证字符串、列表、哈希、集合和有序的基本操作是否正确 持久化:测试aof和aof持久化机制,确保数据在开启后正确恢复。 事务:检查事务的原子性和回滚机制。 发布订阅:确保消息正确传递。 2、性…...
PostgreSQL——环境搭建
一、Linux # 安装 PostgreSQL 15 仓库 sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-x86_64/pgdg-redhat-repo-latest.noarch.rpm# 安装之前先确认是否已经存在PostgreSQL rpm -qa | grep postgres# 如果存在࿰…...

关于easyexcel动态下拉选问题处理
前些日子突然碰到一个问题,说是客户的导入文件模版想支持部分导入内容的下拉选,于是我就找了easyexcel官网寻找解决方案,并没有找到合适的方案,没办法只能自己动手并分享出来,针对Java生成Excel下拉菜单时因选项过多导…...