Пишем код - Шахматы

Код на Github.

картинки нет, но вы держитесь

Предыстория: когда-то давно я реализовывал шахматы в рамках лабораторной работы в университете. Целью была практика работы с сетевыми взаимодействиями через сокеты Unix, а также изучение ООП, так как реализация была на C++. Играли через терминал shell, а вместо фигур были буквы. Но все правила были реализованы строго. Сейчас я хочу повторить то же, но уже на Java. Постараюсь соблюдать процесс, которому бы я следовал, если бы это был не искусственный пример, а более-менее полноценная коммерческая разработка.

Пост будет длинным и довольно подробным, но весь объём кода в него не поместится, поэтому предлагаю сразу скачать проект с github и открыть его в IDE. Здесь же буду приводить наиболее важные и показательные отрывки кода, основной уклон делая на ход рассуждений и принципы принятия решений.

Уточнение требований. Общий дизайн приложения.

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

Таковы в общих чертах бизнес требования. Если все согласны, то аналитики, команда тестирования и менеджер могут быть свободны или остаться, если им интересно, а разговор переходит к технической части. Вебсокеты! Html5 Unreal Engine! Kotlin! Kafka! Kubernetes! Смелые и осторожные, рациональные и безумные, традиционные и новаторские - всевозможные идеи, словно лозунги, доносились из разных сторон комнаты. Верите? Конечно, нет. Делаем на стандартном стеке, ничего лишнего, ничего нового. Слушали, постановили:

  • Frontend - html + jquery
  • Backend - Java 11, Spring Boot (web), Junit
  • Никакой сессии или cookie. Закрыл вкладку в браузере - вышел из игры.
  • Вебсокеты это слишком, привычный REST подойдет
  • На стороне клиента не будет никакой логики. Какой фигурой и на какое поле можно походить сообщает сервер. При этом не должно быть пауз и подгрузок при выборе фигуры.
  • У каждого игрока будет свой персональный токен, который будет передаваться с сервера при начале игры. Этот токен будет необходим при каждом ходе и будет передаваться в заголовке запроса.
  • Работу над сервером и клиентом начинаем одновременно

Сервер (Back-end)

Задача хоть и понятная, но достаточно объёмная. Ясно, что правила так или иначе мы реализуем. Конь ходит буквой Гэ, если при этом не выйдет за границы доски. И на этом поле нет другой фигуры того же цвета. И если после хода не открывается король. Рокировка возможна, если ни одно из полей, которые пересекает король, не находится под боем и если король и ладья в этой партии ещё не ходили. Взятие на проходе возможно только на следующий ход, после этого право теряется. Когда пешка достигает последней линии, она повышается до одной из четырёх фигур. Короче говоря, работа кропотливая и количество условий и проверок огромно. Наша цель - написать код с минимальным количеством ошибок, хорошей читаемостью и структурой.

Вторая важная часть - жизненный цикл игры. Где хранить состояние, как обрабатывать начало игры, ожидание хода соперника, параллельные игровые сессии? Архитектор сказал, что нужно использовать какой-то "Long polling". Пожалуй, придётся и с многопоточностью поработать.

А что с самим интерфейсом, то есть с форматом данных? Фигуры, цвета, ходы... Фронтенд команда просит побыстрее предоставить первую версию сервиса, чтобы им было с чем работать и не сооружать моки (mocks). С этого тогда и начнём, а в процессе глубже вникнем в задачу и наметим план дальнейших действий.

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

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

Все классы, являющиеся телом ответа (@ResponseBody, эта аннотация включена в @RestController), я собрал в отдельном пакете api.model и добавил к их именам постфикс "Dto". Я пользуюсь следующей конвенцией имён - бизнес сущности не имеют никакой специфики. К классам JPA добавляется окончание "Entity", а для REST интерфейса - Dto. Преобразовывать классы разных уровней приходится вручную. На одном из проектов, с которым я работал, все три случая обрабатывались одним классом и мне это показалось очень неудобным и хрупким подходом. В таком классе по умолчанию случайный геттер сразу попадает в JSON, а любое поле Hibernate пытается записать а базу. Когда с этим неизбежно возникает проблема, приходится расставлять повсюду всевозможные @JsonIgnore, transient и так далее. Кроме того, аннотации разных библиотек смешиваются вместе, а любые изменения нужно проверять на всех уровнях.

Но вернёмся к шахматам. Я решил передавать только фигуры, присутствующие на доске. Пустые поля клиент должен определить сам методом исключения. Каждый раз передаётся полное состояние, а не только разница с предыдущим ходом. Позиции фигур будут строкой из двух символов ("e2", "f1") - это не очень удобно в коде сервера, зато очень кратко, естественно и понятно всем. Забегая вперед, скажу, что в самом движке позиция будет представлена классом из двух целых чисел от 1 до 8. Для типов фигур и цвета лучше всего подойдут перечисления (Enum), рассчитываем, что jackson по умолчанию успешно осуществит сериализацию и десериализацию enum в строку. (Для сравнения - JPA по умолчанию превращает enum в число и нужно указывать аннотацию @Enumerated(EnumType.STRING).

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

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

    public class GameStateDto {
        private List<PieceDto> pieces;
        private ColorDto currentPlayer;
        private MoveDto lastOpponentMove;
        ...
    }
    
PieceDto - одна фигура. Тип, цвет и поле. Интересно, что среди картинок фигур был вариант где один конь смотрит вправо, а другой - влево. В этом случае пришлось бы отличать одного от другого и хранить идентификатор. Но я решил, что для шахмат этого противоестественно, ведь теоретически в партии можно сделать до 10 коней для каждого игрока.
    public class PieceDto {
        private String position;
        private ColorDto color;
        private PieceTypeDto pieceType;

        private List<String> validMoves;
        ...
    }
    
Свойство validMoves - куда можно переместить фигуру. Этот список будет заведомо пуст или null в определённых случаях. Введение такой информации усложняет сервер, но иначе процесс хода выглядел бы так - игрок перемещает фигуру, а сервер подтверждает или отвечает ему - "Можно" или "Нельзя!". Конечно, всё это происходило бы с паузами не меньше 50-200ms на обработку запроса. Сейчас же клиент принимает только разрешённые ходы. Естественно, сервер всё равно должен их проверить на случай манипуляций. С перечислениями всё просто:
    public enum ColorDto {
        BLACK, WHITE
    }

    public enum PieceTypeDto {
        BISHOP,
        KING,
        KNIGHT,
        PAWN,
        QUEEN,
        ROCK;
    } 
Создаём объект с парой фигур, чтобы оценить результат сериализации в JSON:
        {
        "pieces": [
              {
                  "position": "e2",
                  "color": "WHITE",
                  "pieceType": "PAWN",
                      "validMoves": [
                          "e3",
                          "e4"
                      ]
              },
              {
                  "position": "f7",
                  "color": "BLACK",
                  "pieceType": "PAWN",
                  "validMoves": null
              }
        ],
        "currentPlayer": "WHITE",
        "lastOpponentMove": null
        }    

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

Game engine

Реализация правил игры состоит из базовых типов и управляющих конструкций языка. Здесь нет взаимодействия с базой данных, веб-сервисов, многопоточности, масштабируемости, контейнеров и фреймворков. Только несколько классов и функций с проверками, циклами, конструкциями switch. Кроме того, алгоритмы очень простые, не требующие оптимальности ввиду маленького игрового поля, а также не использующие никаких сложных структур данных. Код, который нам нужно написать должен проверять, что слон ходит по диагонали и не перепрыгивает через другие фигуру того же цвета. Ладья - по вертикали и горизонтали. Пешка - на одно или два поля вперёд, а атакует по диагонали. Не забываем про рокировки и взятие на проходе (en passant). В общем, много отдельных случаев, каждый из которых прост, но сложно заставить всё работать правильно сразу. Хочется минимизировать риск ошибок, таких как: для белых проверяем, а для чёрных забыли; правую границу проверяем, а левую нет; пешка ходит не в ту сторону. В общем, всех ошибок, которые возможны когда в длинной цепочке проверок и сравнений нескольких чисел потеряли одну, перепутали меньше-больше или плюс с минусом.

Давайте посмотрим на вот такой код. Что он делает, работает ли он? Сколько времени потребуется, чтобы разобраться с ним тому, кто его не писал?

    private List<Position> tricky(int x1, int y1) {
        List<Position> result = new LinkedList<>();

        for (int x2 = 1; x2 <= 8 ; x2++) {
            for (int y2 = 1; y2 <= 8; y2++) {
                int mx = Math.abs(x1 - x2);
                int my = Math.abs(y1 - y2);
                if( (mx + my == 3) && (mx * my == 2)
                    && Math.abs(x2 - 4.5) < 4.5
                    && Math.abs(y2 - 4.5) < 4.5) {
                        result.add(Position.of(x2,y2));
                }
            }
        }

        return result;
    }
    
Результат выполнения функции - все позиции, на которые можно переместить коня с поля (x1, y1). Кратко. Хитро. Не сразу придумаешь, особенно этот трюк с числом 4.5. Его корректность можно показать следующими рассуждениями:
           x - в границе поля.
           1 <= x <= 8, x - целое число, следовательно
           0 < x < 9, следовательно,
        -4.5 < x - 4.5 < 4.5, то есть
           | x - 4.5 | < 4.5
    
В дополнение к коду придётся прикладывать математические выкладки. Однако этот код очень эффективный, и если убрать перебор всех клеток поля, что не очень сложно, то вероятно, будет оптимальным. Но нужна ли нам тиакая эффективность и оптимальность здесь? Думаю, что нет. Нам нужно, чтобы код был выразительным, понятным, поддерживаемым. Подробнее в посте про алгоритмическую сложность.

Я постарался в своей реализации минимизировать операции с целыми числами и проверки границ поля. Вся логика приложения "раскручивается" от четырёх операций с объектом класса Position: up1, down1, left1, right1. Up1 - значит на одно поле вверх. Понятие "вверх" одинаково для игрока за черных и за белых.

    public class Position {
        //from 1 to 8
        private final int x;
        private final int y;
        ...

        public Position up1() {
            return (y < 8) ? of(x, y + 1) : null;
        }
        public Position down1() {
            return (y > 1) ? of(x, y - 1) : null;
        }
        ...//right1, left1 - аналогично
    
Вместо null было бы неплохо использовать Optional, но тогда половина кода превращается в манипуляции с ним, поэтому я оставил null для случаев, когда при перемещении мы выходим за границы поля. Но за пределы класса Position значение null на практике не выходит. Также отмечу, что класс Position используется как неизменяемый (immutable) объект. Любой сдвиг - создание нового экземпляра объекта. Теперь напишем функцию up(), возвращающую все поля выше текущего до границы поля.
    public Stream<Position> up() {
        return Stream.iterate(this, Objects::nonNull, Position::up1).skip(1);
    }
    
Создаём stream при помощи итерации от текущего поля до null, на каждом шаге применяем функцию up1. Первый элемент, являющийся текущей позицией, пропускаем.

Чтобы описать поведение коня, понадобится вспомогательная функция, применяющая несколько одинарных изменений позиции. По сути применяем move.apply(finalPosition) пока не закончатся элементы в массиве moves или операция не вернёт null.

    private Position move(UnaryOperator<Position>... moves) {
        Position finalPosition = this;
        for (int i = 0; i < moves.length && (finalPosition != null); i++) {
            UnaryOperator<Position> move = moves[i];
            finalPosition = move.apply(finalPosition);
        }
        return finalPosition;
    }
    
И вот, что в результате получается для коня:
    public List<Position> knight() {
        List<Position> moves = new LinkedList<>();
        moves.add(move(Position::up1, Position::up1, Position::right1));
        moves.add(move(Position::up1, Position::up1, Position::left1));
        moves.add(move(Position::down1, Position::down1, Position::right1));
        moves.add(move(Position::down1, Position::down1, Position::left1));
        moves.add(move(Position::right1, Position::right1, Position::up1));
        moves.add(move(Position::right1, Position::right1, Position::down1));
        moves.add(move(Position::left1, Position::left1, Position::up1));
        moves.add(move(Position::left1, Position::left1, Position::down1));

        moves.removeIf(Objects::isNull);
        return moves;
    }
    
Можно подумать, как избавиться от восьми однотипных строчек, применив какой-нибудь перебор, но ради одной функции это неразумно. По ходу дела проверяем всё юнит тестами, потому что опечатки и ошибки по невнимательности здесь вполне вероятны.
    @Test
    void testKnight1() {
        Position p = Position.of(1, 1);
        List<Position> kn = p.knight();
        assertEquals(2, kn.size());
        assertTrue(kn.contains(Position.of(3, 2)));
        assertTrue(kn.contains(Position.of(2, 3)));
    }

    @Test
    void testKnight2() {
        Position p = Position.of(4, 3);
        Set<Position> kn = new HashSet<>(p.knight());
        assertEquals(8, kn.size());
    }
    
Я проверяю угловое поле и центральное, поверхностно. Планирую, что тесты на более высокоуровневую логику заодно проверят и эту.

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

    public class Board implements Cloneable {
        final Map<Position, Piece> whitePieces = new HashMap<>();
        final Map<Position, Piece> blackPieces = new HashMap<>();
        ...
    }
    public class ChessGame {
        final PieceColor currentPlayer;
        final List<Board> previousStates;
        final List<PieceMove> previousMoves;
        final Board board;
        ...
    }

    public abstract class Piece {
        final Position position;
        final PieceColor pieceColor;
        final Type pieceType;
        ...
        public abstract Set<Position> validPieceMoves(ChessGame game);

        public final Set<Position> finallyValidMoves(ChessGame game) {
            Set<Position> moves = validPieceMoves(game);

            //remove target positions where same team pieces are present
            moves.removeIf(pos ->
            game.at(pos).map(piece -> piece.pieceColor == pieceColor).orElse(false));

            //remove target positions where king will be in trouble after move
            PieceColor currentPlayer = game.getCurrentPlayer();
            moves.removeIf(m -> game.applyMoveNoValidate(new PieceMove(position, m)).kingUnderAttack(currentPlayer));

            return moves;
         }
    }
    
Абстрактный класс Piece определяет свойства и поведение общее для всех фигур: нельзя перемещать фигуры на поля, занятые другими фирурами того же игрока, после хода король не может попасть или остаться под боем. При этом в общем случае требуется не только информация о текущем положении фигур (Board), но и информация о ходе партии, поэтому в качестве параметра используется ChessGame. Классы Pawn, King, Queen и другие наследуют Piece и отвечают за реализацию сильно специфичного поведения фигур, наподобие рокировок или ходов пешек.

Код классов Queen, Bishop, Knight, Rock - однотипный и тривиальный:

    public class Queen extends Piece {
        ...
        @Override
        public Set<Position> validPieceMoves(ChessGame game) {
            return position.moveUntilHit(position.queen(), game, pieceColor);
        }
        ...
    }
    
А вот для пешки или короля приходится всё-таки использовать разветвлённые if-else конструкции, соответствующие всем проверкам. Ниже, например, реализована проверка возможности взятия на проходе. Напомню, что пешка одного цвета может атаковать пешку соперника, если на предыдущем ходе пешка соперника была перемещена на два поля вперед и таким образом пересекла поле, находящееся под боем.
    Map<PieceColor, UnaryOperator<Position>> MOVE_FOWARD = Map.of(
        PieceColor.WHITE, Position::up1,
        PieceColor.BLACK, Position::down1
    );

    private Optional<Position> enPassant(ChessGame game) {
        if (!ENPASSANT_LINE.get(pieceColor).equals(position.getY())
            || game.getPreviousMoves().isEmpty()) {
            return Optional.empty();
        }
        UnaryOperator<Position> moveForward = MOVE_FOWARD.get(pieceColor);
        UnaryOperator<Position> moveBackward = MOVE_BACKWARD.get(pieceColor);
        Position fl = moveForward.apply(position).left1();
        Position fr = moveForward.apply(position).right1();

        for (Position forwardAttack : new Position[]{fl, fr}) {
             if (forwardAttack != null) {
                  Position near = moveBackward.apply(forwardAttack);
                  Position nearFrom = moveForward.apply(forwardAttack);

                  PieceMove lastOppMove = game.getPreviousMoves().get(game.getPreviousMoves().size() - 1);
                  if (game.at(near).map(p -> p.getPieceType() == Type.PAWN && p.pieceColor != pieceColor).orElse(false)
                        && lastOppMove.getFrom().equals(nearFrom)
                        && lastOppMove.getTo().equals(near)) {
                      return Optional.of(forwardAttack);
                  }
             }
        }

        return Optional.empty();
    }
    
С таким кодом придётся разбираться в любом случае, потому что запутанная логика исходит из бизнес требований, а не из неудачного подхода или плохой реализации. Стараемся выделить подобный код в отдельную функцию и не смешивать с остальной частью, работающей по более-менее стандартной логике. Важно унифицировать логику для белых и чёрных, иначе все тесты придётся писать для обеих сторон. Тесты писать муторно и скучно, потому что приходится воссоздавать ситуацию на доске целиком, но этого не избежать. Во время реализации я проверил все сценарии в тестах, кроме повышения пешки до другой фигуры, которое я решил воспроизвести уже с помощью браузера. И конечно же, там была ошибка по типу copy-paste: ход применялся к исходному полю, а не к его копии (классы Board, ChessGame и другие хоть и имеют изменяемое внутреннее состояние, но перед манипуляциями создаются копии объектов).

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

    @Test
    void testValidMovesKnight1() {
         Piece whiteKing = new King(Position.of(2,2), PieceColor.WHITE);
         Piece blackKnight = new Knight(Position.of(4,3), PieceColor.BLACK);
         Piece whiteKnight = new Knight(Position.of(5,5), PieceColor.WHITE);

         Board board = new Board(Arrays.asList(whiteKing, whiteKnight, blackKnight));
         ChessGame chessGame = new ChessGame(PieceColor.WHITE, Collections.emptyList(), Collections.emptyList(), board);
         Set<Position> validMoves = whiteKnight.finallyValidMoves(chessGame);

         assertEquals(Sets.newSet(blackKnight.getPosition()), validMoves);
    }
    

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

    public boolean kingUnderAttack(PieceColor player) {
        Set<Position> attackPositions =
            board.pieces(player.negate()).stream()
                .flatMap(p -> p.validPieceMoves(this).stream())
                .collect(Collectors.toSet());

        return attackPositions.contains(board.king(player));
    }

    public void updateGameStatus() {
        this.validMovesForCurrentPlayer = validMovesForCurrentPlayer();

        boolean kingAttacked = kingUnderAttack(currentPlayer);
        boolean canMove = validMovesForCurrentPlayer.values().stream().anyMatch(s -> !s.isEmpty());

        if (canMove && kingAttacked) {
            status = GameStatus.CHECK;
        }
        if (!canMove && kingAttacked) {
            status = GameStatus.CHECKMATE;
            finished = true;
        }
        if (!canMove && !kingAttacked) {
            status = GameStatus.DRAW_STALEMATE;
            finished = true;
        }
    }
    

Я очень интенсивно использую Optional и Stream, поэтому полагаю, что код можем показаться сложнее, чем если бы те же проверки осуществлялись простыми циклами и проверками if-else. В первом случаем код получается более декларативным, то есть он описывает "что" нужно сделать, а второй - императивный, то есть "как" сделать, указав конкретные шаги. Декларативный подход чаще всего лучше, но к нему нужно привыкнуть.

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

    @Test
    void testMoves() {
        ChessGame game = ChessGame.startGame();
        List<PieceMove> moves = movesFromString("e2e4 e7e5 d1h5 b8c6 f1c4 g8f6 h5f7").collect(Collectors.toList());
        for (PieceMove move : moves) {
            game = game.applyMove(move);
        }

        assertTrue(game.isFinished());
        assertEquals(GameStatus.CHECKMATE, game.getStatus());
    }
    

Мы всё ещё не можем быть уверены, что ошибок нет, очень много игровых ситуаций не проверено ни вручную, ни автоматически, хотя с учётом ещё нескольких тестов, которые я не упомянул, покрытие строк кода почти полное. Но покрытие строк далеко от покрытия всех сценариев. Как же быть? Как правило, тестирование проводит отдельная команда, которая собирает и поддерживает достаточный список проверок приложения (все или основные бизнес сценарии). По возможности эти проверки автоматизируются, но это уже не юнит тесты, а проверки на живом окружении, в котором приложение работает в условиях, близких к продакшену. Давайте подумаем, как бы мы могли улучшить тесты, чтобы увеличить уверенность в том, что реализация работает верно. Идею написать больше однотипных тестов оставим без рассмотрения как очевидную. Один из вариантов - если бы существовала гарантированно правильная реализация, то мы могли бы сравнить поведение на большом количестве случайных партий. Это очень удачный случай, но не часто будет такая реализация, которую мы можем назвать эталонной, можем использовать в тесте, но не в коде. Другой вариант - если бы удалось получить архив реальных шахматных партий в текстовом формате - тогда можно проверить, что все ходы и результаты партий согласуются с нашей реализацией. Правда, так мы проверим только заведомо разрешённые ходы. Сейчас же в плане юнит тестов для правил остановимся на достигнутом.

На данный момент приложение умеет:

  1. Создавать стартовое игровое поле с 16 фигурами
  2. Определять, на какие поля игрок может переставить каждую фигуру
  3. Проверять, разрешен ход или нет (используя предыдущий пункт)
  4. Менять состояние игрового поля, применяя перемещение фигуры
  5. Определять окончание партии победой игрока или вничью

Однако, весь процесс пока не имеет никакой связи с REST сервисом, который будет использоваться игровым клиентом.

REST

Для начала подумаем какие данные, какому игроку, в какой момент и в каком объёме нужно предоставить. Что касается объёма данных, то, к счастью, оба игрока владеют полной информацией об игровом поле и ходе партии, в отличии, например, от игры в морской бой. Также мы уже условились, что игровое поле передаётся целиком каждый раз, а не только те поля, которые изменили своё состояние. Очевидно, что после завершения хода, то есть когда пользователь кликнет на поле или отпустит кнопку при переносе фигуры, должен быть вызван какой-то сервис для передачи информации о ходе. Ещё нужен способ передать данные от сервера к клиенту, когда оппонент завершит ход. Это несвойственно протоколу HTTP, но необходимо для достижения требований: ход должен передаваться без пауз, а если мы будем периодически опрашивать сервер, даже раз в секунду, то пауз не избежать.

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

  1. Websockets
  2. Server sent events
  3. Long polling

Я остановился на Long Polling, так как раньше он использовался в Facebook (может и сейчас используется) для доставки личных сообщений и обновлений ленты, что очень похоже на нашу задачу по своей природе. Не хочу описывать эти подходы подробно, потому что получится не точно, опыта работы с ними у меня нет. Я понимаю области их применения так: websockets - онлайн игры с интенсивным трафиком, где дорог каждый байт и каждая миллисекунда. Минусы - слишком низкоуровневый протокол, нужно делать собственную надстройку. Может не поддерживаться определёнными браузерами, желательно использовать библиотеки, которые в случае чего переключаются на HTTP, что дополнительно усложнит код и тестирование. Server sent events - хорошо подходит для просмотра графиков биржи с обновлениями в режиме реального времени. Многократные однотипные события через небольшие промежутки времени. Определённым образом ограничивает количество соединений - это требует дополнительного изучения. Long Polling - простой трюк использования протокола HTTP - сервер возвращает данные когда они появляются, а клиент работает как с обычным очень медленным запросом. Главное не попасть на таймауты от прокси сервера, но это решается периодическим повторением запроса. Для нашей задачи, в которой ответ может прийти через секунды, минуты или часы, подходит лучше всего.

Теперь, выбрав Long polling в качестве способа ожидания действия соперника, наш сервис обретает две механики: запросы, которые обрабатываются сразу и запросы, которые обрабатываются неопределённо долго.

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

Так как число одновременных партий никак не ограничено, каждая игра имеет уникальный идентификатор. Чтобы отличать игроков друг от друга, каждому выдаётся свой уникальный (и секретный) токен, который генерируется классом UUID. Так решаются две задачи - во-первых, защита партии от неавторизованных вызовов (ведь процесса аутентификации у нас нет), а во-вторых, идентификация и отличие игрока белыми и игрока чёрными. Напомню, что сейчас информация о партии хранится в классе ChessGame, в котором нет упоминания идентификаторов или токенов. Для этого введём дополнительный класс ChessGameMetadata.

    public class ChessGameMetadata {
        private String id;
        private Map<PieceColor, String> playerTokens = generateTokens();
        private ChessGame chessGame;
        private boolean secondPlayerJoined = false;
    
Эту информацию можно было бы включить и в класс ChessGame, но хочется оставить ядро системы независимым от способа взаимодействия с игроками и сервиса. Классы ChessGame и ChessGameMedatada не предназначены для сериализации в JSON напрямую, поэтому существуют функции, преобразующие их к уже известному нам GameStateDto и новому GameConnectionParamsDto. Второй класс потребовался для передачи токена в начале партии каждому игроку. Не хочется, чтобы токен передавался к игроку при каждом вызове, так его намного легче перехватить, да и клиенту он не нужен, потому что не может измениться в процессе партии.
    public class GameConnectionParamsDto {
        private String id;
        private String token;
        private ColorDto playerSide;
        private GameStateDto gameState;//состояние игры также передаётся, чтобы не заставлять клиента делать лишний вызов.
        ...
    }
    

С учётом всего вышесказанного, полный жизненный цикл игры обеспечивается с помощью четырёх методов (endpoint). Все методы изменяют состояние сервера, поэтому используют HTTP метод POST, даже если тело запроса не содержит параметров.

  1. /host Немедленно создаёт новую игру и возвращает GameConnectionParamsDto с id игры и токеном игрока за белых. Тело запроса пустое. Заголовки запроса отсутствуют. В будущем в тело запроса можно добавить настройки игры: таймер, сторону.
  2. /{id}/join Находит игру по идентификатору "id" и немедленно возвращает GameConnectionParamsDto с id игры и токеном игрока за чёрных. Тело запроса пустое. Заголовки запроса отсутствуют.
  3. /{id}/wait-for-my-move Long polling. Если вызвать на своем ходу, то немедленно возвращает GameStateDto. Если на чужом - то запрос "повисает" до тех пор, пока соперник не сделает ход. Таймаут на сервере установлен в 60 минут, после чего запрос завершится со статусом 408. Впрочем, реализация клиента повторяет запрос намного раньше, каждые пять минут. Тело запроса пустое. Заголовок запроса включает токен игрока. Чтобы не создавать отдельный сервис, подключение второго игрока считается первым ходом чёрных.
  4. /{id}/move Находит игру по id, изменяет её состояние, совершая один ход и немедленно возвращает обновленное состояние в виде GameStateDto. Тело запроса соответствует классу MoveDto, который включает два поля (from, to) и строку promotion, которая заполняется для повышения пешки, достигшей края поля. Заголовок запроса включает токен игрока. Гипотетически, можно было бы объединить c /wait-for-my-move, сразу инициируя ожидание хода коперника. Но тогда клиент должен уметь определить состояние доски после хода, а в случае рокировок и взятий на проходе это не тривиально и потребует логики на клиенте.

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

  1. Первый игрок открывает главную страницу, JS сразу выполняет запрос /host, получает id игры и формирует ссылку для второго игрока, также отправляет запрос /wait-for-my-move
  2. Второй игрок открывает ссылку. Это та же главная страница, но с параметром запроса ?joinid={id}. JS получает id игры из параметров URL и отправляет запрос /{id}/join. Результат запроса содержит стартовое игровое поле в ожидании хода белых. JS отображает это поле в браузере и отправляет запрос /wait-for-my-move в ожидании хода белых.
  3. Как только завершается вызов /join, первый запрос /wait-for-my-move возвращает результат, сообщая о том, что второй игрок подключился и партия началась. Первый игрок думает, потом совершает ход, что приводит к вызову /move и следом /wait-for-my-move.
  4. Как только завершается вызов /move, второй запрос /wait-for-my-move возвращает результат, сообщая о том, что первый игрок совершил ход, и ход переходит к чёрным. Второй игрок думает, потом совершает ход, что приводит к вызову /move и следом /wait-for-my-move.
  5. Цикл завершается, если в объекте GameStateDto поле gameFinished становится равным true.

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

Перейдём к коду. Чтобы не откладывать на потом и не вносить изменения в уже протестированные сервисы, начнём с обработки ошибок. Для это вводим несколько собственных классов исключений, а с помощью аннотации @ExceptionHandler отдельно от основной реализации размещаем код, который для каждого типа ошибки устанавливает соответствующий HTTP статус.

    @ExceptionHandler(GameNotFoundException.class)
    public ResponseEntity<?> handleNotFound() {
        return new ResponseEntity<Object>("game not found", new HttpHeaders(), HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(InvalidMoveException.class)
    public ResponseEntity<?> handleInvalidMove() {
        return new ResponseEntity<Object>("bad move", new HttpHeaders(), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<?> handleInvalidToken() {
        return new ResponseEntity<Object>("bad token", new HttpHeaders(), HttpStatus.FORBIDDEN);
    }
    

С ошибками разобрались, давайте попробуем реализовать host. Нужно расставить фигуры, сгенерировать токен и где-то сохранить результат для дальнейшего использования. Чтобы не завязываться на класс контроллера, добавим интерфейс и реализацию репозитория игр. Так мы абстрагируемся от способа хранения игр - в памяти, в базе данных, в распределённом кэше или на файловой системе - за это будет отвечать реализация, а контроллер об этом знать ничего не будет.

    public interface GameRepository {
        ChessGameMetadata newGame();
        Optional<ChessGameMetadata> find(String id);
        void save(ChessGameMetadata chessGameMetadata);
    }
    
На данный момент сервер у нас будет только один, на облачные хостинги может замахнёмся в другой раз, поэтому реализация в памяти нас вполне устроит.
    @Repository
    public class InMemoryGameRepository implements GameRepository {
        private Map<String, ChessGameMetadata> metadataMap = new ConcurrentHashMap<>();

        @Override
        public ChessGameMetadata newGame() {
            ChessGame chessGame = ChessGame.startGame();
            ChessGameMetadata chessGameMetadata = new ChessGameMetadata();
            chessGameMetadata.setChessGame(chessGame);
            chessGameMetadata.setId(generateId());

            metadataMap.put(chessGameMetadata.getId(), chessGameMetadata);
            return chessGameMetadata;
        }

        @Override
        public Optional<ChessGameMetadata> find(String id) {
            return Optional.ofNullable(metadataMap.get(id));
        }

        @Override
        public void save(ChessGameMetadata chessGameMetadata) {
            metadataMap.put(chessGameMetadata.getId(), chessGameMetadata);
        }
        ...
    }
    
Так как данный код будет использоваться в REST сервисе, который по природе своей работает многопоточно, то выбираем потокобезопасную реализацию Map - ConcurrentHashMap. Вообще, многопоточность - очень скользкая и опасная тема, и проверить или доказать, что использования ConcurrentHashMap достаточно, довольно трудно. Будем стараться делать лучше, чем хуже и тестировать код. Расставлять везде synchronized, чтобы гарантированно избегать коллизий - это плохое решение. Репозиторий также отвечает за генерацию идентификатора - так же, как и обычно при работе с базами данных.

Те сервисы, которые возвращают результат сразу реализованы стандартно через Spring web:

    @PostMapping("/host")
    public ResponseEntity<?> host() {
        ChessGameMetadata metadata = repository.newGame();
        String whitePlayerToken = metadata.getPlayerTokens().get(PieceColor.WHITE);

        return ResponseEntity.ok(new GameConnectionParamsDto(metadata.getId(), whitePlayerToken,
            ColorDto.WHITE, metadata.getGameStateDtoForPlayer(whitePlayerToken)));
    }
    
Логика контроллера заключается только в преобразовании ответа к Dto объектам.

А вот Long Polling - вещь нетривиальная, и на ней остановимся подробнее. В первую очередь, чтобы не занимать поток исполнения веб сервера (tomcat) в качестве возвращаемого результата используется не Dto объект, а специальный DeferredResult:

    @PostMapping("{id}/wait-for-my-move")
    public DeferredResult<?> waitForMove(@RequestHeader("ptoken") String playerToken,
          @PathVariable("id") String id)
    
Сам метод контроллера отработает без пауз, а вот ответ на запрос будет отправлен только тогда, когда явно будет вызван метод deferredResult.setResult. Мы знаем, что это нужно сделать сразу после действия другого игрока. Причём, очевидно, это будет вызов через неопределённое время и из другого потока. Spring web mvc не даёт никаких указаний на счёт того, как это реализовать, нужно решать самостоятельно, выбирая из тех механизмов работы с потоками, которые нам доступны в силу нашего опыта и специфики приложения.

Изолируем задачу: в один момент времени создаётся объект deferredResult в одном потоке. Затем после наступления определённого события нужно выполнить действие setResult. Приведём к стандартной модели Publish-Subscribe. Один поток подписывается на событие - завершение хода другим игроком, а второй поток публикует это событие как только вызывается метод сервиса /move.

Какие же варианты реализации Publish-Subscribe существуют? Вообще их безумно много.

  1. Wait-notify. На собеседованиях иногда предлагают реализовать ping-pong между двумя потоками. Полезно в образовательных целях, в реальных проектах нежелательно, слишком легко допустить ошибку, которая проявится в самый неподходящий момент.
  2. Блокирующие очереди, например, LinkedBlockingQueue. Метод .take() блокируется и ожидает добавления элементов в очередь. Вполне рабочий способ.
  3. Способы с использованием библиотек или внешних сервисов: Kafka, JMS, ActiveMQ, Reactor Event Bus. Хорошие, но тяжеловесные решения. Сейчас они излишни, а вот если переносить приложение в облако контейнеров, то придётся к ним вернуться.
  4. Java 9 Reactive Streams. Самый современный способ. Выберем его, чтобы попрактиковаться и оценить результат. Крайне рекомендую посмотреть пример на Baeldung, иначе будет совсем непонятно.

Логику по работе с Publisher/Subscriber инкапсулируем в отдельном классе GameManager, чтобы не перегружать контроллер. Для начала создадим структуру для хранения обработчиков. Для каждой пары (игра, игрок) отображение содержит ровно один обработчик, который в качестве параметра принимает изменённое состояние доски - после хода соперника.

    public class GamePlayerDesc {
        private final String gameId;
        private final String playerToken;
        ...
    }

    @Service
    public class GameManager {
        final ConcurrentMap<GamePlayerDesc, Consumer<ChessGameMetadata>> handlers = new ConcurrentHashMap<>();
        ...
    }
    
Теперь когда сервис /wait-for-my-move инициирует ожидание, в handlers добавляется обработчик:
    //GameManager
    public void awaitOpponentMove(GamePlayerDesc gamePlayerDesc, Consumer<ChessGameMetadata> handler) {
        handlers.put(gamePlayerDesc, handler);
    }
    //GameController
    gameManager.awaitOpponentMove(new GamePlayerDesc(id, playerToken), m -> {
        deferredResult.setResult(m.getGameStateDtoForPlayer(playerToken));
        log.debug("await move completed {} {}", id, playerToken);
    });
    
А когда обрабатывается /move, то по цепочке вызовов выполняется публикация события:
    gameUpdatesPublisher.submit(chessGameMetadata);
    
В соответствии с принципами работы Reactive Streams это в свою очередь приводит в вызову метода onNext() где-то там на специальном классе, реализующем интерфейс Subscriber. Метод onNext находит обработчик, удаляет его из отображения (Map) и вызывает метод accept. Метод accept вызывает setResult на deferredResult, отправляется ответ на запрос /waitj-for-my-move, тем самым круг замыкается.
    class GameUpdatesSubscriber implements Flow.Subscriber<ChessGameMetadata> {
    ...
        @Override
        public void onNext(ChessGameMetadata metadata) {
             GamePlayerDesc gamePlayerDesc = metadata.currentPlayerDesc();
             Optional.ofNullable(handlers.remove(gamePlayerDesc)).ifPresent(h -> {
                 h.accept(metadata);
             });

             subscription.request(1);
        }
     ...
    }  

Сложно, запутанно, непонятно. А код юнит тестов едва ли не сложнее основного. Но с этим особенно ничего не поделаешь, с многопоточностью так всегда, а упрощать задачу, например, заменив long polling на частые вызовы со стороны клиента, не хотелось. Что касается Reactive Streams, то думаю, что буду применять эту технологию и в дальнейшем. В данном случае она решает задачу хорошо, но кроме того обладает уникальной дополнительной функциональностью неблокирующего "обратного давления" (back pressure), за счёт которого можно избегать переполнения очереди подписчика, если он не успевает обрабатывать события. Эта возможность сама собой достигается при синхронных вызовах, но при асинхронных её было довольно трудно достичь.

Так или иначе, сервер практически готов. На все сервисы по ходу реализации я добавлял юнит тесты. Они получились довольно объёмными и в процессе я нашёл несколько ошибок, но не в коде, а в самих тестах! Например, вот так я отправляю POST запрос:

    private <T, R> T post(String subUrl, Optional<String> pheader, R body, Class<T> clazz) {
         HttpHeaders httpHeaders = new HttpHeaders();
         pheader.ifPresent(h -> {
              httpHeaders.add("ptoken", h);
         });

          HttpEntity<R> request = new HttpEntity<>(body, httpHeaders);
          ResponseEntity<T> responseEntity =
              restTemplate.postForEntity("http://localhost:" + port + "/api/game/" + subUrl, request, clazz);

          assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
          return responseEntity.getBody();
    }
    
А вот так я ожидаю, когда завершится вызов wait-for-my-move:
    private CompletableFuture<GameStateDto> awaitMove(String id, String pheader) {
          return CompletableFuture.supplyAsync(() ->
          post(id + "/wait-for-my-move", Optional.of(pheader), "", GameStateDto.class), cfPool);
    }
    ...
    for (MoveDto move : moves) {
         try {
             state = awaitMove(id, token).get(TIMEOUT, TimeUnit.SECONDS);
         } catch (InterruptedException | ExecutionException | TimeoutException e) {
             e.printStackTrace();
             throw new AssertionFailedError(e.getMessage());
         }
         assertTrue(state.isMyTurn());
         state = move(id, token, move);
         assertFalse(state.isMyTurn());
    }  
В целом схема простая: у каждого игрока есть последовательность ходов, и поочерёдно вызываются сервис /move и /wait-for-my-move. Для каждой партии создаётся два потока под этот цикл: для чёрных и для белых, а в конце тест снова использует CompletableFuture.get(...). чтобы дождаться окончания партии и проверить результат. В тесте, проверяющем одну партию всё работало. Две партии тоже работали. Три не помню, но четыре непременно приводили к повисанию системы навечно. В дампе потоков было видно, что какой-то из пулов потоков полностью исчерпан и все потоки в состоянии WAITING (или TIMED_WAITING, не помню). Я грешил на то, что неправильно использую Reactive Streams. Или может DeferredResult не освобождает поток, вопреки документации. Всё это не подтвердилось.

Короче говоря, проблема оказалась в том, что в тесте на одну партию запускалось 3-4 метода CompletableFuture.get(...), но каждый из них естественно занимает один поток. Он не занимает процессор, память, сеть или диск, то есть ресурсы не тратятся, всё как положено. Но поток-то занят! Да этих потоков можно создать хоть 32 000, а то и больше, но это если создавать из через new Thread(), забивая планировщик задач. Но во всех механизмах работы с потоками, которые сейчас есть в приложении, потоки не создаются бесконтрольно, используются пулы потоков. Это и потоки самого сервера Tomcat, обрабатывающие HTTP запросы (по умолчанию максимум 200 потоков). Это и потоки, которыми пользуется SubmissionPublisher из Reactive Streams. Это и потоки, которые обрабатывают Completable Future в тесте при запуске параллельных партий.

Пул потоков для Tomcat в расчёт не берем, он живет своей жизнью. А вот все остальные по умолчанию используют встроенный ForkJoinPool.commonPool из восьми потоков. Очень быстро все потоки уходят в ожидание результата по CompletableFuture.get(...), а для обработки ходов, которые бы привели к результату и окончанию этого ожидания потоков просто нет. Это вполне можно назвать ситуацией Dead-Lock, потому что цикл разрывается только когда CompletableFuture.get(...) завершается по таймауту, но тогда последовательность выполнения нарушается и результат оказывается неверный. Решение - использовать отдельные пулы для разных категорий обработчиков и, в первую очередь для CompletableFuture. За это отвечает последний параметр cfPool:

    ExecutorService cfPool = Executors.newFixedThreadPool(12, r -> new Thread(r, "completable future pool"));

    ...
    CompletableFuture<GameStateDto> whiteMoveseq = CompletableFuture.supplyAsync(() ->
    moveSequence(white.getId(), white.getToken(), whiteMoves), cfPool);

    CompletableFuture<GameStateDto> blackMoveseq = CompletableFuture.supplyAsync(() ->
        moveSequence(black.getId(), black.getToken(), blackMoves), cfPool);
    
Теперь тест проходит и появляется уверенность (надежда), что большое количество одновременных партий никак не влияют друг на друга, то есть вся эта ужасающая многопоточность работает не только в теории, но и на практике. Лишний раз убеждаюсь в том, что если и работать с многопоточными приложениями, то это нужно делать постоянно и глубоко разбираться в теории, а иначе лучше лишний раз не рисковать.

И вот реализация сервера практически готова. Можно приступать к использованию всех этих сервисов в клиенте. Если работой над клиентом занимается тот же разработчик или, по крайней мере, java программист, то можно надеяться, что документация не потребуется, либо будет достаточно общего описания. А если нет? Если придётся описывать все сервисы, параметры, коды ошибок, приводить примеры запросов где-нибудь на confluence или вообще в Word документе?

Да и как нам самим проверить приложение? Тесты это прекрасно, но хочется запустить приложение целиком и выполнить несколько HTTP запросов так, как это будет делать клиент. Можно использовать клиент Postman, но форматы сообщений и пути к сервисам придётся аккуратно формировать по коду приложения. Чтобы не тратить на всё это время, можно сгенерировать одновременно документацию и клиент с помощью Swagger, при этом затратив минимум усилий. Добавляем две зависимости в проект:

    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>

    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    
И ещё создаём конфигурацию:
    @Configuration
    @EnableSwagger2
    public class SpringFoxConfig {
        @Bean
        public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
             .select()
             .apis(RequestHandlerSelectors.any())
             .paths(PathSelectors.any())
             .build();
        }
    }
    

Swagger клиент доступен по адресу http://localhost:8085/swagger-ui.html#/ . Можно изучать сервисы, параметры и формат данных. Удобно сразу указывать параметры POST запросов, включая заголовки. Интерфейс простой и интуитивный. Можно целую шахматную партию сыграть, не выходя из него. Пара скриншотов для наглядности.

картинки нет, но вы держитесь картинки нет, но вы держитесь

Фронтенд (Front-end)

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

Для наших шахмат попробуем реализовать простой функциональный интерфейс без изысков, но и не в стиле 90-x, по возможности. Тем не менее, вполне вероятно, что мы распределили задачи по разработке бэкенда и фронтенда между сотрудниками отдела. Поэтому будем исходить из предположения, что сервер ещё не готов, максимум существует REST интерфейс, который поддерживает несколько простых вызовов, чтобы мы могли сориентироваться по формату данных.

Для начала создадим минимальный набор файлов: index.html, main.js, main.css, а также скопируем bootstrap и jquery. Пока что все файлы помещаем в корень директории static. Если файлы будут множиться, создадим отдельные директории для Javascript или CSS файлов. Так же сразу в index.html добавляем минимальный head, пустой body м подключаем css и js.

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

Как же отобразить шахматное поле? Можно воспользоваться canvas - будем рисовать все границы, линии и фигуры самостоятельно. Canvas позволит нарисовать всё, что угодно, но кажется слишком сложно, долго и муторно. Если можно сделать отображение на чистом html - это было бь лучше. Какие возможности интерфейса нам нужны?

  1. Доска - всегда 8*8, никак не трансформируется
  2. Ход - клик на одно поле, подсветка полей, на которые можно переместить фигуру и клик на второе поле
  3. Поле - квадрат фиксированного размера, чёрное или белое, может содержать одну из шести шахматных фигур двух цветов

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

картинки нет, но вы держитесь

Так, само поле 8*8, но ещё есть обозначения по краям - то есть заведём таблицу 10*10. Для начала из пары строк:

    <table class="table-bordered">
        <tr>
            <td></td><td>a</td><td>b</td><td>c</td><td>d</td><td>e</td><td>f</td><td>g</td><td>h</td><td></td>
        </tr>
        <tr>
            <td>8</td>
            <td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td>8</td>
        </tr>
    </table>
    

abcdefgh
8 8

Вроде получается, но сразу понятно, что нам нужно контролировать размеры ячеек и отличать чёрные от белых. Поэтому проставим css классы cell, cell-white, cell-black в таблице и объявим их в main.css с минимальными настройками.

    <tr>
         <td>8</td>
         <td class="cell cell-black"></td>
         <td class="cell cell-white"></td>
    ...
    
     .cell {
         width: 60px;
         height: 60px;
     }
     .cell-white {
         background-color: #ffff80;
     }
     .cell-black {
        background-color: #a3a264;
     }
   
Я выбрал цвета с оттенком желтого. Хочу, чтобы было похоже на старые советские деревянные лакированные шахматы. Результат:

a b c d e f g h
8 8

Буквы не по центру, лишние границы по краям, в остальном движемся верно. Подобные мелочи оставлю за кадром. Давайте ещё добавим фигуры и подсветку полей, на которую можно перемещать фигуру, выбранной фигуры и последнего хода соперника (зелёный, чёрный и красный, соответственно). Изображения возьмём векторные и наличие фигуры на поле будет определяться css стилем или data атрибутами, а не содержимым ячейки таблицы. Так будет проще изменять состояние, потому что не нужно создавать или удалять html элементы. Я выбрал data атрибуты, по сути для подобных целей они идеально подходят.

    <td class="cell cell-black" data-piece="bishop-black"></td>
    <td class="cell cell-white" data-piece-state="move"></td>
    <td class="cell cell-black"></td>
    <td class="cell cell-white" data-piece-state="eat"></td>
    <td class="cell cell-black"></td>
    <td class="cell cell-white" data-piece-state="selected"></td>
    
    td[data-piece='bishop-black']{
       background-image: url("/img/posts/chess/bishop.svg");
       background-size: contain;
    }

    td[data-piece-state='selected']{
       border: 5px solid black !important;
    }

    td[data-piece-state='eat']{
      border: 5px solid red !important;
    }

    td[data-piece-state='move']{
        border: 5px solid green !important;
    }
    

a b c d e f g h
8 8

Выглядит неплохо. Не получается пока обойтись одной svg картинкой для каждой фигуры, вероятно придётся отдельно иметь bishop-black.svg и bishop-white.svg (fill:white почему-то не помогает). Опять же, не зацикливаемся на подобных мелочах.

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

Клиент будет работать по принципу одностраничного сайта. Html код страницы загружается по первому запросу, но содержит только скелет приложения и заголовки. Наполнение страницы формируется динамически по результатам AJAX запросов с использованием библиотеки JQuery. Нам уже известен формат объекта GameStateDto, который описывает состояние шахматной доски. Остальные сервисы пока в разработке (гипотетически).

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

Итак, чтобы создать пустое поле нужно сгенерировать восемь строк по восемь столбцов, а для чёрных нужно всё инвертировать (перевернуть доску). Каждое второе поле помечаем белым (cell-white), остальные - чёрным (cell-black). На всех полях требуется идентификатор позиции (data-position='e2'), чтобы потом можно было расставить фигуры. Каждому полю требуется обработчик onClick, для которого пока заведём пустую функцию. Также не забываем про буквы и цифры по краям таблицы. Вот что получилось:

    function emptyBoard() {
        const board = $("#board");
        let trs = [];

        let rowNums = [1, 2, 3, 4, 5, 6, 7, 8];
        let cols = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
        let styles = ['cell-black', 'cell-white'];

        if (state.isBlack) {
            cols.reverse();
        } else {
            rowNums.reverse();
        }

        board.append(topTr(cols));
        rowNums.forEach((r, i) => {
            styles.reverse();
            board.append(boardTr(r, cols, styles));
        });
        board.append(topTr(cols));
    }

    function topTr(cols) {
         let tr = $('<tr class="text-center">');
         tr.append($('<td>'));
         cols.forEach((c, i) => {
             tr.append($('<td>').text(c));
         });
         tr.append($('<td>'));
         return tr;
    }

    function boardTr(row, cols, styles) {
        let tr = $('<tr>');
        tr.append($('<td>').text(row));
        cols.forEach((c, i) => {
            let pos = c + row;
            tr.append($('<td>')
                .addClass('cell')
                .addClass(styles[i % 2])
                .attr('data-position', pos)
                .click(() => {
                     handleBoardClick(pos);
                 }));
        });
        tr.append($('<td>').text(row));
        return tr;
    } 
Не знаю, как прокомментировать javascript код. При его виде, честно говоря, хочется перейти хотя бы на Typescript, чтобы иметь больше возможностей отделить бизнес логику от манипуляций с DOM. Но в целом, он не очень сложный, ведь это просто таблица 10*10. Пустую доску можно было бы вообще зафиксировать в html, но пришлось бы прописывать атрибуты для 64 элементов таблицы для ориентации от белого игрока и ещё столько же от чёрного. Результат:

картинки нет, но вы держитесь

Теперь разместим на поле фигуры. Предполагаем, что в глобальной переменной state.gameState находится объект отражающий структуру GameStateDto.

    function atPosition(pos) {
        return $('#board td[data-position=' + pos + ']');
    }

    function cleanupPieces() {
        foreachPosition(p => {
            atPosition(p).attr('data-piece', '');
        });
    }

    function cleanupSelections() {
        foreachPosition(p => {
            atPosition(p).attr('data-piece-state', '');
        });
    }

    function updateBoardState() {
        cleanupPieces();
        cleanupSelections();

        state.gameState.pieces.forEach((p, i) => {
           setPiece(p);
        });
    }

    function setPiece(piece) {
        const square = atPosition(piece.position);
        const dp = piece.pieceType.toLowerCase() + '-' + piece.color.toLowerCase();
        square.attr('data-piece', dp);
        square.attr('width', '200px');
    }
    
В последней функции мы преобразовываем два поля pieceType (e.g KNIGHT) и color (e.g. WHITE) в строку, которая используется в css стилях (e.g. knight-white). Вот наше поле. Я заменил svg картинки на png, но более красивые из-за того, что просто поменяв заливку на белую, все иконки превратились в непонятную бесконтурную кляксу.

картинки нет, но вы держитесь

Напомню, что все фигуры на доске определяются наличием специальных атрибутов (data-piece). Мы не добавляем и удаляем img теги, а только устанавливаем и очищаем атрибуты, а фигуры появляются на поле благодаря css селекторам. То же самое с подсветкой фигур.

По легенде, мы начали работу над клиентом и сервером одновременно, так что от сервера пока что был доступен только простой сервис, который возвращает GameStateDto с несколькими фигурами. Теперь продолжим разработку клиента с предположением, что все сервисы готовы или, по крайней мере, находятся в уже более-менее финальной стадии разработки. То есть мы можем зайти в swagger документацию, изучили каким образом поддерживается жизненный цикл игры, знаем формат данных и какие параметры нужны. Поэтому приступим к полноценной интеграции.

Для начала разберемся с парой вызовов /host и /join. Ссылку для второго игрока будем отображать в отдельном div поверх основного содержимого:

    <div id="hostscreen" class="ontop">
        <div class="container">
            <div class="row">
                <div class="col-lg-2 col-md-2">
                </div>
                <div class="col-lg-10 col-md-10">
                    <h3>Отправьте эту ссылку второму игроку:</h3>
                    <p>
                        <span id="joinlink"></span>
                    </p>
                    <h4>Ожидаем подключения соперника...</h4>
                </div>
            </div>
         </div>
    </div>
    
    .ontop {
          display: none; /* Hidden by default */
          position: fixed; /* Stay in place */
          z-index: 1; /* Sit on top */
          padding-top: 200px; /* Location of the box */
          left: 0;
          top: 0;
          width: 100%; /* Full width */
          height: 100%; /* Full height */
          overflow: auto; /* Enable scroll if needed */
          background-color: rgb(255,255,255); /* Fallback color */
          background-color: rgba(255,255,255,0.95); /* Black w/ opacity */
    } 

картинки нет, но вы держитесь

Первое, что делаем при открытии страницы - проверяем, есть ли параметр URL joinid:

    $(document).ready(function () {
        const searchParams = new URLSearchParams(window.location.search);
        if (!searchParams.has('joinid')) {
            host();
        } else {
            join(searchParams.get('joinid'));
        }
    }); 

Все наши REST сервисы запрашиваем по общей схеме используя функцию ajax, которая принимает все параметры, заголовки, тело запроса и функции - обработчики успешного вызова и ошибок.

    function host() {
    $.ajax({
         type: "POST",
         url: "/api/game/host",
         data: JSON.stringify({}),
         contentType: "application/json; charset=utf-8",
         dataType: "json",
         success: function (data) {
             initGameParams(data);
         },
         error: function (errMsg) {
             alert(errMsg);
         }
         });
    }
    function initGameParams(params) {
         state.gameId = params.id;
         state.ptoken = params.token;
         state.isBlack = "black" === params.playerSide.toLowerCase();
         state.playerSide = params.playerSide;
         state.gameState = params.gameState;
         emptyBoard();

         updateStatusMessage();
         updateBoardState();

         setJoinLink(state.gameId);
         if (!state.isBlack) {
             $("#hostscreen").toggle();
         }

         await(1000 * 60 * 5, 50);
    } 

Чувствую, что начинаю просто весь javascript код сюда копировать, особо не объясняя. Но здесь самое интересное - это, пожалуй - ожидание действия соперника, а именно метод await. Он вызывает сервис wait-for-my-move и ожидает 5 минут, после чего повторяется. Я ограничил количество вызовов числом 50, чтобы забытые вкладки в браузере не выполняли код бесконечно.

    function await(timeoutms, max) {
    if (max === 0) {
    return;
    }

    $.ajax({
        type: "POST",
        url: "/api/game/" + state.gameId + "/wait-for-my-move",
        data: JSON.stringify({}),
        contentType: "application/json; charset=utf-8",
        headers: {'ptoken': state.ptoken},
        dataType: "json",
        error: function (err) {
            if (err.statusText === 'timeout') {
                await(timeoutms, max - 1);
            } else {
                console.log(err.status + " " + err.statusText);
            }
        },
        success: function (data) {
            handleBoardUpdate(data);
        },
        timeout: timeoutms
        });
    } 

При обработке нажатия на поле мы сначала помечаем соответствующим состоянием (data-piece-state = 'move') поля, на которые можно переместить фигуру. А если поле уже отмечено, то воспринимаем это как завершение хода и отправляем соответствующий запрос /move и вызываем await(). Последний ход оппонента у нас отмечен красным выделением, обновляем его каждый раз, чтобы этот стиль не терялся при коллизиях. Каждый раз обрабатывая обновление игрового поля, сохраняем все разрешённые ходы в общую переменную validMoves, чтобы не искать их там потом линейным проходом по списку фигур.

    function handleBoardClick(pos) {
        if (atPosition(pos).attr('data-piece-state') === 'move') {
            handleMove(pos);
        } else {

        cleanupSelections();
        highlightLastOppMove();
        if (!state.gameState.gameFinished
            && state.gameState.myTurn
            && atPosition(pos).attr('data-piece')
            && atPosition(pos).attr('data-piece').includes(state.playerSide.toLowerCase())) {
                handleMyPieceClickOnMyMove(pos);
            }
        }
    }

    function handleMyPieceClickOnMyMove(pos) {
        atPosition(pos).attr('data-piece-state', 'selected');
        state.selectedPosition = pos;
        const validMoves = state.validMoves[pos];
        if (validMoves) {
            validMoves.forEach((m, i) => {
                atPosition(m).attr('data-piece-state', 'move');
            });
        }
    }  

картинки нет, но вы держитесь

В игре есть ещё немного функционала, который я не описал: верхнее меню, отображение активного игрока (белые или чёрные), выбор фигуры, когда пешка доходит до границы поля, а также всякие мелочи. Но ничего принципиально нового или примечательно в реализации этого функционала нет. Что-то не реализовано: шахматный таймер и список ходов, но код и так уже слишком длинный для подобного формата и опять же, ничего принципиально нового для реализации тут не потребуется. Таймер - просто целое число, которое бы присылал сервер, а javascript уменьшал каждую секунду при помощи setInterval. Настраивать начальный таймер можно дополнительными параметрами метода /host.

Отмечу, что мне не очень нравится, как структурирован Javascript код: всё в одном файле, все функции и переменные глобальные. Нужные функции постоянно приходится искать поиском. Кроме того, нет ни единого юнит теста, поэтому при внесении изменений приходится вручную проверять основные сценарии. Для примера и демонстрации отдельных функций сгодится, но для серьезного приложения нужно изучать более продвинутые практики. Например, использовать прототипы и таким образом распределять код на разные классы. Возможно, следует писать на typescript, а не чистом javascript. Frontend разработчики успешно пользуются этими приемами. Однако в простых приложениях это выглядит избыточным.

Долго ли, коротко ли, а наша реализация подошла к концу. Остаётся только открыть две вкладки в браузере и поиграть, чтобы проверить различные сценарии, рокировки, шахи, маты и прочее. Видно, что ход в одной вкладке мгновенно отображается в другой. Также можно уйти на полдня, сделать ход, и игра успешно продолжится. Однако, если случайно обновить вкладку, то игра будет потеряна, вернуться в неё нельзя - но это соответствует изначальному проекту и требованиям. То, что мы избежали сессии и даже cookie - это может быть намного полезнее в отдельных случаях. Жаль только, что у меня нет сейчас под рукой сервера, чтобы захостить эту реализацию, но локально всё можно запустить из github репозитоия (главный класс ChessApplication), никаких дополнительных настроек не требуется.

Заключение

Цель данного повествования - показать ключевые моменты планирования, проектирования и разработки. Даже немного затронута коммуникация между отдельными командами или разработчиками. Полноценным учебником (гайдом, туториалом), по которому можно повторить все шаги и получить в результате рабочий код, это назвать нельзя. Кроме того, если такое приложение действительно выкладывать в общий доступ и привлекать живых пользователей, то предстоит дополнительный и огромный объём работ разработки и тестирования. Текущая версия - это скорее прототип. Однако реализовывать подобные приложения просто в качестве обучения или практики для расширения кругозора или работы с frontend, с которым традиционно небольшой опыт у java разработчиков, очень полезно.

Опубликовано 2020-04-28