Последнее обновление: 31.10.2015
Протокол UDP не требует установки постоянного подключения, и, возможно, многим покажется легче работать с UDP, чем с TCP. Большинство принципов при работе с UDP те же, что и с TCP.
Вначале создается сокет:
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
Если сокет должен получать сообщения, то надо привязать его к локальному адресу и одному из портов с помощью метода Bind:
IPEndPoint localIP = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5555); socket.Bind(localIP);
После этого можно отправлять и получать сообщения. Для получения сообщений используется метод ReceiveFrom() :
Byte data = new byte; // буфер для получаемых данных //адрес, с которого пришли данные EndPoint remoteIp = new IPEndPoint(IPAddress.Any, 0); int bytes = socket.ReceiveFrom(data, ref remoteIp);
В качестве параметра в метод передается массив байтов, в который надо считать данные, и удаленная точка, с которой приходят эти данные. Метод возвращает количество считанных байтов.
Для отправки данных используется метод SendTo() :
String message = Console.ReadLine(); byte data = Encoding.Unicode.GetBytes(message); EndPoint remotePoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), remotePort); listeningSocket.SendTo(data, remotePoint);
В метод передается массив отправляемых данных, а также адрес, по которому эти данные надо отправить.
Создадим программу UDP-клиента:
Using System; using System.Text; using System.Threading.Tasks; using System.Net; using System.Net.Sockets; namespace SocketUdpClient { class Program { static int localPort; // порт приема сообщений static int remotePort; // порт для отправки сообщений static Socket listeningSocket; static void Main(string args) { Console.Write("Введите порт для приема сообщений: "); localPort = Int32.Parse(Console.ReadLine()); Console.Write("Введите порт для отправки сообщений: "); remotePort = Int32.Parse(Console.ReadLine()); Console.WriteLine("Для отправки сообщений введите сообщение и нажмите Enter"); Console.WriteLine(); try { listeningSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); Task listeningTask = new Task(Listen); listeningTask.Start(); // отправка сообщений на разные порты while (true) { string message = Console.ReadLine(); byte data = Encoding.Unicode.GetBytes(message); EndPoint remotePoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), remotePort); listeningSocket.SendTo(data, remotePoint); } } catch (Exception ex) { Console.WriteLine(ex.Message); } finally { Close(); } } // поток для приема подключений private static void Listen() { try { //Прослушиваем по адресу IPEndPoint localIP = new IPEndPoint(IPAddress.Parse("127.0.0.1"), localPort); listeningSocket.Bind(localIP); while (true) { // получаем сообщение StringBuilder builder = new StringBuilder(); int bytes = 0; // количество полученных байтов byte data = new byte; // буфер для получаемых данных //адрес, с которого пришли данные EndPoint remoteIp = new IPEndPoint(IPAddress.Any, 0); do { bytes = listeningSocket.ReceiveFrom(data, ref remoteIp); builder.Append(Encoding.Unicode.GetString(data, 0, bytes)); } while (listeningSocket.Available > 0); // получаем данные о подключении IPEndPoint remoteFullIp = remoteIp as IPEndPoint; // выводим сообщение Console.WriteLine("{0}:{1} - {2}", remoteFullIp.Address.ToString(), remoteFullIp.Port, builder.ToString()); } } catch (Exception ex) { Console.WriteLine(ex.Message); } finally { Close(); } } // закрытие сокета private static void Close() { if (listeningSocket != null) { listeningSocket.Shutdown(SocketShutdown.Both); listeningSocket.Close(); listeningSocket = null; } } } }
Вначале пользователь вводит порты для приема данных и для отправки. Предполагается, что два приложения клиента, которые будут между собой взаимодействовать, запущены на одной локальной машине. Если адреса клиентов различаются, то можно предусмотреть и ввода адреса для отправки данных.
После ввода портов запускается задача на прослушивание входящих сообщений. В отличие от tcp-сервера здесь не надо вызывать методы Listen и Accept. В бесконечном цикле мы напрямую можем получить получение данные с помощью метода ReceiveFrom() , который блокирует вызывающий поток, пока не придет очередная порция данных.
Этот метод возвращает через ref-параметр удаленную точку, с которой получены данные:
IPEndPoint remoteFullIp = remoteIp as IPEndPoint;
То есть, не смотря на то, что в данном случае прием и отправка сообщений разграничены и текущий клиент отправляет данные только на введенный вначале порт, но мы вполне можем добавить возможность ответа на сообщения, используя данные полученной удаленной точки (адрес и порт).
В главном потоке происходит отправка сообщений с помощью метода SendTo()
Таким образом, приложение сразу осуществляет функции и сервера, и клиента.
Теперь запустим две копии приложения и введем разные данные для портов. Первый клиент:
Введите порт для приема сообщений: 4004 Введите порт для отправки сообщений: 4005 Для отправки сообщений введите сообщение и нажмите Enter 127.0.0.1:4005 - привет порт 4004 добрый день, порт 4005 чудная погодка
Второй клиент:
Введите порт для приема сообщений: 4005 Введите порт для отправки сообщений: 4004 Для отправки сообщений введите сообщение и нажмите Enter привет порт 4004 127.0.0.1:4004 - добрый день, порт 4005 127.0.0.1:4004 - чудная погодка
Отсюда и "заточка" этого протокола под работу с отдельными документами, преимущественно текстовыми. HTTP в своей работе использует возможности TCP/IP, поэтому рассмотрим возможности, предоставляемые java для работы с последним.
В джаве для этого существует специальный пакет "java.net", содержащий класс java.net.Socket. Socket в переводе означает "гнездо", название это было дано по аналогии с гнёздами на аппаратуре, теми самыми, куда подключают штепсели. Соответственно этой аналогии, можно связать два "гнезда", и передавать между ними данные. Каждое гнездо принадлежит определённому хосту (Host - хозяин, держатель). Каждый хост имеет уникальный IP (Internet Packet) адрес. На данный момент интернет работает по протоколу IPv4, где IP адрес записывается 4 числами от 0 до 255 - например, 127.0.0.1 (подробнее о распределении IP адресов тут - RFC 790 , RFC 1918 , RFC 2365 , о версии IPv6 читайте тут - RFC 2373)
Гнёзда монтируются на порт хоста (port). Порт обозначается числом от 0 до 65535 и логически обозначает место, куда можно пристыковать (bind) сокет. Если порт на этом хосте уже занят каким-то сокетом, то ещё один сокет туда пристыковать уже не получится. Таким образом, после того, как сокет установлен, он имеет вполне определённый адрес, символически записывающийся так :, к примеру - 127.0.0.1:8888 (означает, что сокет занимает порт 8888 на хосте 127.0.0.1)
Для того, чтобы облегчить жизнь, чтобы не использовать неудобозапоминаемый IP адрес, была придумана система DNS (DNS - Domain Name Service). Цель этой системы - сопоставлять IP адресам символьные имена. К примеру, адресу "127.0.0.1" в большинстве компьютеров сопоставленно имя "localhost" (в просторечье - "локалхост").
Локалхост, фактически, означает сам компьютер, на котором выполняется программа, он же - локальный компьютер. Вся работа с локалхостом не требует выхода в сеть и связи с какими-либо другими хостами.
Клиентский сокет
Итак, вернёмся к классу java.net.Socket Наиболее удобно инициализировать его следующим образом:
Public Socket(String host, int port) throws UnknownHostException, IOException В строковой константе host можно указать как IP адрес сервера, так и его DNS имя. При этом программа автоматически выберет свободный порт на локальном компьютере и "привинтит" туда ваш сокет, после чего будет предпринята попытка связаться с другим сокетом, адрес которого указан в параметрах инициализации. При этом могут возникнуть два вида исключений: неизвестный адрес хоста - когда в сети нет компьютера с таким именем или ошибка отсутствия связи с этим сокетом.
Так же полезно знать функцию
Public void setSoTimeout(int timeout) throws SocketException Эта функция устанавливает время ожидания (timeout) для работы с сокетом. Если в течение этого времени никаких действий с сокетом не произведено (имеется ввиду получение и отправка данных), то он самоликвидируется. Время задаётся в секундах, при установке timeout равным 0 сокет становится "вечным".
Для некоторых сетей изменение timeout невозможно или установлено в определённых интервалах (к примеру от 20 до 100 секунд). При попытке установить недопустимый timeout, будет выдано соответственное исключение.
Программа, которая открывает сокет этого типа, будет считаться клиентом, а программа-владелец сокета, к которому вы пытаетесь подключиться, далее будет называться сервером. Фактически, по аналогии гнездо-штепсель, программа-сервер - это и будет гнездо, а клиент как раз является тем самым штепселем.
Сокет сервера
Как установить соединение от клиента к серверу я только что описал, теперь - как сделать сокет, который будет обслуживать сервер. Для этого в джава существует следующий класс: java.net.ServerSocket Наиболее удобным инициализатором для него является следующий:
Public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException Как видно, в качестве третьего параметра используется объект ещё одного класса - java.net.InetAddress Этот класс обеспечивает работу с DNS и IP именами, по этому вышеприведённый инициализатор в программах можно использовать так: ServerSocket(port, 0, InetAddress.getByName(host)) throws IOException Для этого типа сокета порт установки указывается прямо, поэтому, при инициализации, может возникнуть исключение, говорящее о том, что данный порт уже используется либо запрещён к использованию политикой безопасности компьютера.
После установки сокета, вызывается функция
Public Socket accept() throws IOException Эта функция погружает программу в ожидание того момента, когда клиент будет присоединяться к сокету сервера. Как только соединение установлено, функция возвратит объект класса Socket для общения с клиентом.
Клиент-сервер через сокеты. Пример
Как пример - простейшая программа, реализующая работу с сокетами.
Со стороны клиента программа работает следующим образом: клиент подсоединяется к серверу, отправляет данные, после чего получает данные от сервера и выводит их.
Со стороны сервера это выглядит следующим образом: сервер устанавливает сокет сервера на порт 3128, после чего ждёт входящих подключений. Приняв новое подключение, сервер передаёт его в отдельный вычислительный поток. В новом потоке сервер принимает от клиента данные, приписывает к ним порядковый номер подключения и отправляет данные обратно к клиенту.
Логическая структура работы программ-примеров
Программа простого TCP/IP клиента
(SampleClient.java) import java. io.* ; import java. net.* ; class SampleClient extends Thread { public static void main(String args) { try { // открываем сокет и коннектимся к localhost:3128 // получаем сокет сервера Socket s = new Socket("localhost" , 3128 ); // берём поток вывода и выводим туда первый аргумент // заданный при вызове, адрес открытого сокета и его порт args[ 0 ] = args[ 0 ] + "\n" + s. getInetAddress() . getHostAddress() + ":" + s. getLocalPort(); s. getOutputStream() . write(args[ 0 ] . getBytes()); // читаем ответ byte buf = new byte [ 64 * 1024 ]; int r = s. getInputStream() . read(buf); String data = new String(buf, 0 , r); // выводим ответ в консоль System. out. println(data); } catch (Exception e) { System. out. println("init error: " + e);} // вывод исключений } }Программа простого TCP/IP сервера
(SampleServer.java) import java. io.* ; import java. net.* ; class SampleServer extends Thread { Socket s; int num; public static void main(String args) { try { int i = 0 ; // счётчик подключений // привинтить сокет на локалхост, порт 3128 ServerSocket server = new ServerSocket(3128 , 0 , InetAddress. getByName("localhost" )); System. out. println("server is started" ); // слушаем порт while (true) { // ждём нового подключения, после чего запускаем обработку клиента // в новый вычислительный поток и увеличиваем счётчик на единичку new SampleServer(i, server. accept()); i++ ; } } catch (Exception e) { System. out. println("init error: " + e);} // вывод исключений } public SampleServer(int num, Socket s) { // копируем данные this. num = num; this. s = s; // и запускаем новый вычислительный поток (см. ф-ю run()) setDaemon(true); setPriority(NORM_PRIORITY); start(); } public void run() { try { // из сокета клиента берём поток входящих данных InputStream is = s. getInputStream(); // и оттуда же - поток данных от сервера к клиенту OutputStream os = s. getOutputStream(); // буффер данных в 64 килобайта byte buf = new byte [ 64 * 1024 ]; // читаем 64кб от клиента, результат - кол-во реально принятых данных int r = is. read(buf); // создаём строку, содержащую полученную от клиента информацию String data = new String(buf, 0 , r); // добавляем данные об адресе сокета: data = "" + num+ ": " + "\n" + data; // выводим данные: os. write(data. getBytes()); // завершаем соединение s. close(); } catch (Exception e) { System. out. println("init error: " + e);} // вывод исключений } }После компиляции, получаем файлы SampleServer.class и SampleClient.class (все программы здесь и далее откомпилированы с помощью JDK v1.4) и запускаем вначале сервер:
Java SampleServer а потом, дождавшись надписи "server is started", и любое количество клиентов: java SampleClient test1 java SampleClient test2 ... java SampleClient testN
Если во время запуска программы-сервера, вместо строки "server is started" выдало строку типа
Init error: java.net.BindException: Address already in use: JVM_Bind то это будет обозначать, что порт 3128 на вашем компьютере уже занят какой-либо программой или запрещён к применению политикой безопасности.
Заметки
Отметим немаловажную особенность сокета сервера: он может принимать подключения сразу от нескольких клиентов одновременно. Теоретически, количество одновременных подключений неограниченно, но практически всё упирается в мощность компьютеров. Кстати, эта проблема конечной мощности компьютеров используется в DOS атаках на серверы: их просто закидывают таким количеством подключений, что компьютеры не справляются с нагрузкой и "падают".
В данном случае я показываю на примере SimpleServer, как нужно обрабатывать сразу несколько одновременных подключений: сокет каждого нового подключения посылается на обработку отдельному вычислительному потоку.
Стоит упомянуть, что абстракцию Socket - ServerSocket и работу с потоками данных используют C/C++, Perl, Python, многие другие языки программирования и API операционных систем, так что многое из сказанного подходит к применению не только для платформы Java .
Пора применить эрланг по его прямому назначению -- для реализации сетевого сервиса. Чаще всего такие сервисы делают на базе веб-сервера, поверх протокола HTTP . Но мы возьмем уровень ниже -- TCP и UDP сокеты.
Я полагаю, вы уже знаете, как устроена сеть, что такое Internet Protocol , User Datagram Protocol и Transmission Control Protocol . Эта тема большинству программистов известна. Но если вы почему-то ее упустили, то придется сперва наверстать упущенное, и потом вернуться к этому уроку.
UDP сокет
Вспомним в общих чертах, что такое UDP:
- протокол передачи коротких сообщений (Datagram);
- быстрая доставка;
- без постоянного соединения между клиентом и сервером, без состояния;
- доставка сообщения и очередность доставки не гарантируется.
Для работы с UDP используется модуль gen_udp .
Давайте запустим две ноды и наладим общение между ними.
На 1-й ноде откроем UDP на порту 2000:
1> {ok, Socket} = gen_udp:open(2000, ). {ok,#Port<0.587>}
Вызываем gen_udp:open/2 , передаем номер порта и список опций. Список всех возможных опций довольно большой, но нас интересуют две из них:
binary -- сокет открыт в бинарном режиме. Как вариант, сокет можно открыть в текстовом режиме, указав опцию list . Разница в том, как мы интерпретируем данные, полученные из сокета -- как поток байт, или как текст.
{active, true} -- сокет открыт в активном режиме, значит данные, приходящие в сокет, будут посылаться в виде сообщений в почтовый ящик потока, владельца сокета. Подробнее об этом ниже.
На 2-й ноде откроем UDP на порту 2001:
1> {ok, Socket} = gen_udp:open(2001, ). {ok,#Port<0.587>}
И пошлем сообщение с 1-й ноды на 2-ю:
2> gen_udp:send(Socket, {127,0,0,1}, 2001, <<"Hello from 2000">>). ok
Вызываем gen_udp:send/4 , передаем сокет, адрес и порт получателя, и само сообщение.
Адрес может быть доменным именем в виде строки или атома, или адресом IPv4 в виде кортежа из 4-х чисел, или адресом IPv6 в виде кортежа из 8 чисел.
На 2-й ноде убедимся, что сообщение пришло:
2> <0.587>,{127,0,0,1},2000,<<"Hello from 2000">>} ok
Сообщение приходит в виде кортежа {udp, Socket, SenderAddress, SenderPort, Packet} .
Пошлем сообщение с 2-й ноды на 1-ю:
3> gen_udp:send(Socket, {127,0,0,1}, 2000, <<"Hello from 2001">>). ok
На 1-й ноде убедимся, что сообщение пришло:
3> flush(). Shell got {udp,#Port<0.587>,{127,0,0,1},2001,<<"Hello from 2001">>} ok
Как видим, тут все просто.
Активный и пассивный режим сокета
И gen_udp , и gen_tcp , оба имеют одну важную настройку: режим работы с входящими данными. Это может быть либо активный режим {active, true} , либо пассивный режим {active, false} .
В активном режиме поток получает входящие пакеты в виде сообщений в своем почтовом ящике. И их можно получить и обработать вызовом receive, как любые другие сообщения.
Для udp сокета это сообщения вида:
{udp, Socket, SenderAddress, SenderPort, Packet}
мы их уже видели:
{udp,#Port<0.587>,{127,0,0,1},2001,<<"Hello from 2001">>}
Для tcp сокета аналогичные сообщения:
{tcp, Socket, Packet}
Активный режим прост в использовании, но опасен тем, что клиент может переполнить очередь сообщений потока, исчерпать память и обрушить ноду. Поэтому рекомендуется пассивный режим.
В пассивном режиме данные нужно забрать самому вызовами gen_udp:recv/3 и gen_tcp:recv/3 :
Gen_udp:recv(Socket, Length, Timeout) -> {ok, {Address, Port, Packet}} | {error, Reason} gen_tcp:recv(Socket, Length, Timeout) -> {ok, Packet} | {error, Reason}
Здесь мы указываем, сколько байт данных хотим прочитать из сокета. Если там есть эти данные, то мы получаем их сразу. Если нет, то вызов блокируется, пока не придет достаточное количество данных. Можно указать Timeout, чтобы не блокировать поток надолго.
Однако, gen_udp:recv игнорирует аргумент Length, и возвращает все данные, которые есть в сокете. Или блокируется и ждет каких-нибудь данных, если в сокете ничего нет. Непонятно, зачем вообще аргумент Length присутствует в АПИ.
Для gen_tcp:recv аргумент Length работает как надо. Если только не указана опция {packet, Size} , о которой речь пойдет ниже.
Еще есть вариант {active, once} . В этом случае сокет запускается в активном режиме, получает первый пакет данных как сообщение, и сразу переключается в пассивный режим.
TCP сокет
Вспомним в общих чертах, что такое TCP:
- надежный протокол передачи данных, гарантирует доставку сообщения и очередность доставки;
- постоянное соединение клиента и сервера, имеет состояние;
- дополнительные накладные расходы на установку и закрытие соединения и на передачу данных.
Надо заметить, что долго держать постоянные соединения с многими тысячами клиентов накладно. Все соединения должны работать независимо друг от друга, а это значит -- в разных потоках. Для многих языков программирования (но не для эрланг) это серьезная проблема.
Именно поэтому так популярен протокол HTTP, который хоть и работает поверх TCP сокета, но подразумевает короткое время взаимодействия. Это позволяет относительно небольшим числом потоков (десятки-сотни) обслуживать значительно большее число клиентов (тысячи, десятки тысяч).
В некоторых случаях остается необходимость иметь долгоживущие постоянные соединения между клиентом и сервером. Например, для чатов или для многопользовательских игр. И здесь эрланг имеет мало конкурентов.
Для работы с TCP используется модуль gen_tcp .
Работать с TCP сокетом сложнее, чем с UDP. У нас появляются роли клиента и сервера, требующие разной реализации. Рассмотрим вариант реализации сервера.
Module(server). -export(). start() -> start(1234). start(Port) -> spawn(?MODULE, server, ), ok. server(Port) -> io:format("start server at port ~p~n", ), {ok, ListenSocket} = gen_tcp:listen(Port, ), ) || Id <- lists:seq(1, 5)], timer:sleep(infinity), ok. accept(Id, ListenSocket) -> io:format("Socket #~p wait for client~n", ), {ok, _Socket} = gen_tcp:accept(ListenSocket), io:format("Socket #~p, session started~n", ), handle_connection(Id, ListenSocket). handle_connection(Id, ListenSocket) -> receive {tcp, Socket, Msg} -> io:format("Socket #~p got message: ~p~n", ), gen_tcp:send(Socket, Msg), handle_connection(Id, ListenSocket); {tcp_closed, _Socket} ->
Есть два вида сокета: Listen Socket и Accept Socket . Listen Socket один, он принимает все запросы на соединение. Accept Socket нужно много, по одному для каждого соединения. Поток, в котором создается сокет, становится владельцем сокета. Если поток-владелец завершается, то сокет автоматически закрывается. Поэтому для каждого сокета мы создаем отдельный поток.
Listen Socket должен работать всегда, а для этого его поток-владелец не должен завершаться. Поэтому в server/1 мы добавили вызов timer:sleep(infinity) . Это заблокирует поток и не даст ему завершиться. Такая реализация, конечно, учебная. По хорошему нужно предусмотреть возможность корректно остановить сервер, а здесь этого нет.
Accept Socket и поток для него можно было бы создавать динамически, по мере появления клиентов. В начале можно создать один такой поток, вызвать в нем gen_tcp:accept/1 и ждать клиента. Этот вызов является блокирующим. Он завершается, когда появляется клиент. Дальше можно обслуживать текущего клиента в этом потоке, и создать новый поток, ожидающий нового клиента.
Но здесь у нас другая реализация. Мы заранее создаем пул из нескольких потоков, и все они ждут клиентов. После завершения работы с одним клиентом сокет не закрывается, а ждет нового. Таким образом, вместо того, чтобы постоянно открывать новые сокеты и закрывать старые, мы используем пул долгоживущих сокетов.
Это эффективнее при большом количестве клиентов. Во-первых, из-за того, что мы быстрее принимаем соединения. Во-вторых, из-за того, что мы более аккуратно распоряжаемся сокетами как системным ресурсом.
Потоки принадлежат эрланговской ноде, и мы можем создавать их сколько угодно. Но сокеты принадлежат операционной системе. Их количество лимитировано, хотя и довольно большое. (Речь идет о лимите на количество файловых дескрипторов, которое операционная система позволяет открыть пользовательскому процессу, обычно это 2 10 - 2 16).
Размер пула у нас игрушечный -- 5 пар поток-сокет. Реально нужен пул из нескольких сотен таких пар. Хорошо бы еще иметь возможность увеличивать и уменьшать этот пул в рантайме, чтобы подстраиваться под текущую нагрузку.
Текущая сессия с клиентом обрабатывается в функции handle_connection/2 . Видно, что сокет работает в активном режиме, и поток получает сообщения вида {tcp, Socket, Msg} , где Msg -- это бинарные данные, пришедшие от клиента. Эти данные мы отравляет обратно клиенту, то есть, реализуем банальный эхо-сервис:)
Когда клиент закрывает соединение, поток получает сообщение {tcp_closed, _Socket} , возвращается обратно в accept/2 и ждет следующего клиента.
Вот как выглядит работа такого сервера с двумя telnet-клиентами:
$ telnet localhost 1234 Trying 127.0.0.1... Connected to localhost. Escape character is "^]". hello from client 1 hello from client 1 some message from client 1 some message from client 1 new message from client 1 new message from client 1 client 1 is going to close connection client 1 is going to close connection ^] telnet> quit Connection closed.
$ telnet localhost 1234 Trying 127.0.0.1... Connected to localhost. Escape character is "^]". hello from client 2 hello from client 2 message from client 2 message from client 2 client 2 is still active client 2 is still active but client 2 is still active but client 2 is still active and now client 2 is going to close connection and now client 2 is going to close connection ^] telnet> quit Connection closed.
2> server:start(). start server at port 1234 ok Socket #1 wait for client Socket #2 wait for client Socket #3 wait for client Socket #4 wait for client Socket #5 wait for client Socket #1, session started Socket #1 got message: <<"hello from client 1\r\n">> Socket #1 got message: <<"some message from client 1\r\n">> Socket #2, session started Socket #2 got message: <<"hello from client 2\r\n">> Socket #2 got message: <<"message from client 2\r\n">> Socket #1 got message: <<"new message from client 1\r\n">> Socket #2 got message: <<"client 2 is still active\r\n">> Socket #1 got message: <<"client 1 is going to close connection\r\n">> Socket #1, session closed Socket #1 wait for client Socket #2 got message: <<"but client 2 is still active\r\n">> Socket #2 got message: <<"and now client 2 is going to close connection\r\n">> Socket #2, session closed Socket #2 wait for client
Сервер в пассивном режиме
Это все хорошо, но хороший сервер должен работать в пассивном режиме. То есть, он должен получать данные от клиента не в виде сообщений в почтовый ящик, а вызовом gen_tcp:recv/2,3 .
Нюанс в том, что тут нужно указать, сколько данных мы хотим прочитать. А откуда сервер может знать, сколько данных ему прислал клиент? Ну, видимо, клиент сам должен сказать, сколько данных он собирается прислать. Для этого клиент сперва посылает небольшой служебный пакет, в котором указывает размер своих данных, и затем посылает сами данные.
Теперь нужно решить, сколько байт должен занимать этот служебный пакет. Если это будет 1 байт, то в него нельзя упаковать число больше 255. В 2 байта можно упаковать число 65535, в 4 байта 4294967295. 1 байт, очевидно, мало. Вполне вероятно, что клиенту будет нужно послать данных больше, чем 255 байт. Заголовок в 2 байта вполне подходит. Заголовок в 4 байта иногда бывает нужен.
Итак, клиент посылает служебный пакет размером в 2 байта, где указано, сколько данных последуют за ним, а затем сами эти данные:
Msg = <<"Hello">>,
Size = byte_size(Msg),
Header = <
Полный код клиента:
Module(client2).
-export().
start() ->
start("localhost", 1234).
start(Host, Port) ->
spawn(?MODULE, client, ).
send(Pid, Msg) ->
Pid ! {send, Msg},
ok.
stop(Pid) ->
Pid ! stop,
ok.
client(Host, Port) ->
io:format("Client ~p connects to ~p:~p~n", ),
{ok, Socket} = gen_tcp:connect(Host, Port, ),
loop(Socket).
loop(Socket) ->
receive
{send, Msg} ->
io:format("Client ~p send ~p~n", ),
Size = byte_size(Msg),
Header = <
Сервер сперва читает 2 байта, определяет размер данных и затем читает все данные:
{ok, Header} = gen_tcp:recv(Socket, 2),
<
В коде сервера функции start/0 и start/1 не изменились, остальное немного поменялось:
Server(Port) ->
io:format("start server at port ~p~n", ),
{ok, ListenSocket} = gen_tcp:listen(Port, ),
) || Id <- lists:seq(1, 5)],
timer:sleep(infinity),
ok.
accept(Id, ListenSocket) ->
io:format("Socket #~p wait for client~n", ),
{ok, Socket} = gen_tcp:accept(ListenSocket),
io:format("Socket #~p, session started~n", ),
handle_connection(Id, ListenSocket, Socket).
handle_connection(Id, ListenSocket, Socket) ->
case gen_tcp:recv(Socket, 2) of
{ok, Header} -> <
Пример сессии со стороны клиента:
2> Pid = client2:start(). Client <0.40.0> connects to "localhost":1234 <0.40.0> 3> client2:send(Pid, <<"Hello">>). Client <0.40.0> send <<"Hello">> ok Client <0.40.0> got message: <<"Hello">> 4> client2:send(Pid, <<"Hello again">>). Client <0.40.0> send <<"Hello again">> ok Client <0.40.0> got message: <<"Hello again">> 5> client2:stop(Pid). Client <0.40.0> closes connection and stops ok
И со стороны сервера:
2> server2:start(). start server at port 1234 ok Socket #1 wait for client Socket #2 wait for client Socket #3 wait for client Socket #4 wait for client Socket #5 wait for client Socket #1, session started Socket #1 got message: <<"Hello">> Socket #1 got message: <<"Hello again">> Socket #1, session closed Socket #1 wait for client
Все это хорошо, но на самом деле нет необходимости вручную разбираться с заголовочным пакетом. Это уже реализовано в gen_tcp . Нужно указать размер служебного пакета в настройках при открытии сокета на стороне клиента:
{ok, Socket} = gen_tcp:connect(Host, Port, ),
и на стороне сервера:
{ok, ListenSocket} = gen_tcp:listen(Port, ),
и необходимость самому формировать и разбирать эти заголовки пропадает.
На стороне клиента упрощается отправка:
Gen_tcp:send(Socket, Msg),
и на стороне сервера упрощается получение:
Handle_connection(Id, ListenSocket, Socket) -> case gen_tcp:recv(Socket, 0) of {ok, Msg} -> io:format("Socket #~p got message: ~p~n", ), gen_tcp:send(Socket, Msg), handle_connection(Id, ListenSocket, Socket); {error, closed} -> io:format("Socket #~p, session closed ~n", ), accept(Id, ListenSocket) end.
Теперь при вызове gen_tcp:recv/2 мы указываем Length = 0. gen_tcp сам знает, сколько байт нужно прочитать из сокета.
Работа с текстовыми протоколами
Кроме варианта со служебным заголовком, есть и другой подход. Можно читать из сокета по одному байту, пока не встретится специальный байт, символизирующий конец пакета. Это может быть нулевой байт, или символ перевода строки.
Такой вариант характерен для текстовых протоколов (SMTP, POP3, FTP).
Писать свою реализацию чтения из сокета нет необходимости, все уже реализовано в gen_tcp . Нужно только указать в настройках сокета вместо {packet, 2} опцию {packet, line} .
{ok, ListenSocket} = gen_tcp:listen(Port, ),
В остальном код сервера остается без изменений. Но теперь мы можем опять вернуться к telnet-клиенту.
$ telnet localhost 1234 Trying 127.0.0.1... Connected to localhost. Escape character is "^]". hello hello hello again hello again ^] telnet> quit Connection closed.
TCP-сервер, текстовый протокол и telnet-клиент нам понадобятся в курсовой работе.