最近開發了一個純異步的redis客戶端,算是比較深刻的使用了一把netty。在使用過程當中一邊優化,一邊解決各類坑。兒這些坑大部分基本上是Netty4對Netty3的改進部分引發的。java
注:這裏說的坑不是說netty很差,只是若是這些地方不注意,或者不去看netty的代碼,就有可能掉進去了。linux
坑1: Netty 4的線程模型轉變程序員
在Netty 3的時候,upstream是在IO線程裏執行的,而downstream是在業務線程裏執行的。好比netty從網絡讀取一個包傳遞給你的handler的時候,你的handler部分的代碼是執行在IO線程裏,而你的業務線程調用write向網絡寫出一些東西的時候,你的handler是執行在業務線程裏。而Netty 4修改了這一模型。在Netty 4裏inbound(upstream)和outbound(downstream)都是執行在EventLoop(IO線程)裏。也就是你若是在業務線程裏經過channel.write向網絡寫出一些東西的時候,在某一點,netty 4會往這個channel的EventLoop裏提交一個寫出的任務。那也就是業務線程和IO線程是異步執行的。redis
這有什麼問題呢?通常咱們在網絡通訊裏,業務層寫出的都是對象。而後通過序列化等手段轉換成字節流到網絡,而Netty給咱們提供了很好的編碼解碼的模型,通常咱們也會將序列化和反序列化放到一個handler裏處理,而在Netty 4裏這些handler都是在EventLoop裏執行,那麼就意味着在Netty 4裏下面的代碼可能會致使一些微妙的結果:數據庫
User user = new User();編程
user.setName("admin");bootstrap
channel.write(user);後端
user.setName("guest");數組
由於序列化和業務線程異步執行,那麼在write執行後並不表示user對象已經序列化了,若是這個時候修改了user對象那麼傳遞到peer的對象可能就再也不是你指望的那個user了。因此在Netty 4裏若是仍是使用handler實現序列化就必定要當心了。你要麼在調用channel.write寫出以前將對象進行深度拷貝,要麼就不在handler裏進行序列化了,直接將序列化好的東西傳遞給channel。promise
2. 在不一樣的線程裏使用PooledByteBufAllocator分配和回收
這個問題實際上是上面一個問題的續集。在碰到以前一個問題後,咱們就決定再也不在handler裏作序列化了,而是直接在業務線程裏作。可是爲了減小內存的拷貝,咱們就指望在序列化的時候直接將字節流序列化到DirectByteBuf裏,這樣經過socket寫出的時候就不進行拷貝了。而DirectByteBuf的分配成本比HeapByteBuf的成本要高,爲此Netty 4借鑑jemalloc的思路實現了一個PooledByteBufAllocator。顧名思義,就是將DirectByteBuf池化起來,回收的時候不真正回收,分配的時候從池裏取一個空閒的。這對於大多數應用來講優化效果仍是很明顯的,好比在一些RPC場景中,咱們所傳遞的對象的大小每每是差很少的,這能夠充分利用池化的效果。
可是咱們在使用相似下面的僞代碼的時候內存佔用不斷飆高,而後瘋狂Full GC,而且有的時候還會出現OOM。這好像是內存泄漏的跡象:
//業務線程
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
ByteBuf buffer = allocator.buffer();
User user = new User();
//將對象直接序列化到ByteBuf
serialization.serialize(buffer, user);
//進入EventLoop
channel.writeAndFlush(buffer);
上面的代碼表面看沒什麼問題。但實際上,PooledByteBufAllocator爲了減小鎖競爭,池是經過thread local來實現的。也就是分配的時候會從本線程(這裏就是業務線程)的thread local裏取。而channel.writeAndFlush調用後,在將buffer寫到socket後,這個buffer將被回收到池裏。回收的時候也是經過thread local找到對應的池,回收掉。這樣就有一個問題,分配的時候是在業務線程,也就是說從業務線程的thread local對應的池裏分配的,而回收的時候是在IO線程。這兩個是不一樣的線程。池的做用徹底喪失了,一個線程不斷地去分配,不斷地轉移到另一個池。
3. ByteBuf擴展引發的問題
其實這個問題和上面一個問題是同樣的。可是比以前的問題更加隱晦,就在你彈冠相慶的時候給你致命一擊。在碰到上面一個問題後咱們就在想,既然分配和回收都得在同一個線程裏執行,那咱們是否是能夠啓動一個專門的線程來負責分配和回收呢?因而就有了下面的代碼:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.util.ReferenceCountUtil;
import qunar.tc.qclient.redis.exception.RedisRuntimeException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class Allocator {
public static final ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
private static final BlockingQueue<ByteBuf> bufferQueue = new ArrayBlockingQueue<ByteBuf>(100);
private static final BlockingQueue<ByteBuf> toCleanQueue = new LinkedBlockingQueue<ByteBuf>();
private static final int TO_CLEAN_SIZE = 50;
private static final long CLEAN_PERIOD = 100;
private static class AllocThread implements Runnable {
@Override
public void run() {
long lastCleanTime = System.currentTimeMillis();
while (!Thread.currentThread().isInterrupted()) {
try {
ByteBuf buffer = allocator.buffer();
//確保是本線程釋放
buffer.retain();
bufferQueue.put(buffer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (toCleanQueue.size() > TO_CLEAN_SIZE || System.currentTimeMillis() - lastCleanTime > CLEAN_PERIOD) {
final List<ByteBuf> toClean = new ArrayList<ByteBuf>(toCleanQueue.size());
toCleanQueue.drainTo(toClean);
for (ByteBuf buffer : toClean) {
ReferenceCountUtil.release(buffer);
}
lastCleanTime = System.currentTimeMillis();
}
}
}
}
static {
Thread thread = new Thread(new AllocThread(), "qclient-redis-allocator");
thread.setDaemon(true);
thread.start();
}
public static ByteBuf alloc() {
try {
return bufferQueue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RedisRuntimeException("alloc interrupt");
}
}
public static void release(ByteBuf buf) {
toCleanQueue.add(buf);
}
}
在業務線程裏調用alloc,從queue裏拿到專用的線程分配好的buffer。在將buffer寫出到socket以後再調用release回收:
//業務線程
ByteBuf buffer = Allocator.alloc();
//序列化
........
//寫出
ChannelPromise promise = channel.newPromise();
promise.addListener(new GenericFutureListener<Future<Void>>() {
@Override
public void operationComplete(Future<Void> future) throws Exception {
//buffer已經輸出,能夠回收,交給專用線程回收
Allocator.release(buffer);
}
});
//進入EventLoop
channel.write(buffer, promise);
好像問題解決了。並且咱們經過壓測發現性能果真有提高,內存佔用也很正常,經過寫出各類不一樣大小的buffer進行了幾番測試結果都很OK。
不過你若是再提升每次寫出包的大小的時候,問題就出現了。在我這個版本的netty裏,ByteBufAllocator.buffer()分配的buffer默認大小是256個字節,當你將對象往這個buffer裏序列化的時候,若是超過了256個字節ByteBuf就會自動擴展,而對於PooledByteBuf來講,自動擴展是會去池裏取一個,而後將舊的回收掉。而這一切都是在業務線程裏進行的。意味着你使用專用的線程來作分配和回收功虧一簣。
上面三個問題就好像冥冥之中,有一雙看不見的手將你一步一步帶入深淵,最後讓你絕望。一個問題引出一個必然的解決方案,而這個解決方案看起來將問題解決了,但倒是將問題隱藏地更深。
若是說前面三個問題是由於你不熟悉Netty的新機制形成的,那麼下面這個問題我以爲就是Netty自己的API設計不合理致使使用的人出現這個問題了。
4. 鏈接超時
在網絡應用中,超時每每是最後一道防線,或是最後一根稻草。咱們不怕乾脆利索的宕機,怕就怕要死不活。當碰到要死不活的應用的時候每每就是依靠超時了。
在使用Netty編寫客戶端的時候,咱們通常會有相似這樣的代碼:
bootstrap.connect(address).await(1000, TimeUnit.MILLISECONDS)
向對端發起一個鏈接,超時等待1秒鐘。若是1秒鐘沒有鏈接上則重連或者作其餘處理。而其實在bootstrap的選項裏,還有這樣的一項:
bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000);
若是這兩個值設置的不一致,在await的時候較短,而option裏設置的較長就出問題了。這個時候你會發現connect裏已經超時了,你覺得鏈接失敗了,但實際上await超時Netty並不會幫你取消正在鏈接的連接。這個時候若是第2秒的時候連上了對端服務器,那麼你剛纔的判斷就失誤了。若是你根據connect(address).await(1000, TimeUnit.MILLISECONDS)來決定是否重連,頗有可能你就創建了兩個鏈接,並且頗有可能你的handler就在這兩個channel裏共享起來了,這就有可能讓你產生:哎呀,Netty的handler不是在單線程裏執行的這樣的假象。因此個人建議是,不要在await上設置超時,而老是使用option上的選項來設置。這個更準確些,超時了就是真的表示沒有連上。
5. 異步處理,流控先行
這個坑其實也不算坑,只是由於懶,該作的事情沒作。通常來說咱們的業務若是比較小的時候咱們用同步處理,等業務到必定規模的時候,一個優化手段就是異步化。異步化是提升吞吐量的一個很好的手段。可是,與異步相比,同步有自然的負反饋機制,也就是若是後端慢了,前面也會跟着慢起來,能夠自動的調節。可是異步就不一樣了,異步就像決堤的大壩同樣,洪水是暢通無阻。若是這個時候沒有進行有效的限流措施就很容易把後端沖垮。若是一會兒把後端沖垮倒也不是最壞的狀況,就怕把後端衝的要死不活。這個時候,後端就會變得特別緩慢,若是這個時候前面的應用使用了一些無界的資源等,就有可能把本身弄死。那麼如今要介紹的這個坑就是關於Netty裏的ChannelOutboundBuffer這個東西的。這個buffer是用在netty向channel write數據的時候,有個buffer緩衝,這樣能夠提升網絡的吞吐量(每一個channel有一個這樣的buffer)。初始大小是32(32個元素,不是指字節),可是若是超過32就會翻倍,一直增加。大部分時候是沒有什麼問題的,可是在碰到對端很是慢(對端慢指的是對端處理TCP包的速度變慢,好比對端負載特別高的時候就有多是這個狀況)的時候就有問題了,這個時候若是仍是不斷地寫數據,這個buffer就會不斷地增加,最後就有可能出問題了(咱們的狀況是開始吃swap,最後進程被linux killer幹掉了)。
爲何說這個地方是坑呢,由於大部分時候咱們往一個channel寫數據會判斷channel是否active,可是每每忽略了這種慢的狀況。
那這個問題怎麼解決呢?其實ChannelOutboundBuffer雖然無界,可是能夠給它配置一個高水位線和低水位線,當buffer的大小超太高水位線的時候對應channel的isWritable就會變成false,當buffer的大小低於低水位線的時候,isWritable就會變成true。因此應用應該判斷isWritable,若是是false就不要再寫數據了。高水位線和低水位線是字節數,默認高水位是64K,低水位是32K,咱們能夠根據咱們的應用須要支持多少鏈接數和系統資源進行合理規劃。
.option(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 64 * 1024)
.option(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 32 * 1024)
在使用一些開源的框架上還真是要熟悉人家的實現機制,而後才能夠大膽的使用啊,否則被坑死都以爲本身很冤枉
其實這篇應該叫Netty實踐,可是爲了與前一篇名字保持一致,因此仍是用一下坑這個名字吧。
Netty是高性能Java NIO網絡框架,在不少開源系統裏都有她的身影,而在絕大多數互聯網公司所實施的服務化,以及最近流行的MicroService中,她都做爲基礎中的基礎出現。
Netty的出現讓咱們能夠簡單容易地就可使用NIO帶來的高性能網絡編程的潛力。她用一種統一的流水線方式組織咱們的業務代碼,將底層網絡繁雜的細節隱藏起來,讓咱們只須要關注業務代碼便可。而且用這種機制將不一樣的業務劃分到不一樣的handler裏,好比將編碼,鏈接管理,業務邏輯處理進行分開。Netty也力所能及的屏蔽了一些NIO bug,好比著名的epoll cpu 100% bug。並且,還提供了不少優化支持,好比使用buffer來提升網絡吞吐量。
可是,和全部的框架同樣,框架爲咱們屏蔽了底層細節,讓咱們能夠很快上手。可是,並不表示咱們不須要對框架所屏蔽的那一層進行了解。本文所涉及的幾個地方就是Netty與底層網絡結合的幾個地方,看看咱們使用的時候應該怎麼處理,以及爲何要這麼處理。
在Netty 4裏我以爲一個頗有用的功能是autoread。autoread是一個開關,若是打開的時候Netty就會幫咱們註冊讀事件(這個須要對NIO有些基本的瞭解)。當註冊了讀事件後,若是網絡可讀,則Netty就會從channel讀取數據,而後咱們的pipeline就會開始流動起來。那若是autoread關掉後,則Netty會不註冊讀事件,這樣即便是對端發送數據過來了也不會觸發讀時間,從而也不會從channel讀取到數據。那麼這樣一個功能到底有什麼做用呢?
它的做用就是更精確的速率控制。那麼這句話是什麼意思呢?好比咱們如今在使用Netty開發一個應用,這個應用從網絡上發送過來的數據量很是大,大到有時咱們都有點處理不過來了。而咱們使用Netty開發應用每每是這樣的安排方式:Netty的Worker線程處理網絡事件,好比讀取和寫入,而後將讀取後的數據交給pipeline處理,好比通過反序列化等最後到業務層。到業務層的時候若是業務層有阻塞操做,好比數據庫IO等,可能還要將收到的數據交給另一個線程池處理。由於咱們絕對不能阻塞Worker線程,一旦阻塞就會影響網絡處理效率,由於這些Worker是全部網絡處理共享的,若是這裏阻塞了,可能影響不少channel的網絡處理。
可是,若是把接到的數據交給另一個線程池處理就又涉及另一個問題:速率匹配。
好比如今網絡實在太忙了,接收到不少數據交給線程池。而後就出現兩種狀況:
1. 因爲開發的時候沒有考慮到,這個線程池使用了某些無界資源。好比不少人對ThreadPoolExecutor的幾個參數不是特別熟悉,就有可能用錯,最後致使資源無節制使用,整個系統crash掉。
//好比開始的時候沒有考慮到會有這麼大量//這種方式線程數是無界的,那麼有可能建立大量的線程對系統穩定性形成影響Executor executor = Executors.newCachedTheadPool(); executor.execute(requestWorker);//或者使用這個//這種queue是無界的,有可能會消耗太多內存,對系統穩定性形成影響Executor executor = Executors.newFixedThreadPool(8); executor.execute(requestWorker);
2. 第二種狀況就是限制了資源使用,因此只好把最老的或最新的數據丟棄。
//線程池滿後,將最老的數據丟棄Executor executor = new ThreadPoolExecutor(8, 8, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(1000), namedFactory, new ThreadPoolExecutor.DiscardOldestPolicy());
其實上面兩種狀況,無論哪種都不是太合理。不過在Netty 4裏咱們就有了更好的解決辦法了。若是咱們的線程池暫時處理不過來,那麼咱們能夠將autoread關閉,這樣Netty就再也不從channel上讀取數據了。那麼這樣形成的影響是什麼呢?這樣socket在內核那一層的read buffer就會滿了。由於TCP默認就是帶flow control的,read buffer變小以後,向對端發送ACK的時候,就會下降窗口大小,直至變成0,這樣對端就會自動的下降發送數據的速率了。等到咱們又能夠處理數據了,咱們就能夠將autoread又打開這樣數據又源源不斷的到來了。
這樣整個系統就經過TCP的這個負反饋機制,和諧的運行着。那麼autoread涉及的網絡知識就是,發送端會根據對端ACK時候所攜帶的advertises window來調整本身發送的數據量。而ACK裏的這個window的大小又跟接收端的read buffer有關係。而不註冊讀事件後,read buffer裏的數據沒有被消費掉,就會達到控制發送端速度的目的。
不過設計關閉和打開autoread的策略也要注意,不要設計成咱們不能處理任何數據了就當即關閉autoread,而咱們開始能處理了就當即打開autoread。這個地方應該留一個緩衝地帶。也就是若是如今排隊的數據達到咱們預設置的一個高水位線的時候咱們關閉autoread,而低於一個低水位線的時候纔打開autoread。不這麼弄的話,有可能就會致使咱們的autoread頻繁打開和關閉。autoread的每次調整都會涉及系統調用,對性能是有影響的。相似下面這樣一個代碼,在將任務提交到線程池以前,判斷一下如今的排隊量(注:本文的全部數字純爲演示做用,全部線程池,隊列等大小數據要根據實際業務場景仔細設計和考量)。
int highReadWaterMarker = 900;int lowReadWaterMarker = 600; ThreadPoolExecutor executor = new ThreadPoolExecutor(8, 8, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(1000), namedFactory, new ThreadPoolExecutor.DiscardOldestPolicy());int queued = executor.getQueue().size();if(queued > highReadWaterMarker){ channel.config().setAutoRead(false); }if(queued < lowReadWaterMarker){ channel.config().setAutoRead(true); }
可是使用autoread也要注意一件事情。autoread若是關閉後,對端發送FIN的時候,接收端應用層也是感知不到的。這樣帶來一個後果就是對端發送了FIN,而後內核將這個socket的狀態變成CLOSE_WAIT。可是由於應用層感知不到,因此應用層一直沒有調用close。這樣的socket就會長期處於CLOSE_WAIT狀態。特別是一些使用鏈接池的應用,若是將鏈接歸還給鏈接池後,必定要記着autoread必定是打開的。否則就會有大量的鏈接處於CLOSE_WAIT狀態。
其實全部異步的場合都存在速率匹配的問題,而同步每每不存在這樣的問題,由於同步自己就是帶負反饋的。
isWritable其實在上一篇文章已經介紹了一點,不過這裏我想結合網絡層再囉嗦一下。上面咱們講的autoread通常是接收端的事情,而發送端也有速率控制的問題。Netty爲了提升網絡的吞吐量,在業務層與socket之間又增長了一個ChannelOutboundBuffer。在咱們調用channel.write的時候,全部寫出的數據其實並無寫到socket,而是先寫到ChannelOutboundBuffer。當調用channel.flush的時候才真正的向socket寫出。由於這中間有一個buffer,就存在速率匹配了,並且這個buffer仍是無界的。也就是你若是沒有控制channel.write的速度,會有大量的數據在這個buffer裏堆積,並且若是碰到socket又『寫不出』數據的時候,頗有可能的結果就是資源耗盡。並且這裏讓這個事情更嚴重的是ChannelOutboundBuffer不少時候咱們放到裏面的是DirectByteBuffer,什麼意思呢,意思是這些內存是放在GC Heap以外。若是咱們僅僅是監控GC的話還監控不出來這個隱患。
那麼說到這裏,socket何時會寫不出數據呢?在上一節咱們瞭解到接收端有一個read buffer,其實發送端也有一個send buffer。咱們調用socket的write的時候實際上是向這個send buffer寫數據,若是寫進去了就表示成功了(因此這裏千萬不能將socket.write調用成功理解成數據已經到達接收端了),若是send buffer滿了,對於同步socket來說,write就會阻塞直到超時或者send buffer又有空間(這麼一看,其實咱們能夠將同步的socket.write理解爲半同步嘛)。對於異步來說這裏是當即返回的。
那麼進入send buffer的數據何時會減小呢?是發送到網絡的數據就會從send buffer裏去掉麼?也不是這個樣子的。還記得TCP有重傳機制麼,若是發送到網絡的數據都從send buffer刪除了,那麼這個數據沒有獲得確認TCP怎麼重傳呢?因此send buffer的數據是等到接收端回覆ACK確認後才刪除。那麼,若是接收端很是慢,好比CPU佔用已經到100%了,而load也很是高的時候,頗有可能來不及處理網絡事件,這個時候send buffer就有可能會堆滿。這就致使socket寫不出數據了。而發送端的應用層在發送數據的時候每每判斷socket是否是有效的(是否已經斷開),而忽略了是否可寫,這個時候有可能就還一個勁的寫數據,最後致使ChannelOutboundBuffer膨脹,形成系統不穩定。
因此,Netty已經爲咱們考慮了這點。channel有一個isWritable屬性,能夠來控制ChannelOutboundBuffer,不讓其無限制膨脹。至於isWritable的實現機制能夠參考前一篇。
全部講TCP的書都會有這麼一個介紹:TCP provides a connection-oriented, reliable, byte stream service。前面兩個這裏就不關心了,那麼這個byte stream究竟是什麼意思呢?咱們在發送端發送數據的時候,對於應用層來講咱們發送的是一個個對象,而後序列化成一個個字節數組,但不管怎樣,咱們發送的是一個個『包』。每一個都是獨立的。那麼接收端是否是也像發送端同樣,接收到一個個獨立的『包』呢?很遺憾,不是的。這就是byte stream的意思。接收端沒有『包』的概念了。
這對於應用層編碼的人員來講可能有點困惑。好比我使用Netty開發,個人handler的channelRead此次明明傳遞給個人是一個ByteBuf啊,是一個『獨立』的包啊,若是是byte stream的話難道不該該傳遞我一個Stream麼。可是這個ByteBuf和發送端的ByteBuf一點關係都沒有。好比:
public class Decorder extends ChannelInboundHandlerAdapter{ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //這裏的msg和發送端channel.write(msg)時候的msg沒有任何關係 } }
這個ByteBuf可能包含發送端多個ByteBuf,也可能只包含發送端半個ByteBuf。可是別擔憂,TCP的可靠性會確保接收端的順序和發送端的順序是一致的。這樣的byte stream協議對咱們的反序列化工做就帶來了一些挑戰。在反序列化的時候咱們要時刻記着這一點。對於半個ByteBuf咱們按照設計的協議若是解不出一個完整對象,咱們要留着,和下次收到的ByteBuf拼湊在一塊兒再次解析,而收到的多個ByteBuf咱們要根據協議解析出多個完整對象,而頗有可能最後一個也是不完整的。不過幸運的是,咱們有了Netty。Netty爲咱們已經提供了不少種協議解析的方式,而且對於這種半包粘包也已經有考慮,咱們能夠參考ByteToMessageDecoder以及它的一連串子類來實現本身的反序列化機制。而在反序列化的時候咱們可能常常要取ByteBuf中的一個片斷,這個時候建議使用ByteBuf的readSlice方法而不是使用copy。
另外,Netty還提供了兩個ByteBuf的流封裝:ByteBufInputStream, ByteBufOutputStream。好比咱們在使用一些序列化工具,好比Hessian之類的時候,咱們每每須要傳遞一個InputStream(反序列化),OutputStream(序列化)到這些工具。而不少協議的實現都涉及大量的內存copy。好比對於反序列化,先將ByteBuf裏的數據讀取到byte[],而後包裝成ByteArrayInputStream,而序列化的時候是先將對象序列化成ByteArrayOutputStream再copy到ByteBuf。而使用ByteBufInputStream和ByteBufOutputStream就再也不有這樣的內存拷貝了,大大節約了內存開銷。
另外,由於socket.write和socket.read都須要一個direct byte buffer(即便你傳入的是一個heap byte buffer,socket內部也會將內容copy到direct byte buffer)。若是咱們直接使用ByteBufInputStream和ByteBufOutputStream封裝的direct byte buffer再加上Netty 4的內存池,那麼內存將更有效的使用。這裏提一個問題:爲何socket.read和socket.write都須要direct byte buffer呢?heap byte buffer不行麼?
總結起來,對於序列化和反序列化來說就是兩條:1 減小內存拷貝 2 處理好TCP的粘包和半包問題
做爲一個應用層程序員,每每是幸福的。由於咱們有豐富的框架和工具爲咱們屏蔽下層的細節,這樣咱們能夠更容易的解決不少業務問題。可是目前程序設計並無發展到不須要了解全部下層的知識就能夠寫出更有效率的程序,因此咱們在使用一個框架的時候最好要對它所屏蔽和所依賴的知識進行一些瞭解,這樣在碰到一些問題的時候咱們能夠根據這些理論知識去分析緣由。這就是理論和實踐的相結合。