1. 项目概述:从“玩具”到“作品”的实战跨越

如果你已经学完了Java基础、Servlet、JSP,甚至接触过一些简单的框架,但打开IDE面对一个空项目时,依然感到无从下手,那么你正处在一个关键的“瓶颈期”。这个阶段,你需要的不是更多的语法知识,而是一个能串联所有知识点、模拟真实开发流程的 完整实战项目 。今天要拆解的这个“JavaWeb实战项目案例六”,正是为突破这个瓶颈而设计的。它不是一个简单的“增删改查”Demo,而是一个麻雀虽小、五脏俱全的综合性练手项目,旨在让你亲身体验从需求分析、数据库设计、前后端开发到部署上线的全链路过程。

为什么说这类项目至关重要?因为面试官和实际工作场景考察的,从来不是你背下了多少API,而是你 解决一个具体业务问题的综合能力 。一个完整的项目案例,能将你零散的知识点(如JDBC连接池、MVC分层、会话管理、前端交互)编织成一张紧密的网。通过亲手实现它,你不仅能巩固技术栈,更能建立起宝贵的“工程化思维”——如何设计可维护的代码结构、如何处理异常、如何进行基础的性能考量。接下来,我将以一个典型的 图书管理系统 作为蓝本,带你深度拆解这个实战项目的每一个核心环节,分享那些只有真正动手做过才会懂的“坑”与技巧。

2. 项目整体架构与技术选型解析

在动手写第一行代码之前,花时间思考架构和技术选型,是区分“新手”和“有经验的开发者”的关键一步。一个好的架构能让后续开发事半功倍,而随意的堆砌代码则会带来无尽的维护噩梦。

2.1 为什么选择经典三层架构?

对于这个练手项目,我强烈推荐采用经典的 MVC模式 三层架构 结合的方式。这不是过时,而是经过无数项目验证的、最适合初学者理解业务逻辑分离的范式。

  • 表现层(View/Controller) :使用JSP(或Thymeleaf等模板引擎)负责页面渲染,Servlet作为控制器(Controller)接收请求、调用业务、转发视图。这让你清晰地看到“请求-响应”的完整生命周期。
  • 业务逻辑层(Service) :这是项目的“大脑”。所有具体的业务规则(如“借书时检查用户是否超期”、“图书库存不足时不能借阅”)都在这里实现。Service层调用数据访问层,并对上层提供干净的接口。
  • 数据访问层(DAO) :负责所有与数据库打交道的操作,即CRUD(增删改查)。它的存在将业务逻辑与特定的数据库技术(如MySQL)解耦,未来更换数据库会容易得多。

注意 :很多新手会图省事,把数据库操作直接写在Servlet里。这会导致Servlet迅速膨胀到几千行,成为难以维护的“上帝类”。严格的分层是项目可扩展性的基石。

2.2 技术栈的“务实”选择

基于“学习”和“实战”的双重目标,我建议以下技术栈组合:

  • 后端核心 :Servlet 3.0+、JSP、JDBC。为什么不直接用Spring Boot?因为“地基”要打牢。亲手用原生的Servlet和JDBC处理一遍,你才能真正理解Spring Boot帮你简化了什么,未来遇到问题才能深入排查。
  • 数据库 :MySQL 5.7+。社区活跃,资料丰富,是工业界的标配。对于这个项目,完全够用。
  • 连接池 必选项 ,而非可选项。使用像HikariCP或Druid这样的高性能连接池。直接使用 DriverManager.getConnection() 在生产环境下是灾难性的,会导致数据库连接耗尽。HikariCP以轻量和快速著称,是很多项目的首选。
  • 前端 :HTML5、CSS3、JavaScript,配合简单的jQuery或原生JS完成Ajax交互。不必追求复杂的前端框架,重点是理解前后端数据交互(JSON格式)和DOM操作。
  • 构建与管理 :Maven。用它来管理项目依赖(如数据库驱动、连接池、JSON解析库),保证环境一致性。
  • 服务器 :Tomcat 9.x。轻量、稳定,是学习JavaWeb的“老伙伴”。

这个组合没有追赶最新潮的技术,但每一环都是企业级应用的基石,掌握了它们,你再学习任何上层框架都会感到游刃有余。

3. 数据库设计与核心表结构剖析

数据库设计是项目的“骨架”,设计得好,后续开发顺风顺水;设计得差,则举步维艰。我们围绕“图书管理”核心业务,设计以下几张表:

3.1 表结构设计思路

  1. 用户表 ( sys_user )

    CREATE TABLE `sys_user` (
      `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
      `username` varchar(50) NOT NULL UNIQUE COMMENT '用户名',
      `password` varchar(255) NOT NULL COMMENT '密码(需加密存储)',
      `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
      `role` tinyint(4) NOT NULL DEFAULT '1' COMMENT '角色:0-管理员,1-普通用户',
      `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
      `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
    
    • 设计要点 password 字段必须预留足够长度(推荐255),为使用BCrypt等强哈希算法加密做准备。 role 字段用于实现简单的权限控制(管理员和用户菜单不同)。 status 实现软删除或账户禁用功能。
  2. 图书信息表 ( book )

    CREATE TABLE `book` (
      `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '图书ID',
      `isbn` varchar(20) NOT NULL UNIQUE COMMENT 'ISBN号',
      `name` varchar(200) NOT NULL COMMENT '图书名称',
      `author` varchar(100) NOT NULL COMMENT '作者',
      `publisher` varchar(100) DEFAULT NULL COMMENT '出版社',
      `price` decimal(10,2) DEFAULT NULL COMMENT '价格',
      `total_count` int(11) NOT NULL DEFAULT '0' COMMENT '总库存数量',
      `available_count` int(11) NOT NULL DEFAULT '0' COMMENT '可借阅数量',
      `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间',
      PRIMARY KEY (`id`),
      KEY `idx_name` (`name`),
      KEY `idx_author` (`author`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书信息表';
    
    • 设计要点 :将库存拆分为 total_count available_count 是关键。总数量不变,可借数量随借阅、归还动态变化。这样设计便于统计和避免超借。为 name author 建立索引,能大幅提升模糊查询效率。
  3. 借阅记录表 ( borrow_record )

    CREATE TABLE `borrow_record` (
      `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
      `user_id` int(11) NOT NULL COMMENT '借阅用户ID',
      `book_id` int(11) NOT NULL COMMENT '图书ID',
      `borrow_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '借出时间',
      `due_time` datetime NOT NULL COMMENT '应还时间',
      `return_time` datetime DEFAULT NULL COMMENT '实际归还时间',
      `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-借出中,1-已归还,2-超期未还',
      PRIMARY KEY (`id`),
      KEY `idx_user_id` (`user_id`),
      KEY `idx_book_id` (`book_id`),
      KEY `idx_due_time` (`due_time`),
      CONSTRAINT `fk_record_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`),
      CONSTRAINT `fk_record_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书借阅记录表';
    
    • 设计要点 :这是核心的业务表。 due_time (应还时间)由业务逻辑计算后存入(如借出时间+30天)。 status 字段用于快速查询不同状态的记录,而无需每次计算时间。 外键约束 在学习阶段建议加上,它能保证数据的一致性,让你理解关系数据库的关联性。生产环境可能会因性能考虑而不用,但原理必须懂。

3.2 为什么不用外键?—— 一个常见的面试题

在上面的设计中我添加了外键,但在很多互联网公司,开发规范明确 禁止使用数据库外键 。为什么?主要出于性能和高并发的考虑:外键约束会带来额外的锁开销,在分布式、分库分表场景下难以维护。那么如何保证数据一致性?答案是通过 应用层逻辑 来保证,比如在Service层删除用户前,先检查是否有未归还的图书。在项目中了解这两种方式的取舍,本身就是一次很好的学习。

4. 核心功能模块的详细实现与编码实战

有了清晰的设计,我们就可以开始编码了。我将挑几个最具代表性的功能模块,深入讲解实现细节和容易踩的坑。

4.1 用户登录与密码安全

这是系统的门户,安全性至关重要。

1. 密码加密存储 永远不要在数据库中存储明文密码!使用BCrypt算法是当前的最佳实践。它自带盐值(salt),能有效抵御彩虹表攻击。

// 引入BCrypt依赖(如 jbcrypt)
public class PasswordUtil {
    private static final int STRENGTH = 12; // 强度因子,值越大越安全但也越慢
    
    public static String hashPassword(String plainPassword) {
        return BCrypt.hashpw(plainPassword, BCrypt.gensalt(STRENGTH));
    }
    
    public static boolean checkPassword(String plainPassword, String hashedPassword) {
        return BCrypt.checkpw(plainPassword, hashedPassword);
    }
}

在用户注册或修改密码时,调用 hashPassword 存储哈希值。登录时,调用 checkPassword 进行验证。

2. 会话管理 用户登录成功后,需要将其身份信息存入Session,以便后续请求识别。

// LoginServlet.java (doPost方法部分)
String username = request.getParameter("username");
String password = request.getParameter("password");
User user = userService.login(username, password);
if (user != null) {
    // 登录成功
    HttpSession session = request.getSession();
    session.setAttribute("loginUser", user); // 存储整个用户对象或关键信息
    session.setAttribute("userId", user.getId());
    session.setAttribute("userRole", user.getRole());
    // 设置会话超时时间(单位:秒),例如30分钟
    session.setMaxInactiveInterval(30 * 60);
    response.sendRedirect("index.jsp"); // 跳转到主页
} else {
    request.setAttribute("errorMsg", "用户名或密码错误");
    request.getRequestDispatcher("/login.jsp").forward(request, response);
}

实操心得 :不要在Session中存放过大对象(如包含大量数据的List),这会增加服务器内存压力。通常只存放用户ID、姓名、角色等关键标识信息。其他信息需要时再从数据库查询。

4.2 图书借阅业务逻辑实现

这是业务核心,涉及事务和并发控制。

Service层核心代码逻辑:

// BookBorrowService.java
public class BookBorrowService {
    private BookDao bookDao = new BookDaoImpl();
    private BorrowRecordDao recordDao = new BorrowRecordDaoImpl();
    // 务必使用同一个Connection保证事务
    private Connection connection = DatabaseUtil.getConnection(); 
    
    public boolean borrowBook(int userId, int bookId) throws SQLException {
        boolean success = false;
        try {
            // 1. 开启事务(手动提交)
            connection.setAutoCommit(false);
            
            // 2. 查询图书当前可借数量(悲观锁:for update)
            String sql = "SELECT available_count FROM book WHERE id = ? FOR UPDATE";
            // ... 执行查询,获取当前数量 currentAvailable
            
            if (currentAvailable <= 0) {
                throw new RuntimeException("图书库存不足,无法借阅");
            }
            
            // 3. 减少可借数量
            String updateSql = "UPDATE book SET available_count = available_count - 1 WHERE id = ?";
            // ... 执行更新
            
            // 4. 创建借阅记录
            BorrowRecord record = new BorrowRecord();
            record.setUserId(userId);
            record.setBookId(bookId);
            record.setBorrowTime(new Date());
            // 计算应还时间:当前时间+30天
            Calendar calendar = Calendar.getInstance();
            calendar.add(Calendar.DAY_OF_MONTH, 30);
            record.setDueTime(calendar.getTime());
            record.setStatus(0);
            recordDao.insert(record, connection);
            
            // 5. 提交事务
            connection.commit();
            success = true;
            
        } catch (Exception e) {
            // 6. 发生异常,回滚事务
            connection.rollback();
            e.printStackTrace();
            throw new RuntimeException("借阅操作失败:" + e.getMessage());
        } finally {
            // 7. 恢复自动提交模式,并谨慎关闭连接(通常连接由连接池管理,这里不直接关闭)
            connection.setAutoCommit(true);
        }
        return success;
    }
}

关键点解析:

  1. 事务(Transaction) :借书涉及“更新库存”和“插入记录”两个操作,必须作为一个原子单元。使用 connection.setAutoCommit(false) 开启手动事务,全部成功则提交( commit ),任一失败则回滚( rollback )。
  2. 并发控制 :在高并发场景下,多个用户同时借同一本最后一本书,可能导致“超借”。这里使用了 SELECT ... FOR UPDATE 进行 悲观锁 ,在查询时就直接锁定这条记录,直到事务结束。这是最安全的做法,但会影响性能。另一种思路是使用 乐观锁 (通过版本号字段),冲突时让用户重试,适合并发冲突不高的场景。
  3. 连接管理 :Service层多个DAO操作必须使用同一个数据库连接,否则事务会失效。通常通过 ThreadLocal 或从连接池获取连接后传递来解决。

4.3 分页查询的通用解决方案

列表查询(如图书列表、借阅记录)必须支持分页,这是基础要求。

后端分页逻辑:

// BaseDao.java 或一个独立的 PageUtil.java
public class PageUtil<T> {
    private int pageNum;     // 当前页码
    private int pageSize;    // 每页条数
    private int totalCount;  // 总记录数
    private int totalPage;   // 总页数
    private List<T> data;    // 当前页数据
    
    // 构造方法、getter/setter 省略...
    
    /**
     * 生成分页查询的SQL片段(MySQL)
     * @param sql 原始查询SQL(不含limit)
     * @param pageNum
     * @param pageSize
     * @return 拼接了limit的SQL
     */
    public static String getPagedSql(String sql, int pageNum, int pageSize) {
        int offset = (pageNum - 1) * pageSize;
        return sql + " LIMIT " + offset + ", " + pageSize;
    }
    
    /**
     * 计算总页数
     */
    public void calculateTotalPage() {
        this.totalPage = (int) Math.ceil((double) totalCount / pageSize);
    }
}

在Servlet中使用:

// BookListServlet.java
int pageNum = 1;
int pageSize = 10;
try {
    pageNum = Integer.parseInt(request.getParameter("pageNum"));
} catch (NumberFormatException e) {
    // 使用默认值
}
String keyword = request.getParameter("keyword");

// 创建分页对象
PageUtil<Book> page = new PageUtil<>();
page.setPageNum(pageNum);
page.setPageSize(pageSize);

// 查询总记录数(这是一个独立的COUNT查询,性能关键点)
int totalCount = bookDao.countBooks(keyword);
page.setTotalCount(totalCount);
page.calculateTotalPage();

// 查询当前页数据
List<Book> bookList = bookDao.findBooksByPage(keyword, pageNum, pageSize);
page.setData(bookList);

// 将page对象放入request,转发到JSP
request.setAttribute("page", page);
request.getRequestDispatcher("/admin/book_list.jsp").forward(request, response);

前端JSP分页栏实现(核心片段):

<!-- book_list.jsp -->
<div class="pagination">
    <c:if test="${page.pageNum > 1}">
        <a href="bookList?pageNum=${page.pageNum - 1}&keyword=${param.keyword}">上一页</a>
    </c:if>
    
    <c:forEach begin="1" end="${page.totalPage}" var="i">
        <c:choose>
            <c:when test="${i == page.pageNum}">
                <span class="current">${i}</span>
            </c:when>
            <c:otherwise>
                <a href="bookList?pageNum=${i}&keyword=${param.keyword}">${i}</a>
            </c:otherwise>
        </c:choose>
    </c:forEach>
    
    <c:if test="${page.pageNum < page.totalPage}">
        <a href="bookList?pageNum=${page.pageNum + 1}&keyword=${param.keyword}">下一页</a>
    </c:if>
    
    共 ${page.totalCount} 条记录, 第 ${page.pageNum}/${page.totalPage} 页
</div>

注意事项 COUNT(*) 查询在数据量大时可能很慢。如果表数据量极大(百万级以上),需要考虑其他方案,如使用估算值、或从搜索引擎/缓存中获取总数。

5. 开发环境搭建与项目配置要点

“工欲善其事,必先利其器”。一个顺畅的开发环境能极大提升效率。

5.1 使用Maven创建Web项目骨架

在IntelliJ IDEA中:

  1. File -> New -> Project ,选择 Maven ,勾选 Create from archetype ,找到 org.apache.maven.archetypes:maven-archetype-webapp
  2. 填写 GroupId (如 com.yourname )、 ArtifactId (如 library-management )。
  3. 创建完成后,补全标准的Maven目录结构:
    src/main/java     —— 存放Java源代码
    src/main/resources —— 存放配置文件(.properties, .xml)
    src/main/webapp   —— 存放Web资源(JSP, HTML, CSS, JS, WEB-INF)
    src/test/java     —— 存放测试代码
    
  4. pom.xml 中添加关键依赖:
    <dependencies>
        <!-- Servlet & JSP API -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
        <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>
        <!-- HikariCP 连接池 -->
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>5.0.1</version>
        </dependency>
        <!-- BCrypt 密码加密 -->
        <dependency>
            <groupId>org.mindrot</groupId>
            <artifactId>jbcrypt</artifactId>
            <version>0.4</version>
        </dependency>
        <!-- JSON处理(用于可能的Ajax接口) -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.15.2</version>
        </dependency>
    </dependencies>
    

5.2 数据库连接池的配置与封装

src/main/resources 下创建 db.properties

jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
jdbc.username=root
jdbc.password=your_password
# HikariCP 配置
hikari.maximumPoolSize=10
hikari.minimumIdle=5
hikari.idleTimeout=300000
hikari.connectionTimeout=20000
hikari.maxLifetime=1800000

创建数据库连接工具类 DatabaseUtil.java

public class DatabaseUtil {
    private static HikariDataSource dataSource;
    
    static {
        try {
            Properties props = new Properties();
            // 从类路径加载配置文件
            InputStream input = DatabaseUtil.class.getClassLoader()
                                                 .getResourceAsStream("db.properties");
            props.load(input);
            
            HikariConfig config = new HikariConfig();
            config.setDriverClassName(props.getProperty("jdbc.driver"));
            config.setJdbcUrl(props.getProperty("jdbc.url"));
            config.setUsername(props.getProperty("jdbc.username"));
            config.setPassword(props.getProperty("jdbc.password"));
            
            // 可选:设置连接池参数
            config.setMaximumPoolSize(Integer.parseInt(props.getProperty("hikari.maximumPoolSize", "10")));
            config.setMinimumIdle(Integer.parseInt(props.getProperty("hikari.minimumIdle", "5")));
            
            dataSource = new HikariDataSource(config);
        } catch (IOException e) {
            throw new ExceptionInInitializerError("Failed to load database configuration.");
        }
    }
    
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }
    
    public static void closeConnection(Connection conn, Statement stmt, ResultSet rs) {
        // 关闭资源的通用方法,注意关闭顺序:ResultSet -> Statement -> Connection
        try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); }
        try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); }
        try { if (conn != null) conn.close(); } // 这里是将连接还给连接池,并非真正关闭
        catch (SQLException e) { e.printStackTrace(); }
    }
}

踩坑提醒 db.properties 文件中的 serverTimezone 参数非常重要,如果没设置或设置错误,可能会导致数据库插入时间时差8小时的问题。 useSSL=false 在本地开发环境可以关闭以简化配置。

6. 前端页面交互与用户体验优化

虽然重点是后端,但一个看得过去、交互友好的前端能让项目增色不少,也更接近真实项目。

6.1 使用JSTL和EL表达式简化JSP

避免在JSP中写大量的Java脚本( <% ... %> ),使用EL表达式( ${} )和JSTL标签。

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html>
<head>
    <title>图书列表</title>
</head>
<body>
    <h1>欢迎您,${sessionScope.loginUser.realName}</h1>
    
    <!-- 使用JSTL的c:if进行条件判断 -->
    <c:if test="${sessionScope.userRole == 0}">
        <a href="bookAdd.jsp">添加新书</a>
    </c:if>
    
    <table border="1">
        <tr>
            <th>ID</th><th>书名</th><th>作者</th><th>价格</th><th>可借数量</th><th>操作</th>
        </tr>
        <!-- 使用JSTL的c:forEach遍历列表 -->
        <c:forEach items="${page.data}" var="book">
        <tr>
            <td>${book.id}</td>
            <td>${book.name}</td>
            <td>${book.author}</td>
            <td>
                <!-- 使用JSTL的fmt:formatNumber格式化价格 -->
                <fmt:formatNumber value="${book.price}" type="currency" pattern="¥#,##0.00"/>
            </td>
            <td>${book.availableCount}</td>
            <td>
                <a href="bookDetail?id=${book.id}">详情</a>
                <c:if test="${book.availableCount > 0}">
                    <a href="javascript:borrowBook(${book.id})">借阅</a>
                </c:if>
            </td>
        </tr>
        </c:forEach>
    </table>
    <!-- 引入分页片段 -->
    <jsp:include page="/common/pagination.jsp">
        <jsp:param name="pageUrl" value="bookList"/>
    </jsp:include>
    
    <script>
    function borrowBook(bookId) {
        if(confirm('确定要借阅这本书吗?')) {
            // 使用Fetch API或jQuery Ajax发起异步请求
            fetch('borrowBook?bookId=' + bookId, {
                method: 'POST',
                headers: {'Content-Type': 'application/x-www-form-urlencoded'}
            })
            .then(response => response.json())
            .then(data => {
                if(data.success) {
                    alert('借阅成功!');
                    location.reload(); // 刷新页面更新库存
                } else {
                    alert('借阅失败:' + data.message);
                }
            })
            .catch(error => console.error('Error:', error));
        }
    }
    </script>
</body>
</html>

6.2 利用Ajax实现无刷新交互

如上例中的 borrowBook 函数,使用 fetch jQuery.ajax 与后端交互,可以极大提升用户体验。后端Servlet需要返回JSON格式的数据。

// BorrowBookServlet.java
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    response.setContentType("application/json;charset=utf-8");
    PrintWriter out = response.getWriter();
    Map<String, Object> result = new HashMap<>();
    
    try {
        int userId = (int) request.getSession().getAttribute("userId");
        int bookId = Integer.parseInt(request.getParameter("bookId"));
        
        boolean success = borrowService.borrowBook(userId, bookId);
        if(success) {
            result.put("success", true);
            result.put("message", "借阅成功");
        } else {
            result.put("success", false);
            result.put("message", "借阅失败,请重试");
        }
    } catch (Exception e) {
        result.put("success", false);
        result.put("message", "系统错误:" + e.getMessage());
    }
    
    // 使用Jackson将Map转换为JSON字符串
    ObjectMapper mapper = new ObjectMapper();
    out.print(mapper.writeValueAsString(result));
    out.flush();
}

7. 项目部署、测试与性能调优入门

开发完成只是第一步,让项目稳定运行并具备一定抗压能力,才是实战的完结。

7.1 将项目打包并部署到Tomcat

  1. 打包 :在项目根目录(包含 pom.xml 的目录)下执行Maven命令:

    mvn clean package
    

    成功后,会在 target 目录下生成一个 项目名.war 文件(如 library-management.war )。

  2. 部署

    • 方式一(热部署) :将 .war 文件复制到Tomcat的 webapps 目录下,启动Tomcat,它会自动解压并部署。
    • 方式二(Manager App) :访问Tomcat管理页面( http://localhost:8080/manager/html ),上传WAR文件进行部署。
    • 方式三(IDEA集成) :在IDEA中配置Tomcat Server,直接使用 Debug Run 模式启动,适合开发阶段。
  3. 访问 :部署成功后,通过 http://localhost:8080/你的项目名/ 访问应用。如果WAR文件名为 library-management.war ,则访问路径为 http://localhost:8080/library-management/

7.2 基础功能测试点

不要只测试“正确路径”,更要测试“异常路径”。

  • 边界测试 :输入超长用户名、密码;借阅库存为0的图书;查询不存在的图书。
  • 并发测试 :模拟两个用户同时借阅最后一本相同的书,观察是否会发生超借。
  • 安全性测试
    • SQL注入 :在登录名输入 ' or '1'='1 ,看是否能绕过登录。
    • XSS攻击 :在图书名输入 <script>alert('xss')</script> ,看页面是否会被执行脚本。
    • 越权访问 :普通用户登录后,尝试直接访问管理员页面的URL(如 /admin/userList ),检查是否有权限控制。
  • 会话测试 :登录后关闭浏览器再打开,访问需要登录的页面,是否跳转到登录页。

7.3 初级的性能考量与优化方向

当项目能跑起来后,可以思考以下优化点,这会让你的项目在面试中脱颖而出:

  1. 数据库查询优化
    • 索引 :为 borrow_record 表的 user_id , book_id , due_time 字段添加索引,能极大提升关联查询和按时间范围查询的速度。
    • **避免SELECT ***:只查询需要的字段,减少网络传输和内存消耗。
    • 批量操作 :如需插入大量初始数据,使用 PreparedStatement addBatch() executeBatch()
  2. 缓存引入 :对于一些不常变化但频繁访问的数据,如图书分类、系统配置,可以引入简单的缓存。从最简单的 HashMap (注意并发问题)开始,到使用 Caffeine Ehcache 本地缓存,理解缓存穿透、击穿、雪崩的概念及应对策略。
  3. 静态资源处理 :将CSS、JS、图片等静态文件放在 webapp 下的特定目录(如 /static ),并在 web.xml 中配置Tomcat的 DefaultServlet 来处理,避免经过你的应用Servlet,提升响应速度。
  4. 连接池监控 :如果使用Druid连接池,它自带监控界面。可以查看活跃连接数、执行慢SQL等信息,帮助定位性能瓶颈。

8. 常见问题排查与调试技巧实录

开发过程中,你一定会遇到各种“诡异”的问题。这里记录几个最常见的问题和排查思路。

8.1 中文乱码问题——永远的“坑”

现象 :页面显示问号 ?? ,或数据库里存的是乱码。 解决方案(四步检查法)

  1. JSP页面编码 :确保JSP文件顶部有 <%@ page contentType="text/html;charset=UTF-8" language="java" %> ,并且文件本身的物理编码也是UTF-8(在IDE中设置)。
  2. Servlet请求/响应编码 :在 doGet / doPost 方法最开始,设置请求和响应的编码。
    request.setCharacterEncoding("UTF-8");
    response.setContentType("text/html;charset=UTF-8");
    
    对于 application/json 响应,则用 response.setContentType("application/json;charset=utf-8");
  3. 数据库连接编码 :JDBC URL中必须指定字符集,如 jdbc:mysql://...?characterEncoding=UTF-8&useUnicode=true
  4. 数据库表/字段编码 :确保MySQL数据库、表和字段的字符集是 utf8mb4 (支持所有emoji和生僻字)。

8.2 空指针异常(NullPointerException)

这是Java中最常见的运行时异常。

  • 场景 :从 request.getParameter() 获取参数,未判空就直接使用;从Session中获取对象,未判空就调用其方法。
  • 防御性编程 :养成习惯,对可能为null的对象先进行判断。
    String idStr = request.getParameter("id");
    if (idStr == null || idStr.trim().isEmpty()) {
        // 处理参数缺失的情况,如返回错误信息或默认值
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "参数id不能为空");
        return;
    }
    int id = Integer.parseInt(idStr); // 此时idStr肯定不为空
    

8.3 事务不生效

现象 :代码中抛出了异常,但数据库数据还是被修改了。 排查

  1. 检查是否使用了 connection.setAutoCommit(false) 开启了手动事务。
  2. 检查Service方法中多个DAO操作使用的是否是 同一个Connection 对象。如果每个DAO都自己从连接池获取新连接,事务必然失效。解决方法是通过参数传递Connection,或使用 ThreadLocal 绑定连接。
  3. 检查是否在 catch 块中进行了 connection.rollback() ,并在 finally 块中恢复了 connection.setAutoCommit(true)
  4. 确保你操作的数据库表是 支持事务的存储引擎 (如InnoDB),而不是MyISAM。

8.4 404错误(资源找不到)

现象 :浏览器显示404,但确定文件存在。 排查

  1. 路径错误 :这是最常见的原因。检查JSP页面中的链接、表单的 action 、Servlet的 @WebServlet 注解或 web.xml 中的 <url-pattern> 是否匹配。
    • 相对路径和绝对路径(以 / 开头)的区别。 / 代表应用根路径( http://host:port/你的应用名/ )。
  2. 未编译或部署失败 :检查 target 目录下或Tomcat的 webapps 目录下,你的类文件( .class )是否成功生成。清理项目并重新构建( mvn clean compile )。
  3. Servlet未配置或注解未扫描 :如果使用 @WebServlet 注解,确保你的Servlet类在 src/main/java 下,且Tomcat能扫描到。或者检查 web.xml <servlet> <servlet-mapping> 的配置是否正确。

8.5 500错误(服务器内部错误)

现象 :浏览器显示500,Tomcat控制台打印了异常堆栈信息。 这是最好的情况 ,因为错误信息一目了然。

  • 看控制台 :直接阅读Tomcat日志(通常在IDEA的 Run Console 窗口,或Tomcat的 logs/catalina.out 文件),找到 Caused by: 后面的根本原因。
  • 常见原因
    • ClassNotFoundException :缺少某个JAR包依赖,检查 pom.xml WEB-INF/lib
    • SQLException :SQL语法错误、连接失败。检查SQL语句和在数据库客户端能否执行。
    • NumberFormatException :将非数字字符串转为数字,如前文的 id 参数判空。
    • JasperException :JSP编译错误,检查JSP页面语法。

掌握这些排查方法,你就能独立解决开发中80%以上的问题。记住, 耐心阅读错误信息 是程序员最重要的能力之一。

更多推荐