Шаг 1. Ищем код эхо-сервера
Если скомпилировать исходники и запустить в отдельных консолях клиент и сервер, то можно обмениваться сообщениями. У этого сервера есть один значительный недостаток: он работает только с одним клиентом. Попробуем запустить в еще одной консоли новый клиент и увидим, как она зависнет. То же самое будет и с четвертым, и с пятым клиентом.
Шаг 2. Реализуем прием нескольких соединений
Чтобы принять несколько одновременных соединений, необходимо:
- функцию приема соединения
conn.Accept()
заключить еще в один циклfor
. - весь код, который был в цикле, вынести в отдельную функцию
process()
. - запустить функцию
process()
как отдельную горутину в циклеfor
сразу после приема соединенияconn.Accept()
В результате небольших изменений наш код примет следующий вид:
Код в main:
Если мы запустим в одной консоли сервер, а в нескольких других консолях программу клиента, то можно видеть, что сервер может обрабатывать уже несколько соединений с клиентами параллельно. На самом деле он их обрабатывает асинхронно: сперва один запрос, потом другой, осуществляя переключения между горутинами.
Шаг 3. Обрабатываем ошибки соединений
Давайте попробуем отсоединить один из клиентов, убив его процесс: наш сервер зациклится. Если что-нибудь набрать в клиенте, то данные куда-то уходят, и клиент ни о чём не подозревает.
Мы забыли обработать ошибки ввода-вывода. Функция вывода в сокет Write имеет два выходных параметра: кол-во считанных байт и ошибку:
Если ошибка не пустая (т.е. не равна nil
), значит мы не смогли принять данные. Какая ошибка произошла, можно узнать с помощью функции err.Error()
Заменим conn.Write(b []byte)
на следующий код:
Аналогичный код пропишем в клиенте. Еще в клиенте отсутствует отложенное закрытие соединения, которое срабатывает при выходе из функции defer conn.Close()
.
Теперь при закрытие клиента или сервера, у нас будет выдаваться сообщение:
Шаг 4. Простой прототип мессенджера
К этому моменту мы допилили эхо-сервер, а теперь осталось сделать простой из него мессенджер. Пусть логика мессенджера будет следующей:
- Клиент коннектится к серверу и получает номер сообщения. Каждай клиент имеет свой уникальный номер.
- Клиент отправляет сообщение серверу с указанием номера клиента, кому адресовано это сообщение.
- Сервер принимает сообщение от клиента, декодирует его и отправляет тому клиенту, которому адресовано это сообщение.
Введем счетчик входящих соединений, а каждое новое соединение сохраним в xeштаблице, организовав таким образом пул:
Каждое соединение после conn.Accept()
мы сохраним в conns,
а в функцию process()
будем передавать весь пул (хештаблицу) и номер текущего соединения. В функции обработки соединения process()
мы можем иметь доступ ко всем активным соединениям. Не забываем увеличивать на единицу счетчик текущих соединений.
В функции process()
мы принимаем не текущее соединение, а пул и номер текущего соединения. Следовательно, чтоб получить доступ к текущему соединению, мы можем его взять из пула:
Новый код сервера:
При тестировании мы видим, что в каждом ответном сообщении сервер возвращает клиенту номер текущего соединения:
Шаг 5. Реализация протокола обмена
Пример:
Для реализации этого протокола, необходимо сделать парсинг сообщения. Номер клиента, мы вытащим, используя fmt.Scanf()
, а само сообщение с использованием слайса:
Дальше все очень просто: зная номер соединения (clientNo
) клиента, мы будем отправлять ответ в нужное соединение. Сообщение было немного изменено, и теперь мы выводим, от какого клиента оно исходит:
Шаг 6. Распараллеливание кода
Выход довольно простой: нужно чтение из сокета и чтение с консоли сделать асинхронными, т.е. сделать так, чтобы ввод из сокета и ввод из консоли не блокировали друг друга. Как говорилось выше, горутины позволяют коду выполняться асинхронно. Следовательно, должны быть запущены две разные горутины: одна должна осуществлять чтение с консоли, а вторая – чтение из сокета.
Первая горутина читает данные из сокета, и если в сокете есть данные, выводит их на печать. Чтение и вывод должны быть заключены в цикл:
Со второй горутиной все немного сложнее, поскольку она должна передать данные в основную программу. Почему нельзя сразу писать их в сокет, как это делает первая горутина? Увы, операция conn.Write()
– блокирующая, и если мы так сделаем, то можем заблокировать другие операции ввода-вывода. Все блокирующие операции нужно разнести по разным асинхронным частям программы.
Основная программа должна запустить две асинхронных горутины: чтение с консоли и из сокета (в цикле читать канал и если в нем есть данные, то записать их в сокет). Чтобы наше консольное приложение не «съело» все ресурсы CPU, необходимо ввести некоторую задержку: time.Sleep(time.Seconds * 2)
Должно получиться примерно следующее:
Шаг 7. Повышаем надежность выполнения кода
Наше приложение должно работать при любых входных данных, даже если они некорректные. Есть несколько простых правил, которые придется соблюдать при построении любых приложений:
- необходимо проверять все входящие данные и если их длинна больше принимающего буфера, то либо обрезать их, либо генерировать ошибку;
- необходимо обрабатывать все функции ввода-вывода на возможность возникновения ошибки.
Например, в коде было много сокращений и специально была опущена обработка функций conn.Accept()
и net.Dial()
:
Также был опущен код обработки объема данных с консоли:
Почти готовое и работоспособное решение можно найти в репозитории: вам остается самостоятельно дописать обработку всех ошибок ввода-вывода. Если возникнет желание сделать pull request в репозиторий, то я смогу указать в комментариях на ошибки или просто похвалить. Сделайте свой код достоянием общественности. Удачи!
Комментарии