實(shí)現(xiàn)一個(gè)任務(wù)調(diào)度系統(tǒng),看這篇就夠了
閱覽一篇「守時(shí)使命結(jié)構(gòu)選型」的文章時(shí),一位網(wǎng)友的留言電到了我:
我看過(guò)那么多所謂的教程,大部分都是教“怎么運(yùn)用東西”的,沒(méi)有多少是教“怎么制造東西”的,能教“怎么拷貝東西”的都現(xiàn)已是百里挑一,我國(guó) 軟件行業(yè),缺的是真正能夠“制造東西”的程序員,而絕對(duì)不缺那些“運(yùn)用東西”的程序員! ...... ”這個(gè)業(yè)界最不需求的便是“會(huì)運(yùn)用XX東西的工程師”,而是“有創(chuàng)造力的軟件工程師”!業(yè)界所有的飯碗,實(shí)質(zhì)便是“有創(chuàng)造力的軟件工程師”供給出來(lái)的啊!
寫(xiě)這篇文章,想和大家從頭到腳說(shuō)說(shuō)使命調(diào)度,期望大家讀完之后,能夠了解完成一個(gè)使命調(diào)度體系的中心邏輯。
1 Quartz
Quartz是一款Java開(kāi)源使命調(diào)度結(jié)構(gòu),也是許多Java工程師觸摸使命調(diào)度的起點(diǎn)。
下圖顯現(xiàn)了使命調(diào)度的全體流程:
Quartz的中心是三個(gè)組件。
- 使命:Job 用于表明被調(diào)度的使命;
- 觸發(fā)器:Trigger 界說(shuō)調(diào)度時(shí)刻的元素,即依照什么時(shí)刻規(guī)矩去履行使命。一個(gè)Job能夠被多個(gè)Trigger相關(guān),可是一個(gè)Trigger 只能相關(guān)一個(gè)Job;
- 調(diào)度器 :工廠類創(chuàng)立Scheduler,依據(jù)觸發(fā)器界說(shuō)的時(shí)刻規(guī)矩調(diào)度使命。
上圖代碼中Quartz 的JobStore是 RAMJobStore,Trigger 和 Job 存儲(chǔ)在內(nèi)存中。
履行使命調(diào)度的中心類是 QuartzSchedulerThread 。
- 調(diào)度線程從JobStore中獲取需求履行的的觸發(fā)器列表,并修正觸發(fā)器的狀況;
- Fire觸發(fā)器,修正觸發(fā)器信息(下次履行觸發(fā)器的時(shí)刻,以及觸發(fā)器狀況),并存儲(chǔ)起來(lái)。
- 最終創(chuàng)立詳細(xì)的履行使命目標(biāo),經(jīng)過(guò)worker線程池履行使命。
接下來(lái)再聊聊 Quartz 的集群布置計(jì)劃。
Quartz的集群布置計(jì)劃,需求針對(duì)不同的數(shù)據(jù)庫(kù)類型(MySQL , ORACLE) 在數(shù)據(jù)庫(kù)實(shí)例上創(chuàng)立Quartz表,JobStore是: JobStoreSupport 。
這種計(jì)劃是分布式的,沒(méi)有擔(dān)任會(huì)集辦理的節(jié)點(diǎn),而是利用數(shù)據(jù)庫(kù)行級(jí)鎖的方式來(lái)完成集群環(huán)境下的并發(fā)操控。
scheduler實(shí)例在集群方式下首要獲取{0}LOCKS表中的行鎖,Mysql 獲取行鎖的句子:
{0}會(huì)替換為裝備文件默許裝備的QRTZ_。sched_name為運(yùn)用集群的實(shí)例名,lock_name便是行級(jí)鎖名。Quartz主要有兩個(gè)行級(jí)鎖觸發(fā)器拜訪鎖 (TRIGGER_ACCESS) 和 狀況拜訪鎖(STATE_ACCESS)。
這個(gè)架構(gòu)處理了使命的分布式調(diào)度問(wèn)題,同一個(gè)使命只能有一個(gè)節(jié)點(diǎn)運(yùn)轉(zhuǎn),其他節(jié)點(diǎn)將不履行使命,當(dāng)碰到許多短使命時(shí),各個(gè)節(jié)點(diǎn)頻頻的競(jìng)賽數(shù)據(jù)庫(kù)鎖,節(jié)點(diǎn)越多功用就會(huì)越差。
2 分布式鎖方式
Quartz的集群方式能夠水平擴(kuò)展,也能夠分布式調(diào)度,但需求事務(wù)方在數(shù)據(jù)庫(kù)中增加對(duì)應(yīng)的表,有必定的強(qiáng)侵入性。
有不少研發(fā)同學(xué)為了防止這種侵入性,也探究出分布式鎖方式。
事務(wù)場(chǎng)景:電商項(xiàng)目,用戶下單后一段時(shí)刻沒(méi)有付款,體系就會(huì)在超時(shí)后封閉該訂單。
一般咱們會(huì)做一個(gè)守時(shí)使命每?jī)煞昼妬?lái)檢查前半小時(shí)的訂單,將沒(méi)有付款的訂單列表查詢出來(lái),然后對(duì)訂單中的商品進(jìn)行庫(kù)存的恢復(fù),然后將該訂單設(shè)置為無(wú)效。
咱們運(yùn)用Spring Schedule的方式做一個(gè)守時(shí)使命。
@Scheduled(cron = "0 */2 * * * ? ") public void doTask() {
log.info("守時(shí)使命發(fā)動(dòng)"); //履行封閉訂單的操作 orderService.closeExpireUnpayOrders();
log.info("守時(shí)使命完畢");
}
在單服務(wù)器運(yùn)轉(zhuǎn)正常,考慮到高可用,事務(wù)量激增,架構(gòu)會(huì)演進(jìn)成集群方式,在同一時(shí)刻有多個(gè)服務(wù)履行一個(gè)守時(shí)使命,有可能會(huì)導(dǎo)致事務(wù)紊亂。
處理計(jì)劃是在使命履行的時(shí)分,運(yùn)用Redis 分布式鎖來(lái)處理這類問(wèn)題。
@Scheduled(cron = "0 */2 * * * ? ") public void doTask() {
log.info("守時(shí)使命發(fā)動(dòng)");
String lockName = "closeExpireUnpayOrdersLock";
RedisLock redisLock = redisClient.getLock(lockName); //嘗試加鎖,最多等候3秒,上鎖今后5分鐘主動(dòng)解鎖 boolean locked = redisLock.tryLock(3, 300, TimeUnit.SECONDS); if(!locked){
log.info("沒(méi)有獲得分布式鎖:{}" , lockName); return;
} try{ //履行封閉訂單的操作 orderService.closeExpireUnpayOrders();
} finally {
redisLock.unlock();
}
log.info("守時(shí)使命完畢");
}
Redis的讀寫(xiě)功用極好,分布式鎖也比Quartz數(shù)據(jù)庫(kù)行級(jí)鎖更輕量級(jí)。當(dāng)然Redis鎖也能夠替換成Zookeeper鎖,也是同樣的機(jī)制。
在小型項(xiàng)目中,運(yùn)用:守時(shí)使命結(jié)構(gòu)(Quartz/Spring Schedule)和 分布式鎖(redis/zookeeper)有不錯(cuò)的效果。
可是呢?咱們能夠發(fā)現(xiàn)這種組合有兩個(gè)問(wèn)題:
- 守時(shí)使命在分布式場(chǎng)景下有空跑的狀況,并且使命也無(wú)法做到分片;
- 要想手藝觸發(fā)使命,必須增加額外的代碼才能完結(jié)。
3 ElasticJob-Lite 結(jié)構(gòu)
ElasticJob-Lite 定位為輕量級(jí)無(wú)中心化處理計(jì)劃,運(yùn)用 jar 的方式供給分布式使命的和諧服務(wù)。
運(yùn)用內(nèi)部界說(shuō)使命類,完成SimpleJob接口,編寫(xiě)自己使命的實(shí)際事務(wù)流程即可。
public class MyElasticJob implements SimpleJob {
@Override public void execute(ShardingContext context) { switch (context.getShardingItem()) { case 0: // do something by sharding item 0 break; case 1: // do something by sharding item 1 break; case 2: // do something by sharding item 2 break; // case n: ... }
}
}
舉例:運(yùn)用A有五個(gè)使命需求履行,分別是A,B,C,D,E。使命E需求分紅四個(gè)子使命,運(yùn)用布置在兩臺(tái)機(jī)器上。
運(yùn)用A在發(fā)動(dòng)后, 5個(gè)使命經(jīng)過(guò) Zookeeper 和諧后被分配到兩臺(tái)機(jī)器上,經(jīng)過(guò)Quartz Scheduler 分開(kāi)履行不同的使命。
ElasticJob 從實(shí)質(zhì)上來(lái)講 ,底層使命調(diào)度仍是經(jīng)過(guò) Quartz ,比較Redis分布式鎖 或者 Quartz 分布式布置 ,它的優(yōu)勢(shì)在于能夠依靠 Zookeeper 這個(gè)大殺器 ,將使命經(jīng)過(guò)負(fù)載均衡算法分配給運(yùn)用內(nèi)的 Quartz Scheduler容器。
從運(yùn)用者的角度來(lái)講,是十分簡(jiǎn)略易用的。但從架構(gòu)來(lái)看,調(diào)度器和履行器仍然在同一個(gè)運(yùn)用方JVM內(nèi),并且容器在發(fā)動(dòng)后,仍然需求做負(fù)載均衡。運(yùn)用假設(shè)頻頻的重啟,不斷的去選主,對(duì)分片做負(fù)載均衡,這些都是相對(duì)比較重的操作。
另外,ElasticJob 的操控臺(tái)是比較粗糙的,經(jīng)過(guò)讀取注冊(cè)中心數(shù)據(jù)展現(xiàn)作業(yè)狀況,更新注冊(cè)中心數(shù)據(jù)修正全局使命裝備。
4 中心化流派
中心化的原理是:把調(diào)度和使命履行,隔離成兩個(gè)部分:調(diào)度中心和履行器。調(diào)度中心模塊只需求擔(dān)任使命調(diào)度屬性,觸發(fā)調(diào)度指令。履行器接收調(diào)度指令,去履行詳細(xì)的事務(wù)邏輯,并且兩者都能夠進(jìn)行分布式擴(kuò)容。
4.1 MQ方式
先談?wù)勎以谒圐埓黉N團(tuán)隊(duì)觸摸的第一種中心化架構(gòu)。
調(diào)度中心依靠Quartz集群方式,當(dāng)使命調(diào)度時(shí)分,發(fā)送音訊到RabbitMQ 。事務(wù)運(yùn)用收到使命音訊后,消費(fèi)使命信息。
這種模型充分利用了MQ解耦的特性,調(diào)度中心發(fā)送使命,運(yùn)用方作為履行器的人物,接收使命并履行。
但這種規(guī)劃強(qiáng)依靠音訊隊(duì)列,可擴(kuò)展性和功用,體系負(fù)載都和音訊隊(duì)列有極大的相關(guān)。這種架構(gòu)規(guī)劃需求架構(gòu)師對(duì)音訊隊(duì)列十分熟悉。
4.2 XXL-JOB
XXL-JOB 是一個(gè)分布式使命調(diào)度平臺(tái),其中心規(guī)劃目標(biāo)是開(kāi)發(fā)敏捷、學(xué)習(xí)簡(jiǎn)略、輕量級(jí)、易擴(kuò)展?,F(xiàn)已開(kāi)放源代碼并接入多家公司線上產(chǎn)品線,開(kāi)箱即用。
咱們重點(diǎn)剖析下架構(gòu)圖 :
▍ 網(wǎng)絡(luò)通訊 server-worker 模型
調(diào)度中心和履行器 兩個(gè)模塊之間通訊是 server-worker 方式。調(diào)度中心本身便是一個(gè)SpringBoot 工程,發(fā)動(dòng)會(huì)監(jiān)聽(tīng)8080端口。
履行器發(fā)動(dòng)后,會(huì)發(fā)動(dòng)內(nèi)置服務(wù)( EmbedServer )監(jiān)聽(tīng)9994端口。這樣兩邊都能夠給對(duì)方發(fā)送指令。
那調(diào)度中心怎么知道履行器的地址信息呢 ?上圖中,履行器會(huì)守時(shí)發(fā)送注冊(cè)指令 ,這樣調(diào)度中心就能夠獲取在線的履行器列表。
經(jīng)過(guò)履行器列表,就能夠依據(jù)使命裝備的路由策略挑選節(jié)點(diǎn)履行使命。常見(jiàn)的路由策略有如下三種:
- 隨機(jī)節(jié)點(diǎn)履行:挑選集群中一個(gè)可用的履行節(jié)點(diǎn)履行調(diào)度使命。適用場(chǎng)景:離線訂單結(jié)算。
-
廣播履行:在集群中所有的履行節(jié)點(diǎn)分發(fā)調(diào)度使命并履行。適用場(chǎng)景:批量更新運(yùn)用本地緩存。
- 分片履行:依照用戶自界說(shuō)分片邏輯進(jìn)行拆分,分發(fā)到集群中不同節(jié)點(diǎn)并行履行,提升資源利用效率。適用場(chǎng)景:海量日志統(tǒng)計(jì)。
▍ 調(diào)度器
調(diào)度器是使命調(diào)度體系里邊十分中心的組件。XXL-JOB 的早期版別是依靠Quartz。
但在v2.1.0版別中徹底去掉了Quartz的依靠,原來(lái)需求創(chuàng)立的 Quartz表也替換成了自研的表。
中心的調(diào)度類是:JobTriggerPoolHelper 。調(diào)用start辦法后,會(huì)發(fā)動(dòng)兩個(gè)線程:scheduleThread 和 ringThread 。
首要 scheduleThread 會(huì)守時(shí)從數(shù)據(jù)庫(kù)加載需求調(diào)度的使命,這里從實(shí)質(zhì)上仍是根據(jù)數(shù)據(jù)庫(kù)行鎖確保一同只有一個(gè)調(diào)度中心節(jié)點(diǎn)觸發(fā)使命調(diào)度。
Connection conn = XxlJobAdminConfig.getAdminConfig()
.getDataSource().getConnection();
connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
preparedStatement.execute();
# 觸發(fā)使命調(diào)度 (偽代碼) for (XxlJobInfo jobInfo: scheduleList) { // 省掉代碼 }
# 事務(wù)提交
conn.commit();
調(diào)度線程會(huì)依據(jù)使命的「下次觸發(fā)時(shí)刻」,采取不同的動(dòng)作:
已過(guò)期的使命需求馬上履行的,直接放入線程池中觸發(fā)履行 ,五秒內(nèi)需求履行的使命放到 ringData 目標(biāo)里。
ringThread 發(fā)動(dòng)后,守時(shí)從 ringData 目標(biāo)里獲取需求履行的使命列表 ,放入到線程池中觸發(fā)履行。
5 自研在巨人的肩膀上
2018年,我有一段自研使命調(diào)度體系的經(jīng)歷。
背景是:兼容技能團(tuán)隊(duì)自研的RPC結(jié)構(gòu),技能團(tuán)隊(duì)不需求修正代碼,RPC注解辦法能夠托管在使命調(diào)度體系中,直接當(dāng)做一個(gè)使命來(lái)履行。
自研過(guò)程中,研讀了XXL-JOB 源碼,一同從阿里云分布式使命調(diào)度 SchedulerX 吸取了許多養(yǎng)分。
- Schedulerx-console 是使命調(diào)度的操控臺(tái),用于創(chuàng)立、辦理守時(shí)使命。擔(dān)任數(shù)據(jù)的創(chuàng)立、修正和查詢。在產(chǎn)品內(nèi)部與 schedulerx-server 交互。
- Schedulerx-server 是使命調(diào)度的服務(wù)端,是 Scheduler的中心組件。擔(dān)任客戶端使命的調(diào)度觸發(fā)以及使命履行狀況的監(jiān)測(cè)。
- Schedulerx-client 是使命調(diào)度的客戶端。每個(gè)接入客戶端的運(yùn)用進(jìn)程便是一個(gè)的 Worker。 Worker 擔(dān)任與 schedulerx-server 建立通信,讓 schedulerx-server發(fā)現(xiàn)客戶端的機(jī)器。 并向schedulerx-server注冊(cè)當(dāng)前運(yùn)用地點(diǎn)的分組,這樣 schedulerx-server 才能向客戶端守時(shí)觸發(fā)使命。
咱們模仿了SchedulerX的模塊,架構(gòu)規(guī)劃如下圖:
我挑選了 RocketMQ 源碼的通訊模塊 remoting 作為自研調(diào)度體系的通訊結(jié)構(gòu)。根據(jù)如下兩點(diǎn):
- 我對(duì)業(yè)界大名鼎鼎的 Dubbo不熟悉,而remoting我現(xiàn)已做了多個(gè)輪子,我信任自己能夠搞定;
- 在閱覽 SchedulerX 1.0 client 源碼中,發(fā)現(xiàn) SchedulerX 的通訊結(jié)構(gòu)和RocketMQ Remoting許多地方都很類似。它的源碼里有現(xiàn)成的工程完成,徹底便是一個(gè)瑰寶。
我將 RocketMQ remoting 模塊去掉名字服務(wù)代碼,做了必定程度的定制。
在RocketMQ的remoting里,服務(wù)端選用 Processor 方式。
調(diào)度中心需求注冊(cè)兩個(gè)處理器:回調(diào)效果處理器CallBackProcessor和心跳處理器HeartBeatProcessor 。履行器需求注冊(cè)觸發(fā)使命處理器TriggerTaskProcessor 。
public void registerProcessor( int requestCode,
NettyRequestProcessor processor,
ExecutorService executor);
處理器的接口:
public interface NettyRequestProcessor { RemotingCommand processRequest(
ChannelHandlerContext ctx,
RemotingCommand request) throws Exception; boolean rejectRequest();
}
關(guān)于通訊結(jié)構(gòu)來(lái)講,我并不需求關(guān)注通訊細(xì)節(jié),只需求完成處理器接口即可。
以觸發(fā)使命處理器TriggerTaskProcessor舉例:
搞定網(wǎng)絡(luò)通訊后,調(diào)度器怎么規(guī)劃 ?終究我仍是挑選了Quartz 集群方式。主要是根據(jù)以下幾點(diǎn)原因:
- 調(diào)衡量不大的狀況下 ,Quartz 集群方式滿足安穩(wěn),并且能夠兼容原來(lái)的XXL-JOB使命;
- 運(yùn)用時(shí)刻輪的話,本身沒(méi)有滿足的實(shí)踐經(jīng)驗(yàn),憂慮出問(wèn)題。 另外,怎么讓使命經(jīng)過(guò)不同的調(diào)度服務(wù)(schedule-server)觸發(fā), 需求有一個(gè)和諧器。于是想到Zookeeper。但這樣的話,又引進(jìn)了新的組件。
- 研發(fā)周期不能太長(zhǎng),想快點(diǎn)出效果。
自研版的調(diào)度服務(wù)花費(fèi)一個(gè)半月上線了。體系運(yùn)轉(zhuǎn)十分安穩(wěn),研發(fā)團(tuán)隊(duì)接入也很順利。 調(diào)衡量也不大 ,四個(gè)月一共挨近4000萬(wàn)到5000萬(wàn)之間的調(diào)衡量。
坦率的講,自研版的瓶頸,我的腦海里經(jīng)常能看到。 數(shù)據(jù)量大,我能夠搞定分庫(kù)分表,但 Quartz 集群根據(jù)行級(jí)鎖的方式 ,注定上限不會(huì)太高。
為了免除心中的困惑,我寫(xiě)一個(gè)輪子DEMO看看可否work:
- 去掉外置的注冊(cè)中心,調(diào)度服務(wù)(schedule-server)辦理會(huì)話;
- 引進(jìn)zookeeper,經(jīng)過(guò)zk和諧調(diào)度服務(wù)。可是HA機(jī)制很粗糙,相當(dāng)于一個(gè)使命調(diào)度服務(wù)運(yùn)轉(zhuǎn),另一個(gè)服務(wù)standby;
- Quartz 替換成時(shí)刻輪 (參閱Dubbo里的時(shí)刻輪源碼)。
這個(gè)Demo版別在開(kāi)發(fā)環(huán)境能夠運(yùn)轉(zhuǎn),但有許多細(xì)節(jié)需求優(yōu)化,僅僅是個(gè)玩具,并沒(méi)有時(shí)機(jī)運(yùn)轉(zhuǎn)到出產(chǎn)環(huán)境。
最近讀阿里云的一篇文章《怎么經(jīng)過(guò)使命調(diào)度完成百萬(wàn)規(guī)矩報(bào)警》,SchedulerX2.0 高可用架構(gòu)見(jiàn)下圖:
文章說(shuō)到:
每個(gè)運(yùn)用都會(huì)做三備份,經(jīng)過(guò) zk 搶鎖,一主兩備,如果某臺(tái) Server 掛了,會(huì)進(jìn)行 failover,由其他 Server 接收調(diào)度使命。
這次自研使命調(diào)度體系從架構(gòu)來(lái)講,并不雜亂,完成了XXL-JOB的中心功用,也兼容了技能團(tuán)隊(duì)的RPC結(jié)構(gòu),但并沒(méi)有完成工作流以及mapreduce分片。
SchedulerX 在升級(jí)到2.0之后根據(jù)全新的Akka 架構(gòu),這種架構(gòu)聲稱完成高功用工作流引擎,完成進(jìn)程間通信,減少網(wǎng)絡(luò)通訊代碼。
在我調(diào)研的開(kāi)源使命調(diào)度體系中,PowerJob也是根據(jù)Akka 架構(gòu),一同也完成了工作流和MapReduce履行方式。
我對(duì)PowerJob十分感興趣,也會(huì)在學(xué)習(xí)實(shí)踐后輸出相關(guān)文章,敬請(qǐng)期待。
6 技能選型
首要咱們將使命調(diào)度開(kāi)源產(chǎn)品和商業(yè)產(chǎn)品 SchedulerX 放在一同,生成一張對(duì)照表:
Quartz 和 ElasticJob從實(shí)質(zhì)上仍是歸于結(jié)構(gòu)的層面。
中心化產(chǎn)品從架構(gòu)上來(lái)講愈加清晰,調(diào)度層面更靈活,能夠支撐更雜亂的調(diào)度(mapreduce動(dòng)態(tài)分片,工作流)。
XXL-JOB 從產(chǎn)品層面現(xiàn)已做到極簡(jiǎn),開(kāi)箱即用,調(diào)度方式能夠滿足大部分研發(fā)團(tuán)隊(duì)的需求。簡(jiǎn)略易用 + 能打,所以十分受大家歡迎。
其實(shí)每個(gè)技能團(tuán)隊(duì)的技能儲(chǔ)備不盡相同,面對(duì)的場(chǎng)景也不一樣,所以技能選型并不能一概而論。
不管是運(yùn)用哪種技能,在編寫(xiě)使命事務(wù)代碼時(shí),仍是需求注意兩點(diǎn):
- 冪等。當(dāng)使命被重復(fù)履行的時(shí)分,或者分布式鎖失效的時(shí)分,程序仍然能夠輸出正確的效果;
- 使命不跑了,千萬(wàn)別驚慌。檢查調(diào)度日志,JVM層面運(yùn)用Jstack指令檢查倉(cāng)庫(kù),網(wǎng)絡(luò)通訊要增加超時(shí)時(shí)刻 ,一般能處理大部分問(wèn)題。
7 寫(xiě)到最終
2015年其實(shí)是十分有趣的一年。ElasticJob 和 XXL-JOB 這兩種不同流派的使命調(diào)度項(xiàng)目都開(kāi)源了。
在 XXL-JOB 源碼里,至今還保留著許雪里教師在開(kāi)源我國(guó)的一條動(dòng)態(tài)截圖:
剛寫(xiě)的使命調(diào)度結(jié)構(gòu) ,Web動(dòng)態(tài)辦理使命,實(shí)時(shí)收效,熱乎的。沒(méi)有意外的話,明天正午推送到git.osc上去。哈哈,下樓炒個(gè)面加個(gè)荷包蛋慶祝下。
看到這個(gè)截圖,內(nèi)心深處居然會(huì)有一種共情,嘴角不自禁的上揚(yáng)。
我又想起:2016年,ElasticJob的作者張亮教師開(kāi)源了sharding-jdbc 。我在github上創(chuàng)立了一個(gè)私有項(xiàng)目,參閱sharding-jdbc的源碼,自己完成分庫(kù)分表的功用。第一個(gè)類名叫:ShardingDataSource,時(shí)刻定格在 2016/3/29。
我不知道怎么界說(shuō)“有創(chuàng)造力的軟件工程師”,但我信任:一個(gè)有好奇心,努力學(xué)習(xí),樂(lè)于分享,樂(lè)意去協(xié)助他人的工程師,運(yùn)氣肯定不會(huì)太差。
覺(jué)得對(duì)您有協(xié)助的話,請(qǐng)給作者一個(gè)「點(diǎn)贊」和「保藏」,咱們下期見(jiàn)。