Цель публикации показать начинающим Java программистам все этапы создания многопоточного сервера. Для полного понимания данной темы основная информация содержится в комментариях моего кода и в выводимых в консоли сообщениях для лучшего понимания что именно происходит и в какой именно последовательности.


В начале будет рассмотрено создание элементарного клиент-сервера, для усвоения базовых знаний, на основе которых будет строиться многопоточная архитектура.


Понятия.


— Потоки: для того чтобы не перепутать что именно подразумевается под потоком я буду использовать существующий в профессиональной литературе синоним — нить, чтобы не путать Stream и Thread, всё-таки более профессионально выражаться — нить, говоря про Thread.


— Сокеты(Sockets): данное понятие тоже не однозначно, поскольку в какой-то момент сервер выполняет — клиентские действия, а клиент — серверные. Поэтому я разделил понятие серверного сокета — (ServerSocket) и сокета (Socket) через который практически осуществляется общение, его будем называть сокет общения, чтобы было понятно о чём речь.


 Кроме того сокетов общения создаётся по одному на каждом из обменивающихся данными приложении, поэтому сокет приложения которое имеет у себя объект - ServerSocket и первоначально открывает порт в ожидании подключения будем называть сокет общения на стороне сервера, а сокет который создаёт подключающееся к порту по известному адресу второе приложение будем называть сокетом общения на стороне клиента.

Спасибо за подсказку про Thread.sleep();!
Конечно в реальном коде Thread.sleep(); устанавливать не нужно — это муветон! В данной публикации я его использую только для того чтобы выполнение программы было нагляднее, что бы успевать разобраться в происходящем.
Так что тестируйте, изучайте и в своём коде никогда не используйте Thread.sleep();!


Оглавление:


1) Однопоточный элементарный сервер.
2) Клиент.
3) Многопоточный сервер – сам по себе этот сервер не участвует в общении напрямую, а лишь является фабрикой однонитевых делегатов(делегированных для ведения диалога с клиентами серверов) для общения с вновь подключившимися клиентами, которые закрываются после окончания общения с клиентом.
4) Имитация множественного обращения клиентов к серверу.


По многочисленным замечаниям выкладываю ссылку на исходники на GitHub:
(https://github.com/merceneryinbox/Clietn-Server_Step-by-step.git)


Итак, начнём с изучения структуры однопоточного сервер, который может принять только одного клиента для диалога. Код приводимый ниже необходимо запускать в своей IDE в этом идея всей статьи. Предлагаю все детали уяснить из подробно задокументированного кода ниже:


  • 1) Однопоточный элементарный сервер.

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class TestAsServer {

/**
 * 
 * @param args
 * @throws InterruptedException
 */
    public static void main(String[] args) throws InterruptedException {
//  стартуем сервер на порту 3345

        try (ServerSocket server= new ServerSocket(3345)){
// становимся в ожидание подключения к сокету под именем - "client" на серверной стороне                                
                Socket client = server.accept();

// после хэндшейкинга сервер ассоциирует подключающегося клиента с этим сокетом-соединением             
                System.out.print("Connection accepted.");

// инициируем каналы для  общения в сокете, для сервера     

// канал записи в сокет
                DataOutputStream out = new DataOutputStream(client.getOutputStream());
                System.out.println("DataOutputStream  created");

                // канал чтения из сокета
                DataInputStream in = new DataInputStream(client.getInputStream());
                System.out.println("DataInputStream created");

// начинаем диалог с подключенным клиентом в цикле, пока сокет не закрыт                
                while(!client.isClosed()){

                System.out.println("Server reading from channel");

// сервер ждёт в канале чтения (inputstream) получения данных клиента               
                String entry = in.readUTF();

// после получения данных считывает их              
                System.out.println("READ from client message - "+entry);

// и выводит в консоль              
                System.out.println("Server try writing to channel");

// инициализация проверки условия продолжения работы с клиентом по этому сокету по кодовому слову       - quit  
                if(entry.equalsIgnoreCase("quit")){
                    System.out.println("Client initialize connections suicide ...");
                    out.writeUTF("Server reply - "+entry + " - OK");                
                    Thread.sleep(3000);
                    break;
                }

// если условие окончания работы не верно - продолжаем работу - отправляем эхо-ответ  обратно клиенту               
                out.writeUTF("Server reply - "+entry + " - OK");                
                System.out.println("Server Wrote message to client.");

// освобождаем буфер сетевых сообщений (по умолчанию сообщение не сразу отправляется в сеть, а сначала накапливается в специальном буфере сообщений, размер которого определяется конкретными настройками в системе, а метод  - flush() отправляет сообщение не дожидаясь наполнения буфера согласно настройкам системы             
                out.flush();    

                }

// если условие выхода - верно выключаем соединения             
                System.out.println("Client disconnected");
                System.out.println("Closing connections & channels.");

                // закрываем сначала каналы сокета !
                in.close();
                out.close();

                // потом закрываем сам сокет общения на стороне сервера!
                client.close();

                // потом закрываем сокет сервера который создаёт сокеты общения
                // хотя при многопоточном применении его закрывать не нужно
                // для возможности поставить этот серверный сокет обратно в ожидание нового подключения

                System.out.println("Closing connections & channels - DONE.");
            } catch (IOException e) {
                e.printStackTrace();
        }
    }
}

  • 2) Клиент.

Сервер запущен и находится в блокирующем ожидании server.accept(); обращения к нему с запросом на подключение. Теперь можно подключаться клиенту, напишем код клиента и запустим его. Клиент работает когда пользователь вводит что-либо в его консоли (внимание! в данном случае сервер и клиент запускаются на одном компьютере с локальным адресом — localhost, поэтому при вводе строк, которые должен отправлять клиент не забудьте убедиться, что вы переключились в рабочую консоль клиента!).
После ввода строки в консоль клиента и нажатия enter строка проверяется не ввёл ли клиент кодовое слово для окончания общения дальше отправляется серверу, где он читает её и то же проверяет на наличие кодового слова выхода. Оба и клиент и сервер получив кодовое слово закрывают ресурсы после предварительных приготовлений и завершают свою работу.
Посмотрим как это выглядит в коде:


import java.io.BufferedReader;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.UnknownHostException;

public class TestASClient {

    /**
     * 
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {

// запускаем подключение сокета по известным координатам и нициализируем приём сообщений с консоли клиента      
        try(Socket socket = new Socket("localhost", 3345);  
                BufferedReader br =new BufferedReader(new InputStreamReader(System.in));
                DataOutputStream oos = new DataOutputStream(socket.getOutputStream());
                DataInputStream ois = new DataInputStream(socket.getInputStream()); )
        {

            System.out.println("Client connected to socket.");
            System.out.println();
            System.out.println("Client writing channel = oos & reading channel = ois initialized.");            

// проверяем живой ли канал и работаем если живой           
                while(!socket.isOutputShutdown()){

// ждём консоли клиента на предмет появления в ней данных                   
                    if(br.ready()){

// данные появились - работаем                      
            System.out.println("Client start writing in channel...");
            Thread.sleep(1000);
            String clientCommand = br.readLine();

// пишем данные с консоли в канал сокета для сервера            
            oos.writeUTF(clientCommand);
            oos.flush();
            System.out.println("Clien sent message " + clientCommand + " to server.");
            Thread.sleep(1000);
// ждём чтобы сервер успел прочесть сообщение из сокета и ответить      

// проверяем условие выхода из соединения           
            if(clientCommand.equalsIgnoreCase("quit")){

// если условие выхода достигнуто разъединяемся             
                System.out.println("Client kill connections");
                Thread.sleep(2000);

// смотрим что нам ответил сервер на последок перед закрытием ресурсов          
                if(ois.available()!=0)      {   
                    System.out.println("reading...");
                    String in = ois.readUTF();
                    System.out.println(in);
                            }

// после предварительных приготовлений выходим из цикла записи чтения               
                break;              
            }

// если условие разъединения не достигнуто продолжаем работу            
            System.out.println("Client sent message & start waiting for data from server...");          
            Thread.sleep(2000);

// проверяем, что нам ответит сервер на сообщение(за предоставленное ему время в паузе он должен был успеть ответить)           
            if(ois.available()!=0)      {   

// если успел забираем ответ из канала сервера в сокете и сохраняем её в ois переменную,  печатаем на свою клиентскую консоль                       
            System.out.println("reading...");
            String in = ois.readUTF();
            System.out.println(in);
                    }           
                }
            }
// на выходе из цикла общения закрываем свои ресурсы
            System.out.println("Closing connections & channels on clentSide - DONE.");

        } catch (UnknownHostException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

  • 3) Многопоточный сервер

А что если к серверу хочет подключиться ещё один клиент!? Ведь описанный выше сервер либо находится в ожидании подключения одного клиента, либо общается с ним до завершения соединения, что делать остальным клиентам? Для такого случая нужно создать фабрику которая будет создавать описанных выше серверов при подключении к сокету новых клиентов и не дожидаясь пока делегированный подсервер закончит диалог с клиентом откроет accept() в ожидании следующего клиента. Но чтобы на серверной машине хватило ресурсов для общения со множеством клиентов нужно ограничить количество возможных подключений. Фабрика будет выдавать немного модифицированный вариант предыдущего сервера(модификация будет касаться того что класс сервера для фабрики будет имплементировать интерфейс — Runnable для возможности его использования в пуле нитей — ExecuteServices). Давайте создадим такую серверную фабрику и ознакомимся с подробным описанием её работы в коде:


  • Фабрика:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author mercenery
 *
 */
public class MultiThreadServer {

    static ExecutorService executeIt = Executors.newFixedThreadPool(2);

    /**
     * @param args
     */
    public static void main(String[] args) {

        // стартуем сервер на порту 3345 и инициализируем переменную для обработки консольных команд с самого сервера
        try (ServerSocket server = new ServerSocket(3345);
                BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
            System.out.println("Server socket created, command console reader for listen to server commands");

            // стартуем цикл при условии что серверный сокет не закрыт
            while (!server.isClosed()) {

                // проверяем поступившие комманды из консоли сервера если такие
                // были
                if (br.ready()) {
                    System.out.println("Main Server found any messages in channel, let's look at them.");

                    // если команда - quit то инициализируем закрытие сервера и
                    // выход из цикла раздачии нитей монопоточных серверов
                    String serverCommand = br.readLine();
                    if (serverCommand.equalsIgnoreCase("quit")) {
                        System.out.println("Main Server initiate exiting...");
                        server.close();
                        break;
                    }
                }

                // если комманд от сервера нет то становимся в ожидание
                // подключения к сокету общения под именем - "clientDialog" на
                // серверной стороне
                Socket client = server.accept();

                // после получения запроса на подключение сервер создаёт сокет
                // для общения с клиентом и отправляет его в отдельную нить
                // в Runnable(при необходимости можно создать Callable)
                // монопоточную нить = сервер - MonoThreadClientHandler и тот
                // продолжает общение от лица сервера
                executeIt.execute(new MonoThreadClientHandler(client));
                System.out.print("Connection accepted.");
            }

            // закрытие пула нитей после завершения работы всех нитей
            executeIt.shutdown();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

  • Модифицированный Runnable сервер для запуска из предыдущего кода:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

public class MonoThreadClientHandler implements Runnable {

    private static Socket clientDialog;

    public MonoThreadClientHandler(Socket client) {
        MonoThreadClientHandler.clientDialog = client;
    }

    @Override
    public void run() {

        try {
            // инициируем каналы общения в сокете, для сервера

            // канал записи в сокет следует инициализировать сначала канал чтения для избежания блокировки выполнения программы на ожидании заголовка в сокете
            DataOutputStream out = new DataOutputStream(clientDialog.getOutputStream());

// канал чтения из сокета
            DataInputStream in = new DataInputStream(clientDialog.getInputStream());
            System.out.println("DataInputStream created");

            System.out.println("DataOutputStream  created");
            ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
            // основная рабочая часть //
            //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

            // начинаем диалог с подключенным клиентом в цикле, пока сокет не
            // закрыт клиентом
            while (!clientDialog.isClosed()) {
                System.out.println("Server reading from channel");

                // серверная нить ждёт в канале чтения (inputstream) получения
                // данных клиента после получения данных считывает их
                String entry = in.readUTF();

                // и выводит в консоль
                System.out.println("READ from clientDialog message - " + entry);

                // инициализация проверки условия продолжения работы с клиентом
                // по этому сокету по кодовому слову - quit в любом регистре
                if (entry.equalsIgnoreCase("quit")) {

                    // если кодовое слово получено то инициализируется закрытие
                    // серверной нити
                    System.out.println("Client initialize connections suicide ...");
                    out.writeUTF("Server reply - " + entry + " - OK");
                    Thread.sleep(3000);
                    break;
                }

                // если условие окончания работы не верно - продолжаем работу -
                // отправляем эхо обратно клиенту

                System.out.println("Server try writing to channel");
                out.writeUTF("Server reply - " + entry + " - OK");
                System.out.println("Server Wrote message to clientDialog.");

                // освобождаем буфер сетевых сообщений
                out.flush();

                // возвращаемся в началло для считывания нового сообщения
            }

            ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
            // основная рабочая часть //
            //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

            // если условие выхода - верно выключаем соединения
            System.out.println("Client disconnected");
            System.out.println("Closing connections & channels.");

            // закрываем сначала каналы сокета !
            in.close();
            out.close();

            // потом закрываем сокет общения с клиентом в нити моносервера
            clientDialog.close();

            System.out.println("Closing connections & channels - DONE.");
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

Для имитации множественного обращения клиентов к серверу, создадим и запустим (после запуска серверной части) фабрику Runnable клиентов которые будут подключаться серверу и писать сообщения в цикле:


  • 4) Имитация множественного обращения клиентов к серверу.

import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {

    // private static ServerSocket server;

    public static void main(String[] args) throws IOException, InterruptedException {

        // запустим пул нитей в которых колличество возможных нитей ограничено -
        // 10-ю.
        ExecutorService exec = Executors.newFixedThreadPool(10);
        int j = 0;

        // стартуем цикл в котором с паузой в 10 милисекунд стартуем Runnable
        // клиентов,
        // которые пишут какое-то количество сообщений
        while (j < 10) {
            j++;
            exec.execute(new TestRunnableClientTester());
            Thread.sleep(10);
        }

        // закрываем фабрику
        exec.shutdown();
    }
}

Как видно из предыдущего кода фабрика запускает — TestRunnableClientTester() клиентов, напишем для них код и после этого запустим саму фабрику, чтобы ей было кого исполнять в своём пуле:


import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

public class TestRunnableClientTester implements Runnable {

    static Socket socket;

    public TestRunnableClientTester() {
        try {

            // создаём сокет общения на стороне клиента в конструкторе объекта
            socket = new Socket("localhost", 3345);
            System.out.println("Client connected to socket");
            Thread.sleep(2000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {

        try (

                // создаём объект для записи строк в созданный скокет, для
                // чтения строк из сокета
                // в try-with-resources стиле
                DataOutputStream oos = new DataOutputStream(socket.getOutputStream());
                DataInputStream ois = new DataInputStream(socket.getInputStream())) {
            System.out.println("Client oos & ois initialized");

            int i = 0;
            // создаём рабочий цикл
            while (i < 5) {

                // пишем сообщение автогенерируемое циклом клиента в канал
                // сокета для сервера
                oos.writeUTF("clientCommand " + i);

                // проталкиваем сообщение из буфера сетевых сообщений в канал
                oos.flush();

                // ждём чтобы сервер успел прочесть сообщение из сокета и
                // ответить
                Thread.sleep(10);
                System.out.println("Client wrote & start waiting for data from server...");

                // забираем ответ из канала сервера в сокете
                // клиента и сохраняем её в ois переменную, печатаем на
                // консоль
                System.out.println("reading...");
                String in = ois.readUTF();
                System.out.println(in);
                i++;
                Thread.sleep(5000);

            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

Запускайте, вносите изменения в код, только так на самом деле можно понять работу этой структуры.


Спасибо за внимание.

Поделиться с друзьями
-->

Комментарии (23)


  1. Suvitruf
    11.06.2017 14:24
    +1

    Вы забыли 5 пункт: выбросить свой велосипед и использовать что-нибудь вроде Netty.


    1. OlegMercenery
      11.06.2017 18:35
      -1

      Смысл публикации в том что бы помочь начинающим разбираться в архитектуре клиент-серверных приложений, а не сделать свой «велосипед». Спасибо за Ваше мнение!


      1. Suvitruf
        11.06.2017 20:24
        +2

        No offense, но я бы никому не рекомендовал учить архитектуру клиент-серверных приложений по этой статье.


        1. OlegMercenery
          11.06.2017 20:47

          Всегда открыт к конструктивной критике…


        1. OlegMercenery
          13.06.2017 15:32

          Правильнее рекомендовать что то другое более продуктивное, лучшее и информативное.


      1. AndreyRubankov
        12.06.2017 10:54
        +1

        Конструктив:
        1. Все исходники следует перенести на GitHub (или подобный)
        2. Сделать больше упор на Архитектуру с картинками, переходами и анализом каждого из вариантв, нежели на сам код.
        3. Если хотите разобрать тему полностью, то следует сразу создать заготовку под серию статей:
        3.1. Old IO (java.net) — ваши однопоточный и многопоточный серверы: синхронные с кучей потоков
        3.2. New IO (java.nio) — асинхронный сервер
        3.3. netty.io — рассмотреть архитектуру и показать пример

        Сейчас Вы рассматриваете ту часть java network, которая уже морально устарела. Old IO следует рассматривать только в ознакомительных целях, а любые продакшн решения должны строиться на NIO.


        1. OlegMercenery
          12.06.2017 12:06

          1. Исходники на GitHub е в куче лежат, сделаю отдельный репозиторий для этой статьи и прикреплю ссылку позже, спасибо.
          2. В сети очень много таких картинок, но они дают абстрактно теоретический эффект, а когда запущен работающий код с подробным комментированием практически понимаешь как всё что на картинках нарисовано исполняется.
          3. — супер, так и сделаю! Спасибо за подсказку!


          1. AndreyRubankov
            12.06.2017 12:32

            В интернете есть куча информации обо всем. Даже про java sockets.

            Код — это хорошо, но чтобы понять всю суть и все проблемы по коду, нужно будет потратить много времени.
            Но «картинка» архитектуры + описание — это лучший способ подачи информации о том, как все должно и будет работать.

            При построении этой «картинки» все проблемные места всплывают сами по себе. Если схема получается сложной — значит в архитектуре есть проблемы, а в процессе объяснения схемы сразу всплывают все костыли («вот этот аппендикс нужен для...»).

            Совет с картинками применим вообще к любому проектированию, а не только к данной статье.


  1. Merlen_Gross
    11.06.2017 15:08
    +3

    Это код с комментариями, а не публикация, при всём уважении.


    1. OlegMercenery
      11.06.2017 18:37
      -1

      Точно! Я так там и написал, что лучше понять эту структуру можно только запустив этот самый код и изучив выводы на консоль всех этапов работы и взаимодействия клиента и сервера. В этом можно сказать вся суть публикации. Спасибо, что вы акцентировали на этом внимание!


  1. Cobolorum
    11.06.2017 18:04
    +3

    Автор больше не пиши.


    1. izzholtik
      11.06.2017 21:32
      +1

      Автор, больше пиши :)
      ибо по коду видно, что опыта мало. Огрехов довольно много, описывать каждый из них смысла нет. Сделайте штук 5 проектов для себя — и большая часть уйдёт сама :)


      1. izzholtik
        11.06.2017 21:37

        меня забанят за смайлики.


      1. OlegMercenery
        12.06.2017 02:18

        Спасибо за позитвные слова ибо таких мало. Можно по ключевым пройтись? Судя по количеству просмотров — людей много интересуется, есть возможность им лучше помочь, ну если есть конечно свободное время и желание!?


  1. OlegMercenery
    11.06.2017 21:04
    -1

    Господа, цель статьи дать первичные знания по основам клиент-сервера, если у вас есть что добавить к публикации или выявленные ошибки в коде или в описании я очень жду конкретных замечаний. Если такие есть — пишите я разберусь и исправлю!


  1. alek_sys
    11.06.2017 23:11

    Потоки: для того чтобы не перепутать что именно подразумевается под потоком я буду использовать существующий в профессиональной литературе синоним — нить, чтобы не путать Stream и Thread, всё-таки более профессионально выражаться — нить, говоря про Thread.


    Я бы все таки порекомендовал переводить thread как «поток», «нить» это чаще всего fiber, который не совсем поток. По крайней мере, раньше во многих книгах использовалась именно такая терминология. Ну, либо вообще не переводить.


    1. OlegMercenery
      12.06.2017 02:21

      Скорее не переводить — это лучший вариант и я сам использую именно его, но такой вариант так же имеет место быть в профессиональной литературе, и поскольку статья всё таки на русском решил применить этот вариант, чтобы новички акцентировали внимание на том что тут существует некая неоднозначность и стали копать — это тоже будет полезно.


      1. avost
        12.06.2017 16:22
        +1

        В какой ещё профессиональной литературе? На заре времён, из которых вы откопали этот нафталиновый пример? Не стОит, тот поезд давно под откосом.


  1. Neuyazvimy1
    12.06.2017 10:06
    +1

    Видно что это ваш первый сервер. Думаю каждый начинающий серверный разработчик написал бы что то типа наподобие вашего творения. Лучше было бы еще пару серверов написать и уже тогда браться за статью.
    Советую реализовывать сервер с rpc принципом. А еще лучше как советовали выше использовать netty.
    А вообще классно было бы использовать связку netty+protobuf.
    https://en.wikipedia.org/wiki/Remote_procedure_call
    https://en.wikipedia.org/wiki/Reactor_pattern
    Удачи в серверной разработке.


    1. OlegMercenery
      12.06.2017 11:55

      Очень полезный комментарий, благодарю. Есть понимание следующего шага в направлении написания серверных приложений. Просто начинающему наверное сразу браться за netty+protobuf отобьёт всё желание разбираться дальше — слишком сложно будет.
      Друзья! Прошу писать свои мысли в подобном виде, со ссылками и конкретными предложениями!


      1. Suvitruf
        12.06.2017 14:01

        Если говорить про netty, то базовые примеры там даже проще и понятнее описаны, чем в вашей статье.

        Но, опять же, проблема не в коде, а подходе. Объяснять базовые вещи в клиент-серверной архитектуре надо не кодом, а схемами. Как только человек поймёт теоретические принципы, то написать пример уже на любой языке будет просто.


        1. OlegMercenery
          13.06.2017 00:54

          Схема появляется за 5 секунд по запросу в google а как процессы протекают внутри кода мне например по UML схеме не понятно, программисту нужно понимать что за чем идёт в потоке выполнения программы, ну и конечно схема нужное дело тоже.


  1. KislyFan
    13.06.2017 15:28

    Ребятки, а есть такой же тутор только для .net с блекджеком и Queue ?