🐸 青蛙编程网
首页 / Java / Spring Boot / 6. 综合练习

综合练习:待办清单应用

这是什么:待办清单应用 = 一个能添加任务、能标记完成的 Web 小程序,Web 版的「图书馆管理」。

为什么用:五章学完:生成项目、写控制器、渲染模板、接收表单。这一章不学新知识,把它们缝成一个完整的待办清单应用。强烈建议亲手敲而不是复制粘贴——价值不在「看懂」,在「敲错、排查、跑通」的完整循环里。

如何用:本章从建 Todo 条目类、写三个路由的控制器,到写出列表页模板,一步步把应用拼起来。

功能与目录

做出来是这样的:

  • 访问 /todos:看到任务列表 + 添加表单
  • 填写任务点【添加】:新任务进列表(POST + @RequestParam)
  • 点任务旁的【切换】按钮:完成 ↔ 未完成来回切(POST + @PathVariable)

功能看着不少,拆开全是老朋友:列表页是模板渲染,添加和切换是表单与用户输入的 POST + 重定向,条目类是面向对象的封装。综合练习的"综合",就是把每一章的"新知识"拼回一张完整的图纸。

在 start.spring.io 生成项目(Artifact 填 todo,勾 Spring Web 和 Thymeleaf),解压后往骨架里填三个类 + 一个模板,完整目录结构:

todo/
├─ mvnw · mvnw.cmd               ← 自带 Maven wrapper
├─ pom.xml                       ← 依赖清单(Web + Thymeleaf)
└─ src/main/
   ├─ java/com/example/todo/
   │  ├─ TodoApplication.java    ← 启动类(生成器已写好)
   │  ├─ Todo.java               ← 待办条目:面向对象上场
   │  └─ TodoController.java     ← 三个路由
   └─ resources/
      └─ templates/todos.html    ← 列表页模板

注意包名变成了 com.example.todo——Artifact 填 todo 的直接后果:启动类、扫描范围全跟着变。你写的三个文件都要放进这个包(或子包),否则框架看不见。

第一步:Todo 条目类

上一板块的封装派上用场——id 和 title 只读,done 只能通过方法改:

注意 id 和 title 都加了 final:任务一创建,编号和标题就不许再动(改标题的需求留给后面的挑战练习)。done 不加 final,因为它是要反复翻转的状态。用 final 把"天生的不变"钉死,把"会变的"留出口子——这是封装设计的第一思考。

package com.example.todo;

public class Todo {
    private final int id;
    private final String title;
    private boolean done;

    public Todo(int id, String title) {
        this.id = id;
        this.title = title;
        this.done = false;
    }

    public int getId() { return id; }
    public String getTitle() { return title; }
    public boolean isDone() { return done; }
    public void setDone(boolean done) { this.done = done; }
}

第二步:控制器(三个路由)

三个路由正好对应"看、加、切"三个动作,注意每个 POST 路由的收尾都是 redirect:/todos——上一章"POST 完了必重定向"的规矩,一条不落:

toggle 方法里的查找循环也眼熟吧?和图书管理的 borrow 一个骨架:找到目标就动手、然后 break 退场。套路学透了,换哪个项目都一样。

package com.example.todo;

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.RequestParam;

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

@Controller
public class TodoController {

    private final List<Todo> todos = new ArrayList<>(); // 内存存储
    private int nextId = 1;

    @GetMapping("/todos")
    public String list(Model model) {
        model.addAttribute("todos", todos);
        return "todos";
    }

    @PostMapping("/todos/add")
    public String add(@RequestParam String title) {
        if (!title.isBlank()) {
            todos.add(new Todo(nextId++, title.trim()));
        }
        return "redirect:/todos";
    }

    @PostMapping("/todos/{id}/toggle")
    public String toggle(@PathVariable int id) {
        for (Todo todo : todos) {
            if (todo.getId() == id) {
                todo.setDone(!todo.isDone()); // 完成 ↔ 未完成
                break;
            }
        }
        return "redirect:/todos";
    }
}

第三步:列表页模板

src/main/resources/templates/todos.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>待办清单</title>
    <style>
        .done { text-decoration: line-through; color: gray; }
        li { margin: 6px 0; }
    </style>
</head>
<body>
    <h1>🐸 我的待办清单</h1>

    <form action="/todos/add" method="post">
        <input type="text" name="title" placeholder="要做什么?" required>
        <button type="submit">添加</button>
    </form>

    <ul>
        <li th:each="todo : ${todos}">
            <span th:text="${todo.done} ? '✅' : '⬜'">⬜</span>
            <span th:text="${todo.title}" th:classappend="${todo.done} ? 'done'">示例任务</span>
            <form th:action="'/todos/' + ${todo.id} + '/toggle'" method="post" style="display:inline">
                <button type="submit">切换</button>
            </form>
        </li>
    </ul>
</body>
</html>

两个新面孔(都是 Thymeleaf 小技巧):

  • th:text="${todo.done} ? '✅' : '⬜'":模板里也能写三元表达式
  • th:classappend="...":往标签上追加 class——任务完成后自动加上灰色删除线样式

模板里访问 ${todo.done}、${todo.title},走的是 Todo 的 getter(isDone / getTitle)——Jackson 转 JSON 靠 getter,Thymeleaf 渲染也靠 getter。面向对象章节那句"布尔 getter 用 is 开头",在这里又一次兑现。

还有个小机关值得看懂:切换按钮的表单用了 th:action="'/todos/' + ${todo.id} + '/toggle'"——每行任务的按钮指向自己的 id。静态的模板 + 动态拼出来的地址,就能让一行模板渲染出 N 个"各自知道自己管谁"的按钮。

运行效果

mvnw spring-boot:run 启动,浏览器访问 http://localhost:8080/todos,添加两个任务、完成第一个(示意):

🐸 我的待办清单
[ 要做什么?        ] [添加]
✅ 写完待办清单应用    [切换]
⬜ 给博客加个新板块    [切换]

逐条对着截图(示意)自检:完成的任务有灰色删除线、未完成的是白框;每行按钮只切换自己那行;刷新页面状态不丢、不重复提交。三条全对上,应用就"毕业"了。

试着刷新页面、重启应用再回来——亲眼验证内存存储"重启清空"的特性。

❓ 常见问题

Q1:点【切换】没反应,任务状态不变?

按链路排查:按钮所在表单的 th:action 拼出来是什么(F12 看渲染后的 HTML)?控制器重启了吗?@PathVariable int id 接到的和页面上的是同一个数吗?九成问题出在 th:action 的字符串拼接——引号、加号、斜杠,一个都不能错。

Q2:添加任务后列表没变化?

先确认提交真的成功了(F12 Network 里看那条 POST 的状态码):404 是路由没对上,400 是 title 参数没接到(模板里 name="title" 写对了吗);200 但列表没变,多半是 title.isBlank() 把空格输入拦下了——那是正常的。

Q3:重启后任务全没了,这个应用是不是白做了?

没白做。应用骨架、路由、渲染、封装一条都不少,缺的只是"数据落到磁盘"。把 List<Todo> 换成数据库,其余代码几乎不动——这正是分层设计的好处:存储方式变了,业务和页面不用跟着变。数据库是本系列之后的进阶话题。

Q4:两个浏览器标签页同时开 /todos,一边添加另一边看得到吗?

看得到。任务存在服务器端的同一个 List 里,两个标签页只是两个"窗口",刷新就能拿到最新列表。理解了这一点,你就明白"数据在服务器、页面只是快照"——这是 Web 的核心心智模型。

动手改:分层练习

下面六题由易到难,全部在 todo 项目上迭代。规则和老规矩一样:先自己写,卡住看提示,最后对参考答案。每做完一题重启验证,别攒到最后一起测。

练习 1 ⭐:删除任务

加一条路由 POST /todos/{id}/delete,页面上每个任务多一个【删除】按钮。

提示:整个结构和 toggle 一模一样——照抄查找循环,把 setDone 换成 todos.remove(todo);模板里复制一个内嵌小表单,action 换成 delete。

参考答案:

@PostMapping("/todos/{id}/delete")
public String delete(@PathVariable int id) {
    for (Todo todo : todos) {
        if (todo.getId() == id) {
            todos.remove(todo);
            break;
        }
    }
    return "redirect:/todos";
}
<form th:action="'/todos/' + ${todo.id} + '/delete'" method="post" style="display:inline">
    <button type="submit">删除</button>
</form>

练习 2 ⭐:顶部统计条

页面标题下加一行:"共 N 条,已完成 M 条"。

提示:统计逻辑放控制器(Java 负责"算"),算好塞进 Model;模板只管显示。

参考答案:

@GetMapping("/todos")
public String list(Model model) {
    int doneCount = 0;
    for (Todo todo : todos) {
        if (todo.isDone()) {
            doneCount++;
        }
    }
    model.addAttribute("total", todos.size());
    model.addAttribute("doneCount", doneCount);
    model.addAttribute("todos", todos);
    return "todos";
}
<p th:text="'共 ' + ${total} + ' 条,已完成 ' + ${doneCount} + ' 条'">统计</p>

多想一步:为什么不在模板里写 ${#lists.size(todos)} 之类的表达式硬算?能算,但逻辑散进模板就难维护了——算术归 Java,显示归模板,这条分界线守得越牢,项目越好改。

练习 3 ⭐⭐:给任务加序号

每条任务前显示"1. 2. 3."编号。

提示:th:each 的第二个变量 stat,上一章讲过 stat.count。

参考答案:

<li th:each="todo, stat : ${todos}">
    <span th:text="${stat.count} + '. '">1. </span>
    <!-- 原有的 ✅/⬜、标题、按钮照旧 -->
</li>

练习 4 ⭐⭐:空标题友好提示

现在提交空标题被 isBlank() 静默拦下,用户一脸懵。改成:拦截时页面上出现一行红字"任务内容不能为空"。

提示:控制器把错误消息放 Model(比如 error 键),模板用 th:if 决定显不显示;注意重定向后 Model 数据会丢——学习阶段最简单的做法是把"错误提示"存成控制器的字段,或者本次直接返回页面名不重定向(想想各有什么代价)。

参考答案(用字段暂存提示的简化版):

private String error = "";

@PostMapping("/todos/add")
public String add(@RequestParam String title) {
    if (title.isBlank()) {
        error = "任务内容不能为空";
        return "redirect:/todos";
    }
    todos.add(new Todo(nextId++, title.trim()));
    error = "";
    return "redirect:/todos";
}

@GetMapping("/todos")
public String list(Model model) {
    model.addAttribute("error", error);
    model.addAttribute("todos", todos);
    return "todos";
}
<p th:if="${error} != ''" th:text="${error}" style="color: red">错误提示</p>

多想一步:这版有个小瑕疵——提示不会自动消失,下次刷新还在。正经解法叫"闪现消息(flash attribute)",是框架提供的正规军,学到再说;能发现瑕疵本身就值一题。

练习 5 ⭐⭐⭐:一键全完成

加一个【全部完成】按钮,点一下所有任务变 ✅。

提示:新路由 + 遍历 setDone(true) + 模板加个独立小表单(放在任务列表外面,不用拼 id)。

参考答案:

@PostMapping("/todos/complete-all")
public String completeAll() {
    for (Todo todo : todos) {
        todo.setDone(true);
    }
    return "redirect:/todos";
}
<form action="/todos/complete-all" method="post" style="display:inline">
    <button type="submit">全部完成</button>
</form>

练习 6 ⭐⭐⭐:修改任务标题

这是最烧脑的一题:加"改名"功能——点【改名】后能提交新标题。注意 Todo 的 title 是 final 的,你面前有两条路:A. 去掉 final,给 Todo 加 setTitle;B. 保持不可变,新建一个同 id 新标题的 Todo 替换旧对象。动手前先想想两条路的代价。

提示:新路由接收两个参数:@PathVariable int id + @RequestParam String newTitle。选 B 的话,遍历时记下下标,用 todos.set(i, newTodo) 原位替换。

参考答案(选 B,保持不可变):

// Todo 需要一个带 done 的新构造方法:
public Todo(int id, String title, boolean done) {
    this.id = id;
    this.title = title;
    this.done = done;
}

// 控制器新路由:
@PostMapping("/todos/{id}/rename")
public String rename(@PathVariable int id, @RequestParam String newTitle) {
    if (newTitle.isBlank()) {
        return "redirect:/todos";
    }
    for (int i = 0; i < todos.size(); i++) {
        Todo t = todos.get(i);
        if (t.getId() == id) {
            todos.set(i, new Todo(t.getId(), newTitle.trim(), t.isDone()));
            break;
        }
    }
    return "redirect:/todos";
}
<form th:action="'/todos/' + ${todo.id} + '/rename'" method="post" style="display:inline">
    <input type="text" name="newTitle" placeholder="新标题" required>
    <button type="submit">改名</button>
</form>

多想一步:方案 B 不用改 Todo 的任何现有代码,title 的 final 承诺依然有效;代价是替换对象那行略绕。两种都合法,但"不可变优先"是更受团队欢迎的口味——理由等写出并发 bug 的那天你就懂了。

六题做完,这个三十来行控制器的小应用,就有了增、删、改、查、统计的完整能力——麻雀虽小,五脏俱全,你已经具备独立做出一个"能用的小网站"的全部基础技能。

六章复盘

整个练习没有一个"全新"知识点,但拼起来的能力是全新的。对照下表,每章的知识都能在这几十行代码里找到自己的位置:

章节 在本练习里的位置
框架是什么 为什么杂活这么少——框架全包了
第一个应用 项目骨架与启动
控制器与路由 三个路由、@PathVariable、@RequestParam
模板渲染 th:each 循环、th:text 填空
表单与输入 两个 POST 表单 + 重定向
面向对象 Todo 类的封装:private + getter/setter

记忆口诀

生成骨架跑起来,控制器里挂路由;模板填空列表出,表单 POST 重定向,封装条目状态稳。

做完整个应用,回头看看这五六章的轨迹:第一章还在聊"框架是什么",现在你已经能从零拼出一个可交互的小网站。概念 → 跑通 → 加功能 → 综合缝纫,这条学习路径比任何"速成宝典"都结实——下一步无论选数据库、前端框架还是别的语言,路数都是这一套。

Spring Boot 毕业 🎉 Java 板块完结!下一站 C#,看看微软的全能选手

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。