Всем привет! В нескольких статьях я хотел бы поделиться опытом создания подобия ММО игры используя Unreal Engine и Netty. Возможно архитектура и мой опыт кому-то пригодится и поможет начать создавать свой игровой сервер в противовес unreal dedicated server, который слегка прожорлив или заменить собой фреймворки для разработки многопользовательских игр такие как Photon.
В конечном итоге у нас будет клиент, который логинится или регистрируется в игре, может создавать игровые комнаты, пользоваться чатом и начинать игры, соединение будет зашифровано, клиенты будут синхронизироваться через сервер, в игре будет присутствовать одно оружие — лазер, выстрел будет проверяться на проверочном сервере. Я не стремился сделать красивую графику, тут будет только необходимый минимум, дальнейший функционал добавляется по аналогии. Логику можно легко расширить на сервере, добавить например случайные игры и балансер. Для меня было важно создать ММО базу и разобраться с тем что понадобится для создания полноценной мобильной ММО игры.
Часть 1. Общая картина, сборка библиотек, подготовка клиента и сервера к обмену сообщениями
Часть 2. Наращивание игрового функционала + алгоритм Diamond Square
В начале я опишу в общих чертах, а затем мы напишем всё шаг за шагом. Общение клиент сервер построено на сокетах, формат обмена сообщениями Protobuf, каждое сообщение после входа в игру шифруется с помощью алгоритма AES используя библиотеку OpenSSL на клиенте и javax.crypto* на сервере, обмен ключами происходит с помощью протокола Диффи-Хеллмана. В качестве асинхронного сервера используется Netty, данные будем хранить в MySQL и использовать для выборки Hibernate. Я ставил целью поддержку игры на Android, поэтому мы уделим немного внимания портированию под эту платформу. Я назвал проект Spiky — колючий, и неспроста:
As a primarily C++ programmer, Unreal Engine 4 isn't «fun» to develop with.
Если я что то пропустил или что-то не сходится смело обращайтесь к исходникам:
Spiky source code
В конечном счёте вот что у нас получится:
Начнем с того как происходит общение между клиентом и сервером. Оба обладают MessageDecoder и DecryptHandler, это точки входа для сообщений, после чтения пакета, сообщения дешифруются, определяется их тип и по типу отправляются на какой-то обработчик. Точка выхода MessageEncoder и EncryptHandler, клиента и сервера соответственно. Когда мы в Netty отправляем сообщение, оно будет проходить через EncryptHandler. Тут принимается решение нужно ли шифровать, и как обёртывать.
Каждое сообщение, оборачивается в протобаф Wrapper, получатель проверяет что внутри Wrapper, для выбора обработчика, это может быть CryptogramWrapper — шифрованные байты или открытые сообщения. Сообщение Wrapper будет выглядеть примерно так (часть его):
Весь обмен сообщениями построен на принципе Decoder-Encoder, если нам надо добавить новую команду в игру, нужно обновить условия. Например клиент хочет зарегистрироваться, сообщение попадает в MessageEncoder, где шифруется, оборачивается и отправляется на сервер. На сервере сообщение поступает на DecryptHandler, дешируется если надо, читается тип по наличию у сообщения полей и отправляется на обработку
Для того чтобы найти поле в сообщении используя .hasField, нам понадобится набор дескрипторов (registration_cw, login_cw) мы их будем хранить отдельно в классе Descriptors.
Итак, если нам нужен новый функционал, то мы
1. Создаём новый тип Protobuf сообщения, вкладываем его в Wrapper/CryptogramWrapper
2. Объявляем поля к которым нужен доступ в дескрипторах клиента и сервера
3. Создаём класс логики в который после определения типа отправляем сообщение
4. Добавляем условие определяющее новый тип в Decode-Encoder клиента и сервера
5. Обрабатываем
Эта ключевой момент который придётся повторять множество раз.
В этом проекте я использовал протокол TCP, конечно лучше писать свою надстроку над UDP, что я и пробовал делать вначале, но всё что у меня выходило, было похоже на TCP единственный минус которого, в моей ситуации, невозможность отключить подтверждение пакетов, TCP ждёт подтверждения, прежде чем продолжить отправку, это создаёт задержки, и добиться пинга меньше 100 будет сложно, если пакет будет потерян при передаче по сети, игра останавливается и ждет, пока пакет не будет доставлен повторно. К сожалению, изменить такое поведение TCP никак нельзя, да и не надо, так как в нем и заключается смысл TCP. Выбор типа сокетов полностью зависит от жанра игры, в играх жанра action важно не то что происходило секунду назад, а важно наиболее актуальное состояние игрового мира. Нам нужно, чтобы данные доходили от клиента к серверу как можно быстрее, и мы не хотим ждать повторной отправки данных. Вот почему не следует использовать TCP для многопользовательских игр.
Но если мы хотим сделать reliable udp нас ждут трудности, нам нужно реализовать упорядоченность, возможность включения отключения подтверждения доставки, контроль загруженности канала, отправку больших сообщений, больше 1400 байт. Action игры должны использовать UDP для тех кто хочет почитать про это подробнее советую начать с этих статей и книги:
Сетевое программирование для разработчиков игр. Часть 1: UDP vs. TCP
Реализация Reliable Udp протокола для .Net
Джошуа Глейзер – Многопользовательские игры. Глава 7 задержки, флуктуация и надёжность.
Мне нужно было надёжное, последовательное соединение, для передачи команд, зашифрованных сообщений и файлов (капча). TCP даёт мне такие возможности из коробки. Для передачи игровых данных, часто обновляемых и не очень важных, таких как перемещение игроков, UDP лучший вариант, я добавил возможность отправки UDP сообщений для полноты и чтобы было с чего начать, но в этом проекте всё общение будет происходить посредством TCP. Возможно стоит использовать TCP и UDP совместно? Однако тогда увеличивается количество потерянных UDP пакетов, так как TCP приоритетнее. UDP остался в области дальнейших улучшений. В этой статье я следую принципу «Done in better when pefect»
В основе сервера лежит Netty, он берет на себя работу с сокетами, реализуя удобную архитектуру. Можно подключить несколько обработчиков для входящих данных. В первом обработчике мы десериализируем входящее сообщение используя ProtobufDecoder, а далее обрабатываем непосредственно игровые данные. При этом можно гибко управлять настройками самой библиотеки, выделять ей необходимое число потоков или памяти. C помощью Netty можно быстро и просто написать любое клиент-серверное приложение, которое будет легко расширяться и масштабироваться. Если для обработки клиентов не хватает одного потока, следует всего лишь передать нужное число потоков в конструктор EventLoopGroup. Если на какой-то стадии развития проекта понадобится дополнительная обработка данных, не нужно переписывать код, достаточно добавить новый обработчик в ChannelPipeline, что значительно упрощает поддержку приложения.
Общая архитектура при использовании Netty у нас выглядит так:
Плюс такого подхода в том что сервер и обработчики можно разнести по разным машинам получив кластер для расчетов игровых данных, получаем довольно гибкую структуру. Пока нагрузки маленькие можно держать все на одном сервере. При возрастании нагрузки логику можно выделить в отдельную машину.
Для проверки попаданий я создал специальный Unreal Engine клиент, задача которого принимать параметры выстрела, размещать объект в мире, на основе того где он был в момент выстрела, симулировать выстрел возвращая основному серверу информацию о попадании, имя объекта перекрытия, кость если есть, или же что промахнулись.
Я старался писать подробно, но многое вынес под спойлер.
Создадим пустой проект с кодом назовём его Spiky. Первым делом удалим созданный по умолчанию GameMode (это класс, определяющий правила текущей игры, может быть переопределен для каждого конкретного уровня, чем мы далее воспользуемся, существует только один экземпляр GameMode) – удалим Spiky_ClientGameModeBase созданный автоматически. Далее откроем Spiky_Client.Build.cs, это часть Unreal Build System в котором мы подключаем различные модули, сторонние библиотеки а так же настраиваем различные сборочные переменные, по умолчанию начиная с версии 4.16 используется режим SharedPCH (Sharing precompiled headers), а так же Include-What-You-Use (IWYU), позволяющий не включать тяжелые заголовки Engine.h. В предыдущих версиях Unreal Engine большая часть функциональности движка была включена через файлы с заголовком модуля, такие как Engine.h и UnrealEd.h, а время компиляции зависело от того, как быстро эти файлы могли быть скомпилированы через Precompiled Header (PCH). По мере роста движка это стало узким местом.
IWYU Reference Guide
В Spiky_Client.Build.cs мы видим
Работает хорошо на быстрых машинах с ssd (для работы с unreal – must have иначе головная боль, еще советую отключить IntelliSense и использовать вместо него VisualAssist) но не обладающим ssd машинам, для удобства и скорости я посоветовал бы переключиться на другой режим, который меньше пишет на диск, что мы и сделаем, включив PCHUsageMode.Default тем самым отключив генерацию Precompiled Header.
Все возможные значения PCHUsage:
Сейчас наш файл содержит следующее:
В чем отличие PublicDependencyModuleNames от PrivateDependencyModuleNames? В Unreal проектах желательно использовать Source/Public и Source/Private для заголовков-интерфейсов и исходного кода, тогда PublicDependencyModuleNames будут доступны в Public и Private папках, но PrivateDependencyModuleNames будет доступен только в папке Private. Разные другие параметры сборки можно изменить переопределив BuildConfiguration.xml, все параметры можно узнать тут:
Configuring Unreal Build System
Двигаемся дальше, добавим вне игровой GameMode для экранов логина, регистрации и главного меню.
Задачей SpikyGameMode будет создание верной ссылки на мир. Мир это объект верхнего уровня, представляющий карту в которой акторы и компоненты будут существовать и визуализироваться. Позже мы создадим класс DiffrentMix унаследованный от UObject в котором будем управлять интерфейсом, для создания виджетов нужна ссылка на текущий мир, которую из классов UObject получить нельзя, поэтому мы создадим GameMode через который инициализируем DiffrentMix и передадим ему ссылку на мир.
Отдельное слово об интерфейсе, это относится к архитектуре клиента. У нас доступ ко всем виджетам, происходит через синглтон DifferentMix, все виджеты размещаются внутри WidgetsContainer, который нам понадобится чтобы размещать виджеты слоями глубину которых можно задать, корень WidgetsContainer это Canvas к сожалению я не нашел способ изменять порядок виджетов используя Viewport. Это удобно когда нужно например чтобы чат гарантированно был поверх всего остального. Для этого выставляем его виджету максимальную глубину (приоритет) у нас в программе mainMenuChatSlot->SetZOrder(10), однако приоритет может быть любой.
Добавим класс DifferentMix, родитель UObject базовый класс для всех объектов, разместим в новой папке Utils Здесь мы будем хранить ссылки на виджеты, редкие функции для которых создавать свои классы было бы лишним, это синглтон через который мы будем управлять пользовательским интерфейсом.
Добавим SpikyGameInstance производный от UGameInstance класс, универсального UObject, который может хранить любые данные переносимые между уровнями. Он создается при создании игры, и существует до тех пор, пока игра не будет закрыта. Мы будем его использовать для хранения уникальных игровых данные, таких как логин игрока, id игровой сессии, ключ шифрования, так же тут мы запускаем и останавливаем потоки слушающие сокеты, и через него мы будем получать доступ к функциям DifferentMix.
Земетьте, возможно из редактора игра после добавления новых классов откажется собираться, это из-за того что мы переключились на режим который требует наличие #include «Spiky_Client.h» во всех исходных файлах, добавим его вручную и соберем через студию, дальше я не добавляю новый код через редактор, я копирую, редактирую вручную и нажимаю на Spiky_Client.uproject пкм Generate Visual Studio project files.
Вернёмся к редактору, создадим папку Maps и сохраним в ней стандартную карту, назовём её MainMap позже мы разместим на ней вращающегося меха (или выбор игрового персонажа как во многих ММО).
Откроем Project Settings → Maps & Modes и выставим созданные GameMode/GameInstance/Map как на снимке:
С подготовкой всё, начнём писать проект с сетевой части реализуем подключение к серверу, восстановление соединения при его потере, слушатели входящих сообщений и поток проверяющий доступность сервера. Главный объект на клиенте через который мы работаем с сетью, обслуживаем сокеты, будет называться SocketObject производный от UObject, добавим его в папку Net. Так как мы используем сеть, нужно добавить модули «Networking», «Sockets» в Spiky_Client.Build.cs
Добавим в заголовок SocketObject деструктор, ряд самоописуемых статических функций и нужные нам инклуды SocketSubsystem и Networking.
Теперь в исходниках, начнём с создания сокетов в InitSocket, выделим буфер, назначим локальные порты, мне известны два способа создания сокетов, один из них билдером:
Или через ISocketSubsystem:
Это базовые абстракции различных сокет интерфейсов, специфичных для конкретной платформы. Так как мы задаём адрес где-то в файле конфигурации или коде строкой нам следует привести его в нужный вид, для этого используем FIPv4Address::Parse, после чего подключаемся и вызываем bIsConnection = Alive(); Метод отправляет пустые сообщения серверу, если доходят значит связь есть. Напоследок создадим UDP сокет с помощью FUdpSocketBuilder, итоговый вид InitSocket должен быть таким:
Закрываем сокеты и удаляем их в деструкторе
Текущее состояние SocketObject такое:
Займемся методом отправкой Alive сообщений, форматом сообщений и сервером. В основе сервера я использовал ассинхронный фреймворк Netty написанный на java. Основное преимущество которого проста чтения и записи в сокеты. Netty поддерживает неблокирующий асинхронный ввод-вывод, легко масштабируется, что важно для онлайн игры, если ваша система должна иметь возможность обрабатывать многие тысячи соединений одновременно. И что тоже важно — Netty легко использовать.
Создадим сервер, тут пользуемся IntelliJ IDEA, создаём Maven проект:
Добавляем необходимые нам зависимости, Netty
Теперь разберёмся с форматом сериализации сообщений. Мы используем Protobuf. Размер сообщения выходит предельно малым, и судя по графикам он во всем превосходит JSON.
*взято отсюда, хороший материал, с примерами протобафа и разными метриками
Для того чтобы определить структуру сериализуемых данных, необходимо создать .proto-файл с исходным кодом этой структуры например:
После чего эта структура данных, компилируется в классы специальным компилятором, protoc, команда компиляции выглядит так:
У протобафа хорошая документация которая лучше поможет понять значение каждого поля.
Протобаф реализован для Java и C++ используемым нашим проектом. Добавим еще одну зависимость:
Теперь нужно добавить поддержку протобафа в Unreal это уже не так просто, для начала получаем ветку с github. Теперь нужно правильно собрать, инструкцию как собрать через Visual Studio можно найти тут. Выставить тип линковки для анриала «Filter through to Configuration Properties > C/C++ > Code Generation > Runtime Library, from the drop down list select Multi-threaded DLL (/MD)» смотрите Linking Static Libraries Using The Build System и собрать libprotobuf.lib. После добавим в проект, создадим в корне папку ThirdParty/Protobuf в которой нужно создать Libs и Includes. Поместить /protobuf-3.0.0-beta-4/cmake/build/solution/Release/libprotobuf.lib в Libs. Поместить /proto-install/include/google в Includes.
Так как моя цель была в поддержке мобильных устройств, нам понадобится собрать библиотеку еще и для Android с помощью Android NDK, список файлов для компиляции можно взять тут, в начале lite, потом остальное. Сам процесс выглядит так, установите Android NDK, создайте папку jni поместите в них два файла Android.mk и Application.mk, там же создайте src в которую скопируйте src из protobuf-3.0.0-beta-4/src и воспользуйтесь ndk-build. Готовые файлы Application.mk и Android.mk:
В случае успеха мы получим «сошку» /android/proto/libs/armeabi-v7a — libprotobuf.so. Скопируем её в проект /Spiky/Spiky_Client/Source/Spiky_Client/armv7.
Советую тестировать функционал внешних библиотек, перед добавлением в проект, отдельно, вне Unreal, это значительно быстрее.
Пока отложим подключение protobuf, скомпилируем OpenSSL чтобы больше не возвращаться к этой теме и не повторяться. Я использую OpenSSL-1.0.2k. Чтобы собрать библиотеку, воспользуйтесь этим руководством (Building the 64-bit static libraries with debug symbols). Пару советов если возникнут трудности:
Что до компиляции под андроид, проще всего это делать из под Ubuntu, воспользовавшись скриптами для armv7 и x86 которые вы можете найти в исходниках проекта.
OpenSSL Android
How to add a shared library (.so) in android project
Скопируем сошку в Source/Spiky_Client/armv7, библиотеки, заголовки в ThirdParty/OpenSSL и скомпилируем.
Подключаем библиотеки в Spiky_Client.Build.cs. Для удобства добавим две функциии ModulePath и ThirdPartyPath, первая возвращает путь к проекту, вторая к папке с подключаемыми библиотеками.
Специфично для каждой платформы мы добавляем библиотеку и заголовки. При компиляции выбирается необходимая платформе библиотека:
Чтобы добавить сошки в сборку нужно создать APL.xml (AndroidPluginLanguage) файл в папке с исходниками, в котором описывается откуда и куда должны быть скопированы библиотеки, и под какую платформу armv7, x86. Примеры и другие параметры можно глянуть тут.
Когда мы добавляем скомпилированные .proto сообщения, анриал выдаёт различные предупреждения, отключить которые можно либо разбираясь с исходникам движка, либо подавить их. Для этого создадим DisableWarnings.proto и скомпилируем
Все наши протобафы мы помещаем в папку Protobufs (Source/Spiky_Client/Protobufs), но лучше настроить автоматичекое размещение сгенерированных файлов, указав полные пути в --cpp_out=. --java_out=.
Едем дальше, настроим Spiky сервер!
Создаём пакет com.spiky.server и добавляем класс ServerMain, входная точка нашего сервера, тут мы будем хранить глобальные переменные, инициализируем и запустим два Netty сервера для tcp и udp соединений (но напомню в проекте используется только tcp). Нам определённо понадобится файл конфигурации, где мы могли бы хранить порты серверов (сервера логики – Netty и проверочного на Unreal), а так же возможность включать отключать криптографию. В папке Recources создадим configuration.properties.
Добавим в ServerMain инициализацию сервера, и чтение файла настроек:
Как работает Netty, на простых примерах эхо сервера, с объясненями можно найти в документации. Еще очень советую прочитать книгу Netty in Action, она небольшая.
Наш сервер почти готов к запуску, добавим ServerInitializer для обоих протоколов:
Создадим два пакета
Pipeline это то через что проходит каждое сообщение, содержит список ChannelHandlers, который обрабатывают входящие и исходящие сообщения. Например один из обработчиков может принимать только строковые данные, другой протобаф, если мы вызовем write(string) то вызовется обработчик для строк, в котором мы решим обрабатывать сообщение дальше, отправить в другой обработчик соответствующий новому типу или отправить клиенту. У каждого обработчика есть тип определяющий для каких он сообщений — входящих или исходящих.
Добавим стандартный обработчик отладки в ServerInitializer, который весьма полезен, можно посмотреть размер входящих сообщений и в каком виде они представлены, так же адресат:
Обработка протобаф сообщений присланных по TCP отличается от присланных по UDP, у Netty есть заготовленные обработчики для протобафа, но работают они только для потоковых соединений таких как TCP, когда мы отправляем сообщение мы должны знать где закончить читать, поэтому в начале каждого сообщения должна идти его длина, затем само тело. Начнём с UDP, добавим и протестируем прием и отправку сообщений сервером и клиентом. Добавим обработчик отладки в ServerInitializer, затем создадим пакет com.spiky.server.udp.handlers. Добавим в него public class ProtoDecoderHandler extends SimpleChannelInboundHandler. ChannelInboundHandlerAdapter, позволяет явным образом обрабатывать только определенные типы входящих сообщений. Например ProtoDecoderHandler обрабатывает только сообщения типа DatagramPacket.
Добавим сюда же PackageHandler — класс с логикой, после декодирования (а далее нам надо будет декодировать и расшифровать) сюда приходят сообщения используемого нами протобаф формата public class PackageHandler extends SimpleChannelInboundHandler<MessageModels.Wrapper>
MessageModels это класс-обёртка верхнего уровня, который будет содержать шифрованные и нешифрованные данные. Все сообщения оборачивается в него, вот его конечный вид, некоторые типы нам еще не знакомы:
Когда мы отправляем сообщение, принимающая сторона читает обёртку и смотрит какие у неё есть поля. Логина, регистрация? А может зашифрованные байты cryptogramWrapper? Тем самым выбирая поток исполнения.
Давайте определим и опишем все протобаф модели в нашем проекте чтобы больше на это не отвлекаться.
DisableWarnings — пустой протобаф, задача которого лишь в том чтобы отключать предупреждения.
MessageModels — содержит в себе главную обёртку Wrapper, внутри которой могут быть нешифрованные сообщения Utility, InputChecking, Registration, Login и шифрованные CryptogramWrapper. CryptogramWrapper содержит зашифрованные байты, например после того как мы обменялись ключами и начали шифровать данные, эти данные присваиваются как одно из полей CryptogramWrapper. Получатель получил, проверил есть ли зашифрованные данные, расшифровал, определил тип по имени поля и отправил дальше на обработку.
UtilityModels — единственная задача этой модели отправлять alive сообщения.
RegLogModels — содержит модели необходимые для регистрации и входа, а так же проверки пользовательского ввода и получении капчи с сервера.
MainMenuModels — данные необходимые нам в главном меню, здесь только чат.
GameRoomModels — всё что надо для создания и обновления игровых комнат.
GameModels — модель для игры, позиция игрока, параметры выстрела, начальное состояние, пинг.
Все модели вы можете найти в Spiky/Spiky_Protospace.
Чтобы определить тип сообщения и как оно должно обрабатываться, мы узнаём что в нем по наличию именованных полей:
И чтобы не захламлять код создадим отдельные классы с набором дескрипторов, добавьте на клиенте и на сервере в Utils класс Descriptors.
Сейчас нам нужно отправить с клиента на сервер Utility которое содержит единственное поле alive принимающее всегда значение true, тип bool позволит использовать минимальный размер сообщения, хочу заметить, чтобы отправить сообщение с полем false его нужно обернуть oneof v1 { bool alive = 1; }, если поле со значение false или ноль, считается что оно отсутствует, когда мы получим сообщение и захотим узнать есть ли alive, мы не сможем узнать это false или же поля просто нет (если поля нет, это сигнал к каким-то действиям, например). Так же мы всегда импортируем DisableWarnings чтобы отключить предупреждения. Каждое протобаф сообщение имеет свой класс, чтобы не приходилось перекомпилировать при любых изменениях всё. Сгенерируем командой классы:
Заголовок DisableWarnings обновился, не забудьте снова добавить подавление ошибок! (из файла add.txt).
Для того чтобы проект в студии обновился и увидел новые файлы, нужно нажать ПКМ на .uproject и выбрать «Generate Visual Studio project files». Теперь с класом PackageHandler все в порядке, SimpleChannelInboundHandler<MessageModels.Wrapper> найден, переопределим channelRead0 как от нас требуют, метод обрабатывающий все поступающие сообщения.
Добавим handlers в pipeline ServerInitializer:
Откроем ProtoDecoderHandler добавим exceptionCaught, метод который вызывается в случаем ошибки, удобно закрывать канал или соединение с базой данных и channelReadComplete, где мы очищаем поток после записи в него. Тут же обновим channelRead0, мы читаем пакет, переводим его в массив байт который затем собираем в сообщение с помощью parseDelimitedFrom – он читает длину, затем само сообщение. Мы не отправляем дальше по обработчику, мы отправим сообщение обратно эхом.
На клиенте реализуем слушатель и отправитель, в классе SocketObject. Нужно добавить новые функции SendByUDP и ReadDelimitedFrom, реализация в c++ которого, в отличии от java к сожалению, отсутствует.
RunUdpSocketReceiver – задаёт скорость проверки новых сообщений, делегирует входящие данные Recv. Recv – читает размер, парсит байты с помощью ReadDelimitedFrom и создаёт обёртку Wrapper. SendByUDP – отправляет по UDP, на вход подаём сообщения различных форматов, определяем что за формат внутри, оборачиваем, сериализуем, и отправляем.
Откроем SpikyGameMode, будем отправлять сообщения на сервер нажатие клавиши Q.
В BeginPlay добавим возможность реагировать на пользовательский ввод, установив:
В EndPlay просто добавим лог сообщение, чтобы видеть когда game mode завершается, или переключается на другой. TestSendUPDMessage – функция которая вызывается при нажатии на клавишу Q.
Откроем SpikyGameInstance и инициализируем сокеты при запуске игры, добавим функции которые вызываются с началом и завершением игры:
Нам понадобится еще один класс Config, где мы будем как на сервере хранить различные статические настройки. Создадим его (Spiky_Client/Source/Spiky_Client/Public), без родителя, поместим туда адреса, порты и флаг что криптография включена (на будущее).
Теперь инициализируем сокеты в SpikyGameInstance::Init()
Осталось только выставить в редакторе реакцию на нажатие клавиш, для этого заходим в Edit → Project Settings → Input → Action Mapping жмём + в текстовом поле пишем Q имя которое мы указали в коде, и добавляем кнопку Q, всё!
После запуска сервера и клиента, по нажатию кнопки в логе сервера благодаря LoggingHandler мы увидим примерно следующие:
В Unreal Engine:
К теме UDP мы больше возвращаться не будем. Отключим на сервере и клиенте UDP функции и создание udp_socket:
Состояние SocketObject на данный момент:
Сделаем все тоже самое для TCP. Создадим пакет Handlers и добавим туда два пустых класса DecryptHandler, EncryptHandler. Все сообщения будут приходить зашифрованными, проходить через DecryptHandler, расшифровываться и затем в зависимости от типа направляться дальше на обработку. Откроем ServerInitializer нам нужно подготовить сообщение с помощью встроенных в Netty протобаф декодеров. Добавим в pipeline протобаф инкодеры и декодеры:
Расширяем DecryptHandler extends MessageToMessageDecoder<MessageModels.Wrapper>, переопределяем метод decode и добавляем последним в pipeline:
Мы никак не обрабатываем alive сообщения на сервере, отправляем обратно эхом в DecryptHandler:
Вернёмся к отправке сообщений на клиенте. Состояние сервера мы будем проверят отправкой сообщения каждую секунду с помощью отдельного потока. В Unreal есть несколько способов создать поток, самое простое это создать таймер. Есть еще Task, созданные для небольших задач, пример с поиском простых чисел:
Implementing Multithreading in UE4
Multi-Threading: Task Graph System
Engine/Source/Runtime/Core/Public/Async/AsyncWork.h
А можно реализовать интерфейс FRunnable чем мы и займёмся.
Multi-Threading: How to Create Threads in UE4
Создадим в папке Net класс ServerStatusCheckingTh с родителем FRunnable.
Мы запускаем поток вызовом RunServerChecking(), который проходит через Init, Run и Exit. Завершаем Shutdown(). Каждую секунду мы отправляем сообщение Alive и если сообщения не доходят, пробуем переподключиться вызывая Reconnect. Реализуем Reconnect и Alive в USocketObject. Reconnect – закрывает сокет, приводит адрес в нормальный вид и снова инициализирует сокеты. Alive – создаёт сообщение и сразу же его отправляет:
Создадим папку Handlers и классы MessageDecoder и MessageEncoder производные от UObject на подобии декодера и инкодера сервера этот классы занимаются расшифрованием/шифрованием и разверткой/обёрткой входящих/исходящих сообщений. Добавим #include «MessageEncoder.h» в SocketObject и скомпилируем.
Нам нужен слушатель входящих сообщений, для этого создадим в Net класс, отдельный поток TCPSocketListeningTh с родителем FRunnable. Тут мы проверяем наличие соединения, и устанавливаем скорость работы потока, чтобы не работать зря, читаем, преобразовываем байты в protobuf, отправляем на обработку в главном игровом потоке:
Включим два новых потока в SpikyGameInstance:
Реализуем encoder, поступает сообщение типа протобаф, в функции мы определяем нужно ли шифровать, его тип, оборачиваем в Wrapper, записываем в буфер длину и тело, затем отправляем по TCP или UDP каналу:
Запустим сервер и клиент, и проверим что сообщения и эхо доходят. Лог сервера:
Лог клиента:
Итак с необходимой подготовкой мы закончили. В итоге у нас получился клиент-сервер общающийся протобаф сообщениями, скомпилированные и подключенные в Android и Windows библиотеки, и начальная архитектура поверх которой дальше мы будем наращивать функционал. Напоследок оставлю список литературы который поможет лучше разобраться с Netty и архитектурой онлайн игр.
Спасибо что дочитали до этого места!
Norman Maurer «Netty in Action» — с помощью Netty можно быстро и просто написать любое клиент-серверное приложение, которое будет легко расширяться и масштабироваться.
Josh Glazer «Multiplayer Game Programming: Architecting Networked Games» — эта книга на реальных примерах рассказывает об особенностях разработки онлайн игр и основах построения надежной многопользовательской архитектуры.
Grenville Armitage «Networking and Online Games: Understanding and Engineering Multiplayer Internet Games» — довольно старая книга, но неплохая книга в которой объясняются принципы работы многопользовательских игр.
В конечном итоге у нас будет клиент, который логинится или регистрируется в игре, может создавать игровые комнаты, пользоваться чатом и начинать игры, соединение будет зашифровано, клиенты будут синхронизироваться через сервер, в игре будет присутствовать одно оружие — лазер, выстрел будет проверяться на проверочном сервере. Я не стремился сделать красивую графику, тут будет только необходимый минимум, дальнейший функционал добавляется по аналогии. Логику можно легко расширить на сервере, добавить например случайные игры и балансер. Для меня было важно создать ММО базу и разобраться с тем что понадобится для создания полноценной мобильной ММО игры.
Часть 1. Общая картина, сборка библиотек, подготовка клиента и сервера к обмену сообщениями
Часть 2. Наращивание игрового функционала + алгоритм Diamond Square
Общая архитектура, как всё работает
В начале я опишу в общих чертах, а затем мы напишем всё шаг за шагом. Общение клиент сервер построено на сокетах, формат обмена сообщениями Protobuf, каждое сообщение после входа в игру шифруется с помощью алгоритма AES используя библиотеку OpenSSL на клиенте и javax.crypto* на сервере, обмен ключами происходит с помощью протокола Диффи-Хеллмана. В качестве асинхронного сервера используется Netty, данные будем хранить в MySQL и использовать для выборки Hibernate. Я ставил целью поддержку игры на Android, поэтому мы уделим немного внимания портированию под эту платформу. Я назвал проект Spiky — колючий, и неспроста:
As a primarily C++ programmer, Unreal Engine 4 isn't «fun» to develop with.
Если я что то пропустил или что-то не сходится смело обращайтесь к исходникам:
Spiky source code
В конечном счёте вот что у нас получится:
Начнем с того как происходит общение между клиентом и сервером. Оба обладают MessageDecoder и DecryptHandler, это точки входа для сообщений, после чтения пакета, сообщения дешифруются, определяется их тип и по типу отправляются на какой-то обработчик. Точка выхода MessageEncoder и EncryptHandler, клиента и сервера соответственно. Когда мы в Netty отправляем сообщение, оно будет проходить через EncryptHandler. Тут принимается решение нужно ли шифровать, и как обёртывать.
Каждое сообщение, оборачивается в протобаф Wrapper, получатель проверяет что внутри Wrapper, для выбора обработчика, это может быть CryptogramWrapper — шифрованные байты или открытые сообщения. Сообщение Wrapper будет выглядеть примерно так (часть его):
message CryptogramWrapper {
bytes registration = 1;
}
message Wrapper {
Utility utility = 1;
CryptogramWrapper cryptogramWrapper = 2;
}
Весь обмен сообщениями построен на принципе Decoder-Encoder, если нам надо добавить новую команду в игру, нужно обновить условия. Например клиент хочет зарегистрироваться, сообщение попадает в MessageEncoder, где шифруется, оборачивается и отправляется на сервер. На сервере сообщение поступает на DecryptHandler, дешируется если надо, читается тип по наличию у сообщения полей и отправляется на обработку
if(wrapper.hasCryptogramWrapper())
{
if(wrapper.getCryptogramWrapper().hasField(registration_cw))
{
byte[] cryptogram = wrapper.getCryptogramWrapper().getRegistration().toByteArray();
byte[] original = cryptography.Decrypt(cryptogram, cryptography.getSecretKey());
RegModels.Registration registration = RegModels.Registration.parseFrom(original);
new Registration().saveUser(ctx, registration);
}
else if (wrapper.getCryptogramWrapper().hasField(login_cw)) {}
}
Для того чтобы найти поле в сообщении используя .hasField, нам понадобится набор дескрипторов (registration_cw, login_cw) мы их будем хранить отдельно в классе Descriptors.
Итак, если нам нужен новый функционал, то мы
1. Создаём новый тип Protobuf сообщения, вкладываем его в Wrapper/CryptogramWrapper
2. Объявляем поля к которым нужен доступ в дескрипторах клиента и сервера
3. Создаём класс логики в который после определения типа отправляем сообщение
4. Добавляем условие определяющее новый тип в Decode-Encoder клиента и сервера
5. Обрабатываем
Эта ключевой момент который придётся повторять множество раз.
В этом проекте я использовал протокол TCP, конечно лучше писать свою надстроку над UDP, что я и пробовал делать вначале, но всё что у меня выходило, было похоже на TCP единственный минус которого, в моей ситуации, невозможность отключить подтверждение пакетов, TCP ждёт подтверждения, прежде чем продолжить отправку, это создаёт задержки, и добиться пинга меньше 100 будет сложно, если пакет будет потерян при передаче по сети, игра останавливается и ждет, пока пакет не будет доставлен повторно. К сожалению, изменить такое поведение TCP никак нельзя, да и не надо, так как в нем и заключается смысл TCP. Выбор типа сокетов полностью зависит от жанра игры, в играх жанра action важно не то что происходило секунду назад, а важно наиболее актуальное состояние игрового мира. Нам нужно, чтобы данные доходили от клиента к серверу как можно быстрее, и мы не хотим ждать повторной отправки данных. Вот почему не следует использовать TCP для многопользовательских игр.
Но если мы хотим сделать reliable udp нас ждут трудности, нам нужно реализовать упорядоченность, возможность включения отключения подтверждения доставки, контроль загруженности канала, отправку больших сообщений, больше 1400 байт. Action игры должны использовать UDP для тех кто хочет почитать про это подробнее советую начать с этих статей и книги:
Сетевое программирование для разработчиков игр. Часть 1: UDP vs. TCP
Реализация Reliable Udp протокола для .Net
Джошуа Глейзер – Многопользовательские игры. Глава 7 задержки, флуктуация и надёжность.
Мне нужно было надёжное, последовательное соединение, для передачи команд, зашифрованных сообщений и файлов (капча). TCP даёт мне такие возможности из коробки. Для передачи игровых данных, часто обновляемых и не очень важных, таких как перемещение игроков, UDP лучший вариант, я добавил возможность отправки UDP сообщений для полноты и чтобы было с чего начать, но в этом проекте всё общение будет происходить посредством TCP. Возможно стоит использовать TCP и UDP совместно? Однако тогда увеличивается количество потерянных UDP пакетов, так как TCP приоритетнее. UDP остался в области дальнейших улучшений. В этой статье я следую принципу «Done in better when pefect»
В основе сервера лежит Netty, он берет на себя работу с сокетами, реализуя удобную архитектуру. Можно подключить несколько обработчиков для входящих данных. В первом обработчике мы десериализируем входящее сообщение используя ProtobufDecoder, а далее обрабатываем непосредственно игровые данные. При этом можно гибко управлять настройками самой библиотеки, выделять ей необходимое число потоков или памяти. C помощью Netty можно быстро и просто написать любое клиент-серверное приложение, которое будет легко расширяться и масштабироваться. Если для обработки клиентов не хватает одного потока, следует всего лишь передать нужное число потоков в конструктор EventLoopGroup. Если на какой-то стадии развития проекта понадобится дополнительная обработка данных, не нужно переписывать код, достаточно добавить новый обработчик в ChannelPipeline, что значительно упрощает поддержку приложения.
Общая архитектура при использовании Netty у нас выглядит так:
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
/* отладка */
//pipeline.addLast(new LoggingHandler(LogLevel.INFO));
/* разворачиваем сообщения */
// Decoders protobuf
pipeline.addLast(new ProtobufVarint32FrameDecoder());
pipeline.addLast(new ProtobufDecoder(MessageModels.Wrapper.getDefaultInstance()));
/* оборачиваем сообщения */
// Encoder protobuf
pipeline.addLast(new ProtobufVarint32LengthFieldPrepender());
pipeline.addLast(new ProtobufEncoder());
/* Соединение закрывается если не было входящих сообщений в течении 30 секунд */
pipeline.addLast(new IdleStateHandler(30, 0, 0));
/* зашифруем исходящее сообщение */
pipeline.addLast(new EncryptHandler());
/* расшифруем входящее сообщение */
pipeline.addLast(new DecryptHandler());
}
}
Плюс такого подхода в том что сервер и обработчики можно разнести по разным машинам получив кластер для расчетов игровых данных, получаем довольно гибкую структуру. Пока нагрузки маленькие можно держать все на одном сервере. При возрастании нагрузки логику можно выделить в отдельную машину.
Для проверки попаданий я создал специальный Unreal Engine клиент, задача которого принимать параметры выстрела, размещать объект в мире, на основе того где он был в момент выстрела, симулировать выстрел возвращая основному серверу информацию о попадании, имя объекта перекрытия, кость если есть, или же что промахнулись.
Начнём с нуля
Я старался писать подробно, но многое вынес под спойлер.
Создадим пустой проект с кодом назовём его Spiky. Первым делом удалим созданный по умолчанию GameMode (это класс, определяющий правила текущей игры, может быть переопределен для каждого конкретного уровня, чем мы далее воспользуемся, существует только один экземпляр GameMode) – удалим Spiky_ClientGameModeBase созданный автоматически. Далее откроем Spiky_Client.Build.cs, это часть Unreal Build System в котором мы подключаем различные модули, сторонние библиотеки а так же настраиваем различные сборочные переменные, по умолчанию начиная с версии 4.16 используется режим SharedPCH (Sharing precompiled headers), а так же Include-What-You-Use (IWYU), позволяющий не включать тяжелые заголовки Engine.h. В предыдущих версиях Unreal Engine большая часть функциональности движка была включена через файлы с заголовком модуля, такие как Engine.h и UnrealEd.h, а время компиляции зависело от того, как быстро эти файлы могли быть скомпилированы через Precompiled Header (PCH). По мере роста движка это стало узким местом.
IWYU Reference Guide
В Spiky_Client.Build.cs мы видим
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
Работает хорошо на быстрых машинах с ssd (для работы с unreal – must have иначе головная боль, еще советую отключить IntelliSense и использовать вместо него VisualAssist) но не обладающим ssd машинам, для удобства и скорости я посоветовал бы переключиться на другой режим, который меньше пишет на диск, что мы и сделаем, включив PCHUsageMode.Default тем самым отключив генерацию Precompiled Header.
Все возможные значения PCHUsage:
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PCHUsage = PCHUsageMode.UseSharedPCHs;
PCHUsage = PCHUsageMode.NoSharedPCHs;
PCHUsage = PCHUsageMode.Default;
Сейчас наш файл содержит следующее:
Spiky_Client.Build.cs
using UnrealBuildTool;
public class Spiky_Client : ModuleRules
{
public Spiky_Client(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.Default;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
В чем отличие PublicDependencyModuleNames от PrivateDependencyModuleNames? В Unreal проектах желательно использовать Source/Public и Source/Private для заголовков-интерфейсов и исходного кода, тогда PublicDependencyModuleNames будут доступны в Public и Private папках, но PrivateDependencyModuleNames будет доступен только в папке Private. Разные другие параметры сборки можно изменить переопределив BuildConfiguration.xml, все параметры можно узнать тут:
Configuring Unreal Build System
Незначительные настройки редактора для удобства
Включим мелкие иконки, отображение frame rate и потребление памяти:
General->Miscellaneous->Performance->Show Frame Rate and Memory
General->User Interface->Use Small Tool Bar Icons
General->Miscellaneous->Performance->Show Frame Rate and Memory
General->User Interface->Use Small Tool Bar Icons
Двигаемся дальше, добавим вне игровой GameMode для экранов логина, регистрации и главного меню.
Добавление SpikyGameMode
File->New C++ Class->Game Mode Base назовём SpikyGameMode, выберем public и создадим папку GameModes. Конечный путь должен выглядеть так:
Spiky/Spiky_Client/Source/Spiky_Client/Public/GameModes
Spiky/Spiky_Client/Source/Spiky_Client/Public/GameModes
Задачей SpikyGameMode будет создание верной ссылки на мир. Мир это объект верхнего уровня, представляющий карту в которой акторы и компоненты будут существовать и визуализироваться. Позже мы создадим класс DiffrentMix унаследованный от UObject в котором будем управлять интерфейсом, для создания виджетов нужна ссылка на текущий мир, которую из классов UObject получить нельзя, поэтому мы создадим GameMode через который инициализируем DiffrentMix и передадим ему ссылку на мир.
Отдельное слово об интерфейсе, это относится к архитектуре клиента. У нас доступ ко всем виджетам, происходит через синглтон DifferentMix, все виджеты размещаются внутри WidgetsContainer, который нам понадобится чтобы размещать виджеты слоями глубину которых можно задать, корень WidgetsContainer это Canvas к сожалению я не нашел способ изменять порядок виджетов используя Viewport. Это удобно когда нужно например чтобы чат гарантированно был поверх всего остального. Для этого выставляем его виджету максимальную глубину (приоритет) у нас в программе mainMenuChatSlot->SetZOrder(10), однако приоритет может быть любой.
Добавим класс DifferentMix, родитель UObject базовый класс для всех объектов, разместим в новой папке Utils Здесь мы будем хранить ссылки на виджеты, редкие функции для которых создавать свои классы было бы лишним, это синглтон через который мы будем управлять пользовательским интерфейсом.
Добавим SpikyGameInstance производный от UGameInstance класс, универсального UObject, который может хранить любые данные переносимые между уровнями. Он создается при создании игры, и существует до тех пор, пока игра не будет закрыта. Мы будем его использовать для хранения уникальных игровых данные, таких как логин игрока, id игровой сессии, ключ шифрования, так же тут мы запускаем и останавливаем потоки слушающие сокеты, и через него мы будем получать доступ к функциям DifferentMix.
Расположение новых классов
Spiky_Client/Source/Spiky_Client/Private/GameModes/SpikyGameMode.h
Spiky_Client/Source/Spiky_Client/Private/Utils/DifferentMix.h
Spiky_Client/Source/Spiky_Client/Private/SpikyGameInstance.h
Spiky_Client/Source/Spiky_Client/Public/GameModes/SpikyGameMode.cpp
Spiky_Client/Source/Spiky_Client/Public/Utils/DifferentMix.cpp
Spiky_Client/Source/Spiky_Client/Public/SpikyGameInstance.cpp
Spiky_Client/Source/Spiky_Client/Private/Utils/DifferentMix.h
Spiky_Client/Source/Spiky_Client/Private/SpikyGameInstance.h
Spiky_Client/Source/Spiky_Client/Public/GameModes/SpikyGameMode.cpp
Spiky_Client/Source/Spiky_Client/Public/Utils/DifferentMix.cpp
Spiky_Client/Source/Spiky_Client/Public/SpikyGameInstance.cpp
Земетьте, возможно из редактора игра после добавления новых классов откажется собираться, это из-за того что мы переключились на режим который требует наличие #include «Spiky_Client.h» во всех исходных файлах, добавим его вручную и соберем через студию, дальше я не добавляю новый код через редактор, я копирую, редактирую вручную и нажимаю на Spiky_Client.uproject пкм Generate Visual Studio project files.
Вернёмся к редактору, создадим папку Maps и сохраним в ней стандартную карту, назовём её MainMap позже мы разместим на ней вращающегося меха (или выбор игрового персонажа как во многих ММО).
Откроем Project Settings → Maps & Modes и выставим созданные GameMode/GameInstance/Map как на снимке:
Сетевая часть
С подготовкой всё, начнём писать проект с сетевой части реализуем подключение к серверу, восстановление соединения при его потере, слушатели входящих сообщений и поток проверяющий доступность сервера. Главный объект на клиенте через который мы работаем с сетью, обслуживаем сокеты, будет называться SocketObject производный от UObject, добавим его в папку Net. Так как мы используем сеть, нужно добавить модули «Networking», «Sockets» в Spiky_Client.Build.cs
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Networking", "Sockets" });
Добавим в заголовок SocketObject деструктор, ряд самоописуемых статических функций и нужные нам инклуды SocketSubsystem и Networking.
SocketObject.h
// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Networking.h"
#include "SocketSubsystem.h"
#include "SocketObject.generated.h"
/**
* Главный сетевой объект, создаёт сокет, отвечает за подключение-отключение и т.п
*/
UCLASS()
class SPIKY_CLIENT_API USocketObject : public UObject
{
GENERATED_BODY()
~USocketObject();
public:
// tcp
static FSocket* tcp_socket;
// tcp адрес сервера
static TSharedPtr<FInternetAddr> tcp_address;
// состояние соединения
static bool bIsConnection;
// переподключиться если соединение потерянно
static void Reconnect();
// проверить онлайн ли сервер
static bool Alive();
// udp
static FSocket* udp_socket;
// udp адрес сервера
static TSharedPtr<FInternetAddr> udp_address;
// мы не создаём отдельный поток для UDP сокет слушателя, у unreal имеется FUdpSocketReceiver, создадим и делегируем входящие сообщения на ф-ю
static FUdpSocketReceiver* UDPReceiver;
static void Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt);
static void RunUdpSocketReceiver();
static int32 tcp_local_port;
static int32 udp_local_port;
// инициализируем сокеты когда запускаем игру, в GameInstance
static void InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port);
};
Теперь в исходниках, начнём с создания сокетов в InitSocket, выделим буфер, назначим локальные порты, мне известны два способа создания сокетов, один из них билдером:
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking()
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
Или через ISocketSubsystem:
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
Это базовые абстракции различных сокет интерфейсов, специфичных для конкретной платформы. Так как мы задаём адрес где-то в файле конфигурации или коде строкой нам следует привести его в нужный вид, для этого используем FIPv4Address::Parse, после чего подключаемся и вызываем bIsConnection = Alive(); Метод отправляет пустые сообщения серверу, если доходят значит связь есть. Напоследок создадим UDP сокет с помощью FUdpSocketBuilder, итоговый вид InitSocket должен быть таким:
USocketObject::InitSocket
void USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
// tcp
/* пример FTcpSocketBuilder
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking() // Socket connect always success. Non blocking you say socket connect dont wait for response (Don?t block) so it will return true.
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
}
Закрываем сокеты и удаляем их в деструкторе
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
delete tcp_socket;
delete udp_socket;
}
Текущее состояние SocketObject такое:
SocketObject.cpp
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "SocketObject.h"
FSocket* USocketObject::tcp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::tcp_address = nullptr;
bool USocketObject::bIsConnection = false;
FSocket* USocketObject::udp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::udp_address = nullptr;
FUdpSocketReceiver* USocketObject::UDPReceiver = nullptr;
int32 USocketObject::tcp_local_port = 0;
int32 USocketObject::udp_local_port = 0;
USocketObject::~USocketObject()
{
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
delete tcp_socket;
delete udp_socket;
}
}
void USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
/*
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking() // Socket connect always success. Non blocking you say socket connect dont wait for response (Don?t block) so it will return true.
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
// tcp
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
// create a proper FInternetAddr representation
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
// parse server address
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
// and set
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
// set the initial connection state
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
}
void USocketObject::RunUdpSocketReceiver()
{
}
void USocketObject::Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt)
{
}
void USocketObject::Reconnect()
{
}
bool USocketObject::Alive()
{
return false;
}
Займемся методом отправкой Alive сообщений, форматом сообщений и сервером. В основе сервера я использовал ассинхронный фреймворк Netty написанный на java. Основное преимущество которого проста чтения и записи в сокеты. Netty поддерживает неблокирующий асинхронный ввод-вывод, легко масштабируется, что важно для онлайн игры, если ваша система должна иметь возможность обрабатывать многие тысячи соединений одновременно. И что тоже важно — Netty легко использовать.
Создадим сервер, тут пользуемся IntelliJ IDEA, создаём Maven проект:
<groupId>com.spiky.server</groupId>
<artifactId>Spiky server</artifactId>
Добавляем необходимые нам зависимости, Netty
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.8.Final</version>
</dependency>
</dependencies>
Теперь разберёмся с форматом сериализации сообщений. Мы используем Protobuf. Размер сообщения выходит предельно малым, и судя по графикам он во всем превосходит JSON.
Сравнение размера
Сравнение производительности
*взято отсюда, хороший материал, с примерами протобафа и разными метриками
Для того чтобы определить структуру сериализуемых данных, необходимо создать .proto-файл с исходным кодом этой структуры например:
syntax = "proto3";
message Player {
string player_name = 1;
string team = 2;
int32 health = 3;
PlayerPosition playerPosition = 4;
}
message PlayerPosition {}
После чего эта структура данных, компилируется в классы специальным компилятором, protoc, команда компиляции выглядит так:
./protoc --cpp_out=. --java_out=. GameModels.proto
У протобафа хорошая документация которая лучше поможет понять значение каждого поля.
Протобаф реализован для Java и C++ используемым нашим проектом. Добавим еще одну зависимость:
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.0.0-beta-4</version>
</dependency>
Теперь нужно добавить поддержку протобафа в Unreal это уже не так просто, для начала получаем ветку с github. Теперь нужно правильно собрать, инструкцию как собрать через Visual Studio можно найти тут. Выставить тип линковки для анриала «Filter through to Configuration Properties > C/C++ > Code Generation > Runtime Library, from the drop down list select Multi-threaded DLL (/MD)» смотрите Linking Static Libraries Using The Build System и собрать libprotobuf.lib. После добавим в проект, создадим в корне папку ThirdParty/Protobuf в которой нужно создать Libs и Includes. Поместить /protobuf-3.0.0-beta-4/cmake/build/solution/Release/libprotobuf.lib в Libs. Поместить /proto-install/include/google в Includes.
Так как моя цель была в поддержке мобильных устройств, нам понадобится собрать библиотеку еще и для Android с помощью Android NDK, список файлов для компиляции можно взять тут, в начале lite, потом остальное. Сам процесс выглядит так, установите Android NDK, создайте папку jni поместите в них два файла Android.mk и Application.mk, там же создайте src в которую скопируйте src из protobuf-3.0.0-beta-4/src и воспользуйтесь ndk-build. Готовые файлы Application.mk и Android.mk:
Application.mk
APP_OPTIM := release
APP_ABI := armeabi-v7a #x86 x86_64
APP_STL := gnustl_static
NDK_TOOLCHAIN_VERSION := clang
APP_CPPFLAGS += -D GOOGLE_PROTOBUF_NO_RTTI=1
APP_CPPFLAGS += -D __ANDROID__=1
APP_CPPFLAGS += -D HAVE_PTHREAD=1
Android.mk
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := libprotobuf
LOCAL_SRC_FILES :=\
src/google/protobuf/arena.cc \
src/google/protobuf/arenastring.cc \
src/google/protobuf/extension_set.cc \
src/google/protobuf/generated_message_util.cc \
src/google/protobuf/io/coded_stream.cc \
src/google/protobuf/io/zero_copy_stream.cc \
src/google/protobuf/io/zero_copy_stream_impl_lite.cc \
src/google/protobuf/message_lite.cc \
src/google/protobuf/repeated_field.cc \
src/google/protobuf/stubs/bytestream.cc \
src/google/protobuf/stubs/common.cc \
src/google/protobuf/stubs/int128.cc \
src/google/protobuf/stubs/once.cc \
src/google/protobuf/stubs/status.cc \
src/google/protobuf/stubs/statusor.cc \
src/google/protobuf/stubs/stringpiece.cc \
src/google/protobuf/stubs/stringprintf.cc \
src/google/protobuf/stubs/structurally_valid.cc \
src/google/protobuf/stubs/strutil.cc \
src/google/protobuf/stubs/time.cc \
src/google/protobuf/wire_format_lite.cc \
src/google/protobuf/any.cc \
src/google/protobuf/any.pb.cc \
src/google/protobuf/api.pb.cc \
src/google/protobuf/compiler/importer.cc \
src/google/protobuf/compiler/parser.cc \
src/google/protobuf/descriptor.cc \
src/google/protobuf/descriptor.pb.cc \
src/google/protobuf/descriptor_database.cc \
src/google/protobuf/duration.pb.cc \
src/google/protobuf/dynamic_message.cc \
src/google/protobuf/empty.pb.cc \
src/google/protobuf/extension_set_heavy.cc \
src/google/protobuf/field_mask.pb.cc \
src/google/protobuf/generated_message_reflection.cc \
src/google/protobuf/io/gzip_stream.cc \
src/google/protobuf/io/printer.cc \
src/google/protobuf/io/strtod.cc \
src/google/protobuf/io/tokenizer.cc \
src/google/protobuf/io/zero_copy_stream_impl.cc \
src/google/protobuf/map_field.cc \
src/google/protobuf/message.cc \
src/google/protobuf/reflection_ops.cc \
src/google/protobuf/service.cc \
src/google/protobuf/source_context.pb.cc \
src/google/protobuf/struct.pb.cc \
src/google/protobuf/stubs/mathlimits.cc \
src/google/protobuf/stubs/substitute.cc \
src/google/protobuf/text_format.cc \
src/google/protobuf/timestamp.pb.cc \
src/google/protobuf/type.pb.cc \
src/google/protobuf/unknown_field_set.cc \
src/google/protobuf/util/field_comparator.cc \
src/google/protobuf/util/field_mask_util.cc \
src/google/protobuf/util/internal/datapiece.cc \
src/google/protobuf/util/internal/default_value_objectwriter.cc \
src/google/protobuf/util/internal/error_listener.cc \
src/google/protobuf/util/internal/field_mask_utility.cc \
src/google/protobuf/util/internal/json_escaping.cc \
src/google/protobuf/util/internal/json_objectwriter.cc \
src/google/protobuf/util/internal/json_stream_parser.cc \
src/google/protobuf/util/internal/object_writer.cc \
src/google/protobuf/util/internal/proto_writer.cc \
src/google/protobuf/util/internal/protostream_objectsource.cc \
src/google/protobuf/util/internal/protostream_objectwriter.cc \
src/google/protobuf/util/internal/type_info.cc \
src/google/protobuf/util/internal/type_info_test_helper.cc \
src/google/protobuf/util/internal/utility.cc \
src/google/protobuf/util/json_util.cc \
src/google/protobuf/util/message_differencer.cc \
src/google/protobuf/util/time_util.cc \
src/google/protobuf/util/type_resolver_util.cc \
src/google/protobuf/wire_format.cc \
src/google/protobuf/wrappers.pb.cc
LOCAL_CPPFLAGS := -std=c++11
LOCAL_LDLIBS := -llog
ifeq ($(TARGET_ARCH),x86)
LOCAL_SRC_FILES := $(LOCAL_SRC_FILES) \
src/google/protobuf/stubs/atomicops_internals_x86_gcc.cc
endif
ifeq ($(TARGET_ARCH),x86_64)
LOCAL_SRC_FILES := $(LOCAL_SRC_FILES) \
src/google/protobuf/stubs/atomicops_internals_x86_gcc.cc
endif
LOCAL_C_INCLUDES = $(LOCAL_PATH)/src
include $(BUILD_SHARED_LIBRARY)
В случае успеха мы получим «сошку» /android/proto/libs/armeabi-v7a — libprotobuf.so. Скопируем её в проект /Spiky/Spiky_Client/Source/Spiky_Client/armv7.
Возможные трудности и ошибки
Если появляется ошибка:
откройте arena.h и напишите в самом вверху
Если после включения в заголовков наших сообщений, возникает конфликт имён протобафа и анриала —
Исправленный вариант type_traits.h выглядит так:
Вообще часто встречаются проблемы совместимости, мы еще с ними столкнёмся когда будем добавлять поддержку OpenSSL и компилировать под андроид. к примеру Android NDK не полностью поддерживает С++ 11, мне нужно было получить миллисекунды я хотел использовать chrono но увы, нужно часто проводить проверки, здесь куча подводных камней.
ThirdParty/Protobuf/Includes\google/protobuf/arena.h(635,25) : error: cannot use typeid with -fno-rtti
откройте arena.h и напишите в самом вверху
#define GOOGLE_PROTOBUF_NO_RTTI
Если после включения в заголовков наших сообщений, возникает конфликт имён протобафа и анриала —
error: "error C3861: 'check': identifier not found
, проблема в совпадении имён макроса check в анриал (AssertionMacros.h), и check в протобафе (type_traits.h), к счастью check в протобафе используется очень мало и проблему легко решить подредактировав исходники, переименовав check в check_UnrealFix, например, и закомментировать #undef check. Решение подсказал вопрос на unreal answers — Error C3861 (identifier not found) when including protocol buffers.template<typename B, typename D>
struct is_base_of {
typedef char (&yes)[1];
typedef char (&no)[2];
// BEGIN GOOGLE LOCAL MODIFICATION -- check is a #define on Mac.
#undef check
// END GOOGLE LOCAL MODIFICATION
static yes check(const B*);
static no check(const void*);
enum {
value = sizeof(check(static_cast<const D*>(NULL))) == sizeof(yes),
};
};
Исправленный вариант type_traits.h выглядит так:
template<typename B, typename D>
struct is_base_of {
typedef char (&yes)[1];
typedef char (&no)[2];
// BEGIN GOOGLE LOCAL MODIFICATION -- check is a #define on Mac.
//#undef check
// END GOOGLE LOCAL MODIFICATION
static yes check_UnrealFix(const B*);
static no check_UnrealFix(const void*);
enum {
value = sizeof(check_UnrealFix(static_cast<const D*>(NULL))) == sizeof(yes),
};
};
Вообще часто встречаются проблемы совместимости, мы еще с ними столкнёмся когда будем добавлять поддержку OpenSSL и компилировать под андроид. к примеру Android NDK не полностью поддерживает С++ 11, мне нужно было получить миллисекунды я хотел использовать chrono но увы, нужно часто проводить проверки, здесь куча подводных камней.
Советую тестировать функционал внешних библиотек, перед добавлением в проект, отдельно, вне Unreal, это значительно быстрее.
Пока отложим подключение protobuf, скомпилируем OpenSSL чтобы больше не возвращаться к этой теме и не повторяться. Я использую OpenSSL-1.0.2k. Чтобы собрать библиотеку, воспользуйтесь этим руководством (Building the 64-bit static libraries with debug symbols). Пару советов если возникнут трудности:
- Найди в папке со студией ml64.exe и скопировать в папку с OpenSSL, не пользуемся NASM — это только для х32
- Используйте чистые исходники (без попыток сборки)
openssl fatal error LNK1112: module machine type 'x64' conflicts with target machine type 'X86'
— откройте Developer Command Prompt for VS2015, перейдите E:\Program Files (x86)\Microsoft Visual Studio 14.0\VC и выполните vcvarsall.bat x64 (источник)- Конфликт имён с Unreal закомментируйте 172 строчку:
openssl/ossl_typ.h(172): error C2365: 'UI': redefinition; previous definition was 'namespace'
Что до компиляции под андроид, проще всего это делать из под Ubuntu, воспользовавшись скриптами для armv7 и x86 которые вы можете найти в исходниках проекта.
OpenSSL Android
How to add a shared library (.so) in android project
Решение возможных проблем
После того как мы получили сошку, с ней нужно немного поработать, надо изменить номер версии иначе будем получать ошибку:
Где то внутри зашит номер версии, воспользуемся улитой Ubuntu для переименования:
E/AndroidRuntime( 1574): java.lang.UnsatisfiedLinkError: dlopen failed: could not load library "libcrypto.so.1.0.0" needed by "libUE4.so"; caused by library "libcrypto.so.1.0.0" not found
Где то внутри зашит номер версии, воспользуемся улитой Ubuntu для переименования:
rpl -R -e .so.1.0.0 "_1_0_0.so" /path/to/libcrypto.so
Скопируем сошку в Source/Spiky_Client/armv7, библиотеки, заголовки в ThirdParty/OpenSSL и скомпилируем.
Подключаем библиотеки в Spiky_Client.Build.cs. Для удобства добавим две функциии ModulePath и ThirdPartyPath, первая возвращает путь к проекту, вторая к папке с подключаемыми библиотеками.
public class Spiky_Client : ModuleRules
{
private string ModulePath
{
get { return ModuleDirectory; }
}
private string ThirdPartyPath
{
get { return Path.GetFullPath(Path.Combine(ModulePath, "../../ThirdParty/")); }
}
...
}
Специфично для каждой платформы мы добавляем библиотеку и заголовки. При компиляции выбирается необходимая платформе библиотека:
Spiky_Client.Build.cs
// Copyright (c) 2017, Vadim Petrov - MIT License
using UnrealBuildTool;
using System.IO;
using System;
public class Spiky_Client : ModuleRules
{
private string ModulePath
{
get { return ModuleDirectory; }
}
private string ThirdPartyPath
{
get { return Path.GetFullPath(Path.Combine(ModulePath, "../../ThirdParty/")); }
}
public Spiky_Client(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.Default;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Networking", "Sockets" });
PrivateDependencyModuleNames.AddRange(new string[] { "UMG", "Slate", "SlateCore" });
string IncludesPath = Path.Combine(ThirdPartyPath, "Protobuf", "Includes");
PublicIncludePaths.Add(IncludesPath);
IncludesPath = Path.Combine(ThirdPartyPath, "OpenSSL", "Includes");
PublicIncludePaths.Add(IncludesPath);
if ((Target.Platform == UnrealTargetPlatform.Win64))
{
string LibrariesPath = Path.Combine(ThirdPartyPath, "Protobuf", "Libs");
PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "libprotobuf.lib"));
LibrariesPath = Path.Combine(ThirdPartyPath, "OpenSSL", "Libs");
PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "libeay32.lib"));
}
if (Target.Platform == UnrealTargetPlatform.Android)
{
string BuildPath = Utils.MakePathRelativeTo(ModuleDirectory, BuildConfiguration.RelativeEnginePath);
AdditionalPropertiesForReceipt.Add(new ReceiptProperty("AndroidPlugin", Path.Combine(BuildPath, "APL.xml")));
PublicAdditionalLibraries.Add(BuildPath + "/armv7/libprotobuf.so");
PublicAdditionalLibraries.Add(BuildPath + "/armv7/libcrypto_1_0_0.so");
}
}
}
Чтобы добавить сошки в сборку нужно создать APL.xml (AndroidPluginLanguage) файл в папке с исходниками, в котором описывается откуда и куда должны быть скопированы библиотеки, и под какую платформу armv7, x86. Примеры и другие параметры можно глянуть тут.
APL
<?xml version="1.0" encoding="utf-8"?>
<root xmlns:android="http://schemas.android.com/apk/res/android">
<resourceCopies>
<isArch arch="armeabi-v7a">
<copyFile src="$S(PluginDir)/armv7/libcrypto_1_0_0.so"
dst="$S(BuildDir)/libs/armeabi-v7a/libcrypto_1_0_0.so" />
<copyFile src="$S(PluginDir)/armv7/libprotobuf.so"
dst="$S(BuildDir)/libs/armeabi-v7a/libprotobuf.so" />
</isArch>
</resourceCopies>
</root>
Можно протестировать работу OpenSSL для windows и android создав тестовый hud и вывести в него hash (в исходниках отсутствует)
// OpenSSL tests
#include <openssl/evp.h>
#include <sstream>
#include <iomanip>
void ADebugHUD::DrawHUD()
{
Super::DrawHUD();
FString hashTest = "Hash test (sha256): " + GetSHA256_s("test", strlen("test"));
DrawText(hashTest, FColor::White, 50, 50, HUDFont);
}
FString ADebugHUD::GetSHA256_s(const void * data, size_t data_len)
{
EVP_MD_CTX mdctx;
unsigned char md_value[EVP_MAX_MD_SIZE];
unsigned int md_len;
EVP_DigestInit(&mdctx, EVP_sha256());
EVP_DigestUpdate(&mdctx, data, (size_t)data_len);
EVP_DigestFinal_ex(&mdctx, md_value, &md_len);
EVP_MD_CTX_cleanup(&mdctx);
std::stringstream s;
s.fill('0');
for (size_t i = 0; i < md_len; ++i)
s << std::setw(2) << std::hex << (unsigned short)md_value[i];
return s.str().c_str();
}
Когда мы добавляем скомпилированные .proto сообщения, анриал выдаёт различные предупреждения, отключить которые можно либо разбираясь с исходникам движка, либо подавить их. Для этого создадим DisableWarnings.proto и скомпилируем
./protoc --cpp_out=. --java_out=. DisableWarnings.proto
затем в полученном заголовке DisableWarnings.pb.h подавим предупреждения, будем включать DisableWarnings в каждый прото файл. В DisableWarnings.proto всего три строчки, версия протобафа, имя java пакета и имя генерируемого класса. #define PROTOBUF_INLINE_NOT_IN_HEADERS 0
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
DisableWarnings.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "DisableWarnings";
DisableWarnings.pb.h
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: DisableWarnings.proto
#define PROTOBUF_INLINE_NOT_IN_HEADERS 0
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
#ifndef PROTOBUF_DisableWarnings_2eproto__INCLUDED
#define PROTOBUF_DisableWarnings_2eproto__INCLUDED
#include <string>
#include <google/protobuf/stubs/common.h>
#if GOOGLE_PROTOBUF_VERSION < 3000000
#error This file was generated by a newer version of protoc which is
#error incompatible with your Protocol Buffer headers. Please update
#error your headers.
#endif
#if 3000000 < GOOGLE_PROTOBUF_MIN_PROTOC_VERSION
#error This file was generated by an older version of protoc which is
#error incompatible with your Protocol Buffer headers. Please
#error regenerate this file with a newer version of protoc.
#endif
#include <google/protobuf/arena.h>
#include <google/protobuf/arenastring.h>
#include <google/protobuf/generated_message_util.h>
#include <google/protobuf/metadata.h>
#include <google/protobuf/repeated_field.h>
#include <google/protobuf/extension_set.h>
// @@protoc_insertion_point(includes)
// Internal implementation detail -- do not call these.
void protobuf_AddDesc_DisableWarnings_2eproto();
void protobuf_AssignDesc_DisableWarnings_2eproto();
void protobuf_ShutdownFile_DisableWarnings_2eproto();
// ===================================================================
// ===================================================================
// ===================================================================
#if !PROTOBUF_INLINE_NOT_IN_HEADERS
#endif // !PROTOBUF_INLINE_NOT_IN_HEADERS
// @@protoc_insertion_point(namespace_scope)
// @@protoc_insertion_point(global_scope)
#endif // PROTOBUF_DisableWarnings_2eproto__INCLUDED
Все наши протобафы мы помещаем в папку Protobufs (Source/Spiky_Client/Protobufs), но лучше настроить автоматичекое размещение сгенерированных файлов, указав полные пути в --cpp_out=. --java_out=.
Едем дальше, настроим Spiky сервер!
Создаём пакет com.spiky.server и добавляем класс ServerMain, входная точка нашего сервера, тут мы будем хранить глобальные переменные, инициализируем и запустим два Netty сервера для tcp и udp соединений (но напомню в проекте используется только tcp). Нам определённо понадобится файл конфигурации, где мы могли бы хранить порты серверов (сервера логики – Netty и проверочного на Unreal), а так же возможность включать отключать криптографию. В папке Recources создадим configuration.properties.
Добавим в ServerMain инициализацию сервера, и чтение файла настроек:
/* файл конфигурации */
private static final ResourceBundle configurationBundle = ResourceBundle.getBundle("configuration", Locale.ENGLISH);
/* серверные порты */
private static final int tcpPort = Integer.valueOf(configurationBundle.getString("tcpPort"));
private static final int udpPort = Integer.valueOf(configurationBundle.getString("udpPort"));
private static void run_tcp() {
EventLoopGroup bossGroup = new NioEventLoopGroup(); // 1
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap(); // 2
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // 3
.childHandler(new com.spiky.server.tcp.ServerInitializer()) // 4
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
ChannelFuture f = b.bind(tcpPort).sync(); // 5
f.channel().closeFuture().sync(); // 6
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
Полный файл, с инициализацией udp и main()
/*
* Copyright (c) 2017, Vadim Petrov - MIT License
*/
package com.spiky.server;
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import java.util.Locale;
import java.util.ResourceBundle;
public class ServerMain {
/* файл конфигурации */
private static final ResourceBundle configurationBundle = ResourceBundle.getBundle("configuration", Locale.ENGLISH);
/* серверные порты */
private static final int tcpPort = Integer.valueOf(configurationBundle.getString("tcpPort"));
private static final int udpPort = Integer.valueOf(configurationBundle.getString("udpPort"));
private static void run_tcp() {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new com.spiky.server.tcp.ServerInitializer())
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
ChannelFuture f = b.bind(tcpPort).sync();
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
private static void run_udp() {
final NioEventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioDatagramChannel.class)
.handler(new com.spiky.server.udp.ServerInitializer());
bootstrap.bind(udpPort).sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new Thread(ServerMain::run_tcp).start();
new Thread(ServerMain::run_udp).start();
}
}
- NioEventLoopGroup — это многопоточный цикл, который обрабатывает операции ввода-вывода. Netty предоставляет различные реализации EventLoopGroup для разных видов транспорта. В этом примере мы реализуем серверное приложение, и поэтому будет использоваться две NioEventLoopGroup. Первый, часто называемое «босс», принимает входящее соединение. Второй, часто называемый «рабочий», обрабатывает трафик принятого соединения, босс принимает соединение и регистрирует принятое соединение с работником. Сколько потоков используется и как они сопоставляются с создаваемыми каналами, зависит от реализации EventLoopGroup и может настраиваться через конструктор
- ServerBootstrap — это вспомогательный класс, который устанавливает сервер. Вы можете настроить сервер напрямую с помощью канала. Однако учтите, что это утомительный процесс, и вам не нужно делать это в большинстве случаев
- Здесь мы указываем использовать класс NioServerSocketChannel, который используется для создания нового канала приема входящих соединений
- Специальный Handler, который предоставляет простой способ инициализации канала после его регистрации в EventLoop. В нем мы добавляем обработчики входящих сообщений, декодеры, инкодеры и логику,
- Привязываем и начнинаем принимать входящие соединения
- Подождём, пока серверный сокет не будет закрыт
Как работает Netty, на простых примерах эхо сервера, с объясненями можно найти в документации. Еще очень советую прочитать книгу Netty in Action, она небольшая.
Наш сервер почти готов к запуску, добавим ServerInitializer для обоих протоколов:
/* Для UDP и TCP*/
public class ServerInitializer extends ChannelInitializer<NioDatagramChannel>
public class ServerInitializer extends ChannelInitializer<SocketChannel>
Создадим два пакета
com.spiky.server.tcp
и com.spiky.server.udp
в каждом из которых создадим класс ServerInitializer (с отличными NioDatagramChannel/SocketChannel) с таким содержимым:package com.spiky.server.tcp;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
}
}
Pipeline это то через что проходит каждое сообщение, содержит список ChannelHandlers, который обрабатывают входящие и исходящие сообщения. Например один из обработчиков может принимать только строковые данные, другой протобаф, если мы вызовем write(string) то вызовется обработчик для строк, в котором мы решим обрабатывать сообщение дальше, отправить в другой обработчик соответствующий новому типу или отправить клиенту. У каждого обработчика есть тип определяющий для каких он сообщений — входящих или исходящих.
Добавим стандартный обработчик отладки в ServerInitializer, который весьма полезен, можно посмотреть размер входящих сообщений и в каком виде они представлены, так же адресат:
...
ChannelPipeline pipeline = ch.pipeline();
/* отладка */
pipeline.addLast(new LoggingHandler(LogLevel.INFO));
...
Обработка протобаф сообщений присланных по TCP отличается от присланных по UDP, у Netty есть заготовленные обработчики для протобафа, но работают они только для потоковых соединений таких как TCP, когда мы отправляем сообщение мы должны знать где закончить читать, поэтому в начале каждого сообщения должна идти его длина, затем само тело. Начнём с UDP, добавим и протестируем прием и отправку сообщений сервером и клиентом. Добавим обработчик отладки в ServerInitializer, затем создадим пакет com.spiky.server.udp.handlers. Добавим в него public class ProtoDecoderHandler extends SimpleChannelInboundHandler. ChannelInboundHandlerAdapter, позволяет явным образом обрабатывать только определенные типы входящих сообщений. Например ProtoDecoderHandler обрабатывает только сообщения типа DatagramPacket.
Добавим сюда же PackageHandler — класс с логикой, после декодирования (а далее нам надо будет декодировать и расшифровать) сюда приходят сообщения используемого нами протобаф формата public class PackageHandler extends SimpleChannelInboundHandler<MessageModels.Wrapper>
MessageModels это класс-обёртка верхнего уровня, который будет содержать шифрованные и нешифрованные данные. Все сообщения оборачивается в него, вот его конечный вид, некоторые типы нам еще не знакомы:
message Wrapper {
Utility utility = 1;
InputChecking inputChecking = 2;
Registration registration = 3;
Login login = 4;
CryptogramWrapper cryptogramWrapper = 5;
}
Когда мы отправляем сообщение, принимающая сторона читает обёртку и смотрит какие у неё есть поля. Логина, регистрация? А может зашифрованные байты cryptogramWrapper? Тем самым выбирая поток исполнения.
Давайте определим и опишем все протобаф модели в нашем проекте чтобы больше на это не отвлекаться.
DisableWarnings — пустой протобаф, задача которого лишь в том чтобы отключать предупреждения.
DisableWarnings.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "DisableWarnings";
MessageModels — содержит в себе главную обёртку Wrapper, внутри которой могут быть нешифрованные сообщения Utility, InputChecking, Registration, Login и шифрованные CryptogramWrapper. CryptogramWrapper содержит зашифрованные байты, например после того как мы обменялись ключами и начали шифровать данные, эти данные присваиваются как одно из полей CryptogramWrapper. Получатель получил, проверил есть ли зашифрованные данные, расшифровал, определил тип по имени поля и отправил дальше на обработку.
MessageModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "MessageModels";
import "UtilityModels.proto";
import "RegLogModels.proto";
import "DisableWarnings.proto";
message CryptogramWrapper {
bytes registration = 1;
bytes login = 2;
bytes initialState = 3;
bytes room = 4;
bytes mainMenu = 5;
bytes gameModels = 6;
}
message Wrapper {
Utility utility = 1;
InputChecking inputChecking = 2;
Registration registration = 3;
Login login = 4;
CryptogramWrapper cryptogramWrapper = 5;
}
UtilityModels — единственная задача этой модели отправлять alive сообщения.
UtilityModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "UtilityModels";
import "DisableWarnings.proto";
message Utility {
bool alive = 1;
}
RegLogModels — содержит модели необходимые для регистрации и входа, а так же проверки пользовательского ввода и получении капчи с сервера.
RegLogModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "RegistrationLoginModels";
import "DisableWarnings.proto";
import "GameRoomModels.proto";
message InputChecking {
string login = 1;
string mail = 2;
string captcha = 3;
bool getCaptcha = 4;
bytes captchaData = 5;
oneof v1 {
bool loginCheckStatus = 6;
bool mailCheckStatus = 7;
bool captchaCheckStatus = 8;
}
}
message Login {
string mail = 1;
string hash = 2;
string publicKey = 3;
oneof v1 {
int32 stateCode = 4;
}
}
message Registration {
string login = 1;
string hash = 2;
string mail = 3;
string captcha = 4;
string publicKey = 5;
oneof v1 {
int32 stateCode = 6;
}
}
message InitialState {
string sessionId = 1;
string login = 2;
repeated CreateRoom createRoom = 3;
}
MainMenuModels — данные необходимые нам в главном меню, здесь только чат.
MainMenuModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "MainMenuModels";
import "DisableWarnings.proto";
message ChatMessage {
int64 time = 1;
string name = 2;
string text = 3;
}
message Chat {
int64 time = 1;
string name = 2;
string text = 3;
oneof v1 {
bool subscribe = 4;
}
repeated ChatMessage messages = 5;
}
message MainMenu {
Chat chat = 1;
}
GameRoomModels — всё что надо для создания и обновления игровых комнат.
GameRoomModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "GameRoomModels";
import "DisableWarnings.proto";
import "MainMenuModels.proto";
message Room {
CreateRoom createRoom = 1;
RoomsListUpdate roomsListUpdate = 2;
SubscribeRoom subscribeRoom = 3;
RoomUpdate roomUpdate = 4;
bool startGame = 5;
string roomName = 6;
}
message CreateRoom {
string roomName = 1;
string mapName = 2;
string gameTime = 3;
string maxPlayers = 4;
string creator = 5;
}
message RoomsListUpdate {
bool deleteRoom = 1;
bool addRoom = 2;
string roomName = 3;
string roomOwner = 4;
}
message SubscribeRoom {
oneof v1 {
bool subscribe = 1;
}
string roomName = 2;
int32 stateCode = 3;
RoomDescribe roomDescribe = 4;
string player = 5;
string team = 6;
}
message RoomDescribe {
repeated TeamPlayer team1 = 1;
repeated TeamPlayer team2 = 2;
repeated TeamPlayer undistributed = 3;
string roomName = 4;
string mapName = 5;
string gameTime = 6;
string maxPlayers = 7;
string creator = 8;
Chat chat = 9;
}
message TeamPlayer {
string player_name = 1;
}
message RoomUpdate {
RoomDescribe roomDescribe = 1;
string targetTeam = 2;
string roomName = 3;
}
GameModels — модель для игры, позиция игрока, параметры выстрела, начальное состояние, пинг.
GameModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "GameModels";
import "DisableWarnings.proto";
message GameInitialState {
bool startGame = 1;
repeated Player player = 2;
}
message Player {
string player_name = 1;
string team = 2;
int32 health = 3;
PlayerPosition playerPosition = 4;
}
message PlayerPosition {
Location loc = 1;
Rotation rot = 2;
message Location {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
message Rotation {
int32 Pitch = 1;
int32 Roll = 2;
int32 Yaw = 3;
}
string playerName = 3;
int64 timeStamp = 4;
}
message Ping {
int64 time = 1;
}
message Shot {
Start start = 1;
End end = 2;
PlayerPosition playerPosition = 3;
message Start {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
message End {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
int64 timeStamp = 4;
string requestFrom = 5;
string requestTo = 6;
string roomOwner = 7;
oneof v1 {
bool result_hitState = 8;
}
string result_bonename = 9;
}
message GameData {
GameInitialState gameInitialState = 1;
PlayerPosition playerPosition = 2;
Ping ping = 3;
Shot shot = 4;
}
Все модели вы можете найти в Spiky/Spiky_Protospace.
Чтобы определить тип сообщения и как оно должно обрабатываться, мы узнаём что в нем по наличию именованных полей:
// java
if(wrapper.getCryptogramWrapper().hasField(registration_cw)) //сделать что-то
// cpp
if (wrapper->cryptogramwrapper().GetReflection()->HasField(wrapper->cryptogramwrapper(), Descriptors::registration_cw)) //сделать что-то
И чтобы не захламлять код создадим отдельные классы с набором дескрипторов, добавьте на клиенте и на сервере в Utils класс Descriptors.
Descriptors.java
// Copyright (c) 2017, Vadim Petrov - MIT License
package com.spiky.server.utils;
import com.spiky.server.protomodels.*;
/**
* Разные дескрипторы для того чтобы определить содержимое сообщений
* */
public class Descriptors {
public static com.google.protobuf.Descriptors.FieldDescriptor registration_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("registration");
public static com.google.protobuf.Descriptors.FieldDescriptor login_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("login");
public static com.google.protobuf.Descriptors.FieldDescriptor initialState_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("initialState");
public static com.google.protobuf.Descriptors.FieldDescriptor room_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("room");
public static com.google.protobuf.Descriptors.FieldDescriptor mainMenu_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("mainMenu");
public static com.google.protobuf.Descriptors.FieldDescriptor gameModels_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("gameModels");
public static com.google.protobuf.Descriptors.FieldDescriptor getCaptcha_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("getCaptcha");
public static com.google.protobuf.Descriptors.FieldDescriptor login_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("login");
public static com.google.protobuf.Descriptors.FieldDescriptor mail_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("mail");
public static com.google.protobuf.Descriptors.FieldDescriptor captcha_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("captcha");
public static com.google.protobuf.Descriptors.FieldDescriptor login_reg = RegistrationLoginModels.Registration.getDefaultInstance().getDescriptorForType().findFieldByName("login");
public static com.google.protobuf.Descriptors.FieldDescriptor mail_reg = RegistrationLoginModels.Registration.getDefaultInstance().getDescriptorForType().findFieldByName("mail");
public static com.google.protobuf.Descriptors.FieldDescriptor captcha_reg = RegistrationLoginModels.Registration.getDefaultInstance().getDescriptorForType().findFieldByName("captcha");
public static com.google.protobuf.Descriptors.FieldDescriptor publicKey_reg = RegistrationLoginModels.Registration.getDefaultInstance().getDescriptorForType().findFieldByName("publicKey");
public static com.google.protobuf.Descriptors.FieldDescriptor publicKey_log = RegistrationLoginModels.Login.getDefaultInstance().getDescriptorForType().findFieldByName("publicKey");
public static com.google.protobuf.Descriptors.FieldDescriptor subscribe_chat = MainMenuModels.Chat.getDefaultInstance().getDescriptorForType().findFieldByName("subscribe");
public static com.google.protobuf.Descriptors.FieldDescriptor chat_mm = MainMenuModels.MainMenu.getDefaultInstance().getDescriptorForType().findFieldByName("chat");
public static com.google.protobuf.Descriptors.FieldDescriptor deleteRoom_room = GameRoomModels.RoomsListUpdate.getDefaultInstance().getDescriptorForType().findFieldByName("deleteRoom");
public static com.google.protobuf.Descriptors.FieldDescriptor startGame_room = GameRoomModels.Room.getDefaultInstance().getDescriptorForType().findFieldByName("startGame");
public static com.google.protobuf.Descriptors.FieldDescriptor requestTo_shot_gd = GameModels.Shot.getDefaultInstance().getDescriptorForType().findFieldByName("requestTo");
}
Descriptors.h/Descriptors.сpp
// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include <google/protobuf/descriptor.h>
class Descriptors
{
public:
static const google::protobuf::FieldDescriptor* captchaDataField_ich;
static const google::protobuf::FieldDescriptor* loginCheckStatus_ich;
static const google::protobuf::FieldDescriptor* mailCheckStatus_ich;
static const google::protobuf::FieldDescriptor* captchaCheckStatus_ich;
static const google::protobuf::FieldDescriptor* publicKey_reg;
static const google::protobuf::FieldDescriptor* stateCode_reg;
static const google::protobuf::FieldDescriptor* publicKey_log;
static const google::protobuf::FieldDescriptor* stateCode_log;
static const google::protobuf::FieldDescriptor* registration_cw;
static const google::protobuf::FieldDescriptor* login_cw;
static const google::protobuf::FieldDescriptor* initialState_cw;
static const google::protobuf::FieldDescriptor* room_cw;
static const google::protobuf::FieldDescriptor* mainMenu_cw;
static const google::protobuf::FieldDescriptor* gameModels_cw;
static const google::protobuf::FieldDescriptor* chat_mm;
static const google::protobuf::FieldDescriptor* nameField_chat;
static const google::protobuf::FieldDescriptor* player_sub;
static const google::protobuf::FieldDescriptor* player_team;
static const google::protobuf::FieldDescriptor* chat_room;
};
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "Descriptors.h"
#include "Protobufs/RegLogModels.pb.h"
#include "Protobufs/MessageModels.pb.h"
#include "Protobufs/MainMenuModels.pb.h"
const google::protobuf::FieldDescriptor* Descriptors::captchaDataField_ich = InputChecking::default_instance().descriptor()->FindFieldByName("captchaData");
const google::protobuf::FieldDescriptor* Descriptors::loginCheckStatus_ich = InputChecking::default_instance().descriptor()->FindFieldByName("loginCheckStatus");
const google::protobuf::FieldDescriptor* Descriptors::mailCheckStatus_ich = InputChecking::default_instance().descriptor()->FindFieldByName("mailCheckStatus");
const google::protobuf::FieldDescriptor* Descriptors::captchaCheckStatus_ich = InputChecking::default_instance().descriptor()->FindFieldByName("captchaCheckStatus");
const google::protobuf::FieldDescriptor* Descriptors::publicKey_reg = Registration::default_instance().descriptor()->FindFieldByName("publicKey");
const google::protobuf::FieldDescriptor* Descriptors::stateCode_reg = Registration::default_instance().descriptor()->FindFieldByName("stateCode");
const google::protobuf::FieldDescriptor* Descriptors::publicKey_log = Login::default_instance().descriptor()->FindFieldByName("publicKey");
const google::protobuf::FieldDescriptor* Descriptors::stateCode_log = Login::default_instance().descriptor()->FindFieldByName("stateCode");
const google::protobuf::FieldDescriptor* Descriptors::registration_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("registration");
const google::protobuf::FieldDescriptor* Descriptors::login_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("login");
const google::protobuf::FieldDescriptor* Descriptors::initialState_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("initialState");
const google::protobuf::FieldDescriptor* Descriptors::room_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("room");
const google::protobuf::FieldDescriptor* Descriptors::mainMenu_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("mainMenu");
const google::protobuf::FieldDescriptor* Descriptors::gameModels_cw = CryptogramWrapper::default_instance().descriptor()->FindFieldByName("gameModels");
const google::protobuf::FieldDescriptor* Descriptors::chat_mm = MainMenu::default_instance().descriptor()->FindFieldByName("chat");
const google::protobuf::FieldDescriptor* Descriptors::nameField_chat = Chat::default_instance().descriptor()->FindFieldByName("name");
const google::protobuf::FieldDescriptor* Descriptors::player_sub = SubscribeRoom::default_instance().descriptor()->FindFieldByName("player");
const google::protobuf::FieldDescriptor* Descriptors::player_team = SubscribeRoom::default_instance().descriptor()->FindFieldByName("team");
const google::protobuf::FieldDescriptor* Descriptors::chat_room = RoomDescribe::default_instance().descriptor()->FindFieldByName("chat");
Сейчас нам нужно отправить с клиента на сервер Utility которое содержит единственное поле alive принимающее всегда значение true, тип bool позволит использовать минимальный размер сообщения, хочу заметить, чтобы отправить сообщение с полем false его нужно обернуть oneof v1 { bool alive = 1; }, если поле со значение false или ноль, считается что оно отсутствует, когда мы получим сообщение и захотим узнать есть ли alive, мы не сможем узнать это false или же поля просто нет (если поля нет, это сигнал к каким-то действиям, например). Так же мы всегда импортируем DisableWarnings чтобы отключить предупреждения. Каждое протобаф сообщение имеет свой класс, чтобы не приходилось перекомпилировать при любых изменениях всё. Сгенерируем командой классы:
./protoc --cpp_out=c:/Spiky/Spiky_Client/Source/Spiky_Client/Protobufs --java_out=c:/Spiky/Spiky_Server/src/main/java *.proto
Заголовок DisableWarnings обновился, не забудьте снова добавить подавление ошибок! (из файла add.txt).
add.txt
#define PROTOBUF_INLINE_NOT_IN_HEADERS 0
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
Для того чтобы проект в студии обновился и увидел новые файлы, нужно нажать ПКМ на .uproject и выбрать «Generate Visual Studio project files». Теперь с класом PackageHandler все в порядке, SimpleChannelInboundHandler<MessageModels.Wrapper> найден, переопределим channelRead0 как от нас требуют, метод обрабатывающий все поступающие сообщения.
@Override
protected void channelRead0(ChannelHandlerContext ctx, MessageModels.Wrapper wrapper) throws Exception {
}
Добавим handlers в pipeline ServerInitializer:
public class ServerInitializer extends ChannelInitializer<NioDatagramChannel> {
@Override
protected void initChannel(NioDatagramChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
/* отладка */
pipeline.addLast(new LoggingHandler(LogLevel.INFO));
/* proto декодер */
pipeline.addLast(new ProtoDecoderHandler());
/* обработчик сообщений */
pipeline.addLast(new PackageHandler());
}
Откроем ProtoDecoderHandler добавим exceptionCaught, метод который вызывается в случаем ошибки, удобно закрывать канал или соединение с базой данных и channelReadComplete, где мы очищаем поток после записи в него. Тут же обновим channelRead0, мы читаем пакет, переводим его в массив байт который затем собираем в сообщение с помощью parseDelimitedFrom – он читает длину, затем само сообщение. Мы не отправляем дальше по обработчику, мы отправим сообщение обратно эхом.
ProtoDecoderHandler
/*
* Copyright (c) 2017, Vadim Petrov - MIT License
*/
package com.spiky.server.udp.handlers;
import com.spiky.server.protomodels.MessageModels;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.socket.DatagramPacket;
import java.io.ByteArrayInputStream;
public class ProtoDecoderHandler extends SimpleChannelInboundHandler<DatagramPacket> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket datagramPacket) throws Exception {
ByteBuf buf = datagramPacket.content();
byte[] bytes = new byte[buf.readableBytes()];
int readerIndex = buf.readerIndex();
buf.getBytes(readerIndex, bytes);
ByteArrayInputStream input = new ByteArrayInputStream(bytes);
MessageModels.Wrapper wrapper = MessageModels.Wrapper.parseDelimitedFrom(input);
System.out.println("udp: ");
System.out.println(wrapper.toString());
System.out.println(datagramPacket.sender().getAddress() + " " + datagramPacket.sender().getPort());
// отправить обратно (эхо)
ctx.write(new DatagramPacket(Unpooled.copiedBuffer(datagramPacket.content()), datagramPacket.sender()));
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
На клиенте реализуем слушатель и отправитель, в классе SocketObject. Нужно добавить новые функции SendByUDP и ReadDelimitedFrom, реализация в c++ которого, в отличии от java к сожалению, отсутствует.
SocketObject.cpp
#include "Spiky_Client.h"
#include "SocketObject.h"
#include "Protobufs/MessageModels.pb.h"
#include <google/protobuf/message.h>
#include <google/protobuf/io/zero_copy_stream_impl_lite.h>
#include <google/protobuf/io/coded_stream.h>
FSocket* USocketObject::tcp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::tcp_address = nullptr;
bool USocketObject::bIsConnection = false;
FSocket* USocketObject::udp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::udp_address = nullptr;
FUdpSocketReceiver* USocketObject::UDPReceiver = nullptr;
int32 USocketObject::tcp_local_port = 0;
int32 USocketObject::udp_local_port = 0;
USocketObject::~USocketObject()
{
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
delete tcp_socket;
delete udp_socket;
}
}
void USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
// tcp
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
// create a proper FInternetAddr representation
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
// parse server address
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
// and set
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
// set the initial connection state
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
}
void USocketObject::RunUdpSocketReceiver()
{
FTimespan ThreadWaitTime = FTimespan::FromMilliseconds(30);
UDPReceiver = new FUdpSocketReceiver(udp_socket, ThreadWaitTime, TEXT("UDP_RECEIVER"));
UDPReceiver->OnDataReceived().BindStatic(&USocketObject::Recv);
UDPReceiver->Start();
}
void USocketObject::Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt)
{
GLog->Log("Reveived UDP data");
uint8_t * buffer = ArrayReaderPtr->GetData();
size_t size = ArrayReaderPtr->Num();
GLog->Log("Size of incoming data: " + FString::FromInt(size));
google::protobuf::io::ArrayInputStream arr(buffer, size);
google::protobuf::io::CodedInputStream input(&arr);
std::shared_ptr<Wrapper> wrapper(new Wrapper);
ReadDelimitedFrom(&input, wrapper.get());
std::string msg;
wrapper->SerializeToString(&msg);
GLog->Log(msg.c_str());
}
bool USocketObject::SendByUDP(google::protobuf::Message * message)
{
Wrapper wrapper;
if (message->GetTypeName() == "Utility")
{
Utility * mes = static_cast<Utility*>(message);
wrapper.set_allocated_utility(mes);
}
size_t size = wrapper.ByteSize() + 5; // include size, varint32 never takes more than 5 bytes
uint8_t * buffer = new uint8_t[size];
google::protobuf::io::ArrayOutputStream arr(buffer, size);
google::protobuf::io::CodedOutputStream output(&arr);
output.WriteVarint32(wrapper.ByteSize());
wrapper.SerializeToCodedStream(&output);
if (wrapper.has_utility())
{
wrapper.release_utility();
}
int32 bytesSent = 0;
bool sentState = false;
sentState = udp_socket->SendTo(buffer, output.ByteCount(), bytesSent, *udp_address);
delete[] buffer;
return sentState;
}
bool USocketObject::ReadDelimitedFrom(google::protobuf::io::CodedInputStream * input, google::protobuf::MessageLite * message)
{
// Read the size.
uint32_t size;
if (!input->ReadVarint32(&size)) return false;
// Tell the stream not to read beyond that size.
google::protobuf::io::CodedInputStream::Limit limit = input->PushLimit(size);
// Parse the message.
if (!message->MergeFromCodedStream(input)) return false;
if (!input->ConsumedEntireMessage()) return false;
// Release the limit.
input->PopLimit(limit);
return true;
}
void USocketObject::Reconnect()
{
}
bool USocketObject::Alive()
{
return false;
}
RunUdpSocketReceiver – задаёт скорость проверки новых сообщений, делегирует входящие данные Recv. Recv – читает размер, парсит байты с помощью ReadDelimitedFrom и создаёт обёртку Wrapper. SendByUDP – отправляет по UDP, на вход подаём сообщения различных форматов, определяем что за формат внутри, оборачиваем, сериализуем, и отправляем.
Откроем SpikyGameMode, будем отправлять сообщения на сервер нажатие клавиши Q.
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
void TestSendUPDMessage();
В BeginPlay добавим возможность реагировать на пользовательский ввод, установив:
EnableInput(GetWorld()->GetFirstPlayerController());
InputComponent->BindAction("Q", IE_Pressed, this, &ASpikyGameMode::TestSendUPDMessage);
В EndPlay просто добавим лог сообщение, чтобы видеть когда game mode завершается, или переключается на другой. TestSendUPDMessage – функция которая вызывается при нажатии на клавишу Q.
void ASpikyGameMode::TestSendUPDMessage()
{
GLog->Log("send ->>>");
std::shared_ptr<Utility> utility(new Utility);
utility->set_alive(true);
USocketObject::SendByUDP(utility.get());
}
SpikyGameMode.cpp
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "SpikyGameMode.h"
#include "SocketObject.h"
#include "Runtime/Engine/Classes/Engine/World.h"
#include "Protobufs/UtilityModels.pb.h"
void ASpikyGameMode::BeginPlay()
{
Super::BeginPlay();
GLog->Log("AClientGameMode::BeginPlay()");
EnableInput(GetWorld()->GetFirstPlayerController());
InputComponent->BindAction("Q", IE_Pressed, this, &ASpikyGameMode::TestSendUPDMessage);
}
void ASpikyGameMode::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
GLog->Log("AClientGameMode::EndPlay()");
}
void ASpikyGameMode::TestSendUPDMessage()
{
GLog->Log("send ->>>");
std::shared_ptr<Utility> utility(new Utility);
utility->set_alive(true);
USocketObject::SendByUDP(utility.get());
}
Откроем SpikyGameInstance и инициализируем сокеты при запуске игры, добавим функции которые вызываются с началом и завершением игры:
virtual void Init() override;
virtual void Shutdown() override;
Нам понадобится еще один класс Config, где мы будем как на сервере хранить различные статические настройки. Создадим его (Spiky_Client/Source/Spiky_Client/Public), без родителя, поместим туда адреса, порты и флаг что криптография включена (на будущее).
Config.h/Config.cpp
// .h
// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include <string>
class Config
{
public:
static std::string address;
static size_t tcp_local_port;
static size_t tcp_server_port;
static size_t udp_local_port;
static size_t udp_server_port;
static bool bEnableCrypt;
};
// .cpp
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "Config.h"
bool Config::bEnableCrypt = true;
std::string Config::address = "127.0.0.1";
size_t Config::tcp_local_port = 7678;
size_t Config::tcp_server_port = 7680;
size_t Config::udp_local_port = 7679;
size_t Config::udp_server_port = 7681;
Теперь инициализируем сокеты в SpikyGameInstance::Init()
void USpikyGameInstance::Init()
{
GLog->Log("UClientGameInstance::Init()");
USocketObject::InitSocket(Config::address.c_str(), Config::tcp_local_port, Config::tcp_server_port, Config::udp_local_port, Config::udp_server_port);
// запустить поток слушающий udp сокет
USocketObject::RunUdpSocketReceiver();
}
Осталось только выставить в редакторе реакцию на нажатие клавиш, для этого заходим в Edit → Project Settings → Input → Action Mapping жмём + в текстовом поле пишем Q имя которое мы указали в коде, и добавляем кнопку Q, всё!
После запуска сервера и клиента, по нажатию кнопки в логе сервера благодаря LoggingHandler мы увидим примерно следующие:
udp:
utility {
alive: true
}
/127.0.0.1 7679
июл 10, 2017 4:42:30 PM io.netty.handler.logging.LoggingHandler channelRead
INFO: [id: 0x89373e1f, L:/0:0:0:0:0:0:0:0:7681] RECEIVED: DatagramPacket(/127.0.0.1:7679 => /0:0:0:0:0:0:0:0:7681, PooledUnsafeDirectByteBuf(ridx: 0, widx: 5, cap: 2048)), 5B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 04 0a 02 08 01 |..... |
+--------+-------------------------------------------------+----------------+
июл 10, 2017 4:42:30 PM io.netty.handler.logging.LoggingHandler write
INFO: [id: 0x89373e1f, L:/0:0:0:0:0:0:0:0:7681] WRITE: DatagramPacket(=> /127.0.0.1:7679, UnpooledUnsafeHeapByteBuf(ridx: 0, widx: 5, cap: 5)), 5B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 04 0a 02 08 01 |..... |
+--------+-------------------------------------------------+----------------+
июл 10, 2017 4:42:30 PM io.netty.handler.logging.LoggingHandler flush
INFO: [id: 0x89373e1f, L:/0:0:0:0:0:0:0:0:7681] FLUSH
В Unreal Engine:
Reveived UDP data
Size of incoming data: 5
К теме UDP мы больше возвращаться не будем. Отключим на сервере и клиенте UDP функции и создание udp_socket:
ServerMain
//new Thread(ServerMain::run_udp).start();
SpikyGameInstance
// запустить поток слушающий udp сокет
//USocketObject::RunUdpSocketReceiver();
Состояние SocketObject на данный момент:
SocketObject.cpp
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "SocketObject.h"
#include "Protobufs/MessageModels.pb.h"
FSocket* USocketObject::tcp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::tcp_address = nullptr;
bool USocketObject::bIsConnection = false;
FSocket* USocketObject::udp_socket = nullptr;
TSharedPtr<FInternetAddr> USocketObject::udp_address = nullptr;
FUdpSocketReceiver* USocketObject::UDPReceiver = nullptr;
int32 USocketObject::tcp_local_port = 0;
int32 USocketObject::udp_local_port = 0;
USocketObject::~USocketObject()
{
GLog->Log("USocketObject::~USocketObject()");
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
//UDPReceiver->Stop();
delete tcp_socket;
delete udp_socket;
}
}
void USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
/*
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking() // Socket connect always success. Non blocking you say socket connect dont wait for response (Don?t block) so it will return true.
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
// tcp
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
// create a proper FInternetAddr representation
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
// parse server address
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
// and set
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
// set the initial connection state
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
/*
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
}
void USocketObject::RunUdpSocketReceiver()
{
FTimespan ThreadWaitTime = FTimespan::FromMilliseconds(100);
UDPReceiver = new FUdpSocketReceiver(udp_socket, ThreadWaitTime, TEXT("UDP_RECEIVER"));
UDPReceiver->OnDataReceived().BindStatic(&USocketObject::Recv);
UDPReceiver->Start();
}
void USocketObject::Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt)
{
GLog->Log("Reveived UDP data");
uint8_t * buffer = ArrayReaderPtr->GetData();
size_t size = ArrayReaderPtr->Num();
GLog->Log("Size of incoming data: " + FString::FromInt(size));
google::protobuf::io::ArrayInputStream arr(buffer, size);
google::protobuf::io::CodedInputStream input(&arr);
std::shared_ptr<Wrapper> wrapper(new Wrapper);
ReadDelimitedFrom(&input, wrapper.get());
std::string msg;
wrapper->SerializeToString(&msg);
GLog->Log(msg.c_str());
}
bool USocketObject::SendByUDP(google::protobuf::Message * message)
{
Wrapper wrapper;
if (message->GetTypeName() == "Utility")
{
Utility * mes = static_cast<Utility*>(message);
wrapper.set_allocated_utility(mes);
}
size_t size = wrapper.ByteSize() + 5; // include size, varint32 never takes more than 5 bytes
uint8_t * buffer = new uint8_t[size];
google::protobuf::io::ArrayOutputStream arr(buffer, size);
google::protobuf::io::CodedOutputStream output(&arr);
output.WriteVarint32(wrapper.ByteSize());
wrapper.SerializeToCodedStream(&output);
if (wrapper.has_utility())
{
wrapper.release_utility();
}
int32 bytesSent = 0;
bool sentState = false;
sentState = udp_socket->SendTo(buffer, output.ByteCount(), bytesSent, *udp_address);
delete[] buffer;
return sentState;
}
void USocketObject::Reconnect()
{
}
bool USocketObject::Alive()
{
return false;
}
bool USocketObject::ReadDelimitedFrom(google::protobuf::io::CodedInputStream * input, google::protobuf::MessageLite * message)
{
// Read the size.
uint32_t size;
if (!input->ReadVarint32(&size)) return false;
// Tell the stream not to read beyond that size.
google::protobuf::io::CodedInputStream::Limit limit = input->PushLimit(size);
// Parse the message.
if (!message->MergeFromCodedStream(input)) return false;
if (!input->ConsumedEntireMessage()) return false;
// Release the limit.
input->PopLimit(limit);
return true;
}
Сделаем все тоже самое для TCP. Создадим пакет Handlers и добавим туда два пустых класса DecryptHandler, EncryptHandler. Все сообщения будут приходить зашифрованными, проходить через DecryptHandler, расшифровываться и затем в зависимости от типа направляться дальше на обработку. Откроем ServerInitializer нам нужно подготовить сообщение с помощью встроенных в Netty протобаф декодеров. Добавим в pipeline протобаф инкодеры и декодеры:
// Decoders protobuf
pipeline.addLast(new ProtobufVarint32FrameDecoder());
pipeline.addLast(new ProtobufDecoder(MessageModels.Wrapper.getDefaultInstance()));
// Encoder protobuf
pipeline.addLast(new ProtobufVarint32LengthFieldPrepender());
pipeline.addLast(new ProtobufEncoder());
Расширяем DecryptHandler extends MessageToMessageDecoder<MessageModels.Wrapper>, переопределяем метод decode и добавляем последним в pipeline:
/* расшифруем входящее сообщение */
pipeline.addLast(new DecryptHandler());
Мы никак не обрабатываем alive сообщения на сервере, отправляем обратно эхом в DecryptHandler:
ctx.writeAndFlush(wrapper);
Вернёмся к отправке сообщений на клиенте. Состояние сервера мы будем проверят отправкой сообщения каждую секунду с помощью отдельного потока. В Unreal есть несколько способов создать поток, самое простое это создать таймер. Есть еще Task, созданные для небольших задач, пример с поиском простых чисел:
Implementing Multithreading in UE4
Multi-Threading: Task Graph System
Engine/Source/Runtime/Core/Public/Async/AsyncWork.h
А можно реализовать интерфейс FRunnable чем мы и займёмся.
Multi-Threading: How to Create Threads in UE4
Создадим в папке Net класс ServerStatusCheckingTh с родителем FRunnable.
FServerStatusCheckingTh
// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include "Runtime/Core/Public/HAL/Runnable.h"
#include "Runtime/Core/Public/HAL/RunnableThread.h"
class SPIKY_CLIENT_API FServerStatusCheckingTh : public FRunnable
{
// Singleton instance, can access the thread any time via static accessor, if it is active!
static FServerStatusCheckingTh* Runnable;
// Thread to run the worker FRunnable on
FRunnableThread* Thread;
// The way to stop
static bool bThreadRun;
public:
FServerStatusCheckingTh();
~FServerStatusCheckingTh();
// FRunnable interface
virtual bool Init();
virtual uint32 Run();
// Logics
static FServerStatusCheckingTh* RunServerChecking();
// Shuts down the thread. Static so it can easily be called from outside the thread context
static void Shutdown();
};
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "ServerStatusCheckingTh.h"
#include "SocketObject.h"
FServerStatusCheckingTh* FServerStatusCheckingTh::Runnable = nullptr;
bool FServerStatusCheckingTh::bThreadRun = false;
FServerStatusCheckingTh::FServerStatusCheckingTh()
{
Thread = FRunnableThread::Create(this, TEXT("ServerStatusChecking"), 0, TPri_BelowNormal);
}
FServerStatusCheckingTh::~FServerStatusCheckingTh()
{
delete Thread;
Thread = nullptr;
}
bool FServerStatusCheckingTh::Init()
{
bThreadRun = true;
return true;
}
uint32 FServerStatusCheckingTh::Run()
{
while (bThreadRun)
{
FPlatformProcess::Sleep(1.f); // проверять каждую секунду
if (!USocketObject::bIsConnection) // если нет соединения, переподключиться
{
USocketObject::Reconnect();
}
else
{
USocketObject::bIsConnection = USocketObject::Alive(); // если пакеты перестают доходить, значит сервер оффлайн
}
// удобно в отладке
//GLog->Log("Connect state (bIsConnection) = " + FString::FromInt((int32)USocketObject::bIsConnection) + " | FServerStatusCheckingTh::CheckServer");
}
return 0;
}
FServerStatusCheckingTh* FServerStatusCheckingTh::RunServerChecking()
{
if (!Runnable && FPlatformProcess::SupportsMultithreading())
{
Runnable = new FServerStatusCheckingTh();
}
return Runnable;
}
void FServerStatusCheckingTh::Shutdown()
{
bThreadRun = false;
GLog->Log("FServerStatusCheckingTh::Shutdown()");
if (Runnable)
{
delete Runnable;
Runnable = nullptr;
}
}
Мы запускаем поток вызовом RunServerChecking(), который проходит через Init, Run и Exit. Завершаем Shutdown(). Каждую секунду мы отправляем сообщение Alive и если сообщения не доходят, пробуем переподключиться вызывая Reconnect. Реализуем Reconnect и Alive в USocketObject. Reconnect – закрывает сокет, приводит адрес в нормальный вид и снова инициализирует сокеты. Alive – создаёт сообщение и сразу же его отправляет:
void USocketObject::Reconnect()
{
tcp_socket->Close();
uint32 OutIP;
tcp_address->GetIp(OutIP);
FString ip = FString::Printf(TEXT("%d.%d.%d.%d"), 0xff & (OutIP >> 24), 0xff & (OutIP >> 16), 0xff & (OutIP >> 8), 0xff & OutIP);
InitSocket(ip, tcp_local_port, tcp_address->GetPort(), udp_local_port, udp_address->GetPort());
}
bool USocketObject::Alive()
{
std::shared_ptr<Utility> utility(new Utility);
utility->set_alive(true);
// Send пока отсутствует, параметры: сообщение, шифровать? По tcp?
return UMessageEncoder::Send(utility.get(), false, true);
}
Создадим папку Handlers и классы MessageDecoder и MessageEncoder производные от UObject на подобии декодера и инкодера сервера этот классы занимаются расшифрованием/шифрованием и разверткой/обёрткой входящих/исходящих сообщений. Добавим #include «MessageEncoder.h» в SocketObject и скомпилируем.
Нам нужен слушатель входящих сообщений, для этого создадим в Net класс, отдельный поток TCPSocketListeningTh с родителем FRunnable. Тут мы проверяем наличие соединения, и устанавливаем скорость работы потока, чтобы не работать зря, читаем, преобразовываем байты в protobuf, отправляем на обработку в главном игровом потоке:
FTCPSocketListeningTh
// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include "Runtime/Core/Public/HAL/Runnable.h"
#include "Runtime/Core/Public/HAL/RunnableThread.h"
class SPIKY_CLIENT_API FTCPSocketListeningTh : public FRunnable
{
FRunnableThread* Thread;
static FTCPSocketListeningTh* Runnable;
static bool bThreadRun;
public:
FTCPSocketListeningTh();
~FTCPSocketListeningTh();
virtual bool Init();
virtual uint32 Run();
static FTCPSocketListeningTh* RunSocketListening();
static void Shutdown();
};
#include "Spiky_Client.h"
#include "TCPSocketListeningTh.h"
#include "SocketObject.h"
#include "MessageDecoder.h"
#include <google/protobuf/io/zero_copy_stream_impl_lite.h>
#include <google/protobuf/io/coded_stream.h>
#include "Protobufs/MessageModels.pb.h"
#include "Async.h"
FTCPSocketListeningTh* FTCPSocketListeningTh::Runnable = nullptr;
bool FTCPSocketListeningTh::bThreadRun = false;
FTCPSocketListeningTh::FTCPSocketListeningTh()
{
Thread = FRunnableThread::Create(this, TEXT("TCP_RECEIVER"), 0, TPri_BelowNormal);
}
FTCPSocketListeningTh::~FTCPSocketListeningTh()
{
delete Thread;
Thread = nullptr;
}
bool FTCPSocketListeningTh::Init()
{
bThreadRun = true;
return true;
}
uint32 FTCPSocketListeningTh::Run()
{
while (bThreadRun)
{
// проверить наличие соединения
if (USocketObject::bIsConnection == false) // нет соединения
{
FPlatformProcess::Sleep(1.f); // скорость работы потока, медленно
}
else
{
FPlatformProcess::Sleep(0.03f);
if (!USocketObject::tcp_socket) return 0;
//Binary Array!
TArray<uint8> ReceivedData;
uint32 Size;
while (USocketObject::tcp_socket->HasPendingData(Size)) // пока есть что читать
{
ReceivedData.Init(FMath::Min(Size, 65507u), Size);
int32 Read = 0;
USocketObject::tcp_socket->Recv(ReceivedData.GetData(), ReceivedData.Num(), Read);
}
if (ReceivedData.Num() > 0)
{
GLog->Log(FString::Printf(TEXT("Data Read! %d"), ReceivedData.Num()) + " | FTCPSocketListeningTh::Run");
// преобразовать байты в protobuf
uint8_t * buffer = ReceivedData.GetData();
size_t size = ReceivedData.Num();
google::protobuf::io::ArrayInputStream arr(buffer, size);
google::protobuf::io::CodedInputStream input(&arr);
bool protosize = true;
/* в одном пакете может быть несколько протобаф сообщений, tcp накапливает если сообщения слишком маленькие */
while (protosize)
{
std::shared_ptr<Wrapper> wrapper(new Wrapper);
protosize = USocketObject::ReadDelimitedFrom(&input, wrapper.get());
/* многое должно выполняться в игровом потоке, отправить на обработку */
AsyncTask(ENamedThreads::GameThread, [wrapper]() {
UMessageDecoder * Handler = NewObject<UMessageDecoder>(UMessageDecoder::StaticClass());
Handler->SendProtoToDecoder(wrapper.get());
});
}
}
}
}
return 0;
}
FTCPSocketListeningTh* FTCPSocketListeningTh::RunSocketListening()
{
if (!Runnable && FPlatformProcess::SupportsMultithreading())
{
Runnable = new FTCPSocketListeningTh();
}
return Runnable;
}
void FTCPSocketListeningTh::Shutdown()
{
bThreadRun = false;
GLog->Log("FTCPSocketListeningTh::Shutdown()");
if (Runnable)
{
delete Runnable;
Runnable = nullptr;
}
}
Включим два новых потока в SpikyGameInstance:
...
#include "ServerStatusCheckingTh.h"
#include "TCPSocketListeningTh.h"
...
// запускаем при запуске программы
USpikyGameInstance::Init()
// запустить поток проверяющий доступность сервера
FServerStatusCheckingTh::RunServerChecking();
// запустить поток слушающий tcp сокет
FTCPSocketListeningTh::RunSocketListening();
// выключаем при закрытии программы
USpikyGameInstance::Shutdown()
// остановить проверку доступности сервера
FServerStatusCheckingTh::Shutdown();
// остановить поток слушающий tcp сокет
FTCPSocketListeningTh::Shutdown();
Реализуем encoder, поступает сообщение типа протобаф, в функции мы определяем нужно ли шифровать, его тип, оборачиваем в Wrapper, записываем в буфер длину и тело, затем отправляем по TCP или UDP каналу:
MessageEncoder
// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "MessageEncoder.h"
#include "SocketObject.h"
#include "Protobufs/MessageModels.pb.h"
#include <google/protobuf/io/zero_copy_stream_impl_lite.h>
#include <google/protobuf/io/coded_stream.h>
bool UMessageEncoder::Send(google::protobuf::Message * message, bool bCrypt, bool bTCP)
{
Wrapper wrapper;
// шифрованное или не шифрованное
if (bCrypt)
{
}
else
{
if (message->GetTypeName() == "Utility")
{
Utility * mes = static_cast<Utility*>(message);
wrapper.set_allocated_utility(mes);
}
}
size_t size = wrapper.ByteSize() + 5; // include size, varint32 never takes more than 5 bytes
uint8_t * buffer = new uint8_t[size];
google::protobuf::io::ArrayOutputStream arr(buffer, size);
google::protobuf::io::CodedOutputStream output(&arr);
// записать длину сообщения и сообщение в buffer
output.WriteVarint32(wrapper.ByteSize());
wrapper.SerializeToCodedStream(&output);
// освободить память выделенную для utility
if (wrapper.has_utility())
{
wrapper.release_utility();
}
int32 bytesSent = 0;
bool sentState = false;
if (bTCP)
{
//send by tcp
sentState = USocketObject::tcp_socket->Send(buffer, output.ByteCount(), bytesSent);
}
else
{
//send by udp
sentState = USocketObject::udp_socket->SendTo(buffer, output.ByteCount(), bytesSent, *USocketObject::udp_address);
}
delete[] buffer;
return sentState;
}
Запустим сервер и клиент, и проверим что сообщения и эхо доходят. Лог сервера:
utility { alive: true }
Лог клиента:
Connect state (bIsConnection) = 1 | FServerStatusCheckingTh::CheckServer
Data Read! 5 | FTCPSocketListeningTh::Run
Заключение
Итак с необходимой подготовкой мы закончили. В итоге у нас получился клиент-сервер общающийся протобаф сообщениями, скомпилированные и подключенные в Android и Windows библиотеки, и начальная архитектура поверх которой дальше мы будем наращивать функционал. Напоследок оставлю список литературы который поможет лучше разобраться с Netty и архитектурой онлайн игр.
Спасибо что дочитали до этого места!
Norman Maurer «Netty in Action» — с помощью Netty можно быстро и просто написать любое клиент-серверное приложение, которое будет легко расширяться и масштабироваться.
Josh Glazer «Multiplayer Game Programming: Architecting Networked Games» — эта книга на реальных примерах рассказывает об особенностях разработки онлайн игр и основах построения надежной многопользовательской архитектуры.
Grenville Armitage «Networking and Online Games: Understanding and Engineering Multiplayer Internet Games» — довольно старая книга, но неплохая книга в которой объясняются принципы работы многопользовательских игр.