Миры роботов 2: Общение

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

Подведение итогов

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

Общение

Сегодня я хотел бы начать настройку коммуникации между клиентом и сервером через TCP-сокет. Для этого нам понадобится сериализовать и десериализовать сообщения в/из JSON, а также отправлять JSON через TCP-сокет. Давайте посмотрим, сможем ли мы вызвать команду launch, отправив ее в формате JSON на сервер. Но сначала…

Возвращение результата

Посмотрев на нашу команду запуска, я заметил, что она не возвращает результат.

fun handleRequest(request: Request) {
    launchRobot()
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

{
  "result": "OK"
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Поэтому давайте сначала изменим наш тест.

@Test
fun `execute a launch command`() {
    val world = World()
    val result = world.handleRequest(Request(command = "launch"))
    assertThat(world.robotCount).isEqualTo(1)
    assertThat(result).isEqualTo(CommandResult(result = "OK"))
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это не компилируется, потому что класс CommandResult еще не существует. Давайте сначала создадим его.

class CommandResult(result: String)
Вход в полноэкранный режим Выход из полноэкранного режима

Пока достаточно хорошо. Теперь наш тест терпит неудачу: ожидалось:<[nl.dirkgroot.robotworlds.CommandResult@750e2b97]>, но было:<[kotlin.Unit]>. Это выглядит некрасиво. Давайте сделаем это красивее:

data class CommandResult(val result: String)
Войти в полноэкранный режим Выход из полноэкранного режима

Классы данных в Kotlin — это удобный способ создания классов, предназначенных для хранения данных. Помимо прочего, компилятор Kotlin автоматически предоставляет классы данных с equals, hashCode и toString. Сгенерированный метод toString делает сообщение об ошибке более красивым.

Вот так, это намного лучше: ожидалось <[CommandResult(result=OK)]>, но было:<[kotlin.Unit]>. Теперь давайте сделаем так, чтобы тест прошел.

fun handleRequest(request: Request): CommandResult {
    launchRobot()
    return CommandResult(result = "OK")
}
Вход в полноэкранный режим Выход из полноэкранного режима

(Де)сериализация

Мы продолжаем работать над игровым сервером. Теперь, когда у нас есть базовая команда launch, давайте посмотрим, что нам нужно добавить к серверу, чтобы мы могли вызывать его с помощью сообщения JSON. Давайте пока проигнорируем часть TCP и сделаем небольшой шаг вверх по стеку вызовов. Думаю, нам понадобится MessageReceiver, который переводит JSON-сообщения в вызовы World::handleRequest.

Нет, давайте немного отступим и сначала убедимся, что мы можем создать Request из JSON. Если у нас есть такая возможность, создание MessageReceiver должно быть тривиальным.

@Test
fun `create a request from JSON`() {
    val request = Request.fromJSON("""{ "command": "launch" }""")
    assertThat(request).isEqualTo(Request(command = "launch"))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Я позволю IntelliJ сгенерировать заглушку для Request::fromJSON.

class Request(command: String) {
    companion object {
        fun fromJSON(json: String): Request {
            TODO("Not yet implemented")
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь наш тест, конечно же, провалился: An operation is not implemented: Not yet implemented. Давайте воспользуемся сериализацией Kotlin, чтобы реализовать это.

fun fromJSON(json: String): Request {
    return Json.decodeFromString(json)
}
Вход в полноэкранный режим Выходим из полноэкранного режима

По-прежнему не удается: Сериализатор для класса 'Request' не найден. Пометьте класс как @Serializable или укажите сериализатор явно. Request должен быть сериализуемым.

@Serializable
class Request(command: String)
Вход в полноэкранный режим Выйдите из полноэкранного режима

Теперь мы получаем ошибку компилятора: «Этот класс не сериализуется автоматически, потому что имеет первичные параметры конструктора, которые не являются свойствами». Хорошо, давайте сделаем command свойством.

@Serializable
class Request(val command: String)
Войти в полноэкранный режим Выйдите из полноэкранного режима

Теперь наш тест проваливается с уродливым сообщением: ожидалось:<...robotworlds.Request@[4de025bf]>, но было:<...robotworlds.Request@[538613b3]>. Я подозреваю, что это потому, что Request не имеет реализации equals, поэтому давайте переведем его в класс data class, как мы сделали с CommandResult.

@Serializable
data class Request(val command: String)
Вход в полноэкранный режим Выход из полноэкранного режима

Это было приятно и просто. Теперь давайте перейдем к нашему MessageReceiver.

Запуск с помощью JSON

Сначала мы попробуем вызвать команду launch с помощью сообщения JSON.

class MessageReceiverTest {
    @Test
    fun `invoke launch command with JSON message`() {
        val world = World()
        val messageReceiver = MessageReceiver(world)
        messageReceiver.receive("""{ "command": "launch" }""")
        assertThat(world.robotCount).isEqualTo(1)
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Конечно, это не компилируется, поэтому создадим класс MessageReceiver с методом receive.

class MessageReceiver(world: World) {
    fun receive(jsonMessage: String) {
        TODO("Not yet implemented")
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Реализуйте его, чтобы тест прошел. Для этого нам придется сделать параметр конструктора world членом класса.

class MessageReceiver(private val world: World) {
    fun receive(jsonMessage: String) {
        val request = Request.fromJSON(jsonMessage)
        world.handleRequest(request)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Я бы хотел, чтобы receive возвращала результат в формате JSON, поэтому давайте убедимся, что мы можем сериализовать CommandResult в JSON.

class CommandResultTest {
    @Test
    fun `serialize to JSON`() {
        val json = CommandResult(result = "OK").toJSON()
        assertThat(json).isEqualTo("""{ "result": "OK" }""")
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Реализуйте это одним махом и снова используйте сериализацию Kotlin.

@Serializable
data class CommandResult(val result: String) {
    fun toJSON(): String {
        return Json.encodeToString(this)
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Тест проваливается: ожидалось:<"{[ "result": "OK" ]}">, но было:<"{[ "result": "OK"]}">. Очевидно, Json.encodeToString использует как можно меньше пробелов. Давайте подкорректируем тест.

assertThat(json).isEqualTo("""{"result":"OK"}""")
Войти в полноэкранный режим Выйдите из полноэкранного режима

Готово. Теперь давайте изменим MessageReceiver::receive, чтобы он возвращал результат в формате JSON.

@Test
fun `invoke launch command with JSON message`() {
    val world = World()
    val messageReceiver = MessageReceiver(world)
    val result = messageReceiver.receive("""{ "command": "launch" }""")
    assertThat(world.robotCount).isEqualTo(1)
    assertThat(result).isEqualTo("""{"result":"OK"}""")
}
Вход в полноэкранный режим Выход из полноэкранного режима

Не получается ожидалось:<["{"result": "OK"}"]>, а получилось:<[kotlin.Unit]>. Мы будем использовать CommandResult, возвращенный World::handleRequest в качестве возвращаемого значения.

fun receive(jsonMessage: String): String {
    val request = Request.fromJSON(jsonMessage)
    return world.handleRequest(request).toJSON()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Небольшой рефакторинг

В предыдущей статье у нас уже было подозрение, что World в конечном итоге придется разделить. Теперь, когда у нас есть MessageReceiver, становится очевидным, что World::handleRequest находится не на своем месте. Помните, его задача — принять запрос, выполнить запрошенную команду и вернуть результат. Его ответственность сосредоточена на сообщениях запроса и ответа. Я думаю, что основной обязанностью World должна быть обработка игровой логики. Он не должен беспокоиться о сообщениях, и MessageReceiver кажется гораздо лучшим местом для этого. Поэтому давайте переместим handleRequest в MessageReceiver.

Я начну с копирования handleRequest в MessageReceiver, изменю receive на использование MessageReceiver::handleRequest вместо World::handleRequest и заставлю его скомпилироваться.

fun receive(jsonMessage: String): String {
    val request = Request.fromJSON(jsonMessage)
    return handleRequest(request).toJSON()
}

fun handleRequest(request: Request): CommandResult {
    world.launchRobot()
    return CommandResult(result = "OK")
}
Вход в полноэкранный режим Выход из полноэкранного режима

Тесты по-прежнему проходят. Теперь у нас есть два похожих теста для команды launch, один в WorldTest и один в MessageReceiverTest. Это та, которая находится в WorldTest.

@Test
fun `execute a launch command`() {
    val world = World()
    val result = world.handleRequest(Request(command = "launch"))
    assertThat(world.robotCount).isEqualTo(1)
    assertThat(result).isEqualTo(CommandResult(result = "OK"))
}
Вход в полноэкранный режим Выход из полноэкранного режима

А вот тест в MessageReceiverTest.

@Test
fun `invoke launch command with JSON message`() {
    val world = World()
    val messageReceiver = MessageReceiver(world)
    val result = messageReceiver.receive("""{ "command": "launch" }""")
    assertThat(world.robotCount).isEqualTo(1)
    assertThat(result).isEqualTo("""{"result":"OK"}""")
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Эти тесты в основном одинаковы, за исключением того, что тест для MessageReceiver использует JSON, а тест для World использует объект Request. Теперь мы можем сделать две вещи: Мы можем переместить тест в WorldTest в MessageReceiverTest и изменить его, чтобы он использовал MessageReceiver::handleRequest, или мы можем просто удалить тест в WorldTest. Давайте избавимся от дублирования, удалив тест в WorldTest.

Тесты по-прежнему проходят. Теперь, когда дублирующий тест исчез, World::handleRequest нигде не используется, поэтому мы можем удалить и его. Наконец, мы можем сделать MessageReceiver::handleRequest приватным, потому что он используется только в MessageReceiver::receive.

private fun handleRequest(request: Request): CommandResult {
    world.launchRobot()
    return CommandResult(result = "OK")
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вас обслуживают?

Теперь давайте начнем настраивать приемник TCP-сокета.

class SocketListenerTest {
    @Test
    fun `listens on free TCP port when no port is given`() {
        val socketListener = SocketListener()
        val port = socketListener.port

        val socket = Socket("127.0.0.1", port)

        assertThat(socket.isConnected)
            .isTrue()

        socket.close()
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Создайте класс SocketListener.

class SocketListener {
    val port = 1
}
Войти в полноэкранный режим Выйдите из полноэкранного режима

И, как и ожидалось, наш тест терпит неудачу: java.net.ConnectException: Connection refused. Сделать его проходящим очень просто.

class SocketListener {
    private val serverSocket = ServerSocket(0)

    val port get() = serverSocket.localPort
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы инициализируем ServerSocket с портом 0, так что он автоматически выберет доступный порт. Если мы выберем фиксированный порт, всегда есть (небольшой) риск, что этот порт уже занят, что может привести к нестабильным тестам на конвейере CI/CD. Нам не нужны нестабильные тесты, поэтому я сделаю все возможное, чтобы этого не произошло.

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

@Test
fun `listens on free TCP port when no port is given`() {
    val socketListener = SocketListener()
    val port = socketListener.port

    Socket("127.0.0.1", port).use {
        assertThat(it.isConnected).isTrue()
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Так намного лучше. Теперь давайте посмотрим, сможем ли мы заставить его обрабатывать запрос.

@Test
fun `handles a command`() {
    val socketListener = SocketListener()
    val port = socketListener.port

    Socket("127.0.0.1", port).use {
        it.getOutputStream().writer().write("""{ "command": "launch" }""")
        val response = it.getInputStream().bufferedReader().readLine()

        assertThat(response).isEqualTo("""{"result":"OK"}""")
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Есть много вещей, которые можно улучшить в этом коде, но давайте сначала посмотрим, что происходит. SocketListener не обрабатывает никаких сообщений, поэтому тест бесконечно ждет ответа. Это не идеально. Мы хотим, чтобы наш тест не сработал, а не ждал вечно. Давайте исправим это, используя assertTimeoutPreemptively в JUnit.1.

@Test
fun `handles a command`() {
    val socketListener = SocketListener()
    val port: Int = socketListener.port

    assertTimeoutPreemptively(Duration.ofSeconds(1)) {
        Socket("127.0.0.1", port).use {
            it.getOutputStream().writer().write("""{ "command": "launch" }""")
            val response = it.getInputStream().bufferedReader().readLine()

            assertThat(response).isEqualTo("""{"result":"OK"}""")
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

val socketListener = SocketListener(MessageReceiver(World()))
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь нам нужно изменить конструктор SocketListener.

class SocketListener(private val messageReceiver: MessageReceiver)
Вход в полноэкранный режим Выход из полноэкранного режима

И наконец, нам нужно запустить поток и обработать запрос.

init {
    Thread {
        serverSocket.accept().use {
            val request = it.getInputStream().bufferedReader().readLine()
            val response = messageReceiver.receive(request)

            it.getOutputStream().writer().write("$responsen")
        }
    }.start()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Это не удается с таймаутом. Ах, я забыл отправить новую строку после сообщения запроса. Давайте это изменим.

it.getOutputStream().writer().write("""{ "command": "launch" }""" + "n")
Войти в полноэкранный режим Выйти из полноэкранного режима

Все еще не работает. Очевидно, я делаю что-то не так, но я не могу сразу понять, что именно. Я не вижу другого варианта, кроме как использовать отладчик. Итак, SocketListener продолжает блокировать val request = it.getInputStream().bufferedReader().readLine(). Нужно ли нам промытьписателя? Давайте попробуем.

it.getOutputStream().writer().apply {
    write("""{ "command": "launch" }""" + "n")
    flush()
}
Войти в полноэкранный режим Выход из полноэкранного режима

Тест по-прежнему не работает: ожидалось:<"{"result": "OK"}">, но было <null>. Похоже, что нам также нужно сделать flush в SocketListener.

it.getOutputStream().writer().apply {
    write("$responsen")
    flush()
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

Да, это помогло. Теперь наш код можно немного улучшить, так что давайте сделаем это. Прежде всего, давайте сделаем наш тестовый код более читабельным.

@Test
fun `handles a command`() {
    val socketListener = SocketListener(MessageReceiver(World()))
    val port = socketListener.port

    assertTimeoutPreemptively(Duration.ofSeconds(1)) {
        Socket("127.0.0.1", port).use { socket ->
            sendLaunchCommand(socket)
            val response = receiveResponse(socket)

            assertThat(response).isEqualTo("""{"result":"OK"}""")
        }
    }
}

private fun sendLaunchCommand(socket: Socket) {
    socket.getOutputStream().writer().apply {
        write("""{ "command": "launch" }""" + "n")
        flush()
    }
}

private fun receiveResponse(socket: Socket) =
    socket.getInputStream().bufferedReader().readLine()
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь стало немного понятнее, что на самом деле делает наш тест. Давайте сделаем нечто подобное в SocketListener.

init {
    Thread {
        serverSocket.accept().use {
            val request = receiveRequest(it)
            val response = messageReceiver.receive(request)

            sendResponse(it, response)
        }
    }.start()
}

private fun receiveRequest(socket: Socket) =
    socket.getInputStream().bufferedReader().readLine()

private fun sendResponse(socket: Socket, response: String) {
    socket.getOutputStream().writer().apply {
        write("$responsen")
        flush()
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Ретро

Все шло гладко, пока мы не начали возиться с сокетами. Я не часто работаю с сокетами или потоками ввода/вывода, поэтому необходимость промывки не была сразу очевидна для меня. Это то, что обычно происходит, когда вы работаете на «краях» системы. Именно там нам приходится иметь дело с вводом/выводом, или базами данных, или очередями, и зачастую с неинтуитивными API.

Скромный объект

Вот почему я держу как можно больше логики отдельно от кода, который должен иметь дело с этими видами API. Таким образом, мы максимизируем количество кода, который можно легко протестировать и понять. Это то, что называется паттерном «Скромный объект». Наш SocketListener — это скромный объект. Его единственная обязанность — принимать соединения, передавать сообщения MessageReceiver и отправлять результат обратно клиенту.

YAGNI

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

Я неустанно применяю принцип YAGNI, чтобы добраться до нашего ходячего скелета. Я с удовольствием исключаю все, что не является строго необходимым для наших целей. Посмотрим, не пожалею ли я об этом, но думаю, что нет. Время покажет 😃.

Спасибо, что прочитали, и увидимся в следующей статье. Вы можете найти мой исходный код на GitHub: https://github.com/dirkgroot/robot-worlds.


  1. Это утверждение утверждает, что лямбда завершается до превышения таймаута. Для получения дополнительной информации читайте JavaDocs. 

Оцените статью
devanswers.ru
Добавить комментарий