Наш ходячий скелет на данный момент не более чем неживой череп. Необходимо добавить еще несколько костей, чтобы сделать его полноценным скелетом. Давайте посмотрим, сможем ли мы установить связь между клиентом и сервером.
Подведение итогов
Итак, на чем мы остановились? В первом посте этой серии мы создали общее представление о том, как выглядит архитектура игры, и начали создавать ходячий скелет. Мы начали с нижней части стека вызовов и шаг за шагом продвигаемся вверх.
Общение
Сегодня я хотел бы начать настройку коммуникации между клиентом и сервером через 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.
-
Это утверждение утверждает, что лямбда завершается до превышения таймаута. Для получения дополнительной информации читайте JavaDocs.