Статьи

Игровые серверы и Couchbase с Node.js — Часть 3

Это сообщение от Бретта Лоусона из блога Couchbase.

В этой части серии мы будем настраивать систему хранения игровых данных, чтобы вы могли сохранять игровое состояние игрока в течение всего времени, в течение которого они наслаждались игрой. Для этого мы собираемся создать некоторые конечные точки / state и / states, которые будут представлять отдельные блоки данных состояния. Мы позволим нескольким именованным блокам состояния позволить игре разделить данные состояния на отдельно обновляемые блоки, чтобы избежать необходимости записывать много блоков состояния, когда изменилась только одна часть.

Если вам еще предстоит прочитать Часть 1 и Часть 2 этой серии, я советую вам это сделать, поскольку эта часть и любые будущие будут основаны на них!

Quick Aside — Обновление сессии

Что-то, что должно было быть в моем предыдущем сообщении в блоге, и это важно, это продление сеанса пользователей всякий раз, когда они получают к нему доступ. Без этого сессия гарантированно истекает через 60 минут, независимо от того, продолжает ли игрок играть. Это явно не наше намерение, так что давайте исправим это!

Сначала нам нужно добавить новую функцию в нашу SessionModel, поэтому откройте файл sessionmodel.js и давайте добавим следующий блок. Это довольно простая функция; он берет идентификатор сеанса и выполняет с ним операцию касания, чтобы снова установить время истечения до 3600 (начиная со времени выполнения касания, а не с момента, когда ключ был первоначально вставлен).

SessionModel.touch = function(sid, callback) {
  var sessDocName = 'sess-' + sid;

  db.touch(sessDocName, {expiry: 3600}, function(err, result) {
    callback(err);
  });
};

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

function authUser(req, res, next) {
  req.uid = null;
  if (req.headers.authorization) {
    var authInfo = req.headers.authorization.split(' ');
    if (authInfo[0] === 'Bearer') {
      var sid = authInfo[1];
      sessionModel.get(sid, function(err, uid) {
        if (err) {
          next('Your session id is invalid');
        } else {
          sessionModel.touch(sid, function(){});
          req.uid = uid;
          next();
        }
      });
    } else {
      next('Must be authorized to access this endpoint');
    }
  } else {
    next('Must be authorized to access this endpoint');
  }
}

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

Состояния игры — модель

Теперь, когда мы очистили нашу небольшую проблему от предыдущей части, давайте перейдем к реализации сохранения состояния игры! Как я уже говорил выше, мы собираемся позволить играм хранить данные в нескольких блоках состояния, чтобы уменьшить сетевой трафик. С точки зрения хранения, мы будем хранить все эти блоки состояний в одном документе Couchbase, и этот документ будет лениво создаваться при первом запросе для сохранения информации для пользователя. До момента, когда что-то будет сохранено, мы будем эмулировать пустой список состояний для пользователя, как вы вскоре увидите.

Для начала давайте настроим нашу стандартную разметку файла модели в model / statemodel.js. Мы импортируем наши необходимые модули и устанавливаем модель без методов с именем StateModel.

var db = require('./../database').mainBucket;
var couchbase = require('couchbase');

function StateModel() {
}

module.exports = StateModel;

Теперь, когда у нас есть основы для нашей модели, давайте приступим к реализации некоторых методов, которые будут необходимы. Давайте начнем с модели, которая позволит нам сохранить новый блок состояния. Эта функция будет обрабатывать как создание, так и обновление блока состояния. Это значительно упрощает клиентскую логику, поскольку нам не нужно беспокоиться о том, существует ли блок состояния на уровне API. Мы будем использовать форму оптимистической блокировки, где номер версии будет храниться с каждым блоком состояния. Всякий раз, когда блок состояния обновляется, вам нужно будет передать существующий номер версии, который находится на сервере, прежде чем сервер примет новые данные. Это сделано для того, чтобы предотвратить одновременное попирание данных несколькими копиями игры. Это также первое место, где мы будем использовать Couchbase.Оптимистичная блокировка, чтобы гарантировать, что мы не вносим одновременные изменения в наш объект состояний из двух вызовов конечной точки.

Начнем с нашего прототипа функции сохранения.

StateModel.save = function(uid, name, preVer, data, callback) {
};

Нашим первым реальным шагом является создание имени для нашего документа о состоянии хранилища, а затем запросить этот документ у Couchbase, чтобы проверить, существует ли он еще.

var stateDocName = 'user-' + uid + '-state';
db.get(stateDocName, function(err, result) {
  // Code below goes in here!
});

Теперь мы проверяем, не было ли ошибок при запросе существующего документа состояний. Если мы сталкиваемся с ошибкой, мы проверяем, что это не ошибка «не найдена». Если документ не был найден, мы игнорируем эту ошибку и продолжаем, это происходит из-за ленивого характера нашего документа состояний.

if (err) {
  if (err.code !== couchbase.errors.keyNotFound) {
    return callback(err);
  }
}

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

var stateDoc = {
  type: 'state',
  uid: uid,
  states: {}
};
if (result.value) {
  stateDoc = result.value;
}

Теперь мы делаем то же самое для нашего государственного блока. Вы заметите, что мы гарантируем, что наша переменная stateBlock ссылается на массив состояний документов фактического состояния. Еще один момент, о котором стоит упомянуть, это то, что наша версия состояния блока по умолчанию равна 0. Это означает, что при первом выполнении сохранения ожидается, что клиент укажет версию 0, чтобы уточнить, что он знает, что это будет новое состояние. блок.

var stateBlock = {
  version: 0,
  data: null
};
if (stateDoc.states[name]) {
  stateBlock = stateDoc.states[name];
} else {
  stateDoc.states[name] = stateBlock;
}

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

if (stateBlock.version !== preVer) {
  return callback('Your version does not match the server version.');
} else {
  stateBlock.version++;
  stateBlock.data = data;
}

Как я упоминал в начале этого раздела, мы также будем использовать оптимистическую блокировку, встроенную в Couchbase, чтобы гарантировать, что наши записи в документах состояния будут выполнены по порядку. Из-за того, что мы предварительно преобразуем наш метод get previous, затем проводим сравнение версий и, наконец, снова делаем запись здесь, есть вероятность, что еще один вызов нашей конечной точки сохранения состояния изменил объект с момента нашего первоначального получения, но до нашего набора, оптимистичного блокировка с использованием значений cas предотвращает это. Чтобы узнать больше о значениях cas, ознакомьтесь с руководством Couchbase по значениям cas .

var setOptions = {};
if (result.value) {
  setOptions.cas = result.cas;
}

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

db.set(stateDocName, stateDoc, setOptions, function(err, result) {
  if (err) {
    return callback(err);
  }

  callback(null, stateBlock);
});

Наконец, вот весь наш метод сохранения. Это довольно долго, но, надеюсь, относительно понятно!

StateModel.save = function(uid, name, preVer, data, callback) {
  var stateDocName = 'user-' + uid + '-state';
  db.get(stateDocName, function(err, result) {
    if (err) {
      if (err.code !== couchbase.errors.keyNotFound) {
        return callback(err);
      }
    }

    var stateDoc = {
      type: 'state',
      uid: uid,
      states: {}
    };
    if (result.value) {
      stateDoc = result.value;
    }

    var stateBlock = {
      version: 0,
      data: null
    };
    if (stateDoc.states[name]) {
      stateBlock = stateDoc.states[name];
    } else {
      stateDoc.states[name] = stateBlock;
    }

    if (stateBlock.version !== preVer) {
      return callback('Your version does not match the server version.');
    } else {
      stateBlock.version++;
      stateBlock.data = data;
    }

    var setOptions = {};
    if (result.value) {
      setOptions.cas = result.cas;
    }

    db.set(stateDocName, stateDoc, setOptions, function(err, result) {
      if (err) {
        return callback(err);
      }

      callback(null, stateBlock);
    });
  });
};

Следующий метод, который мы включим — это findByUserId. Этот метод позволит нам построить конечную точку, которая возвращает все блоки состояния для любого конкретного пользователя. Это в основном оптимизация на стороне клиента, позволяющая извлекать все блоки состояния одновременно, а не выполнять несколько запросов. Функция очень проста. Используя то же имя документа, что и наша функция сохранения, мы пытаемся загрузить документ состояний из нашего кластера, если он существует, мы возвращаем список состояний в этом блоке, если документ отсутствует, мы возвращаем пустой список пользователю. Любые другие ошибки передаются вызывающей стороне.

StateModel.findByUserId = function(uid, callback) {
  var stateDocName = 'user-' + uid + '-state';
  db.get(stateDocName, function(err, result) {
    if (err) {
      if (err.code === couchbase.errors.keyNotFound) {
        return callback(null, {});
      } else {
        return callback(err);
      }
    }
    var stateDoc = result.value;

    callback(null, stateDoc.states);
  });
};

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

StateModel.get = function(uid, name, callback) {
  var stateDocName = 'user-' + uid + '-state';

  db.get(stateDocName, function(err, result) {
    if (err) {
      return callback(err);
    }
    var stateDoc = result.value;

    if (!stateDoc.states[name]) {
      return callback('No state block with this name exists.');
    }

    callback(null, stateDoc.states[name]);
  });
};

Состояния игры — обработка запросов

Теперь, когда наша модель полностью завершена и готова к использованию, давайте приступим к созданию обработчиков запросов для наших 3 конечных точек! Мы собираемся создать конечную точку для запроса всех состояний для одного пользователя, конечную точку для запроса определенного блока состояний и, наконец, конечную точку для обновления конкретного блока состояний.

Прежде чем мы сможем построить наши обработчики запросов, нам сначала нужно добавить ссылку на файл statemodel.js, который мы создали ранее!

var stateModel = require('./models/statemodel');

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

app.put('/state/:name', authUser, function(req, res, next) {
  stateModel.save(req.uid, req.params.name, parseInt(req.query.preVer, 10),
      req.body, function(err, state) {
    if (err) {
      return next(err);
    }

    res.send(state);
  });
});

Далее нам нужна возможность извлечь блок состояния, который мы ранее сохранили.

app.get('/state/:name', authUser, function(req, res, next) {
  stateModel.get(req.uid, req.params.name, function(err, state) {
    if (err) {
      return next(err);
    }

    res.send(state);
  });
});

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

app.get('/states', authUser, function(req, res, next) {
  stateModel.findByUserId(req.uid, function(err, states) {
    if (err) {
      return next(err);
    }

    res.send(states);
  });
});

Finito!

Теперь, когда мы создали нашу модель и внедрили необходимые обработчики запросов, теперь вы сможете запускать свое приложение, как и в предыдущих частях, и выполнять некоторые запросы к нашему игровому серверу, чтобы окупиться за нашу тяжелую работу!

> POST /state/test?preVer=0
Header(Authorization): Bearer 0e9dd36c-5e2c-4f0e-9c2c-bffeea72d4f7
{
  "name": "We Rock!",
  "level": "13"
}
< 200 OK
{
    "version": 1,
    "data": {
        "name": "We Rock!",
        "level": "13"
    }
}


> GET /state/test
Header(Authorization): Bearer 0e9dd36c-5e2c-4f0e-9c2c-bffeea72d4f7
< 200 OK
{
    "version": 1,
    "data": {
        "name": "We Rock!",
        "level": "13"
    }
}


> GET /states
Header(Authorization): Bearer 0e9dd36c-5e2c-4f0e-9c2c-bffeea72d4f7
< 200 OK
{
    "test": {
        "version": 1,
        "data": {
            "name": "We Rock!",
            "level": "13"
        }
    }
}

Успех!

Полный исходный код этого приложения доступен здесь: https://github.com/brett19/node-gameapi.

Наслаждайтесь! Brett