當前位置:
首頁 > 科技 > Nodejs cluster模塊深入探究

Nodejs cluster模塊深入探究

前言

本期早讀文章由微店 @ 欲休投稿分享。

正文從這開始~

由表及裡

HTTP伺服器用於響應來自客戶端的請求,當客戶端請求數逐漸增大時服務端的處理機制有多種,如tomcat的多線程、nginx的事件循環等。而對於node而言,由於其也採用事件循環和非同步I/O機制,因此在高I/O並發的場景下性能非常好,但是由於單個node程序僅僅利用單核cpu,因此為了更好利用系統資源就需要fork多個node進程執行HTTP伺服器邏輯,所以node內建模塊提供了child_process和cluster模塊。利用child_process模塊,我們可以執行shell命令,可以fork子進程執行代碼,也可以直接執行二進位文件;利用cluster模塊,使用node封裝好的API、IPC通道和調度機可以非常簡單的創建包括一個master進程下HTTP代理伺服器 + 多個worker進程多個HTTP應用伺服器的架構,並提供兩種調度子進程演算法。本文主要針對cluster模塊講述node是如何實現簡介高效的服務集群創建和調度的。那麼就從代碼進入本文的主題:

constcluster=require( cluster );

consthttp=require( http );

if(cluster.isMaster){

letnumReqs=;

setInterval(()=>{

console.log(`numReqs =${numReqs}`);

},1000);

functionmessageHandler(msg){

if(msg.cmd&&msg.cmd=== notifyRequest ){

numReqs+=1;

}

}

constnumCPUs=require( os ).cpus().length;

for(leti=;i

cluster.fork();

}

for(constidincluster.workers){

cluster.workers[id].on( message ,messageHandler);

}

}else{

// Worker processes have a http server.

http.Server((req,res)=>{

res.writeHead(200);

res.end( hello world
);

process.send({cmd: notifyRequest });

}).listen(8000);

}

主進程創建多個子進程,同時接受子進程傳來的消息,循環輸出處理請求的數量;

子進程創建http伺服器,偵聽8000埠並返迴響應。

泛泛的大道理誰都了解,可是這套代碼如何運行在主進程和子進程中呢?父進程如何向子進程傳遞客戶端的請求?多個子進程共同偵聽8000埠,會不會造成埠reuse error?每個伺服器進程最大可有效支持多少並發量?主進程下的代理伺服器如何調度請求? 這些問題,如果不深入進去便永遠只停留在寫應用代碼的層面,而且不了解cluster集群創建的多進程與使用child_process創建的進程集群的區別,也寫不出符合業務的最優代碼,因此,深入cluster還是有必要的。

cluster與net

cluster模塊與net模塊息息相關,而net模塊又和底層socket有聯繫,至於socket則涉及到了系統內核,這樣便由表及裡的了解了node對底層的一些優化配置,這是我們的思路。介紹前,筆者仔細研讀了node的js層模塊實現,在基於自身理解的基礎上詮釋上節代碼的實現流程,力圖做到清晰、易懂,如果有某些紕漏也歡迎讀者指出,只有在互相交流中才能收穫更多。

一套代碼,多次執行

很多人對code1代碼如何在主進程和子進程執行感到疑惑,怎樣通過cluster.isMaster判斷語句內的代碼是在主進程執行,而其他代碼在子進程執行呢?

其實只要你深入到了node源碼層面,這個問題很容易作答。cluster模塊的代碼只有一句:

module.exports=( NODE_UNIQUE_ID inprocess.env)?

require( internal/cluster/child ):

require( internal/cluster/master );

只需要判斷當前進程有沒有環境變數「NODE_UNIQUE_ID」就可知道當前進程是否是主進程;而變數「NODE_UNIQUE_ID」則是在主進程fork子進程時傳遞進去的參數,因此採用cluster.fork創建的子進程是一定包含「NODE_UNIQUE_ID」的。

這裡需要指出的是,必須通過cluster.fork創建的子進程才有NODE_UNIQUE_ID變數,如果通過child_process.fork的子進程,在不傳遞環境變數的情況下是沒有NODE_UNIQUE_ID的。因此,當你在child_process.fork的子進程中執行cluster.isMaster判斷時,返回 true。

主進程與伺服器

code1中,並沒有在cluster.isMaster的條件語句中創建伺服器,也沒有提供伺服器相關的路徑、埠和fd,那麼主進程中是否存在TCP伺服器,有的話到底是什麼時候怎麼創建的?

相信大家在學習nodejs時閱讀的各種書籍都介紹過在集群模式下,主進程的伺服器會接受到請求然後發送給子進程,那麼問題就來到主進程的伺服器到底是如何創建呢?主進程伺服器的創建離不開與子進程的交互,畢竟與創建伺服器相關的信息全在子進程的代碼中。

當子進程執行

http.Server((req,res)=>{

res.writeHead(200);

res.end( hello world
);

process.send({cmd: notifyRequest });

}).listen(8000);

Server.prototype.listen=function(){

...

// 根據參數創建 handle句柄

options=options._handle||options.handle||options;

// (handle[, backlog][, cb]) where handle is an object with a handle

if(optionsinstanceofTCP){

this._handle=options;

this[async_id_symbol]=this._handle.getAsyncId();

listenInCluster(this,null,-1,-1,backlogFromArgs);

returnthis;

}

...

varbacklog;

if(typeofoptions.port=== number ||typeofoptions.port=== string ){

if(!isLegalPort(options.port)){

thrownewRangeError( "port" argument must be >= 0 and < 65536 );

}

backlog=options.backlog||backlogFromArgs;

// start TCP server listening on host:port

if(options.host){

lookupAndListen(this,options.port|,options.host,backlog,

options.exclusive);

}else{// Undefined host, listens on unspecified address

// Default addressType 4 will be used to search for master server

listenInCluster(this,null,options.port|,4,

backlog,undefined,options.exclusive);

}

returnthis;

}

...

thrownewError( Invalid listen argument: +util.inspect(options));

};

由於本文只探究cluster模式下HTTP伺服器的相關內容,因此我們只關注有關TCP伺服器部分,其他的Pipe(domain socket)服務不考慮。

listen函數可以偵聽埠、路徑和指定的fd,因此在listen函數的實現中判斷各種參數的情況,我們最為關心的就是偵聽埠的情況,在成功進入條件語句後發現所有的情況最後都執行了listenInCluster函數而返回,因此有必要繼續探究。

functionlistenInCluster(server,address,port,addressType,

backlog,fd,exclusive){

...

if(cluster.isMaster||exclusive){

server._listen2(address,port,addressType,backlog,fd);

return;

}

// 後續代碼為worker執行邏輯

constserverQuery={

address:address,

port:port,

addressType:addressType,

fd:fd,

flags:

};

...

cluster._getServer(server,serverQuery,listenOnMasterHandle);

}

listenInCluster函數傳入了各種參數,如server實例、ip、port、ip類型(IPv6和IPv4)、backlog(底層服務端socket處理請求的最大隊列)、fd等,它們不是必須傳入,比如創建一個TCP伺服器,就僅僅需要一個port即可。

簡化後的listenInCluster函數很簡單,cluster模塊判斷當前進程為主進程時,執行_listen2函數;否則,在子進程中執行cluster._getServer函數,同時像函數傳遞serverQuery對象,即創建伺服器需要的相關信息。

因此,我們可以大膽假設,子進程在cluster._getServer函數中向主進程發送了創建伺服器所需要的數據,即serverQuery。實際上也確實如此:

cluster._getServer=function(obj,options,cb){

constmessage=util._extend({

act: queryServer ,

index:indexes[indexesKey],

data:null

},options);

send(message,functionmodifyHandle(reply,handle)=>{

if(typeofobj._setServerData=== function )

obj._setServerData(reply.data);

if(handle)

shared(reply,handle,indexesKey,cb);// Shared listen socket.

else

rr(reply,indexesKey,cb);// Round-robin.

});

};

子進程在該函數中向已建立的IPC通道發送內部消息message,該消息包含之前提到的serverQuery信息,同時包含act: queryServer 欄位,等待服務端響應後繼續執行回調函數modifyHandle。

主進程接收到子進程發送的內部消息,會根據act: queryServer 執行對應queryServer方法,完成伺服器的創建,同時發送回復消息給子進程,子進程執行回調函數modifyHandle,繼續接下來的操作。

至此,針對主進程在cluster模式下如何創建伺服器的流程已完全走通,主要的邏輯是在子進程伺服器的listen過程中實現。

net模塊與socket

functionsetupListenHandle(address,port,addressType,backlog,fd){

// worker進程中,_handle為fake對象,無需創建

if(this._handle){

debug( setupListenHandle: have a handle already );

}else{

debug( setupListenHandle: create a handle );

if(rval===null)

rval=createServerHandle(address,port,addressType,fd);

this._handle=rval;

}

this[async_id_symbol]=getNewAsyncId(this._handle);

this._handle.onconnection=onconnection;

varerr=this._handle.listen(backlog||511);

}

通過createServerHandle函數創建句柄(句柄可理解為用戶空間的socket),同時給屬性onconnection賦值,最後偵聽埠,設定backlog。

那麼,socket處理請求過程「socket(),bind()」步驟就是在createServerHandle 完成。

functioncreateServerHandle(address,port,addressType,fd){

varhandle;

// 針對網路連接,綁定地址

if(address||port||isTCP){

if(!address){

err=handle.bind6( :: ,port);

if(err){

handle.close();

returncreateServerHandle( 0.0.0.0 ,port);

}

}elseif(addressType===6){

err=handle.bind6(address,port);

}else{

err=handle.bind(address,port);

}

}

returnhandle;

}

在createServerHandle中,我們看到了如何創建socket(createServerHandle在底層利用node自己封裝的類庫創建TCP handle),也看到了bind綁定ip和地址,那麼node的net模塊如何接收客戶端請求呢?

必須深入c++模塊才能了解node是如何實現在c++層面調用js層設置的onconnection回調屬性,v8引擎提供了c++和js層的類型轉換和介面透出,在c++的tcp_wrap 中:

voidTCPWrap::Listen(constFunctionCallbackInfo&args){

TCPWrap*wrap;

ASSIGN_OR_RETURN_UNWRAP(&wrap,

args.Holder(),

args.GetReturnValue().Set(UV_EBADF));

int backloxxg=args[]->Int32Value();

int err=uv_listen(reinterpret_cast(&wrap->handle_),

backlog,

OnConnection);

args.GetReturnValue().Set(err);

}

我們關注uv_listen函數,它是libuv封裝後的函數,傳入了handle_,backlog和OnConnection回調函數,其中handle_為node調用libuv介面創建的socket封裝,OnConnection函數為socket接收客戶端連接時執行的操作。我們可能會猜測在js層設置的onconnction函數最終會在OnConnection中調用,於是進一步深入探查node的connection_wrap c++ 模塊:

template

voidConnectionWrap::OnConnection(uv_stream_t*handle,

int status){

if(status==){

if(uv_accept(handle,client_handle))

return;

// Successful accept. Call the onconnection callback in JavaScript land.

argv[1]=client_obj;

}

wrap_data->MakeCallback(env->onconnection_string(),arraysize(argv),argv);

}

過濾掉多餘信息便於分析。當新的客戶端連接到來時,libuv調用OnConnection,在該函數內執行uv_accept接收連接,最後將js層的回調函數onconnection[通過env->onconnection_string()獲取js的回調]和接收到的客戶端socket封裝傳入MakeCallback中。其中,argv數組的第一項為錯誤信息,第二項為已連接的clientSocket封裝,最後在MakeCallback中執行js層的onconnection函數,該函數的參數正是argv數組傳入的數據,「錯誤代碼和clientSocket封裝」。

js層的onconnection 回調

functiononconnection(err,clientHandle){

varhandle=this;

if(err){

self.emit( error ,errnoException(err, accept ));

return;

}

varsocket=newSocket({

handle:clientHandle,

allowHalfOpen:self.allowHalfOpen,

pauseOnCreate:self.pauseOnConnect

});

socket.readable=socket.writable=true;

self.emit( connection ,socket);

}

這樣,node在C++層調用js層的onconnection函數,構建node層的socket對象,並觸發connection事件,完成底層socket與node net模塊的連接與請求打通。

至此,我們打通了socket連接建立過程與net模塊(js層)的流程的交互,這種封裝讓開發者在不需要查閱底層介面和數據結構的情況下,僅使用node提供的http模塊就可以快速開發一個應用伺服器,將目光聚集在業務邏輯中。

backlog是已連接但未進行accept處理的socket隊列大小。在linux 2.2以前,backlog大小包括了半連接狀態和全連接狀態兩種隊列大小。linux 2.2以後,分離為兩個backlog來分別限制半連接SYN_RCVD狀態的未完成連接隊列大小跟全連接ESTABLISHED狀態的已完成連接隊列大小。這裡的半連接狀態,即在三次握手中,服務端接收到客戶端SYN報文後並發送SYN+ACK報文後的狀態,此時服務端等待客戶端的ACK,全連接狀態即服務端和客戶端完成三次握手後的狀態。backlog並非越大越好,當等待accept隊列過長,服務端無法及時處理排隊的socket,會造成客戶端或者前端伺服器如nignx的連接超時錯誤,出現「error: Broken Pipe」。因此,node默認在socket層設置backlog默認值為511,這是因為nginx和redis默認設置的backlog值也為此,盡量避免上述錯誤。

多個子進程與埠復用

再回到關於cluster模塊的主線中來。code1中,主進程與所有子進程通過消息構建出偵聽8000埠的TCP伺服器,那麼子進程中有沒有也創建一個伺服器,同時偵聽8000埠呢?其實,在子進程中壓根就沒有這回事,如何理解呢?子進程中確實創建了net.Server對象,可是它沒有像主進程那樣在libuv層構建socket句柄,子進程的net.Server對象使用的是一個人為fake出的一個假句柄來「欺騙」使用者埠已偵聽,這樣做的目的是為了集群的負載均衡,這又涉及到了cluster模塊的均衡策略的話題上。

在本節有關cluster集群埠偵聽以及請求處理的描述,都是基於cluster模式的默認策略RoundRobin之上討論的,關於調度策略的討論,我們放在下節進行。

在主進程與伺服器這一章節最後,我們只了解到主進程是如何創建偵聽給定埠的TCP伺服器的,此時子進程還在等待主進程創建後發送的消息。當主進程發送創建伺服器成功的消息後,子進程會執行modifyHandle回調函數。還記得這個函數嗎?主進程與伺服器這一章節最後已經貼出來它的源碼:

functionmodifyHandle(reply,handle)=>{

if(typeofobj._setServerData=== function )

obj._setServerData(reply.data);

if(handle)

shared(reply,handle,indexesKey,cb);// Shared listen socket.

else

rr(reply,indexesKey,cb);// Round-robin.

}

它會根據主進程是否返回handle句柄(即libuv對socket的封裝)來選擇執行函數。由於cluter默認採用RoundRobin調度策略,因此主進程返回的handle為null,執行函數rr。在該函數中,做了上文提到的hack操作,作者fake了一個假的handle對象,「欺騙」上層調用者:

functionlisten(backlog){

return;

}

consthandle={close,listen,ref:noop,unref:noop};

handles[key]=handle;

cb(,handle);

看到了嗎?fake出的handle.listen並沒有調用libuv層的Listen方法,它直接返回了。這意味著什麼??子進程壓根沒有創建底層的服務端socket做偵聽,所以在子進程創建的HTTP伺服器偵聽的埠根本不會出現埠復用的情況。 最後,調用cb函數,將fake後的handle傳遞給上層net.Server,設置net.Server對底層的socket的引用。此後,子進程利用fake後的handle做埠偵聽(其實壓根啥都沒有做),執行成功後返回。

那麼子進程TCP伺服器沒有創建底層socket,如何接受請求和發送響應呢?這就要依賴IPC通道了。既然主進程負責接受客戶端請求,那麼理所應當由主進程分發客戶端請求給某個子進程,由子進程處理請求。實際上也確實是這樣做的,主進程的伺服器中會創建RoundRobinHandle決定分發請求給哪一個子進程,篩選出子進程後發送newconn消息給對應子進程:

constmessage={act: newconn ,key:this.key};

sendHelper(worker.process,message,handle,(reply)=>{

if(reply.accepted)

handle.close();

else

this.distribute(,handle);// Worker is shutting down. Send to another.

this.handoff(worker);

});

子進程接收到newconn消息後,會調用內部的onconnection函數,先向主進程發送開始處理請求的消息,然後執行業務處理函數handle.onconnection。還記得這個handle.onconnection嗎?它正是上節提到的node在c++層執行的js層回調函數,在handle.onconnection中構造了net.Socket對象標識已連接的socket,最後觸發connection事件調用開發者的業務處理函數(此時的數據處理對應在網路模型的第四層傳輸層中,node的http模塊會從socket中獲取數據做應用層的封裝,解析出請求頭、請求體並構造響應體),這樣便從內核socket->libuv->js依次執行到開發者的業務邏輯中。

到此為止,相信讀者已經明白node是如何處理客戶端的請求了,那麼下一步繼續探究node是如何分發客戶端的請求給子進程的。

請求分發策略

上節提到cluster模塊默認採用RoundRobin調度策略,那麼還有其他策略可以選擇嗎?答案是肯定的,在windows機器中,cluster模塊採用的是共享服務端socket方式,通俗點說就是由操作系統進行調度客戶端的請求,而不是由node程序調度。其實在node v0.8以前,默認的集群模式就是採用操作系統調度方式進行,直到cluster模塊的加入才有了改變。

那麼,RoundRobin調度策略到底是怎樣的呢?

RoundRobinHandle.prototype.distribute=function(err,handle){

this.handles.push(handle);

constworker=this.free.shift();

if(worker)

this.handoff(worker);

};

// 發送消息和handle給對應worker進程,處理業務邏輯

RoundRobinHandle.prototype.handoff=function(worker){

if(worker.idinthis.all===false){

return;// Worker is closing (or has closed) the server.

}

consthandle=this.handles.shift();

if(handle===undefined){

this.free.push(worker);// Add to ready queue again.

return;

}

constmessage={act: newconn ,key:this.key};

sendHelper(worker.process,message,handle,(reply)=>{

if(reply.accepted)

handle.close();

else

this.distribute(,handle);// Worker is shutting down. Send to another.

this.handoff(worker);

});

};

核心代碼就是這兩個函數,濃縮的是精華。distribute函數負責篩選出處理請求的子進程,this.free數組存儲空閑的子進程,this.handles數組存放待處理的用戶請求。handoff函數獲取排隊中的客戶端請求,並通過IPC發送句柄handle和newconn消息,等待子進程返回。當子進程返回正在處理請求消息時,在此執行handoff函數,繼續分配請求給該子進程,不管該子進程上次請求是否處理完成(node的非同步特性和事件循環可以讓單進程處理多請求)。按照這樣的策略,主進程每fork一個子進程,都會調用handoff函數,進入該子進程的處理循環中。一旦主進程沒有緩存的客戶端請求時(this.handles為空),便會將當前子進程加入free空閑隊列,等待主進程的下一步調度。這就是cluster模式的RoundRobin調度策略,每個子進程的處理邏輯都是一個閉環,直到主進程緩存的客戶端請求處理完畢時,該子進程的處理閉環才被打開。

這麼簡單的實現帶來的效果卻是不小,經過全世界這麼多使用者的嘗試,主進程分發請求還是很平均的,如果RoundRobin的調度需求不滿足你業務中的要求,你可以嘗試仿照RoundRobin模塊寫一個另類的調度演算法。

那麼cluster模塊在windows系統中採用的shared socket策略(後文簡稱SS策略)是什麼呢?採用SS策略調度演算法,子進程的伺服器工作邏輯完全不同於上文中所講的那樣,子進程創建的TCP伺服器會在底層偵聽埠並處理響應,這是如何實現的呢?SS策略的核心在於IPC傳輸句柄的文件描述符,並且在C++層設置埠的SO_REUSEADDR選項,最後根據傳輸的文件描述符還原出handle(net.TCP),處理請求。這正是shared socket名稱由來,共享文件描述符。

子進程繼承父進程fd,處理請求

importsocket

importos

defmain():

serversocket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

serversocket.bind(("127.0.0.1",8888))

serversocket.listen()

# Child Process

ifos.fork()==:

accept_conn("child",serversocket)

accept_conn("parent",serversocket)

defaccept_conn(message,s):

whileTrue:

c,addr=s.accept()

print Got connection from in %s %message

c.send( Thank you for your connecting to %s
%message)

c.close()

if__name__=="__main__":

main()

需要指出的是,在子進程中根據文件描述符還原出的handle,不能再進行bind(ip,port)和listen(backlog)操作,只有主進程創建的handle可以調用這些函數。子進程中只能選擇accept、read和write 操作。

既然SS策略傳遞的是master進程的服務端socket的文件描述符,子進程偵聽該描述符,那麼由誰來調度哪個子進程處理請求呢?這就是由操作系統內核來進行調度。可是內核調度往往出現意想不到的效果,在linux下導致請求往往集中在某幾個子進程中處理。這從內核的調度策略也可以推算一二,內核的進程調度離不開上下文切換,上下文切換的代價很高,不僅需要保存當前進程的代碼、數據和堆棧等用戶空間數據,還需要保存各種寄存器,如PC,ESP,最後還需要恢復被調度進程的上下文狀態,仍然包括代碼、數據和各種寄存器,因此代價非常大。而linux內核在調度這些子進程時往往傾向於喚醒最近被阻塞的子進程,上下文切換的代價相對較小。而且內核的調度策略往往受到當前系統的運行任務數量和資源使用情況,對專註於業務開發的http伺服器影響較大,因此會造成某些子進程的負載嚴重不均衡的狀況。那麼為什麼cluster模塊默認會在windows機器中採用SS策略調度子進程呢?原因是node在windows平台採用的IOCP來最大化性能,它使得傳遞連接的句柄到其他進程的成本很高,因此採用默認的依靠操作系統調度的SS策略。

SS調度策略非常簡單,主進程直接通過IPC通道發送handle給子進程即可,此處就不針對代碼進行分析了。此處,筆者利用node的child_process模塊實現了一個簡易的SS調度策略的服務集群,讀者可以更好的理解:

master 代碼

varnet=require( net );

varcp=require( child_process );

varw1=cp.fork( ./singletest/worker.js );

varw2=cp.fork( ./singletest/worker.js );

varw3=cp.fork( ./singletest/worker.js );

varw4=cp.fork( ./singletest/worker.js );

varserver=net.createServer();

server.listen(8000,function(){

// 傳遞句柄

w1.send({type: handle },server);

w2.send({type: handle },server);

w3.send({type: handle },server);

w4.send({type: handle },server);

server.close();

});

child 代碼

varserver=require( http ).createServer(function(req,res){

res.write(cluster.isMaster+ );

res.end(process.pid+ )

})

varcluster=require( cluster );

process.on( message ,(data,handle)=>{

if(data.type!== handle )

return;

handle.on( connection ,function(socket){

server.emit( connection ,socket)

});

});

這種方式便是SS策略的典型實現,不推薦使用者嘗試。

結尾

開篇提到的一些問題至此都已經解答完畢,關於cluster模塊的一些具體實現本文不做詳細描述,有興趣感受node源碼的同學可以在閱讀本文的基礎上再翻閱,這樣事半功倍。本文是在node源碼和筆者的計算機網路基礎之上混合後的產物,起因於筆者研究PM2的cluster模式下God進程的具體實現。在嘗試幾天仔細研讀node cluster相關模塊後有感於其良好的封裝性,故產生將其內部實現原理和技巧向日常開發者所展示的想法,最後有了這篇文章。

那麼,閱讀了這篇文章,熟悉了cluster模式的具體實現原理,對於日常開發者有什麼促進作用呢?首先,能不停留在使用層面,深入到具體實現原理中去,這便是比大多數人強了;在理解實現機制的階段下,如果能反哺業務開發就更有意義了。比如,根據業務設計出更匹配的負載均衡邏輯;根據服務的日常QPS設置合理的backlog值等;最後,在探究實現的過程中,我們又回顧了許多離應用層開發人員難以接觸到的底層網路編程和操作系統知識,這同時也是學習深入的過程。

接下來,筆者可能會抽時間針對node的其他常用模塊做一次細緻的解讀。其實,node較為重要的Stream模塊筆者已經分析過了,node中的Stream、深入node之Transform,經過深入探究之後在日常開發node應用中有著很大的提升作用,讀者們可以嘗試下。既然提到了Stream模塊,那麼結合本文的net模塊解析,我們就非常容易理解node http模塊的實現了,因為http模塊正是基於net和Stream模塊實現的。那麼下一篇文章就針對http模塊做深入解析吧!

參考文章

Node.js v0.12的新特性 -- Cluster模式採用Round-Robin負載均衡

TCP SOCKET中backlog參數

關於本文

作者:@欲休

原文:http://www.cnblogs.com/accordion/p/7207740.html

點擊展開全文

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 前端早讀課 的精彩文章:

面向初學者的高階組件教程
構建高性能展開&收縮動畫
BATJ 前端面試的 5 大關鍵點,你 Get 到了嗎?
坦然面對:應對前端疲勞

TAG:前端早讀課 |

您可能感興趣

Node.js進階:cluster模塊深入剖析
深入 JVM 分析 spring-boot 應用 hibernate-validatorNoClassDefFoundError
深入 git rebase
深入 SpringBoot : 怎樣排查 expectedsinglematchingbeanbutfound 2 的異常
Greenlight深入分析Oculus Go的消費者認知
MapReduce Shuffle深入理解
深入解讀Google Lens
深入 Spring Boot :實現對 Fat Jar jsp 的支持
convergencias展覽 深入探索古巴設計
深入GlobalFoundries的晶元前沿領域探索之路
文獻 深入探尋Grotowski
深入近賞劃時代手機 Samsung Galaxy Fold
深入對比數據科學工具箱:SparkR vs Sparklyr
async/await使用深入詳解
深入學習Redis:Redis內存模型
三星GalaxyBuds深入體驗報告附對比榮耀flypods
深入淺析一致性模型之Linearizability
Glibc堆漏洞利用基礎-深入理解ptmalloc2 part1
深入了解加州最有名的監獄紋身大師 Freddy Negrete 及黑灰素描紋身大師 Mark Mahoney
「深入探討Xbox One X優化」萬代南夢宮&Arc System Works談《龍珠戰士Z》