简介:这是一套面向高校Java EE初学者的课程设计级图书管理系统完整实现,适用于计算机专业《Java Web开发》《企业级应用开发》等课程的期末作业与综合实训。系统采用B/S架构,支持借阅者(学生、教师等)与管理员两类角色,涵盖图书检索、借阅归还、用户管理、库存统计等核心业务功能,可直接部署运行并用于答辩演示。压缩包共350个文件,包含51个Java后端逻辑类、14个JSP页面模板、56个JavaScript交互脚本、25个HTML静态页、19个CSS/SCSS样式文件及22个Jar依赖库,另有SQL数据库脚本与配套开发文档,整体大小为30.12MB。目前已有5126人学习下载,资源结构清晰,含Layui、Font Awesome等主流前端组件,便于理解MVC分层设计与Java EE技术栈整合实践,是掌握Servlet、JSP、JDBC及基础权限控制的优质参考案例。
1. 项目概述:一个典型的Java EE课程设计意味着什么?
如果你是一名计算机相关专业的学生,或者刚入行的Java开发者,看到“Java EE开发期末作业课程设计图书管理系统”这个标题,大概率会心一笑。这几乎是国内高校软件工程、计算机科学与技术等专业Java EE课程的“标配”毕业设计或课程设计题目。它不仅仅是一个作业,更是一个涵盖了从需求分析、技术选型、编码实现到文档编写的完整小型项目生命周期演练。这个压缩包里的“源码+开发文档+数据库”,就是一个项目交付物的标准三件套。
为什么是图书管理系统?因为它业务逻辑清晰,实体关系典型(书、用户、借阅记录),同时又能很好地覆盖Java EE(现在更多称为Jakarta EE)的核心技术栈:Servlet/JSP处理Web请求,JDBC或ORM框架(如MyBatis, Hibernate)操作数据库,MVC模式进行分层解耦,再配合一些前端技术(JSP、HTML、简单的JavaScript)完成页面展示。完成这样一个系统,意味着你初步打通了从浏览器到数据库的完整数据流,理解了B/S架构应用的基本开发流程。
对于学习者而言,这个项目的价值不在于其业务创新,而在于其“麻雀虽小,五脏俱全”的完整性。通过剖析这样一个成熟(或至少是完整)的课程设计源码,你可以快速建立起对Java Web开发全貌的认知,避免在初期陷入某个技术细节而迷失方向。接下来,我将以一个“过来人”和面试官的双重视角,为你深度拆解这个项目包,告诉你除了运行起来,更应该关注哪些核心细节、技术选型背后的逻辑,以及如何将它从一份作业升级为一份拿得出手的个人项目经验。
2. 项目整体架构与技术栈深度解析
拿到一个课程设计源码,第一步不是急着导入IDE运行,而是先看它的“骨架”——技术架构。这决定了代码的组织方式、可维护性和扩展性。
2.1 经典三层架构:MVC模式的落地实践
绝大多数此类课程设计会采用经典的三层架构,这是Java EE开发的基石。
表现层 (View):负责展示数据和接收用户输入。在这个项目中,很可能使用的是JSP(Java Server Pages)配合JSTL标签库和EL表达式。JSP允许在HTML中嵌入Java代码,虽然现在主流已转向前后端分离,但在教学场景中,JSP能直观地展示服务器端渲染的逻辑。你需要关注的点是:JSP页面是否纯净?业务逻辑是否被大量写在了<% ... %>脚本片段里?好的实践应该是JSP只负责展示,从Servlet传递过来的数据通过EL表达式${}或JSTL标签进行渲染。
控制层 (Controller):作为中枢,接收前端请求,调用业务逻辑,并决定跳转到哪个视图。通常由Servlet实现。这里的关键是Servlet的职责是否单一。一个常见的“坏味道”是出现一个名为BookServlet的类,里面用巨大的if-else或switch语句处理“添加图书”、“删除图书”、“查询图书”等所有操作。更好的做法是遵循“一个请求,一个Servlet”或使用前端控制器模式(如Spring MVC的DispatcherServlet),通过配置将不同请求映射到不同的处理方法。
业务逻辑层 (Service):这是系统的“大脑”,包含核心的业务规则和流程。例如,“借书”这个操作,在Service层会校验用户是否存在、图书是否可借、用户是否超限等。这一层应该是无状态的,并且不关心数据如何持久化。你需要检查项目中是否有独立的BookService、UserService等接口及其实现类,这体现了面向接口编程的思想,便于测试和替换。
数据访问层 (DAO):负责与数据库打交道,执行CRUD(增删改查)操作。早期项目可能直接使用JDBC,在Servlet或Service层拼接SQL字符串。更规范的做法是使用DAO模式,为每个实体(如Book、User)创建对应的DAO接口和实现类,将数据访问逻辑封装起来。观察项目是否使用了PreparedStatement来防止SQL注入,这是安全性的基本要求。
2.2 技术选型背后的考量:为什么是它们?
- Servlet + JSP vs. Spring Boot:课程设计多采用原生Servlet/JSP,是因为教学需要从底层理解HTTP请求/响应、会话管理等机制。Spring Boot做了大量封装,虽然高效,但不利于初学者理解原理。通过这个项目,你能深刻体会到为什么需要Spring这样的框架——它解决了原生Java EE开发中大量的样板代码和配置问题。
- JDBC vs. MyBatis/Hibernate:如果项目直接使用JDBC,你会看到大量重复的获取连接、创建语句、处理结果集、关闭资源的代码。这引入了模板方法模式或使用Apache Commons DbUtils等工具的必要性。如果使用了MyBatis,关注其Mapper XML文件如何与接口绑定,动态SQL如何编写。如果用了Hibernate,则关注实体类注解、关联映射以及Session的管理。理解这些ORM框架如何简化数据持久化工作,是进阶的关键。
- 数据库连接池:一个容易被忽略但至关重要的组件。在
src目录下找找有没有c3p0、Druid或HikariCP的配置文件。连接池能显著提升数据库访问性能。如果项目是每次操作都新建连接,那在实际应用中将是灾难性的。你可以思考如何将其改造为使用连接池。
注意:查看项目的
WEB-INF/lib目录或构建工具文件(如pom.xml),是快速了解其技术依赖的最佳方式。一个规范的pom.xml文件本身就是一个技术栈说明书。
3. 核心模块拆解与代码实现要点
让我们深入到图书管理系统的几个核心业务模块,看看一个合格的实现应该包含哪些细节。
3.1 实体建模与数据库设计
这是所有功能的基石。通常会有book(图书)、user/reader(用户/读者)、borrow_record/lend_list(借阅记录)三张核心表。
图书表 (book) 设计要点:
CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, -- 主键,自增 isbn VARCHAR(20) UNIQUE, -- ISBN号,应设唯一约束 name VARCHAR(100) NOT NULL, -- 书名 author VARCHAR(50), publisher VARCHAR(50), price DECIMAL(10,2), total_count INT DEFAULT 0, -- 总数量 current_count INT DEFAULT 0, -- 当前可借数量 location VARCHAR(50), -- 馆藏位置 status TINYINT DEFAULT 1 -- 状态:1-可借,0-不可借(如遗失、下架) );关键点:区分total_count和current_count非常重要。借书时current_count减1,还书时加1,但total_count不变。这样便于统计馆藏总数和可借阅数。status字段提供了更灵活的状态管理。
用户表 (user) 设计要点:
CREATE TABLE user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, -- 登录名 password VARCHAR(100) NOT NULL, -- 密码应加密存储 real_name VARCHAR(50), user_type TINYINT DEFAULT 0, -- 用户类型:0-普通读者,1-管理员 max_borrow INT DEFAULT 5, -- 最大借阅量 phone VARCHAR(20), email VARCHAR(50) );关键点:password字段绝对不能明文存储。即使是课程设计,也应体现安全意识。至少使用MD5(已不安全)或SHA-256进行哈希加盐处理。user_type是实现权限控制的基础。
借阅记录表 (borrow_record) 设计要点:
CREATE TABLE borrow_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 借出时间 due_time DATETIME NOT NULL, -- 应还时间 return_time DATETIME, -- 实际归还时间(为空表示未还) status TINYINT DEFAULT 0, -- 状态:0-借出未还,1-已归还,2-超期未还 FOREIGN KEY (user_id) REFERENCES user(user_id), FOREIGN KEY (book_id) REFERENCES book(book_id) );关键点:外键约束保证了数据完整性。due_time(应还时间)通常由系统根据规则自动计算(如借期30天)。status字段可以根据return_time和due_time动态更新,方便查询。
3.2 用户登录与会话管理
这是Web应用的入口。核心是验证用户凭证并创建会话。
Servlet中的登录逻辑伪代码:
// LoginServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) { String username = request.getParameter("username"); String inputPassword = request.getParameter("password"); // 1. 参数校验(略) // 2. 调用Service查询用户 UserService userService = new UserServiceImpl(); User user = userService.findUserByUsername(username); // 3. 密码验证(对比加密后的密文) if (user != null && user.getPassword().equals(encrypt(inputPassword))) { // 4. 登录成功,将用户信息存入Session HttpSession session = request.getSession(); session.setAttribute("loginUser", user); // 5. 根据用户类型跳转 if (user.getUserType() == 1) { response.sendRedirect("admin/index.jsp"); } else { response.sendRedirect("reader/index.jsp"); } } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } }实操心得:
- 密码加密:在Service层或一个专门的工具类中进行加密操作。例如,使用
DigestUtils.md5Hex(password + salt)。 - 会话超时:在
web.xml中配置<session-config><session-timeout>30</session-timeout></session-config>,单位是分钟。 - 登录拦截(过滤器):这是体现Java Web高级特性的地方。创建一个
AuthFilter,对非登录页面(如/admin/*,/reader/*)的请求进行拦截,检查Session中是否存在用户信息。这是实现权限控制的第一个关口。
3.3 图书管理模块的增删改查
这是后台管理的核心。我们以“添加图书”为例,看一个相对完整的流程。
BookAddServlet 处理流程:
public class BookAddServlet extends HttpServlet { private BookService bookService = new BookServiceImpl(); protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 获取并校验参数(使用Apache Commons Lang3的StringUtils等工具) String name = request.getParameter("name"); String isbn = request.getParameter("isbn"); // ... 其他字段 if (StringUtils.isBlank(name)) { // 返回错误信息 return; } // 2. 封装对象 Book book = new Book(); book.setName(name); book.setIsbn(isbn); // ... 设置其他属性 // 3. 调用业务层 try { boolean success = bookService.addBook(book); if (success) { // 4. 成功:重定向到图书列表页(PRG模式,防止重复提交) response.sendRedirect("bookList"); } else { request.setAttribute("error", "添加失败,ISBN可能重复"); request.getRequestDispatcher("/admin/book_add.jsp").forward(request, response); } } catch (Exception e) { e.printStackTrace(); request.setAttribute("error", "系统错误:" + e.getMessage()); request.getRequestDispatcher("/admin/book_add.jsp").forward(request, response); } } }Service层 (BookServiceImpl) 的关键逻辑:
public class BookServiceImpl implements BookService { private BookDao bookDao = new BookDaoImpl(); @Override public boolean addBook(Book book) throws Exception { // 1. 业务校验:例如,检查ISBN是否已存在 Book existingBook = bookDao.findByIsbn(book.getIsbn()); if (existingBook != null) { throw new Exception("ISBN已存在"); } // 2. 设置默认值 if (book.getTotalCount() > 0) { book.setCurrentCount(book.getTotalCount()); // 初始时,可借数等于总数 } else { book.setTotalCount(1); book.setCurrentCount(1); } book.setStatus(1); // 默认可借 // 3. 调用DAO层 return bookDao.insert(book) > 0; } }DAO层 (BookDaoImpl) 使用JDBC的示例:
public class BookDaoImpl implements BookDao { // 假设通过一个工具类获取连接 @Override public int insert(Book book) throws SQLException { String sql = "INSERT INTO book (isbn, name, author, publisher, price, total_count, current_count) VALUES (?, ?, ?, ?, ?, ?, ?)"; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 数据库连接工具类 pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); pstmt.setString(1, book.getIsbn()); pstmt.setString(2, book.getName()); // ... 设置其他参数 int affectedRows = pstmt.executeUpdate(); if (affectedRows > 0) { rs = pstmt.getGeneratedKeys(); if (rs.next()) { return rs.getInt(1); // 返回生成的主键ID } } return 0; } finally { DBUtil.close(rs, pstmt, conn); // 确保资源关闭,放在finally块中 } } }注意事项:
- 防止重复提交:添加成功后使用
response.sendRedirect(PRG模式),而非转发,可以避免用户刷新页面时重复提交表单。 - 事务管理:如果添加图书涉及多张表(如同时更新库存日志),需要在Service层开启事务。原生JDBC中,需要手动管理
Connection的setAutoCommit(false)、commit()和rollback()。这是项目可以优化的一个高级点。 - DAO层的资源关闭:必须确保
Connection、PreparedStatement、ResultSet在finally块中被关闭,否则会导致连接泄漏。使用try-with-resources语法(Java 7+)是更优雅的方式。
3.4 图书借阅与归还的业务逻辑
这是系统最复杂的业务逻辑所在,涉及多个实体的状态联动。
借阅业务 (BorrowService) 核心步骤:
public boolean borrowBook(int userId, int bookId) throws BusinessException { // 1. 验证用户是否存在、状态是否正常 User user = userDao.findById(userId); if (user == null || user.getStatus() != 1) { // 假设有状态字段 throw new BusinessException("用户不存在或状态异常"); } // 2. 验证图书是否存在、是否可借 Book book = bookDao.findById(bookId); if (book == null || book.getCurrentCount() <= 0 || book.getStatus() != 1) { throw new BusinessException("图书不存在或不可借"); } // 3. 验证用户借阅数量是否超限 int borrowedCount = borrowRecordDao.countBorrowingByUser(userId); if (borrowedCount >= user.getMaxBorrow()) { throw new BusinessException("借阅数量已达上限"); } // 4. 验证用户是否有超期未还图书(可选,但很重要) boolean hasOverdue = borrowRecordDao.hasOverdueRecord(userId); if (hasOverdue) { throw new BusinessException("存在超期未还图书,请先归还"); } // 5. 所有验证通过,开始事务(伪代码) Connection conn = startTransaction(); try { // 6. 创建借阅记录 BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); // 计算应还时间,例如30天后 Calendar cal = Calendar.getInstance(); cal.add(Calendar.DAY_OF_MONTH, 30); record.setDueTime(cal.getTime()); record.setStatus(0); borrowRecordDao.insert(record, conn); // 传入连接,保证在同一个事务中 // 7. 更新图书可借数量 book.setCurrentCount(book.getCurrentCount() - 1); bookDao.updateCurrentCount(bookId, book.getCurrentCount(), conn); // 8. 提交事务 commitTransaction(conn); return true; } catch (Exception e) { // 9. 发生异常,回滚事务 rollbackTransaction(conn); throw new BusinessException("借阅操作失败:" + e.getMessage()); } finally { closeConnection(conn); } }归还业务 (ReturnService) 核心步骤: 归还逻辑相对简单,但需要处理超期计算和罚款(如果实现的话)。
- 根据
record_id找到借阅记录。 - 检查
return_time是否为空(确保未归还过)。 - 设置
return_time为当前时间,更新status为“已归还”。 - 对应的图书
current_count加1。 - 计算是否超期:比较
return_time和due_time。如果超期,可以记录超期天数,并触发罚款计算逻辑(这通常需要另一张fine表)。
实操心得:
- 事务边界:借阅和归还是典型的需要事务管理的操作,因为它们涉及多张表的更新,必须保证原子性(要么全成功,要么全失败)。在纯Servlet/JSP项目中,手动管理事务是个挑战,但也是理解事务本质的好机会。
- 异常处理:使用自定义的
BusinessException来区分业务异常和系统异常,便于在Servlet层进行不同的处理(业务异常提示友好信息,系统异常记录日志并提示“系统繁忙”)。 - 并发问题:在高并发下,两个用户同时借最后一本书,可能会发生超借。这可以通过数据库悲观锁(
SELECT ... FOR UPDATE)或乐观锁(版本号)来解决。课程设计通常不考虑,但这是面试中常问的问题,知道这个概念是加分项。
4. 开发文档解读与项目重构建议
课程设计中的“开发文档”通常包含需求分析、数据库设计、系统设计、测试报告等。它的价值在于展示你的工程化思维。
4.1 如何从文档中学习
- 数据库设计文档:查看E-R图,理解实体之间的关系(一对一、一对多、多对多)。检查表结构设计是否满足三大范式的基本要求,思考是否有冗余字段。
- 系统设计文档:查看功能模块图、系统架构图。看它是否明确划分了前后台、各个功能模块。
- 接口设计:如果有API接口说明,即使是用Servlet,也可以关注其请求路径、参数、响应格式的设计思想,这为理解RESTful API打下基础。
4.2 将课程设计升级为个人项目
如果你想让这个项目在简历上更出彩,可以尝试以下重构和优化:
- 技术栈升级:
- 框架化:将原生Servlet/JSP项目迁移到Spring Boot。用
@Controller替代Servlet,用@Service、@Repository管理Bean,用JPA或MyBatis-Plus简化DAO层。这会让你立刻接触到工业级开发流程。 - 前后端分离:保留后端Java代码,将前端JSP替换为Vue.js或React。使用RESTful API进行数据交互。这能极大丰富你的技术栈。
- 框架化:将原生Servlet/JSP项目迁移到Spring Boot。用
- 功能增强:
- 加入搜索:实现基于书名、作者、ISBN的模糊查询。更进一步,可以集成Elasticsearch实现全文检索(高级)。
- 加入分页:图书列表、借阅记录列表必须支持分页。研究如何使用JDBC或MyBatis实现高效的分页查询。
- 加入图表统计:使用ECharts等库,为管理员后台增加数据看板,展示图书借阅排行榜、月度借阅趋势等。
- 实现简单的预约功能:当图书被借光时,用户可以预约,图书归还后通知预约者。
- 代码质量优化:
- 引入日志:使用Logback或Log4j2替换
System.out.println,规范日志输出。 - 统一异常处理:定义一个全局异常处理器(在Spring中可用
@ControllerAdvice),避免每个Servlet都写try-catch。 - 参数校验:使用Hibernate Validator或Spring的
@Valid注解进行参数校验,使代码更简洁。 - 配置外部化:将数据库连接信息、文件上传路径等写到
.properties或.yml配置文件中。
- 引入日志:使用Logback或Log4j2替换
- 工程化与部署:
- 使用Maven/Gradle:如果原项目是手动导jar包,将其改造为Maven项目,学习依赖管理。
- 编写单元测试:为Service层关键方法编写JUnit测试,保证核心逻辑正确。
- 容器化:学习Docker,为项目编写
Dockerfile,将其打包成镜像运行。这是当下非常流行的技能。
5. 常见问题排查与调试技巧实录
运行别人的代码,尤其是课程设计代码,遇到问题几乎是必然的。下面是一些常见坑点和解决思路。
5.1 环境配置与启动问题
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 导入IDE后大量报错(红叉) | 1. JDK版本不匹配。 2. 缺少依赖库(jar包)。 3. Servlet API等未配置。 | 1. 检查项目使用的JDK版本(项目属性),确保本地已安装对应版本。 2. 检查 WEB-INF/lib下jar包是否完整,或检查pom.xml依赖能否正确下载。3. 如果是Web项目,确保在IDE中配置了Tomcat等服务器,并添加了Servlet API库。 |
启动Tomcat时报ClassNotFoundException或NoClassDefFoundError | 某个类找不到,通常是依赖的jar包未部署到WEB-INF/lib,或Tomcat的lib目录下。 | 1. 确保所有依赖jar包都已复制到项目的WEB-INF/lib目录。2. 对于Maven项目,使用 mvn clean package打包成war,然后部署到Tomcat的webapps下。 |
| 访问JSP页面显示源码,而非渲染后的HTML | Tomcat未正确识别JSP文件,可能缺少JSP相关的jar包,或web.xml配置有误。 | 1. 确保Tomcat安装完整(包含jasper.jar等)。2. 检查web.xml中是否配置了 *.jsp的映射(通常Tomcat默认已配置)。 |
| 数据库连接失败 | 1. 数据库服务未启动。 2. 连接URL、用户名、密码错误。 3. 数据库驱动jar包版本不匹配或未加载。 | 1. 检查MySQL等服务是否运行。 2. 核对连接字符串(注意时区参数 serverTimezone=UTC)。3. 确认驱动类名是否正确( com.mysql.cj.jdbc.Driver)。4. 在代码中打印连接信息,或使用数据库客户端工具(如Navicat)直接测试连接。 |
5.2 运行时业务逻辑问题
- 中文乱码:这是经典问题。解决方案是“统一编码”。
- 数据库层面:确保建库建表时使用
utf8mb4字符集。 - 连接层面:在JDBC连接URL后加上
?characterEncoding=utf8。 - Servlet层面:在
doGet/doPost方法最开始处,设置请求和响应的编码:request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); - JSP页面:在页面顶部添加
<%@ page contentType="text/html;charset=UTF-8" language="java" %>。
- 数据库层面:确保建库建表时使用
- Servlet 404错误:找不到Servlet。
- 检查
web.xml中<servlet>和<servlet-mapping>的配置是否正确,<url-pattern>是否匹配你访问的路径。 - 如果使用了注解(
@WebServlet),检查注解的路径是否正确,并确保你的Tomcat支持Servlet 3.0+规范。
- 检查
- 获取表单参数为null:特别是
doPost方法中。- 确保前端表单的
method="post"。 - 确保表单字段的
name属性与Servlet中request.getParameter(“name”)的参数名一致。 - 在获取参数前,一定要先调用
request.setCharacterEncoding("UTF-8"),否则POST中文参数会乱码。
- 确保前端表单的
- 页面跳转或数据传递失败:
forward(转发)和sendRedirect(重定向)要分清。转发是服务器内部跳转,地址栏不变,请求域(request)内的数据可以传递;重定向是客户端二次请求,地址栏变化,请求域数据丢失,但可以通过Session传递。- 需要传递对象到JSP时,使用
request.setAttribute(“key”, obj),然后在JSP中用EL表达式${key}获取。
5.3 数据库操作相关问题
- SQL异常:仔细阅读异常堆栈信息,通常能定位到出错的SQL语句。
- 将程序运行时生成的SQL语句打印到控制台(可以在DAO层日志中输出
PreparedStatement的SQL和参数),然后拿到数据库客户端里直接执行,看是否报错。 - 检查SQL语法、表名、字段名是否正确,特别是大小写(Linux下MySQL区分大小写)。
- 将程序运行时生成的SQL语句打印到控制台(可以在DAO层日志中输出
- 事务不生效:在手动管理JDBC事务时,必须确保多个DAO操作使用的是同一个
Connection对象,并且要设置conn.setAutoCommit(false),最后根据执行情况调用commit()或rollback()。一个常见的错误是在每个DAO方法内部都获取和关闭连接,这样无法形成事务。 - 连接泄漏:确保所有
Connection、Statement、ResultSet都在finally块中关闭,或使用try-with-resources。长时间运行后系统变慢,可能就是因为连接未关闭。
调试这类项目,最有效的工具就是日志和断点调试。在关键业务逻辑处打上日志,输出参数、中间结果和异常信息。使用IDE的调试功能,一步步跟踪代码执行流程,观察变量值的变化,是理解程序运行逻辑和定位Bug的终极武器。
最后,我想说,这个“图书管理系统”的课程设计,其价值远超代码本身。它是一张地图,指引你遍历了Java Web开发的主要地标。不要满足于让它跑起来,要带着问题去读代码:这里为什么这么设计?如果让我来写,会有什么不同?有没有潜在的性能或安全问题?尝试去修复一个Bug,增加一个小功能,甚至用新的技术栈重写它。这个过程,才是你从“学生作业完成者”迈向“项目开发者”的真正一步。
本文还有配套的精品资源,点击获取