В этой серии вы найдете краткий практический подход к изучению языка Go.

В Части 1 мы рассмотрели основы установки компилятора Go, запуска программ Go и системы модулей Go.

В Части 2 мы разработали программу управления паролями и узнали о нескольких языковых функциях и пакетах из стандартной библиотеки.

В части 3 мы разработаем игру, изучая больше возможностей языка Go.

Вы, наверное, знакомы с игрой TicTacToe. Давайте создадим версию онлайн-плеера.

В многопользовательских играх используется веб-сервер, и мы разработаем веб-приложение на основе Go, которое будет обслуживать игру. Игроки подключаются к серверу для игры.

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

Поскольку это веб-сервер, естественно думать, что игроки используют браузерные клиенты. Но поскольку мы изучаем Go, давайте также создадим клиент на Go. Так интереснее :-)

Общая модель

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

В рабочей области создайте каталог ticatactoe и дочерний каталог common.

mkdir -p tictactoe/common

В tictactoe создайте модуль

go mod init antosara.com/tictactoe

Добавьте локальный преобразователь для нашего модуля в go.mod

module antosara.com/tictactoe
replace antosara.com/tictactoe => ../tictactoe 1.14

Здесь создайте файл common.go с содержимым:

package common
const GAME_NEW = 0
const GAME_ACTIVE = 1
const GAME_DONE = 2
const PLAYER_X = "X"
const PLAYER_O = "O"
type Game struct {
 Id int
 State int
 Player string
 Board [9]string
 Winner string
}
  • GAME_NEW, GAME_ACTIVE, GAME_DONE — это состояния игры. GAME_NEW — это новая игра, созданная одним игроком и ожидающая, пока к ней присоединится другой. GAME_ACTIVE — когда второй игрок присоединяется и игра начинается. GAME_DONE — ничья или победа одного из игроков.
  • PLAYER_X и PLAYER_O — два игрока в каждой игре.
  • Игра — это структура
  • Id — это случайный идентификатор, присвоенный игре.
  • Состояние относится к одному из состояний игры, указанных выше.
  • Игрок относится к текущему игроку между PLAYER_X и PLAYER_O.
  • Доска — это массив доски TicTacToe с 9 квадратами. Каждый элемент начинается с пробела («») и принимает значение «X» или «O», когда соответствующий игрок играет.
  • Победитель устанавливается на «X» или «O» или «-» в случае ничьей

Игровой сервер

Создайте каталог «сервер» в каталоге tictactoe.

mkdir server
cd server

Создайте здесь файл «server.go».

package main
 
import (
    "log"
    "net/http"
    "io/ioutil"
    "encoding/json"
    "math/rand"
    "time"
    "strconv"
    "os"
    "strings"
    "sync"
    "tictactoe/common"
)
 
type Games struct {
    G map[int]*common.Game
    sync.Mutex
}
 
var games *Games
  • Мы намерены создать исполняемый файл, поэтому пакет является основным
  • Импортируйте пакеты на данный момент. Вы увидите использование по мере того, как вы идете в длинную позицию. Обратите внимание, что мы импортировали tictactoe/common, чтобы получить доступ к игровой модели.
  • Игры — это структура данных, содержащая карту созданных игр. Каждая игра по мере ее создания будет добавляться к этому.
  • Обратите внимание на определение карты как элемент «G». Ключ — целое число, а значение — указатель на игру.
  • «games» — это указатель на экземпляр Games, который создает сервер.
  • Обратите внимание на мьютекс в структуре Games, который управляет одновременным доступом к играм. Он называется встроенным мьютексом, потому что мы не назвали это поле. Как мы увидим в будущем, у нас будут функции на сервере, которые читают/записывают «игры», и Go выполняет эти функции одновременно. Чтобы предотвратить состояние гонки, мы хотели бы сериализовать любые обновления/чтения в «играх». В этом помогает мьютекс. Он охраняет членов Games, поэтому он определен внутри структуры.

Напишите основную функцию:

func main() {
    log.SetOutput(os.Stdout)
    rand.Seed(time.Now().UnixNano())
    games = &Games{G : make(map[int]*common.Game)}
    http.HandleFunc("/tictactoe/", gameHandler)
    http.HandleFunc("/tictactoe/start", startHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
  • Инициализируйте журнал и установите его для вывода на стандартный вывод
  • Инициализировать генератор случайных чисел с текущим временем в качестве начального числа
  • Инициализируйте «игры». Обратите внимание, как мы инициализируем карту с помощью функции make(). Присваиваем ссылку указателю «games»
  • http.HandleFunc добавляет, что функция имеет обработчик для заданного пути URL. Здесь мы определили два таких обработчика, которые реализуем позже.
  • startHandler — это функция (подробнее ниже), которая обрабатывает запросы к /tictactoe/start. Реализует механику запуска игры
  • gamehandler — это функция (подробнее ниже), которая обрабатывает запросы во время игры.
  • http.ListenAndAServe() запускает http-сервер с заданным адресом и обработчиком. В этом случае, указав «nil», мы просто используем предоставленный Go обработчик по умолчанию.

Функция startHandler:

func startHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("START game")
    games.Lock()
    // Iterate the games and check for an available game
    var g *common.Game;
    for _, game := range games.G {
        if game.State == common.GAME_NEW {
            // This game is waiting for another player.
            // Start the game.
            game.State = common.GAME_ACTIVE
            g = game
            break;
        }
    }
    if g == nil {
        // Create a new game with a random id and the player as X
        gameId := rand.Intn(20000-1000) + 1000
        game := common.Game{Id: gameId, Player: common.PLAYER_X, State:common.GAME_NEW, Board:[9]string{" "," "," "," "," "," "," "," "," "}}
        games.G[gameId] = &game
        g = &game
    }
    games.Unlock()
    js, _ := json.Marshal(*g)
    log.Println("Game started: ", g.Id)
    w.Header().Set("Content-Type", "application/json")
    w.Write(js)
}
  • Любой обработчик принимает эти два параметра: модуль записи ответа HTTP и запрос HTTP.
  • Каждый обработчик выполняется одновременно для нескольких запросов, происходящих одновременно. Поскольку мы делимся картой игр, лучше сериализовать эти запросы с целью создания игр. Если нет, есть вероятность, что второй игрок при подключении может создать новую игру вместо того, чтобы присоединиться к существующей игре из-за состояния гонки.
  • Чтобы предотвратить проблемы с параллелизмом, мы блокируем карту игр с помощью мьютекса. games.lock() — способ сделать это. Пока он не будет разблокирован, другой поток должен ждать, прежде чем продолжить работу с этим блоком кода.
  • Мы перебираем игры на карте, чтобы проверить, не создана ли игра уже и не ожидает ли к ней присоединения другого игрока. На это состояние указывает состояние GAME_NEW. Если найдено, мы получаем игру и присваиваем указатель «g» и устанавливаем игру как запущенную с состоянием GAME_ACTIVE.
  • Если мы не нашли открытой игры, мы создаем ее и добавляем на карту игр. Идентификатор игры генерируется случайным образом в диапазоне от 1000 до 20000. Мы назначаем игрока как «X» с состоянием игры GAME_NEW. Мы также инициализируем массив board значениями « » (пробел). Получаем отсылку к игре с «г»
  • Разблокируйте мьютекс, потому что мы закончили с модификациями игр.
  • Верните игру в виде сериализованного ответа JSON. Библиотека json помогает в преобразовании. Затем мы записываем содержимое в ответ HTTP вместе с заголовком для типа содержимого.

Теперь обработчик игры:

func gameHandler(w http.ResponseWriter, r *http.Request) {
    gameIdStr := strings.Split(r.URL.Path, "/")[2]
    gameId, _ := strconv.Atoi(gameIdStr)
    switch r.Method {
    case "GET":
        log.Println("GET game " + r.URL.Path + ":" + gameIdStr)
        js, _ := json.Marshal(games.G[gameId])
        log.Println("Return Game: ", string(js))
        w.Header().Set("Content-Type", "application/json")
        w.Write(js)
    case "POST":
        log.Println("POST game")
        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Printf("Error reading body: %v", err)
            return
        }
        var playerGame common.Game;
        json.Unmarshal(body, &playerGame)
        game := games.G[gameId]
        log.Println("Player is " + playerGame.Player)
        if game.Player == playerGame.Player {
            game.Board = playerGame.Board
            if isDraw(game) {
                game.State = common.GAME_DONE
                game.Winner = "-"
            } else if isWon(game, playerGame.Player) {
                game.State = common.GAME_DONE
                game.Winner = playerGame.Player
            } else {
                if game.Player == common.PLAYER_X {
                    game.Player = common.PLAYER_O
                } else {
                    game.Player = common.PLAYER_X
                }
            }
        }
        js, _ := json.Marshal(game)
        w.Header().Set("Content-Type", "application/json")
        w.Write(js)
    default:
        w.Write([]byte("Unsupported"))
    }
}
  • Запросы к /tictactoe/*, отличные от /tictactoe/start, обслуживаются этим.
  • Для игры, которую мы пишем, мы ожидаем только запросы GET и POST tictactoe/‹gameid›. Идентификатор игры создается startHandler и отправляется обратно клиентам игроков. Пока игра не будет завершена, игроки отправляют запросы на эту конечную точку.
  • Мы получаем gameId из пути запроса. Например, /tictactoe/6825
  • Для запросов GET мы извлекаем игру из карты игр, сериализуем ее в JSON и отправляем обратно в виде тела ответа.
  • POST используется клиентом игрока для обновления игрового поля после заполнения квадрата его отметкой (X или O). Весь игровой объект будет сериализован на клиенте и отправлен как тело запроса POST.
  • В POST мы десериализуем (json.Unmarshal) тело запроса JSON в «playerGame». Мы получаем серверную версию игры, используя gameId из карты игр. Теперь нам нужно проверить, какой игрок отправил запрос. Текущий игрок отслеживается в игровых данных как «Игрок». Если игрок в запросе не соответствует текущему игроку в игре, мы игнорируем и просто отправляем текущую игру обратно. Если он совпадает, мы обновляем игровое поле сервера.
  • Теперь, когда мы обновляем доску, мы проверяем, является ли игра ничьей или победителем. Методы isDraw() и isWon() показаны ниже. В случае ничьей мы устанавливаем Победителя как «-». В случае победы игрока мы устанавливаем Победителя вместе с игроком (X или O). Если ничего не происходит, это означает, что мы даем другому игроку следующий ход. Если текущий игрок «X», мы устанавливаем игрового игрока «O» и наоборот.
  • Затем мы отправляем обратно сериализованную игру в формате JSON.
func isWon(game *common.Game, player string) bool {
    log.Println("Checking win for " + player)
    log.Println(game.Board)
    won := ((game.Board[0] == player && game.Board[1] == player && game.Board[2] == player) ||
            (game.Board[3] == player && game.Board[4] == player && game.Board[5] == player) ||
            (game.Board[6] == player && game.Board[7] == player && game.Board[8] == player) ||
            (game.Board[0] == player && game.Board[3] == player && game.Board[6] == player) ||
            (game.Board[1] == player && game.Board[4] == player && game.Board[7] == player) ||
            (game.Board[2] == player && game.Board[5] == player && game.Board[8] == player) ||
            (game.Board[0] == player && game.Board[4] == player && game.Board[8] == player) ||
            (game.Board[2] == player && game.Board[4] == player && game.Board[6] == player))
    log.Println("Checking win for " + player + " : " + strconv.FormatBool(won))
    return won
}
func isDraw(game *common.Game) bool {
    log.Println("Checking draw")
    draw := (game.Board[0] != " " && game.Board[1] != " " && game.Board[2] != " " &&
        game.Board[3] != " " && game.Board[4] != " " && game.Board[5] != " " &&
        game.Board[6] != " " && game.Board[7] != " " && game.Board[8] != " ")
    log.Println("Checking draw: " + strconv.FormatBool(draw))
    return draw
}
  • IsWon() и isDraw() — довольно простые реализации для определения состояния доски TicTacToe. Вы можете улучшить их, чтобы быть более эффективными. Здесь мы просто проверяем, выиграл ли игрок, заполняя столбец/строку/диагональ. Если все квадраты заполнены без выигрыша, это ничья.

Мы закончили с сервером.

Запустите сервер. Перейдите в каталог сервера и

go build
./server

В настоящее время вы можете протестировать конечные точки с помощью curl или клиентского инструмента REST, такого как Postman. Попробуйте следующее:

curl http://localhost:8080/tictactoe/start

Ответ:

{"Id":7111,"State":0,"Player":"X","Board":[" "," "," "," "," "," "," "," "," "],"Winner":""}

Подключен первый игрок. Состояние GAME_NEW.

Второй запрос на скручивание запустит игру между двумя игроками:

{"Id":7111,"State":1,"Player":"X","Board":[" "," "," "," "," "," "," "," "," "],"Winner":""}

Подключен второй игрок. Состояние GAME_ACTIVE. Игрок X делает первый ход. Далее вы увидите, как вы будете использовать это для управления ходами клиента игрока.

Теперь отправьте сообщение на http://localhost:8080/tictactoe/7111, где игрок X выбирает первый квадрат:

curl --location --request POST 'http://localhost:8080/tictactoe/7111' \
--header 'Content-Type: application/json' \
--data-raw '{"Id":7111,"State":1,"Player":"X","Board":["X"," "," "," "," "," "," "," "," "],"Winner":""}'

Ответ:

{"Id":7111,"State":1,"Player":"O","Board":["X"," "," "," "," "," "," "," "," "],"Winner":""}

Вы видите, что состояние доски фиксируется сервером, а игрок переключается на «O».

Далее мы собираем игровой клиент.

Игровой клиент

Напомним, что мы хотели создать клиент плеера и на GoLang. Это будет консольный плеер. Итак, все, что у нас есть, это ASCII для рисования доски. Доску TicTacToe легко рисовать с помощью символов ASCII. Это будет выглядеть так:

-------------
|   |   |   |
-------------
| X |   | O |
-------------
|   | X |   |
-------------

Просто, верно? Давайте начнем.

Создайте каталог «client» в каталоге tictactoe. Здесь создайте файл с именем player.go

mkdir client
cd client

В player.go начните добавлять код:

package main
import (
    "log"
    "os"
    "fmt"
    "bufio" 
    "strings"
    "net/http"
    "encoding/json"
    "time"
    "io/ioutil"
    "strconv"
    "bytes"
    "tictactoe/common"
)
 
const BASE_URL = "http://localhost:8080/tictactoe"
const START_URL = BASE_URL + "/start"
var game common.Game
  • Обычный пакет и зависимости
  • Некоторые константы для URL-адресов, с которыми мы общаемся.
  • Переменная «game» для хранения игровых данных.
func readTrimmed(reader *bufio.Reader) (string, error) {
    str, err := reader.ReadString('\n')
    return strings.Replace(strings.TrimSpace(str)," ", "_", -1), err
}
  • Чтобы прочитать пользовательский ввод. Мы использовали то же самое в менеджере паролей. Это обрезает пробелы вокруг входной строки.
func printBoard() {
    fmt.Println("-------------")
    fmt.Println("| " + game.Board[0] + " | " + game.Board[1] + " | " + game.Board[2] + " |" )
    fmt.Println("-------------")
    fmt.Println("| " + game.Board[3] + " | " + game.Board[4] + " | " + game.Board[5] + " |" )
    fmt.Println("-------------")
    fmt.Println("| " + game.Board[6] + " | " + game.Board[7] + " | " + game.Board[8] + " |" )
    fmt.Println("-------------")
}
  • Это будет печатать игровое поле с текущим состоянием всякий раз, когда нам нужно

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

func main() {
    // Setup a reader from standard input
    reader := bufio.NewReader(os.Stdin)
    // Intro to excite the player :-)
    fmt.Println("+++++++++++++++++++++++++++++++++++++")
    fmt.Println("Welcome to TicTacToe!! ")
    fmt.Println("+++++++++++++++++++++++++++++++++++++")
    for {
        // Initialize to keep track of the player to blank
        // Once the start request is sent, the player is set
        // and remains the same for the rest of the game
        player := ""
        // Info for the user
        fmt.Println("Starting a new game. Please wait...")
        // Call the START_URL endpoint to start/join a game
        resp, err := http.Get(START_URL)
        if err != nil {
            log.Fatalln(err)
        }
        // Read the response from game server
        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            log.Fatalln(err)
        }
        // Deserialize response to the "game" data structure
        json.Unmarshal(body, &game)
        // Make sure we close the response body
        resp.Body.Close()
        // If the server responded with game state of GAME_NEW
        // this is the player that started a game. 
        // The starting player will be assigned "X" or "O" on server
        // which we get here too.
        // If server responded with GAME_ACTIVE, this player
        // joined a game. This player should be assigned the other mark     
        if game.State == common.GAME_NEW {
            player = game.Player
        } else {
            if game.Player == common.PLAYER_X {
                player = common.PLAYER_O
            } else {
                player = common.PLAYER_X
            }
        }
        // If we have received GAME_NEW, we just
        // wait for another player to join and start the game
        // Keep fetching the game every second, until that happens
        for game.State == common.GAME_NEW {
            // Get the game using the game endpoint.
            // We know the game Id by now
            resp, err := http.Get(BASE_URL + "/" + strconv.Itoa(game.Id))
            if err != nil {
                log.Fatalln(err)
            }
            body, err := ioutil.ReadAll(resp.Body)
            if err != nil {
                log.Fatalln(err)
            }
            json.Unmarshal(body, &game)
            resp.Body.Close()
            time.Sleep(1 * time.Second)
        }
        // If we are here, the server has two players and the game is started.
        fmt.Println("***********************")
        fmt.Println("Game " + strconv.Itoa(game.Id) + " started. You are: " + player)
        fmt.Println("***********************")
        // We start a forever loop until the game is done.
        // There are certain things like showing a message once
        // until some change occurs. We use the marked flag for that.
        var marked = true
        for game.State != common.GAME_DONE {
            // Pause for a second before we send the next game update request
            time.Sleep(1 * time.Second)
            resp, _ := http.Get(BASE_URL + "/" + strconv.Itoa(game.Id))
            body, _ := ioutil.ReadAll(resp.Body)
            // Read the game
            json.Unmarshal(body, &game)
            // If the game is done, exit the loop
            if game.State == common.GAME_DONE {
                break
            }
            // If this player is NOT the current game player,
            // we print a wait message and go back to the start
            // of the game loop
            if game.Player != player {
                // We need to show this message only once until
                // some state changes
                if marked {
                    printBoard()
                    fmt.Println("Waiting for other player...")
                    marked = false
                }
                // Give extra time
                time.Sleep(1 * time.Second)             
                continue
            } else {
                // It is this player's turn. Print any board update
                printBoard()
                
                // Track the player choice. The squares are numbered
                // 0 to 8 sequentially right and down.
                choice := -1
                // Ask for a valid choice. Any square between 0 and 8
                // inclusive and containing " " as value is acceptable
                // Keep asking until we get it
                for choice == -1 {
                    fmt.Print("Fill square (0-8): ")
                    c, _ := readTrimmed(reader)
                    choice, _ = strconv.Atoi(c)
                    if choice < 0 || choice > 8 || game.Board[choice] != " " {
                        fmt.Println("Invalid choice.")
                        choice = -1
                    }
                }
                // Update the player's board
                game.Board[choice] = player
                // Reset the state for any messages
                marked = true
                // Post the game data as JSON to the game endpoint
                postBody, _ := json.Marshal(game)
                postResp, _ := http.Post(BASE_URL + "/" +  strconv.Itoa(game.Id), "application/json", bytes.NewBuffer(postBody))
                postResp.Body.Close()
            }
        }
        // We will be here when one of these happen
        // -> Game is a tie
        // -> Game has winner
        // We can find it from the game.Winner field
        // Give appropriate message to the player
        printBoard()
        fmt.Println("***********************")
        if game.Winner == "-" {
            fmt.Println("It's a tie, try harder next time!!")
        } else if player == game.Winner {
            fmt.Println("You won, keep it up!!")
        } else {
            fmt.Println("You lost, but good luck next time!!")
        }
        fmt.Println("***********************")
        // Let the player choose to play another game or exit
        fmt.Println("Enter 'y' if you want to play again: ")
        d, _ := readTrimmed(reader)
        if d != "y" {
            fmt.Println("Thanks for playing!!")
            break;
        }
    }
}

Потрясающий! Если вы последовали за ним, вы закончили с клиентом.

Соберите клиент:

go build

Предполагая, что вы уже используете сервер, запустите клиент

./client

Теперь вы можете играть в игру. Пригласите друга поиграть в другом окне консоли.

Если вы играете в клиент в двух окнах, вы должны увидеть что-то вроде этого:

Клиент 1

+++++++++++++++++++++++++++++++++++++
Welcome to TicTacToe!! 
+++++++++++++++++++++++++++++++++++++
Starting a new game. Please wait...
***********************
Game 3448 started. You are: X
***********************
-------------
|   |   |   |
-------------
|   |   |   |
-------------
|   |   |   |
-------------
Fill square (0-8): 0
-------------
| X |   |   |
-------------
|   |   |   |
-------------
|   |   |   |
-------------
Waiting for other player...
-------------
| X |   |   |
-------------
|   | O |   |
-------------
|   |   |   |
-------------
Fill square (0-8):

Клиент 2:

+++++++++++++++++++++++++++++++++++++
Welcome to TicTacToe!! 
+++++++++++++++++++++++++++++++++++++
Starting a new game. Please wait...
***********************
Game 3448 started. You are: O
***********************
-------------
|   |   |   |
-------------
|   |   |   |
-------------
|   |   |   |
-------------
Waiting for other player...
-------------
| X |   |   |
-------------
|   |   |   |
-------------
|   |   |   |
-------------
Fill square (0-8): 4
-------------
| X |   |   |
-------------
|   | O |   |
-------------
|   |   |   |
-------------
Waiting for other player...

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

Мы изучим продвинутую тему Go в Части 4.