简介:本资源是一套完整的Java Web聊天系统课程大作业实现,面向高校计算机专业学生及Java Web初学者,解决Web实时通信项目开发与分层架构实践的学习需求。项目采用Spring Boot + Vue前后端分离架构,严格遵循MVC分层规范:controller封装API接口,service提供业务逻辑,dao基于JPA操作数据库,entity映射表结构,dto/vo实现数据传输与视图隔离,config、utils、processor等包各司其职,便于理解企业级工程组织方式。压缩包共138个文件,含66个Java后端类、12个Vue组件、11个SCSS样式文件、9个Markdown文档说明及配套图片、配置与字体资源,整体仅2.08MB,轻量易导入。已有1152人学习下载,附带详细项目总文档(.docx)与多份CSS/HTML前端资源,结构清晰、注释完整,适合用于课程设计参考、分模块调试及分层开发思维训练。
1. 这不是又一个“Hello World”聊天页:Java Web大作业聊天系统,是能跑通登录、消息实时推送、多用户在线状态的最小可运行闭环
你手头这份“Java Web大作业 聊天系统”,不是那种只在 index.html 里用 localStorage 模拟发消息的前端幻灯片。它是一套结构清晰、分层明确、能真正在 Tomcat 上跑起来、后端用 JPA 操作数据库、前端用纯 HTML/CSS/JS(无 Vue/React)完成基础交互的真实 Java Web 工程——核心价值在于:它把 Servlet + JSP(或纯 Servlet)+ JPA + Filter + Listener 的经典 Java Web 技术栈,压缩在一个可编译、可部署、可调试的最小闭环里。适合刚学完《Java Web 编程技术》课程、正卡在“理论懂了但不知道代码往哪放”的同学;也适合面试前想快速复现一个带状态管理、有 DAO 层抽象、能体现分层思想的实战项目的人。它不追求 WebSocket 高并发或 Redis 消息队列,但把 session 管理、消息存库、在线用户统计、请求过滤这些企业级 Web 开发中天天打交道的“脏活”都落到了实处。文档(项目总文档.docx)里甚至写了每个包的作用和调用链路图——这不是玩具,是能让你在答辩时指着代码说“这里 Controller 接口接收参数,校验后交给 Service 处理业务逻辑,再通过 DAO 持久化到 entity 映射的表里”的底气来源。
2. 从源码结构到运行环境:5 分钟搭起本地开发闭环,看清每个包的真实职责
2.1 目录结构即设计哲学:为什么 config/controller/dao/dto/entity/processor/service/utils/vo 必须这样分?
这个项目目录不是 IDE 自动生成的摆设,而是对 Java Web 分层架构的具象实践。我们逐个拆解其不可替代性:
config:存放WebConfig.java或SpringContextConfig.java(若用 Spring)——但本项目更可能是原生 Servlet 架构,所以这里大概率是DBConfig.java(封装 JDBC 连接池初始化)、InitServlet.java(实现ServletContextListener,应用启动时加载全局配置)。它的存在意义是:把硬编码的数据库地址、驱动类名、连接数等参数抽离出来,避免散落在 DAO 层各处,方便测试环境切换。controller:所有*.java文件以XXXServlet结尾(如LoginServlet.java,ChatServlet.java)。它们只做三件事:① 解析 HTTP 请求参数(request.getParameter());② 调用 Service 层方法;③ 设置响应内容类型(response.setContentType("text/html;charset=UTF-8"))并跳转页面或输出 JSON。绝不处理业务逻辑,也不直接操作数据库——这是分层的第一道铁律。dao:核心是UserDao.java和MessageDao.java,内部使用EntityManager(JPA)或Connection.prepareStatement()(JDBC)执行 CRUD。关键点在于:DAO 只返回 entity 对象或 List ,不返回 Map 或 Object[],保证数据形态纯净。比如findOnlineUsers()返回List<UserEntity>,而非List<Map<String, Object>>。dto:例如UserLoginDTO.java,只包含username和password字段。它和entity的区别在于:DTO 是 Controller 接收参数的“入口契约”,字段少、无业务逻辑、无 JPA 注解;而 entity 是数据库映射的“实体契约”,带@Entity,@Id,@Column。这种分离防止前端传入恶意字段(如is_admin=1)直接污染 entity。entity:UserEntity.java和MessageEntity.java。必须有@Entity,@Table(name="user"),@Id @GeneratedValue——这是 JPA 能自动生成建表 SQL 的前提。注意:MessageEntity中的senderId和receiverId应为Long类型,对应数据库BIGINT,若写成int会导致 MySQL 插入超长 ID 时静默截断(血泪经验)。processor:这里是项目的“神经中枢”。LoginFilter.java拦截未登录请求(检查HttpSession.getAttribute("user") != null);OnlineUserListener.java实现HttpSessionListener,在sessionCreated()时将用户加入ConcurrentHashMap<String, UserEntity>在线列表,在sessionDestroyed()时移除。Listener 不是装饰器,它是 Tomcat 容器生命周期事件的监听者,必须在web.xml中注册<listener><listener-class>xxx.OnlineUserListener</listener-class></listener>才生效。service:UserService.java和ChatService.java。接口定义行为(UserService.login(String, String)),实现类UserServiceImpl.java注入UserDao并调用其方法。Service 层是事务边界:所有涉及多表更新的操作(如“登录成功则更新 last_login_time 并插入一条在线记录”)必须在此层用@Transactional(Spring)或手动conn.setAutoCommit(false)控制。utils:DateUtils.java(格式化时间)、MD5Utils.java(密码加密)——工具类必须是public static方法,且不依赖任何外部上下文(如 ServletContext)。若出现new SimpleDateFormat("yyyy-MM-dd"),必须加synchronized或改用DateTimeFormatter,否则高并发下会线程不安全。vo:ChatMessageVO.java,字段为senderName,content,sendTime(格式化后的字符串)。VO 是 Controller 返回给前端的“出口契约”,它把 entity 中的senderId转为senderName,把timestamp转为易读字符串,彻底隔离数据库结构与前端展示。
提示:
demo.css/main.css/index.css是前端样式文件,iconfont.css+.eot是字体图标资源,chat2.html是主聊天界面——它们共同构成“静态资源层”,与 Java 后端完全解耦。Tomcat 默认将/static或/css下的文件直接映射为 HTTP 资源,无需 Servlet 处理。
2.2 本地运行四步法:不装 Maven?用 mvnw.cmd 照样编译部署
本项目自带mvnw.cmd(Windows)和mvnw(Mac/Linux),这是 Maven Wrapper,无需提前安装 Maven,只要 JDK 8+ 就能一键构建。以下是 Windows 下完整流程(Mac/Linux 替换mvnw.cmd为./mvnw):
# 步骤1:确认 JDK 版本(必须 8 或 11,JDK 17 会因 Servlet API 版本不兼容报错) java -version # 输出应为 java version "1.8.0_361" 或 "11.0.20" # 步骤2:进入项目根目录(含 pom.xml 的目录),执行编译打包 mvnw.cmd clean package -Dmaven.test.skip=true # 步骤3:检查 target/ 目录生成的 war 包(如 chat-system-1.0.war) dir target\*.war # 步骤4:部署到本地 Tomcat(假设 Tomcat 安装在 D:\tomcat) copy target\chat-system-1.0.war D:\tomcat\webapps\ # 启动 Tomcat(双击 bin\startup.bat 或命令行执行) D:\tomcat\bin\startup.bat编译成功标志:BUILD SUCCESS且target/下生成.war文件;部署成功标志:访问http://localhost:8080/chat-system-1.0/跳转到index.html(或login.jsp),无 404 错误。关键参数说明:
-Dmaven.test.skip=true:跳过单元测试(项目未提供 test 目录,跳过避免编译失败);clean package:先清空target/再重新编译打包,防止旧 class 文件残留;.war文件名由pom.xml中<finalName>标签决定,若未设置则默认为${artifactId}-${version}。
若遇到java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet,说明 JDK 版本过高(如 JDK 17)或 Tomcat 版本过低(如 Tomcat 7)。解决方案:降级 JDK 到 11,或升级 Tomcat 到 10+(需同步修改pom.xml中 servlet-api 依赖为jakarta.servlet-api)。
2.3 数据库初始化:三步搞定 H2 内存库或 MySQL 持久化
项目使用 JPA,但未明确指定数据库。根据pom.xml依赖(需自行检查)和常见教学实践,大概率采用H2 内存数据库(开发调试) + MySQL(部署演示)双模式。配置文件通常在src/main/resources/application.properties或config/DBConfig.java中:
# application.properties 示例(H2 模式) spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driver-class-name=org.h2.Driver spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.h2.console.enabled=true spring.h2.console.path=/h2-console启用 H2 控制台后,启动项目访问http://localhost:8080/h2-console,填入:
- JDBC URL:
jdbc:h2:mem:testdb - Username:
sa - Password: (留空)
点击 Connect 即可看到自动生成的user和message表。但注意:H2 内存库重启即丢失数据,仅用于验证逻辑;若需持久化,必须切换到 MySQL:
# application.properties(MySQL 模式) spring.datasource.url=jdbc:mysql://localhost:3306/chatdb?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update执行前需手动创建数据库:
CREATE DATABASE chatdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;ddl-auto=update会让 JPA 根据 entity 注解自动建表或添加字段,但不会删除废弃字段——若 entity 中删了nickName字段,数据库表里该列仍存在,可能引发后续插入异常。
3. 消息流转全链路解析:从用户输入到浏览器刷新,看透一次聊天请求的 7 个关键节点
3.1 前端触发:chat2.html 中的 form 提交与 AJAX 分界线
chat2.html是核心交互页面,其表单提交方式决定了后端处理逻辑:
<!-- 方式1:传统 form 提交(页面跳转,适合初学者理解流程) --> <form action="/chat/send" method="post"> <input type="text" name="content" placeholder="输入消息..." required> <button type="submit">发送</button> </form>此时ChatServlet.doPost()接收请求,处理消息后response.sendRedirect("/chat2.html")重定向回页面——缺点是每次发送都刷新整个页面,体验生硬。
<!-- 方式2:AJAX 提交(推荐,实现局部刷新) --> <form id="chatForm"> <input type="text" id="msgInput" placeholder="输入消息..." required> <button type="button" onclick="sendMessage()">发送</button> </form> <div id="chatHistory"></div> <script> function sendMessage() { const content = document.getElementById('msgInput').value.trim(); if (!content) return; fetch('/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'content=' + encodeURIComponent(content) }) .then(r => r.text()) .then(data => { document.getElementById('chatHistory').innerHTML += '<div class="msg">' + data + '</div>'; document.getElementById('msgInput').value = ''; }); } </script>关键点:fetch发送的是application/x-www-form-urlencoded,后端ChatServlet必须用request.getParameter("content")获取,而非getReader().readLine()(那是application/json的解析方式)。若前端误设headers: {'Content-Type': 'application/json'},后端需用request.getReader()读取流并new JSONObject(...)解析,否则getParameter()返回 null。
3.2 后端路由:web.xml 中的 servlet-mapping 如何决定 URL 映射
web.xml是 Servlet 时代的配置中心,本项目必然存在。关键片段如下:
<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>controller.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <servlet> <servlet-name>ChatServlet</servlet-name> <servlet-class>controller.ChatServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>ChatServlet</servlet-name> <url-pattern>/chat/*</url-pattern> </servlet-mapping>URL 匹配规则:
- 访问
/login→ 触发LoginServlet.doPost()(表单提交)或doGet()(GET 请求); - 访问
/chat/send→ChatServlet的doPost()方法(因为url-pattern是/chat/*,/chat/send匹配成功); - 访问
/chat/history→ 同样触发ChatServlet,但需在doGet()中判断request.getRequestURI()后缀来区分动作。
注意:
<url-pattern>/chat/*</url-pattern>中的*是通配符,不是正则。/chat/send和/chat/receive都会映射到同一个 Servlet,业务逻辑必须在 Servlet 内部用request.getServletPath()或request.getPathInfo()区分子路径。
3.3 消息持久化:JPA Entity 的 save() 调用时机与事务边界
ChatService.sendMessage()是消息落地的核心:
@Service public class ChatServiceImpl implements ChatService { @Autowired private MessageDao messageDao; @Autowired private UserService userService; @Override @Transactional // 关键!确保数据库操作原子性 public void sendMessage(Long senderId, Long receiverId, String content) { // 1. 构建 MessageEntity MessageEntity message = new MessageEntity(); message.setSenderId(senderId); message.setReceiverId(receiverId); message.setContent(content); message.setSendTime(new Date()); // 2. 保存到数据库 messageDao.save(message); // JPA save() 方法 // 3. 更新接收方未读消息数(可选) userService.incrementUnreadCount(receiverId); } }为什么必须加@Transactional?
假设messageDao.save()成功,但userService.incrementUnreadCount()因网络问题抛出异常,若无事务控制,消息已入库但未读数未更新,导致数据不一致。加上@Transactional后,整个方法回滚,消息也不会存入数据库。
JPA save() 的底层行为:
- 若
MessageEntity.id == null→ 执行INSERT INTO message (...) VALUES (...); - 若
id有值 → 先SELECT查询是否存在,存在则UPDATE,不存在则INSERT(即merge语义)。
因此,前端传来的 JSON 中若包含id字段,且值为 0 或负数,JPA 会误判为“存在”,尝试UPDATE不存在的记录,抛出EmptyResultDataAccessException。
3.4 在线状态同步:Listener + Filter 如何协作维持用户心跳
在线用户列表存储在OnlineUserListener.java的静态ConcurrentHashMap中:
public class OnlineUserListener implements HttpSessionListener { public static final Map<String, UserEntity> ONLINE_USERS = new ConcurrentHashMap<>(); @Override public void sessionCreated(HttpSessionEvent se) { // session 创建时,不立即加入在线列表(此时用户未必已登录) } @Override public void sessionDestroyed(HttpSessionEvent se) { // session 销毁时,从 ONLINE_USERS 移除对应用户 String sessionId = se.getSession().getId(); ONLINE_USERS.values().removeIf(u -> u.getSessionId().equals(sessionId)); } }但sessionCreated()不是登录入口——真正的登录发生在LoginServlet中:
// LoginServlet.java HttpSession session = request.getSession(true); session.setAttribute("user", userEntity); session.setAttribute("sessionId", session.getId()); // 存入 entity OnlineUserListener.ONLINE_USERS.put(userEntity.getUsername(), userEntity);Filter 的作用是守门员:LoginFilter.java检查每个请求:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 放行登录、注册、静态资源 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/register") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(req, resp); return; } // 检查 session 中是否有 user HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.html"); return; } chain.doFilter(req, resp); }关键细节:request.getSession(false)中的false表示“不创建新 session”,避免未登录用户访问/chat2.html时被分配无效 session。只有登录成功后request.getSession(true)才创建有效 session。
4. 避坑指南:90% 的编译失败、404 和消息不显示,都源于这 5 个具体错误
4.1 现象:HTTP Status 404 – Not Found,访问/login报错
原因:web.xml中<servlet-mapping>的<url-pattern>与实际请求 URL 不匹配,或pom.xml中maven-war-plugin配置缺失导致 WAR 包未正确打包。
解决:
① 检查web.xml中<url-pattern>/login</url-pattern>是否存在,且<servlet-name>与<servlet>块中的名称一致;
② 查看target/chat-system-1.0.war解压后WEB-INF/web.xml是否包含该配置(用 7-Zip 打开 WAR 包验证);
③ 确认pom.xml有以下插件配置(缺一不可):
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.3.2</version> <configuration> <failOnMissingWebXml>false</failOnMissingWebXml> </configuration> </plugin>4.2 现象:java.lang.NullPointerException在messageDao.save()处崩溃
原因:MessageDao未被 Spring 容器管理(缺少@Repository注解),或ChatService中未用@Autowired注入,导致messageDao为 null。
解决:
①MessageDao.java类上添加@Repository;
②ChatService实现类上添加@Service,且字段注入写法为:
@Service public class ChatServiceImpl implements ChatService { @Autowired // 必须有此注解 private MessageDao messageDao; // ... }③ 若用原生 Servlet(非 Spring),则messageDao需在ChatServlet中手动new MessageDaoImpl(),但必须确保EntityManagerFactory已初始化(见config/DBConfig.java)。
4.3 现象:消息发送成功,但chat2.html页面不刷新,或历史消息重复叠加
原因:前端fetch成功回调中,document.getElementById('chatHistory').innerHTML += ...使用了+=,导致每次发送都追加而非追加新消息;或后端返回的data是 HTML 片段而非纯文本。
解决:
① 后端ChatServlet.doPost()必须设置响应类型并输出纯文本:
response.setContentType("text/plain;charset=UTF-8"); response.getWriter().write("【" + senderName + "】:" + content);② 前端 JS 中,innerHTML +=改为innerHTML = innerHTML + ...(效果相同),但更推荐用 DOM 操作避免 XSS:
const div = document.createElement('div'); div.className = 'msg'; div.textContent = '【' + senderName + '】:' + content; document.getElementById('chatHistory').appendChild(div);4.4 现象:H2 控制台能连,但user表为空,login总提示“用户不存在”
原因:spring.jpa.hibernate.ddl-auto=create会在每次启动时清空重建表,但初始数据(如管理员账号)未通过import.sql或@Sql注解插入。
解决:
① 在src/main/resources/下创建import.sql,内容为:
INSERT INTO user (username, password, nickname) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', '管理员');② 确保application.properties中开启导入:
spring.jpa.hibernate.ddl-auto=create spring.jpa.defer-datasource-initialization=true spring.sql.init.mode=always③ 密码21232f297a57a5a743894a0e4a801fc3是admin的 MD5 值,与LoginServlet中的加密逻辑一致。
4.5 现象:Chrome 控制台报错Failed to load resource: the server responded with a status of 405 ()
原因:前端用GET请求访问/chat/send,但后端ChatServlet只实现了doPost(),未覆盖doGet(),导致 Tomcat 返回 405 Method Not Allowed。
解决:
① 前端确保fetch方法为POST(见 3.1 节);
② 后端ChatServlet添加空doGet()防御:
@Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); }③ 或统一用service()方法处理所有请求:
@Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { if ("POST".equalsIgnoreCase(request.getMethod())) { doPost(request, response); } else { response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); } }5. 进阶技巧:用 Chrome DevTools 定位消息延迟、用日志分级排查 Filter 失效、用 Postman 验证 API 契约
5.1 消息延迟诊断:三步定位是前端渲染慢、网络传输慢还是后端处理慢
当用户抱怨“发消息要等 2 秒才显示”,不能只盯着 Java 代码。打开 Chrome DevTools(F12)→ Network 标签页,发送一条消息,观察:
| 请求 URL | Status | Time | Size | Initiator |
|---|---|---|---|---|
/chat/send | 200 | 1850ms | 24 B | sendMessage() |
- Time = 1850ms:总耗时。拆解为:
▪Stalled:DNS 查询或 TCP 队列等待(>100ms 说明浏览器并发连接数满);
▪DNS Lookup:域名解析时间(本地开发应为 0,若 >50ms 检查 hosts 文件);
▪Initial Connection:TCP 三次握手(>100ms 说明网络或服务器负载高);
▪Request Sent→Content Download:后端处理时间(即ChatServlet.doPost()执行时长)。
精准测量后端耗时:在ChatServlet.doPost()开头和结尾加日志:
long start = System.currentTimeMillis(); // ... 业务逻辑 long end = System.currentTimeMillis(); System.out.println("ChatServlet processing time: " + (end - start) + "ms");若日志显示processing time: 1500ms,而 Network 中Content Download仅 200ms,则问题在后端(如数据库慢查询);若日志显示50ms,Network 中却1800ms,则是网络或前端问题。
5.2 Filter 生效验证:用日志级别区分“全局拦截”与“路径放行”
LoginFilter是否对/css/main.css放行?最可靠的方法不是猜,而是加日志:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; String uri = request.getRequestURI(); // DEBUG 级别日志,只在开发环境输出 System.out.println("[DEBUG] Filter intercepting: " + uri); if (uri.contains("/login") || uri.contains("/register") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png")) { System.out.println("[INFO] Filter allowing static resource: " + uri); chain.doFilter(req, resp); return; } System.out.println("[INFO] Filter checking login for: " + uri); // ... 登录校验逻辑 }启动 Tomcat 后,控制台会打印:
[DEBUG] Filter intercepting: /chat2.html [INFO] Filter checking login for: /chat2.html [DEBUG] Filter intercepting: /css/main.css [INFO] Filter allowing static resource: /css/main.css若看不到[INFO] Filter allowing static resource,说明uri.endsWith(".css")判断失败——可能因为请求 URL 是/chat-system-1.0/css/main.css,endsWith()检查的是完整路径,应改为uri.contains("/css/")。
5.3 API 契约测试:用 Postman 绕过前端,直击 Controller 层
chat2.html的 UI 可能掩盖后端逻辑缺陷。用 Postman 测试/chat/send接口:
| Key | Value |
|---|---|
| Method | POST |
| URL | http://localhost:8080/chat-system-1.0/chat/send |
| Headers | Content-Type: application/x-www-form-urlencoded |
| Body | content=Hello%20World |
发送后,预期响应:【张三】:Hello World(纯文本)。若返回 404,检查 URL 中的上下文路径(chat-system-1.0)是否与 WAR 包名一致;若返回 500,查看 Tomcatlogs/catalina.out中的堆栈,定位到具体哪一行NullPointerException。
进阶测试边界值:
content=(空字符串)→ 应返回 400 Bad Request 或前端提示;content=<script>alert(1)</script>→ 后端应做 HTML 转义,返回【张三】:<script>alert(1)</script>,防止 XSS;- 并发发送 100 次 → 观察
OnlineUserListener.ONLINE_USERS.size()是否稳定,验证线程安全性。
5.4 文档驱动开发:用项目总文档.docx反向生成类图与序列图
项目总文档.docx不是摆设。我习惯用它做三件事:
①反向生成类图:打开文档,找到“模块划分说明”,按包名列出所有类,用 PlantUML 写:
@startuml package "controller" { [LoginServlet] [ChatServlet] } package "service" { [ChatService] [UserServiceImpl] } package "dao" { [MessageDao] [UserDao] } LoginServlet --> ChatService : calls ChatService --> MessageDao : uses @enduml②绘制关键序列图:针对“用户登录”场景,画出login.html→LoginServlet→UserService→UserDao→DB的调用链,标注每步的输入/输出参数;
③核对 DTO/VO 字段:文档中若写“登录返回 VO 包含 username 和 token”,则检查LoginServlet中是否真的response.getWriter().write("{\"username\":\""+user.getUsername()+"\",\"token\":\""+token+"\"}");——很多翻车源于文档写的是 JSON,代码输出的是 HTML。
从那以后我每次拿到新项目,第一件事就是打开文档,用记事本快速敲出包结构树,再对照源码逐行确认。不是信文档,而是用文档当索引,逼自己看清每一行代码在架构中的坐标。希望帮到你。
本文还有配套的精品资源,点击获取