1. 这不是“又一个登录Demo”,而是Web与移动端通信的最小可行骨架
你可能在某次技术分享里听过这句话:“注册登录功能,是所有应用的‘Hello World’”。但真正动手搭过Servlet+安卓+MySQL三端联动的人会立刻笑出声——这根本不是一句轻飘飘的比喻,而是一道横跨网络协议、线程安全、数据校验、异常传播和移动端适配的真实分水岭。我第一次用这套组合实现用户登录时,在模拟器上点了十几次“登录”按钮才看到成功提示,中间经历了Tomcat控制台疯狂刷屏的NullPointerException、安卓端NetworkOnMainThreadException的红色报错、MySQL中文乱码导致密码比对永远失败……这些不是教科书里的“注意事项”,而是真实踩出来的坑。它之所以重要,是因为它强制你直面三个世界交汇处的摩擦:后端Servlet处理HTTP请求的生命周期管理、安卓端异步网络调用与UI线程的隔离机制、MySQL字符集与连接参数对数据完整性的底层约束。这套组合不追求高并发或微服务架构,但它像一把解剖刀,能清晰切开B/S与C/S混合架构中数据流动的每一层肌理。适合刚学完Java Web基础、正准备接触Android开发的开发者,也适合需要快速验证业务逻辑原型的产品同学——它不依赖任何云服务或第三方SDK,所有组件都可本地部署、全程可控。关键词就藏在这条链路的每个关节里:Servlet生命周期、安卓AsyncTask/OkHttp线程模型、MySQL utf8mb4字符集配置、JSON数据格式边界、密码明文传输风险意识。这不是一个“做完就扔”的练习,而是一套可延展的基座:今天跑通注册登录,明天就能接入短信验证码、OAuth2授权、或把用户数据同步到Redis缓存。下面,我们就从最原始的web.xml配置开始,一砖一瓦垒起这个看似简单、实则精密的通信骨架。
2. Servlet端:别只写doPost(),先搞懂容器如何把URL映射到你的Java类
很多人写Servlet,习惯性地在doPost()方法里堆砌全部逻辑:接收参数、查数据库、拼JSON、写响应。这就像在厨房里把菜刀、砧板、锅铲全塞进一个抽屉——能用,但每次取用都得翻半天。真正的关键,其实在doPost()被调用之前:Tomcat如何把http://localhost:8080/app/login这个URL,精准地路由到你写的LoginServlet.class?答案就藏在web.xml的<servlet-mapping>配置里,而它的设计逻辑,直接决定了你后续代码的可维护性。
我们先看一个典型的错误配置:
<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.example.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping>这段代码本身没错,但它把“路由规则”和“业务逻辑”完全耦合。当项目需要支持/api/v1/login和/api/v2/login两个版本时,你不得不复制整个Servlet类,或者在doPost()里加一堆if-else判断版本号。更合理的做法,是让Servlet只做一件事:解析HTTP请求,转换为领域对象,再委托给Service层处理。这就引出了标准的三层结构雏形:
2.1 Servlet层:纯粹的HTTP协议适配器
LoginServlet.java的核心职责,应严格限定在“协议转换”:
public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); // 依赖注入的简化版 @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 统一设置响应编码,避免中文乱码(关键!) resp.setContentType("application/json;charset=UTF-8"); resp.setCharacterEncoding("UTF-8"); // 2. 从请求体读取JSON(非表单提交!) StringBuilder jsonBuffer = new StringBuilder(); String line; try (BufferedReader reader = req.getReader()) { while ((line = reader.readLine()) != null) { jsonBuffer.append(line); } } // 3. 解析JSON为LoginRequest对象(使用Jackson或Gson) ObjectMapper mapper = new ObjectMapper(); LoginRequest request = mapper.readValue(jsonBuffer.toString(), LoginRequest.class); // 4. 调用Service层,获取结果 Result result = userService.login(request.getUsername(), request.getPassword()); // 5. 将Result序列化为JSON并写入响应 mapper.writeValue(resp.getWriter(), result); } }这里有几个必须深挖的细节:
- 为什么用
req.getReader()而不是req.getParameter()?因为安卓端通常用OkHttpClient发送JSON Body,而非HTML表单的application/x-www-form-urlencoded格式。getParameter()只能解析URL参数或表单数据,对纯JSON Body完全无效。这是90%初学者卡住的第一个点。 resp.setContentType("application/json;charset=UTF-8")为何必须在getWriter()之前调用?Servlet规范规定,一旦调用getWriter()或getOutputStream(),响应头就已锁定。如果先调用getWriter()再设charset,浏览器仍会按ISO-8859-1解析中文,导致JSON中的"msg":"登录成功"变成"msg":"鐿诲綍鎴愬姛"。这个顺序错误,会让前端调试陷入“明明后端返回了中文,页面却显示乱码”的死循环。ObjectMapper实例是否该声明为static?答案是肯定的。ObjectMapper是线程安全的,且创建开销大。将其作为Servlet的成员变量(非static)会导致每个请求都新建一个实例,浪费内存;而声明为static,则全局共享一个高效实例。这是Tomcat高并发场景下的基本性能常识。
2.2 Service层:业务逻辑的纯净沙盒
UserService接口定义了“登录”这一行为的契约:
public interface UserService { Result login(String username, String password); }其实现类UserServiceImpl则专注数据访问:
public class UserServiceImpl implements UserService { private final UserDAO userDAO = new UserDAOImpl(); @Override public Result login(String username, String password) { // 1. 基础校验(空值、长度) if (username == null || username.trim().isEmpty() || password == null || password.length() < 6) { return Result.fail("用户名或密码格式错误"); } // 2. 查询用户(注意:密码不能明文存储!此处仅为演示流程) User user = userDAO.findByUsername(username); if (user == null) { return Result.fail("用户不存在"); } // 3. 密码比对(实际项目必须用BCrypt加密) if (!password.equals(user.getPassword())) { return Result.fail("密码错误"); } // 4. 登录成功,生成Token(简化版,无JWT) String token = UUID.randomUUID().toString().replace("-", ""); user.setToken(token); // 存入数据库或Redis userDAO.updateToken(user.getId(), token); return Result.success("登录成功", user); } }这里的关键经验是:Service层必须对输入做防御性校验。很多开发者把校验全丢给前端,认为“后端只是执行者”。但HTTP请求可被任意工具(如curl、Postman)绕过前端直接发起。一个username="admin' OR '1'='1"的SQL注入攻击,如果没有后端校验,会直接穿透到DAO层。因此,username.trim().isEmpty()和password.length() < 6不是可有可无的代码,而是第一道安全防线。
2.3 DAO层:与MySQL对话的翻译官
UserDAOImpl负责将Java对象映射为SQL操作:
public class UserDAOImpl implements UserDAO { private static final String URL = "jdbc:mysql://localhost:3306/myapp?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; @Override public User findByUsername(String username) { String sql = "SELECT id, username, password, token FROM users WHERE username = ?"; try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setToken(rs.getString("token")); return user; } } } catch (SQLException e) { e.printStackTrace(); // 实际项目应记录日志,而非打印堆栈 } return null; } }这个URL字符串里的参数,每一个都直指MySQL集成的痛点:
useUnicode=true&characterEncoding=utf8mb4:强制JDBC驱动使用utf8mb4字符集,否则存储emoji或生僻汉字时会报错Incorrect string value。serverTimezone=Asia/Shanghai:解决MySQL服务器时区与JVM时区不一致导致的java.sql.SQLException: The server time zone value 'XXX' is unrecognized异常。这是本地开发环境最常见的报错之一,根源在于MySQL 8.0+默认时区为SYSTEM,而JVM时区可能是UTC。
提示:不要在DAO层硬编码数据库密码!生产环境必须使用JNDI数据源或Spring Boot的
application.properties进行外部化配置。此处硬编码仅为演示最小可行性。
3. 安卓端:AsyncTask已淘汰,但它的线程模型思想永不过时
2023年,AsyncTask已被官方标记为@Deprecated,但它的核心思想——将耗时操作移出主线程,避免ANR(Application Not Responding)——依然是安卓网络编程的铁律。很多新手直接在onClick()里写new URL(...).openConnection(),结果点击登录按钮后,整个App卡死5秒,系统弹出“应用无响应,是否关闭?”的对话框。这不是代码bug,而是对安卓线程模型的根本性误解。
3.1 OkHttp:现代安卓网络请求的基石
我们选用OkHttp作为网络客户端,它比原生HttpURLConnection更简洁、更健壮。首先在app/build.gradle中添加依赖:
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' // 用于调试关键不是“怎么发请求”,而是“请求发出去后,如何安全地更新UI”。OkHttp本身是纯异步的,它不提供回调到主线程的魔法。你需要手动切换线程。以下是LoginActivity.java中登录按钮的点击逻辑:
private void performLogin() { String username = etUsername.getText().toString().trim(); String password = etPassword.getText().toString().trim(); // 1. 构建JSON请求体 JSONObject jsonBody = new JSONObject(); try { jsonBody.put("username", username); jsonBody.put("password", password); } catch (JSONException e) { showError("JSON构建失败: " + e.getMessage()); return; } // 2. 创建OkHttp请求 RequestBody body = RequestBody.create( jsonBody.toString(), MediaType.get("application/json; charset=utf-8") ); Request request = new Request.Builder() .url("http://10.0.2.2:8080/app/login") // 注意:安卓模拟器访问本机用10.0.2.2 .post(body) .build(); // 3. 执行异步请求 OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { // 失败回调在子线程,必须切回主线程更新UI runOnUiThread(() -> showError("网络错误: " + e.getMessage())); } @Override public void onResponse(Call call, Response response) throws IOException { // 成功回调也在子线程! String responseBody = response.body().string(); // 解析JSON,并切回主线程更新UI runOnUiThread(() -> handleLoginResponse(responseBody)); } }); }这里有两个极易忽略的细节:
runOnUiThread()是强制要求,不是可选项。onFailure()和onResponse()的回调都在OkHttp的后台线程池中执行。如果你在onResponse()里直接调用Toast.makeText(...).show()或tvResult.setText(...),会抛出android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.。这个异常的名字已经说得很清楚:只有创建View的线程(即主线程)才能操作View。http://10.0.2.2:8080这个地址是模拟器专用。很多新手把localhost或127.0.0.1写进安卓代码,结果请求永远超时。因为localhost在安卓模拟器内部指向模拟器自己,而非你的开发机。10.0.2.2是Android模拟器预设的、指向宿主机(即你电脑)的特殊IP。真机调试时,则需将10.0.2.2换成你电脑在局域网内的真实IP(如192.168.1.100),并确保防火墙放行8080端口。
3.2 JSON解析:Gson的TypeToken与泛型擦除陷阱
后端返回的JSON通常是这样的结构:
{ "code": 200, "msg": "登录成功", "data": { "id": 123, "username": "testuser", "token": "a1b2c3d4..." } }用Gson解析时,新手常犯的错误是:
// ❌ 错误:无法正确解析嵌套的data字段 Result result = gson.fromJson(jsonString, Result.class);因为Result类中的data字段是Object类型,Gson无法知道它具体是User类。正确的做法是使用TypeToken:
// ✅ 正确:明确指定data字段的类型 Type type = new TypeToken<Result<User>>(){}.getType(); Result<User> result = gson.fromJson(jsonString, type);TypeToken的原理,是利用Java泛型的“类型擦除”特性制造的一个“类型占位符”。在编译期,Result<User>会被擦除为Result,但TypeToken通过匿名内部类的getClass().getGenericSuperclass(),在运行时捕获了完整的泛型信息。这是Gson处理复杂嵌套JSON的必备技巧。
3.3 UI反馈:不只是“登录成功”,更是用户体验的闭环
一个专业的登录流程,UI反馈必须覆盖所有分支:
private void handleLoginResponse(String json) { try { Type type = new TypeToken<Result<User>>(){}.getType(); Result<User> result = gson.fromJson(json, type); if (result.getCode() == 200) { // 登录成功:保存Token到SharedPreferences,跳转主界面 SharedPreferences prefs = getSharedPreferences("user", MODE_PRIVATE); prefs.edit().putString("token", result.getData().getToken()).apply(); startActivity(new Intent(this, MainActivity.class)); finish(); // 关闭登录页,防止用户返回 } else { // 业务失败:显示后端返回的msg showError(result.getMsg()); } } catch (JsonParseException e) { // JSON解析失败:可能是后端返回了HTML错误页(如404) showError("响应格式错误,请检查网络或重启App"); } } private void showError(String message) { Toast.makeText(this, "❌ " + message, Toast.LENGTH_LONG).show(); // 同时在界面上高亮错误区域 tvResult.setText("错误: " + message); tvResult.setTextColor(Color.RED); }这里的经验是:永远假设后端返回的数据是不可信的。JsonParseException的捕获,就是为了应对后端因异常而返回<html><body>HTTP Status 404</body></html>这种非JSON内容。如果不捕获,App会直接Crash。而finish()调用,则是防止用户点击返回键回到登录页,造成Token重复使用或状态混乱。
4. MySQL端:字符集、时区与密码安全的三重门
很多开发者把MySQL当成一个“黑盒子”,只要CREATE TABLE能执行,就认为配置完成了。但在Servlet+安卓的链路中,MySQL的配置错误,往往表现为最诡异的现象:后端日志显示“查询成功”,安卓端却收到空数据;或者密码明明正确,SELECT却查不到记录。这些问题的根因,几乎都指向三个配置项:字符集、时区、密码认证插件。
4.1 字符集:utf8mb4才是真正的“万国码”
MySQL的utf8其实是“假utf8”。它最多只支持3字节的Unicode字符,而emoji和许多中文生僻字(如“𠮷”)需要4字节。这就是为什么你存入😊后,数据库里变成?。解决方案是全面升级到utf8mb4:
步骤1:修改MySQL配置文件(my.cnf或my.ini)
[client] default-character-set = utf8mb4 [mysql] default-character-set = utf8mb4 [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init_connect='SET NAMES utf8mb4' skip-character-set-client-handshake = false步骤2:重启MySQL服务
# Linux sudo systemctl restart mysql # Windows net stop mysql && net start mysql步骤3:修改现有数据库和表的字符集
-- 修改数据库 ALTER DATABASE myapp CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; -- 修改表 ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 验证 SHOW VARIABLES LIKE 'character_set%'; SHOW VARIABLES LIKE 'collation%';最关键的验证命令是SHOW VARIABLES LIKE 'character_set_client',它必须显示utf8mb4。如果仍是utf8,说明init_connect配置未生效,需检查配置文件路径是否正确。
4.2 时区:Asia/Shanghai不是可选项,而是必需项
MySQL的NOW()函数返回的时间,取决于其time_zone系统变量。如果它和你的Java应用时区不一致,就会出现“用户下午3点注册,数据库里却显示下午11点”的荒诞场景。在my.cnf中添加:
[mysqld] default-time-zone = '+08:00'然后在MySQL命令行中执行:
SET GLOBAL time_zone = '+08:00'; SELECT NOW(); -- 应返回当前北京时间同时,在JDBC URL中显式指定serverTimezone=Asia/Shanghai,形成双重保险。这是跨时区应用的黄金准则。
4.3 密码安全:告别明文存储,拥抱BCrypt
在UserDAOImpl的示例代码中,我们用了password.equals(user.getPassword()),这仅用于演示流程。任何生产环境,都必须对密码进行不可逆哈希。推荐使用BCrypt,它内置盐值(salt)和计算成本(cost factor),能有效抵御彩虹表和暴力破解。
在UserService.login()中,替换密码比对逻辑:
// 引入BCrypt库:implementation 'org.springframework.security:spring-security-crypto:5.8.5' import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // cost factor=12 // 登录时验证 if (!encoder.matches(password, user.getPassword())) { return Result.fail("密码错误"); } // 注册时加密(在RegisterServlet中) String encryptedPassword = encoder.encode(rawPassword); user.setPassword(encryptedPassword); userDAO.save(user);cost factor=12意味着BCrypt会进行2^12=4096次哈希迭代。这个值需要权衡:值越大越安全,但登录验证耗时越长。12是目前兼顾安全与性能的推荐值。BCrypt生成的密码字符串形如$2a$12$XkKvQZzY...,前缀$2a$表示算法版本,12是cost factor,后面是盐值和哈希值。每次对同一密码加密,结果都不同,这正是盐值的作用。
注意:不要用MD5或SHA-256!它们是通用哈希算法,没有内置盐值,且计算速度极快,容易被GPU集群暴力破解。BCrypt是专为密码设计的慢哈希算法。
5. 全链路联调:从“404 Not Found”到“Token已刷新”的完整排查路径
当Servlet、安卓、MySQL三端代码都写完,启动Tomcat、运行App,却只看到404 Not Found或java.net.ConnectException时,不要急于改代码。一个成熟的开发者,会遵循一套标准化的排查路径,像医生问诊一样,逐层定位问题。
5.1 第一层:网络连通性(物理层)
这是最容易被忽视,却最基础的一环。打开安卓模拟器的浏览器,输入http://10.0.2.2:8080/app/login,看是否能访问到Tomcat的欢迎页。如果打不开:
- 检查Tomcat是否真的在运行?
ps aux | grep tomcat(Linux/Mac)或任务管理器(Windows)。 - 检查Tomcat端口是否被占用?
netstat -ano | findstr :8080(Windows)。 - 检查防火墙是否阻止了8080端口?临时关闭防火墙测试。
- 检查
web.xml中<url-pattern>是否与安卓请求的URL完全一致(注意大小写和斜杠)。
5.2 第二层:Servlet映射与请求方法(应用层)
如果浏览器能访问Tomcat首页,但/app/login返回404,问题一定出在Servlet配置。此时,打开Tomcat的logs/catalina.out日志,搜索Deploying web application,确认你的应用是否被成功加载。如果没看到,说明web.xml有语法错误,或WEB-INF目录结构不对(必须是WEB-INF/web.xml和WEB-INF/classes/xxx.class)。
如果应用已加载,但请求仍404,检查LoginServlet的@Override注解是否正确:
// ❌ 错误:方法名拼写错误,导致重载而非重写 protected void dopost(HttpServletRequest req, HttpServletResponse resp) { ... } // ✅ 正确:必须是doPost,首字母大写 protected void doPost(HttpServletRequest req, HttpServletResponse resp) { ... }一个字母的大小写错误,就会让Tomcat找不到处理方法,直接返回404。
5.3 第三层:JSON数据流(表示层)
当网络和Servlet都正常,安卓端却收不到预期JSON,或收到{"code":500,"msg":"Internal Server Error"},就要检查数据流。此时,启用OkHttp的HttpLoggingInterceptor,它会在Logcat中打印完整的请求和响应:
--> POST http://10.0.2.2:8080/app/login {"username":"test","password":"123456"} --> END POST <-- 200 http://10.0.2.2:8080/app/login (123ms) {"code":500,"msg":"java.lang.NullPointerException"} <-- END HTTP看到NullPointerException,说明LoginServlet.doPost()中某行代码引用了null对象。结合Tomcat日志中的完整堆栈,定位到具体行号。常见原因是ObjectMapper未初始化,或userService为null(忘记new实例)。
5.4 第四层:MySQL数据一致性(数据层)
当后端日志显示“查询成功”,但安卓端data字段为空,问题必在数据库。此时,直接在MySQL命令行中执行相同SQL:
SELECT * FROM users WHERE username = 'testuser';如果返回空,说明数据根本没插入,或username字段有前后空格。在RegisterServlet中,务必对输入做trim():
String username = request.getParameter("username").trim(); // 关键!如果SQL能查到数据,但安卓端还是空,检查User类的字段名是否与数据库列名完全一致(Java是userName,数据库是username,Gson默认按字段名匹配,不一致则为null)。解决方案是用@JsonProperty注解:
public class User { private Long id; @JsonProperty("username") private String userName; @JsonProperty("password") private String password; // ... }5.5 第五层:Token状态管理(业务层)
登录成功后,安卓端能拿到Token,但下次请求(如获取用户信息)却提示“Token无效”。这通常是因为Token未持久化,或过期策略不合理。在UserServiceImpl.login()中,我们只是简单地user.setToken(...)并updateToken(...),但没有设置过期时间。一个健壮的方案是:
// 生成带过期时间的Token String token = Jwts.builder() .setSubject(username) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) // 24小时 .signWith(SignatureAlgorithm.HS256, "mySecretKey") .compact();但这需要引入jjwt-api和jjwt-impl库。对于最小可行项目,一个更轻量的方案是:在User表中增加token_expired_at字段,每次登录时更新它,并在后续接口中校验token_expired_at > NOW()。
最后一个实战心得:在
LoginServlet的doPost()开头,加一行日志System.out.println("Login request received: " + jsonBuffer.toString());。这行看似简单的输出,能在90%的联调场景中,让你一眼看清“问题到底出在请求端还是响应端”。它比任何断点调试都来得直接。
6. 从“能跑通”到“可交付”:安全加固与可观测性补丁
当Login和Register按钮终于变成绿色,并弹出“登录成功”的Toast时,项目只完成了50%。剩下的50%,是让这个Demo具备生产环境的雏形。这不需要你立刻上Kubernetes或Prometheus,而是几个关键的“补丁”,能让代码质量产生质的飞跃。
6.1 输入校验:从“能用”到“防攻击”的跨越
目前的校验只做了空值和长度检查。一个可交付的系统,必须防范OWASP Top 10中的常见攻击:
- SQL注入:虽然我们用了
PreparedStatement,但若在LIKE查询中拼接%,仍可能被利用。WHERE username LIKE ?是安全的,但WHERE username LIKE '%'+?+'%'是危险的。 - XSS(跨站脚本):如果用户注册时输入
<script>alert('xss')</script>作为用户名,而后端未经转义就存入数据库,再在个人中心页面直接渲染,就会触发XSS。解决方案是在存储前进行HTML转义:String safeUsername = StringEscapeUtils.escapeHtml4(username);(需引入commons-text库)。 - 暴力破解:没有登录失败次数限制,攻击者可用脚本无限尝试密码。一个轻量方案是:在
UserService.login()中,记录用户最近5次失败时间,若15分钟内失败超过5次,则拒绝登录并返回Result.fail("请15分钟后重试")。
6.2 日志体系:让问题“自己说话”
目前的e.printStackTrace()只在控制台输出,无法追溯历史。一个最小化的日志方案是:
- 替换所有
System.out.println()为Logger.getLogger(LoginServlet.class.getName()).info("..."); - 使用
java.util.logging(JDK自带)或更强大的logback。 - 将日志输出到文件,而非控制台,便于问题复盘。
6.3 错误处理:从“崩溃”到“优雅降级”
当前代码中,任何SQLException都会导致Result.fail("数据库错误")。但用户不需要知道是“连接超时”还是“主键冲突”。更好的方式是:
} catch (SQLException e) { if (e.getSQLState().equals("08S01")) { // 连接异常 return Result.fail("服务暂时不可用,请稍后再试"); } else if (e.getSQLState().equals("23000")) { // 主键冲突 return Result.fail("用户名已被注册"); } else { logger.severe("Unexpected SQL error", e); return Result.fail("系统繁忙"); } }SQLState是SQL标准定义的5位错误码,比e.getMessage()更稳定、更易分类。
6.4 可观测性:一个健康检查端点
最后,为你的Servlet加一个/health端点,返回{"status":"UP","timestamp":1234567890}。这不仅是给运维看的,更是给自己一个快速确认“后端是否活着”的入口。在安卓端,可以在App启动时静默调用它,如果失败,就提示用户“服务器连接异常”。
这套补丁,不增加新功能,却让整个系统从“玩具”蜕变为“产品”。它教会你的,不是某个API怎么用,而是一种工程思维:每一个用户看不见的角落,都值得用代码去守护。当你把LoginServlet里的e.printStackTrace()替换成结构化日志,当你在UserDAO里为每种SQL异常写上专属提示,当你在安卓端为每一次网络请求加上超时和重试——你就已经走出了新手村,站在了专业开发者的起跑线上。这个Servlet+安卓+MySQL的注册登录,从来就不是一个终点,而是你亲手锻造的第一把瑞士军刀。