Как Go спасает инди разработчика (часть 2)
Лениво пишем в Protobuf без мапинга
Задача всего последующего текста, показать как я пытался написать быстрый сервер, в котором прототировать и вводить новые фичи будет так же просто, как и на клиенте.

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

Начнем с небольшого отступления. ООП по-настоящему прекрасный концепт. Он особенно хорошо себя показывает в условиях одной среды: мы описали поведение объекта, спрятали "внутряк" и можем говорить ему что делать, не задумываясь над данными. Объект знает сам. Но, допустим, мы хотим отправить этот объект на другой сервис. Очевидно, что, что отправить поведение мы не можем и поэтому мы создаем еще один объект, кастрированную версию исходного, который передаем по сети. Это называется mapping, или мапирование

У мапирования есть 4 существенные проблемы:
  • Больше работы: нам нужно, как минимум, описать 2 метода: создание исходного из сетевых данных и наоборот
  • Больше логики: это еще один слой, и многие наверное сталкивались с тем, как констукторы могут конфликтовать с маппингом. То есть объекты созданные естественным путем могут отличаться от тех, что мы создаем при сериализации
  • Под все изменения протокола нужно подправлять маппинг
  • Выделение памяти

Дальше снова пойдет речь о решении сетевых проблем, но сначала опишу задачу.

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

Из этих ограничений я сформулировал требования к интерфейсу создания сообщений (выше - приоритетнее):
  • Писать наименьшие сообщения максимально быстро
  • Понимать, было ли изменено сообщение, не писать пустое сообщение и не делать лишнюю сериализацию. Корректно перезаписывать дважды одну и ту же часть сообщени
  • Избавиться от маппинга
  • Обновлять часть сообщения. Например, у нас вложенная структура. Есть профиль пользователя, а в нем данные обо всех инвентарях и ресурсах. Обновляя ресуры, не отправлять весь профиль
  • Это же касается игроков и монстров, которые в отсылаются в массиве. Хочется иметь возможность сессии X отправить изменения игрока Y. Это делается в том числе из соображений защиты. Любая информация, которая не предназначалась игроку, будет использована против вас
  • Иногда я хочу работать с данными, но не отправлять изменения на клиент
  • Возможность слать разные сообщения разным сессиям
  • Я делаю кооперативное PVE и хочу иметь возможность быстро менять общие данные на личные данные игрока. Например, в процессе прототипирования может понадобиться иметь общий сундук на всех игроков, или уникальный сундук для каждого игрок
То есть, хотелось бы иметь возможность писать и общие сообщения и уникальные используя для общих сообщений общие объекты.

Всё серверное сообщение выглядит так
message ServerData {
    LoadingData loadingData = 1;
    ConnectionData connectionData = 2;
    AccountGeneral accountGeneral = 3;
    LevelUpScene levelUpScene = 4;
    SocialScene socialScene = 5;
    LocationData locationData = 12;
    RoomData roomData = 13;
    repeated CustomObject customObjects = 6;
    repeated EnemyObject enemies = 7;
    repeated PlayerObject players = 8;
    repeated ServerEffect serverEffects = 10;
    repeated TurnAction actions = 11;
}
фрагмент Protobuf сообщения: интерфейсы, предметы, профиль
message AccountGeneral {
    Resources     resources = 1;
    Inv stash = 4;
    Inv inventory = 5;
    Inv wearing = 6;
    Inv loot = 7;
    Inv market = 9;
    repeated uint32 achievements = 8;
}

message Item {
    uint32 id = 2;
    uint32 price = 1;
}

message Inv {
    repeated Item items = 1;
}

message Resources {
    uint32 money = 1;
    uint32 moneyCurrent = 2;
    uint32 rareRes = 3;
}
Как видно выше, сообщения - это большие структры, многие имеют двойную-тройную вложенность

Тут используется сразу несколько концептов:
в предыдущих статьях я писал про принцип работы серверных эффектов - это в чистом виде дельта компрессия. Остальные объекты являются "полными" данными и имеют всю текущую информацию о себе.

Прежде чем прийти к текущему варианту, я перепробовал много всего, в том числе, может показаться удобным работать напрямую с объектом protobuf, делать глубокую проверку, изменилась ли структура и только тогда отправлять. Но тут много нюансов, у вас в игре скорее всего будут и статичные данные (профиль например) и дельта компрессия, серверные эффекты в моем случае. И, если в конце одного серверного тика у вас есть эффект "+ 1 монета", а в конце другого серверного тика точно такой же - это означает, что сообщение поменялось и игрок должен увидеть "+1 монета" дважды. Можете попытаться формировать protobuf сообщение в конце тика, но логики в одном месте получится МНОГО, а гибкости никакой.
Для начала я написал обертку всей protobuf структуры. Это решило проблему отслеживания изменений. У каждого соединения есть объект такого сообщения, и если захочется его изменить, придется вызвать WriteToMsg(), который сразу же инвалидирует сообщение и вернет protobuf объект. В конце тика, если IsChanged() == true – это сообщение сериализуется и в виде бинарных данных отправится в отдельную горутину на запись
package msutil;

import x "github.com/dearcj/network"

type XServerDataMsg struct {
	UniqueID int
	changed bool
	data *x.ServerData
	backup *x.ServerData
}

func (n* XServerDataMsg) WriteToMsg() *x.ServerData {
	n.changed = true
	return n.data
}

func (n* XServerDataMsg) Reset() {
	*n.data = *n.backup
	n.changed = false
}

func (n* XServerDataMsg) IsChanged() bool {
	return n.changed
}

func CreateXServerData() *x.ServerData {
	return &x.ServerData{}
}

func CreateXServerDataMsg(UniqueID int) XServerDataMsg {
	return XServerDataMsg{
		UniqueID: UniqueID,
		backup: CreateXServerData(),
		data: CreateXServerData()}
}
Интерфейс вставки
Окей, а как организовать запись в это сообщение? Удивительно, но мне понадобилось довольно много времени, чтобы написать эти 3 строчки и сильно облегчить себе работу:
type Insertable interface {
  Insert(s *XServerDataMsg)
}
Смысл в том, что объекты серверной логики, которые реализуют Insertable - это объекты которые знают, куда себя вставить в сообщении. Называется Insertable, чтобы не было соблазна заниматься внутри мэппингом.
type (
	PlayerResources struct {
		*data.Resources
	}
)

func (p PlayerResources) Insert(s *msutil.XServerDataMsg) {
	msg := s.WriteToMsg()
	msg.AccountGeneral = msutil.NewOrSame(msg.AccountGeneral).(*data.AccountGeneral)
	msg.AccountGeneral.Resources = p.Resources
}
Необходимо описать вот такие обертки над protobuf данными. К сожалению, кодогенерацией здесь не отделаться, есть более изощренные вставки. Например, вставка игрока в массив по его ID. Хочу отметить, что в редких случаях Insert можно использовать для мэппинга, если, например, мы хотим, чтобы один и тот же объект сундука слал разные состояния для игроков, которые его открыли, или еще нет. Для этого я зарезервировал у XServerDataMsg поле UniqueID

Кстати, вот такие обертки как PlayerResources являются бесплатными, если вы сериализуете их в JSON. В Go json.Marshal не сериализует структуры, у которых нет детей с именами. Если у вас структура X { Y { Z { Value int64 }}} сериализуется только значение Value. В моем случае, я использую все те же proto структуры для отправки NO-SQL базу. Любые дополнительные данные я могу описать либо отдельным объектом, либо внутри обертки.
вставка всего профиля пользователя просто вызывает вставки у "детей"
func (a *Account) Insert(s *msutil.XServerDataMsg) {
	a.CurrentLoot.Insert(s)
	a.JSONAccount.Resources.Insert(s)
	a.JSONAccount.Wearing.Insert(s)
	a.JSONAccount.Inventory.Insert(s)
	a.JSONAccount.Stash.Insert(s)
	a.JSONAccount.Achievements.Insert(s)
}
Помните серверные эффекты из первой части?

Нам ничего не мешает реализовать Insert и для них:
func (s FList) Insert(m *msutil.XServerDataMsg) {
	if len(s) > 0 {
		msg := m.WriteToMsg()
		msg.ServerEffects = append(msg.ServerEffects, s...)
	}
}

func (s *ServerEffect) Insert(m *msutil.XServerDataMsg) {
	if s != nil {
		msg := m.WriteToMsg()
		msg.ServerEffects = append(msg.ServerEffects, (*data.ServerEffect)(s))
	}
}
Окей, давайте предположим что мы описали Insert для всех игровых данных. Что теперь?
Так как я изначально хотел иметь полный контроль над тем что и когда отправляется, то пожертвовать в моем случае пришлось инкапсуляцией. Писать приходится явно.

Для сервера, команды и сессии я реализовал метод needToKnow:
func (s *Session) needToKnow(l ...n.Insertable) {
	for _, v := range l {
		if v != nil {
			v.Insert(&s.con.XServerDataMsg)
		}
	}
}
Реализация
код открытия сундука
if picklock != nil {
	logger.Action("CMD_OPEN_CHEST with picklock")
	playerInv := session.account.JSONAccount.Inventory
	playerInv.RemoveItemByPlace(place),
							
	session.needToKnow(
		playerInv,
		chest.playerOpenChest(session, v),
		session.account.CurrentLoot.Set(session.run.Loot.generateLoot(chest.drop, nil)),
		chest.Effect(confServerEffects.PlayerNotification).V(pnOpened))
} else {
	session.needToKnow(
		chest.Effect(confServerEffects.PlayerNotification).V(pnNeedPicklock)))
}
Если у нас есть отмычка, мы ее уничтожаем, открываем сундук, меняем текущий лут и передаем нотификацию, что сундукт открыт. В противном случае вылетает оповещение, что сундук закрыт. Я взял за правило, что все функции, которые работают с Insertable возвращают кортежем все Insertable, на которые они повлияли.
if session.progress.perkPoints > 0 {
	session.needToKnow(session.progress.LevelUpScene.Set(session.progress.pm.generateLevelUpScene()))
	team.needToKnow(session.player.Effect(config.Skills.ServerEffects.LevelUp))
}
Или вот так, мы генерируем новые LevelUp данные для игрока и отсылаем их только ему. А всей команде эффект, что текущий игрок повысился в уровне.
text := fmt.Sprintf("Server will be rebooted in %s", time.Hour)
server.needToKnow(&ServerNotify{Text: text})
Тут мы всему серверу разошлем нотификацию, что он будет в скором времени перезагружен
session.account.JSONAccount.Wearing.OnAdd = func(id uint32, placeInx int32) {
    item := config.Items.List[id]
    if item.OnWearBuff {
      r.team.needToKnow(unit.addBuff(item.OnWearBuff, &unit.BaseCharacter, INFINITE_BUFF,  nil))
    }
  }
Так как addBuff возвращает список эффектов, валидно будет писать вот так.
Таким образом, при надевании предмета вся команда увидит эффекты от надетой на вас вещи.

В основной части игры – боевой механике – я стараюсь спрятать needToKnow под капот, но в особых местах, как примеры выше, оставляю себе много пространства

Преимуществ у этого подхода море. Недостатка всего два. Первый. Нужно понимать, что случайно вызывая методы сервера, вы работаете только с данными. Чтобы они ушли на клиент, заворачивайте их в needToKnow.
Второй недостаток, мы вставляем указатели в сообщения и поэтому, сделав
team.needToKnow(SomeData.Set(A))
SomeData.Set(B)
Вы отправите значение B. В этом есть смысл, если смотреть на отправку, как на намерение передать текущее состояние объекта SomeData
Эпилог
Я пишу на Go два года, суммарно программирую 10 лет и есть два момента в которых Go не перестает меня удивлять. Первый – худший, и лениво написанный код на Go, выглядит лучше, чем на большинстве языков. И второй, открывая ваш код спустя неделю - месяц вы моментально понимаете, что в нем происходит.
Следить в твиттере за новыми постами @DorogoyYuriy
По всем вопросам, ddearcj@gmail.com
This site was made on Tilda — a website builder that helps to create a website without any code
Create a website