1. 项目概述与核心价值拆解1.1 为什么留言板是SpringBoot入门的最佳练手项目带过不少新人也看很多人自学SpringBoot第一个完整项目基本都绕不开留言板。这东西看起来太不起眼了不就是几个增删改查的接口吗但恰恰是这份不起眼让它在教学练手这个场景下有着极高的性价比。先说留言板覆盖了哪些东西一个Web应用最核心的链路从数据库表设计、持久层访问、业务层校验、控制层参数接收再到前端页面渲染和用户交互全都有。你写一个用户管理系统可能还要考虑权限、角色这些额外复杂度你写一个电商项目光商品、订单、库存的关系就够绕一阵子了。写留言板业务规则简单到一句话能说清——用户提交一条消息系统保存并展示出来。但真正开始做的时候你会发现越是这种看似简单的需求越能逼你把基础功打扎实。数据库连接怎么配时区问题为什么会导致时间差8小时表单提交的编码为什么乱码校验失败之后怎么把错误信息回显到页面上刷新页面为什么会出现重复提交这些全是实际生产环境里会遇到的问题在一个留言板项目里全都能遇到一遍。我常说留言板做完你SpringBoot的入门水平基本就到位了。从求职面试角度看留言板也是很好的面试谈资。它简单所以你完全有时间把每个细节讲透它功能边界清晰面试官问起来你能从表结构聊到缓存优化从参数校验聊到XSS防护这些都是加分项。很多人觉得项目要够大才够唬人其实面试官更在意你有没有真正理解自己写的东西。1.2 需求清单与功能边界动手写代码之前先把需求理清楚。留言板的核心功能就三个用户查看已有留言列表按时间倒序排列最新的在最上面用户填写昵称、邮箱、留言内容并提交系统校验数据合法性合法则保存入库并刷新列表非法则给出明确错误提示围绕这三个核心点还可以扩展一些常见的辅助功能分页展示留言多了以后一次全查出来会越来越慢、后台删除权限管理不合规内容、留言置顶让特定内容排最前、回复功能等等。我建议第一版把分页和删除做上这两块对理解Spring Data JPA的排序和分页机制帮助很大。需要说明的是第一版不要贪多。我见过有人一上来就想做用户注册登录、留言审核、消息通知一堆功能结果写了两个星期连基础CRUD都还没跑通。先把主流程做顺再逐步加功能这才是正确的节奏。我习惯把需求分成必须做和可选做两档。必须做的包括留言列表展示、留言提交、参数校验、数据入库、按时间排序。可选做的包括分页、删除、编辑、置顶、邮箱格式校验、敏感词过滤。第一版完成必做项然后根据自己进度挑可选做项往里加每个功能都是一个独立的知识点。2. 技术选型与整体设计思路2.1 版本选型和依赖清单技术选型上我默认选择SpringBoot 2.x的稳定版本。不建议盲目追新用3.x因为3.x基于Jakarta EE规范改造一些老教程和第三方依赖的兼容性会有差异初学者踩到坑会非常打击信心。2.7.x是2.x分支里很成熟的一个版本网上资源丰富遇到问题容易搜到答案。依赖方面核心需要三个spring-boot-starter-web提供嵌入式Web服务器和Spring MVC这是必选项spring-boot-starter-data-jpa提供数据持久化能力用JPA操作数据库spring-boot-starter-thymeleaf服务端模板引擎用来渲染页面mysql-connector-jMySQL驱动没有它应用就不知道该怎么连数据库spring-boot-starter-validation参数校验框架后面做表单校验要用如果你是新手第一次看到这么多starter可能会懵。我换个说法解释starter就是SpringBoot预先打包好的一组依赖你声明一个starter相当于一次性告诉Maven我要用Web功能它就把一整套相关的jar包都拉下来。比如spring-boot-starter-web里面已经包含了Spring MVC、内嵌Tomcat等不需要你一个个手动引入。提示版本选择上以稳定为主。除非你能明确说出新版本带来的收益否则不要在生产项目里用RC版或者刚发布的大版本。2.2 数据库选型与表结构设计数据库我用MySQL这也是国内绝大多数公司的标配。开发阶段你可以先用H2这种嵌入式数据库内存里跑省去安装配置的麻烦但最终建议切回MySQL因为生产环境不会用H2。留言板的表结构非常简单我直接给出实际在建表过程中反复调整后的最终版本CREATE TABLE message ( id bigint NOT NULL AUTO_INCREMENT, nickname varchar(50) NOT NULL COMMENT 留言昵称, email varchar(100) DEFAULT NULL COMMENT 邮箱选填, content varchar(1000) NOT NULL COMMENT 留言内容, created_at datetime NOT NULL COMMENT 创建时间, updated_at datetime NOT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT留言表;有几个设计点值得展开说。第一表名用message而不是comment因为comment在某些数据库方言里是保留字容易出幺蛾子。user更是重灾区新手用user做表名在MySQL里没报错换个环境可能就炸了所以尽量避开这些词。第二字符集必须用utf8mb4不是utf8。MySQL的utf8实际上只支持最多3字节的字符存不了emoji而现在的用户输入里emoji简直不要太常见。用utf8mb4才能完整支持4字节字符。第三created_at和updated_at两个时间字段都保留。有的人只留一个创建时间等后面做最后编辑时间功能的时候还得改表麻烦。我习惯一开始就把这两个字段都加上成本极低收益长远。第四email字段允许为空。很多留言板并不强制填邮箱但用户如果填了后续做回复通知功能时就用得上。允许为空设计上更灵活。2.3 前端渲染方案的选择留言板的前端有两条路可以走一是用Thymeleaf做服务端渲染二是用Vue/React做前后端分离。我强烈建议入门阶段选择Thymeleaf。原因是SpringBoot Thymeleaf的学习曲线非常平缓。你写完Controller方法返回一个视图名Thymeleaf就去templates目录下找对应的HTML模板把数据塞进去渲染成完整页面返回给浏览器。整个流程没有跨域问题、没有前后端联调成本、没有API文档维护一个人全搞定。前后端分离的架构更适合团队开发或者需要高交互复杂度的项目。留给留言板这种项目属于杀鸡用牛刀你还要额外搭一套前端工程、处理CORS、自己设计JSON接口规范对初学者来说是负担而不是锻炼。我用一张表把两者的适用场景梳理一下维度Thymeleaf服务端渲染前后端分离学习成本低掌握th:each等几个标签即可高需要额外学习前端构建工具链开发效率高一人搞定前后端中需要维护两套代码适合场景内容展示型、后台管理型高交互SPA、移动端接口复用对后端能力锻炼全覆盖偏向纯接口开发当初我第一次带A同学做这个项目时他坚持用Vue写前端结果三天时间耗在webpack配置上页面接口还没联调。后来换成Thymeleaf一天就把页面跑通了。不是说前后端分离不好而是要把精力花在刀刃上——留言板的核心价值在后端逻辑前端越简单越好。3. 分层实现与核心代码解析3.1 项目包结构与实体层项目代码结构遵循标准的MVC分层这也是Java后端最主流的组织方式com.example.messageboard ├── MessageboardApplication.java // 启动类 ├── controller │ └── MessageController.java ├── service │ └── MessageService.java ├── repository │ └── MessageRepository.java └── entity └── Message.java实体类对应数据库表结构字段一一映射Entity Table(name message) public class Message { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 50) private String nickname; Column(length 100) private String email; Column(nullable false, length 1000) private String content; Column(nullable false, updatable false) private LocalDateTime createdAt; Column(nullable false) private LocalDateTime updatedAt; PrePersist PreUpdate public void prePersist() { if (createdAt null) { createdAt LocalDateTime.now(); } updatedAt LocalDateTime.now(); } }这里有几个点值得强调。时间字段统一使用LocalDateTime不要用java.util.Date。Date类本身设计有缺陷可变、非线程安全、时间语义混乱而且配合SpringBoot的属性绑定很容易出时区问题。LocalDateTime是Java 8引入的新时间API简洁且不可变是现代Java开发的标准选择。自动填充时间用的是JPA的PrePersist和PreUpdate回调注解在实体保存和更新操作前自动执行不用在Service层手动set时间。很多人第一次看到这个写法会疑惑为什么不在数据库里用DEFAULT CURRENT_TIMESTAMP其实也可以但用JPA回调的方式更灵活而且测试时方便Mock。GeneratedValue(strategy GenerationType.IDENTITY)对应MySQL的自增主键。这里要注意不同数据库对主键生成策略的支持不同如果你用Oracle之类的数据库IDENTITY就不适用了。单体项目不用纠结这个MySQL用户直接用IDENTITY就行。3.2 数据访问层与业务层Repository层是Spring Data JPA的核心体现接口只需要继承JpaRepository几乎所有单表查询都能自动搞定public interface MessageRepository extends JpaRepositoryMessage, Long { // 按创建时间倒序查询 ListMessage findAllByOrderByCreatedAtDesc(); }你没有看错就这一行代码底层实现Spring Data JPA自动帮你生成了。它的方法名解析机制会读findAllByOrderByCreatedAtDesc翻译成SQL的ORDER BY created_at DESC这是Spring Data系列最酷的地方之一——通过方法签名就能声明查询逻辑。分页也很简单在Service层调用PageMessage page messageRepository.findAll( PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, createdAt)) );PageRequest.of接收页码、每页条数和排序规则返回Page对象里面既包含数据列表也包含总条数、总页数这些分页元数据。Service层负责业务规则这一段经常被初学者忽略觉得Controller里直接调Repository就完事了。我在实际项目中见过太多这种写法Controller越来越臃肿业务规则和HTTP细节层层纠缠最后没法维护。正确的做法是Controller只做参数接收和响应封装具体业务逻辑全部下沉到Service。Service public class MessageService { private final MessageRepository messageRepository; public MessageService(MessageRepository messageRepository) { this.messageRepository messageRepository; } Transactional public Message createMessage(Message message) { // 业务规则留言内容不能为空白 if (message.getContent() null || message.getContent().trim().isEmpty()) { throw new IllegalArgumentException(留言内容不能为空); } // 业务规则昵称长度限制 if (message.getNickname().length() 50) { throw new IllegalArgumentException(昵称长度不能超过50个字符); } return messageRepository.save(message); } public ListMessage listMessages() { return messageRepository.findAllByOrderByCreatedAtDesc(); } public PageMessage listMessagesByPage(int page, int size) { return messageRepository.findAll( PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createdAt)) ); } public void deleteMessage(Long id) { messageRepository.deleteById(id); } }注意构造器注入的写法Spring官方推荐用构造器注入而不是字段Autowired。原因有两个层面一是显式的构造器参数让依赖关系一目了然测试的时候可以直接new MessageService(repo)而不用依赖Spring容器二是避免字段注入导致循环依赖的问题在启动时才暴露。Transactional注解标记在写入操作上。很多人只知道加了这个注解会有事务但不知道为什么写操作必须要加。MySQL默认每条SQL是自动提交的如果你的业务是先扣库存再生成订单中间第二条SQL执行失败第一条SQL依然已经提交了数据就处于不一致状态。加上Transactional之后整个方法内的所有SQL会在同一个事务里执行任何一个环节失败都会整体回滚保证原子性。留言板只有一个save操作加不加事务效果看不出来但这是好习惯。3.3 控制器与页面交互Controller层是HTTP请求的入口也是初学者最容易写乱的地方。我给出一个比较规范的示例先是列表和新增Controller public class MessageController { private final MessageService messageService; public MessageController(MessageService messageService) { this.messageService messageService; } GetMapping(/) public String list(Model model, RequestParam(defaultValue 1) int page) { PageMessage messagePage messageService.listMessagesByPage(page - 1, 10); model.addAttribute(messages, messagePage.getContent()); model.addAttribute(totalPages, messagePage.getTotalPages()); model.addAttribute(currentPage, page); return index; } PostMapping(/message) public String create(Valid ModelAttribute MessageForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { redirectAttributes.addFlashAttribute(errors, bindingResult.getAllErrors()); redirectAttributes.addFlashAttribute(form, form); return redirect:/; } Message message new Message(); message.setNickname(form.getNickname()); message.setEmail(form.getEmail()); message.setContent(form.getContent()); messageService.createMessage(message); redirectAttributes.addFlashAttribute(success, 留言成功); return redirect:/; } }这里用到了Valid和BindingResult的组合。Valid触发JSR-303参数校验校验失败的信息会写入紧随其后的BindingResult对象里。这里有个新手必踩的坑BindingResult必须紧跟在Valid参数后面中间不能插任何参数否则Spring不会把校验结果绑定进来而是直接抛异常。RedirectAttributes.addFlashAttribute是Post-Redirect-Get模式的标配。用户提交表单成功后服务器返回一个302重定向到列表页用户刷新页面时浏览器请求的是GET地址不会重复提交表单。如果不做这层重定向直接返回视图用户按F5刷新时浏览器会重新提交POST请求产生重复数据。这个问题几乎所有新手都会遇到一次但很少有人理解背后的HTTP机制。删除接口返回JSON而不是页面跳转因为删除操作更适合用AJAX异步调用PostMapping(/message/{id}/delete) ResponseBody public ResponseEntityVoid delete(PathVariable Long id) { try { messageService.deleteMessage(id); return ResponseEntity.ok().build(); } catch (Exception e) { return ResponseEntity.badRequest().build(); } }控制器返回ResponseEntity可以让HTTP状态码更精确。成功返回200失败返回400前端拿到状态码后做对应提示。还有一类问题值得单独说就是表单回显。当用户提交的校验失败时我们通过addFlashAttribute(form, form)把用户填过的昵称和内容传回去在页面上重新填充。如果不做这一步用户校验失败后只能看到一个昵称不能为空的提示刚写的长篇大论全没了体验极差。3.4 页面模板与表单回显页面放在src/main/resources/templates/index.html用Thymeleaf语法渲染。前端模板的核心代码!DOCTYPE html html langzh xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title留言板/title /head body h1留言板/h1 !-- 全局错误提示 -- div th:if${errors} th:eacherror : ${errors} p classerror-msg th:text${error.defaultMessage}错误信息/p /div div th:if${success} th:text${success}提示信息/div !-- 留言列表 -- ul li th:eachmsg : ${messages} strong th:text${msg.nickname}昵称/strong span th:text${msg.createdAt}时间/span p th:text${msg.content}内容/p /li /ul !-- 分页 -- div a th:if${currentPage 1} th:href{/?page${currentPage - 1}}上一页/a span th:text${currentPage / totalPages}1 / 1/span a th:if${currentPage totalPages} th:href{/?page${currentPage 1}}下一页/a /div !-- 提交表单 -- form th:action{/message} methodpost label昵称/label input typetext namenickname th:value${form?.nickname} / label邮箱/label input typetext nameemail th:value${form?.email} / label内容/label textarea namecontent th:text${form?.content}/textarea button typesubmit提交/button /form /body /htmlth:each就是Thymeleaf的循环等价于Java的for-eachth:text是文本输出${form?.nickname}是Thymeleaf的安全导航操作符form为null时不会抛空指针而是直接返回空值这个写法在处理校验失败后回显的场景下特别舒服。有个细节值得注意th:text会自动对内容做HTML转义。如果用户留言内容是scriptalert(xss)/scriptThymeleaf渲染时会把它转义成纯文本不会执行脚本这是Thymeleaf默认的安全行为。想看的时候用th:utext但那个不做转义一旦用户输入恶意脚本就会执行新手别乱用。我见过有人为了显示带格式的内容改用th:utext没过几天就被恶意脚本刷屏了这是实打实的安全事故。分页链接用th:href{/?page${currentPage - 1}}生成。第一次写分页的人容易把页码参数拼错用Thymeleaf的URL表达式可以自动处理特殊字符的转义比字符串拼接靠谱得多。4. 实操记录、踩坑与扩展方案4.1 一步一步复现这个项目光看代码不动手等于白看。我按照实际操作的顺序给你列一个从零到一的完整清单。第一打开Spring Initializrstart.spring.io选择Maven、Java 11或8、SpringBoot 2.7.x。Group填com.exampleArtifact填messageboard。依赖勾选Web、JPA、MySQL Driver、Thymeleaf、Validation。点击生成下载得到一个zip包解压后用IDEA打开。第二编辑application.yml配置数据源和JPAspring: datasource: url: jdbc:mysql://localhost:3306/messageboard?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: trueddl-auto: update意味着Hibernate会根据实体类自动创建或更新表结构开发阶段非常方便不用手动执行建表SQL。但生产环境务必改成validate或none否则一个手滑Hibernate可能直接改掉线上表结构。serverTimezoneAsia/Shanghai这个参数必须加。如果不加MySQL驱动会用服务器的默认时区很多情况下和本地时区不一致导致时间字段差8个小时。这个坑我踩过身边人也踩过属于MySQL Java开发的高频问题。第三按代码结构创建包和类依次是实体、Repository、Service、Controller。然后启动项目打开浏览器访问http://localhost:8080你应该能看到一个空列表页面。第四提交测试数据的两种方式直接在页面上填写提交以及用Postman发POST请求到/message。如果你在页面上填写时没有正确跳转或者出现500错误多半是模板路径写错了检查return index和templates/index.html是否对应。第五测试分页。在数据库里手动插入20条以上数据访问/?page1、/?page2确认分页按钮能正常切换。这时候你会体会到PageRequest带来的便利——不用自己写LIMIT offset, size也不用自己算总页数。4.2 高频问题排查速查我把调试过程中最常遇见的几个问题整理成了速查表方便你照着排查现象可能原因解决方案启动时报Unable to create connection数据库连接配置错误或MySQL没启动检查url、用户名、密码确认3306端口可访问时间字段比实际时间少8小时连接串缺少serverTimezoneAsia/Shanghai在JDBC url中补充时区参数中文乱码数据库或连接字符集不是utf8建表用utf8mb4url加characterEncodingutf8Valid没生效非法参数直接入库缺少spring-boot-starter-validation依赖在pom.xml中引入validation starter校验失败时抛MethodArgumentNotValidExceptionBindingResult没有紧跟Valid参数调整参数顺序让两者紧邻刷新页面导致留言重复提交表单处理完直接返回视图没走重定向改用Post-Redirect-Get模式redirect:/启动报Table message doesnt exist表名写错或设置了ddl-auto: none检查表名映射或开发阶段把ddl-auto改为update留言内容出现script被执行模板用了th:utext改用th:text或对内容做HTML转义分页数据重复或丢失排序字段有重复值排序增加次要字段如Sort.by(createdAt).descending()...Connection is not available报错连接池耗尽检查是否循环里长时间占用连接或调整池大小这十来个问题覆盖了我带人做留言板时90%以上的报错场景。建议你遇到问题时先对着表格排查一遍如果表格里没有再考虑用日志定位。4.3 我踩过的坑和经验建议聊几个网上教程不会告诉你的细节都是我自己实际做项目时踩过、或者反复帮别人调过的经历。第一个坑是关于Service层校验和Controller层校验的分工。很多教程会在Controller里做所有校验Service只负责保存看起来没什么问题但实际上业务校验应该放在Service层。为什么因为Controller只是入口之一如果哪天你加了定时任务或者MQ消费者来创建留言绕过Controller的校验非法数据照样能入库。把校验下沉到Service才能做到所有入口统一防护。第二个坑是不要返回JpaRepository自带的ListMessage直接给前端。直接返回实体对象意味着数据库表结构被暴露给了页面一旦将来表结构调整前端可能跟着崩。更稳妥的做法是在Service层把实体转换为页面需要的数据结构比如Map或者DTO只传递必要的字段。留言板这个项目太简单这么做的收益不明显但这是一开始就应该养成的习惯。第三个坑是测试数据别用手工一条条插。写一个CommandLineRunner在应用启动时检查数据库条数如果为空就自动批量生成100条测试数据。这样每次重置数据库之后页面上立刻有分页效果可以看不用手动插数据。第四个坑是关于日志。开发阶段把日志级别调到DEBUG尤其是SQL日志能让你清楚地看到每条请求实际执行了什么语句。配置很简单logging: level: org.hibernate.SQL: debug org.hibernate.type.descriptor.sql: trace第二行会打印SQL参数的具体值排查问题的时候非常有用。等部署上线再调回INFO避免日志量过大。4.4 这个项目还能继续怎么玩基础版跑通之后这个项目的扩展空间其实很大。我按难度递增列几个方向你可以挑感兴趣的往里加。最容易的是增加回复功能。给每条留言加一个父ID字段页面渲染时把回复嵌套在对应留言下面。这一步能让你理解自关联表查询实际开发中评论区的设计基本都是这个套路。进阶一点可以在留言内容上做敏感词过滤。加载一份敏感词列表到内存提交时做遍历替换。这个功能能锻炼字符串算法能力比如Trie树匹配对性能的理解也会加深。我实际做过一个版本用Trie树把2000个敏感词的匹配耗时控制在毫秒级比逐个contains高效得多。再进阶一点可以引入Redis做缓存。把留言列表缓存到Redis中设置5分钟过期读请求优先打缓存写请求时删除缓存让下一次重建。这一步能让你体会缓存穿透、缓存雪崩这些概念在实际代码里是怎么演化出来的。很多人一上来就看Redis的八股文不如自己动手实现一次。还可以加上AOP日志切面。写一个切面拦截所有Controller方法打印请求参数、处理耗时、异常堆栈。理解了AOP的实际应用场景后你对Spring的架构理解会上一个台阶。我个人带新人时有个习惯不管项目多简单一定要有一个加分项——比基础需求多迈半步的设计。这半步能让你在面试聊项目时有得聊也能倒逼你去学新的知识。留言板做完分页和删除之后再往上加一个敏感词过滤或者Redis缓存比再做一个同样难度的新项目有价值得多。最后分享一个心得做这个项目时千万别把代码写完能跑就当作结束。多问自己几个为什么——为什么这个接口返回重定向而不是视图为什么校验注解放在DTO而不是实体上为什么写操作要加事务这些问题想清楚了你收获的东西比敲那一百多行代码多得多。