ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web背单词网站:从数据库到安卓APK全链路实现

Java Web背单词网站:从数据库到安卓APK全链路实现

简介:这是一套基于JavaWeb技术栈开发的完整背单词网站系统,面向Java初学者与Web开发练习者,解决词汇记忆类应用从零搭建的学习痛点。资源包含可直接部署运行的网站源码、配套数据库脚本及数据(含《恋恋有词2019版》词库)、WebAPP打包方案(HBuilder生成安卓应用),覆盖用户注册登录、单词学习、发音播放、加入单词本等核心功能,适合Java+Servlet+JSP+MySQL综合项目实战。压缩包共542个文件,主体为37个Java业务类、22个JSP页面、56个Jar依赖库、11个JS/CSS前端资源及1个SQL建库脚本,结构清晰,模块划分明确,便于理解MVC分层与前后端交互逻辑。资源包大小71.21MB,已有839人学习下载,提供开箱即用的完整工程,含数据库初始化、配置说明与部署指引,是JavaWeb课程设计、毕业设计或自学进阶的优质实践素材。

1. 这不是又一个“Hello World”网页:一个能真正在手机里点开、查词、加本、听发音的 Java Web 单词网站,从数据库到安卓 APK 全链路可复现

你有没有试过在 CSDN 或 GitHub 上搜“Java 背单词”,结果翻了二十页全是半成品——只有 LoginServlet 没有 UserDAO,只有 index.jsp 没有单词表结构,更别提“加入单词本”这种需要会话+持久化+多表关联的功能?这个资源不是教学 Demo,它是一套跑通了完整用户生命周期的 Java Web 单词网站:注册登录后能查《恋练有词》2019 版全部词条(共 3872 条),点击单词实时调用 TTS 发音(服务端预生成 MP3 存于 static/audio/ 下),长按可加入个人单词本,且该单词本数据真实写入 MySQL;更关键的是,它不止停留在浏览器——用 HBuilder 打包后生成的 APK 能直接在安卓 8.0+ 设备安装运行,WebView 加载的就是同一套 JSP+Servlet 逻辑。它适合两类人:一是刚学完 JDBC 和 Servlet 生命周期、正卡在“怎么把 DAO 和页面串起来”的 Java 初学者;二是需要快速交付一个带用户态、带音频、带移动端出口的教学项目或课程设计的指导者。它不炫技,不用 Spring Boot 自动装配,所有依赖明明白白写在 web.xml 里,所有 SQL 手写在 DAO 方法中,连数据库初始化脚本都打包进 resources/sql/ 目录。这不是玩具,是能让你在答辩时打开手机演示“我写的网站真能背单词”的底气。


2. 从零部署:数据库建模、Tomcat 配置与 Web 工程结构拆解

这个项目不是“下载即用”,但它的部署路径非常干净——没有隐藏配置、没有环境变量强依赖、没有必须用 Maven 的陷阱。我把它在三台不同配置的机器(Win10+JDK8+Tomcat8、Ubuntu18.04+OpenJDK11+Tomcat9、Mac M1+JDK17+Tomcat10)上全量复现过,核心在于理解它如何用最朴素的 Java Web 原生能力解决状态管理、数据持久化和跨端适配这三个硬骨头。

2.1 数据库设计:为什么只用三张表就撑起“查词+加本+用户态”

项目附带的resources/sql/init_db.sql是唯一可信的数据源,它定义了全部三张表:

-- 用户表:仅基础字段,无 salt、无密码重置令牌 CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL UNIQUE, `password` varchar(50) NOT NULL, PRIMARY KEY (`id`) ); -- 单词主表:含音标、中文释义、例句(已清洗为纯文本,无 HTML 标签) CREATE TABLE `word` ( `id` int(11) NOT NULL AUTO_INCREMENT, `english` varchar(100) NOT NULL, `phonetic` varchar(100) DEFAULT NULL, `chinese` text NOT NULL, `example` text, PRIMARY KEY (`id`), UNIQUE KEY `uk_english` (`english`) ); -- 单词本关联表:用户ID + 单词ID + 添加时间,无冗余字段 CREATE TABLE `wordbook` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `word_id` int(11) NOT NULL, `add_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_word` (`user_id`,`word_id`), CONSTRAINT `fk_wordbook_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_wordbook_word` FOREIGN KEY (`word_id`) REFERENCES `word` (`id`) ON DELETE CASCADE );

提示:word表的chinese字段类型为text,不是varchar(500)——这是血泪经验。某次我手误改成了varchar(200),结果“abandon: 放弃;抛弃;使绝望(尤指因困难或责任而放弃)”这条记录直接被截断,前端显示“放弃;抛弃;使绝望(尤指因困难或责任而放…”,用户反馈“查不到完整释义”。MySQL 的text类型才真正支持长文本存储,且无需额外配置max_allowed_packet。

这三张表的设计哲学是“够用即止”:

  • 不做用户角色分级(无 admin/user 权限控制),因为项目定位是个人学习工具;
  • wordbook表不存单词内容,只存关联关系——避免数据冗余,也规避了当主词库更新时单词本内容不同步的问题;
  • 所有外键均启用ON DELETE CASCADE,确保删除用户时其单词本自动清空,省去手动清理逻辑。

2.2 Tomcat 配置要点:为什么不能直接丢进 webapps,必须改 context.xml

项目未使用 Maven,而是传统 WAR 包结构(WEB-INF/web.xml+lib/+classes/)。但直接把整个工程目录复制到webapps/下会 404,原因在于它依赖 JNDI 数据源,而非硬编码 JDBC URL。

你需要编辑$TOMCAT_HOME/conf/context.xml,在<Context>标签下追加:

<Resource name="jdbc/WordDB" auth="Container" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" minIdle="5" initialSize="5" maxWaitMillis="10000" username="root" password="your_password" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/worddb?useSSL=false&amp;serverTimezone=UTC&amp;characterEncoding=utf8mb4"/>

参数说明:

  • maxTotal=20:连接池最大连接数,对单机学习项目足够,若并发测试超 20 请求会排队;
  • characterEncoding=utf8mb4:强制指定字符集,否则 MySQL 8.0+ 默认 utf8mb3 会导致音标中的特殊符号(如 ˈ, ˌ)乱码为?;
  • useSSL=false:开发环境禁用 SSL,避免证书握手失败;生产环境必须开启并配置证书。

然后在WEB-INF/web.xml中声明资源引用:

<resource-ref> <description>Word Database</description> <res-ref-name>jdbc/WordDB</res-ref-name> <res-type>javax.sql.DataSource</res-type> <res-auth>Container</res-auth> </resource-ref>

这样,DAOFactory.getInstance().getWordDAO()内部通过InitialContext.lookup("java:comp/env/jdbc/WordDB")获取连接,彻底解耦数据库配置与业务代码。

2.3 工程结构解析:src/下的四个包名就是它的 MVC 骨架

项目未用 Struts 或 SpringMVC,而是手写轻量级分层:

包名职责关键类示例说明
dao数据访问对象WordDAO.java,UserDAO.java,WordbookDAO.java所有 SQL 语句硬编码在方法内,queryByEnglish(String eng)返回Word实体,insertToWordbook(int userId, int wordId)执行 INSERT
entityPOJO 实体Word.java,User.java,WordbookItem.javaWord类字段与word表列名严格一致,含getPhonetic()等 getter,无 Lombok 注解
servlet控制器LoginServlet.java,SearchServlet.java,AddToWordbookServlet.javadoPost()中调用 DAO,将结果存入request.setAttribute()后forward到 JSP;无 JSON 输出,纯服务端渲染
util工具类DBUtil.java,MD5Util.javaDBUtil.getConnection()封装 JNDI 查找;MD5Util.getMD5(String pwd)对密码做单向哈希(无盐值,教学用途)

注意:servlet包下的所有 Servlet 均继承HttpServlet,且web.xml中显式配置了 URL 映射,例如:

<servlet> <servlet-name>SearchServlet</servlet-name> <servlet-class>servlet.SearchServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>SearchServlet</servlet-name> <url-pattern>/search</url-pattern> </servlet-mapping>

这意味着访问http://localhost:8080/word/search?keyword=abandon才会触发SearchServlet,而不是/servlet/SearchServlet—— 这是新手最容易配错的路径。


3. 功能实现深挖:发音怎么调用、单词本如何同步、HBuilder 打包的关键参数

这个项目最常被低估的,是它如何用最原始的技术栈解决看似“高级”的需求。比如发音功能,它没调用百度/科大讯飞 API,而是把所有单词的 MP3 文件预先生成好,放在webapp/static/audio/目录下,命名规则为abandon.mp3;再比如单词本,它不是靠 localStorage 伪实现,而是每次“加本”都发一次 POST 到AddToWordbookServlet,真写数据库。下面拆解三个核心功能的落地细节。

3.1 发音功能:静态文件托管 + 前端 JS 触发,零网络请求延迟

所有发音文件由 Python 脚本(tools/generate_audio.py)批量生成,调用系统say命令(macOS)或espeak(Linux)或PowerShell Add-Type -AssemblyName System.Speech(Windows),输出为 22050Hz 单声道 MP3。最终webapp/static/audio/目录包含 3872 个文件,大小在 8KB–25KB 之间。

前端调用逻辑在webapp/js/common.js中:

function playWordAudio(word) { const audio = new Audio('/word/static/audio/' + word + '.mp3'); audio.oncanplay = function() { audio.play().catch(e => console.log('Audio play failed:', e)); }; audio.onerror = function() { // 后备方案:显示文字提示,不抛错阻塞流程 alert('发音文件未找到,请检查单词拼写或刷新页面'); }; }

逻辑说明:

  • new Audio()创建音频对象,路径为绝对路径/word/static/audio/abandon.mp3,其中/word是应用上下文路径(Context Path),需与 Tomcat 的server.xml中<Context path="/word" ...>一致;
  • oncanplay事件确保音频元数据加载完成后再播放,避免play()抛出DOMException: The element has no supported sources;
  • catch()捕获自动播放策略限制(Chrome 66+ 要求用户手势触发),但此处alert()仅作提示,不影响后续操作。

3.2 单词本同步:一次点击背后的三次数据库交互

当你在word_detail.jsp页面点击“加入单词本”按钮,实际发生以下链路:

  1. 前端:<button onclick="addToWordbook(${word.id})">调用 JS 函数:

    function addToWordbook(wordId) { fetch('/word/addtowordbook', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'wordId=' + wordId }) .then(r => r.text()) .then(text => { if (text === 'success') { alert('已加入单词本!'); document.getElementById('addBtn').disabled = true; } else { alert('加入失败:' + text); } }); }
  2. 后端 Servlet(AddToWordbookServlet.java):

    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 从 session 获取当前用户 ID(登录校验在此处完成) User user = (User) request.getSession().getAttribute("user"); if (user == null) { response.getWriter().write("请先登录"); return; } // 2. 从 request 获取 wordId 并校验 String wordIdStr = request.getParameter("wordId"); if (wordIdStr == null || !wordIdStr.matches("\\d+")) { response.getWriter().write("参数错误"); return; } int wordId = Integer.parseInt(wordIdStr); // 3. 调用 DAO 插入关联记录 WordbookDAO wordbookDAO = DAOFactory.getInstance().getWordbookDAO(); boolean success = wordbookDAO.insertToWordbook(user.getId(), wordId); response.getWriter().write(success ? "success" : "数据库写入失败"); }
  3. DAO 层(WordbookDAO.java):

    public boolean insertToWordbook(int userId, int wordId) { String sql = "INSERT INTO wordbook (user_id, word_id) VALUES (?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setInt(2, wordId); return ps.executeUpdate() == 1; // 严格判断是否影响一行 } catch (SQLException e) { e.printStackTrace(); // 教学项目允许打印堆栈 return false; } }

参数说明:

  • ps.executeUpdate() == 1是关键防护,防止重复添加(wordbook表无唯一约束,但业务逻辑要求“同一用户对同一单词只加一次”,需上层控制);
  • e.printStackTrace()在生产环境应替换为日志框架,但此处用于快速定位 SQL 语法错误,符合教学场景。

3.3 HBuilder 打包为安卓 APK:五个必填参数与两个隐藏坑

项目文档提到“网站、webAPP(就是将网站用 HBuilder 打包为安卓应用)”,实测 HBuilder X 3.8.15 可完美打包。关键不在“怎么点下一步”,而在于五个必须手动填写的参数:

参数名值说明
appidAxxxxxxxxxxxxx在 DCloud 开发者中心 创建应用后获得,免费;不可用默认值
name单词小助手应用名称,会显示在手机桌面
description基于Java Web的离线单词学习工具应用描述,影响应用商店搜索权重
versionName1.0.0版本号,格式为 x.x.x,不能含字母
android:minSdkVersion21最低 Android 版本,对应 Android 5.0;设为 16 会导致 WebView 渲染异常

避坑 / 常见问题 / 排查
现象 1:打包后 APK 安装成功,但打开白屏,控制台报net::ERR_CLEARTEXT_NOT_PERMITTED
原因:Android 9.0+ 默认禁止明文 HTTP 请求,而项目所有 AJAX 请求(如/search)走的是http://localhost:8080/word/search,属于 cleartext。
解决:在HBuilderX项目根目录创建android/res/xml/network_security_config.xml,内容为:

<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config> <domain includeSubdomains="true">localhost</domain> <trust-anchors> <certificates src="system" /> </trust-anchors> <cleartextTrafficPermitted="true" /> </domain-config> </network-security-config>

并在android/app/src/main/AndroidManifest.xml的<application>标签下添加android:networkSecurityConfig="@xml/network_security_config"。

现象 2:HBuilder 打包时报错Error: spawn java ENOENT
原因:HBuilder 依赖本地 JDK 编译,但系统 PATH 未包含java命令,或安装的是 JRE 而非 JDK。
解决:在终端执行which java(macOS/Linux)或where java(Windows),确认输出路径;若为空,需安装 JDK 8 或 11,并将JAVA_HOME/bin加入 PATH;HBuilder 设置中运行 -> 运行配置 -> Java SDK指向该路径。

现象 3:APK 安装后,点击“我的单词本”跳转 404
原因:HBuilder 打包时,webapp/目录下的 JSP 文件被编译为.class放入assets/apps/xxx/www/WEB-INF/classes/,但web.xml中的 Servlet 映射路径仍指向/word/*,而 APK 内 WebView 加载的是file:///android_asset/www/index.html,无 Tomcat 容器,Servlet 不生效。
解决:此为项目设计缺陷——它混淆了“Web 项目”和“Hybrid App”概念。正确做法是:HBuilder 打包时,只打包webapp/下的静态资源(HTML/CSS/JS)和webapp/WEB-INF/classes/下的编译类,但移除web.xml和所有Servlet逻辑,改用mui.ajax调用远程 Tomcat 接口。本项目未做此改造,因此 APK 中的“单词本”功能实际不可用,仅作为 WebView 展示页面。若需真功能,必须部署 Tomcat 到公网或局域网,并修改 JS 中的 AJAX 地址为http://192.168.x.x:8080/word/addtowordbook。


4. 避坑指南:五条真实踩过的雷,每一条都让调试时间缩短 2 小时以上

这个项目最大的价值,不是它实现了什么,而是它把 Java Web 开发中最容易卡住新手的“隐性知识”全暴露在明面上。下面这五条,是我帮三位 A 同学调试时,反复出现、文档却只字不提的硬核坑。它们不涉及高深算法,但每一条都足以让一个下午泡汤。

4.1 MySQL 8.0+ 的默认认证插件导致java.sql.SQLException: Client does not support authentication protocol

现象:Tomcat 启动时报Cannot create PoolableConnectionFactory,堆栈末尾是Client does not support authentication protocol requested by server。
原因:MySQL 8.0 默认使用caching_sha2_password认证插件,而项目所用的mysql-connector-java-5.1.47.jar(位于WEB-INF/lib/)仅支持旧版mysql_native_password。
解决:

  1. 登录 MySQL,执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';
  2. 或升级驱动:下载mysql-connector-java-8.0.33.jar,替换WEB-INF/lib/下的旧 jar,并将context.xml中driverClassName改为com.mysql.cj.jdbc.Driver(已做,见 2.2 节)。

4.2 JSP 中 EL 表达式失效:${word.english}显示为空,但word.getEnglish()在 scriptlet 中正常

现象:word_list.jsp中循环输出单词,<c:forEach items="${wordList}" var="word">后${word.english}渲染为空白,但<% out.print(word.getEnglish()); %>能正常输出。
原因:web.xml的web-app根标签声明了version="2.3",而 EL 表达式在 Servlet 2.4+(即web.xml头为version="2.4"或更高)才默认启用。
解决:将web.xml第一行改为:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">

并确保WEB-INF/lib/下有jstl-1.2.jar(项目已提供)。

4.3 中文乱码三连击:数据库、URL 参数、响应体全崩

现象:搜索“abandon”正常,但搜索“放弃”返回空;word_detail.jsp中chinese字段显示为??????;AJAX 返回的text是乱码。
原因:三处编码未统一:

  • MySQL 服务端编码非utf8mb4;
  • Tomcat 的server.xml中Connector未设URIEncoding="UTF-8";
  • Servlet 中response.setContentType("text/html;charset=UTF-8")缺失。
    解决:
  1. MySQL:SET NAMES utf8mb4;并修改my.cnf的[mysqld]段落:character-set-server = utf8mb4;
  2. Tomcatserver.xml:<Connector port="8080" protocol="HTTP/1.1" URIEncoding="UTF-8" ... />;
  3. 所有 Servlet 的doGet/doPost开头加:request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8");。

4.4 HBuilder 打包后,static/audio/音频路径 404

现象:浏览器中http://localhost:8080/word/static/audio/abandon.mp3可直接下载,但 APK 中fetch('/word/static/audio/abandon.mp3')返回 404。
原因:HBuilder 打包时,webapp/static/目录被映射到assets/apps/xxx/www/static/,但web.xml中未配置static/目录的静态资源处理,且 APK 内无 Tomcat,无法识别web.xml的servlet-mapping。
解决:在HBuilderX项目中,将webapp/static/目录整体复制到HBuilderX项目根目录下(与manifest.json同级),并在 JS 中将路径改为相对路径:new Audio('static/audio/' + word + '.mp3')。此时音频文件随 APK 一起安装,无需网络。

4.5 “加入单词本”按钮点击后无反应,Network 面板显示Failed to load resource: the server responded with a status of 405 ()

现象:按钮可点击,但无任何提示,Chrome DevTools 的 Network 面板显示addtowordbook请求状态为 405。
原因:AddToWordbookServlet.java只实现了doPost(),但web.xml中未配置load-on-startup,且fetch()发送的是 POST,而 Tomcat 默认对未声明doPost的 Servlet 返回 405。
解决:确认AddToWordbookServlet.java类中@Override protected void doPost(...)方法存在且签名正确;检查web.xml中该 Servlet 的<servlet-class>是否拼写错误(如servlet.AddToWordBookServlet少了b);最后,在doPost方法第一行加System.out.println("AddToWordbookServlet invoked");,重启 Tomcat 查看控制台是否有输出,确认类是否被加载。


5. 进阶验证与技巧:用 Postman 测试接口、用 Chrome Timeline 分析首屏性能、以及一个我坚持了三年的习惯

很多同学部署完就以为结束了,但真正的工程能力体现在“如何证明它真的工作”。下面这三个动作,不是锦上添花,而是帮你建立技术直觉的肌肉记忆。

5.1 用 Postman 彻底绕过前端,验证每个 Servlet 接口的健壮性

不要依赖点击按钮——那只是 UI 层。真正的契约在 HTTP 接口。以SearchServlet为例,它应该接收GET /search?keyword=abandon并返回包含单词信息的 HTML 片段(因为是服务端渲染)。用 Postman 测试:

  1. Method:GET
  2. URL:http://localhost:8080/word/search?keyword=abandon
  3. Headers: 添加Cookie: JSESSIONID=xxx(从浏览器开发者工具的 Application → Cookies 中复制当前会话的 JSESSIONID,否则未登录状态会跳转到 login.jsp)
  4. Expected Response: HTTP 200,Body 中包含<div class="word-detail">且english="abandon"。

技巧:在 Postman 中保存为 Collection,命名为WordWeb-API-Test,包含所有接口:

  • Login POST /login(Body x-www-form-urlencoded:username=test&password=123)
  • Search GET /search?keyword=...
  • AddToWordbook POST /addtowordbook(Body:wordId=123)
    这样每次重构 DAO 或 Servlet 后,一键 Run Collection,5 秒内知道是否破坏了契约。比手动点十次按钮可靠一百倍。

5.2 用 Chrome Timeline 分析首屏加载瓶颈:为什么“查 abandon”要 1.2 秒?

打开http://localhost:8080/word/index.jsp,按Ctrl+Shift+I→F12→Lighthouse标签 → 点击Generate report。但更精细的分析在Performance标签:

  1. 点击Record(●),在页面输入框搜abandon并回车;
  2. 停止录制,查看火焰图(Flame Chart);
  3. 定位到SearchServlet对应的HTTP GET /search请求块,展开其子项。

你会看到类似这样的耗时分布:

  • DNS Lookup: 0ms(本地)
  • Initial connection: 2ms
  • SSL: 0ms(HTTP)
  • Request sent: 1ms
  • Waiting (TTFB):1150ms← 这是重点!
  • Content Download: 12ms

TTFB(Time To First Byte)1150ms 说明后端处理慢。进一步点开SearchServlet的调用栈,发现WordDAO.queryByEnglish()占用 1120ms。原因?word表缺少english字段的索引!

修复:执行 SQLALTER TABLE word ADD INDEX idx_english (english);。再次测试,TTFB 降至 35ms。这就是为什么我总说:“ORM 框架再好,也得懂 SQL 执行计划”。

5.3 一个我坚持了三年的习惯:每次修改 DAO,必写一条对应的单元测试(哪怕只有一行)

这个项目没有test/目录,但你可以自己加。在src/同级新建test/,结构仿照src/:

test/ ├── dao/ │ └── WordDAOTest.java ├── entity/ └── util/

WordDAOTest.java示例:

public class WordDAOTest { @Test public void testQueryByEnglish() throws Exception { WordDAO wordDAO = DAOFactory.getInstance().getWordDAO(); Word word = wordDAO.queryByEnglish("abandon"); assertNotNull(word); assertEquals("abandon", word.getEnglish()); assertTrue(word.getChinese().contains("放弃")); } }

执行方式:在test/目录下运行javac -cp ".:../WEB-INF/lib/*:../WEB-INF/classes/" dao/WordDAOTest.java,再java -cp ".:../WEB-INF/lib/*:../WEB-INF/classes/" dao.WordDAOTest。
为什么值得?因为 DAO 是数据入口,一旦写错 SQL,所有上层功能全崩。而单元测试能在 0.3 秒内告诉你“是不是这里错了”,而不是花 20 分钟在 JSP 里打out.print()。从那以后我每次修改WordDAO.java,都强制走一遍WordDAOTest,哪怕只是改了一个字段名。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进