Тема, которая будет писаться в многих частях будет в основном представлять этапы написания своего серверного ядра для мультиплеера популярной игры Minecraft.

Об игре Minecraft

Игра на данный момент имеет большую популярность среди многих людей, наверное зайдя на тот же ютуб вы можете встретить множество видео или стримов по этой игре, в неё играют люди разных возрастов. В ней вы можете играть как в одиночном режиме, так и мультиплеере. Мы будем разбирать конкретно мультиплеер так-как именно он позволяет играть людям вместе, где есть некоторые проблемы, но так-же и добавлять разнообразие в игру.

О серверах игры

В самой игре нет централизированной системы серверов, что позволяет создавать сервера где угодно, но чаще под них арендуют услуги хостинга. Сервера можно найти очень легко, например введя в поисковик гугл "Мониторинг майнкрафт серверов", а так-же вы можете и создать свой. В основе большенства серверов лежат ядра, которые в свою очередь основаны от одного ядра - Vanilla. Вот малый список ядер, которые часто использут: Paper, Purpur, Spigot, Sponge, Glowstone.

Программная часть серверов

Все ядра, которые были перечислены выше имеют одну и ту же проблемную зависимость - они написаны почти полностью на Java. Сам по себе этот язык классный, много классных особенностей, но в случае огромных проектов часто бывают проблемы например с потреблением ОЗУ, а уже процессор часто бывает второстепенный. Обычный сервер, без дополнений (плагины), при тех же 20-40 игроках может спокойно использовать и 1 или даже 2 гигабайта ОЗУ, а что же говорить о том, что при долгой работе без перезагрузок потребление может занимать и более 6ГБ ОЗУ. Поэтому находятся люди, которые пытаются создавать собственные ядра например на Rust ведётся разработка одного из таких ядер. Я же планирую по частям описывать создание своего ядра, но уже на Go.

Почему же Go?

Я не раз пробовал разные языки для всего подряд (да, даже PHP для серваков...), но мне показался самым классным Go. В основном нравиться его простота, удобство и экосистема, ведь есть много классных библиотек. Так-же бы я выделил его производительность, ведь она тут очень замечательная.

Зачем?

Да просто :D. А если говорить по правде, то я просто давно хотел сделать, что-то интересное не только для меня, но и другим. А может даже и поможет это кому-то... Ну и конечно возможно это будет очень удобно использовать где-то у себя.

Перед началом

В данном посте я планирую сделать лишь основы, поэтому в следующей части будет больше интересного).

Сначала я бы хотел уточнить, что буду использовать Go последней версии (на момент поста 1.17.5), а так-же редактор GoLand от JetBrains и буду надеяться на вашу поддержу :)
Пока на самом начале сервер будет поддерживаться только на 1.12.2, потому-что моё супер вычислительное устройство очень плохо работает в связке GoLand + Minecraft 1.16.5 и выше.

Начало

Называться ядро пока-что будет именно ULE, поэтому будет инициализироваться проект в GoLand, создаём main.go в качестве запускатора.

Инициализация проекта
Инициализация проекта

В качестве упрощения в создании будем использовать одну библиотеку для протокола игры. Она очень мощная, но из неё мне понадобиться только функционал для парсинга сообщений и чтении/записи пакетов, а так-же небольшой обработке игроков. Поэтому установим её таким образом:

go get github.com/Tnze/go-mc@master
go.mod после добавления go-mc
go.mod после добавления go-mc

После чего данная библиотека будет автоматически добавлена в go.mod и мы сможем её использовать в нашем коде обращаясь по "github.com/Tnze/go-mc/".

Теперь для удобства я создам директорию server и в нём файл server.go со следующим содержимым:

package server

// Импортируем пакеты
import (
	"github.com/Tnze/go-mc/net"
	"log"
)

// InitSRV - Функция запуска сервера
func InitSRV() {
	// Запускаем сокет по адрессу 0.0.0.0:25565
	loop, err := net.ListenMC(":25565")
	// Если есть ошибка, то выводим её
	if err != nil {
		log.Fatalf("Ошибка при запуске сервера: %v", err)
	}

	// Цикл обрабатывающий входящие подключеня
	for {
		// Принимаем подключение или ждём
		connection, err := loop.Accept()
		// Если произошла ошибка - пропускаем соденение
		if err != nil {
			continue
		}
		// Принимаем подключение и обрабатываем его не блокируя основной поток
		go acceptConnection(connection)
	}
}

Как мы видим для работы мы используем net из go-mc для подключений, а так-же принимаем их с помощью нашей функции acceptConnection, которая объявлена в server/accepter.go и её код уже такой:

package server

import (
	"github.com/Distemi/ULE/server/protocol/serverbound"
	"github.com/Tnze/go-mc/net"
)

func acceptConnection(conn net.Conn) {
	defer func(conn *net.Conn) {
		err := conn.Close()
		if err != nil {
			return
		}
	}(&conn)
	// Читаем пакет-рукопожатие(HandSnake)
	_, nextState, _, _, err := server.ReadHandSnake(conn)
	// Если при чтении была некая ошибка, то просто перестаём обрабатывать подключение
	if err != nil {
		return
	}

	// Обрабатываем следющее состояние(1 - пинг, 2 - игра)
	switch nextState {
	case 1:
		acceptPing(conn)
	default:
		return
	}
}

Здесь вы можете уже заметить, что в списке инпутов есть свой пакет, в папке server/protocol/serverbound, а там находиться уже файл handsnake.go для "рукопожатий", но перед этим стоит разобрать код функции для принятия подключений, в ней мы пока используем при чтении только nextState, так-как в первой части будет готов только пинг и поэтому в обработке типа подключения из HandSnake мы используем только 1, который означает, что это пинг.

Далее по очереди у нас очень важный компонент в работе ядра - чтение HandSnake, который как описывал был расположен в server/protocol/serverbound/handsnake.go и всё что находиться в директории связанной с протоколом конечно будет всё, что с ним связано и делиться всё на ServerBound (для сервера) и ClientBound (для клиента), поэтому при таком разделении у нас будет именно чтение HandSnake со следующим содержимым:

package serverbound

import (
	"github.com/Tnze/go-mc/net"
	"github.com/Tnze/go-mc/net/packet"
)

// ReadHandSnake - чтение HandSnake пакета( https://wiki.vg/Protocol#Handshake )
func ReadHandSnake(conn net.Conn) (protocol, intention int32, address string, port uint16, err error) {
	// Переменные пакета
	var (
		p                   packet.Packet
		Protocol, NextState packet.VarInt
		ServerAddress       packet.String
		ServerPort          packet.UnsignedShort
	)
	// Читаем входящий пакет и при ошибке ничего не возращаем
	if err = conn.ReadPacket(&p); err != nil {
		return
	}
	// Читаем содержимое пакета
	err = p.Scan(&Protocol, &ServerAddress, &ServerPort, &NextState)
	// Возращаем результат чтения в привычной форме для работы(примитивные типы)
	return int32(Protocol), int32(NextState), string(ServerAddress), uint16(ServerPort), err
}

Ну и написанными комментариями я думаю расписал всё довольно подробно и если вы хотите почитать сами про структуру пакета и почему всё так читаем, то можете ознакомиться на официальном источнике.

После чтения HandSnake пакета мы так-же решаем что делать с ним и поэтому в accepter.go при обработке состояния при 1 - принимаем в виде пинга в функции acceptPing, но принятие пинга уже кому-то может составить проблему из-за большого кода, ведь тут вся функция пинга обходиться нам в server/accepter_ping.go не в 20 строчек:

package server

import (
	"encoding/json"
	"github.com/Distemi/ULE/config"
	"github.com/Tnze/go-mc/chat"
	"github.com/Tnze/go-mc/net"
	"github.com/Tnze/go-mc/net/packet"
	"github.com/google/uuid"
	"log"
)

// Получаем пинг-подкючение(PingList)
func acceptPing(conn net.Conn) {
	// Инициализируем пакет
	var p packet.Packet
	// Пинг или описание, будем принимать только 3 раза
	for i := 0; i < 3; i++ {
		// Читаем пакет
		err := conn.ReadPacket(&p)
		// Если ошибка - перестаём обрабатывать
		if err != nil {
			return
		}
		// Обрабатываем пакет по типу
		switch p.ID {
		case 0x00: // Описание
			// Отправляем пакет со списком
			err = conn.WritePacket(packet.Marshal(0x00, packet.String(listResp())))
		case 0x01: // Пинг
			// Отправляем полученный пакет
			err = conn.WritePacket(p)
		}
		// При ошибке - прекращаем обработку
		if err != nil {
			return
		}
	}
}

// Тип игрока для списка при пинге
type listRespPlayer struct {
	Name string    `json:"name"`
	ID   uuid.UUID `json:"id"`
}

// Генерация JSON строки для ответа на описание
func listResp() string {
	// Строение пакета для ответа( https://wiki.vg/Server_List_Ping#Response )
	var list struct {
		Version struct {
			Name     string `json:"name"`
			Protocol int    `json:"protocol"`
		} `json:"version"`
		Players struct {
			Max    int              `json:"max"`
			Online int              `json:"online"`
			Sample []listRespPlayer `json:"sample"`
		} `json:"players"`
		Description chat.Message `json:"description"`
		FavIcon     string       `json:"favicon,omitempty"`
	}

	// Устанавливаем данные для ответа
	list.Version.Name = "ULE #1"
	list.Version.Protocol = config.ProtocolVersion
	list.Players.Max = 100
	list.Players.Online = 5
	list.Players.Sample = []listRespPlayer{{
		Name: "Пример игрока :)",
		ID:   uuid.UUID{},
	}}
	list.Description = config.MOTD

	// Превращаем структуру в JSON байты
	data, err := json.Marshal(list)
	if err != nil {
		log.Panic("Ошибка перевода в JSON из обьекта")
	}
	// Возращаем результат в виде строки, переведя из байтов
	return string(data)
}

Обошлось нам всё в 83 строчки... Наверное это ещё очень мало так-как под некоторое была выделена папка config в которой вскоре будет располагаться вся конфигурация, но пока поговорим о нашем любимом пинге. Принятый пинг-подключение мы читать будем не более 3-х раз так-как обычному клиенту этого хватить должно в большинстве случаев, но это и частично нас обезопасит от ping-аттак.. частично... ну ладно, если не получилось отправить пакет, то перестаём его обрабатывать, поэтому каждый раз прочитывая пакет мы так-же узнаём его тип, так-как:

  • 0x00 - получение описания

  • 0x01 - пинг игрока

И вот тут самое интересное, при пинге игрока нам надо просто возвращать отправленный игроком пакет, а вот уже при описании нам приходиться генерировать JSON из нашей структуры, но сама генерация идёт в функции listResp, но для неё у нас есть структура данных listRespPlayer, которая говорит частично за себя ведь она описывать игрока для ответа, другая структура в самой функции генерации ответа уже гораздо больше, которая соответствует минимальному стандарту ответа. Мы так-же устанавливаем в структуру значения по дефолту

	list.Version.Name = "ULE #1"
	list.Version.Protocol = config.ProtocolVersion
	list.Players.Max = 100
	list.Players.Online = 5
	list.Players.Sample = []listRespPlayer{{
		Name: "Пример игрока :)",
		ID:   uuid.UUID{},
	}}
	list.Description = config.MOTD

И мы тут можем заметить, что идёт обращение к какому-то config, а это просто в корне проекта config/basic.go:

package config

import "github.com/Tnze/go-mc/chat"

var (
	ProtocolVersion uint16       = 340
	MOTD            chat.Message = chat.Text("Тестовое ядро §aULE")
)

И в нём установлены некоторые дефолтные значения по типу версии протокола (для 1.12.2 версия протокола - 340), а так-же MOTD или же то что вы видите в виде текста под названием сервера.

Для генерации JSON из структуры используем json.Marshal, который может вывести ошибку и так-как он не должен выводить ошибку, то мы заканчиваем работу программы с ошибкой.


Итог

Вот и конец первой части истории о самописном серверном ядре для Minecraft Java. Весь исходный код доступен на GitHub. И я буду очень сильно надеяться на вашу поддержку.

В итоге первой части мы получили:

Результат пинга
Результат пинга

При этом ядро пока использует 2.1МБ ОЗУ, но стоит учесть, что на Linux он будет использовать гораздо меньше так-как размер потребления указан на Windows 11.

Спасибо за прочтение статьи и скоро выйдет новая часть, посвящённая написанию своего ядра! :)

Комментарии (8)


  1. Lexicon
    19.01.2022 14:24
    +3

    Ох, новые движки всегда хорошая новость для сообщества игроков

    Стало интересно, какой у вас план

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

    Поддержка модов, - второй момент. Мало того, что почти все кастомные ядра их не поддерживают, те, что способны еще дальше проседают по производительности и не предлагают решений.

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

    Что вы запланировали для своего ядра?


    1. Distemi Автор
      20.01.2022 06:27

      Привет)

      Из планов у меня пока сделать ядро, который по функционалу будет хотя бы большей частью похожи на GlowStone или тот же Vanilla. В качестве защиты от атак тоже будет развитие, но пока увы в первой часте мог успеть лишь сделать основу...

      На счёт плагинов - такая часть тоже должна быть, но как скоро не знаю, возможно будут на WASM, возможно на Lua/JS... А так всё-таки планов на развитие ещё много и надо бы реализовать

      Если говорить об оптмизации в мультипотоке для тпс - в Go гораздо меньше нагрузки может быть чем в той же яве, но так-же в любом случае мне придётся делать мультипоточную работу миров так-как мне кажеться не получиться идеально обрабатывать всего и вся в одном потоке, а вот как уже реализовано будет... надеюсь хорошо


      1. Ukrainskiy
        20.01.2022 09:02
        +4

        При всем уважении, это не основа, это HelloWorld на 25565'ом порту. Об основе для сервера можно будет говорить когда будет реализована генерация хотя-бы плоского мира, физика твердых/сыпучих/жидких тел, работа с чанками и т.д. Пока ваша идея выглядит слишком амбициозно. Но я готов поддержать и помочь, если увижу основу, где можно сломать и поставить блок, с адекватной архитектурой кода.


        1. Distemi Автор
          20.01.2022 09:07

          Согласен, что это пока HelloWorld и для полноценной оновы требуется работа с миром и другим функционалом, конечно это не получится сделать в самой первой часте выложить и поэтому надо ещё много сделать частей о плагинах, входе в мир, физике и тд.


  1. andrew000
    20.01.2022 06:28
    +1

    Будет круто, если будет возможность писать плагины для ядра. Интересно как это реализуют


    1. Distemi Автор
      20.01.2022 06:30

      Ну наверное сервер без возможности написания плагинов это как телефон без приложений)

      Поэтому когда-то завезу такой функционал в ядро, возможно он будет на WASM, а если на нём не получится, то выбор падёт на вариант, который точно заработает - Lua/JS


      1. andrew000
        20.01.2022 14:54
        +1

        Как по мне хороший вариант будет тот - который будет максимально удобен для быстрой и чистой разработки.

        Можно сделать чтобы ядро сервера подгружало плагины (как обычное ядро на Java), написанные на том же Go и скомпиленные как библиотека.

        Хоть этот вариант возможно и усложнит разработку плагинов, однако не будет особого оверхеда для поддержки тех же Lua/JS


        1. Distemi Автор
          20.01.2022 15:02

          Мне кажеться лучше бы поддержу WASM так-как он очень удобен, особенно в таком случае имеется большой выбор языков на которых можно писать и быстро компилировать и подгружать в ядре тот самый плагин, вот например написать пл на TS/Rust/Go/.. с использованием библиотеки от ядра, которые смогут использовать АПИ для связи с ядром и допустим создавать конфиги или слушать событии и тд. Если же компилить тот же Go в нативные файлы библиотек, то тут будет проблема с поддержкой систем, поэтому выбор и падает на WASM/JS/Lua.