ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

黑马点评短信登录模块深度拆解:Redis+Token+拦截器链路

黑马点评短信登录模块深度拆解:Redis+Token+拦截器链路 我花了两个周末把黑马点评的短信登录模块从头到尾啃了一遍包括写代码、跑流程、埋断点、看源码还翻了大量相关的面试题。这篇笔记不是把课程代码抄一遍而是把我踩过的坑、想通的逻辑、以及面试可能会追问的点都揉碎了写出来。如果你正在学这个项目或者准备面试被问到“项目里怎么做登录”这篇内容应该能帮你省下不少时间。1. 这个模块到底在解决什么问题1.1 从“登录”说起为什么短信登录是个好切入点黑马点评是一个仿大众点评的实战项目用户要下单、点评、点赞第一步一定是身份识别。而“短信登录”是移动端App最常见的登录方式它的交互流程大家都很熟悉——输入手机号获取验证码填验证码点登录完事。但就是这个看起来简单的流程放在后端实现时会牵扯出一堆东西验证码怎么生成存哪里怎么防刷登录成功后怎么让用户后续请求都能被识别token过期怎么办多台服务器时session还靠得住吗这些问题的答案就是短信登录模块真正要教的东西。我刚开始学的时候以为这个模块就是写两个接口发送验证码、登录然后往Redis里塞数据。真正动手之后才发现整个模块的核心其实是两件事验证码的存储与校验以及登录状态的保持与刷新。这两件事做好后续所有需要用户信息的功能才能安全地往下走。1.2 项目里的技术选型为什么是RedisToken黑马点评这个项目里短信登录模块用了Redis来存验证码和用户信息用UUID作为token返回给前端。很多人第一次看到会问为什么不用Session这恰恰是这个模块最值得深挖的设计点。传统的JavaWeb项目一般用Session保存登录状态浏览器会把SessionID存在Cookie里每次请求带上来服务器根据SessionID找到对应的用户。这个方案在单机环境下很好用但一旦项目部署到多台服务器问题就来了——用户在A服务器登录了下一次请求被负载均衡转发到B服务器B服务器的Session里没有用户信息用户就被判定为未登录。解决方案不是没有比如Session黏滞、Session共享、Session复制但这些方案要么引入额外的复杂度要么会有性能损耗。而在真实互联网项目中Redis作为分布式缓存早就成了标配把登录状态直接放到Redis里天然就是多服务器共享的配合token在请求间传递问题迎刃而解。黑马点评选择Redis存token本质上是在模拟真实项目的做法而不仅仅是教学上的简化。这一点在面试时答出来比单纯背结论要加分得多。2. 发送验证码细节多到超出预期2.1 发送接口的设计思路先看发送验证码的接口。前端传一个手机号后端要做的事情按顺序拆开是这样的校验手机号格式是否合法生成6位随机验证码把验证码存到Redis设置5分钟过期时间把验证码通过短信服务商发送给用户课程里是打日志模拟代码看起来不长但每一步都有讲究。手机号格式校验这块我一开始觉得无所谓只写了“如果手机号为空就报错”后来发现不行——如果用户传过来的是一串乱码后面存Redis、发短信全是白干。正规做法是用正则表达式判断Java里可以这样写private boolean isPhoneValid(String phone) { if (phone null || phone.length() ! 11) { return false; } // 正则1开头第二位3-9后面9位数字 String regex ^1[3-9]\\d{9}$; return phone.matches(regex); }这里的^1[3-9]\\d{9}$是一个很常用的手机号校验正则1开头第二位是3-9排除了11、12等不存在的号段后面9位任意数字。写代码的时候注意Java字符串里反斜杠需要转义所以\d要写成\\d。2.2 验证码的随机性与存储验证码本身是6位数字。课程里用的是Random生成一个100000到999999之间的随机数也有人喜欢凑一下用String.format(%06d, random.nextInt(1000000))这样即使生成的数字小于100000也会补零到6位。两种写法都行但我建议养成补零的习惯防止出现5位或更少的验证码。生成验证码之后的存储是这个环节的另一个核心。我见过有人用String类型直接存像这样stringRedisTemplate.opsForValue().set(login:code: phone, code, 5, TimeUnit.MINUTES);这个写法没大毛病但项目中选的是Hash结构key是login:code:手机号field也是codevalue是验证码值。为什么要用Hash而不是String这里要说一下我的理解。从功能上看String完全够了。但如果你后续想存手机号维度更多的信息比如发送次数、发送时间、上次发送的IPHash可以只用一个key把多个字段都塞进去而String就得分多个key管理和清理都比较麻烦。真正让我觉得有必要用Hash的是突然想到如果后面做“验证码防刷”功能——同一个手机号60秒内不能重复发送、同IP一天最多发送5次——这些计数数据放哪再开一个key那日志排查的时候就得对着好几个key去猜。而用Hash全都塞在login:code:手机号这一个key里过期时间统一管理清爽得很。过期时间设5分钟这个数值也不是随便拍的而是结合业务场景定的。太短了用户来不及查看短信太长则会扩大验证码被暴力破解的时间窗口。课程里给的是5分钟真实项目中常见的是5到10分钟做项目时掌握一个原则就好验证码的过期时间不是越长越好而是刚好覆盖用户“查看短信-输入验证码-提交”这一整个动线的时间。2.3 发送频率限制课程演示时没有明显提到发送频率限制但这绝对是生产环境必做的点。我在自己写扩展的时候加了一个简单逻辑发送验证码之前先检查Redis里有没有login:code:手机号这个key有的话判断它的sendTime字段和当前时间差是否小于1分钟小于就抛异常提示“发送过于频繁”。这段逻辑用伪代码表示就是// 检查是否发送过 MapObject, Object cache stringRedisTemplate.opsForHash().entries(login:code: phone); if (!cache.isEmpty()) { String sendTime (String) cache.get(sendTime); long elapsed System.currentTimeMillis() - Long.parseLong(sendTime); if (elapsed 60000) { throw new RuntimeException(发送过于频繁请稍后再试); } }这个防刷逻辑是实打实会出现在真实项目里的面试时如果能主动提一句“我考虑了验证码防刷同一个手机号60秒内不能重复发送”会让面试官觉得你想问题很全。3. 登录与Token机制整个模块的定海神针3.1 校验验证码的误区用户收到验证码后会提交手机号验证码来登录。登录接口的逻辑拆开是根据手机号从Redis里取验证码判断验证码是否存在是否过期判断验证码是否匹配匹配成功后生成token存入Redis设置30分钟过期把token和用户信息返回给前端这里就有个大坑——校验验证码时不能用equals直接比对。为什么因为我们拿到的验证码是String rand (String) cache.get(code);而用户提交的验证码是通过String code request.getParam(code);来的两个都是字符串看起来可以equals但你得先确认Redis里的验证码到底是不是空。如果Redis里的验证码已经过期被自动删除了cache.get(code)返回的是null此时调用rand.equals(code)会抛NullPointerException。所以正确顺序是if (rand null || !rand.equals(code)) { throw new RuntimeException(验证码错误或已过期); }这个先判空再比较的顺序我写的时候还真踩了一次坑当时是拿一个过期key测试结果控制台直接红了。不要觉得这是小事生产环境一个NPE能把整个请求链路都带崩。3.2 用户信息的存储为什么不直接存用户ID验证码校验通过后接下来是查询用户信息。首次登录的用户手机号在数据库里肯定是没有记录的所以要先根据手机号查数据库查不到就注册一个——把手机号作为新用户写入数据库。这种“查不到就自动注册”的套路在互联网产品里很常见也解释了为什么很多App你第一次用手机号验证码登录就自动创建了账号。创建或查找到用户后用户信息放进Redis。这里要仔细说一下存储结构的选择。项目里的做法是用Hash结构key是login:token:UUIDfield是uservalue是用户对象的JSON。为什么不用String直接存用户对象JSON因为Hash结构可以单独更新其中某个字段比如用户改了昵称你只需要opsForHash().put(key, user, newJson)而不用动其他字段。此外从Redis内存角度想Hash对小对象更友好和String存储相等数据量时常常更省一点。再一个关键点Redis里存的用户信息不能把敏感字段存进去比如密码。虽然这个项目没有密码但养成习惯是好的。存进去的应该是“对客户端不可见、但对后续业务够用”的信息比如用户ID、手机号、昵称、头像。如果业务里只需要用户ID那就只存ID和昵称这类展示字段就好。3.3 为什么返回给前端的是随机Token登录成功后后端返回给前端一个UUID字符串前端把它存在本地后续请求在请求头里带上。这个设计背后有一个很重要的思路不要暴露用户的标识规则。如果直接返回用户ID作为身份凭证那用户ID是自增的别人很容易猜出你这个平台的用户总量是多少也可以伪造ID访问别人数据。而UUID是一个无规则的随机字符串根本没法猜测攻击难度直接拉满。UUID的生成方式很简单String token UUID.randomUUID().toString();注意这里没有replace(-, )因为在Redis里作为key的时候带不带横线都不影响保持原样就可以了。如果你有强迫症想去掉横线那就注意去掉之后长度正好是32位别搞错。3.4 Token在Redis里的过期时间策略Token存储时设置30分钟过期时间这个过期时间是整个登录态的生命周期。用户在这30分钟内如果一直不操作登录态就自动失效需要重新登录—这就是“会话过期”的概念。但真实App的使用场景是我刷一会儿App隔一会儿再刷如果每次都要重新登录体验会很差。所以黑马点评里做了一个token刷新的机制用一个拦截器用户每次发请求时如果携带的token还有效就顺带把过期时间重置为30分钟。这样只要用户一直在活跃登录态就永远不会断只有连续30分钟没操作才会被踢下线。这个机制也叫“滑动过期时间”。4. 拦截器实现登录校验与刷新这个模块的精华4.1 拦截器的整体结构登录状态保持要落地靠的是两个拦截器配合而不是一个。这两个拦截器分别是RefreshTokenInterceptor负责刷新token过期时间并把用户信息保存到ThreadLocalLoginInterceptor负责校验用户是否登录没登录直接拦截返回401为什么要拆成两个我一开始觉得一个拦截器就够了——校验了登录顺便刷新一下token不就行了但后来发现拆开是有讲究的。项目中有一部分接口是公开的比如首页的店铺信息、发送验证码这些接口不需要登录也能访问。如果你用一个拦截器既做刷新又做校验那这些公开接口怎么处理放行放行了刷新逻辑也就执行不了了。拆成两个之后RefreshTokenInterceptor拦截所有请求但它只做一件事——如果带的token有效就刷新过期时间并保存用户信息如果无效什么都不做直接放行。后续的LoginInterceptor再根据RefreshTokenInterceptor有没有保存用户信息来决定是否放行。这样设计的好处是公开接口能正常访问同时只要用户带了有效token登录状态就在持续刷新需要登录的接口通过LoginInterceptor统一把关逻辑清晰4.2 拦截器的注册顺序与路径配置两个拦截器注册时是有顺序的在WebMvcConfigurer里注册谁先谁后就是拦截器的执行顺序。RefreshTokenInterceptor必须注册在前面、先执行因为LoginInterceptor的判断要依赖于前者留下的用户信息。配置的代码大致是这样Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private RefreshTokenInterceptor refreshTokenInterceptor; Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(refreshTokenInterceptor) .addPathPatterns(/**) .order(0); registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns( /user/login, /user/sendCode, /shop/**, /shop-type/**, /upload/**, /voucher/** ) .order(1); } }这里有两个关键点我在踩坑后才彻底明白。第一个是order。数字越小越先执行RefreshTokenInterceptor的order必须是0LoginInterceptor是1顺序反了登录校验就会先跑刷新机制还没执行用户信息自然就是空的所有需要登录的接口全挂。第二个是路径匹配规则。addPathPatterns(/**)表示拦截所有路径。LoginInterceptor里的excludePathPatterns是白名单那些公开接口如果被拦截了用户还没登录就访问岂不是崩了。写白名单的时候把发送验证码和登录接口放了其他像/shop/**、/voucher/**这种浏览类接口也要放不然游客模式就被误杀了。4.3 两个拦截器的职责拆分与代码逻辑RefreshTokenInterceptor的核心逻辑其实是一个“能刷新就刷新不能刷新就算了”的过程。它的preHandle里大概做了这几步public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 从请求头里获取token String token request.getHeader(authorization); if (StrUtil.isBlank(token)) { return true; // 没有token直接放行交给LoginInterceptor处理 } // 2. 基于token从Redis取用户 MapObject, Object userMap stringRedisTemplate.opsForHash().entries(login:token: token); if (userMap.isEmpty()) { return true; // token无效同样放行 } // 3. 把查到的用户信息转为UserDTO UserDTO userDTO BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); // 4. 存入ThreadLocal UserHolder.saveUser(userDTO); // 5. 刷新token过期时间 stringRedisTemplate.expire(login:token: token, 30, TimeUnit.MINUTES); return true; }注意到没有它任何情况下都返回true也就是说它本身并不做“拦截”这个动作只是尽可能地把用户信息保存下来。真正决定请求能不能通过的是LoginInterceptor。LoginInterceptor的逻辑就简单了判断UserHolder里有没有用户public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() null) { response.setStatus(401); return false; } return true; }有人可能会问UserHolder又是什么它是一个基于ThreadLocal的工具类负责在当前线程里保存用户信息。为什么用ThreadLocal因为一个请求从进入到返回Tomcat的工作线程是不变的用ThreadLocal保存用户信息在整个请求链路里Controller、Service、Mapper都能随时取到当前用户是谁不用每个方法都把用户当做参数传来传去。这个模式在真实项目里非常常见面试时提到“用ThreadLocal保存当前登录用户”是很标准的做法。但要记住一点用完要清理。RefreshTokenInterceptor的afterCompletion里要调用UserHolder.removeUser()否则线程池复用时上一个请求的用户信息会留在当前线程的ThreadLocal里导致串号。这个坑在真实项目中出过无数次事故养成清理的习惯特别重要。4.4 ThreadLocal与用户信息传递为什么不能存全量对象这里再补充一点我自己的理解。项目中ThreadLocal里存的不是完整的用户对象而是一个只有ID、昵称、头像的UserDTO。为什么只存这几个字段首先是安全考虑。完整用户对象里有手机号甚至如果项目扩展了还有密码、身份证号之类的敏感信息。ThreadLocal里的数据在当前请求的任何地方都能拿到如果不小心被日志打印出来就是一次敏感信息泄露事故。其次是性能考虑Redis里也只存了必要字段取出来转成DTO后占用的内存小处理速度也更快。实际开发中如果要用ThreadLocal存用户信息推荐只在其中放用户ID和少量业务必备字段需要用完整用户信息时再到Service层查一次数据库。不要图省事把整个大对象塞进去。5. 从代码细节到面试考点我整理的高频问题清单5.1 为什么用Redis存验证码和Session不能直接扩展这是短信登录模块面试中被问得最多的一个问题答得好的关键是要讲到拓展性。单机下的Session当然能用但Session是存储在JVM内存里的每一台服务器只能看到自己的Session。当你的服务从一台变成两台、三台用户的请求被负载均衡分发到不同服务器时登录状态就丢了。你可以回答说Redis是独立于应用服务器的中间件所有服务器都访问同一个Redis所以不存在多机状态同步的问题。再补充一句即便Redis本身要做高可用也可以搭建主从或集群实现比Session共享更优雅的扩展方案。这样由浅入深答下来面试官基本能对你建立信心。5.2 验证码被暴力破解怎么办面试官老喜欢追问验证码的安全性。这个问题可以从三个层面答。第一过期时间。验证码只存5分钟过期自动删就算被拿到了攻击窗口也很短。第二刷新机制。用户提交验证码登录成功后可以立即删除Redis里的验证码key保证验证码一次性有效防止同一验证码被重复使用。第三次数限制。例如同一个手机号60秒内只能发送一次验证码同一个IP一天最多发送5次超出就拒绝服务。还可以加上图形验证码或滑块验证来进一步防机器人。把这三个层面讲清楚面试官会认为你不是只会调API而是真的考虑过安全问题。5.3 登录后用户信息放在ThreadLocal里会不会内存泄漏这是个很能区分水平的面试题。直接回答“会”或“不会”都不够深入。正确的答案是ThreadLocal的内存泄漏风险是存在的因为ThreadLocalMap的Entry继承了WeakReferencekey也就是ThreadLocal对象本身是弱引用value是强引用。当ThreadLocal对象没有外部强引用时key会被回收但value还会留在ThreadLocalMap里如果线程一直存活比如Tomcat的工作线程池里的线程value就永远无法被回收这就造成了内存泄漏。解决办法有两个一是用完调用remove()把整个Entry清理掉这是最推荐的做法二是在afterCompletion里清理这也就是黑马点评里做的。能答出WeakReference和强引用value这两个关键点面试官基本就能判断你读过ThreadLocal的源码了。5.4 过期登录态的处理方式最后再补充一个容易被忽略的细节。如果你在LoginInterceptor里发现用户没登录直接返回一个401状态码前端收到401后应该跳到登录页。但有些项目里不是返回401而是返回一个业务码比如code 401前缀统一为JSON格式由前端统一解析。这两种方式在业界都存在。黑马点评用的是HTTP状态码401而在真实项目中很多团队更习惯“HTTP状态码固定200业务code区分成功和失败”的做法这样前端处理起来逻辑更统一。面试时如果被问到这个你可以表达出两种方案都可以并说明不同场景下的取舍比如纯前后端分离用HTTP状态码更规范而业务中嵌套了很多网关逻辑时统一200更简单。6. 实测过程与踩坑记录6.1 我自己完整跑通一次短信登录的步骤前面讲了很多理论这里把我在本地完整跑通一次登录的步骤记录一下。我用的是黑马点评的源码后端是SpringBoot前端是Vue项目Redis是本地Windows版。大致过程如下启动Redis确认redis-cli ping返回PONG。启动后端端口默认8080。启动前端Vue项目用Nginx或直接npm run serve都行能访问首页和登录页就行。浏览器打开登录页输入手机号F12打开开发者工具切到Network面板。点击发送验证码观察请求和响应。后端日志会打印验证码比如392581。回到页面输入验证码点击登录观察Network面板里登录请求的响应里面应该带上token字段。打开Redis客户端执行keys login:*会看到类似login:token:xxxxx的key。用HGETALL login:token:xxxxx可以看到用户信息。刷新一次页面再操作几个需要登录的接口比如给商户点赞观察拦截器是否正常放行。实测下来只要Redis里token存在并且没过期接口都能正常访问。如果把token删掉再访问需要登录的接口前台会跳到登录页。6.2 踩坑一拦截器不生效第一次写拦截器时我犯了一个很低级的错误——配置文件里Configuration类加了拦截器也注册了但路径写成了/user/**结果发现没拦住refreshToken。排查了半天发现我是漏掉了一个关键点拦截器注册的addPathPatterns(/**)才能匹配所有路径只写/user/**意味着只有/user开头的请求才会进拦截器。还有一种情况是拦截器不生效是因为项目里同时有SpringBootApplication和Configuration导致配置类被扫描了两次或者拦截器没有交给Spring管理。这些都可以通过检查启动日志和Spring容器里的Bean来判断。6.3 踩坑二前端一直拿不到登录用户这个问题我当时排查了很久。后端接口返回的数据里UserDTO是null。后来发现RefreshTokenInterceptor里拿到request的header前端请求头里带的键名是authorization而后端读取时用的是Authorization大写A。HTTP header键名不区分大小写但如果你在后端写的是request.getHeader(authorization)前端写的是Authorization从HTTP协议角度是可以匹配的但有些框架做到严格校验时就会有意外。稳妥起见前后端约定同一个名字大小写保持一致最省心。6.4 踩坑三Redis序列化问题用Redis存用户信息时如果你用的是StringRedisTemplate就没有序列化问题——key、value都会自动变成字符串。但如果是直接用RedisTemplate默认的JdkSerializationRedisSerializer序列化出来的value是一串Java反序列化特有的乱码你在Redis命令行里看到的就是\xAC\xED...这样的二进制内容可读性极差排查问题时非常痛苦。黑马点评的项目里用的是StringRedisTemplate存储的时候手动把对象转成JSON字符串取出来再转回对象。这个习惯非常好也是实际企业中比较推荐的做法——用字符串存储配合JSON序列化简单直观不引入额外的序列化配置复杂度。6.5 踩坑四并发场景下token刷新可能有覆盖风险这一点是后来看源码的时候的发现虽然项目课程没有重点讲但是在高并发场景下同一次登录生成的token如果被两个线程同时刷新过期时间理论上并不会出现问题——过期时间只是被重置没有数据覆盖。不过如果业务后续需要更新用户信息到Redis里比如修改了昵称那并发写Hash的field时就存在覆盖问题那就需要引入分布式锁或版本号来控制。这个点属于进阶内容不一定会被问到但如果你能在面试中主动说出来说明你对并发场景下的存储稳定性有过思考。7. 模块进阶如果让我重新设计我会加哪些东西学完这个模块之后我给它做了一轮“生产级”解剖。如果这是一个上线的商业项目以下的点我认为是必须补上的。第一图形验证码。短信验证码的前置防线。现在只有短信验证码机器人可以用脚本疯狂调发送接口导致短信费用飙升。在发送短信验证码之前先让用户通过图形验证码确认“我是人”再走短信逻辑是很常见的做法。第二幂等性控制。用户连续点击几次发送验证码后端如果重复生成和发送体验很差还浪费短信资源。加一个幂等判断——同一手机号60秒内不允许重复发送既能防刷又间接实现了幂等。第三登录日志与风控。记录每次登录的IP、设备、时间当检测到异地登录或常用设备变化时可以触发风控比如要求二次验证保证账号安全。这是大厂登录体系里很重要的一个环节。第四分布式锁多级缓存。这个模块目前对Redis的直接读写比较简单。如果登录QPS很高可以把热点用户的Token缓存放到本地缓存比如Caffeine里Redis作为二级缓存降低对Redis的访问压力。不过引入本地缓存就要处理缓存一致性问题这又会让整个模块复杂不少属于权衡后的选择。这些内容虽然课程里没展开但都是基于这个模块很自然的衍生问题在真实项目里都会遇到。8. 我在这个模块里学到的通用收获说完了具体的登录模块我想跳出这个项目聊聊方法论。我在写这个模块的时候最大的感触是很多看起来很基础的代码只要追问几个“为什么”就能牵出一大串知识。比如验证码为什么存Redis背后是分布式Session问题用户信息为什么用ThreadLocal保存背后是线程隔离和内存泄漏Token为什么用UUID背后是防猜测和会话管理过期时间为什么要刷新背后是用户体验与安全性的平衡。这些思路迁移到任何项目里都是适用的。每写一个功能都能自问三层第一层这个功能这么做能不能跑通第二层换一种方案行不行区别是什么第三层如果流量涨十倍地涨、机器从一台变十台这个方案还扛得住吗多问自己几层写出来的代码质量会明显不同。9. 一些学习建议和练习方向如果你正在学这个模块我建议你按下面的顺序做几个练习效果会比你单纯把代码敲一遍好得多。先做一次“无提示重写”——不看课程代码自己凭理解把发送验证码、登录、拦截器三个部分默写一遍。遇到卡住的点就是你没真正理解的知识盲区回头再翻笔记补。然后做一次“录音讲解”——把自己假想成面试官对着电脑讲一遍这个模块的完整流程和技术选型尽量讲得让一个没学过的人也能听懂。讲的过程中你会发现很多你以为懂但说不清楚的地方这些就是你要再学一遍的点。再做一次“黑盒测试”——用Postman模拟各种异常输入无效手机号、验证码过期、重复提交、越权访问、无token访问需要登录的接口观察接口的返回是否符合预期。通过测试你能发现代码里很多隐藏的问题。最后做一次“代码Review”——打开你几天前写的代码以一起工作的同事的视角去看。如果看到一个方法逻辑太长、一个if嵌套太深就顺手重构。反复几轮之后你的代码洁癖会慢慢养成。短信登录模块虽然只是黑马点评的第一个实战模块但它打通了“前端交互-后端处理-Redis存储-拦截器链路”这一整条请求生命周期。把这套链路吃透后面的商户查询缓存、优惠券秒杀这些复杂模块学起来会顺手很多。我自己在学完之后再往后看明显感觉对请求怎么进来、数据怎么流转、状态怎么保持心里有了一个整体画面。如果你正在学这个模块建议别赶进度把每一个为什么都弄明白再往后走。
返回列表