1. 项目起点:同城信息网到底要做什么
如果你已经学过 Java 基础、Servlet、JSP 和 MySQL,但一直没找到一个能把这些零散知识点串起来的东西,那这个同城信息网系统应该正好填上这块空白。我当初做这个项目之前,手头的东西是这样的:会用 Session 存用户登录态,会在 JSP 里写<c:forEach>循环列表,数据库也会建几张表做联表查询。但真要我独立从一个空项目开始,自己设计表结构、分层写代码、把增删改查完整落地成一套能演示的系统,说实话心里是没底的。
同城信息网的核心目标很朴素:让本地用户能注册登录、发布信息(二手交易、房屋租赁、招聘求职、本地服务),然后所有访客可以按分类浏览这些信息。你打开首页能看到分类导航,点进去是某个分类下的信息列表,再点进去是详情页。对发布者来说,他能管理自己发布的信息,能删能改。对管理员来说,他需要能审核内容、能下架违规信息。整个系统看下来,它的功能边界非常清晰,非常适合用来理解 MVC 三层架构到底是怎么在一个真实项目里分工协作的。
我在敲代码之前先把需求边界画了出来,因为做项目最容易犯的毛病就是想到哪写到哪。同城信息网我框定了几个核心模块:
- 用户模块:注册、登录、退出、个人信息查看
- 信息发布模块:登录用户可以发布信息,填标题、分类、详情、联系方式
- 信息展示模块:按分类分页展示信息列表,支持按关键词模糊搜索
- 信息管理模块:发布者可以修改或删除自己的信息,管理员可以下架所有信息
这个范围定下来之后,整个开发过程的复杂度立刻变得可控了。不做用户间私信、不做评论系统、不做积分体系,把精力集中在主链路上,项目做出来不仅完整,而且每一块代码你都能讲清楚"为什么这么写"。
很多教程上来就给项目结构和代码,读者跟着抄一遍,代码是跑起来了,但脑子里还是浆糊。我自己的经验是反过来:先给这个项目画一张业务数据流图(不是画给老师看的那种,是画给自己理思路的那种)——用户在注册页填完表单,请求带着参数到了服务器,服务器先做参数校验,再调用业务层判断用户名是否已存在,然后把密码加密后写入数据库,最后跳转到登录页。这张图在脑海里清晰了之后,后面写代码只是翻译工作而已。
2. 技术选型的底层逻辑:为什么依然是 Servlet + JSP + MySQL
这个同城信息网我选用的是 JavaWeb 领域里最经典的组合:Servlet + JSP + MySQL,配合 Tomcat 容器和 Maven 做项目管理。我在选型的时候其实犹豫过要不要直接上 Spring Boot,因为现在外面招聘要求动辄 Spring 系列全家桶,写 Servlet 好像很过时。但真把这个项目做下来,我反而更确信这个选择是对的——在掌握 Servlet 和 JSP 之前直接冲 Spring Boot,你根本不知道 Spring 帮你封装了什么,出了问题连排查方向都不知道。
说说这套组合里的关键点,如果你要用 Maven 管理依赖,pom.xml里核心要加这几个东西:
<dependencies> <!-- Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP API --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL 标签库,用于 JSP 页面中做循环和条件判断 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependency>我用的 JDK 是 8,Tomcat 9,MySQL 8。这里有个容易踩的坑:MySQL 8 的驱动连接串和旧版不同,要写成jdbc:mysql://localhost:3306/tongcheng?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8,少了serverTimezone参数启动的时候大概率会报时区错误,当场给你个The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,新手看到这个乱码一样的报错直接懵圈。其实只是因为驱动拿不到有效时区,加参数就好。
关于数据库连接,我没有用连接池(比如 C3P0、Druid),直接用最原始的 JDBC 封装了一层 JDBCUtil。原因很简单:这个系统属于教学演示和中小规模场景,不会出现高并发连接挤压的问题,直接DriverManager.getConnection()够用了。理解这一层之后,以后再用连接池的时候你会明白它存在的意义——getConnection()每次都在建立物理连接,开销大,连接池本质上就是提前建一批连接放着随取随用。
数据访问层我用的是 DBUtils 这个轻量级工具,它帮我们处理了ResultSet到 JavaBean 的映射。自己写的话你需要手动rs.getInt("id")、rs.getString("title"),不仅啰嗦而且容易出 bug。引入 DBUtils 之后,查询用户列表只需要一行:
QueryRunner runner = new QueryRunner(JDBCUtil.getDataSource()); List<User> users = runner.query(sql, new BeanListHandler<>(User.class), params);这套组合的复杂度可以说是刚刚好——有需要自己动手的地方(比如 Servlet 里的控制逻辑、请求转发的流程),也有封装好的工具(DBUtils 映射),不至于让新手掉进过度的配置地狱里。
2.1 项目目录结构:按 MVC 分层物理隔离
技术选型定下来之后,第一步不是写代码,而是把项目的目录结构搭出来。我的建议是目录分层一定要严格,强迫自己遵守约定胜于配置的原则。结构如下:
tongcheng-info/ ├── pom.xml ├── src/main/java │ ├── com.tongcheng │ │ ├── controller (Servlet 层:接收请求、分发调用) │ │ ├── service (业务逻辑层:处理规则) │ │ ├── dao (数据访问层:SQL 与结果映射) │ │ ├── entity (实体类:User、Category、Info) │ │ ├── util (工具类:JDBCUtil 等) │ │ └── filter (过滤器:编码处理、登录拦截) ├── src/main/resources ├── src/main/webapp │ ├── static (CSS/JS/图片资源) │ ├── WEB-INF │ │ ├── web.xml │ │ └── jsp │ │ ├── index.jsp │ │ ├── login.jsp │ │ ├── register.jsp │ │ ├── info_detail.jsp │ │ └── ...我把 JSP 全部放在WEB-INF目录下面,这个细节可能刚接触的同学不理解。WEB-INF下的资源浏览器不能直接通过 URL 访问,所有请求必须先经过 Servlet,由 Servlet 决定转发到哪个页面。这样做有两个好处:一是防止用户跳过登录直接访问受保护的页面(比如在地址栏输入/WEB-INF/jsp/publish.jsp是找不到的);二是强制走 MVC 流程,避免有人图省事在 JSP 里直接写 JDBC 查询代码,把架构彻底带偏。
2.2 数据库设计:表结构怎么反映业务关系
数据库我建了四张表:user、category、info、admin。设计的时候我盯着需求想了两件事:信息怎么分类,以及信息属于谁。于是主表info里必须有两个外键字段:user_id和category_id。
建表的 SQL 核心部分长这样:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(MD5加密存储)', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `sort` int(11) DEFAULT '0' COMMENT '显示排序', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `info` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '信息标题', `content` text COMMENT '信息详情', `category_id` int(11) DEFAULT NULL, `user_id` int(11) DEFAULT NULL, `contact` varchar(50) DEFAULT NULL COMMENT '联系方式', `status` tinyint(4) DEFAULT '1' COMMENT '1正常 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个值得展开的设计细节。密码不能明文存,我用了 MD5 加密,虽然现在更推荐 BCrypt,但对这个项目来说 MD5 已经足够演示"不能存明文"的原则,你也完全可以把 MD5 换成 BCrypt(引入spring-security-crypto依赖即可)。status字段用来做逻辑删除和审核下架,而不是物理删除数据,这个设计的好处下次再说。
分类表我提前插好了几条固定数据:二手闲置、房屋租赁、全职招聘、兼职求职、本地服务。为什么不直接写在代码里而存数据库?因为分类一旦存在数据库里,以后做后台管理增删改不需要改代码,DBA 和运营能自助操作,这更接近真实项目的使用方式。
3. MVC三层架构在这个项目里的落地方式
理论书上把 MVC 三层架构画成图:浏览器发请求到 Controller,Controller 调 Service,Service 调 Dao,Dao 操作数据库,结果再逐层返回。这个图谁都会画,真正容易迷惑的是:Controller 里到底放什么,Service 里放什么,Dao 里放什么,边界线在哪里。
在这个项目里,我是这样分的:
Controller 层(Servlet)只干三件事——接收参数、调用 Service、决定跳转。比如发布信息这个需求,PublishServlet 接收表单里的 title、content、categoryId、contact,封装成一个 Info 对象,然后调用infoService.publish(info),根据返回值判断结果是成功还是失败,再转发到对应的 JSP。除此之外什么也不干。不写 SQL,不写业务判断,不直接操作HttpSession里的值去做逻辑判断。
Service 层(业务逻辑)管规则。比如用户注册时,Service 层要做的事情是:先查用户名是否已存在,存在就抛一个UserExistException,不存在才加密密码并调用 Dao 插入。再比如发布信息时,Service 层要校验登录用户是否真实存在,信息标题不能为空、分类必须有效。这些规则属于业务范畴,必须写在 Service 层,写在 Controller 层会让 Controller 变得臃肿且无法复用。
Dao 层(数据访问)只写 SQL 相关的操作。UserDao有findByUsername(String username)、insert(User user),InfoDao有findByCategoryAndPage(...)、update(Info info)、deleteById(int id)。每一条方法名都对应一条 SQL 的执行。Dao 层不关心"用户名已存在会怎样",它只负责"查出来,返回 null 表示不存在"。
整个请求流转在项目里走一遍就这样:
浏览器 POST /publish → PublishServlet(接收参数封装Info对象) → InfoService.publish(Info info)(校验标题非空、分类合法) → InfoDao.insert(Info info)(执行 INSERT SQL) → 返回主键 → 返回"发布成功" → 转发到 info_detail.jsp我在真实开发中深切体会到的一个好处是:如果某个功能出 bug 了,我会先看页面报错出现在哪层。页面 500 报错,先看 Servlet 转发的目标路径对不对;业务逻辑结果不对(比如下架状态的信息还在列表里出现),那问题一定在 Service 层的状态过滤上;数据库查出的数据不对,才需要排查 Dao 层的 SQL。这样定位问题的速度完全不是一个量级的。
3.1 用户登录模块:Session 状态管理的关键路径
登录模块是整个系统的入口,也是理解 Session 原理的最佳场景。LoginServlet 做的事:接收 username 和 password,把密码做同样的 MD5 加密,调userService.login(username, md5Password),Service 层调 Dao 查询,返回 User 对象。如果 User 是 null,说明用户名密码不对,回到登录页并提示错误信息;如果查到了,把 User 对象存进session.setAttribute("loginUser", user),然后重定向到首页。
这里面有三个容易出问题的点,我给你一一排掉。
第一,密码比对必须在 Service 层做,不能把数据库里的密文取出来在 Servlet 里比。你把加密逻辑裸露到 Controller 层,以后要改加密算法(比如换成 BCrypt 加盐),要改的地方散布在各处,风险极高。
第二,登录成功后要用重定向(sendRedirect),不能用转发(forward)。转发的话,地址栏还是/login这个 URL,用户刷新页面时浏览器会重新提交一次表单,有可能造成重复登录。重定向让浏览器发起一次全新的 GET 请求到/index,地址栏也变成/index,用户数据已经存在于 Session 中不会丢。
第三,要在 Session 里存完整的 User 对象而不是把用户名和密码分开存。这样在 JSP 页面可以方便地通过${sessionScope.loginUser.username}直接显示,如果要显示用户头像、ID,也不用再去数据库查一遍。
为了控制页面访问权限,我写了一个LoginFilter:
@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // 获取请求路径,注意去掉上下文路径 String path = req.getRequestURI().substring(req.getContextPath().length()); // 放行:登录、注册、首页、静态资源 if (path.startsWith("/login") || path.startsWith("/register") || path.startsWith("/index") || path.startsWith("/static") || path.startsWith("/info/detail") || "/".equals(path)) { chain.doFilter(request, response); return; } // 未登录访问受保护路径,重定向到登录页 if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } chain.doFilter(request, response); } }有个小细节要留意:我在写放行条件的时候没有盲目放过所有路径,而是精确列了"不需要登录就能看"的名单,剩下的全部拦截。所谓白名单思路,比黑名单安全得多,以后新增一个需要登录才能访问的功能时,天然就处于拦截范围内,不会因为忘了加拦截而漏出权限漏洞。
3.2 信息发布与分页列表:最核心的 CRUD 全景
发布信息这个功能,代码量不大但足够体现一个完整 MVC 链路的写法。我在InfoDao里写了:
public int insert(Info info) throws SQLException { String sql = "INSERT INTO info (title, content, category_id, user_id, contact, status) VALUES (?, ?, ?, ?, ?, 1)"; Object[] params = { info.getTitle(), info.getContent(), info.getCategoryId(), info.getUserId(), info.getContact() }; QueryRunner runner = new QueryRunner(JDBCUtil.getDataSource()); return runner.update(sql, params); }发布成功后,用户应该能立刻看到自己刚发的内容,所以我的设计是发布完成后重定向到detail?id=目标详情页,让用户对自己的操作结果有即时反馈。
分页这块是整个项目里最值得讲的一段。列表页展示信息不能一次把全部数据查出来,同城信息网的场景下数据量可能不大,但分页是一个工程习惯,也是面试爱问的点。我采用最传统也最直观的 LIMIT 分页方案:
SELECT * FROM info WHERE status = 1 AND category_id = ? ORDER BY create_time DESC LIMIT ?, ?对应的 Servlet 从请求里拿pageNum和pageSize,算偏移量offset = (pageNum - 1) * pageSize,然后传给 Dao。为了渲染分页按钮,我还需要知道总记录数。于是 Dao 里再加一个countByCategory(categoryId)查询总数,Service 层把数据和总数打包进一个PageBean<Info>(包含totalCount、totalPage、currentPage、list),传入 JSP。JSP 里用 JSTL 配合写页码按钮:
<c:forEach begin="1" end="${pageBean.totalPage}" var="p"> <a href="${pageContext.request.contextPath}/info/list?pageNum=${p}&categoryId=${param.categoryId}" class="${p == pageBean.currentPage ? 'current' : ''}">${p}</a> </c:forEach>有个超常规的小问题你早晚会遇到——当用户正在第 5 页,他删除了当前页最后一条数据,系统如果没有处理的话会掉到"当前页没有数据"的尴尬状态。解决方法是在 Service 里做一次边界调整:如果请求的 pageNum 大于总页数,直接把 pageNum 重置为总页数。
4. 从列表到详情:搜索、分类筛选与数据权限控制
信息列表页不只是把数据罗列出来那么简单,里面包含三个业务的交叉:按分类筛选、按关键词搜索、按登录权限展示操作按钮。任何一个单独的 CRUD 练多了都会,但把几个条件叠加在一起,骨架的严密性就定了。
我设计了一个InfoListServlet,它接收三个可选参数:categoryId、keyword、pageNum。然后动态拼接 SQL:
StringBuilder sql = new StringBuilder("SELECT * FROM info WHERE status = 1"); List<Object> params = new ArrayList<>(); if (categoryId != null && !"0".equals(categoryId)) { sql.append(" AND category_id = ?"); params.add(categoryId); } if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" AND (title LIKE ? OR content LIKE ?)"); params.add("%" + keyword.trim() + "%"); params.add("%" + keyword.trim() + "%"); } sql.append(" ORDER BY create_time DESC LIMIT ?, ?");动态 SQL 这种写法在 MyBatis 里变成了<where>和<if>标签,但底层逻辑依然是这个——先搭骨架,再按条件拼装。把这个原理搞懂之后,你去学 MyBatis 动态 SQL 会非常快速。
详情页面的信息权限也要单独考虑。InfoDetailServlet从请求中拿到 id 之后调 Dao 查出 Info 对象,同时查出它关联的 User 对象和 Category 对象。为了减少联表查询复杂度,我的做法是在Info实体类里额外加两个非表字段:username和categoryName,在 Dao 查询时通过 JOIN 把这两个字段填上:
String sql = "SELECT i.*, u.username, c.name as categoryName FROM info i " + "LEFT JOIN user u ON i.user_id = u.id " + "LEFT JOIN category c ON i.category_id = c.id " + "WHERE i.id = ?";为什么要用LEFT JOIN而不是INNER JOIN?因为这个项目里发布者的账号可能会被管理员删除,如果用内连接,发布者不在了,这条信息也被牵连着查不出来了——产生了一种数据"凭空消失"的假象。左连接保证即使关联不到用户或分类,信息本身也能正常显示出来,只是用户名是 null。
详情页的操作区做了一次登录判断和归属判断:
<c:if test="${not empty sessionScope.loginUser}"> <c:if test="${sessionScope.loginUser.id == info.userId || sessionScope.loginUser.role == 'ADMIN'}"> <a href="edit?id=${info.id}">编辑</a> <a href="delete?id=${info.id}" onclick="return confirm('确定删除吗?')">删除</a> </c:if> </c:if>这个判断逻辑放在 JSP 里,主要是为了渲染层面做按钮隐藏。真正的访问控制依然在 Servlet 层的EditServlet和DeleteServlet里做二次校验:根据 Session 里的 loginUser 判断他是不是这条信息的主人或管理员,如果不是直接 403 拒绝。页面隐藏只是用户体验,服务端校验才是安全底线,这个原则永远不要丢。
4.1 我踩过的坑:事务处理与参数传染
这个项目里有一个典型的、几乎每个做 JavaWeb 项目的新手都会踩但不知道原因的坑——更新操作的事务边界。信息编辑功能刚写好的时候,我遇到一个诡异现象:修改数据偶尔成功、偶尔失败,而且数据库里有时会出现部分字段更新成功但另一部分没更新的情况。
排查下来找到根因:InfoService.updateInfo(info)做了很多事情——先判断信息是否存在,再判断操作者权限,最后调 Dao 更新字段。这几个步骤分别调用了多次 Dao 方法,但每一步都默认 Autocommit。一旦中间某个步骤执行失败(比如权限判断时查用户信息抛了异常),前面已经执行成功的更新却交出去了,事务被截断成了碎片。
正确的做法是在需要保证原子性的操作上开启事务。JDBC 原生写法是conn.setAutoCommit(false),但我这个项目用的是 QueryRunner,可以像下面这样显式控制:
public void updateInfo(Info info) throws Exception { Connection conn = JDBCUtil.getConnection(); try { conn.setAutoCommit(false); QueryRunner runner = new QueryRunner(); runner.update(conn, "UPDATE info SET title=?, content=?, category_id=?, contact=? WHERE id=?", ...); // 其他关联操作 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); // 恢复自动提交,避免连接池污染 conn.close(); } }有了事务,更新的过程要么全成功、要么全失败,不存在半新半旧的脏数据。
再有就是参数传染问题。设计Info实体类时,很多人会照抄表单字段,觉得够用就行。我早期就是title、content、categoryId、contact,更新的时候把这些字段setInfo进去,结果发现用户不填的联系方式字段把数据库里原有数据覆盖成了空。受害者不止我一个,这属于典型的"表单部分字段提交导致整行字段被覆盖"问题。解决方式有两种:更新前先从数据库查出完整当前数据,跟表单提交的数据做合并;或者把表单字段和数据库字段分离开,用 DTO 接收到底哪些变了。这个项目的体量我选了前者——先按 id 查出原 Info 对象,再手动 set 那些表单允许修改的字段,最后才调update,这样用户没填的字段保持了原值。
5. 部署与验收:从本机跑通到可供演示
项目代码全部写完,并不意味着万事大吉。我见过太多人代码写得还行,结果演示的时候就地翻车。部署和验收环节是很多教程一笔带过但实际操作中坑最多的阶段,我专门拿一节出来讲。
前提是本地需要已装好 JDK8、Maven、Tomcat9、MySQL8。四者版本匹配关系建议直接沿用我前面列的,别单个升级某个组件,别问为什么,出错了排查成本太高。
打包前先检查一个地方:pom.xml里定义的打包方式是war,并且确认 Tomcat 的 webapps 目录下没有残留旧项目。打包命令很简单:
mvn clean package打出来的 war 包在target/目录下。然后把它复制到 Tomcat 的webapps目录里,启动 Tomcat:
cp target/tongcheng-info.war {tomcat安装路径}/webapps/ catalina.sh run # 或者用 startup.sh 后台启动启动之后注意观察日志,确认没有报错。很多人在这里卡住是因为web.xml里没配置首页 welcome-file,结果访问根路径自动拼了一个不存在的文件名,页面直接 404。我在web.xml里显式配置了:
<welcome-file-list> <welcome-file>index</welcome-file> <welcome-file>index.jsp</welcome-file> </welcome-file-list>这样访问http://localhost:8080/tongcheng-info/就能直接跳到首页。
演示之前完整走一遍验收清单,我列出来你对照着自测:
| 测试项 | 前置条件 | 预期结果 |
|---|---|---|
| 用户注册 | 无 | 注册成功,跳转登录页 |
| 重复用户名注册 | 用户已存在 | 提示用户名已存在 |
| 登录成功 | 注册过账号 | 首页右上角显示用户名 |
| 登录失败 | 正确账号名+错误密码 | 提示密码错误 |
| 未登录访问发布页 | 清掉 Session | 自动跳转登录页 |
| 发布信息 | 已登录 | 详情页出现新发布内容 |
| 按分类筛选 | 分类中有数据 | 列表只显示该分类数据 |
| 关键词搜索 | 标题含某词 | 列表只显示匹配数据 |
| 非本人编辑他人信息 | A 登录后改 B 的信息 | 操作被拒绝 |
| 管理员下架信息 | 管理员登录 | 前台列表不再显示该信息 |
验收时还要测一个最容易忽略的边界场景:数据库里没有数据时打开列表页,页面不应该报错,而应该显示"暂无相关发布信息"的提示。这个场景 JSP 里必须有个空值判断:
<c:if test="${empty pageBean.list}"> <p class="empty-tip">暂无相关发布信息,去发布一条吧</p> </c:if>缺少这个判断,第一次打开项目的访客直接面对一个空白页面,体验很差,而且搞不清是系统坏了还是没有数据。
5.1 发布部署时常见的三个翻车现场
我实际部署过程中遇到过三个非常典型的翻车现场,都值得单独拿出来说。
第一个是端口占用。本机原来跑着别的项目的 Tomcat 没关,自带容器端口 8080 被占用了。启动的时候日志直接报Port 8080 required by Tomcat v9.0 Server at localhost is already in use,解决方法是先杀掉占用进程或者改端口,用命令行可以快速查看:
lsof -i :8080第二个是时区问题留下的后患。数据库连接串里没加serverTimezone,当时项目一启动查询带时间的字段就直接抛异常,页面还能渲染但全是 500。我的排查套路是:先看控制台最底部异常堆栈,看到java.sql.SQLException: The server time zone value,就去改 JDBC 连接串,问题立刻解决。
第三个是JSP 页面里的乱码问题。我把web.xml里加了编码过滤器(EncodingFilter),强制 request 和 response 都是 UTF-8,同时给每个 JSP 页面加了<%@ page contentType="text/html;charset=UTF-8" language="java" %>。这俩缺一不可——只加过滤器,JSP 里中文硬编码仍可能乱;只加页面声明,POST 请求提交的中文参数照样乱。最佳实践是两层都上。
// EncodingFilter @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }注意request.setCharacterEncoding("UTF-8")只对 POST 请求体里的参数生效。如果你用 GET 方式提交中文参数,Tomcat 默认会按 ISO-8859-1 解码 URL,需要在server.xml里的 Connector 上配置URIEncoding="UTF-8",或者干脆所有含中文的操作用 POST。
6. 做完之后的复盘:这个项目教会我的几件事
项目已经能完整跑通了,但我的经验是别急着交差。复盘一次,收获会比写代码时更大。
第一件事是关于三层架构的真正理解。没动手之前我以为 MVC 就是把.java文件分别放在几个包里,代码按包名分类而已。做完之后我才明白,三层架构的意义在于可替换性与可测试性。因为 Controller 不写业务、Dao 不写判断,当我后来想把数据库从 MySQL 换成 PostgreSQL 时,只需要改 Dao 层实现类;当我后来想加一层 Redis 缓存时,只动 Service 层的接口实现,Controller 完全不用碰。哪怕你已经会 Spring Boot 的全家桶自动装配,理解了这层再去看 Spring 的分层设计,你会反应过来——它本质上就是帮你把 Controller/Service/Dao 这套我一直手写的东西,从繁琐地手动注入变成了约定交给框架。
第二件事是一个完整系统必须考虑非主流程的防御分支。主流程写起来是很爽的:发布、浏览、删除。但真实的项目用户会以各种"错误路径"使用系统——不登录直接访问详情页、修改别人的信息、提交空标题、在页码参数里塞负数和特殊字符。这些非正常输入是系统健壮性的试金石。我的PageBean里专门加了对 pageNum 的防御:小于 1 则重置为 1,大于总页数则重置为最后一页。就这么一行代码,列表页在各种极端操作下都不会白屏。
第三件事是日志与错误提示比想象中更重要。在这个项目早期调试时,我把所有异常信息printStackTrace()打出来再返回一个空页面,导致出问题后只能盯着 IDEA 控制台找原因。后来我在 Service 层加了一个简单的异常处理:把错误信息转成提示写到 request 域里,转发到错误页面展示:
} catch (Exception e) { e.printStackTrace(); request.setAttribute("errorMsg", "操作失败,请稍后重试"); request.getRequestDispatcher("/WEB-INF/jsp/error.jsp").forward(request, response); }这带来的体验改变很大——用户能看到明文提示而不是一脸懵的 500 白页。这个习惯带到我后来做别的项目也一样适用:面向用户的产品,错误提示永远要友好。
最后聊一点开发节奏的体会。这个项目我前后花了三到四周的业余时间,但真正有效产出集中在思路最清楚的那几天。我固执地用了先写 Dao 层再写 Service 层再写 Controller 层最后写 JSP 的顺序,每写完一层就在脑海里过一遍上一层怎么调用它,遇到不合理的地方当场改。这种自下而上的编写节奏适合一个人做小项目,先确认数据层无误,再层层往上,最后写页面调试体验,整个流程顺畅不返工。如果你更习惯从页面需求反推数据的结构,也可以自上而下,但保证每次只在某一层内部改动,不要提前把跨层的假设焊死。
这个项目做完之后,你对 JavaWeb 的核心脉络基本就建立起来了。后面去学框架会更加有的放矢——比如你看到 MyBatis 的 Mapper 接口就知道它本质上是替你写 Dao 实现,看到@RequestMapping就知道它对应 Servlet 的doGet/doPost,看到 SpringMVC 的ModelAndView就知道那是 request 域加转发的变体。从这个意义上讲,同城信息网不只是一个小项目,更是你进入 Web 开发世界的一块稳当的垫脚石。