Кратко о содержании сегодняшнего поста:
Неблокирующий сервер
Протокол
Полезные ссылки
В прошлый раз мы остановились на многопоточном блокирующем сервером.
Сегодня же мы рассмотрим неблокирующий однопоточный сервер.
В этой статье мы уйдем больше в сторону Java, хотя реализация такого сервера возможна и на других языках.
Мы будем использовать Java.NIO;
К сожалению, информации о написании неблокирующего однопоточного сервера не так много как хотелось бы, но в итоге я собрал несколько полезных ссылок, где рассказывается о сервере на NIO. Ссылки будут ниже.
Сбор информации и планирование архитектуры сервера заняло у меня около 1 недели. Само же ядро сервера было написано за 4 дня.
Начнём.
Первым изменением у нас будет указать сокету, чтобы он не блокировался:
socket.cofigureBlocking(false);
теперь операции accept/write и read не будут останавливать программу.
Но теперь нет гарантии что мы что-то прочитали или что-то записали, мы сами должны за этим следить, а accept возвращает null если подключения нет.
В целом всё хорошо, но есть одно НО. Наш сервер безостановочно крутит это цикл раз за разом нагружая систему. Мы можем увести поток в сон на короткое время 4-16 мс, но есть решения намного круче.
Это селектор. Что же это такое?
Эта такая классная штука которая просыпается только тогда когда есть данные которое нужно обработать, а если их нет, то он просто блокирует выполнение.
Для работы с селектором мы должны сперва его открыть, а после подписывать на него интересующие нас сокеты + указать ключ на какие событие мы подписываемся:
Selector selector = Selector.open();
selector.register(socket, OP_READ | OP_WRITE);
Есть 4 ключа:
OP_READ - чтение
OP_WRITE - записать
OP_CONNECT - если сокет подключился к удалённому сокету(только клиент)
OP_ACCEPT - если удалённый сокет подключился к нашему(только сервер)
Ключи можно комбинировать записывая их через '|'.
Работает эта фишка за счёт битовых операции.
Код сервера теперь выглядит так:
Очень важно: client.socket().setTcpNoDelay(true);
отключает алгоримт Нейгла, чтобы уменьшить задержку передачи данных.
Код клиента выглядит примерно также, за исключением создания сокета и ключа в селекторе.
Написав такие реализации я стал думать как их грамотно объединить.
Я не хотел дублировать код в игре 2 раза: один раз для синглплеера, второй для мультиплеера.
Поэтому решил сделать такой хитрый ход конём.
Синглплеером будет локальный сервер запущенный в этой же программе.
В чём же его особенность? А в том что к нему нельзя будет подключится из вне, только если игрок сам этого не захочет.
Я стал думать как это сделать и родилась такая концепция.
Класс SocketBase - основа работы с селектором
от него наследуются:
SocketClient
SocketServer
Но оказалось всё не так просто, сервер обрабатывает на одном селекторе сразу много подключении, а клиент только одно, код чтения и записи дублируется, к тому же нет возможности локально подключится, не подключая сервер к порту.
Тогда я разделил это вот так:
ServerBase
---ServerLocal
---ServerNetwork
ClientBase
---ClientLocal
---ClientNetwork
И тут тоже оказалось всё не так гладко, код также дублировался, хотя и было исправлена проблема с локальной игрой(без настоящего подключения к сети).
Я внимательно обдумал концепцию того что я хочу сделать и тут меня осенило
А ведь серверу вообще не обязательно знать работает он с подключением из сети или локально.
Так родилась идея каналов:
Channel
---ChannelLocal
---ChannelNetwork
Суть в том что все каналы могут принимать и отправлять данные, а как они это делают уже скрыто реализацией.
Но чтобы сетевые каналы работали я создал специальный класс Network, именно он занимается ChannelNetwork. Создается он в отдельном потоке и работает через селектор.
Network
---NetworkClient
---NetworkServer
Что позволяет создавать сервер локально, а если нужно чтобы к нему могли подключится из вне, то я просто пишу server.bind(порт), в этот момент создается NetworkServer внутри сервера и начинает обрабатывать сетевые подключения.
Это всё позволяет играть в синглплеер, а в любом удобный момент открыть сервер для сети.
Хоть такая возможность и есть, но я не уверен что буду её делать.
Но хостить сервер из игры точно можно будет, только через меню.
ChannelLocal работает достаточно просто, он просто связывает память клиента и сервера, через буферы пакетов.
Поэтому когда сервер что-то записывает в локальный канал, эти данные сразу попадают клиенту игнорируя сокеты и прочие вещи, то же самое и с клиентом.
Следовательно пинг равен нулю, так как передача данных моментальна.
Проще говоря мой сервер может работать не подключаясь к сети. Само собой если сервер запустили как отдельное приложение, то он подключается к сети.
Финальная структура выглядит так:
Протокол
Чтобы как-то обмениваться данными между клиентом и сервером нам нужны правила.
Так как отправляет и принимаем мы байты, нам нужно как-то их разделять.
Есть в целом 3 популярных метода:
1)Фиксированный размер данных.
2)Специальный маркер конца сообщения(пакета).
3)Запись длинны в начало сообщения.
1 способ неудобен тем что мы не можем передавать данные разного размера(к примеру чат).
Хорошо подходит для очень маленьких игр и если нет желания делать свой протокол.
2 способ, такой маркер случайно можно отправить передавая обычные данные.
Такой способ отлично подходит под текст, но мы будем передавать кучу разных данных поэтому нам он не подходит.
3 способ нам подходит больше всего.
Структура пакетов и игре выглядит так.
Длинна пакеты - 2 байта
ID пакета - 2 байта
Данные - N байт
Длинна записывается в байтах и равна 2 байта(ID пакета) + N байт(данные).
Следовательно максимальный размер пакета 65 535(т.к. 2 байта = 16 бит = 65 535).
Тоже самое и для ID пакета 65 535 различных вариантов пакетов.
Примеры пакетов:
<id> <название>
<данные>
1 Login
int protocol;
String username;
3 ConnectionApproved
int playerID;
33 TileSet
int x, y, id;
На момент написания статьи в игре уже 29 пакетов, нумерация идет не по порядку, и представлены они в виде такой структуры: