Netty是搞後臺開發必須要學習的一個網絡框架,也是面試中常常會被拿出來問的一個點,這主要是因爲現在Netty在大型項目中的應用越來越廣,像elasticsearch、twitter、facebook等都在用它,而且Netty自身的框架設計以及性能都非常優秀,非常值得去學習。
1、Netty的優勢
Netty有哪些特點使得大家對他趨之若鶩呢?
(1) 降低了開發高性能高併發網絡應用的門檻。使用Netty你無需成爲一個網絡編程的專家也能比較容易的寫出高性能,高併發的網絡應用,這主要得益於Netty良好的封裝,使得網絡編程中許多複雜的細節和性能問題對開發者透明。
(2) 良好的性能和複用性。使用Netty能夠寫出性能非常高同時複用性很高的代碼,這一點隨後可以用代碼來驗證一下。
(3) Netty使應用的邏輯代碼和網絡層代碼解耦,開發者只關注業務邏輯,不用太多關注底層網絡實現的問題。
2、Java網絡API和Netty的對比
我們先想一想支持一個大量併發請求的高性能網絡應用程序都需要哪些知識呢?網絡原理,多線程,大併發這三塊是必須要懂,這裏面每一塊單獨拿出來都足夠寫一本書,Netty的出現其主要目的之一就是降低這些門檻,使得許許多多缺乏相關領域知識的開發者也能快速編寫出性能很高的應用程序。
設想一下,現在老闆有一個idea,然後找到你,讓你儘快開發出一個至少支持同時300,000併發請求的系統,你該如何開始?
2.1 阻塞式IO
假設這個時候Netty還沒有問世,所以你最先想到的肯定是Java自帶的網絡API,於是很自然的,你可能寫出類似下面這樣的代碼:
//創建一個在指定端口上監聽連接的套接字
ServerSocket serverSocket = new ServerSocket(port);
//監聽客戶端請求
Socket clientSocket = serverSocket.accept();
BufferedReader in = new BufferedReader(clientSocket.getInputStream());
PrintWriter out = new PrintWriter(clientSocket.getOutputStream());
String request, response;
while((request = in.readLine()) != null) {
//處理請求
response = processRequest(request);
out.println(response);
}
這是最古老的網絡編程模式,它明顯無法支撐起300,000併發請求的要求,問題在哪呢?
所有的API是阻塞的,比如accept就會一直阻塞等待直到有客戶端的請求到來爲止,因此我們需要把accept放到一個無限循環中,這個是理所當然的,沒有什麼問題。
然後read、write也都是阻塞的,也就是說如果客戶端一直不發送消息,那麼服務端就得一直等,這顯然是不可接受的,那自然而然就得用多線程,每個線程處理一個客戶請求,於是代碼就像下面這樣:
//創建一個在指定端口上監聽連接的套接字
ServerSocket serverSocket = new ServerSocket(port);
//監聽客戶端請求
while (true) {
Socket clientSocket = serverSocket.accept();
new Thread() {
@Override
public void run() {
BufferedReader in = new BufferedReader(clientSocket.getInputStream());
PrintWriter out = new PrintWriter(clientSocket.getOutputStream());
String request, response;
while((request = in.readLine()) != null) {
//處理請求
response = processRequest(request);
out.println(response);
}
}
}.start();
}
這樣明目張膽的使用線程顯然也有問題:一臺服務器的資源畢竟是有限的,同時30W個請求,難道要30W個線程,那瞬間估計你的系統就癱瘓了,於是你又自然而然的想到了線程池來複用線程。
使用線程池貌似是個好辦法,線程可以複用,但是壓測以後你會發現依然無法支持要求的併發量。使用線程池雖然解決了線程創建和銷燬的問題,但是無法解決頻繁的線程上下文切換所帶來的開銷,另外多線程之間的同步開銷也很大。
2.2 多路複用IO
直接用JDK提供的阻塞式IO的網絡API編寫高性能的應用程序顯然比較困難,好在在學習網絡編程的時候我們學習過IO多路複用這個概念。
阻塞式IO使得我們不得不創建單獨的線程來處理請求,由於線程的創建、銷燬和上下文切換都會有開銷,而且當系統的負載非常大的時候這些開銷會使得系統難以繼續運行下去,於是又有了IO多路複用。我們來回顧一下使用IO多路複用進行網絡編程的範式。
首先簡單回憶一下select這個函數,提到IO多路複用就繞不開它(現在的Linux系統,基本上都用性能更高的epoll取代select,但是二者的基本原理是相同的)。
int select(int n,fd_set * readfds,fd_set * writefds,fd_set * exceptfds,struct timeval * timeout);
特別注意到select的三個集合參數:
readfds表示要監聽的讀套接字,當這些套接字中有數據可讀的時候,select就會返回;
writefds表示要監聽的寫套接字,當集合中的套接字可以寫入時,select返回;
最後的參數timeout指定了select的超時時間,在指定的時間內如果集合中的套接字狀態還沒有發生變化,則返回,代碼中可以在for循環中來輪詢。
可以把select理解爲一個觀察者模式,我們通過select向系統註冊一些要監聽的套接字,當這些套接字狀態發生變化時(可讀、可寫或者異常)我們收到內核給我們的通知,進行處理。
以下是一個典型的多路複用的方式進行處理的網絡程序,從套接字讀取內容然後寫入到文件:
main()
{
int sock;
struct fd_set fds;
struct timeval timeout = {3,0}; //select 3秒輪詢一次
FILE *fp = fopen(...); //打開要寫入內容的文件
char buffer[256]={0};
sock = socket(...);
bind(...);
while(1)
{
FD_ZERO(&fds); //每次循環都要清空集合,否則不能檢測描述符變化
FD_SET(sock, &fds); //監聽套接字,當可讀時select返回,
FD_SET(fp, &fds); //監聽文件描述符,當文件可寫時select返回
maxfdp = sock > fp ? sock+1 : fp+1;
switch(select(maxfdp, &fds, &fds, NULL, &timeout)) //select
{
case -1:
exit(-1);
break; //select錯誤,退出程序
case 0:
break; //超時
default:
if(FD_ISSET(sock, &fds)) //套接字有數據了
{
recvfrom(sock, buffer, 256, ...);//讀數據
if(FD_ISSET(fp, &fds)) //文件可寫
fwrite(fp, buffer...);//寫入文件
}
}
}
}
JAVA的NIO背後的核心實際上就是對select的封裝,用Selector封裝了select。我們看看用Java NIO的方式編寫的網絡程序。
public class NioServer {
public void serve(int port) throws IOException {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
ServerSocket ssocket = serverChannel.socket();
InetSocketAddress address = new InetSocketAddress(port);
ssocket.bind(address);
Selector selector = Selector.open();
//監聽套接字accept事件(可以想象底層是把套接字添加到select的讀集合中)
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());
for (;;) {
try {
//等待accept事件(對應底層的select函數返回)
selector.select();
} catch (IOException ex) {
ex.printStackTrace(); // handle exception
break;
}
//檢查可讀的套接字(可以理解爲底層用FD_ISSET進行檢查)
Set<SelectionKey> readyKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = readyKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
try {
if (key.isAcceptable()) { //有完成3次握手的連接了
ServerSocketChannel server = (ServerSocketChannel)key.channel();
client.configureBlocking(false);
//將客戶套接字加入select的讀、寫集合,監聽其讀寫事件
client.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, msg.duplicate());
if (key.isWritable()) {
//可以向套接字寫數據了
SocketChannel client = (SocketChannel)key.channel();
ByteBuffer buffer =(ByteBuffer)key.attachment();
while (buffer.hasRemaining()) {
if (client.write(buffer) == 0) {
break;
}
}
client.close();
}
} catch (IOException e) {
//...
}
對比之前的阻塞式IO,最大的區別就是我們不在需要對accept到的每個連接單獨創建一個線程,通過IO多路複用,可以用少量的線程處理大量的連接,大大降低了多線程帶來的開銷。
許多商業應用都直接使用JDK的NIO開發應用,但是能夠正確並高效的用NIO開發網絡應用是件極其考驗編程能力的事,尤其是在系統負載變得很重的情況下還能可靠、高效的分發並處理IO事件對開發者得水平依賴很大。
2.3 用Netty編寫的代碼
我們再來看看同樣功能的代碼,用Netty寫出來是什麼樣子的,直接上代碼:
public class NettyNioServer {
public void server(int port) throws Exception {
final ByteBuf buf = Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8"));
//事件循環,每個EventLoop都關聯一個線程處理IO事件
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(group).channel(NioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch)throws Exception {
//向channel的處理管線中增加一個處理器
ch.pipeline().addLast( new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx)
throws Exception {
//當客戶端連接建立好以後發送消息給客戶端
ctx.writeAndFlush(buf.duplicate()).addListener(
ChannelFutureListener.CLOSE);
}
});
}
}
ChannelFuture f = b.bind().sync();
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
}
對比一下直接用JDK API編寫的版本,最大的區別是沒有Selector了,我們不用自己用select註冊事件並分發,取而代之的是把自己的業務邏輯寫到處理器中並註冊到channel(你可以理解爲是對socket的抽象)的處理管線中即可。
這就是Netty的設計哲學,將細節對開發者透明,開發者只需要關注自己的業務邏輯實現並加入到pipeline中就可以了,當事件發生時Netty會通過回調執行我們的邏輯,也就是響應式編程。
2.4 Netty的複用性如何體現
前面提到Netty的一個特點之一就是能提高代碼的複用性,這一點我們可以通過一個場景來理解。假設最開始你的應用是基於JDK的同步IO來編寫的,之後隨着業務的增長,你發現同步的方式已經支撐不住了,於是想切到NIO的模式,這時你會發現你有大量的工作要做,特別是如果最初的代碼邏輯沒有寫好,分散在各處,那這種重構的代價會非常巨大。
而如果用Netty,只需要把上面代碼中的:
EventLoopGroup group = new NioEventLoopGroup();
變成OioEventLoopGroup就能實現從NIO到同步方式的轉變。Netty框架約束我們把各種事件的處理邏輯封裝在處理器中,而這些包含了業務邏輯的處理器是可以隨時拿來複用的,Netty本身又把細節問題封裝起來,最終的效果就是一兩行代碼就搞定以前需要耗費大量時間才能搞定的事情。
3 總結
這篇文章簡單的介紹了一下Netty的特點,通過代碼對比了使用JDK API和使用Netty編寫的網絡應用的異同,初步體驗了一把Netty的優勢。
記住Netty的特點:
1、封裝了編寫高性能高併發網絡應用的複雜細節,降低了開發一個能支撐高併發網絡應用的門檻;
2、使用Netty能使我們的應用更加具有可複用性;
3、解耦網絡層和應用層,開發者基本上只需要編寫業務邏輯處理器,其他的可以不用關心。