Netzwerkprogrammierung mit Java Eine Übersicht über Java NIO Andre Ufer a09008@hb.dhbw-stuttgart.de Zusammenfassung. Diese Ausarbeitung beschreibt die Netzwerkprogrammierung unter Java. Der Schwerpunkt liegt dabei auf einem Vergleich zwischen den Bibliotheken bzw. Frameworks java.net und java.nio. bezüglich ihrer Fähigkeiten, Vor- und Nachteile. Betrachtet werden dabei Umsetzungen einfacher Server und Einsatzzwecke und implementierende Frameworks. 1 Einführung 1.1 Motivation Das Internet ist heute aus der modernen Kommunikation nicht mehr wegzudenken und dabei größter und bekanntester Vertreter und Grundlage sogenannter Verteilter Systeme. Dabei meint die Kommunikation nicht nur den Austausch von Informationen zwischen verschiedenen Personen, sondern auch der ständige Datenaustausch nahezu beliebiger (Software-)Komponenten. Durch den hohen Grad der Vernetzung von Hard- und Software benötigt man klare Strukturen, einheitliche Schnittstellen und Konzepte, um diese Kommunikation zu ermöglichen. Sie lässt sich dabei aus technischer Sicht aus mehreren Blickwinkeln betrachten. Auf der einen Seite gibt es die technische bzw. hardwarebasierte Sicht. Hierunter fallen alle Komponenten, die die technische Basis dieser Kommunikation bilden. Im ISO-OSI-Schichtenmodell würden wir von der Physical Layer oder Schicht 1 reden. Auf der anderen Seite steht die Software. Unter Software ist in diesem Zusammenhang nicht nur ein ausführbares Stück Programmcode zu verstehen, sondern alle Schichten des ISO/OSI-Modells von zwei bis sieben. Darunter fallen Adressierungskonzepte (z.b. MAC), Übertragungsprotokolle (z.b. TCP/IP, HTTP) sowie beliebig abstrakte Implementierungen, die eine Kommunikation zwischen vernetzen Komponenten bzw. Verteilten Systemen ermöglichen. Java NIO gehört zur letzteren Kategorie und soll im Rahmen dieser Arbeit mit der alten Java Netzwerk-API 1 2 mit Bezug auf Nutzen, Vor- und Nachteile verglichen werden. 1 Java NIO befindet sich im Paket java.nio, die alte API in java.net 2 Im weiteren Verlauf wird die java.net API inkl. der benötigten Klasen aus java.io als NET, die java.nio API als NIO bezeichnet
1.2 Was ist Java NIO Die Java new I/O (NIO) API wurde mit dem JDK v1.4 veröffentlicht und erweitert die Java API vor allem um Funktionen zur Dateibehandlung und zur Netzwerkkommunikation. Sie ist Buffer-orientiert und nicht-blockierend. Die alten APIs java.io und java.net hingegen sind Stream-orientert und blockierend. Der Schwerpunkt dieser Arbeit liegt auf den neuen Möglichkeiten in der Netzwerkprogrammierung, die um Funktionen wie Channels für Buffer, Selectors für non-blocking I/O und Erweiterungen zur klassischen Socket-Programmierung ergänzt wurden. Ziele von NIO sind vor allem eine bessere Performance und Skalierbarkeit durch die nicht-blockierenden Implementierung der neuen Funktionen. 2 Grundlagen der Netzwerkprogrammierung 2.1 Java NET und Java NIO Die Netzwerkprogrammierung mittels NET erfolgt über klassische Programmierung mittels Sockets. Dabei fordert der Client eine Verbindung an, indem eine Instanz der java.net.socket-klasse erstellt wird. Als Parameter werden i.d.r. Server-Url und Portnummer verwendet. Der Server arbeitet mit einer Instanz der Klasse java.net.serversocket. Dieser bekommt als Parameter den Port, auf den er lauschen soll. Der Verbindungsaufbau findet über die accept()-methode des ServerSockets statt. Nach Verbindungsaufbau findet anschließend ein Nachrichtenaustausch statt. Dieser erfolgt in der java.net API streamorientiert. Jeder Socket bzw. ServerSocket besitzt dazu Input- sowie einen OutputStream, mit dem Daten via Byte-Arrays übertragen werden können. NIO arbeitet (zumindest an der Oberfläche) grundsätzlich anders. Die Datenübertragung erfolgt nicht mehr stream- sondern blockorientiert über sogenannte Channels. Die übertragenen Daten werden mittels Buffer gekapselt. Ein einfaches NIO-Beispiel folgt im nächsten Kapitel. 2.2 Beipsiel: einfacher Server Es folgt ein einfacher Server, der ausschließlich mit den Klassen aus der java.net API programmiert wurde. Das Lesen und Schreiben geschieht über Streams, die Verbindung wird explizit über Sockets hergestellt. 1 public class SimpleNetServer { 2 public static void main ( String [] args ) throws Exception { 3 ServerSocket server = new ServerSocket (21312) ; 4 5 while ( true ) { 6 Socket client = server. accept () 7 byte [] rawdata = new byte [1024]; 8 int numread = server. getinputstream (). read ( rawdata ); 9 if( numread > 0) { 10 System. out. println ( new String ( rawdata ));
11 client. getoutputstream (). write ( rawdata ); 12 } 13 } 14 } 15 } Listing 1.1. Einfacher Server mit java.net Dieses Code-Beispiel entspricht einem minimalen Server, der mittels java.net API entwickelt wurde. Der ServerSocket lauscht auf Port 21312 und nimmt in einer Endlosschleife Verbindungen an (accept()), ließt über seinen InputStream und schreibt auf den OutputStream des verbundenen Clients. Diese Art der Netzwerkprogrammierung ist blockierend. Das heißt, dass der Server an den Stellen read(rawdata) und write(rawdata) wartet, bis das Lesen bzw. Schreiben abgeschlossen ist und währenddessen keine neue Verbindung annehmen kann. Abb. 1. Klassischer multithreaded Server Der nächste Schritt wäre nun, eine Klasse von Thread oder Runnable abzuleiten, die den Client-Socket als Parameter übernimmt und die Kommunikation in der Methode run() des Threads/Runnable-Objekts abarbeitet. Damit wäre das Problem des Blockierens zwar umgangen, jedoch skaliert diese Lösung sehr schlecht, da für jede Verbindung ein Thread erstellt wird. Dieser wartet in der Regel einen großen Teil der Zeit auf I/O und verschwendet währenddessen Speicher des Stacks. 3 Features von NIO Nachdem nun die klassiche Netzwerkprogrammierung mit Sockets beschrieben wurde, sollen nun die Vorzüge der Netzwerkprogrammierung mit NIO beschrieben werden. Vorgestellt werden das Prinzip des select()-calls inkl. der SelectionKeys (zusammengefasst als Select-Pattern ) und das Reactor-Pattern als Entwurfsmuster für Anwendungen, die die Abarbeitung der SelectionKeys quasi-verteilt vornehmen.
3.1 Select-Pattern Der Selector wird dazu verwendet, um nicht-blockierende Netzwerkprogrammierung zu ermöglichen. Das Selector-Pattern verfolgt dabei einen ereignisorientierten Ansatz. Channels, die die Verbindung zwischen den Endpunkten darstellen, werden bei einem Selektor registriert. Durch einen select()-call erhält man die Anzahl der Events, die bei allen registrierten Channels eingetreten sind. Die Abarbeitung der Events erfolgt über ein Abfragen der Events mittels selectedkeys() und dem anschließendem, iterativen bearbeiten. Hierfür bietet der Selector die Methode iterator() an. Es folgt ein Beispiel für einen einfachen Server mit Selector und ServerSocket- Channel als Basis: 1 public class SimpleNioServer { 2 3 public static void main ( String [] args ) { 4 Selector selector = Selector. open (); 5 6 ServerSocketChannel server = ServerSocketChannel. open (); 7 server. socket (). bind ( new InetSocketAddress (21312) ); 8 server. configureblocking ( false ); 9 10 while ( true ) { 11 if( selector. select () > 0) { 12 Iterator it = selector. selectedkeys (). iterator (); 13 14 while (it. hasnext ()) { 15 SelectionKey key = ( SelectionKey ) it. next (); 16 it. remove (); 17 if(key. isacceptable ()) { 18 SocketChannel client = server. accept (); 19 client. configureblocking ( false ); 20 SelectionKey clientkey = client. register ( selector, SelectionKey. OP_READ ); 21 } else if( key. isreadable ()) { 22 SocketChannel client = key. channel (); 23 ByteBuffer buffer = ByteBuffer. allocate (1024) ; 24 int numread = client. read ( buffer ); 25 System. out. println (" Client meldet : " + new String ( buffer. array ())); 26 } 27 } 28 } 29 } 30 } 31 } Listing 1.2. Einfacher Server mit java.net 3.2 Reactor-Pattern Das Reactor-Pattern stellt ein Designmuster dar, bei dem die Aufgaben der Verbindungsannahme und der Bearbeitung ausgelagert bzw. verteilt werden (Stich-
wort: dispatcher). Dieses Prinzip ähnelt damit dem AWT-Event-Dispatching [Lea12]. Der Reactor stellt dabei die zentrale Einheit der Serveranwendung dar. Bei Verbindungswünschen durch Clients an den Reactor gibt dieser die Anfragen an einen Acceptor weiter. Die Bearbeitung von darauffolgenden I/O-Operationen werden anschließend durch den Acceptor weiterdeligiert. Der schematische Aufbau eines Reactors ist in Abb. 2 zu sehen. Abb. 2. Schematischer Aufbau des Reactor-Patterns Das Reactor-Pattern verfolgt dabei den Ansatz, pro auftretendem Event einen Thread zu erstellen anstatt pro Client-Verbindung [JNet12] und arbeitet so nach dem Divide and Conquer Prinzip. Dafür verwendet die Reactor-Klasse die attach()-methode der SelectionKey-Klasse um dem SelectionKey, der bei der Registrierung des ServerSocketChannels zurückgegeben wird, eine Acceptor- Instanz als eine Art Callback übergibt. Das Reactor-Pattern erhöht dabei die Skalierbarkeit noch weiter, als der einfache Ansatz, wie er bereits oben beschrieben wird. Ein Nachteil, der sich durch die Verwendung des Reactor-Patterns ergibt, ist die Gefahr eines potenziellen Memoryleaks. Das Problem dabei wird z.b. von Jeanfrancois Arcand [Arc12] beschrieben. Es entsteht dadurch, dass das Attachement eines SelectionKeys möglicherweise für längere Zeit ungebraucht im Speicher liegt. Bei mehreren Tausend Verbindungen entsteht dabei ein immer größerer Speicherbedarf für Objekte, die nicht gebraucht werden. Dem lässt sich jedoch dadurch entgegenwirken, indem z.b. der Reactor als Singleton implementiert wird. Damit kann sichergestellt werden, dass es insgesamt nur ein Acceptor-Attachement innerhalb der Serveranwendung gibt. 4 Einsatzgebiete von Java NIO Die NIO-API kann bzw. sollte überall dort in der Netzwerkprogrammierung eingesetzt werden, wo eine hohe Anzahl an Verbindungen erwartet wird und eine hohe Skalierbarkeit benötigt wird. Die Arbeit mit NIO gestaltet sich dabei
jedoch grundsätzlich etwas komplexer als mit NET. Aus diesem Grund sind einige Frameworks am Markt zu finden, die auf NIO aufbauen. Eines dieser Frameworks ist Netty, dass urpsrünglich zu JBoss gehörte und ein asynchrones, ereignisbasiertes Framework für RAD von Netzwerkanwendungen darstellt [Nety12]. Als Beispiel ist unter http://netty.io/docs/stable/xref/org/jboss/netty/example/echo/p summary.html (letzter Zugriff: 18.05.2012) ein Echoserver zu finden, der mit Netty implementiert wurde. 5 Fazit und Ausblick NIO spielt seine Stärken, also die nicht-blockierende API und die damit verbundene, bessere Skalierbarkeit vor allem dort aus, wo in der Netzwerkprogrammierung viele hunderte oder tausenden Verbindungen gleichzeitig behandelt werden müssen. Hier stößt NET an ihre Grenzen. Zwar lassen sich mit NET auch multithreaded Anwendungen entwickeln, jedoch muss der Entwickler hier noch viel Arbeit selbst erledigen. Selector, Channels und Buffer stehen unter NIO bereits out of the box zur Verfügung und ermöglichen zwar etwas komplexere aber besser skalierende und flexiblere Lösungen. Literaturverzeichnis [Arc12] Tricks and Tips with NIO part II: Why SelectionKey.attach() is evil http://jfarcand.wordpress.com/2006/07/06/tricks-and-tips-with-niopart-ii-why-selectionkey-attach-is-evil/ Letzter Zugriff: 18.05.2012 [JNet12] Architecture of a Highly Scalable NIO-Based Server http://today.java.net/article/2007/02/08/architecture-highly-scalable-niobased-server Letzter Zugriff: 17.05.2012 [Lea12] Scalable I/O in Java, Dough Lea State University of New York at Oswego http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf Letzter Zugriff: 26.04.2012 [Nety12] Netty - the Java NIO Client Server Socket Framework http://www.netty.io Letzter Zugriff: 18.05.2012