☰
JSP+Servlet网盘系统实战:从建表到文件上传的完整指南
2026/10/1 13:29:36 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计、毕业设计开发者的网盘系统实战源码,采用JSP+Servlet技术栈,后端以MySQL存储数据,覆盖用户管理、文件上传下载、关注关系与后台管理等典型网盘业务场景,适合用来理解传统MVC分层开发与过滤器权限控制的实现思路。压缩包共123个文件,约2.28MB,其中22个jsp页面承担视图展示,20个java源文件与20个class文件对应Servlet、DAO及实体类等核心逻辑,另有css、js、图片与字体资源负责前端样式,properties、xml等配置文件用于环境与数据库连接设置,并附带jar依赖便于直接部署。目前已有116人学习下载。项目源码经过本地编译验证,按文档配置环境即可运行,难度适中,读者可据此掌握请求分发、登录过滤、文件读写与数据库交互的完整链路,也可作为毕设或课程作业的参考方案,遇到问题可向作者咨询。

1. JSP+Servlet 网盘系统:从建表到文件上传,这套老技术栈还能怎么打

很多人一听 JSP+Servlet 就皱眉,觉得这是十几年前的老古董,现在都 Spring Boot 微服务了谁还碰这个。但如果你去翻一翻高校的 JavaWeb 课程设计、头歌实训答案、基于 JSP 的毕设选题,会发现这套组合依然是绝对主力。原因很现实:它把 HTTP 请求响应、Session 管理、数据库连接这些底层机制暴露得足够清楚,不像 Spring 那样一层注解就把所有细节吞掉了。你要做一个网盘系统,核心需求无非是用户注册登录、文件上传下载、文件列表展示、文件夹管理这几件事,用 JSP+Servlet+MySQL 完全撑得住。这篇文章不讲空泛的架构演进,就围绕一个能跑起来的网盘系统,把表结构设计、Servlet 请求分发、文件上传落盘、JSP 页面渲染、连接池配置这些环节一步步拆开。适合正在做课程设计的学生,也适合想回头补一补 JavaWeb 基本功的开发者。你跟着走完,至少能拿到一个结构清晰、能部署到 Tomcat 的 WAR 包。

2. 网盘系统的数据库设计与 MySQL 环境准备

2.1 三张核心表撑起用户、文件与目录关系

网盘系统的数据模型不复杂,但设计时如果不把目录层级和文件归属想清楚,后面写 SQL 会非常别扭。我一般用三张表:用户表存账号密码和配额,文件表存文件元信息(文件名、大小、类型、存储路径、所属用户、所属目录),目录表存文件夹层级关系。这里有个关键决策:文件和目录是分开两张表还是一张表用 type 字段区分?分开写查询更直观,但做「列出当前目录下所有内容」时需要 UNION;合在一起写递归查询方便,但字段会有冗余。我倾向于分开,因为网盘系统的目录深度一般不会太深,UNION 的性能损耗可以接受,代码可读性更好。

先看建表语句,注意字符集用 utf8mb4,否则中文文件名会出问题:

CREATE DATABASE IF NOT EXISTS netdisk DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE netdisk; -- 用户表 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(128) NOT NULL, -- 存 SHA-256 哈希,不存明文 `quota` BIGINT DEFAULT 1073741824, -- 默认配额 1GB,单位字节 `used` BIGINT DEFAULT 0, -- 已用空间 `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- 目录表 CREATE TABLE `directory` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, `parent_id` INT DEFAULT 0, -- 0 表示根目录 `user_id` INT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ) ENGINE=InnoDB; -- 文件表 CREATE TABLE `file` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, `size` BIGINT NOT NULL, `type` VARCHAR(100), `store_path` VARCHAR(500) NOT NULL, -- 磁盘上的实际存储路径,用 UUID 重命名 `dir_id` INT DEFAULT 0, -- 所属目录,0 表示根目录 `user_id` INT NOT NULL, `upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ) ENGINE=InnoDB;

这里有几个参数需要解释。quota默认值设成 1073741824 也就是 1GB,你可以根据实际磁盘空间调整。store_path存的是磁盘上的真实路径,我习惯用 UUID 重命名文件避免同名覆盖,原始文件名存在name字段里。dir_id和parent_id都用 0 表示根目录,这样查询根目录内容时直接WHERE dir_id = 0就行,不用额外处理 NULL。外键约束加上去能防止脏数据,但如果你的 MySQL 版本或者存储引擎不支持,去掉也不影响功能。

2.2 MySQL 8 在 Windows 和 Linux 上的安装要点

MySQL 安装本身不难,但有几个坑几乎每个人都会踩一次。Windows 上推荐用 MySQL Installer 装 8.0 版本,安装过程中会让你设 root 密码,设完记牢,后面 JDBC 连接要用。安装完成后检查服务是否启动:services.msc里找 MySQL80,或者命令行net start mysql80。Linux 上用apt install mysql-server或者 rpm 包都行,CentOS 系注意先yum remove mariadb-libs避免冲突。

安装完第一件事是建一个专用账号给应用连接,不要直接用 root:

CREATE USER 'netdisk'@'localhost' IDENTIFIED BY 'YourStrongPass123!'; GRANT ALL PRIVILEGES ON netdisk.* TO 'netdisk'@'localhost'; FLUSH PRIVILEGES;

如果你遇到ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock',说明 MySQL 服务没起来或者 socket 路径不对,先systemctl status mysql看状态。Windows 上如果 JDBC 连接报Public Key Retrieval is not allowed,在连接串后面加allowPublicKeyRetrieval=true&useSSL=false。MySQL 8 默认用 caching_sha2_password 认证插件,老版本 JDBC 驱动可能不兼容,要么升级驱动到 8.0 以上,要么把用户认证方式改成 mysql_native_password。

2.3 JDBC 连接池:为什么不能每次请求都 DriverManager.getConnection

新手最容易犯的错误是在每个 Servlet 的 doGet/doPost 里写DriverManager.getConnection(),请求量一上来数据库连接数直接爆炸。连接池的作用是预先创建一批连接放在池子里,用的时候借,用完还,避免频繁创建销毁的开销。Tomcat 自带的 DBCP 或者 Druid 都行,我一般用 Druid,监控功能好用。

在 Maven 的 pom.xml 里加依赖:

<dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

然后在src/main/resources下建druid.properties:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/netdisk?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=netdisk password=YourStrongPass123! initialSize=5 maxActive=20 maxWait=3000

写一个工具类加载配置:

public class DBUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new RuntimeException("连接池初始化失败", e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

initialSize=5表示启动时创建 5 个连接,maxActive=20是最大连接数,maxWait=3000是借连接超时时间(毫秒)。这几个值根据你的并发量调,课程设计场景 5 到 10 足够了。注意serverTimezone必须设,否则 MySQL 8 的时区问题会让时间字段差 8 小时。

3. Servlet 请求分发与文件上传落盘

3.1 用注解映射 URL:@WebServlet 的路径规划

Servlet 3.0 之后可以用@WebServlet注解代替 web.xml 配置,代码更干净。网盘系统的 URL 我一般这样规划:/user/*走用户相关操作(注册、登录、登出),/file/*走文件操作(上传、下载、删除、列表),/dir/*走目录操作。每个 Servlet 内部用request.getPathInfo()或者参数区分具体动作。

@WebServlet("/file/*") public class FileServlet extends HttpServlet { private FileService fileService = new FileService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getPathInfo(); // 拿到 /list、/download 等 if ("/list".equals(action)) { listFiles(req, resp); } else if ("/download".equals(action)) { download(req, resp); } else { resp.sendError(404); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getPathInfo(); if ("/upload".equals(action)) { upload(req, resp); } else if ("/delete".equals(action)) { delete(req, resp); } } }

这里getPathInfo()返回的是 Servlet 映射路径之后的部分。比如请求/file/list,getPathInfo()返回/list。这种风格比用?action=list参数更 RESTful,也更好做权限拦截。注意doGet和doPost要分开处理,下载和列表用 GET,上传和删除用 POST,这是 HTTP 语义的基本要求。

3.2 文件上传:Part 接口与磁盘存储策略

Servlet 3.0 之后用request.getPart()拿上传文件,不需要额外引入 Commons FileUpload。但前提是 Servlet 上要加@MultipartConfig注解,否则getPart()会返回 null。

@WebServlet("/file/*") @MultipartConfig( fileSizeThreshold = 1024 * 1024 * 2, // 2MB 以上写入磁盘临时文件 maxFileSize = 1024 * 1024 * 100, // 单文件最大 100MB maxRequestSize = 1024 * 1024 * 200 // 整个请求最大 200MB ) public class FileServlet extends HttpServlet { // ... 其他代码 private void upload(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { User user = (User) req.getSession().getAttribute("user"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } Part part = req.getPart("file"); String originalName = part.getSubmittedFileName(); long size = part.getSize(); // 检查配额 if (user.getUsed() + size > user.getQuota()) { resp.getWriter().write("{\"code\":1,\"msg\":\"空间不足\"}"); return; } // UUID 重命名,按日期分目录存储 String uuid = UUID.randomUUID().toString(); String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String storePath = "D:/netdisk_data/" + datePath + "/" + uuid; File dest = new File(storePath); dest.getParentFile().mkdirs(); part.write(storePath); // 写数据库 int dirId = Integer.parseInt(req.getParameter("dirId")); fileService.save(new FileEntity(originalName, size, part.getContentType(), storePath, dirId, user.getId())); user.setUsed(user.getUsed() + size); resp.getWriter().write("{\"code\":0,\"msg\":\"上传成功\"}"); } }

fileSizeThreshold控制内存缓冲阈值,超过这个大小的文件会先写到磁盘临时文件再处理,避免大文件撑爆 JVM 内存。maxFileSize和maxRequestSize根据你的业务定,网盘系统一般单文件限制在 100MB 到 1GB 之间。存储路径用yyyy/MM/dd分目录是为了避免单个目录下文件过多导致文件系统性能下降,UUID 重命名是为了防止同名覆盖和路径穿越攻击。注意part.write()的路径必须是绝对路径,相对路径会相对于 Tomcat 的临时目录,容易找不到文件。

3.3 文件下载:Content-Disposition 与断点续传的取舍

下载的核心是设置响应头Content-Disposition: attachment; filename="xxx",告诉浏览器这是附件而不是直接打开。中文文件名需要 URL 编码,否则会乱码。

private void download(HttpServletRequest req, HttpServletResponse resp) throws IOException { int fileId = Integer.parseInt(req.getParameter("id")); FileEntity file = fileService.getById(fileId); if (file == null) { resp.sendError(404); return; } File diskFile = new File(file.getStorePath()); if (!diskFile.exists()) { resp.sendError(404); return; } String encodedName = URLEncoder.encode(file.getName(), "UTF-8") .replace("+", "%20"); resp.setContentType("application/octet-stream"); resp.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName); resp.setContentLengthLong(diskFile.length()); try (InputStream in = new FileInputStream(diskFile); OutputStream out = resp.getOutputStream()) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } }

filename*=UTF-8''是 RFC 5987 规定的编码方式,比直接filename=兼容性更好。缓冲区 8192 字节是经验值,太小频繁 IO,太大占内存。断点续传需要处理Range请求头,返回 206 状态码和Content-Range头,实现起来代码量翻倍。课程设计场景一般不做断点续传,但如果你要支持大文件下载,建议加上,否则用户下载到一半断网就得重来。

4. JSP 页面渲染与 Session 权限控制

4.1 用 JSTL 替代脚本片段:列表页的写法

JSP 里写<% %>脚本片段是新手最容易滥用的地方,页面里混着 Java 代码和 HTML,后期改起来想死。正确做法是用 JSTL 标签库加 EL 表达式。先在 pom.xml 加依赖:

<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

文件列表页list.jsp这样写:

<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head><title>我的网盘</title></head> <body> <h2>文件列表 - ${sessionScope.user.username}</h2> <table border="1"> <tr><th>文件名</th><th>大小</th><th>上传时间</th><th>操作</th></tr> <c:forEach items="${fileList}" var="f"> <tr> <td>${f.name}</td> <td><fmt:formatNumber value="${f.size / 1024}" maxFractionDigits="1"/> KB</td> <td><fmt:formatDate value="${f.uploadTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td> <a href="${pageContext.request.contextPath}/file/download?id=${f.id}">下载</a> <a href="javascript:del(${f.id})">删除</a> </td> </tr> </c:forEach> </table> </body> </html>

${sessionScope.user.username}直接从 Session 取当前用户,<c:forEach>遍历文件列表,<fmt:formatNumber>做大小单位换算。注意${pageContext.request.contextPath}拿的是应用上下文路径,部署到 Tomcat 后如果应用名不是 ROOT,这个前缀能保证链接正确。JSTL 的fmt标签库处理日期和数字格式化比手写 SimpleDateFormat 干净得多。

4.2 Session 拦截:用 Filter 统一做登录校验

每个 Servlet 里都写一遍if (session.getAttribute("user") == null)太啰嗦,用 Filter 统一拦截。建一个AuthFilter,拦截/file/*、/dir/*这些需要登录的路径:

@WebFilter(urlPatterns = {"/file/*", "/dir/*"}) public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

getSession(false)表示如果 Session 不存在就返回 null,不新建。如果用getSession()或者getSession(true),每个未登录请求都会创建一个新 Session,白白占内存。Filter 的urlPatterns里不要拦截/user/*,否则登录注册请求也会被拦下来,死循环。

4.3 登录注册:密码哈希与 SQL 注入防护

密码绝对不能明文存数据库。用 SHA-256 加盐哈希:

public static String hashPassword(String password, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(salt.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(password.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }

盐值每个用户随机生成,存在用户表里(加个salt字段)。登录时用同样的盐哈希后比对。SQL 查询必须用 PreparedStatement,不要用字符串拼接:

String sql = "SELECT * FROM user WHERE username = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ResultSet rs = ps.executeQuery();

?占位符会自动转义特殊字符,防止' OR '1'='1这种注入。这是底线,没有商量余地。

5. 部署与常见问题排查

5.1 打包 WAR 部署到 Tomcat 的完整流程

项目开发完要打包成 WAR 部署到 Tomcat。Maven 的pom.xml里<packaging>设成war,然后mvn clean package,target 目录下会生成netdisk.war。把 WAR 丢到 Tomcat 的webapps目录下,启动 Tomcat 会自动解压部署。访问地址是http://localhost:8080/netdisk/。

如果你用 IDEA,配置 Tomcat Server 时注意 Application context 设成/netdisk,否则路径对不上。部署前检查WEB-INF/lib下有没有 MySQL 驱动和 Druid 的 jar,Maven 依赖 scope 设成provided的(比如 servlet-api)不会打进去,这是对的,因为 Tomcat 自带。但 MySQL 驱动和 Druid 必须是compilescope,否则运行时报 ClassNotFoundException。

5.2 文件上传大小限制:三层配置都要改

文件上传报FileSizeLimitExceededException或者上传大文件直接 400,通常是三层限制没对齐。第一层是@MultipartConfig的maxFileSize,第二层是 Tomcat 的server.xml里 Connector 的maxPostSize(默认 2MB),第三层是 Nginx 反代时的client_max_body_size。三层都要改,只改一层没用。

Tomcat 的server.xml里:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxPostSize="209715200" />

maxPostSize单位是字节,209715200 就是 200MB。注意 Tomcat 8 以后maxPostSize只对application/x-www-form-urlencoded生效,multipart 请求不受这个限制,但设大一点没坏处。Nginx 那边在http或server块里加client_max_body_size 200m;。

5.3 中文乱码:请求、响应、数据库三处都要设 UTF-8

中文乱码是 JavaWeb 的经典玄学问题,表现是上传的文件名变成问号,或者页面显示乱码。根因是字符编码在请求解析、响应输出、数据库存储三个环节不一致。请求端在 Filter 里统一设request.setCharacterEncoding("UTF-8"),响应端设response.setContentType("text/html;charset=UTF-8"),数据库连接串加characterEncoding=utf8,建表时用utf8mb4。四处都对齐了就不会乱码。

Tomcat 8 以后 GET 请求的默认编码已经是 UTF-8,但 POST 请求体还是 ISO-8859-1,所以 Filter 里那行setCharacterEncoding不能省。如果你用@WebFilter注解,注意 Filter 的执行顺序,编码 Filter 要放在权限 Filter 前面。

6. 从能跑到好用:几个让网盘系统更稳的技巧

课程设计做完能跑就行,但如果你想让这个系统真正能用,有几个地方值得再花点时间。第一个是文件秒传,上传前先算文件的 MD5,查数据库里有没有相同 MD5 的文件,有的话直接复制一条记录指向同一个存储路径,不用重复上传。这个功能对网盘系统来说体验提升巨大,实现也不复杂:

public String md5(InputStream in) throws Exception { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { md.update(buf, 0, len); } StringBuilder sb = new StringBuilder(); for (byte b : md.digest()) { sb.append(String.format("%02x", b)); } return sb.toString(); }

上传时先读一遍算 MD5,再读一遍写磁盘,大文件会多一次 IO。优化做法是边读边写边算,用DigestInputStream包装。数据库文件表加个md5字段,建索引,秒传判断就是一条SELECT的事。

第二个是文件删除的软删除策略。直接DELETE FROM file加diskFile.delete()有风险,万一用户误删就找不回来了。我一般加个status字段,删除时只改状态不删磁盘文件,后台跑个定时任务清理超过 30 天的软删除文件。这样用户有后悔药吃,磁盘空间也不会无限膨胀。

第三个是下载限速。网盘系统如果不限速,一个用户开多线程下载能把带宽跑满。用Thread.sleep()做简单限速:

int bytesPerSec = 1024 * 1024; // 1MB/s long startTime = System.currentTimeMillis(); long totalWritten = 0; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); totalWritten += len; long expectedTime = totalWritten * 1000 / bytesPerSec; long actualTime = System.currentTimeMillis() - startTime; if (expectedTime > actualTime) { Thread.sleep(expectedTime - actualTime); } }

这个限速逻辑简单粗暴但有效,缺点是占用线程。生产环境一般用 Nginx 的limit_rate或者令牌桶算法,但课程设计场景够用了。

最后一个建议是日志。Servlet 里用System.out.println打日志在 Tomcat 控制台能看到,但部署到服务器后日志文件会越来越大。换成 Logback 或者 Log4j2,配置按天滚动,保留 7 天。出问题时看日志比猜快得多,这是我踩了无数次坑之后养成的习惯。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询