综合练习:待办清单应用
这是什么:待办清单应用 = 一个能添加任务、能标记完成的 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#,看看微软的全能选手