实验二: Thymeleaf的使用

实验过程

  1. 引入 Thymeleaf 模板引擎

首先,在项目中引入 Thymeleaf 依赖,使 Spring Boot 支持模板渲染:

打开pom.xml文件

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
  • 使项目能够解析 templates 目录下的 HTML 文件
  • 支持 th:* 语法

  1. 调整项目结构

将原有静态页面从 static 目录移动到:

src/main/resources/templates/

并将文件命名为:

wuziqi.html
  • static → 纯静态页面(不会走 Thymeleaf)
  • templates → 模板页面(由 Thymeleaf解析)

  1. 编写 Controller(核心步骤)

创建控制器 PageController,用于:

  • 初始化棋盘
  • 接收用户点击坐标
  • 更新棋盘状态
  • 判断胜负
  • 向前端传递数据

完整代码文件:

package com.wuziqi.java.shiyan1.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.ArrayList;
import java.util.List;

@Controller
public class PageController {

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;

    @GetMapping("/game")
    public String gamePage(
            @RequestParam(required = false) Integer x,
            @RequestParam(required = false) Integer y,
            Model model) {

        // 初始化
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }

        // 落子(+胜负判断)
        if (x != null && y != null && winner == null) {
            if (board.get(x).get(y) == 0) {

                board.get(x).set(y, currentPlayer);

                // 胜负判断
                if (win(x, y)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;
                }
            }
        }

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);

        return "wuziqi";
    }

    // 极简胜负判断
    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1,0},{0,1},{1,1},{1,-1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0]*k, ny = y + dir[1]*k;
                if (nx<0||ny<0||nx>=15||ny>=15||board.get(nx).get(ny)!=p) break;
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0]*k, ny = y - dir[1]*k;
                if (nx<0||ny<0||nx>=15||ny>=15||board.get(nx).get(ny)!=p) break;
                count++;
            }

            if (count >= 5) return true;
        }
        return false;
    }
}

核心代码逻辑

(1)初始化棋盘

if (board.isEmpty()) {
    for (int i = 0; i < 15; i++) {
        List<Integer> row = new ArrayList<>();
        for (int j = 0; j < 15; j++) {
            row.add(0);
        }
        board.add(row);
    }
}

生成 15×15 棋盘

(2)处理点击落子

if (x != null && y != null && winner == null) {
    if (board.get(x).get(y) == 0) {
        board.get(x).set(y, currentPlayer);
    }
}

根据点击坐标更新棋盘

(3)胜负判断

if (win(x, y)) {
    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
}

判断是否形成五子连珠

(4)切换玩家

currentPlayer = 3 - currentPlayer;

黑白轮流

(5)向页面传递数据

model.addAttribute("board", board);
model.addAttribute("currentPlayer", ...);
model.addAttribute("message", "欢迎你");
model.addAttribute("winner", winner);

供 Thymeleaf 使用

  1. 编写 Thymeleaf 页面

wuziqi.html 中使用 Thymeleaf 指令实现动态页面。

完整代码文件:

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>五子棋</title>

    <style>
        body {
            text-align: center;
            font-family: "Microsoft YaHei";
            background: #f5e1b5;
        }

        .board {
            position: relative;
            width: 420px;
            height: 420px;
            margin: 30px auto;
            background: #deb887;
        }

        /* 横线 */
        .line-h {
            position: absolute;
            left: 0;
            width: 100%;
            height: 1px;
            background: #333;
        }

        /* 竖线 */
        .line-v {
            position: absolute;
            top: 0;
            height: 100%;
            width: 1px;
            background: #333;
        }

        /* 交叉点 */
        .point {
            position: absolute;
            width: 20px;
            height: 20px;
            transform: translate(-50%, -50%);
        }

        .point a {
            display: block;
            width: 100%;
            height: 100%;
        }

        .piece {
            width: 18px;
            height: 18px;
            border-radius: 50%;
            margin: auto;
        }

        .black {
            background: black;
        }

        .white {
            background: white;
            border: 1px solid #999;
        }
    </style>
</head>

<body>

<h1>五子棋</h1>
<h2 th:text="${message}"></h2>
<p>当前玩家:<span th:text="${currentPlayer}"></span></p>
<h2 th:if="${winner != null}"
    th:text="${winner}"
    style="color:red;"></h2>
<div class="board">

    <!-- 横线 -->
    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-h"
         th:style="|top:${i * 30}px|"></div>

    <!-- 竖线 -->
    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-v"
         th:style="|left:${i * 30}px|"></div>

    <!-- 所有交叉点 -->
    <div th:each="row,rowStat : ${board}">
        <div th:each="cell,colStat : ${row}"
             class="point"
             th:style="|top:${rowStat.index * 30}px; left:${colStat.index * 30}px|">

            <a th:href="@{/game(x=${rowStat.index}, y=${colStat.index})}">

                <div th:if="${cell == 1}" class="piece black"></div>
                <div th:if="${cell == 2}" class="piece white"></div>

            </a>

        </div>
    </div>

</div>

</body>
</html>

(1)显示文本信息

<h2 th:text="${message}"></h2>
<p>当前玩家:<span th:text="${currentPlayer}"></span></p>

显示后端数据

(2)胜负提示

<h2 th:if="${winner != null}" th:text="${winner}"></h2>

条件显示胜利信息

(3)生成棋盘(双层循环)

<div th:each="row,rowStat : ${board}">
    <div th:each="cell,colStat : ${row}">

根据二维数组动态生成棋盘

(4)棋子显示

<div th:if="${cell == 1}" class="black"></div>
<div th:if="${cell == 2}" class="white"></div>

判断显示黑棋或白棋

(5)点击落子(核心交互)

<a th:href="@{/game(x=${rowStat.index}, y=${colStat.index})}">

点击棋盘发送坐标到后端

(6)动态样式(定位棋盘)

th:style="|top:${rowStat.index * 30}px; left:${colStat.index * 30}px|"

计算每个交叉点位置

  1. 页面运行与测试

启动项目后,在浏览器访问:

http://localhost:8080/game

介绍这个实验

一、Thymeleaf 是什么

Thymeleaf 是一种 Java Web 模板引擎,主要用于在服务器端将数据渲染到 HTML 页面中。用于将后端数据动态渲染到 HTML 页面中的模板引擎,实现了页面与数据的解耦。

简单理解:

  • Controller 负责数据
    Thymeleaf 负责把数据“填进 HTML”

核心特点

  1. 服务端渲染
    • 页面在服务器生成后再返回浏览器
  2. HTML友好
    • 页面本身仍然是合法HTML
  3. 使用 th: 语法操作数据
    • th:textth:eachth:if

二、本项目整体说明

本项目实现了一个基于 Spring Boot 的五子棋游戏,主要结构如下:

  1. Controller(后端逻辑)

负责:

  • 初始化棋盘
  • 处理落子
  • 判断胜负
  • 向页面传递数据

例如:

model.addAttribute("board", board);
model.addAttribute("currentPlayer", ...);
model.addAttribute("message", "欢迎你");
model.addAttribute("winner", winner);

这些数据会被 Thymeleaf 使用

  1. HTML(前端模板)

负责:

  • 显示棋盘
  • 显示棋子
  • 显示当前玩家、胜负结果

但不是写死的,而是通过 Thymeleaf 动态生成

  1. 数据流
用户点击 → /game?x=..&y=..
→ Controller处理
→ Model传数据
→ Thymeleaf渲染HTML
→ 页面更新

三、项目中使用 Thymeleaf 的地方

  1. th:text(显示数据)
<h2 th:text="${message}"></h2>
<p>当前玩家:<span th:text="${currentPlayer}"></span></p>
  • 把 Controller 传来的数据显示出来

举例

Controller:

model.addAttribute("message", "欢迎你");

页面:

<h2 th:text="${message}"></h2>

页面显示:欢迎你

  1. th:if(条件渲染)
<h2 th:if="${winner != null}" th:text="${winner}"></h2>
  • 只有胜利时才显示文字

效果

winner 页面
null 不显示
黑棋胜利 显示
  1. th:each(循环渲染棋盘)
<div th:each="row,rowStat : ${board}">
    <div th:each="cell,colStat : ${row}">
  • 遍历二维数组(棋盘)

对应后端

List<List<Integer>> board

通过循环生成 15×15 棋盘

  1. th:style(动态样式)
th:style="|top:${rowStat.index * 30}px; left:${colStat.index * 30}px|"
  • 根据坐标计算位置
  • 实现棋盘“交叉点布局”
  1. th:href(点击落子)
<a th:href="@{/game(x=${rowStat.index}, y=${colStat.index})}">
  • 点击棋盘时发送请求
  • 把坐标传给 Controller

实际效果----点击某个点:

/game?x=7&y=8
  1. th:if(棋子显示)
<div th:if="${cell == 1}" class="piece black"></div>
<div th:if="${cell == 2}" class="piece white"></div>
  • 判断当前位置是什么棋子
  • 决定显示黑棋还是白棋

总结:

本项目中使用了 Thymeleaf 模板引擎,实现了后端数据向前端页面的动态渲染。在控制器中通过 Model 传递棋盘数据、当前玩家和胜负信息,在 HTML 页面中通过 th:text、th:each、th:if、th:href 等指令实现数据展示、循环生成棋盘、条件渲染棋子以及点击交互。相比传统静态页面,Thymeleaf 能够实现页面与数据分离,提高代码的可维护性和动态性。

实验三:post使用和异步交互

先上代码

package com.wuziqi.java.shiyan1.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Controller
@RequestMapping("/game")
public class PageController {

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;

    @GetMapping
    public String gamePage(Model model) {
        initBoard();

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);

        return "wuziqi";
    }

    @PostMapping("/move")
    @ResponseBody
    public Map<String, Object> move(
            @RequestParam int row,
            @RequestParam int col) {

        initBoard();

        if (winner == null) {
            if (row >= 0 && row < 15 && col >= 0 && col < 15 && board.get(row).get(col) == 0) {
                board.get(row).set(col, currentPlayer);

                if (win(row, col)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;
                }
            }
        }

        return buildGameState("欢迎你");
    }

    @PostMapping("/reset")
    @ResponseBody
    public Map<String, Object> reset() {
        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();

        return buildGameState("游戏已重置");
    }

    private Map<String, Object> buildGameState(String message) {
        Map<String, Object> result = new HashMap<>();
        result.put("board", board);
        result.put("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        result.put("message", message);
        result.put("winner", winner);
        return result;
    }

    private void initBoard() {
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }
    }

    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1, 0}, {0, 1}, {1, 1}, {1, -1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0] * k;
                int ny = y + dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0] * k;
                int ny = y - dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            if (count >= 5) {
                return true;
            }
        }
        return false;
    }
}
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>五子棋</title>

    <style>
        body {
            text-align: center;
            font-family: "Microsoft YaHei";
            background: #f5e1b5;
        }

        .board {
            position: relative;
            width: 420px;
            height: 420px;
            margin: 30px auto;
            background: #deb887;
        }

        .line-h {
            position: absolute;
            left: 0;
            width: 100%;
            height: 1px;
            background: #333;
        }

        .line-v {
            position: absolute;
            top: 0;
            height: 100%;
            width: 1px;
            background: #333;
        }

        .point {
            position: absolute;
            width: 20px;
            height: 20px;
            transform: translate(-50%, -50%);
            cursor: pointer;
        }

        .piece {
            width: 18px;
            height: 18px;
            border-radius: 50%;
            margin: auto;
        }

        .black {
            background: black;
        }

        .white {
            background: white;
            border: 1px solid #999;
        }

        .btn-reset {
            padding: 8px 18px;
            font-size: 16px;
            border: none;
            background: #8b4513;
            color: white;
            border-radius: 4px;
            cursor: pointer;
            margin-top: 10px;
        }

        .btn-reset:hover {
            opacity: 0.9;
        }
    </style>
</head>

<body>

<h1>五子棋</h1>
<h2 id="message" th:text="${message}"></h2>
<p>当前玩家:<span id="currentPlayer" th:text="${currentPlayer}"></span></p>
<h2 id="winner"
    th:text="${winner}"
    style="color:red;"></h2>

<button class="btn-reset" onclick="resetGame()">重新开始</button>

<div class="board" id="board">

    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-h"
         th:style="|top:${i * 30}px|"></div>

    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-v"
         th:style="|left:${i * 30}px|"></div>

    <div th:each="row,rowStat : ${board}">
        <div th:each="cell,colStat : ${row}"
             class="point"
             th:attr="data-x=${rowStat.index},data-y=${colStat.index}"
             th:style="|top:${rowStat.index * 30}px; left:${colStat.index * 30}px|">

            <div th:if="${cell == 1}" class="piece black"></div>
            <div th:if="${cell == 2}" class="piece white"></div>

        </div>
    </div>

</div>

<script>
    function bindPointClick() {
        const points = document.querySelectorAll('.point');
        points.forEach(point => {
            point.onclick = async function () {
                const row = parseInt(this.dataset.x);
                const col = parseInt(this.dataset.y);

                try {
                    const response = await fetch(`/game/move?row=${row}&col=${col}`, {
                        method: 'POST'
                    });

                    const data = await response.json();
                    renderGame(data);
                } catch (e) {
                    console.error('落子请求失败:', e);
                }
            };
        });
    }

    async function resetGame() {
        try {
            const response = await fetch('/game/reset', {
                method: 'POST'
            });

            const data = await response.json();
            renderGame(data);
        } catch (e) {
            console.error('重置请求失败:', e);
        }
    }

    function renderGame(data) {
        updateBoard(data.board);
        document.getElementById('currentPlayer').innerText = data.currentPlayer;
        document.getElementById('message').innerText = data.message;
        document.getElementById('winner').innerText = data.winner ? data.winner : '';
    }

    function updateBoard(board) {
        const points = document.querySelectorAll('.point');
        points.forEach(point => {
            const x = parseInt(point.dataset.x);
            const y = parseInt(point.dataset.y);
            const cell = board[x][y];

            point.innerHTML = '';
            if (cell === 1) {
                point.innerHTML = '<div class="piece black"></div>';
            } else if (cell === 2) {
                point.innerHTML = '<div class="piece white"></div>';
            }
        });
    }

    bindPointClick();
</script>

</body>
</html>

在这里插入图片描述
增加本地环境
在这里插入图片描述
在这里插入图片描述
测试/game/move
在这里插入图片描述

在这里插入图片描述

得到以下结果
在这里插入图片描述
在这里插入图片描述
落子成功

测试棋盘重置接口/game/reset
在这里插入图片描述

得到结果
在这里插入图片描述

一、项目里的 Post 方法到底在做什么

后端 PageController 里有两个最关键的 Post 接口:

  • @PostMapping("/move")
  • @PostMapping("/```reset")

它们都挂在类级别路径 /game 下面,所以完整地址分别是:

  • /game/move
  • /game/reset

1)@PostMapping("/move"):处理“落子”

这一段代码的本质是:
前端点击棋盘某个点后,把坐标 row、col 发给后端,后端完成一次游戏状态更新,再把最新棋盘状态以 JSON 返回给前端。

代码结构上,它做了几件事:

第一层:接收参数

public Map<String, Object> move(
        @RequestParam int row,
        @RequestParam int col)

这里的 @RequestParam 表示从请求参数里取值。
前端发的是:

fetch(`/game/move?row=${row}&col=${col}`, {
    method: 'POST'
});

所以 rowcol 是通过 URL 查询参数传进来的,不是放在请求体里。

也就是说,这里虽然用了 POST,但参数传递方式更像“带参数的 URL 请求”。
这是可以工作的,但从规范角度看,更常见的写法是把数据放进请求体 JSON 中。

第二层:确保棋盘已初始化

initBoard();

initBoard() 的作用是:如果 board 为空,就创建一个 15×15 的二维列表,并全部填充为 0。
0 表示空位,1 表示黑棋,2 表示白棋。

所以每次落子请求开始前,后端都会先保证棋盘存在。

第三层:判断游戏还能不能继续

if (winner == null) {

这个条件非常重要。
意思是:只有还没有赢家时,才允许继续落子。

一旦某次操作已经产生了 winner,之后再点击棋盘,后端不会继续处理新的落子。

第四层:校验当前点击是否合法

if (row >= 0 && row < 15 && col >= 0 && col < 15 && board.get(row).get(col) == 0)

这一句同时校验了三件事:

  • 行坐标是否越界
  • 列坐标是否越界
  • 当前格子是不是空的

只有合法且为空的位置才能落子。
这说明后端并没有盲目相信前端,而是自己再次做了校验。这个思路是对的。

第五层:真正修改游戏状态

board.get(row).set(col, currentPlayer);

这里把当前位置设为当前玩家的棋子值:

  • 1 → 黑棋
  • 2 → 白棋

所以 board 承担了整个游戏核心状态存储的作用。

第六层:判断是否胜利

if (win(row, col)) {
    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
} else {
    currentPlayer = 3 - currentPlayer;
}

这里逻辑非常经典:

  • 如果当前这一步导致五子连珠,就设置赢家
  • 否则切换玩家

其中:

currentPlayer = 3 - currentPlayer;

是个很巧妙的写法:

  • 当前是 1,则 3 - 1 = 2
  • 当前是 2,则 3 - 2 = 1

也就是在黑白之间切换。

第七层:返回 JSON 游戏状态

return buildGameState("欢迎你");

buildGameState() 会把以下内容打包成一个 Map<String, Object> 返回:

  • board
  • currentPlayer
  • message
  • winner

因为方法上加了 @ResponseBody,所以 Spring 不会把返回值当成页面名,而是直接序列化成 JSON 响应给前端。

这也是异步交互成立的关键之一。


2)@PostMapping("/reset"):处理“重置游戏”

这一段的职责比 move 更简单,目标就是把整局游戏恢复初始状态。

它依次做了:

board.clear();
currentPlayer = 1;
winner = null;
initBoard();

意思分别是:

  • 清空棋盘数据
  • 当前玩家恢复为黑棋先手
  • 清空赢家
  • 重新初始化 15×15 空棋盘

然后返回新的游戏状态 JSON:

return buildGameState("游戏已重置");

所以这个接口的本质是:
服务端状态重置 + 将重置后的完整状态同步给前端。


二、为什么这里要用 Post,而不是 Get

从 HTTP 语义上看,这两个请求都在修改服务器状态

  • /move 会修改棋盘、切换玩家、可能生成 winner
  • /reset 会清空当前对局状态

因此它们适合使用 POST,而不是 GET。
这是符合 REST 基本思想的:

  • GET:获取资源,不应产生副作用
  • POST:提交动作,修改服务端状态

你项目中也确实这样设计了:

  • @GetMapping 用来加载页面
  • @PostMapping 用来执行动作

这点是比较规范的。


三、前端异步交互功能是怎么实现的

你这个项目的“异步交互”核心全部集中在 HTML 最下面那段 JavaScript。

所谓异步交互,简单说就是:

用户操作 → JS 发请求 → 等服务器返回 → 局部更新页面,不整页刷新。

1)bindPointClick():给每个棋盘点绑定点击事件

function bindPointClick() {
    const points = document.querySelectorAll('.point');
    points.forEach(point => {
        point.onclick = async function () {
            const row = parseInt(this.dataset.x);
            const col = parseInt(this.dataset.y);
            ...
        };
    });
}

这里做了几件事:

a. 先选中所有棋盘交点

页面中每个可点击位置都有 .point 类。
这些元素在 Thymeleaf 渲染棋盘时就已经生成好了。

b. 为每个点绑定点击函数

每个点点下去时,都会执行一个 async function
async 表示这是一个异步函数,可以在内部使用 await

c. 从 data-xdata-y 取出坐标

const row = parseInt(this.dataset.x);
const col = parseInt(this.dataset.y);

这里的坐标来自 HTML 中的自定义属性:

th:attr="data-x=${rowStat.index},data-y=${colStat.index}"

所以前端并不需要自己计算坐标,模板渲染时就把每个点的逻辑位置写好了。


2)fetch + await:发起异步 POST 请求

const response = await fetch(`/game/move?row=${row}&col=${col}`, {
    method: 'POST'
});

这句是整个异步交互最核心的一步。

它的含义可以拆开讲:

fetch(...)

浏览器原生提供的网络请求 API,用来代替早期的 XMLHttpRequest。

method: 'POST'

指定本次请求是 POST。

await

表示“等这个 Promise 完成”。
也就是说,JS 不会阻塞整个页面,但会在当前异步函数中等待请求结果回来。

这就是“异步”的关键:

  • 页面不会刷新
  • 浏览器不会卡死
  • 请求完成后再继续执行下面代码

3)await response.json():把响应解析成 JSON

const data = await response.json();

后端返回的是 Map<String, Object>,由于有 @ResponseBody,Spring 会自动转成 JSON。
前端这里再把它解析为 JavaScript 对象 data

所以最终 data 结构会类似这样:

{
  board: [...],
  currentPlayer: "黑棋",
  message: "欢迎你",
  winner: null
}

或者如果有人赢了:

{
  board: [...],
  currentPlayer: "黑棋",
  message: "欢迎你",
  winner: "黑棋胜利!"
}

4)renderGame(data):局部刷新界面

renderGame(data);

这一步代表:
不用重新请求整个 HTML 页面,只根据后端最新状态把局部 DOM 改掉。

这就是异步交互最大的价值:

  • 交互流畅
  • 不闪屏
  • 不丢失页面状态
  • 用户体验更像“本地应用”

5)异常处理:try...catch

try {
   ...
} catch (e) {
    console.error('落子请求失败:', e);
}

这里说明前端已经考虑到请求失败的情况,比如:

  • 后端没启动
  • 网络异常
  • 返回不是合法 JSON
  • 接口 500 错误

当前处理方式只是打印到控制台,没有给用户提示。
这能保证程序不至于直接崩,但用户体验还不够完善。


四、重置按钮的异步交互原理

resetGame() 和落子逻辑是同一模式。

async function resetGame() {
    try {
        const response = await fetch('/game/reset', {
            method: 'POST'
        });

        const data = await response.json();
        renderGame(data);
    } catch (e) {
        console.error('重置请求失败:', e);
    }
}

这说明重置并不是“前端自己把棋子清掉”,而是:

  1. 发 POST /game/reset
  2. 后端真正重置状态
  3. 返回新的完整棋盘
  4. 前端根据返回数据重新渲染

这个设计是对的,因为游戏真实状态应该以后端为准


五、renderGame()updateBoard() 是如何完成“局部刷新”的

1)renderGame(data):更新页面文本 + 棋盘

function renderGame(data) {
    updateBoard(data.board);
    document.getElementById('currentPlayer').innerText = data.currentPlayer;
    document.getElementById('message').innerText = data.message;
    document.getElementById('winner').innerText = data.winner ? data.winner : '';
}

它相当于一个总控函数,负责把返回状态映射到页面上。

更新了三类信息:

  • 棋盘格子内容
  • 当前玩家文本
  • 提示信息和赢家文本

注意这里不是整个 HTML 重绘,而是只更新变化的 DOM 元素


2)updateBoard(board):根据二维数组重画棋子

function updateBoard(board) {
    const points = document.querySelectorAll('.point');
    points.forEach(point => {
        const x = parseInt(point.dataset.x);
        const y = parseInt(point.dataset.y);
        const cell = board[x][y];

        point.innerHTML = '';
        if (cell === 1) {
            point.innerHTML = '<div class="piece black"></div>';
        } else if (cell === 2) {
            point.innerHTML = '<div class="piece white"></div>';
        }
    });
}

这段逻辑是:

  • 遍历所有棋盘点
  • 根据 data-x / data-y 找到对应坐标
  • 去返回的二维数组 board[x][y] 里取值
  • 0:不显示棋子
  • 1:渲染黑棋
  • 2:渲染白棋

所以,前端页面本质上是把后端返回的 board 当成“渲染数据源”。

这说明你的项目采用的是一种简化版“状态驱动 UI”思想:

  • 后端维护状态
  • 前端拿状态渲染页面

虽然没有用 Vue、React,但思想上已经接近了。


六、整个异步交互链路完整流程

我用一次“点击落子”给你串起来:

第 1 步:页面初始加载

浏览器访问 /game,进入:

@GetMapping
public String gamePage(Model model)

后端初始化棋盘,把 board/currentPlayer/message/winner 放入 Model,返回 wuziqi 页面。

Thymeleaf 根据这些数据生成初始 HTML 棋盘。


第 2 步:前端绑定点击事件

bindPointClick();

页面加载后,给所有 .point 绑定 onclick


第 3 步:用户点击一个交点

JS 拿到该点的 row/col


第 4 步:前端异步 POST 到 /game/move

fetch(`/game/move?row=${row}&col=${col}`, { method: 'POST' })

浏览器发请求,但页面不刷新。


第 5 步:后端处理落子

move() 完成:

  • 校验位置
  • 落子
  • 判赢
  • 切换玩家
  • 返回新状态 JSON

第 6 步:前端收到 JSON

const data = await response.json();

解析为 JS 对象。


第 7 步:前端局部更新页面

renderGame(data);

只更新棋盘和文本,不刷新整个网页。


七、这个项目里的异步交互有什么优点

1)用户体验更流畅

如果每下一步棋都整个页面刷新,会出现闪烁、等待、重渲染。
现在改成异步请求后,落子体验自然很多。

2)前后端职责分离比较清晰

  • 后端负责业务规则:能不能下、谁赢了、轮到谁
  • 前端负责界面交互:点击、发请求、更新页面

这是非常典型的 Web 应用结构。

3)避免前端自己维护复杂规则

比如胜负判断没有写在 JS,而是写在后端 win() 方法里。
这样前端不需要承担核心规则判断,减少了逻辑分散。

4)后端返回完整状态,前端只负责渲染

这种做法简单直接,调试起来也容易。
因为每次接口返回的就是“当前游戏全量状态”。

八、这个项目中 Post 和异步交互的关键技术点

1)@ResponseBody 是异步接口成立的关键

没有它,Spring 会把返回的 Map 误当成视图处理。
加上它之后,move()reset() 变成“返回 JSON 数据的接口”。


2)fetch + await 是前端异步的关键

这使请求流程像同步代码一样易读,但底层仍然是异步 Promise。


3)data-* 属性让 DOM 和棋盘坐标绑定

data-x
data-y

这是前端非常实用的一种做法。
DOM 元素自己就带着业务坐标,不需要额外查表。


4)后端全量返回状态,简化前端逻辑

前端不用猜谁该下,也不用自己判断输赢,只要照着后端返回渲染。


一句话
POST 方法负责“改状态”,异步交互负责“无刷新地把状态变化同步到页面”。

更具体一点:

  • GET /game:负责首次打开页面
  • POST /game/move:负责一次合法落子与胜负判断
  • POST /game/reset:负责恢复初始对局
  • fetch + await:负责把用户操作异步发送到后端
  • renderGame():负责把后端返回的新状态立刻反映到页面上

所以这个项目虽然不大,但已经完整体现了 Web 项目里非常重要的一套思想:

“后端维护状态,前端异步提交动作,收到结果后局部刷新 UI。”

实验4:Spring Data JPA

先上代码
在这里插入图片描述

package com.wuziqi.java.shiyan1.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import com.wuziqi.java.shiyan1.repositories.GameRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@Controller
@RequestMapping("/game")
public class PageController {

    private final GameRepository gameRepository;
    private final ObjectMapper objectMapper;

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;
    private String gameMode = "PVP"; // PVP / PVE

    public PageController(GameRepository gameRepository, ObjectMapper objectMapper) {
        this.gameRepository = gameRepository;
        this.objectMapper = objectMapper;
    }

    @GetMapping
    public String gamePage(Model model) {
        initBoard();

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);
        model.addAttribute("gameMode", gameMode);

        return "wuziqi";
    }

    @PostMapping("/move")
    @ResponseBody
    public Map<String, Object> move(@RequestParam int row,
                                    @RequestParam int col) {
        initBoard();

        if (winner == null) {
            if (isValidMove(row, col)) {
                board.get(row).set(col, currentPlayer);

                if (win(row, col)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;

                    // 玩家对AI模式:AI随机下一步
                    if ("PVE".equals(gameMode) && winner == null && currentPlayer == 2) {
                        aiMove();
                    }
                }
            }
        }

        return buildGameState("欢迎你");
    }

    @PostMapping("/reset")
    @ResponseBody
    public Map<String, Object> reset() {
        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();
        return buildGameState("游戏已重置");
    }

    @PostMapping("/mode")
    @ResponseBody
    public Map<String, Object> changeMode(@RequestParam String mode) {
        if ("PVE".equalsIgnoreCase(mode)) {
            gameMode = "PVE";
        } else {
            gameMode = "PVP";
        }

        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();

        return buildGameState("已切换为 " + ("PVE".equals(gameMode) ? "玩家对AI" : "玩家对玩家"));
    }

    @PostMapping("/save")
    @ResponseBody
    public Map<String, Object> saveGame() {
        Map<String, Object> result = new HashMap<>();
        try {
            String boardJson = objectMapper.writeValueAsString(board);
            String player = currentPlayer == 1 ? "黑棋" : "白棋";

            Game game = new Game(boardJson, player);
            Game savedGame = gameRepository.save(game);

            result.put("success", true);
            result.put("id", savedGame.getId());
            result.put("message", "保存成功,存档ID为:" + savedGame.getId());
        } catch (JsonProcessingException e) {
            result.put("success", false);
            result.put("message", "保存失败:" + e.getMessage());
        }
        return result;
    }

    @GetMapping("/load/{id}")
    @ResponseBody
    public Map<String, Object> loadGame(@PathVariable Long id) {
        Optional<Game> optionalGame = gameRepository.findById(id);

        if (optionalGame.isEmpty()) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "未找到ID为 " + id + " 的存档");
            return result;
        }

        Game game = optionalGame.get();

        try {
            List<List<Integer>> loadedBoard = objectMapper.readValue(
                    game.getBoard(),
                    new TypeReference<List<List<Integer>>>() {}
            );

            board.clear();
            board.addAll(loadedBoard);

            currentPlayer = "黑棋".equals(game.getCurrentPlayer()) ? 1 : 2;
            winner = null;

            Map<String, Object> result = buildGameState("加载成功,存档ID:" + id);
            result.put("success", true);
            result.put("id", id);
            return result;
        } catch (JsonProcessingException e) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "加载失败:" + e.getMessage());
            return result;
        }
    }

    private void aiMove() {
        List<int[]> emptyCells = new ArrayList<>();

        for (int i = 0; i < 15; i++) {
            for (int j = 0; j < 15; j++) {
                if (board.get(i).get(j) == 0) {
                    emptyCells.add(new int[]{i, j});
                }
            }
        }

        if (!emptyCells.isEmpty()) {
            int[] move = emptyCells.get(new Random().nextInt(emptyCells.size()));
            int row = move[0];
            int col = move[1];
            board.get(row).set(col, currentPlayer);

            if (win(row, col)) {
                winner = "白棋胜利!";
            } else {
                currentPlayer = 1;
            }
        }
    }

    private boolean isValidMove(int row, int col) {
        return row >= 0 && row < 15 && col >= 0 && col < 15 && board.get(row).get(col) == 0;
    }

    private Map<String, Object> buildGameState(String message) {
        Map<String, Object> result = new HashMap<>();
        result.put("board", board);
        result.put("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        result.put("message", message);
        result.put("winner", winner);
        result.put("gameMode", gameMode);
        return result;
    }

    private void initBoard() {
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }
    }

    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1, 0}, {0, 1}, {1, 1}, {1, -1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0] * k;
                int ny = y + dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0] * k;
                int ny = y - dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            if (count >= 5) {
                return true;
            }
        }
        return false;
    }
}
package com.wuziqi.java.shiyan1.entities;

import jakarta.persistence.*;

@Entity
@Table(name = "games")
public class Game {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String board;

    @Column(nullable = false)
    private String currentPlayer;

    public Game() {
    }

    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }

    public long getId() {
        return id;
    }

    public void setId(long id) {
        this.id = id;
    }

    public String getBoard() {
        return board;
    }

    public void setBoard(String board) {
        this.board = board;
    }

    public String getCurrentPlayer() {
        return currentPlayer;
    }

    public void setCurrentPlayer(String currentPlayer) {
        this.currentPlayer = currentPlayer;
    }
}
package com.wuziqi.java.shiyan1.repositories;

import com.wuziqi.java.shiyan1.entities.Game;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface GameRepository extends JpaRepository<Game, Long> {
}

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>五子棋</title>

    <style>
        body {
            text-align: center;
            font-family: "Microsoft YaHei";
            background: #f5e1b5;
        }

        .toolbar {
            margin: 15px 0;
        }

        .toolbar button {
            padding: 8px 16px;
            font-size: 15px;
            border: none;
            background: #8b4513;
            color: white;
            border-radius: 4px;
            cursor: pointer;
            margin: 0 6px 8px 6px;
        }

        .toolbar button:hover {
            opacity: 0.9;
        }

        .board {
            position: relative;
            width: 420px;
            height: 420px;
            margin: 30px auto;
            background: #deb887;
        }

        .line-h {
            position: absolute;
            left: 0;
            width: 100%;
            height: 1px;
            background: #333;
        }

        .line-v {
            position: absolute;
            top: 0;
            height: 100%;
            width: 1px;
            background: #333;
        }

        .point {
            position: absolute;
            width: 20px;
            height: 20px;
            transform: translate(-50%, -50%);
            cursor: pointer;
        }

        .piece {
            width: 18px;
            height: 18px;
            border-radius: 50%;
            margin: auto;
        }

        .black {
            background: black;
        }

        .white {
            background: white;
            border: 1px solid #999;
        }
    </style>
</head>

<body>

<h1>五子棋</h1>
<h2 id="message" th:text="${message}"></h2>
<p>当前玩家:<span id="currentPlayer" th:text="${currentPlayer}"></span></p>
<p>当前模式:<span id="gameMode" th:text="${gameMode}"></span></p>
<h2 id="winner" th:text="${winner}" style="color:red;"></h2>

<div class="toolbar">
    <button onclick="changeMode('PVP')">玩家对玩家</button>
    <button onclick="changeMode('PVE')">玩家对AI</button>
    <button onclick="saveGame()">保存游戏</button>
    <button onclick="loadGame()">加载游戏</button>
    <button onclick="resetGame()">重新开始</button>
</div>

<div class="board" id="board">

    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-h"
         th:style="|top:${i * 30}px|"></div>

    <div th:each="i : ${#numbers.sequence(0,14)}"
         class="line-v"
         th:style="|left:${i * 30}px|"></div>

    <div th:each="row,rowStat : ${board}">
        <div th:each="cell,colStat : ${row}"
             class="point"
             th:attr="data-x=${rowStat.index},data-y=${colStat.index}"
             th:style="|top:${rowStat.index * 30}px; left:${colStat.index * 30}px|">

            <div th:if="${cell == 1}" class="piece black"></div>
            <div th:if="${cell == 2}" class="piece white"></div>

        </div>
    </div>
</div>

<script>
    function bindPointClick() {
        const points = document.querySelectorAll('.point');
        points.forEach(point => {
            point.onclick = async function () {
                const row = parseInt(this.dataset.x);
                const col = parseInt(this.dataset.y);

                try {
                    const response = await fetch(`/game/move?row=${row}&col=${col}`, {
                        method: 'POST'
                    });

                    const data = await response.json();
                    renderGame(data);
                } catch (e) {
                    console.error('落子请求失败:', e);
                }
            };
        });
    }

    async function resetGame() {
        try {
            const response = await fetch('/game/reset', {
                method: 'POST'
            });

            const data = await response.json();
            renderGame(data);
        } catch (e) {
            console.error('重置请求失败:', e);
        }
    }

    async function changeMode(mode) {
        try {
            const response = await fetch(`/game/mode?mode=${mode}`, {
                method: 'POST'
            });

            const data = await response.json();
            renderGame(data);
        } catch (e) {
            console.error('切换模式失败:', e);
        }
    }

    async function saveGame() {
        try {
            const response = await fetch('/game/save', {
                method: 'POST'
            });

            const data = await response.json();
            alert(data.message);
        } catch (e) {
            console.error('保存失败:', e);
        }
    }

    async function loadGame() {
        const id = prompt("请输入要加载的存档ID:");
        if (!id) return;

        try {
            const response = await fetch(`/game/load/${id}`, {
                method: 'GET'
            });

            const data = await response.json();
            if (data.success) {
                renderGame(data);
            } else {
                alert(data.message);
            }
        } catch (e) {
            console.error('加载失败:', e);
        }
    }

    function renderGame(data) {
        if (data.board) {
            updateBoard(data.board);
        }
        document.getElementById('currentPlayer').innerText = data.currentPlayer || '';
        document.getElementById('message').innerText = data.message || '';
        document.getElementById('winner').innerText = data.winner ? data.winner : '';
        document.getElementById('gameMode').innerText = data.gameMode || '';
    }

    function updateBoard(board) {
        const points = document.querySelectorAll('.point');
        points.forEach(point => {
            const x = parseInt(point.dataset.x);
            const y = parseInt(point.dataset.y);
            const cell = board[x][y];

            point.innerHTML = '';
            if (cell === 1) {
                point.innerHTML = '<div class="piece black"></div>';
            } else if (cell === 2) {
                point.innerHTML = '<div class="piece white"></div>';
            }
        });
    }

    bindPointClick();
</script>

</body>
</html>

spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect

spring.thymeleaf.cache=false
server.port=8080
-- 创建数据库
CREATE DATABASE IF NOT EXISTS wuziqi
    DEFAULT CHARACTER SET utf8mb4;

-- 使用数据库
USE wuziqi;

-- 创建表
CREATE TABLE IF NOT EXISTS games (
                                     id BIGINT PRIMARY KEY AUTO_INCREMENT,
                                     board TEXT NOT NULL,
                                     current_player VARCHAR(50) NOT NULL
);

在mysql里面运行sql文件创建数据库和表

加分:使用H2数据库
pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.3</version>
        <relativePath/>
    </parent>

    <groupId>com.wuziqi.java</groupId>
    <artifactId>shiyan1</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>shiyan1</name>
    <description>shiyan1</description>


    <properties>
        <java.version>17</java.version>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
            <version>3.5.3</version>
        </dependency>

<!--        <dependency>-->
<!--            <groupId>com.mysql</groupId>-->
<!--            <artifactId>mysql-connector-j</artifactId>-->
<!--            <scope>runtime</scope>-->
<!--        </dependency>-->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

application.properties

# H2 数据库配置:文件模式,数据会保存到项目目录下的 data/wuziqi.mv.db
spring.datasource.url=jdbc:h2:file:./data/wuziqi;MODE=MySQL;DATABASE_TO_UPPER=false
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver

# JPA 配置
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

# 开启 H2 控制台
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Thymeleaf
spring.thymeleaf.cache=false

# 端口
server.port=8080

Game.java

package com.wuziqi.java.shiyan1.entities;

import jakarta.persistence.*;

@Entity
@Table(name = "games")
public class Game {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 棋盘数据(JSON字符串)
     * 使用 @Lob 适配 H2 / MySQL 等数据库的大文本存储
     */
    @Lob
    @Column(nullable = false)
    private String board;

    /**
     * 当前玩家(黑棋 / 白棋)
     */
    @Column(nullable = false)
    private String currentPlayer;

    // ===== 构造方法 =====
    public Game() {
    }

    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }

    // ===== Getter / Setter =====
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getBoard() {
        return board;
    }

    public void setBoard(String board) {
        this.board = board;
    }

    public String getCurrentPlayer() {
        return currentPlayer;
    }

    public void setCurrentPlayer(String currentPlayer) {
        this.currentPlayer = currentPlayer;
    }

    // ===== 可选:方便调试 =====
    @Override
    public String toString() {
        return "Game{" +
                "id=" + id +
                ", currentPlayer='" + currentPlayer + '\'' +
                '}';
    }
}

在这里插入图片描述

代码运行之后自动生成对应数据库
在这里插入图片描述

代码解析

五子棋游戏实验报告

一、项目概述

这是一个基于Spring Boot框架的五子棋Web应用,支持玩家对战(PVP)和玩家对战AI(PVE)两种模式,并提供游戏存档和加载功能。

二、完整实验过程

1. 项目结构

src/main/java/com/wuziqi/java/shiyan1/
├── controller/
│   └── PageController.java      # 控制器,处理HTTP请求
├── entities/
│   └── Game.java                 # 实体类,对应数据库表
└── repositories/
    └── GameRepository.java       # 数据访问层

src/main/resources/
├── templates/
│   └── wuziqi.html               # 前端页面
└── application.properties        # 配置文件

2. 数据库配置详解

application.properties - 数据库连接配置:

# 数据库连接配置
spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
spring.datasource.username=root      # MySQL用户名
spring.datasource.password=123456    # MySQL密码
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA配置 - 自动管理数据库表结构
spring.jpa.hibernate.ddl-auto=update   # 自动创建/更新表结构
spring.jpa.show-sql=true                # 控制台显示SQL语句
spring.jpa.properties.hibernate.format_sql=true  # 格式化SQL输出
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect  # MySQL8方言

# Thymeleaf模板引擎配置
spring.thymeleaf.cache=false    # 关闭模板缓存,便于开发调试
server.port=8080                # 服务端口

3. 核心代码解释

(1) 实体类 Game.java - 数据库表映射
package com.wuziqi.java.shiyan1.entities;

import jakarta.persistence.*;

@Entity                           // 标记为JPA实体,与数据库表对应
@Table(name = "games")            // 指定数据库表名为"games"
public class Game {

    @Id                           // 主键标识
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // 自增主键
    private long id;

    @Column(columnDefinition = "TEXT", nullable = false)  // TEXT类型,不能为空
    private String board;         // 存储棋盘状态(JSON格式)

    @Column(nullable = false)     // 不能为空
    private String currentPlayer;  // 存储当前轮到谁下棋

    // 无参构造器(JPA要求)
    public Game() {}
    
    // 有参构造器
    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }
    
    // getter和setter方法...
}

关键点解释:

  • @Entity:告诉Spring这是一个需要持久化到数据库的Java类
  • @Id:主键,每条记录的唯一标识
  • @GeneratedValue:主键自动生成策略
  • columnDefinition = "TEXT":MySQL中TEXT类型可存储最多65535字符,足够存储15x15棋盘
(2) 存储库接口 GameRepository.java - 数据访问层
package com.wuziqi.java.shiyan1.repositories;

import com.wuziqi.java.shiyan1.entities.Game;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository                    // 标记为数据访问组件
public interface GameRepository extends JpaRepository<Game, Long> {
    // 继承JpaRepository后,自动获得以下方法:
    // save()    - 保存/更新记录
    // findById() - 根据ID查询
    // findAll()  - 查询所有
    // deleteById() - 删除记录
    // 不需要写任何实现代码!
}

关键点解释:

  • JpaRepository<Game, Long>:泛型参数
    • 第一个:要操作的实体类类型
    • 第二个:主键的类型
  • Spring Data JPA会自动生成实现类,无需编写SQL语句
(3) 控制器中的载入和保存代码

保存游戏:

@PostMapping("/save")
@ResponseBody
public Map<String, Object> saveGame() {
    Map<String, Object> result = new HashMap<>();
    try {
        // 1. 将棋盘对象转为JSON字符串
        // board是一个 List<List<Integer>> 类型的15x15数组
        String boardJson = objectMapper.writeValueAsString(board);
        
        // 2. 获取当前玩家(1=黑棋,2=白棋,转成中文存储)
        String player = currentPlayer == 1 ? "黑棋" : "白棋";
        
        // 3. 创建Game实体对象
        Game game = new Game(boardJson, player);
        
        // 4. 调用Repository保存到数据库
        Game savedGame = gameRepository.save(game);
        
        // 5. 返回成功响应,包含自动生成的存档ID
        result.put("success", true);
        result.put("id", savedGame.getId());
        result.put("message", "保存成功,存档ID为:" + savedGame.getId());
    } catch (JsonProcessingException e) {
        result.put("success", false);
        result.put("message", "保存失败:" + e.getMessage());
    }
    return result;
}

加载游戏:

@GetMapping("/load/{id}")
@ResponseBody
public Map<String, Object> loadGame(@PathVariable Long id) {
    // 1. 根据ID查询存档
    Optional<Game> optionalGame = gameRepository.findById(id);
    
    // 2. 检查存档是否存在
    if (optionalGame.isEmpty()) {
        Map<String, Object> result = new HashMap<>();
        result.put("success", false);
        result.put("message", "未找到ID为 " + id + " 的存档");
        return result;
    }
    
    Game game = optionalGame.get();
    
    try {
        // 3. 将JSON字符串还原为棋盘对象
        List<List<Integer>> loadedBoard = objectMapper.readValue(
            game.getBoard(),
            new TypeReference<List<List<Integer>>>() {}
        );
        
        // 4. 更新当前棋盘状态
        board.clear();
        board.addAll(loadedBoard);
        
        // 5. 恢复当前玩家(中文转回数字)
        currentPlayer = "黑棋".equals(game.getCurrentPlayer()) ? 1 : 2;
        winner = null;  // 加载存档后清除胜利状态
        
        // 6. 返回成功响应
        Map<String, Object> result = buildGameState("加载成功,存档ID:" + id);
        result.put("success", true);
        result.put("id", id);
        return result;
    } catch (JsonProcessingException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("success", false);
        result.put("message", "加载失败:" + e.getMessage());
        return result;
    }
}

4. 数据库SQL脚本

wuziqi.sql - 手动创建数据库和表:

-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS wuziqi
    DEFAULT CHARACTER SET utf8mb4;  -- utf8mb4支持emoji等特殊字符

-- 使用数据库
USE wuziqi;

-- 创建表
CREATE TABLE IF NOT EXISTS games (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,  -- 自增主键
    board TEXT NOT NULL,                    -- 棋盘JSON数据
    current_player VARCHAR(50) NOT NULL    -- 当前玩家
);

5. 前端关键代码解释

// 保存游戏 - 发送POST请求到后端
async function saveGame() {
    try {
        const response = await fetch('/game/save', {
            method: 'POST'  // POST请求,用于保存数据
        });
        const data = await response.json();
        alert(data.message);  // 显示"保存成功,存档ID为:xxx"
    } catch (e) {
        console.error('保存失败:', e);
    }
}

// 加载游戏 - 用户输入ID后发送GET请求
async function loadGame() {
    const id = prompt("请输入要加载的存档ID:");  // 弹出输入框
    if (!id) return;
    
    try {
        // GET请求,通过URL路径传递ID
        const response = await fetch(`/game/load/${id}`, {
            method: 'GET'
        });
        const data = await response.json();
        if (data.success) {
            renderGame(data);  // 重新渲染棋盘
        } else {
            alert(data.message);
        }
    } catch (e) {
        console.error('加载失败:', e);
    }
}

三、数据流转图

【保存游戏流程】
前端棋盘状态(board) → JSON字符串 → Game实体 → Repository.save() → MySQL数据库

【加载游戏流程】
用户输入ID → Repository.findById() → Game实体 → JSON解析 → 恢复棋盘状态

四、运行步骤

  1. 创建数据库:执行 wuziqi.sql 脚本
  2. 修改数据库密码:编辑 application.properties,修改 spring.datasource.password=你的密码
  3. 启动MySQL服务
  4. 运行Spring Boot应用:执行主类
  5. 访问游戏:浏览器打开 http://localhost:8080/game

五、注意事项

  1. 确保MySQL服务已启动
  2. 数据库用户名/密码与配置文件一致
  3. 首次运行时会自动创建表(ddl-auto=update
  4. 保存的JSON格式示例:[[0,0,1,0,...],[...]](0空,1黑,2白)

实验五:mybatis的使用

在这里插入图片描述

先上代码:

package com.wuziqi.java.shiyan1.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import com.wuziqi.java.shiyan1.mapper.GameMapper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@Controller
@RequestMapping("/game")
public class PageController {

    private final GameMapper gameMapper;
    private final ObjectMapper objectMapper;

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;
    private String gameMode = "PVP"; // PVP / PVE

    public PageController(GameMapper gameMapper, ObjectMapper objectMapper) {
        this.gameMapper = gameMapper;
        this.objectMapper = objectMapper;
    }

    @GetMapping
    public String gamePage(Model model) {
        initBoard();

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);
        model.addAttribute("gameMode", gameMode);

        return "wuziqi";
    }

    @PostMapping("/move")
    @ResponseBody
    public Map<String, Object> move(@RequestParam int row,
                                    @RequestParam int col) {
        initBoard();

        if (winner == null) {
            if (isValidMove(row, col)) {
                board.get(row).set(col, currentPlayer);

                if (win(row, col)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;

                    // 玩家对AI模式:AI随机下一步
                    if ("PVE".equals(gameMode) && winner == null && currentPlayer == 2) {
                        aiMove();
                    }
                }
            }
        }

        return buildGameState("欢迎你");
    }

    @PostMapping("/reset")
    @ResponseBody
    public Map<String, Object> reset() {
        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();
        return buildGameState("游戏已重置");
    }

    @PostMapping("/mode")
    @ResponseBody
    public Map<String, Object> changeMode(@RequestParam String mode) {
        if ("PVE".equalsIgnoreCase(mode)) {
            gameMode = "PVE";
        } else {
            gameMode = "PVP";
        }

        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();

        return buildGameState("已切换为 " + ("PVE".equals(gameMode) ? "玩家对AI" : "玩家对玩家"));
    }

    @PostMapping("/save")
    @ResponseBody
    public Map<String, Object> saveGame() {
        Map<String, Object> result = new HashMap<>();
        try {
            String boardJson = objectMapper.writeValueAsString(board);
            String player = currentPlayer == 1 ? "黑棋" : "白棋";

            Game game = new Game(boardJson, player);

            // MyBatis 插入
            gameMapper.insert(game);

            result.put("success", true);
            result.put("id", game.getId());
            result.put("message", "保存成功,存档ID为:" + game.getId());
        } catch (JsonProcessingException e) {
            result.put("success", false);
            result.put("message", "保存失败:" + e.getMessage());
        }
        return result;
    }

    @GetMapping("/load/{id}")
    @ResponseBody
    public Map<String, Object> loadGame(@PathVariable Long id) {

        // MyBatis 查询
        Game game = gameMapper.findById(id);

        if (game == null) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "未找到ID为 " + id + " 的存档");
            return result;
        }

        try {
            List<List<Integer>> loadedBoard = objectMapper.readValue(
                    game.getBoard(),
                    new TypeReference<List<List<Integer>>>() {}
            );

            board.clear();
            board.addAll(loadedBoard);

            currentPlayer = "黑棋".equals(game.getCurrentPlayer()) ? 1 : 2;
            winner = null;

            Map<String, Object> result = buildGameState("加载成功,存档ID:" + id);
            result.put("success", true);
            result.put("id", id);
            return result;
        } catch (JsonProcessingException e) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "加载失败:" + e.getMessage());
            return result;
        }
    }

    private void aiMove() {
        List<int[]> emptyCells = new ArrayList<>();

        for (int i = 0; i < 15; i++) {
            for (int j = 0; j < 15; j++) {
                if (board.get(i).get(j) == 0) {
                    emptyCells.add(new int[]{i, j});
                }
            }
        }

        if (!emptyCells.isEmpty()) {
            int[] move = emptyCells.get(new Random().nextInt(emptyCells.size()));
            int row = move[0];
            int col = move[1];
            board.get(row).set(col, currentPlayer);

            if (win(row, col)) {
                winner = "白棋胜利!";
            } else {
                currentPlayer = 1;
            }
        }
    }

    private boolean isValidMove(int row, int col) {
        return row >= 0 && row < 15 && col >= 0 && col < 15 && board.get(row).get(col) == 0;
    }

    private Map<String, Object> buildGameState(String message) {
        Map<String, Object> result = new HashMap<>();
        result.put("board", board);
        result.put("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        result.put("message", message);
        result.put("winner", winner);
        result.put("gameMode", gameMode);
        return result;
    }

    private void initBoard() {
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }
    }

    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1, 0}, {0, 1}, {1, 1}, {1, -1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0] * k;
                int ny = y + dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0] * k;
                int ny = y - dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            if (count >= 5) {
                return true;
            }
        }
        return false;
    }
}
package com.wuziqi.java.shiyan1.entities;

public class Game {

    private Long id;
    private String board;
    private String currentPlayer;

    public Game() {
    }

    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getBoard() {
        return board;
    }

    public void setBoard(String board) {
        this.board = board;
    }

    public String getCurrentPlayer() {
        return currentPlayer;
    }

    public void setCurrentPlayer(String currentPlayer) {
        this.currentPlayer = currentPlayer;
    }
}

package com.wuziqi.java.shiyan1.mapper;

import com.wuziqi.java.shiyan1.entities.Game;

public interface GameMapper {

    int insert(Game game);

    Game findById(Long id);
}

package com.wuziqi.java.shiyan1;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.wuziqi.java.shiyan1.mapper")
public class Shiyan1Application {

    public static void main(String[] args) {
        SpringApplication.run(Shiyan1Application.class, args);
    }

}

GameMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.wuziqi.java.shiyan1.mapper.GameMapper">

    <!-- 插入 -->
    <insert id="insert" parameterType="Game" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO games (board, current_player)
        VALUES (#{board}, #{currentPlayer})
    </insert>

    <!-- 查询 -->
    <select id="findById" parameterType="long" resultType="Game">
        SELECT
            id,
            board,
            current_player AS currentPlayer
        FROM games
        WHERE id = #{id}
    </select>

</mapper>

application.properties:

spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.wuziqi.java.shiyan1.entities
mybatis.configuration.map-underscore-to-camel-case=true

spring.thymeleaf.cache=false
server.port=8080

pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.3</version>
        <relativePath/>
    </parent>

    <groupId>com.wuziqi.java</groupId>
    <artifactId>shiyan1</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>shiyan1</name>
    <description>shiyan1</description>


    <properties>
        <java.version>17</java.version>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

<!--        <dependency>-->
<!--            <groupId>org.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-data-jpa</artifactId>-->
<!--            <version>3.5.3</version>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.4</version>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

加分:mybatisplus
先删除mapper目录下的xml文件
pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.3</version>
        <relativePath/>
    </parent>

    <groupId>com.wuziqi.java</groupId>
    <artifactId>shiyan1</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>shiyan1</name>
    <description>shiyan1</description>


    <properties>
        <java.version>17</java.version>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

<!--        <dependency>-->
<!--            <groupId>org.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-data-jpa</artifactId>-->
<!--            <version>3.5.3</version>-->
<!--        </dependency>-->
<!--        <dependency>-->
<!--            <groupId>org.mybatis.spring.boot</groupId>-->
<!--            <artifactId>mybatis-spring-boot-starter</artifactId>-->
<!--            <version>3.0.4</version>-->
<!--        </dependency>-->


        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.9</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# MyBatis-Plus 配置
mybatis-plus.configuration.map-underscore-to-camel-case=true

spring.thymeleaf.cache=false
server.port=8080

GameMapper.java

package com.wuziqi.java.shiyan1.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface GameMapper extends BaseMapper<Game> {

}

PageController.java

package com.wuziqi.java.shiyan1.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import com.wuziqi.java.shiyan1.mapper.GameMapper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@Controller
@RequestMapping("/game")
public class PageController {

    private final GameMapper gameMapper;
    private final ObjectMapper objectMapper;

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;
    private String gameMode = "PVP";

    public PageController(GameMapper gameMapper, ObjectMapper objectMapper) {
        this.gameMapper = gameMapper;
        this.objectMapper = objectMapper;
    }

    @GetMapping
    public String gamePage(Model model) {
        initBoard();

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);
        model.addAttribute("gameMode", gameMode);

        return "wuziqi";
    }

    @PostMapping("/move")
    @ResponseBody
    public Map<String, Object> move(@RequestParam int row,
                                    @RequestParam int col) {
        initBoard();

        if (winner == null) {
            if (isValidMove(row, col)) {
                board.get(row).set(col, currentPlayer);

                if (win(row, col)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;

                    if ("PVE".equals(gameMode) && currentPlayer == 2) {
                        aiMove();
                    }
                }
            }
        }

        return buildGameState("欢迎你");
    }

    @PostMapping("/reset")
    @ResponseBody
    public Map<String, Object> reset() {
        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();
        return buildGameState("游戏已重置");
    }

    @PostMapping("/mode")
    @ResponseBody
    public Map<String, Object> changeMode(@RequestParam String mode) {
        gameMode = "PVE".equalsIgnoreCase(mode) ? "PVE" : "PVP";

        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();

        return buildGameState("已切换模式");
    }

    @PostMapping("/save")
    @ResponseBody
    public Map<String, Object> saveGame() {
        Map<String, Object> result = new HashMap<>();
        try {
            String boardJson = objectMapper.writeValueAsString(board);
            String player = currentPlayer == 1 ? "黑棋" : "白棋";

            Game game = new Game(boardJson, player);

            // MyBatis-Plus 插入
            gameMapper.insert(game);

            result.put("success", true);
            result.put("id", game.getId());
            result.put("message", "保存成功,ID:" + game.getId());
        } catch (JsonProcessingException e) {
            result.put("success", false);
            result.put("message", "保存失败:" + e.getMessage());
        }
        return result;
    }

    @GetMapping("/load/{id}")
    @ResponseBody
    public Map<String, Object> loadGame(@PathVariable Long id) {

        // MyBatis-Plus 查询
        Game game = gameMapper.selectById(id);

        if (game == null) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "未找到存档");
            return result;
        }

        try {
            List<List<Integer>> loadedBoard = objectMapper.readValue(
                    game.getBoard(),
                    new TypeReference<List<List<Integer>>>() {}
            );

            board.clear();
            board.addAll(loadedBoard);

            currentPlayer = "黑棋".equals(game.getCurrentPlayer()) ? 1 : 2;
            winner = null;

            Map<String, Object> result = buildGameState("加载成功");
            result.put("success", true);
            return result;
        } catch (JsonProcessingException e) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "加载失败:" + e.getMessage());
            return result;
        }
    }

    private void aiMove() {
        List<int[]> emptyCells = new ArrayList<>();

        for (int i = 0; i < 15; i++) {
            for (int j = 0; j < 15; j++) {
                if (board.get(i).get(j) == 0) {
                    emptyCells.add(new int[]{i, j});
                }
            }
        }

        if (!emptyCells.isEmpty()) {
            int[] move = emptyCells.get(new Random().nextInt(emptyCells.size()));
            board.get(move[0]).set(move[1], currentPlayer);

            if (win(move[0], move[1])) {
                winner = "白棋胜利!";
            } else {
                currentPlayer = 1;
            }
        }
    }

    private boolean isValidMove(int row, int col) {
        return row >= 0 && col >= 0 && row < 15 && col < 15 && board.get(row).get(col) == 0;
    }

    private Map<String, Object> buildGameState(String message) {
        Map<String, Object> result = new HashMap<>();
        result.put("board", board);
        result.put("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        result.put("message", message);
        result.put("winner", winner);
        result.put("gameMode", gameMode);
        return result;
    }

    private void initBoard() {
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }
    }

    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1,0},{0,1},{1,1},{1,-1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0] * k;
                int ny = y + dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) break;
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0] * k;
                int ny = y - dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) break;
                count++;
            }

            if (count >= 5) return true;
        }
        return false;
    }
}

Game.java

package com.wuziqi.java.shiyan1.entities;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("games")
public class Game {

    @TableId(type = IdType.AUTO)
    private Long id;

    private String board;

    private String currentPlayer;

    public Game() {
    }

    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getBoard() {
        return board;
    }

    public void setBoard(String board) {
        this.board = board;
    }

    public String getCurrentPlayer() {
        return currentPlayer;
    }

    public void setCurrentPlayer(String currentPlayer) {
        this.currentPlayer = currentPlayer;
    }
}

在这里插入图片描述

一、这个实验整体在做什么

项目是一个 Spring Boot 五子棋项目。

页面负责下棋、切换模式、保存游戏、加载游戏;后端 Controller 负责处理这些请求;数据库负责保存棋盘状态。

其中和数据库有关的功能主要有两个:

gameMapper.insert(game);

用于保存当前棋局。

gameMapper.findById(id);

用于根据存档 ID 读取棋局。

也就是说,MyBatis 在这个实验中只负责数据库持久化部分:把当前棋盘保存到 MySQL,再从 MySQL 查出来恢复游戏。


二、项目中的数据流

以“保存游戏”为例:

前端点击:

fetch('/game/save', {
    method: 'POST'
});

进入后端:

@PostMapping("/save")
@ResponseBody
public Map<String, Object> saveGame()

后端把当前棋盘 board 转成 JSON 字符串:

String boardJson = objectMapper.writeValueAsString(board);

然后封装成实体对象:

Game game = new Game(boardJson, player);

再调用 MyBatis Mapper:

gameMapper.insert(game);

最后 MyBatis 执行 XML 中的 SQL:

INSERT INTO games (board, current_player)
VALUES (#{board}, #{currentPlayer})

保存到数据库。


三、MyBatis 是什么

MyBatis 是一个半自动 ORM 框架

ORM 的意思是:对象关系映射

简单说,就是:

Java 对象  <——>  数据库表

在你的项目里:

Game

对应数据库中的:

games

Java 字段:

private Long id;
private String board;
private String currentPlayer;

对应数据库字段:

id
board
current_player

四、为什么说 MyBatis 是“半自动”

因为 MyBatis 不像 JPA 那样完全自动生成 SQL。

JPA 中你之前这样写:

gameRepository.save(game);
gameRepository.findById(id);

SQL 基本由框架自动生成。

但 MyBatis 中,你需要自己写 SQL:

<insert id="insert">
    INSERT INTO games (board, current_player)
    VALUES (#{board}, #{currentPlayer})
</insert>
<select id="findById">
    SELECT id, board, current_player AS currentPlayer
    FROM games
    WHERE id = #{id}
</select>

所以 MyBatis 的特点是:

SQL 自己写,执行过程交给框架。

这也是它常用于企业项目的原因:SQL 更清楚、更可控、更适合复杂查询。


五、MyBatis 的核心原理

MyBatis 的核心可以理解成 4 件事:

1. Mapper 接口

你写了一个接口:

public interface GameMapper {

    int insert(Game game);

    Game findById(Long id);
}

这个接口没有实现类。

但程序运行时,MyBatis 会自动帮你生成一个代理对象。

也就是说,你虽然没有写:

class GameMapperImpl implements GameMapper

但 MyBatis 会在运行时动态生成类似的实现。


2. XML 映射文件

你的 XML 文件:

<mapper namespace="com.wuziqi.java.shiyan1.mapper.GameMapper">

这行非常关键。

它告诉 MyBatis:

这个 XML 对应的是 GameMapper 接口。

然后:

<insert id="insert">

对应接口中的:

int insert(Game game);
<select id="findById">

对应接口中的:

Game findById(Long id);

所以 MyBatis 靠的是:

namespace + id

来找到接口方法对应的 SQL。


3. 参数绑定

例如:

gameMapper.insert(game);

传进去的是一个 Game 对象。

XML 里写:

VALUES (#{board}, #{currentPlayer})

MyBatis 会自动调用:

game.getBoard()
game.getCurrentPlayer()

把值填进 SQL。

也就是说:

#{board}

对应:

game.getBoard()
#{currentPlayer}

对应:

game.getCurrentPlayer()

这就是参数绑定。


4. 结果映射

查询时 XML 写的是:

<select id="findById" resultType="Game">
    SELECT
        id,
        board,
        current_player AS currentPlayer
    FROM games
    WHERE id = #{id}
</select>

查询结果是数据库的一行记录。

MyBatis 会把这行记录封装成一个 Game 对象。

数据库字段:

id
board
current_player

映射到 Java 属性:

id
board
currentPlayer

因为数据库字段 current_player 和 Java 属性 currentPlayer 名字不完全一样,所以你在 SQL 中写了:

current_player AS currentPlayer

这样 MyBatis 就能正确赋值给:

setCurrentPlayer(...)

六、这个实验中 MyBatis 的完整工作流程

以加载游戏为例:

前端请求:

fetch(`/game/load/${id}`)

后端接收:

@GetMapping("/load/{id}")
@ResponseBody
public Map<String, Object> loadGame(@PathVariable Long id)

调用 Mapper:

Game game = gameMapper.findById(id);

MyBatis 做了这些事情:

1. 找到 GameMapper 接口
2. 根据方法名 findById 找到 XML 中 id="findById" 的 SQL
3. 把 id 参数绑定到 #{id}
4. 执行 SQL:
   SELECT id, board, current_player AS currentPlayer
   FROM games
   WHERE id = ?
5. 把查询结果封装成 Game 对象
6. 返回给 Controller

然后 Controller 取出棋盘 JSON:

game.getBoard()

再转回二维数组:

List<List<Integer>> loadedBoard = objectMapper.readValue(
        game.getBoard(),
        new TypeReference<List<List<Integer>>>() {}
);

最后恢复页面上的棋盘状态。


七、每个文件在实验中的作用

1. Game.java

它是实体类,也叫 POJO。

public class Game {
    private Long id;
    private String board;
    private String currentPlayer;
}

作用是:封装数据库中的一条游戏存档记录。

它不再需要:

@Entity
@Table
@Id
@Column

因为这些是 JPA 的注解,MyBatis 不依赖它们。


2. GameMapper.java

它是 Mapper 接口:

public interface GameMapper {

    int insert(Game game);

    Game findById(Long id);
}

作用是:声明数据库操作方法。

它相当于以前的:

GameRepository

但不同的是,GameRepository 是 JPA 自动生成 SQL,而 GameMapper 是和 XML 里的 SQL 对应。


3. GameMapper.xml

这是 MyBatis XML 映射文件。

作用是:真正写 SQL 的地方。

<insert id="insert">
    INSERT INTO games (board, current_player)
    VALUES (#{board}, #{currentPlayer})
</insert>
<select id="findById">
    SELECT id, board, current_player AS currentPlayer
    FROM games
    WHERE id = #{id}
</select>

XML 中的 id 必须和接口方法名一致。


4. Shiyan1Application.java

启动类中加了:

@MapperScan("com.wuziqi.java.shiyan1.mapper")

作用是:告诉 Spring Boot 去哪里扫描 MyBatis 的 Mapper 接口。

如果不写这个,Spring 可能找不到 GameMapper,Controller 注入时会报错:

No qualifying bean of type 'GameMapper'

5. application.properties

这里配置数据库和 MyBatis。

spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi
spring.datasource.username=root
spring.datasource.password=123456

这是数据库连接信息。

mybatis.mapper-locations=classpath:mapper/*.xml

这表示 MyBatis 去:

src/main/resources/mapper/

下面找 XML 文件。

mybatis.type-aliases-package=com.wuziqi.java.shiyan1.entities

这表示 XML 里可以写:

parameterType="Game"
resultType="Game"

而不用写完整类名:

parameterType="com.wuziqi.java.shiyan1.entities.Game"
mybatis.configuration.map-underscore-to-camel-case=true

表示开启下划线转驼峰。

例如:

current_player -> currentPlayer

虽然你 XML 里已经写了:

current_player AS currentPlayer

但保留这个配置是好习惯。


本实验在 Spring Boot 五子棋项目中集成 MyBatis,实现游戏存档的保存与读取功能。项目通过 Mapper 接口定义数据库操作方法,通过 XML 映射文件编写 SQL 语句,并利用 MyBatis 完成 Java 对象与数据库记录之间的映射。

在保存游戏时,Controller 将当前棋盘状态转换为 JSON 字符串,封装到 Game 对象中,并调用 GameMapper 的 insert 方法。MyBatis 根据 Mapper 接口方法找到 XML 中对应的 insert 语句,将对象属性绑定到 SQL 参数中,最终将棋局数据保存到 games 表。

在加载游戏时,Controller 根据用户输入的存档 ID 调用 GameMapper 的 findById 方法。MyBatis 执行 XML 中配置的 select 语句,将查询结果封装为 Game 对象,再由 Controller 将棋盘 JSON 字符串反序列化为二维集合,从而恢复棋盘状态。

通过本实验,可以理解 MyBatis 的核心思想:Mapper 接口负责定义操作,XML 文件负责编写 SQL,MyBatis 在运行时为接口生成代理对象,完成参数绑定、SQL 执行和结果映射。相比 Spring Data JPA,MyBatis 需要手写 SQL,但SQL更加直观、灵活,适合复杂查询场景。

实验七

在这里插入图片描述

先上代码:
CacheConfig.java

package com.wuziqi.java.shiyan1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;

import java.time.Duration;

@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                // Cache TTL = 10 minutes.
                .entryTtl(Duration.ofMinutes(10))

                // Do not cache null values.
                .disableCachingNullValues()

                // Serialize cache values as JSON.
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer()
                        )
                );
    }
}

SecurityConfig.java

package com.wuziqi.java.shiyan1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // Frontend uses fetch POST without CSRF token, so disable CSRF for now.
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        // Public pages.
                        .requestMatchers("/", "/game", "/error").permitAll()

                        // Public API for archive count display.
                        .requestMatchers(HttpMethod.GET, "/game/archive/count").permitAll()

                        // Public game actions.
                        .requestMatchers(HttpMethod.POST, "/game/move", "/game/reset", "/game/mode").permitAll()

                        // Login required for archive read/write.
                        .requestMatchers(HttpMethod.POST, "/game/save").authenticated()
                        .requestMatchers(HttpMethod.GET, "/game/load/**").authenticated()

                        // Any other request requires login.
                        .anyRequest().authenticated()
                )
                // Use default login page and redirect to /game after successful login.
                .formLogin(form -> form.defaultSuccessUrl("/game", true))
                .logout(logout -> logout.logoutSuccessUrl("/game"));

        return http.build();
    }
}

PageController.java

package com.wuziqi.java.shiyan1.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import com.wuziqi.java.shiyan1.service.GameArchiveService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

@Controller
@RequestMapping("/game")
public class PageController {

    private final GameArchiveService gameArchiveService;
    private final ObjectMapper objectMapper;

    private final List<List<Integer>> board = new ArrayList<>();
    private int currentPlayer = 1;
    private String winner = null;
    private String gameMode = "PVP";

    public PageController(GameArchiveService gameArchiveService, ObjectMapper objectMapper) {
        this.gameArchiveService = gameArchiveService;
        this.objectMapper = objectMapper;
    }

    @GetMapping
    public String gamePage(Model model) {
        initBoard();

        model.addAttribute("board", board);
        model.addAttribute("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        model.addAttribute("message", "欢迎你");
        model.addAttribute("winner", winner);
        model.addAttribute("gameMode", gameMode);
        model.addAttribute("archiveCount", gameArchiveService.countAll());

        return "wuziqi";
    }

    @PostMapping("/move")
    @ResponseBody
    public Map<String, Object> move(@RequestParam int row,
                                    @RequestParam int col) {
        initBoard();

        if (winner == null) {
            if (isValidMove(row, col)) {
                board.get(row).set(col, currentPlayer);

                if (win(row, col)) {
                    winner = currentPlayer == 1 ? "黑棋胜利!" : "白棋胜利!";
                } else {
                    currentPlayer = 3 - currentPlayer;

                    if ("PVE".equals(gameMode) && currentPlayer == 2) {
                        aiMove();
                    }
                }
            }
        }

        return buildGameState("欢迎你");
    }

    @PostMapping("/reset")
    @ResponseBody
    public Map<String, Object> reset() {
        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();
        return buildGameState("游戏已重置");
    }

    @PostMapping("/mode")
    @ResponseBody
    public Map<String, Object> changeMode(@RequestParam String mode) {
        gameMode = "PVE".equalsIgnoreCase(mode) ? "PVE" : "PVP";

        board.clear();
        currentPlayer = 1;
        winner = null;
        initBoard();

        return buildGameState("已切换模式");
    }

    @PostMapping("/save")
    @ResponseBody
    public Map<String, Object> saveGame() {
        Map<String, Object> result = new HashMap<>();
        try {
            String boardJson = objectMapper.writeValueAsString(board);
            String player = currentPlayer == 1 ? "黑棋" : "白棋";

            Game game = new Game(boardJson, player);
            // Save through service layer so DB write and cache update are handled together.
            gameArchiveService.save(game);

            result.put("success", true);
            result.put("id", game.getId());
            result.put("message", "保存成功,ID:" + game.getId());
        } catch (JsonProcessingException e) {
            result.put("success", false);
            result.put("message", "保存失败:" + e.getMessage());
        }
        return result;
    }

    @GetMapping("/load/{id}")
    @ResponseBody
    public Map<String, Object> loadGame(@PathVariable Long id) {

        // Load through service layer: cache first, database on miss.
        Game game = gameArchiveService.findById(id);

        if (game == null) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "未找到存档");
            return result;
        }

        try {
            List<List<Integer>> loadedBoard = objectMapper.readValue(
                    game.getBoard(),
                    new TypeReference<List<List<Integer>>>() {}
            );

            board.clear();
            board.addAll(loadedBoard);

            currentPlayer = "黑棋".equals(game.getCurrentPlayer()) ? 1 : 2;
            winner = null;

            Map<String, Object> result = buildGameState("加载成功");
            result.put("success", true);
            return result;
        } catch (JsonProcessingException e) {
            Map<String, Object> result = new HashMap<>();
            result.put("success", false);
            result.put("message", "加载失败:" + e.getMessage());
            return result;
        }
    }

    @GetMapping("/archive/count")
    @ResponseBody
    public Map<String, Object> archiveCount() {
        Map<String, Object> result = new HashMap<>();
        // Count is cached to reduce repeated count SQL.
        result.put("total", gameArchiveService.countAll());
        return result;
    }

    private void aiMove() {
        List<int[]> emptyCells = new ArrayList<>();

        for (int i = 0; i < 15; i++) {
            for (int j = 0; j < 15; j++) {
                if (board.get(i).get(j) == 0) {
                    emptyCells.add(new int[]{i, j});
                }
            }
        }

        if (!emptyCells.isEmpty()) {
            int[] move = emptyCells.get(new Random().nextInt(emptyCells.size()));
            board.get(move[0]).set(move[1], currentPlayer);

            if (win(move[0], move[1])) {
                winner = "白棋胜利!";
            } else {
                currentPlayer = 1;
            }
        }
    }

    private boolean isValidMove(int row, int col) {
        return row >= 0 && col >= 0 && row < 15 && col < 15 && board.get(row).get(col) == 0;
    }

    private Map<String, Object> buildGameState(String message) {
        Map<String, Object> result = new HashMap<>();
        result.put("board", board);
        result.put("currentPlayer", currentPlayer == 1 ? "黑棋" : "白棋");
        result.put("message", message);
        result.put("winner", winner);
        result.put("gameMode", gameMode);
        return result;
    }

    private void initBoard() {
        if (board.isEmpty()) {
            for (int i = 0; i < 15; i++) {
                List<Integer> row = new ArrayList<>();
                for (int j = 0; j < 15; j++) {
                    row.add(0);
                }
                board.add(row);
            }
        }
    }

    private boolean win(int x, int y) {
        int p = board.get(x).get(y);
        int[][] d = {{1, 0}, {0, 1}, {1, 1}, {1, -1}};

        for (int[] dir : d) {
            int count = 1;

            for (int k = 1; k < 5; k++) {
                int nx = x + dir[0] * k;
                int ny = y + dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            for (int k = 1; k < 5; k++) {
                int nx = x - dir[0] * k;
                int ny = y - dir[1] * k;
                if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15 || board.get(nx).get(ny) != p) {
                    break;
                }
                count++;
            }

            if (count >= 5) {
                return true;
            }
        }
        return false;
    }
}

Game.java

package com.wuziqi.java.shiyan1.entities;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("games")
public class Game {

    @TableId(type = IdType.AUTO)
    private Long id;

    private String board;

    private String currentPlayer;

    public Game() {
    }

    public Game(String board, String currentPlayer) {
        this.board = board;
        this.currentPlayer = currentPlayer;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getBoard() {
        return board;
    }

    public void setBoard(String board) {
        this.board = board;
    }

    public String getCurrentPlayer() {
        return currentPlayer;
    }

    public void setCurrentPlayer(String currentPlayer) {
        this.currentPlayer = currentPlayer;
    }
}

GameMapper.java

package com.wuziqi.java.shiyan1.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wuziqi.java.shiyan1.entities.Game;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface GameMapper extends BaseMapper<Game> {

}

GameArchiveService.java

package com.wuziqi.java.shiyan1.service;

import com.wuziqi.java.shiyan1.entities.Game;
import com.wuziqi.java.shiyan1.mapper.GameMapper;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;

@Service
public class GameArchiveService {

    private final GameMapper gameMapper;

    public GameArchiveService(GameMapper gameMapper) {
        this.gameMapper = gameMapper;
    }

    @Caching(
            // After DB insert, write this archive into cache by archive id.
            put = @CachePut(cacheNames = "gameArchive", key = "#result.id", unless = "#result == null || #result.id == null"),

            // Archive count changes after save, so evict total-count cache.
            evict = @CacheEvict(cacheNames = "gameArchiveCount", key = "'total'")
    )
    public Game save(Game game) {
        gameMapper.insert(game);
        return game;
    }

    @Cacheable(cacheNames = "gameArchive", key = "#id", unless = "#result == null")
    public Game findById(Long id) {
        // Read from cache first; if miss, query database.
        return gameMapper.selectById(id);
    }

    @Cacheable(cacheNames = "gameArchiveCount", key = "'total'")
    public long countAll() {
        // Cache total archive count to reduce repeated count SQL.
        Long count = gameMapper.selectCount(null);
        return count == null ? 0L : count;
    }
}

application.properties

# MySQL datasource
spring.datasource.url=jdbc:mysql://localhost:3306/wuziqi?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# MyBatis-Plus config
mybatis-plus.configuration.map-underscore-to-camel-case=true

# Web config
spring.thymeleaf.cache=false
server.port=8080

# Redis cache config
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.cache-null-values=false
spring.data.redis.host=localhost
spring.data.redis.port=6379

# Spring Security demo user
spring.security.user.name=student
spring.security.user.password=123456

2. 本次新增能力总览

2.1 安全管理(Spring Security)

新增规则:

  1. 所有人都可以访问和游玩:
    • GET /game
    • POST /game/move
    • POST /game/reset
    • POST /game/mode
    • GET /game/archive/count
  2. 必须登录才可以访问:
    • POST /game/save
    • GET /game/load/{id}

系统表现:

  1. 未登录点击“保存游戏/加载游戏”时,不会直接成功。
  2. 会进入登录流程(登录账号密码见下文配置)。
  3. 登录后可正常保存/加载。

2.2 缓存管理(Redis Cache)

新增缓存点:

  1. 存档详情缓存(按存档 id):
    • 缓存名:gameArchive
  2. 总存档数缓存:
    • 缓存名:gameArchiveCount
    • key:total

缓存策略:

  1. 默认 TTL:10 分钟
  2. 不缓存 null
  3. Redis 序列化器:GenericJackson2JsonRedisSerializer
  4. 每次成功保存新存档时,自动清掉“总存档数缓存”,让页面下次拿到最新数量

系统表现:

  1. 同一个存档 ID 多次加载时,响应更快(优先从 Redis 取)。
  2. “总存档数”展示会随着新存档保存而更新。

3. 深入说明:安全管理(Spring Security)

3.1 为什么要加安全管理

当前项目是网页游戏,原始版本里保存/加载接口是开放的,任何人都能调用。实际场景中,存档属于“用户数据”,至少要做到“先登录再操作”。

这次采用了“最小改造”方案:

  1. 保留你原来的五子棋玩法流程不变。
  2. 只对“保存/加载”加登录门槛。
  3. 游玩接口继续保持匿名可用,演示和体验不受影响。

3.2 代码里怎么实现

实现文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\config\SecurityConfig.java
  2. E:\J2EE\实验七\src\main\resources\application.properties
  3. E:\J2EE\实验七\src\main\resources\templates\wuziqi.html

核心机制:

  1. 通过 SecurityFilterChain 配置请求授权规则。
  2. 使用 .requestMatchers(...).permitAll() 定义匿名可访问接口。
  3. 使用 .requestMatchers(...).authenticated() 定义必须登录接口。
  4. 使用 .formLogin(...) 启用默认登录页。
  5. 在前端统一处理“被重定向到登录页”的情况,给用户明确提示。

3.3 请求流程(未登录保存存档)

  1. 用户在页面点击“保存游戏”。
  2. 前端发起 POST /game/save
  3. Spring Security 判断该接口需要登录。
  4. 若未登录,返回登录页重定向。
  5. 前端 fetchJsonOrGoLogin() 检测到重定向后提示用户,并跳转 /login
  6. 用户登录成功后回到 /game,再次保存即可成功。

3.4 请求流程(已登录保存存档)

  1. 用户已登录,点击“保存游戏”。
  2. 请求通过安全过滤器。
  3. 控制器执行保存逻辑。
  4. 返回 JSON:success/id/message
  5. 页面弹出提示并刷新总存档数。

3.5 配置项解释

application.properties 中:

  1. spring.security.user.name=student
  2. spring.security.user.password=123456

说明:

  1. 这是 Spring Security 的演示账号(内存用户)。
  2. 优点:配置简单、快速可跑。
  3. 后续若要上线,建议改成数据库用户体系。

3.6 为什么关闭 CSRF

当前前端是用 fetch 直接发 POST,未携带 CSRF Token。若立刻开启 CSRF,/game/save 等 POST 很可能返回 403。

因此本次先关闭 CSRF,目标是:

  1. 先把“权限控制 + 业务可用”稳定跑通。
  2. 下一步再做“开启 CSRF + 前端携带 Token”的增强。

3.7 风险与边界

  1. 现在是“单账号演示登录”,不是完整多用户系统。
  2. 关闭 CSRF 适合学习与内网演示,不适合直接生产。
  3. 页面是服务端模板 + fetch,登录态依赖 session/cookie。

4. 深入说明:缓存管理(Redis Cache)

4.1 为什么要加缓存

项目里有两类“重复读取”非常适合缓存:

  1. 按存档 ID 的加载(load/{id}
  2. 总存档数查询(页面加载和保存后都会查)

没有缓存时:

  1. 每次都打数据库。
  2. 在频繁操作下,数据库压力会持续累积。

加缓存后:

  1. 热点数据直接走 Redis。
  2. 数据库只在缓存未命中时查询。

4.2 代码里怎么实现

实现文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\config\CacheConfig.java
  2. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\service\GameArchiveService.java
  3. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\Shiyan1Application.java
  4. E:\J2EE\实验七\src\main\resources\application.properties

核心机制:

  1. 启动类加 @EnableCaching,开启注解缓存能力。
  2. @Cacheable:查缓存,未命中再查数据库。
  3. @CachePut:数据库写入成功后,主动更新缓存。
  4. @CacheEvict:数据库变更后,删除相关缓存,避免脏数据。

4.3 缓存键设计

  1. 存档详情缓存:
    • 缓存名 gameArchive
    • key = 存档 id
  2. 总存档数缓存:
    • 缓存名 gameArchiveCount
    • key = total

这样设计的原因:

  1. 简单直观,便于排查。
  2. 和接口语义强对应,维护成本低。

4.4 失效策略

  1. TTL:统一 10 分钟,防止缓存长期过期不更新。
  2. 保存新存档后:
    • 总数必然变化,因此立即删除 gameArchiveCount::total
  3. 下次查询总数时会自动回源数据库并重建缓存。

4.5 数据序列化策略

采用 GenericJackson2JsonRedisSerializer

  1. 缓存值以 JSON 方式序列化。
  2. 相比 JDK 序列化更通用。
  3. 对后续跨语言或调试查看更友好。

4.6 读写流程示例

示例 A:加载存档
  1. 前端请求 GET /game/load/12
  2. GameArchiveService.findById(12) 先查 gameArchive::12
  3. 命中则直接返回
  4. 未命中则查 MySQL,再写回缓存
示例 B:保存存档
  1. 前端请求 POST /game/save
  2. GameArchiveService.save(game) 写数据库
  3. 同时 @CachePut 把新存档写入 gameArchive::<id>
  4. 同时 @CacheEvict 清理 gameArchiveCount::total

4.7 风险与边界

  1. Redis 未启动时,项目可能在缓存初始化阶段报错或退化(视环境而定)。
  2. 当前是单节点 Redis,无高可用配置。
  3. TTL 10 分钟是演示值,可按业务压测结果调整。

5. 代码层实现说明(按文件)

5.1 依赖层(Maven)

文件:

  1. E:\J2EE\实验七\pom.xml

关键改动:

  1. 引入安全依赖:
    • spring-boot-starter-security
    • spring-security-core
  2. 引入 Redis 依赖:
    • spring-boot-starter-data-redis

5.2 基础配置层

5.2.1 启用缓存注解

文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\Shiyan1Application.java

实现:

  1. 在启动类增加 @EnableCaching
5.2.2 Redis 与安全账号配置

文件:

  1. E:\J2EE\实验七\src\main\resources\application.properties

实现:

  1. Redis 配置:host、port、TTL、null 缓存策略
  2. 安全配置:默认演示账号密码

5.3 安全控制层

文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\config\SecurityConfig.java

说明:

  1. 控制“放行接口”和“必须登录接口”
  2. 登录成功回跳游戏页
  3. 配合前端完成未登录提示与跳转

5.4 缓存业务层

文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\service\GameArchiveService.java

说明:

  1. save:写库 + 回填单条缓存 + 清理总数缓存
  2. findById:优先查缓存
  3. countAll:总数缓存

5.5 控制器层

文件:

  1. E:\J2EE\实验七\src\main\java\com\wuziqi\java\shiyan1\controller\PageController.java

说明:

  1. 保存/加载改为走 GameArchiveService
  2. 首页模型新增 archiveCount
  3. 新增接口 GET /game/archive/count

5.6 前端页面层

文件:

  1. E:\J2EE\实验七\src\main\resources\templates\wuziqi.html

说明:

  1. 增加总数展示区域
  2. 增加 refreshArchiveCount()
  3. 增加 fetchJsonOrGoLogin(),处理未登录重定向

6. 启动与验证步骤

  1. 启动 MySQL,并确认 wuziqi 库和 games 表存在。
  2. 启动 Redis(默认 localhost:6379)。
  3. 在项目根目录运行:mvn spring-boot:run
  4. 打开:http://localhost:8080/game
  5. 验证:
    • 页面显示总存档数
    • 未登录点击保存/加载会提示并跳登录
    • 登录 student/123456 后可保存/加载
    • 保存后总存档数自动刷新
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐