做后端开发的,几乎没人能绕过登录认证这道坎。之前带几个刚入行的朋友做项目,十有八九都卡在这一块:登录接口写好了,却发现别人不登录也能直接调其它接口拿数据;密码明文存数据库,看着都心惊;改个前端页面,后端一重启token就失效。这些坑我基本都踩过一遍,后来在做Tlias这个JavaWeb后台管理系统的时候,才把整套登录认证链路彻底捋顺了。今天就把这套后端实战里最核心的登录认证部分拆开讲清楚,从方案选型、JWT封装、MD5加盐、拦截器校验、ThreadLocal存取用户信息到问题排查,一条龙讲完,适合刚做完SSM或SpringBoot入门、想搞清楚“登录到底怎么落地”的朋友。
1. 登录认证在Tlias项目里的定位
1.1 先搞清楚后端项目里登录认证解决什么问题
站在使用者的角度,登录认证就是“输入用户名密码,进系统干活”。但站在后端的角度,登录认证真正要解决的是两个问题:
第一,确认“你是谁”,也就是身份认证。用户提交的用户名和密码,要在后端和数据库里存的数据比对,匹配上了才承认这个人的身份。
第二,确认“你配不配做这件事”,也就是权限控制。比如Tlias项目里,普通员工能看自己的考勤和薪资,但只有管理员能修改员工信息、调整部门。后台管理系统不比门户网站,很多接口涉及敏感数据,不可能让任何人随便调。
这两个问题在技术实现上是分开的:认证靠登录接口完成,权限控制靠拦截器加角色判断完成。但很多人写项目时只做了前者,忘了后者,接口裸奔,这就是典型的只做了“登录页面”没做“登录认证”。
Tlias这个项目里,登录认证的落地方式是:用户登录成功之后,后端签发一个带签名和过期时间的令牌(Token),前端每次请求都带上这个令牌,后端通过拦截器统一校验令牌是否有效、是否过期、属于哪个用户。整套方案属于无状态认证,服务端不存Session,扩展起来省心。
1.2 为什么选JWT而不是Session
不少初学者会问:明明Servlet那一套Session机制也能做登录,为什么非要用JWT?
Session机制的原理是:用户登录后,服务端把用户信息存在内存里,生成一个JSESSIONID给浏览器存Cookie,后续请求带上这个ID,服务端就能找到对应的Session数据。
听起来也不复杂,但它有两个绕不开的痛点:
- Session存在服务端内存中,用户量上来后,单机内存不够用;做集群部署时,用户的请求被负载均衡分发到另一台机器,Session就找不到了,还得专门做Session共享或粘滞会话。
- Session依赖Cookie传输,而浏览器对Cookie的跨域支持很麻烦。现在前段后端分离,前端页面可能跑在8080端口,后端接口在9090端口,跨域场景下Cookie处理起来非常头痛。
JWT的思路完全反过来:用户登录后,服务端把用户ID、角色等信息加密签成一个字符串发给前端,服务端自己不存任何状态。后续请求只要带这个字符串,服务端验签通过就认账。
用生活类比解释,Session机制像去健身房办月卡,前台电脑里记着你的名字和照片,每次去都要核查档案;JWT机制像拿了张带防伪钢印的VIP卡,门口保安不查档案,只验卡上的钢印和有效期。保安不占地方,卡片丢了也不影响别的会员。
对于Tlias这类中小型的后台管理系统,JWT的“服务端无状态”这个特性太合适了,部署简单、水平扩容无压力、前后端分离也顺畅。这也是我现在做JavaWeb项目默认选用JWT的原因。
2. 登录认证方案的关键设计拆解
2.1 登录认证核心链路
整个登录认证流程可以拆成五步,对应的代码模块也清清楚楚:
- 用户提交凭证:前端把用户名和密码POST给后端的登录接口。
- 后端验证密码:Service层根据用户名查数据库,拿到密码密文,比对结果。
- 签发Token:密码匹配后,后端把用户ID、用户名、角色等关键信息封装进JWT,定好过期时间,用密钥签名后返回给前端。
- 前端存储Token:前端把Token存在本地存储或内存中,每次请求在Header里带
token字段。 - 后端校验Token:拦截器拦截需要认证的请求,解析Token,验签名,查过期时间。通过就把用户信息放进ThreadLocal供后续逻辑使用,不通过直接返回401。
这个链路中有一个容易被忽略的点:登录接口本身不能被拦截器拦住。如果登录接口都被拦了,用户还没拿到Token就要求先提供Token,逻辑上就死循环了。所以拦截器一定要配置放行路径,白名单里至少要包含登录接口、静态资源等。
2.2 密码存储:MD5加密和加盐处理
聊完认证链路,接着说密码存储。我见过不少初学者在数据库里直接明文存密码,登录时直接查出来比对,这么做在生产环境等于把用户密码挂在墙上。
Tlias项目里用的是MD5加盐的方案。MD5是摘要算法,把一个任意长度的字符串转成固定128位二进制,通常以32位十六进制表示。它的特点是不可逆,理论上你无法从密文反推出原文。
但光用MD5是有问题的。现在网上有彩虹表,常见密码的MD5值都被算好了,你把123456的MD5存库里,攻击者一看密文就知道原文是啥。所以我常提醒一句:MD5必须加盐。
加盐的意思是在用户原始密码后面拼一段随机字符串,再对整个字符串做MD5。比如用户密码是abc123,盐值是Tlias2024,实际计算的是MD5("abc123Tlias2024")。盐值本身要存到数据库里,同一个用户每次校验时用同一个盐,不同用户的盐尽量不同。
这样做的意义是:就算两个用户密码相同,密文也不一样;就算攻击者拿到数据库,没有盐值也难破解。但要注意,MD5本身已经不够安全,现在更稳妥的推荐是BCrypt这类自适应哈希算法。如果是从零开始做新项目,建议直接用BCrypt。Tlias这个项目用MD5加盐是为了贴合教学场景、便于理解,生产环境我一般会升级为BCrypt。
2.3 JWT的结构和签名原理
JWT全称JSON Web Token,长这样:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiJ9.9nSY4WnO3qJxLqVlWnKQ8gX6YMeW0TkZbF6TUaFJhYc肉眼看着像一串乱码,其实由三部分组成,每部分用点号隔开:
- Header头:声明令牌类型和签名算法,通常是
{"alg":"HS256","typ":"JWT"}。这部分Base64编码,不加密。 - Payload载荷:存放实际传递的数据,比如用户ID、用户名、过期时间。这里也是Base64编码,不是加密,所以敏感信息别往JWT里塞。
- Signature签名:把前两段用Base64编码后的字符串拼接,加上密钥,再用Header里声明的算法(HS256)算出一个签名。签名的作用是防篡改:只要前两段内容被改动,签名对不上,服务端验签就会失败。
签名算法就是JWT安全性的根基。服务端有一个只有自己知道的密钥,签发Token时用密钥算签名,校验时再用同一个密钥算一遍签名,比对是否一致。密钥就像前面健身卡例子里的防伪钢印,只有官方有,别人伪造不出来。
常用的依赖是Java的jjwt库,API设计得很简洁,几行就能完成生成和解析。
3. 后端实战:从登录接口到拦截器
3.1 环境准备和依赖引入
我用的开发环境是Spring Boot 2.7版本,JDK 1.8。先引入必要依赖。
<!-- JWT工具库 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!-- SpringBoot Web框架 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Lombok,简化实体类代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>注意jjwt 0.9.1版本存在一个坑:它依赖javax.xml.bind的JAXB模块,在高版本JDK里默认被移除了,运行时会报ClassNotFoundException: javax/xml/bind/DatatypeConverter。JDK 8没问题,JDK 9以上就需要额外引入JAXB依赖,我后面会给出解决办法。
数据库表设计比较简单,用户表核心字段是id、username、password、salt。我在项目里还加了name、avatar这些基础字段,这里不再展开,重点看登录相关的。
3.2 创建统一返回结果类
在真实项目中,接口返回值不建议直接返回实体或Map,最好是统一封装。Tlias项目里用了Result这个类,结构很简单:
@Data public class Result { private Integer code; // 1代表成功,0代表失败 private String msg; // 提示信息 private Object data; // 返回的数据 public static Result success() { Result result = new Result(); result.setCode(1); return result; } public static Result success(Object data) { Result result = new Result(); result.setCode(1); result.setData(data); return result; } public static Result error(String msg) { Result result = new Result(); result.setCode(0); result.setMsg(msg); return result; } }统一返回结果类的价值在于:前端拿到响应后,先判断code是不是1,再决定下一步操作。这样整个项目的接口契约一致,前端不用每个接口单独写一套判断逻辑。登录接口返回的是Token字符串,就放在data字段里。
3.3 登录接口的Service和Controller
登录接口的Service层逻辑,要处理三类情况:
- 用户名不存在,直接提示“用户名或密码错误”。这里有个细节:我之前会分别提示“用户名不存在”和“密码错误”,后来被灌输了一个安全理念——不要暴露用户是否存在。攻击者可以用“用户名不存在”这个提示来遍历用户名,所以统一返回“用户名或密码错误”更安全。
- 密码校验失败,同样返回“用户名或密码错误”。
- 校验通过,签发Token,返回给前端。
@Service public class UserService { @Autowired private UserMapper userMapper; @Autowired private JwtUtils jwtUtils; public LoginInfo login(String username, String password) { // 1. 根据用户名查询用户 User user = userMapper.findByUsername(username); if (user == null) { throw new LoginException("用户名或密码错误"); } // 2. 密码加盐后做MD5,和数据库密文比对 String encryptedPassword = Md5Util.getEncryptedPwd(password, user.getSalt()); if (!encryptedPassword.equals(user.getPassword())) { throw new LoginException("用户名或密码错误"); } // 3. 签发JWT Map<String, Object> claims = new HashMap<>(); claims.put("id", user.getId()); claims.put("username", user.getUsername()); claims.put("role", user.getRole()); String token = jwtUtils.generateToken(claims); // 4. 返回Token和用户基本信息 LoginInfo loginInfo = new LoginInfo(); loginInfo.setToken(token); loginInfo.setUsername(user.getUsername()); loginInfo.setRole(user.getRole()); return loginInfo; } }Controller层就薄薄一层:
@RestController public class LoginController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest loginRequest) { LoginInfo loginInfo = userService.login(loginRequest.getUsername(), loginRequest.getPassword()); return Result.success(loginInfo); } }注意两个细节:
- 登录请求体最好单独建一个
LoginRequest类,不要直接拿User实体接收。因为User实体往往含有多余字段(比如salt),一旦被人利用,前端传进来的数据直接绑定到实体上,可能造成安全隐患。 - 密码比对不应该在前端做,也不应该裸传明文密码到日志中。如果在开发调试阶段需要打日志,严禁打印密码字段。
3.4 JWT工具类的封装
JWT工具类是整个认证模块的“地基”,我把生成和解析封装在两个静态方法里:
@Component public class JwtUtils { // 密钥:生产环境必须放到配置中心或环境变量,不能写死在代码里 private static final String SECRET_KEY = "tlias-secret-key-2024"; // 过期时间:2小时,单位毫秒 private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000; // 生成Token public static String generateToken(Map<String, Object> claims) { return Jwts.builder() .setClaims(claims) // 自定义数据 .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) // 过期时间 .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 签名算法和密钥 .compact(); } // 解析Token,返回Claims public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }密钥设置有一些讲究。项目代码里写一个固定密钥是为了演示方便,但生产环境绝对不行。密钥一旦泄露,任何人都能自己签发有效Token,整个认证体系就名存实亡了。正确做法是把密钥放到环境变量或配置中心,部署时注入,不同环境用不同密钥。
过期时间的长短也是个权衡。太短,用户频繁重新登录,体验差;太长,Token被盗后风险窗口变大。Tlias项目里设2小时,一般后台管理系统用2到4小时比较常见。有些系统还会做成双Token机制(短期Token + 长期Refresh Token),Tlias属于教材项目,单Token够用。
3.5 MD5加盐工具类
MD5加盐的实现思路是:用户注册或初始化密码时,生成一个随机盐值,用MD5(密码 + 盐值)得到密文存入数据库。校验时取出盐值,重新计算比对。
盐值生成我推荐用UUID然后截取一段,或者用SecureRandom生成随机字节数组转十六进制。UUID简单,但严格来说熵值够用,做项目没问题:
public class Md5Util { public static String getSalt() { String uuid = UUID.randomUUID().toString(); return uuid.replace("-", "").substring(0, 8); } public static String getEncryptedPwd(String password, String salt) { // 拼接:密码 + 盐值 String raw = password + salt; // 做MD5计算 return DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8)); } public static void main(String[] args) { String salt = getSalt(); String encrypted = getEncryptedPwd("123456", salt); System.out.println("盐值: " + salt); System.out.println("密文: " + encrypted); } }这里有一个值得说清楚的点:为什么是“密码 + 盐值”再加密,而不是先加密密码再加盐?原因很简单,加盐的主要目的是让彩虹表失效。如果先做MD5再把盐拼到密文后面,等于盐没有参与摘要计算,彩虹表照样能命中密码部分。必须把盐混进原始密码里,让摘要结果完全改变,才达到加盐的效果。
3.6 拦截器实现登录校验
有了Token生成,还不能算完成登录认证,因为后端还没有任何机制确保“不带Token、带错Token的请求被拦在外面”。这个活儿交给拦截器。
Spring MVC的拦截器是从Servlet Filter演化而来的,在这里用HandlerInterceptor接口实现:
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 放行OPTIONS预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 2. 从Header中获取Token String token = request.getHeader("token"); if (token == null || token.isEmpty()) { // 未登录,直接返回401,并写入提示信息 response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":0,\"msg\":\"未登录或登录已过期\"}"); return false; } // 3. 解析Token,验签、查过期 try { Claims claims = JwtUtils.parseToken(token); // 把当前用户ID保存到ThreadLocal ThreadLocalUtil.set("userId", claims.get("id")); ThreadLocalUtil.set("username", claims.get("username")); ThreadLocalUtil.set("role", claims.get("role")); return true; } catch (Exception e) { // Token被篡改或已过期 response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":0,\"msg\":\"未登录或登录已过期\"}"); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 防止内存泄漏,请求结束后必须清理ThreadLocal ThreadLocalUtil.remove(); } }这段代码里有几个容易忽视的细节:
第一,afterCompletion里必须调用ThreadLocalUtil.remove()。ThreadLocal在线程池场景下,线程是复用的,如果不清理,下个请求复用同一个线程时能读到上一个请求的数据,轻则数据串了,重则导致越权访问。这是我强烈建议所有人养成的习惯。
第二,解析Token时我没有把用户信息直接返回给前端,而是放进了ThreadLocal。好处是后续的Controller和Service代码里,不用每个方法都接收一遍用户ID参数,直接ThreadLocalUtil.get("userId")就能拿到当前登录用户,代码会干净很多。
第三,跨域场景下,前端自定义Header触发的是预检请求OPTIONS。预检请求不带业务Token,如果拦截器不放行它,前端就会报CORS错误,但后端日志里又看不到具体异常。我最初排查这个问题时浪费了好些时间,后来才知道要在拦截器里先放行OPTIONS。
3.7 注册拦截器和白名单配置
拦截器写好了,还要注册进Spring MVC配置里,指定哪些路径拦截、哪些路径放行。
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") // 拦截所有路径 .excludePathPatterns( "/login", // 登录接口放行 "/register", // 注册接口放行 "/captcha", // 图形验证码放行 "/error" // 错误页放行 ); } }白名单配置要精心设计。原则是:任何在用户拿到Token之前就必须调用的接口,都得放行。除了登录、注册、验证码,如果有图片资源、静态页面、前端框架需要的接口,也要逐项加到白名单里。
我之前见过一个案例,同事把/**全部拦截了,结果前端页面静态资源全部404,登录页都打不开。排查了半天才意识到,静态资源的请求也被拦截器拦了。这就是白名单设计不细致导致的连锁问题。
还有一个容易踩的坑:路径匹配规则。excludePathPatterns里写/login只放行精确路径,如果登录接口是/api/login,要写/api/login,否则拦截器照样拦。配置完之后,一定用接口测试工具实际走一遍登录流程,确认没有把自己拦在门外。
3.8 ThreadLocal的封装和使用
ThreadLocal在登录认证里扮演的角色,是“当前请求的上下文容器”。每个请求进来,拦截器把用户ID放进去;请求结束,清理掉。在同一个请求的处理过程中,任意层都能拿到当前用户信息。
我封装了一个简单的工具类:
public class ThreadLocalUtil { private static final ThreadLocal<Map<String, Object>> THREAD_LOCAL = new ThreadLocal<>(); public static void set(String key, Object value) { Map<String, Object> map = THREAD_LOCAL.get(); if (map == null) { map = new HashMap<>(); THREAD_LOCAL.set(map); } map.put(key, value); } public static Object get(String key) { Map<String, Object> map = THREAD_LOCAL.get(); return map == null ? null : map.get(key); } public static void remove() { THREAD_LOCAL.remove(); } }使用场景很典型:修改员工信息时,需要在Service里记录“这个操作是谁发起的”。没有ThreadLocal的话,Controller方法从拦截器拿用户ID,再传参给Service,再传给Mapper,每层方法签名都得多一个参数,非常啰嗦。有了ThreadLocal,Service里直接Long userId = (Long) ThreadLocalUtil.get("userId")就能拿到。
但要记住ThreadLocal的适用范围:它绑定的是当前线程。异步操作时,新线程里拿不到原线程的ThreadLocal值,这个场景就需要用阿里开源的TTL(TransmittableThreadLocal)来做线程间传递。Tlias项目目前没有异步链路,用标准的ThreadLocal没问题。
4. 实操验证和常见问题排查实录
4.1 用接口测试工具跑通全流程
登录认证代码写完之后,别急着庆祝,先得验证。我习惯用Postman或Apifox来做接口测试。
第一步,调用登录接口。请求方式是POST,请求体是JSON:
{ "username": "admin", "password": "123456" }正常情况下,响应会包含成功状态码和数据:
{ "code": 1, "data": { "token": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiJ9.9nSY4WnO3qJxLqVlWnKQ8gX6YMeW0TkZbF6TUaFJhYc", "username": "admin", "role": "ADMIN" } }第二步,拿这个Token去请求一个需要认证的接口,比如“查询部门列表”。请求时要在Header里加上:
token: eyJhbGciOiJIUzI1NiJ9...正常响应,说明整个链路通了。
第三步,验证Token过期或无效的情况。可以手动把Token末尾改几个字符,模拟篡改。此时应该返回401和“未登录或登录已过期”的提示。
第四步,验证不携带Token请求受限接口。同样应该返回401。
这一套验证跑完,登录认证才算真正落地。我在实践中把这一步做成“回归测试脚本”,每次改动认证相关代码后都先过一遍,防止改出低级问题。
4.2 常见异常和排查清单
这里梳理我在做Tlias登录认证时实际踩过、也帮别人排查过的高频问题,每个都有对应的解决方向。
问题一:高版本JDK下启动报ClassNotFoundException: javax/xml/bind/DatatypeConverter
原因就是jjwt 0.9.1依赖JAXB模块,JDK 9以后不再内置。解决办法有两个:
- 升级到
jjwt 0.11.5或更高版本,API略微变化,但不用处理JAXB依赖; - 额外引入依赖:
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>个人建议升级版本,毕竟JDK 8已经进入维护期很久了,新项目直接JDK 17起步,配套0.11.x的jjwt更省心。
问题二:登录后访问接口返回401,但明明带了Token
这种情况从三个方向查:
- 检查Header名是否正确。前端传的是
token,后端取的也是token,大小写和拼写任何一处不一致都会取不到值。 - 检查Token是否完整。有些前端框架会自动截断或转义长Token,导致后端解析失败。
- 检查密钥是否一致。JWT签发和解析用的密钥必须完全一致,如果配置中心或环境变量在不同环境里覆盖了密钥,就会出现“本地能过、测试环境过不了”的问题。
问题三:前端提示跨域,后端却看不到拦截器日志
八成是OPTIONS预检请求被拦截了。我前面提过,在preHandle里放行OPTIONS,或者在WebConfig里配置CORS。标准做法是两者都做,并确保CORS处理优先级在拦截器之前或内部放行。
问题四:ThreadLocal数据串了
症状是:用户A登录后,查询数据时偶尔能看到用户B的信息。绝大多数情况是afterCompletion里没清理ThreadLocal。线程池里的线程是复用的,上一个请求的数据残留,下个请求就串了。解决办法只有一个:每个请求结束必须ThreadLocalUtil.remove()。
问题五:Token过期后前端不跳转登录页
后端已经返回401了,但前端没有统一处理,用户停留在页面里点了半天没反应。这不是后端问题,但作为后端开发者应该主动和前端约定好:所有接口遇到401状态码时,前端统一跳转登录页并清理本地缓存的Token。这个约定放在接口文档里,省得后续扯皮。
4.3 登录认证模块的扩展建议
Tlias项目的登录认证是单角色、单Token的基础版本。实际工作中,这个模块还能往这几个方向扩展:
- 角色权限控制:现在的拦截器只校验“是否登录”,不校验“是否有权限”。可以给每个接口配置要求的角色,在拦截器里从ThreadLocal取出当前用户角色做比对,不满足就返回403。
- 登录失败次数限制:防止暴力破解,连续输错5次密码后锁定账号15分钟。实现方案是Redis计数器加过期时间。
- 图形验证码:登录接口增加验证码字段,后端生成图片,把答案存在Redis并设置5分钟过期。能挡掉一批脚本攻击。
- 密码加密方案升级:从MD5加盐换成BCrypt。BCrypt算法自带盐值、计算代价可调,更符合当前安全要求。Spring Security的
BCryptPasswordEncoder可以直接用,即使不用Spring Security,单独引入这个类也行。 - 双Token机制:短期Token(Access Token)有效期30分钟,长期Token(Refresh Token)有效期7天。Access Token过期后用Refresh Token换新,用户无感知续期,安全性也更高。
这些扩展如果都做进去,登录认证模块的完整度就比较接近实际生产项目了。Tlias作为教学项目把主线打通,剩下的是一层层加功能的过程。
我在实际操练这个项目时,印象最深的不是JWT本身,而是“完整链路”这四个字。登录认证不是一个Login方法的事,它是从数据库密码设计、Token签发、传输、拦截校验、上下文传递到异常处理的整个闭环。每一步都有坑:不加盐的MD5等于没加密,拦截器漏放行自己都进不去,ThreadLocal不清理数据会串,预检请求不放行前端一片红。这些细节在视频教程里可能一两句话带过,但落到自己代码里,每一个都是真实的生产事故隐患。
如果看完这篇,你决定在自己项目里重写一遍登录认证,我建议按这个顺序动手:先封装Result和ThreadLocalUtil,再做JwtUtils和Md5Util,然后写登录Service和Controller,最后写拦截器并注册。每写完一层,停下来想想这层如果不写会发生什么问题。这套思考习惯,比代码本身更值钱。