TCP сокети

Светлин Наков

www.nakov.com

 

Както вече знаем от краткия преглед на Интернет протоколите, който направихме в началото, TCP сокетите представляват надежден двупосочен транспортен канал за данни между две приложения. Приложенията, които си комуникират през сокет, могат да се изпълняват на един и същ компютър или на различни компютри, свързани по между си чрез Интернет или друга TCP/IP мрежа. Тези приложения биват два вида – сървъри и клиенти. Клиентите се свързват към сървърите по IP адрес и номер на порт чрез класа java.net.Socket. Сървърите приемат клиенти чрез класа java.net.ServerSocket. При разработка на сървъри обикновено трябва да се съобразяваме с необходимостта от обслужване на много потребители едновременно и независимо един от друг. Най-често този проблем се решава с използване на нишки за всеки потребител. Нека първо разгледаме по-простия вариант – обслужване само на един клиент в даден момент.

Прост TCP сървър

Да разгледаме сорс-кода на едно просто сървърско приложение – DateServer:

DateServer.java
import java.util.Date;
import java.io.OutputStreamWriter;
import java.io.IOException;
import java.net.Socket;
import java.net.ServerSocket;
 
public class DateServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(2002);
        while (true) {
        Socket socket = serverSocket.accept();
            OutputStreamWriter out =
                new OutputStreamWriter(
                    socket.getOutputStream());
            out.write(new Date()+ "\n");
            out.close();
            socket.close();
        }
    }
}

Този сървър отваря за слушане TCP порт 2002, след което в безкраен цикъл приема клиенти, изпраща им текущата дата и час и веднага след това затваря сокета с тях. Отварянето на сокет за слушане става като се създава обект от класа ServerSocket, в конструктора на който се задава номера на порта. Приемането на клиент се извършва от метода accept() на класа ServerSocket, при извикването на който текущата нишка блокира до пристигането на клиентска заявка, след което създава сокет връзка между сървъра и пристигналия клиент. От създадената сокет връзка сървърът взема изходния поток за изпращане на данни към клиента (чрез метода getOutputStream()) и изпраща в него текущата дата и час, записани на една текстова линия. Затварянето на изходния поток е важно. То предизвиква действителното изпращане на данните към клиента, понеже извиква метода flush() на изходния поток. Ако нито един от методите close() или flush() не бъде извикан, клиентът няма да получи нищо, защото изпратените данни ще останат в буфера на сокета и няма да отпътуват по него. Накрая, затварянето на сокета предизвиква прекъсване на комуникацията с клиента. Сървърът можем да изтестваме със стандартната програмка telnet, която е включена в повечето версии на Windows, Linux и Unix като напишем на конзолата следната команда:

telnet localhost 2002

Резултатът е получената от сървъра дата:

Wed Mar 03 20:31:05 EET 2004

Прост TCP клиент

Нека сега напишем клиент за нашия сървър – програма, която се свързва към него, взема датата и часа, които той връща и ги отпечатва на конзолата. Ето как изглежда една примерна такава програмка:

DateServerClient.java
import java.io.*; 
import java.net.Socket; 
 
public class DateServerClient { 
    public static void main(String[] args) throws IOException { 
        Socket socket = new Socket("localhost", 2002); 
        BufferedReader in = new BufferedReader( 
            new InputStreamReader( 
                socket.getInputStream() ) ); 
        System.out.println("The date on the server is: " + 
            in.readLine()); 
        socket.close(); 
    } 
}

Свързването към TCP сървър става чрез създаването на обект от класа java.net.Socket, като в конструктора му се задават IP адреса или името на сървъра и номера на порта. От свързания успешно сокет се взема входния поток и се прочита това, което сървърът изпраща. След приключване на работа сокетът се затваря. Ето какъв би могъл да е изхода от изпълнението на горната програмка, ако сървърът е стартиран на локалната машина и работи нормално:

The date on the server is: Wed Mar 03 20:34:12 EET 2004

Обработка на изключения

Ако стартираме клиентската програмка, но преди това спрем сървъра, ще получим малко по-различен резултат:

java.net.ConnectException: Connection refused: connect
    at java.net.PlainSocketImpl.socketConnect(Native Method)
    at java.net.PlainSocketImpl.doConnect(
        PlainSocketImpl.java:305)
    at java.net.PlainSocketImpl.connectToAddress(
        PlainSocketImpl.java:171)
    at java.net.PlainSocketImpl.connect(
        PlainSocketImpl.java:158)
    at java.net.Socket.connect(Socket.java:426)
    at java.net.Socket.connect(Socket.java:376)
    at java.net.Socket.<init>(Socket.java:291)
    at java.net.Socket.<init>(Socket.java:119)
    at DateServerClient.main(DateServerClient.java:6)
Exception in thread "main" Process terminated with exit code 1

Полученото изключение обяснява, че при опит за свързване към сървъра в конструктора на класа java.net.Socket се е получил проблем, защото съответният порт е бил затворен.

При работа със сокети и входно-изходни потоци понякога възникват грешки, в резултат на което се хвърлят изключения (exceptions). Затова е задължително и в двете програми, които дадохме за пример, кодът, който комуникира по сокет да бъде поставен или в try ... catch блок или методът, в който се използва входно-изходна комуникация, да бъде обявен като метод, който може да породи изключението java.io.IOException. Изключения възникват в най-разнообразни ситуации. Например ако сървърът не е пуснат и клиентът се опита да се свърже с него, ако връзката между клиента и сървъра се прекъсне, ако сървърът се опита да слуша на зает вече порт, ако сървърът няма право да слуша на поискания порт и в много други случаи.

Четенето от сокет е блокираща операция

Една важна особеност при четенето от сокет е, че ако клиентът се опита да прочете данни от сървъра, а той не му изпрати нищо, клиентът ще блокира до затваряне на сокета. Затова сървърът и клиентът трябва да комуникират по предварително известен и за двамата протокол и да го спазват стриктно. Протоколът трябва да индикира по някакъв начин на клиента и на сървъра дали да очакват получаването на още данни.

Обслужване на много потребители едновременно

Даденият по-горе пример за сървър обслужва клиентите си последователно един след друг. Ако двама клиенти едновременно дадат заявка, първият ще бъде обслужен веднага, а вторият едва след приключване на обслужването на първия. Тази стратегия работи, но само за прости сървъри, в които обслужването на клиент отнема много малко време. В повечето случаи обслужването на един клиент отнема известно време и останалите клиенти не могат да бъдат карани да го изчакват. Затова се налага сървърът да обслужва клиентите си едновременно и независимо един от друг. За реализация на такава стратегия в средата на Java най-често се използва многонишковият подход, при който за всеки клиент се създава отделна нишка. Това е препоръчвания начин за разработка на сървъри, предназначени да работят с повече от един клиент. Ако трябва да сме точни, от JDK 1.4 в Java се поддържат и асинхронни сокети, с които могат да се обработват едновременно много клиенти само с една нишка, но засега няма да разглеждаме този програмен модел.

Многопотребителски сървър-речник

Да си поставим за задача реализацията на прост сървър, който по зададена дума на английски език връща преводът й на български език, а за при непозната думи връща грешка. Сървърът трябва да може да обслужва много потребители едновременно и независимо един от друг, без да е необходимо някой от тях да чака докато сървърът обслужва останалите. За простота ще считаме, че думите и техните преводи са дадени като константа с ясната идея, че в една реална ситуация те трябва да се извличат от база данни или от някаква друга система. Ето как можем да реализираме нашият сървър-речник:

DictionaryServer.java
import java.io.*; 
import java.net.ServerSocket; 
import java.net.Socket; 
 
public class DictionaryServer { 
    public static int LISTENING_PORT = 3333; 
 
    public static void main(String[] args) throws IOException { 
        ServerSocket serverSocket = 
            new ServerSocket(LISTENING_PORT); 
        while (true) { 
            Socket socket = serverSocket.accept(); 
            ClientThread clientThread = 
                new ClientThread(socket); 
            clientThread.start(); 
        } 
    } 
} 
 
class ClientThread extends Thread { 
    private Socket mSocket; 
    private BufferedReader mSocketReader; 
    private PrintWriter mSocketWriter; 
 
    public ClientThread(Socket aSocket) throws IOException { 
        mSocket = aSocket; 
        mSocketReader = new BufferedReader( 
            new InputStreamReader(mSocket.getInputStream())); 
        mSocketWriter = new PrintWriter( 
            new OutputStreamWriter(mSocket.getOutputStream())); 
    } 
 
    public void run() { 
        try { 
            mSocketWriter.println("Dictinary server ready."); 
            mSocketWriter.flush(); 
            while (!isInterrupted()) { 
                String word = mSocketReader.readLine(); 
                if (word == null) 
                    break; // Client closed the socket 
                String translation = getTranslation(word); 
                mSocketWriter.println(translation); 
                mSocketWriter.flush(); 
            } 
        } catch (Exception ex) { 
            ex.printStackTrace(); 
        } 
    } 
 
    private String getTranslation(String aWord) { 
        if (aWord.equalsIgnoreCase("network")) { 
            return "мрежа"; 
        } else if (aWord.equalsIgnoreCase("firewall")) { 
            return "защитна стена"; 
        } else { 
            return "! непозната дума !"; 
        } 
    } 
}

Как работи сървърът-речник

Сървърът-речник е изключително прост. Той отваря за слушане сървърски сокет на порт 3333 и започва да слуша в цикъл за клиентски заявки идващи по този сокет. При приемане на клиент създава нишка, която да го обслужва, подава й създадения за клиента клиентски сокет и стартира нишката.

Нишката, която обслужва клиентите, първо им изпраща поздравително съобщение, след което в цикъл чете дума от клиента, намира преводът й в речника си и изпраща към клиента този превод. Това продължава докато нишката не бъде помолена да завърши работата си. Ако междувременно от клиента се прочете празен низ, това означава, че е достигнат края на входния поток, т.е. сокет връзката се е разпаднала по някаква причина. В такъв случай се прекратява обслужването на клиента и нишката завършва.

Забележете, че веднага след като изпратим нещо към сървъра извикваме flush() метода, за да осигурим реалното изпращане на данните по сокета. Ако не извикаме flush(), данните ще останат да чакат в буфера на класа PrintWriter и няма да отпътуват по сокета, все едно не са изпратени към потока. Тази особеност с буферирането е много важна при комуникация с потоци и винаги трябва да се съобразяваме с нея.

Клиент за сървъра-речник

Нека сега напише и клиент за нашия сървър речник. Всичко, което трябва да прави клиента е да се свърже със сървъра, да извлече от него поздравителното съобщение и след това постоянно да чете дума от конзолата, да я изпраща към сървъра за превод и да отпечатва превода получен от клиента. Ето една реализация:

DictionaryClient.java
import java.io.*; 
import java.net.Socket; 
 
public class DictionaryClient { 
    public static void main(String[] args) throws IOException { 
        Socket socket = new Socket("localhost", 3333); 
        BufferedReader socketReader = new BufferedReader( 
            new InputStreamReader(socket.getInputStream()) ); 
        PrintWriter socketWriter = 
            new PrintWriter(socket.getOutputStream()); 
        BufferedReader consoleReader = new BufferedReader( 
            new InputStreamReader(System.in) ); 
        String welcomeMessage = socketReader.readLine(); 
        System.out.println(welcomeMessage); 
        try { 
            while (true) { 
                String word = consoleReader.readLine(); 
                socketWriter.println(word); 
                socketWriter.flush(); 
                String translation = socketReader.readLine(); 
                System.out.println(translation); 
            } 
        } finally { 
            socket.close(); 
        } 
    } 
}

Как работи клиентът за сървъра-речник

Всичко което прави клиентът е да отвори сокет към сървъра, да прочете от него поздравителното съобщение, след което в безкраен цикъл да чете дума от конзолата, да я изпраща към сървъра за превод, да прочита отговора на сървъра и да го отпечатва в конзолата. Забележете отново, че след изпращане на заявката към сървъра се извиква методът flush() на изходния поток. Ако този метод не се извика, програмата ще блокира, защото заявката няма да достигне сървъра. Програмата ще се опитва неограничено дълго време да прочете отговора на сървъра, а сървърът ще чака неограничено дълго време да получи заявка от клиента и така никой няма да дочака другия.