GrabDuck

Reddwarf для создания Java-сервера на примере онлайн-игры «Камень-ножницы-бумага»: Сервер

:

В статье RedDwarf — cерверная платформа для разработки онлайн-игр на Java я рассказал об особенностях этой платформы для создания игровых серверов. В данной статье я попробую показать на примере, как написать сервер и использованием RedDwarf.
В качестве примера решено было написать онлайн-реализацию игры «Камень-Ножницы-Бумага».
В этой статье мы напишем сервер и попробуем его запустить. В следующей статье напишем для этого сервера небольшой клиент и проверим их работоспособность.

Подготовка к работе


Для начала необходимо закачать сервер Reddwarf в архиве sgs-server-dist-0.10.2.zip отсюда и распаковать содержимое в папку sgs-server-dist-0.10.2.

Создание проекта


Создадим проект в любимой среде разработки.
Проект будет простой, поэтому maven использовать не будем.
Для разработки нужна библиотека sgs-server-api-0.10.2.jar из директории sgs-server-dist-0.10.2\lib\

Создаем папку META-INF, в ней должен находиться файл манифеста MANIFEST.MF. Без него платформа отказывается работать с jar-файлом проекта. У меня файл содержит только одну строчку:
Manifest-Version: 1.0

Также в папке META-INF необходимо создать файл app.properties. В этом файле содержатся настройки запуска сервера. Для нашего проекта файл содержит следующие свойства:

# Название игры. Служит уникальным идентификатором игры при старте сервера
com.sun.sgs.app.name=RockPaperScissors
# Класс, реализующий интерфейс AppListener и служащий точкой запуска приложения
com.sun.sgs.app.listener=hello.reddwarf.server.Server
# Имя директории, в которой будет храниться база данных игры
com.sun.sgs.app.root=data

Это минимальный необходимый набор опций. При разработке могут быть ещё полезны следующие свойства:
  • com.sun.sgs.impl.transport.tcp.listen.port — порт, на котором слушает сервер (по умолчанию 62964)
  • com.sun.sgs.app.authenticators — имена классов, отвечающих за аутентификацию (процесс аутентификации вынесен из игровой логики и может идти независимым модулем)
  • com.sun.sgs.impl.service.session.allow.new.login — позволять ли подключаться уже подключенным игрокам с другого клиента. Если true, то того, кто сейчас в игре выкидывает. Если false, не позволяет подключаться с другого клиента.

Подробнее о других свойствах можно почитать в документации.

Архитектура игры


Для игры потребуются следующие сущности.
Server — класс, хранящий список игроков онлайн и занимающийся обработкой их подключения.
Player — представляет собой игрока. Игрок имеет следующие атрибуты: имя (оно же логин) и количество очков. Может участвовать в битве.
Battle — представляет собой битву. В этом объекте происходит ожидание ответов игроков и определение победителя. Хранит в себе ссылки на двух игроков.
Weapon — простое перечисление видов оружия: непосредственно камень, ножницы и бумага.

Если изобразить в виде диаграммы классов, получается вот что:

Все игровые сущности (кроме Weapon) во время работы сервера хранятся во внутренней базе данных, обеспечивающей транзакционность, ссылаются друг на друга, поэтому они должны реализовывать интерфейсы java.io.Serializable и com.sun.sgs.app.ManagedObject.

Класс Server. Инициализация и подключение игрока

Класс Server является точкой запуска сервера, поэтому должен реализовывать интерфейс com.sun.sgs.app.AppListener:

void initialize(Properties props) вызывается при первом запуске сервера. Он заполняет внутреннюю базу данных необходимыми для работы начальными значениями. Важная особенность: если сервер остановить (или убить), а потом снова запустить, этот метод вызываться не будет, т.к. внутренняя база данных хранится между запусками сервера и позволяет продолжить работу с момента остановки.

ClientSessionListener loggedIn(ClientSession session) вызывается после успешной аутентификации и должен вернуть объект, олицетворяющий игрока. В нашем примере это будет Player.

Все игроки, подключенные к серверу, будут хранится в специальной коллекции. В Reddwarf для игровых сущностей существует специальная коллекция ScalableHashMap. Достоинства этой коллекции в том, что при изменениях она блокируется (имеется в виду блокировка во внутренней БД) не целиком, а частично. Причем в объекте Server хранить будем не саму коллекцию, а ссылку на нее (ManagedReference).

Переходя от слов к делу, получаем следующий код:

package hello.reddwarf.server;

import java.io.Serializable;

import com.sun.sgs.app.*;
import com.sun.sgs.app.util.ScalableHashMap;

import java.util.Properties;

/**
 * Сервер игры. Этот класс автоматически загружается платформой,
 * инициализируется и его платформа уведомляет о новых подключениях.
 */
public class Server implements AppListener, Serializable, ManagedObject {

    public ManagedReference<ScalableHashMap<String, Player>> onlinePlayersRef;

    @Override
    public void initialize(Properties props) {

        // Создаем коллекцию для игроков онлайн
        ScalableHashMap<String, Player> onlinePlayers = new ScalableHashMap<String, Player>();
        onlinePlayersRef = AppContext.getDataManager().createReference(onlinePlayers);

    }

    @Override
    public ClientSessionListener loggedIn(ClientSession session) {

        String name = session.getName();

        // Подключился пользователь. Необходимо загрузить его из базы данных, либо зарегистрировать нового
        Player player = loadOrRegister(name);

        // Установим игроку сессию. Сессия - это объект, через который осуществляется
        // сетевое взаимодействие - отсылка сообщений на клиент
        player.setSession(session);
        // Уведомляем игрока о том, что он подключился
        player.connected();
        // Добавим его в список онлайн-игроков
        onlinePlayersRef.get().put(player.name, player);

        return player;
    }
}

Для работы с базой данных используется DataManager, который позволяет писать в БД, читать из БД и создавать ссылки ManagedReference. Поскольку база данных представляет собой key-value хранилище, то в качестве ключа используется имя игрока с префиксом «player.», в значение же сериализуется объект Player целиком. Напишем функцию загрузки игрока из базы (если игрок не найден в базе, создадим его).

    private Player loadOrRegister(String name) {
        try {
            return (Player) AppContext.getDataManager().getBindingForUpdate("player." + name);
        } catch (NameNotBoundException e) {
            // Попытка загрузить объект и перехват исключения - 
            // единственный способ узнать, есть ли такой объект в базе
            Player player = new Player(name, this);
            AppContext.getDataManager().setBinding("player." + name, player);
            return player;
        }
    }
Класс Player и протокол

Пришла очередь создать класс Player. Этот класс олицетворяет игрока и получает от платформы уведомления о пришедших сообщениях. А значит, самое время поговорить о протоколе. Reddwarf дает возможность работать с входящими и исходящими сообщениями как с массивом байт, оставляя реализацию протокола на усмотрение разработчика игры. Для игры «Камень-ножницы-бумага» будем использовать простой текстовый протокол.

(сервер --> клиент) SCORE <число> — сервер сообщает игроку количество очков
(клиент --> сервер) PLAY — запрос игрока на начало игры
(сервер --> клиент) BATLE <имя> — началась битва с указанным игроком
(сервер --> клиент) ERROR — игрок для битвы не найден (никого на сервере нет или все в битве)
(клиент --> сервер) ROCK — игрок говорит «Камень»
(клиент --> сервер) SCISSORS — игрок говорит «Ножницы»
(клиент --> сервер) PAPER — игрок говорит «Бумага»
(сервер --> клиент) DRAW — ничья
(сервер --> клиент) WON — игрок победил
(сервер --> клиент) LOST — игрок проиграл

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

Кодировать текст в байты и обратно можно с помощью данного кода:

package hello.reddwarf.server;

import java.nio.ByteBuffer;

public class Messages {

    public static ByteBuffer encodeString(String s) {
        return ByteBuffer.wrap(s.getBytes());
    }

    public static String decodeString(ByteBuffer message) {
        byte[] bytes = new byte[message.remaining()];
        message.get(bytes);
        return new String(bytes);
    }
}

Теперь переходим к написанию объекта игрока.
Игрок будет хранить у себя следующие поля:

  • имя
  • количество очков
  • ссылка на сервер (чтобы иметь доступ к списку онлайн-игроков)
  • ссылка на сессия (чтобы отправлять сообщения на клиент)
  • ссылка на битва (если игрок сейчас в битве, иначе null)
package hello.reddwarf.server;

import com.sun.sgs.app.*;
import com.sun.sgs.app.util.ScalableHashMap;

import java.io.Serializable;
import java.nio.ByteBuffer;
import java.util.*;

public class Player implements Serializable, ManagedObject, ClientSessionListener {

    private final static Random random = new Random();

    public final String name;

    private int score;

    // Ссылка на сессию, через которую можно отправлять сообщения на клиент
    private ManagedReference<ClientSession> sessionRef;

    // Ссылка на сервер для доступа к списку онлайн-игроков 
    private ManagedReference<Server> serverRef;

    // Ссылка на текущую битву. Если игрок не в битве - значение этого поля null
    private ManagedReference<Battle> battleRef;

    public Player(String name, Server server) {
        this.name = name;
        serverRef = AppContext.getDataManager().createReference(server);
        score = 0;
    }

    @Override
    public void receivedMessage(ByteBuffer byteBuffer) {
        // При получении сообщения разбираем его и вызываем соответствующий метод
        String message = Messages.decodeString(byteBuffer);
        if (message.equals("PLAY")) {
            play();
        } else if (message.equals("ROCK")) {
            answer(Weapon.ROCK);
        } else if (message.equals("PAPER")) {
            answer(Weapon.PAPER);
        } else if (message.equals("SCISSORS")) {
            answer(Weapon.SCISSORS);
        }
    }

    @Override
    public void disconnected(boolean b) {
        serverRef.get().disconnect(this);
    }

    private void answer(Weapon weapon) {
        if (battleRef != null) {
            battleRef.getForUpdate().answer(this, weapon);
        }
    }

    private void play() {
        logger.info("Choosing enemy for "+name);
        // Выберем случайного игрока из списка онлайн и начнем битву
        Player target = getRandomPlayer();
        if (target != null && target.battleRef == null) {
            Battle battle = new Battle(this, target);
            this.sessionRef.get().send(Messages.encodeString("BATTLE " + target.name));
            target.sessionRef.get().send(Messages.encodeString("BATTLE " + this.name));
            target.battleRef = AppContext.getDataManager().createReference(battle);
            this.battleRef = target.battleRef;
            battle.start();
        } else {
            this.sessionRef.get().send(Messages.encodeString("ERROR"));
        }
    }

    /**
     * Поиск случайного соперника (кроме самого игрока)
     * Если никого найти не удалось, возвращается null
     * @return случайный соперник или null, если не найден
     */
    private Player getRandomPlayer() {
        ScalableHashMap<String,Player> onlineMap = serverRef.get().onlinePlayersRef.get();
        Set<String> namesSet = new HashSet<String>(onlineMap.keySet());
        namesSet.remove(name);
        if (namesSet.isEmpty()) {
            return null;
        } else {
            ArrayList<String> namesList = new ArrayList<String>(namesSet);
            String randomName =  namesList.get(random.nextInt(namesList.size()));
            return onlineMap.get(randomName);
        }
    }

    public void connected() {
        // При подключении к серверу сообщим клиенту, сколько у нас очков
        sessionRef.get().send(Messages.encodeString("SCORE " + score));
    }

    /**
     * Бой закончен, игрок уведомляется о результате боя
     */
    public void battleResult(Battle.Result result) {
        switch (result) {
            case DRAW:
                score+=1;
                sessionRef.get().send(Messages.encodeString("DRAW"));
                break;
            case WON:
                score+=2;
                sessionRef.get().send(Messages.encodeString("WON"));
                break;
            case LOST:
                sessionRef.get().send(Messages.encodeString("LOST"));
                break;
        }
        sessionRef.get().send(Messages.encodeString("SCORE " + score));
        battleRef = null;
    }

    public void setSession(ClientSession session) {
        sessionRef = AppContext.getDataManager().createReference(session);
    }
}
Классы Weapon и Battle

Перечисление Weapon очень простое и комментариев не требует.
package hello.reddwarf.server;

public enum Weapon {
    ROCK,
    PAPER,
    SCISSORS;
    boolean beats(Weapon other) {
        return other != null && this != other && this.ordinal() == (other.ordinal() + 1) % values().length;
    }
}

Переходим к битве.

Битва имеет уникальный идентификатор, содержит ссылки на двух игроков, данные ими ответы, а также флаг активности.

Как только битва создана, запускается отдельная задача, которая завершит битву через 5 секунд.
По прошествии этого времени подводятся итоги битвы. Если ответ дал только один из игроков, то он считается победителем, если оба — победитель определяется по обычным правилам «Камень-ножницы-бумага».

Задача ставится на исполнение с помощью сервиса TaskManager, который можно получить с помощью AppContext.getTaskManager(). Этот менеджер позволяет запускать задачи, выполняемые в отдельной транзакции либо сразу, либо через заданный промежуток времени, либо периодически. Как и следует ожидать, все задачи также хранятся во внутренней БД, а значит, будут выполняться и после перезапуска сервера.

Итак, код класса Battle.

package hello.reddwarf.server;

import com.sun.sgs.app.AppContext;
import com.sun.sgs.app.ManagedObject;
import com.sun.sgs.app.ManagedReference;
import com.sun.sgs.app.Task;

import java.io.Serializable;
import java.util.concurrent.atomic.AtomicInteger;

public class Battle implements ManagedObject, Serializable {

    // Битва длится 5 секунд
    private static final long BATTLE_TIME_MS = 5000;

    enum Result {
        DRAW,
        WON,
        LOST
    }

    private boolean active;

    private ManagedReference<Player> starterPlayerRef;
    private ManagedReference<Player> invitedPlayerRef;

    private Weapon starterWeapon = null;
    private Weapon invitedWeapon = null;

    public Battle(Player starterPlayer, Player invitedPlayer) {
        starterPlayerRef = AppContext.getDataManager().createReference(starterPlayer);
        invitedPlayerRef = AppContext.getDataManager().createReference(invitedPlayer);
        active = false;
    }

    /**
     * Начало игры.
     * Запускается игра, через BATTLE_TIME_MS мс она будет завершена.
     */
    public void start(){
        active = true;
        AppContext.getTaskManager().scheduleTask(new BattleTimeout(this), BATTLE_TIME_MS);
    }

    /**
     * Игрок дал свой ответ.
     * Записываем ответ, данный игроком.
     * @param player - игрок
     * @param weapon - его ответ
     */
    public void answer(Player player, Weapon weapon){
        if (active) {
            if (player.name.equals(starterPlayerRef.get().name)) {
                starterWeapon = weapon;
            } else {
                invitedWeapon = weapon;
            }
        }
    }

    /**
     * Битва завершена.
     * Подводим итоги.
     */
    private void finish() {
        active = false;
        Player starterPlayer = starterPlayerRef.getForUpdate();
        Player invitedPlayer = invitedPlayerRef.getForUpdate();

        if (starterWeapon != null && starterWeapon.beats(invitedWeapon)) {
            starterPlayer.battleResult(Result.WON);
            invitedPlayer.battleResult(Result.LOST);
        } else if (invitedWeapon != null && invitedWeapon.beats(starterWeapon)) {
            invitedPlayer.battleResult(Result.WON);
            starterPlayer.battleResult(Result.LOST);
        } else {
            starterPlayer.battleResult(Result.DRAW);
            invitedPlayer.battleResult(Result.DRAW);
        }

        AppContext.getDataManager().removeObject(this);
    }

    /**
     * Задача, завершаюшая игру по прошествии заданного времени.
     */
    private static class BattleTimeout implements Serializable, Task {

        private ManagedReference<Battle> battleRef;

        public BattleTimeout(Battle battle) {
            battleRef = AppContext.getDataManager().createReference(battle);
        }

        @Override
        public void run() throws Exception {
            battleRef.getForUpdate().finish();
        }
    }
}

При чтении данного кода может возникнуть вопрос: «Почему внутренний класс BattleTimeout сделан статическим и хранит в себе ссылку на battle в явном виде? Можно же объявить его нестатическим и обращаться к полям Battle напрямую».
Дело в том, что нестатический внутренний класс будет хранить ссылку на родительский Battle в неявном виде и обращаться к Battle через нее. Но особенности платформы Reddwarf (транзакционность) запрещают обращаться к ManagedObject (которым является Battle) из другой транзакции напрямую: в таком случае будет выброшено исключение, т.к. прямая ссылка на объект в другой транзакции некорректна. Именно с этим связана рекомендация создателей платформы использовать только статические внутренние классы.

Отдельно хочется отметить получение managed-объекта по ссылке.
В вышеприведенном коде для ManagedReference используются как метод get(), так и getForUpdate().
В принципе, можно использовать только get(). Использование getForUpdate() позволяет серверу ещё до завершения транзакции знать, какие объекты будут изменены и в случае обнаружения конфликтующих транзакций отменить задачу чуть раньше. Это дает некоторый выигрыш в скорости по сравнению с использованием get().

Наконец наш сервер почти готов.
Добавим немного логирования (для простоты используем java.util.logging) и можно собирать проект.
В результате сборки мы должны получить jar-файл, допустим, deploy.jar.
Если вы не хотите собирать это всё вручную, готовый файл deploy.jar можно взять отсюда.
Этот файл необходимо поместить в sgs-server-dist-0.10.2\dist.
Теперь, находясь в директории sgs-server-dist-0.10.2 выполняем следующую команду:

java -jar bin/sgs-boot.jar

В результате чего в консоли можно увидеть следующее:

фев 02, 2012 9:45:19 PM com.sun.sgs.impl.kernel.Kernel <init>
INFO: The Kernel is ready, version: 0.10.2.1
фев 02, 2012 9:45:19 PM com.sun.sgs.impl.service.data.store.DataStoreImpl <init>
INFO: Creating database directory : C:\sgs-server-dist-0.10.2.1\data\dsdb
фев 02, 2012 9:45:19 PM com.sun.sgs.impl.service.watchdog.WatchdogServerImpl registerNode
INFO: node:com.sun.sgs.impl.service.watchdog.NodeImpl[1,health:GREEN,backup:(none)]@black registered
фев 02, 2012 9:45:19 PM hello.reddwarf.server.Server initialize
INFO: Starting new Rock-Paper-Scissors Server. Initialized database.
фев 02, 2012 9:45:19 PM com.sun.sgs.impl.kernel.Kernel startApplication
INFO: RockPaperScissors: application is ready

Ура! Сервер запустился! Теперь можно заняться клиентом:
Reddwarf на примере онлайн-игры «Камень-ножницы-бумага»: Клиент

Ссылки


Javadoc по API сервера
Документация, собранная сообществом
Форум проекта