最近完整过了一遍基于 JSP + Servlet + MySQL + Bootstrap 的酒水商城管理系统,从业务梳理、数据库建表到代码编写和部署排错全部走完。这个技术组合放在今天看确实有点复古,但如果你真想理解 Java Web 的底层运行逻辑,JSP + Servlet 依然是最干净的入口——没有框架帮你隐藏细节,请求怎么进来、响应怎么出去、Session 怎么维持,每一步都看得见、摸得着。这篇文章就把这个项目从架构设计、数据库建模、核心模块实现到典型报错排查完整拆开讲,适合正在写毕设、刚学完 JavaSE 想进入 Web 开发、或者想用一个完整案例打通“浏览器—Tomcat—MySQL”这条链路的人参考。
1. 项目全景拆解:这套酒水商城到底要做什么
1.1 业务需求与用户场景梳理
先明确这个系统服务的对象。酒水商城不是一个泛泛的电商项目,它有非常具体的业务边界:酒水商品和普通日用品不一样,存在品牌、度数、规格(瓶/箱)、库存批次这些维度,而且用户下单后通常还涉及配送方式的选择。管理端需要维护商品信息、上下架状态、订单发货状态;用户端则需要完成注册登录、浏览商品、按分类筛选、加入购物车、提交订单这几个闭环动作。
整个系统可以拆成两条主线。第一条是用户操作流:注册账号、登录、在首页或者分类页浏览酒水、点进详情页看规格、加购物车、结算下单、等待发货。第二条是管理员操作流:登录后台、新增或编辑酒水商品、设置价格和库存、查看订单列表、把订单状态从“待发货”改成“已发货”。两条线共用同一套数据库,用户表的 role 字段区分普通用户和管理员。设计时最容易被忽略的是“库存”这个字段,很多教学项目喜欢放着不管,但真实商城里用户下单后库存必须同步扣减,否则就会出现你明明买了最后一瓶但系统还在继续卖的尴尬场景。
1.2 为什么还是 JSP + Servlet,不用 Spring Boot
你可能会问,2025 年了谁还用 JSP?这个项目的技术选型确实不是冲着“生产环境最流行”去的,而是冲着“教学路径最完整”去的。Spring Boot 确实把容器、装配、配置全封装掉了,但封装的前提是隐藏了太多细节:初学者根本不知道一次 HTTP 请求经过了多少环节,遇到 404 只会懵,遇到中文乱码更不知道怎么查。而 JSP + Servlet 这套组合,请求到 Servlet、Servlet 调用 DAO、DAO 拿 JDBC 查 MySQL、结果放 request 域、转发到 JSP、JSP 渲染成 HTML——每一步都手动完成。
更实际的原因是很多学校的 JavaWeb 课程大纲依然以 Servlet + JSP 为主体,毕业设计和课程设计的要求也指向这个技术栈。如果你直接上 Spring Boot,可能还要重学依赖注入、自动配置、内嵌容器这些概念,对于课时有限的初学者负担反而更大。换句话说,这个项目是一个“把底层路基打扎实”的阶段,Spring Boot 可以放在后面迁移。Bootstrap 加入的原因更简单——原生 HTML 太难看了,在不需要单独部署前端工程的前提下,Bootstrap 提供了一套响应式栅格和现成组件,几分钟就能把后台管理界面从“毛坯房”变成“简装房”。
1.3 标准工程结构与分层规划
一个规范的 JavaWeb 工程不是把 Servlet 随便堆在一起的。我用的是最常见的分层方式:com.shop.entity 放实体类,com.shop.dao 放数据库访问类,com.shop.service 放业务逻辑层(这个项目业务不重,可以直接合并到 DAO,但建议保留),com.shop.servlet 放控制器,com.shop.filter 放过滤器。webapp 目录下面按 static/bootstrap、static/css、static/js、admin、user 分目录放页面资源,JSP 统一放 WEB-INF 的 views 子目录里避免用户直接访问。
目录规范之后最直接的收益就是排查问题快。一个 Servlet 只负责一个模块的跳转,比如 GoodsServlet 管商品模块所有操作,通过隐藏字段 method 值区分 list、detail、add、edit、delete。如果全写在 index.jsp 一个文件里,越写到后面越痛苦,改一个功能要滚动几屏,而且 JSP 和 Servlet 混在一起根本没法讨论什么是 MVC。工程结构清了,后面的数据库设计和代码实现才有抓手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心架构与请求流转原理
2.1 MVC 在 JSP + Servlet 中的落地方式
这个项目的本质是一个入门版 MVC。Servlet 当 Controller:接收 HttpServletRequest,解析参数,调 Service/DAO 拿结果,然后把结果放进 request 或 session 域,通过 RequestDispatcher.forward() 转到 JSP 页面。JSP 当 View:从 request 作用域或者 session 作用域取值,用 JSTL 和 EL 表达式渲染成 HTML,不再直接写 Java 代码块。业务模型和数据库访问藏在 Entity + DAO 这层,Servlet 不知道 MySQL 长什么样,DAO 也不知道前端长什么样。
请求流转顺序写下来就是:浏览器发起 GET 或 POST 请求 -> 容器根据 web.xml 或 @WebServlet 注解找到对应 Servlet -> Servlet 设置编码、解析参数、调用 DAO -> DAO 执行 SQL 返回结果 -> Servlet 把结果放进 request.setAttribute() -> forward 到 JSP -> JSP 用 EL 表达式取出数据渲染页面 -> 响应回到浏览器。整个过程里没有 AJAX,也没有 JSON 接口,因为这是一个以服务端渲染为主的商城,Bootstrap 只负责页面的好看,不承担数据交互。理清这条链路之后再去看框架,你会秒懂为什么 Spring MVC 叫做“DispatcherServlet 分发器”,因为本质上还是这套请求-控制器-视图模型。
2.2 过滤器与编码处理的关键位置
中文乱码是 JSP + Servlet 项目里出现频率最高的坑,根源在于三个环节的字符集不一致。第一个环节是请求参数编码,POST 请求需要 request.setCharacterEncoding("UTF-8"),GET 请求还要在 Tomcat 的 server.xml 里的 Connector 上配置 URIEncoding="UTF-8"。第二个环节是响应编码,需要通过 response.setCharacterEncoding("UTF-8") 和 response.setContentType("text/html;charset=UTF-8") 保证输出到浏览器的字节流是 UTF-8。第三个环节是数据库连接编码,JDBC URL 上必须带 useUnicode=true&characterEncoding=utf8,数据库表和字段的字符集也要统一成 utf8mb4。
与其每个 Servlet 写一遍编码设置,不如写一个 CharacterEncodingFilter 一次性解决。filter 在 web.xml 中配置 url-pattern 为 /,放在所有 Servlet 之前执行。当时我第一次写这个过滤器的时候漏了 setCharacterEncoding,结果注册页的中文用户名入库全部变成问号,整整折腾了一个晚上。所以我把“请求编码 -> 响应编码 -> 数据库字符集”三条链路拧成一个检查清单,任何一次环境重装或者项目迁移之后,中文乱码就从这三点排查,基本三两分钟定位完。这个经验后来用到大大小小的 JavaWeb 项目中,从来没有失效过。
2.3 Bootstrap 静态资源与后端页面的整合
Bootstrap 在这里不是动态框架,它只是静态的资源包。你可以把官网下载的 bootstrap.min.css、bootstrap.min.js 放在 webapp/static/ 目录下,然后在 JSP 里用 ${pageContext.request.contextPath}/static/bootstrap/css/bootstrap.min.css 这种方式引用。用了 JSTL 的 c:url 标签更稳妥,它会自动拼接上下文根路径。引用路径最大的问题是少了上下文根,如果你把路径写成 /static/... 而在 IDEA 里部署时 Application context 是 /shop,那 404 是必然的——因为真实路径已经变成了 /shop/static/...。
我个人的习惯是前端资源全部走本地文件,不走 CDN。原因很现实:这个项目可能要在机房演示、要在没有外网的宿舍答辩,一旦 CDN 无法访问,再好的 Bootstrap 样式也会瞬间变成“纯文本网页”。本地资源只要把整个 webapp 目录拷走,样式永远在。另一个细节是注意 Bootstrap 版本,老教程多数用 Bootstrap 3,这个版本的栅格类名是 col-md-4、col-xs-6,分页组件是 .pagination,新版本改了类名,照着 3 的写法拷到 5 的项目里会废掉一半样式。
3. 数据库建模:五张核心表的设计与实现
3.1 数据库初始化与字符集选择
先用 Navicat 或者命令行创建一个酒水商城的数据库。如果你在 Windows 上装 MySQL,最好在安装时就把默认字符集选成 utf8mb4。MySQL 8.0 安装完之后,建议用下面这段 SQL 建库:
sql复制CREATE DATABASE IF NOT EXISTS liquor_shop
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
字符集不要用 utf8,因为 MySQL 的“utf8”最多只支持 3 字节的字符,遇到生僻字或者 Emoji 会直接报错,utf8mb4 才是真正的全量 UTF-8 支持。古龙水的名字里如果出现拼音 emoji,或者商品描述里有人输入特殊符号,用 utf8 就直接崩了。排序规则建议用 utf8mb4_general_ci,虽然它不算最精确的排序规则,但对中文场景来说速度和够用程度平衡得比较好。表结构里所有 varchar 字段统一用 utf8mb4 字符集,如果建库时已指定,表单里通常只需继承即可。
3.2 五张核心表的建表 SQL 与字段解析
整个商城五张核心表,分别是用户表 t_user、商品分类表 t_category、商品表 t_goods、购物车表 t_cart、订单表 t_order 和订单明细表 t_order_item。多说一句,实际是六张,购物车和订单明细虽然在概念上简单,但在表结构上各有不可替代的作用。
用户表要设计 role 字段区分普通用户和管理员,status 字段用来锁定被禁止登录的账号。密码字段存的是 MD5 之后的密文,长度留够 32 位。注意密码不要用明文,哪怕只是毕业设计也得养成存档加密的习惯。商品分类表只有 id 和 name 两个字段,但 name 要加唯一约束,防止用户创建重复分类导致前台筛选混乱。商品表是核心中的核心,字段包括 id、name、category_id、price、stock、sales、image、description、status,其中 status 是上下架标识,使用 1 表示上架 0 表示下架。price 用 DECIMAL(10,2) 而不是 FLOAT 或 DOUBLE,因为浮点数会在运算中出现 0.30000000000000004 这种精度问题,商城的金额计算绝不能用浮点型。
购物车表设计上要注意,如果只存 Session,用户清一下浏览器缓存购物车就没了,这个系统里我选择把购物车写进数据库,每个用户一条记录对应一个商品 id,数量字段 quantity 大于 1 时复用同一条记录。订单表记录了用户下单后的核心状态,包括订单号 order_no、总金额 total_amount、状态 status、创建时间 create_time、支付时间 pay_time,状态用整型常量定义更清晰,0 待支付、1 已支付、2 已发货、3 已完成、4 已取消,比字符串存“已支付”这种中文状态科学得多——因为一旦状态文案要改,你就不用去 update 数据库的记录。订单明细表把下单那一刻的商品快照存下来,包括商品名称、单价、数量,注意这里必须冗余商品名称和单价字段,不能只存 goods_id,因为商品下架或被改价之后,历史订单里的“当时买了什么花多少钱”依然要能准确还原。
3.3 关系设计中的常见误区
外键的问题是我执行这些建表 SQL 时观察到的最大误区。很多教材为了演示方便,给每张表都加了 FOREIGN KEY,但实际开发中特别是电商业务的高并发场景,连 outer join 都尽量少,更不用说数据库外键。这个项目里我保留逻辑关联但不建物理外键,也就是只保留 category_id、goods_id 这类关联字段,通过名称约定和代码层面的约束来保证一致性。这样做的好处是:删除分类时用 delete 语句直接按 id 删,不需要被外键约束挡住;插入子表时也不会有外键检查的性能损耗。需要注意的是你要在 code 层面确保不产生脏数据,比如删除分类前先执行 UPDATE t_goods SET category_id = NULL 或者先删除该分类下所有商品。
另一个容易踩的坑是字段类型不一致导致 JOIN 查不出来。例如商品表的 id 是 INT,购物车表的 goods_id 如果建成 VARCHAR,表面上数据看起来差不多,但 JOIN 走索引时会因为隐式类型转换导致索引失效,数据大了以后是灾难。设计表的时候统一主键字段命名和类型,关联字段必须和主键类型完全一致,这一点我吃过亏。
4. 功能模块实现:从登录到下单的完整链路
4.1 用户模块:注册、登录与 Session 管理
用户模块相对独立,也最适合第一个实现。注册页面用 Bootstrap 写一个居中的卡片式表单,URL 是 register.jsp,提交到 RegisterServlet。Servlet 拿到用户名和密码后,先做一次查重:SELECT COUNT(*) FROM t_user WHERE username = ?,如果存在直接响应提示“用户名已存在”。密码的存储我用 MD5 加盐处理,盐值可以取固定字符串或者用户名本身,避免直接使用原始 MD5 的字典表反查问题。这个方案如果面对真实生产环境强度不够,但作为教学项目已经足够让你养成“不存明文密码”的习惯。
登录成功之后用 HttpSession 保存用户对象的关键信息,包括 id、username、role。整个商城所有需要登录的 Servlet 都要检查 session.getAttribute("user") 是否为 null,为空就跳回 login.jsp,并提示先登录。这个判断写在各 Servlet 里容易重复,方案是抽一个 LoginFilter 统一拦截 — 在这里我要提醒一点,如果 filter 拦截 URL 模式时配置了 @WebFilter(urlPatterns = "/user/*"),那所有需要登录的接口都放在 /user 前缀下面就能统一拦截,前台商品浏览的接口则不需要登录。我当时就写错了路径,把商品查询也拦截了,导致用户没登录连商品列表都看不了。
Session 的另一个重要作用是维持购物车的用户上下文。如果购物车在 Session 里,用户维度通过 session 区分;如果购物车在数据库里,那 t_cart 表只需要存 user_id 而不用额外传用户参数。我选择了数据库方案,因此购物车接口都要以 session 里的 user_id 为准。这一点其实方便了后续订单中库存扣减、历史订单读取等流程。
4.2 商品展示与分页查询的落地
商品列表页是商城最重要的信息流之一。如果商品数量到了几百条,一次性查出所有商品并把它们全部渲染在页面上,页面会越长越慢,用户根本受不了。分页是必须的。分页的核心是 SQL 里的 LIMIT 和页面上页码导航的配合。JSP 里通过 EL 表达式读取三个关键值:当前页 currentPage、总页数 totalPages、商品列表 goodsList。一个简单的分页查询代码如下:
java复制int pageSize = 8;
int currentPage = 1;
String pageParam = request.getParameter("page");
if (pageParam != null && !pageParam.isEmpty()) {
currentPage = Integer.parseInt(pageParam);
}
int offset = (currentPage - 1) * pageSize;
String sql = "SELECT * FROM t_goods WHERE status = 1 ORDER BY id DESC LIMIT ?, ?";
总页数的计算逻辑:先把总记录数 COUNT 出来,然后用 (int) Math.ceil(totalCount * 1.0 / pageSize) 向上取整。商品的列表 JSP 用 Bootstrap 栅格排版,每行四个卡片,每个卡片显示商品图、名称、价格和“加入购物车”按钮。图片路径如果存的是相对路径,注意某些商品图片因为 Tomcat 对 webapp 目录外的资源访问权限原因访问不到;最简单的方案是把图片也放到 webapp 下,数据库只存 static/images/xxx.png 这种相对路径,用 pageContext.request.contextPath 拼接。
分页导航可以用 Bootstrap 的 .pagination 组件实现。上一页和下一页只有当前页边界时禁用,中间页用数字按钮表示。我实际开发中喜欢在 URL 里保留分类参数,比如分页接口的地址是 goods?method=list&categoryId=3&page=2 —— 如果分页导航没有把当前分类 id 拼进去,就会出现跳第二页的时候分类被清空,商品列表瞬间变成全部商品的 bug。这个坑很隐蔽,排查花了一些时间。
4.3 购物车与下单:事务和库存扣减细节
购物车功能拆成“加购、改数量、移除、结算”四个操作。加购时先查购物车表是否已经有相同 user_id + goods_id 的记录,如果有就 UPDATE quantity = quantity + 1,没有才 INSERT。数量的上下限要控制:最少 1 件,最多不能超过商品当前库存,否则结算时会出现库存不符。检查库存时不能只相信购物车页面里显示的数字,必须在真正下单那一时刻再查一次数据库最新库存——因为在你浏览购物车到点击结算的这段时间,商品可能已经被别的用户买走了。类似买火车票,页面显示的余票数只是一个参考,真正支付时系统会再扣一次余票。
下单是整个系统中事务处理最核心的场景。一个完整的下单动作包含三步:往 t_order 表插入一条订单记录、往 t_order_item 表插入订单里的每个商品明细、UPDATE t_goods SET stock = stock - 购买数量 WHERE id = ? 扣减库存。这三步要么全部成功要么全部失败,否则数据库就会处于“有订单没扣库存”或者“扣了库存没生成订单”的不一致状态。JDBC 原生实现事务的关键代码:
java复制Connection conn = DBUtil.getConnection();
conn.setAutoCommit(false);
try {
// 1. 插入订单
// 2. 插入订单明细
// 3. 扣减库存
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
DBUtil.close(conn);
}
订单号生成有个细节,不要直接用时间戳,并发下极可能重复。我采用的是 yyyyMMddHHmmss + 用户id + 随机数 的拼接,虽然不保证全局绝对唯一,但对这个小项目来说足够应付。下单成功后把购物车中对应商品条目清空,然后跳到订单详情页或者订单列表页。用户地址字段在这个教学版本里可以暂时简化成不维护,但真实商城必须单独建用户地址表。
4.4 管理端功能与订单状态流转
管理端和用户端共用同一个登录入口,只是登录时判断 user.role == 1 才放行到 admin 模块。管理员能做的事情集中在商品和订单两块:新增商品、编辑商品、上下架商品、查看订单列表、修改订单状态。
新增商品时价格输入框要把字符串转成 BigDecimal,不要再拿 Float 去接收。库存字段设置初始值,比如 100。上下架操作本质是 UPDATE t_goods SET status = ? WHERE id = ?,在列表页放一个按钮切换。编辑商品时必须回显原数据,做法是管理员点击“编辑”链接时带商品 id,编辑 Servlet 查出对应的 Goods 对象塞进 request 域,JSP 里将 value="${goods.name}" 回填到表单。
订单状态流转用枚举或常量类来约束。管理员在订单列表看到待发货订单,可以点“发货”,状态从 1 变成 2;用户确认收货之后状态从 2 变成 3。这个状态机需要有一个操作权限的控制:管理员不能把已完成的订单改成已支付,这会造成订单历史失真。最简单的方法是在订单状态更新 Servlet 里用 switch 判断当前状态和目标状态是否满足合法流转,不合法直接拒绝。这一步虽然代码多几行,但对保证数据真实性意义重大。
5. 高频踩坑与排查技巧实录
5.1 中文乱码:三处编码统一的排查路径
中文乱码是 JSP + Servlet 项目的“老朋友”。我见过最典型的情况是数据库里中文正常、页面正常,但通过表单新增的商品名称插入数据库后变成了 ??????。排查顺序要按数据流的方向走:浏览器到 Servlet(请求编码)、Servlet 到 JSP(响应编码)、JDBC 到 MySQL(连接编码)。如果 POST 请求乱码,先确认 CharacterEncodingFilter 是否真的配置生效了;如果 JSP 页面中文乱码,先看页面顶部的 <%@ page contentType="text/html;charset=UTF-8" %> 是否存在;如果数据库里乱码,检查 JDBC URL 是否带了 characterEncoding=utf8,数据库表字符集是否为 utf8mb4。漏检查 URIEncoding 时,GET 请求参数中的中文也会乱,所以 Tomcat 的 Connector 建议加 URIEncoding="UTF-8"。
5.2 数据库连不上:驱动、时区与端口的三板斧
数据库连接失败报错常见有三种。第一种是“ClassNotFoundException: com.mysql.jdbc.Driver”,原因通常是没把 mysql-connector-java.jar 放进 WEB-INF/lib。如果你用的是 MySQL 8.x,驱动类名变成了 com.mysql.cj.jdbc.Driver,旧包名在新版本已经移除,别照抄老教程。第二种是“The server time zone value...”,这是 MySQL 8.x 的时区问题,在 JDBC URL 末尾加上 serverTimezone=Asia/Shanghai 就行。第三种是端口连不上,先 ping 一下 3306 端口,用命令行执行 mysql -u root -p 看服务有没有启动。Windows 上如果服务没有自动启动,可以用 net start mysql 手动拉起。如果你换了机器演示项目,检查一下防火墙是不是挡住了 3306 端口,我演示时就被 Windows 防火墙坑过。
连接池在这个阶段就可以引入。项目规模小直接用最简单的 JDBC 工具类也可以,但每次请求都创建新连接,数据库连接数一高就撑不住。建议至少用 C3P0 或者 Druid 的常用配置,Druid 的配置文件在 src 下,注意配置文件的 path 不要写错,否则初始化连接池会直接抛异常。
5.3 404 和 500 的快速定位方法
404 意味着请求的路径在 Tomcat 里找不到映射。排查顺序是:先看浏览器地址栏的访问路径,再对比 Servlet 注解里的 @WebServlet("/goods")——这里特别容易忘记路径开头的 /。如果路径无误,看项目部署在 Tomcat 中的 Application context 是不是 /shop,那完整的访问路径应是 /shop/goods,不注意前缀就会 404。如果用 web.xml 方式配置 Servlet 映射,检查 servlet-mapping 里 class 的包名全名是否写对,很多低级错误发生在这里。
500 是服务器端代码异常。最快的定位方式是直接看 Tomcat 日志。IDEA 的控制台会打印完整的异常堆栈,按第一行“Caused by:”开始往下看,十有八九能定位。JSP 页面里写了 Java 代码导致的编译错误,会在 Tomcat 的 work 目录下生成对应的 .java 文件,去看它编译报错的地图,信息量非常直观。
5.4 页面样式丢失与静态资源路径问题
页面能访问,但 CSS 全都“裸奔”,通常是因为静态资源路径拼接错误。我在前面反复强调过 ${pageContext.request.contextPath} 这个 EL 表达式,它就是解决路径问题的核心。如果你在 JSP 中写了固定的 /static/bootstrap/css/bootstrap.min.css,在部署上下文是 /shop 时,浏览器实际请求的资源路径会变成 /shop/static/...,而后端映射和磁盘路径自然是按 /static/... 找的,就找不到。解决方式一是所有静态资源引用都用 c:url 标签包一层,二是用相对路径,但它受当前页面 URL 层级影响大,不够稳定。推荐前者。
还有一个容易忽略的问题:如果 Filter 的拦截路径是 /,静态资源也被拦截。比如 LoginFilter 会拦截所有 URL,导致 CSS、JS 都变成未登录跳转的 302,样式自然全丢。处理方法是 Filter 中排除掉 /static/ 开头的路径,或者把静态资源放在不被拦截的目录,明确放行白名单。
5.5 IDEA 运行 JavaWeb 项目的配置注意点
如果你用的是 IDEA Community 版本,它默认不带 Tomcat 集成,装一个 Smart Tomcat 插件可以直接运行。如果是 Ultimate 版,配置 Tomcat Server 时注意 Deployment 里要把项目 artifact 选成 war exploded,这是热部署调试最方便的形态,改代码不用反复重启。Application context 建议改成 /,这样访问页面不需要加项目名前缀,演示起来更干净。配置完还要检查项目 SDK 和 Tomcat 的版本兼容性,比如 Tomcat 10 对应的是 jakarta.servlet 而不是 javax.servlet,项目的 Maven 依赖或者 lib 里的 servlet-api.jar 版本必须和容器匹配,否则映现很多莫名其妙的 NoClassDefFoundError。我在给一个同学排查时,发现他导入的项目用的还是老 javax.servlet,而本地 Tomcat 是 10,一启动就报错,换一个 Tomcat 9 解决。
--
这个项目做完之后,我最大的体会是:JSP + Servlet 绝不是一个“过时无用”的技术栈,它是理解 Java Web 一切后续框架的前提。你在 Spring Boot 里天天用的 @RestController,本质上就是 Servlet 的封装;你在 Spring MVC 里配置的注解,本质上还是在规定请求怎么映射到方法。如果能在这样一个酒水商城项目里把 Servlet 生命周期、JSP 渲染原理、JDBC 事务边界和数据库设计基础全部搞清楚,再出发去学 Spring Boot,你会觉得所有东西都是“已知知识的新语法”,而不是一头雾水地背注解。至于这个项目再往后延展,方向其实很多:把 JDBC 换成 MyBatis、把 JSP 换成 Vue 前后端分离、把本地图片换成对象存储服务,都是很自然的演进步骤——但无论怎么演进,这条从浏览器到数据库的链路,始终是你吃透 Web 开发的根基。
