Пишем мессенджер на Go за час 7 простых шагов от эхо-сервера к асинхронному обмену сообщениями

Пишем мессенджер на Go за час 7 простых шагов от эхо-сервера к асинхронному обмену сообщениями

Если скомпилировать исходники и запустить в отдельных консолях клиент и сервер, то можно обмениваться сообщениями. У этого сервера есть один значительный недостаток: он работает только с одним клиентом. Попробуем запустить в еще одной консоли новый клиент и увидим, как она зависнет. То же самое будет и с четвертым, и с пятым клиентом.

Шаг 2. Реализуем прием нескольких соединений

Чтобы принять несколько одновременных соединений, необходимо:

  • функцию приема соединения conn.Accept() заключить еще в один цикл for .
  • весь код, который был в цикле, вынести в отдельную функцию process() .
  • запустить функцию process() как отдельную горутину в цикле for сразу после приема соединения conn.Accept()

В результате небольших изменений наш код примет следующий вид:

Если мы запустим в одной консоли сервер, а в нескольких других консолях программу клиента, то можно видеть, что сервер может обрабатывать уже несколько соединений с клиентами параллельно. На самом деле он их обрабатывает асинхронно: сперва один запрос, потом другой, осуществляя переключения между горутинами.

Шаг 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 в репозиторий, то я смогу указать в комментариях на ошибки или просто похвалить. Сделайте свой код достоянием общественности. Удачи!