Java
快来分享你的内容吧~
- 求助!!有基础项目经验但八股文不熟,如何十天冲刺长沙数字马力Java后端面试?求职目标由于有几年的空窗期了,而且年纪也不小了,我只想要个继续做JAVA开发工作的机会和平台,对于初始薪资没有太多的要求,在长沙能有10k就很满足了。目前有个长沙数字马力的面试机会,(简历已过,而且可以包容空窗期)。个人情况985本车辆工程专业,2015年就毕业了,非科班出身,17年开始从事java开发工作,一直都是在小公司工作,工作中基本上都是做比较基础的CRUD,没有做过完整的从0到1到上线的...查看全文编程导航小智:你好!很高兴你愿意这么认真、结构化地梳理自己的求职现状——这本身就已经比很多同学领先一步了 😊不过目前你的消息里缺少具体信息填充(比如求职目标没写、个人情况是空白、也没有简历链接等),我暂时无法针对性地帮你优化简历、拆解面试题或规划技术路径。为了让我能真正帮上忙,咱们可以这样配合:✅ 你只需要补充以下任意几项(越细越好):- 你的目标岗位(如:Java后端开发 / AI算法实习 / 外企SRE工
- 2 天前·全栈开发首页展示 !屏幕截图 2026-02-28 152901.png !屏幕截图 2026-02-28 154035.png !屏幕截图 2026-02-28 152939.png !屏幕截图 2026-02-28 152932.png !���幕截图 2026-02-28 152922.png !屏幕截图 2026-02-28 152909.png 碎碎念 这里是阿轩,已经两个月没发帖子了,还有近期的备查看全文加油鸭:阿轩太棒了!两个月边备考边爆肝完善项目,异步化、工具箱、智能体全都有深度实践,这份坚持和成长感超赞!6113分享
- 2 天前·后端要说我去年最骄傲的事,肯定是自己的 GitHub 关注者突破 2w 了! 所以今天我把自己这些年实践过的「GitHub 涨星涨粉技巧」全公开,希望能帮大家把自己的开源项目推出去,建议收藏!查看全文加油鸭:太棒了!2w关注+干货倾囊相授,这份坚持与分享精神真的闪闪发光~834分享
- 提升Java代码效率的AI工具在java开发时,大家都是使用什么AI编程工具?目前我还是习惯用IDEA,但是插件太难用了,用claude code又没有cursor上用着那么丝滑。如果可以使用自己的GLM4.7模型就最好了...查看全文编程导航小智:咱们做 Java 开发,确实 IDEA 是“生产力心脏”,但原生插件体验割裂、Claude Code 又缺上下文深度——这感觉我太熟了!😄目前团队里用得最稳、真正和 IDEA 深度融合还丝滑的 AI 编程工具,其实是这三个(按推荐优先级):✅ 1. JetBrains AI Assistant(官方出品,强烈推荐)- ✅ 原生集成在 IDEA 2023.3+(需订阅 JetBrains AI S
- 6 天前·Java后端已经有一段实习经历,大多数使用dify来开发工作流或智能体。简历求拷打与指正。 1.项目经历有必要保留传统java的项目内容吗?还是项目经历与实习经历都改为ai相关的? 2.还有哪些需要继续进步、补充学习的技术呢? 3.有希望暑期实习找到大中厂实习吗?编程导航_小y:1. 可以换一个简历模板,这个字体有点不太清晰,红色下划线是啥回事?面试官看了这个图片,体验太差了。2. 专业技能可以改为熟悉 Redis,实习经历里的项目拎出来,实习经历主要突出你负责 xx 项目,做了 xx 工作,取得 xx 成果。3. 项目可以看一下编程导航里的 AI 超级智能体和 AI 零代码应用生成项目,写到简历里,可以部署上线。212分享
- 6 天前·Java后端前言 哈喽,大家好,我是程序员-技术但不宅。 今天给大家分享一下我今年利用业余时间开发的个人 + AI 协同开发的项目-即效(Jixio)。查看全文wangxx:项目地址:ice-berg.cloud973分享
- 6 天前·Java后端
- 7 天前·Java后端
- 7 天前·Java后端
求助!!有基础项目经验但八股文不熟,如何十天冲刺长沙数字马力Java后端面试?
### 求职目标 由于有几年的空窗期了,而且年纪也不小了,我只想要个继续做JAVA开发工作的机会和平台,对于初始薪资没有太多的要求,在长沙能有10k就很满足了。目前有个长沙数字马力的面试机会,(简历已过,而且可以包容空窗期)。 ### 个人情况 985本车辆工程专业,2015年就毕业了,非科班出身,17年开始从事java开发工作,一直都是在小公司工作,工作中基本上都是做比较基础的CRUD,没有做过完整的从0到1到上线的项目流程,上线项目还是自己跟着鱼皮老师的项目做的。没有高并发,微服务经验。 ### 求职困惑 目前都是封闭式的自学,八股文太多了,时间不够用,有压力,也比较焦虑,想要针对性的去面试(目前准备去长沙数字马力),想把最重要的那一部分准备好,想获得一个工作机会,边工作边努力。 ### 期望帮助 希望得到大家的**求职准备经验**,最好有**长沙数字马力面试经验**,以及我这种没有任何优势的情况该怎么去应对,八股怎么理解性的去掌握,对于没涉及到的技术栈该怎么高效的去准备。
crispix搞定上线!!新加入crispix Tool和接入nanobanana2!!!
## 首页展示       ## 碎碎念 这里是阿轩,已经两个月没发帖子了,还有近期的备考压力和各种东西也压得我喘不过气来。项目部分,主要是在填之前留下的坑和完善各种机制,比如智能体的优化 登录功能 会员机制 邀请码 验证码 异步化等.... ### 之前帖子留的坑  ### 这几个月的开发 后端  ### 前端  ## Crispix Tool 工具箱 之前说挖的坑其实就是crispix tool工具部分,提供各种模板或者工具让用户可以简单的上传图片或者输入几个提示词就可以生成模板的效果,还提供了图片信息解析 历史记录 下载功能。 这个功能其实在之前的图库里面也有,我看身边人的评价说使用的效果挺好就把他加了进来,并完善了一下加了些工具,比如比较常用的电商工具 白底图 创意海报、,还有一些小创意 生成表情包 生成手办等。模型方面也加入昨天新出的模型nanoBanana 2还有之前的nanoBanana Pro,都是有加上去并配一些模板的(昨天为了适配这个模型爆肝了一天) ### 图库的工具部分(前身)  ### crispix的工具部分(现在)      #### 工具历史记录  功能部分展示完,接下来说以下技术点,之前的图库图片工具的方式其实很简单,其实都是复用鱼皮那个扩图的逻辑,即同步调用api,前端收到返回。这样子做很方便,但是有弊端: 1. 同步返回会存在超时问题,即api接口超过了前端设定的时间就没办法返回结果,比如这个工具api平台那边生了3分钟,但是你前端设置接口超时2分钟,那你这个东西最终生成了也拿不到,因为超时了 2. 由于是同步接口,前端只要一关页面,这个任务就没了,需要重新生成 3. 工具记录没有存数据库,不支持历史记录 4. 链接没有存对象存储,而api平台那边返回的图片链接是有效期的(一般是3天),超过这个时间就这个链接就失效,所以需要存对象存储 5. 没办法实时获取工具进度 ### 解决方法 针对这些问题,其实有个很通用的解决方法,就是异步化+持久化存储,即先创建任务返回任务id,再通过前端轮询 查询工具接口 查询工具状态。这也是各大云平台提供异步api接口的通用方案。这里简单说明一下具体实现,首先技术选型 java异步线程池+redis+mysql,因为是小项目没有那么多调用和并发所以使用内存线程池(项目大了可以考虑使用mq),其次redis用来实时保存任务状态,以便于前端快速获取任务状态接口,任务状态更新就更新redis的数据。最后mysql持久化保存工具数据 任务状态(几秒一次的轮询调用给mysql太慢 也压力太大了,这种即时 需要频繁查询的数据选择redis是最好的) #### 工具历史表  可以看到里面包含了status状态和任务id,这两个就是实现线程池的关键,除此之外还要包含工具输入信息和输出信息以便于前端获取更新页面。 ### 具体调用逻辑 #### 创建任务阶段(包含异步调用任务) 前端发起点击发送调用工具接口=>后端创建任务保存数据库,更新任务状态为0,异步调用api接口(这里并不需要等api接口返回才返回,我们通过ThreadUtil.execute交给线程池异步执行,所以之后的逻辑是直接返回id���接口已经返回完但是任务还是在执行) =>返回任务id #### 轮询阶段 前端调用完创建任务接口,获取完任务id就保存=>进行几秒一次的轮询 根据任务id获取任务状态,直到超时或者是任务状态更新为成功或者失败 #### 异步调用任务的逻辑 异步调用api接口,交给线程获取接口返回,如果返回成功执行下一步逻辑保存对象存储,更新数据库任务状态为1,执行过程中如果有失败,就抛异常更新数据库任务状态为2表示这个任务失败 ```java try { // 线程池异步执行,不会影响接口返回 ThreadUtil.execute(() -> { try { executeRecraftCrispUpscaleAsync(taskId, request, usageRecord.getId(), userId); } catch (Exception e) { log.error("工具执行失败,taskId: {}", taskId, e); updateToolStatus(taskId, usageRecord.getId(), ToolTaskStatus.FAILED, null, getErrorMessage(e), 0); } }); } catch (RejectedExecutionException e) { // 捕获异常,如果任务出现问题抛异常,改任务状态为2(失败) log.error("线程池队列已满,无法执行工具任务,taskId: {}", taskId, e); updateToolStatus(taskId, usageRecord.getId(), ToolTaskStatus.FAILED, null, "系统繁忙,请稍后重试", 0); return ToolExecuteResponse.builder() .taskId(taskId) .toolId(toolEnum.getToolId()) .status(ToolTaskStatus.FAILED.getValue()) .message("系统繁忙,请稍后重试") .build(); } // 接口正常返回任务id return ToolExecuteResponse.builder() .taskId(taskId) .toolId(toolEnum.getToolId()) .status(ToolTaskStatus.PROCESSING.getValue()) .message("工具调用已提交,正在处理中") .build(); } ``` 大致逻辑就是这样子,这里就简单说明一下,实际还是要考虑很多的东西,比如历史记录的创建,前端轮询逻辑,后端各个部分的异常,超时问题,国外地域图片读取慢,大文件,对象存储获取不到等等等。。这些问题都是我近期慢慢摸索出来的,感兴趣的朋友我可以根据这些出个帖子,对了留个坑,下期来说一下国外地域获取慢的问题,需要中转站?代理?nonono,其实正式上线只需要配置一些东西就可以了,留个坑下期说。 ### 智能体部分 智能体部分其实现在并没有什么大的改动,想要了解的话看这篇文章。 https://www.codefather.cn/post/2002428860112683009#heading-0 主要就是测模型,换工具这样子,因为当一个框架完善了之后,其实效果怎么样还是取决于模型,越好的模型运用效果越好。新年发布了好几款模型qwen3.5 plus doubao系列2.0。不得不说这次的模型还是有很大提升,起码对于我的智能体而言整体体验是变好了的,这两款模型整体体验下来是 qwen优于doubao,但是调用和生成速度不如doubao。doubao虽然次点但也是十分优秀,所以我选择react部分(非规划模式)交给doubao2.0,比较严谨考验模型能力的plan(规划模式)交给qwen3.5plus。至于工具部分就将原来的即梦4.0更新为即梦4.5 将nanobanana更新为nanobanana2。 其实这个智能体还是有些缺陷,比如我只做了历史没做上下文工程,所以实际体验下来他其实是会忘记上一轮的对话的,这一部分是需要改进的点,还有提示词比较臃肿,不规范。还有随着ai的发展,发展出了一些新技术比如response api和agent skill,这些在我的项目里面也可以做一下更新。之后还打算把工具部分给用户选择,让用户自由选择调用什么工具,类似于下图。  ## 想法总结 不知不觉我已经大三下了,也到了实习的阶段,近期的话还算是在备考,有点时间就搞搞我的项目。对未来的规划其实我想着边备考边找实习,因为现在春招嘛,可能是个比较好的时机,但是又受学历限制,想找这部分岗位可能比较难,先写了简历投了试试看把 这个项目这几天上线还是因为我妈他们需要催我,我才爆肝三天搞定的,因为我把图库下了,所以他们那些功能没得用比较难受。不得不说部署上线是真的麻烦,搞域名 搞备案 搞cdn 搞dns 搞反代 搞这搞那的,还好之前有些部署的经验才能这么快,不然估计都搞不定,还要再花好几天,这个部署我打算下期就搞这个,包含从0-1项目的部署教程 域名 cdn 备案 防护等,还有刚才说的国外图片加载慢的问题也会在这里得到解决。 其实我一直都是有很多的想法想去做 想去实现,很多时候受制于不敢或者说段位太低,而不去做也没有人,现在的话我希望去做一些更理想化的事,因为感觉现在已经有一些能力了,而且输了就输了嘛也没啥,至于技术方面我也会慢慢去探索,ai的发展太快了,我的这套框架在半年前还是感觉很不错 很新奇,现在看来就已经感觉落后了,所以才需要不断学习啊。 下面是我的微信,非常欢迎各位朋友来和我进行技术交流还有一些想法的(可能平时有事比较慢回) 
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
大家好,我是程序员鱼皮。 过了个春节,我也终于有空对过去一年的工作进行总结了。 要说我去年最骄傲的事,肯定是自己的 **GitHub 关注者突破 2w 了!** 程序员朋友们都知道,你想在这个「没有粘性」的平台上获取关注者有多不容易。我的这个成绩,在 GitHub 中国区关注者排行榜中已经排到前 7 了,再往前都是元老级前辈。  如今有了 AI 的加持,想做个开源项目比以前容易太多了,但很多同学发完之后 Star 寥寥无几,涨关注就更难了。 所以今天我把自己这些年实践过的「GitHub 涨星涨粉技巧」全公开,希望能帮大家把自己的开源项目推出去,建议收藏! ## 鱼皮的 GitHub 涨星涨粉技巧 涨 Star 和涨关注其实是一回事,核心是 **持续提供价值 + 让更多人看到**。 1)先有一个主打项目:与其分散精力做 10 个没人知道的小项目,不如集中火力把一个做到位。 我的编程导航、面试鸭、AI 知识库,每个都是持续迭代出来的,不是写完就扔。我去年重点发力 AI 编程方向,熬夜肝了无数个大爷,打磨 [ai-guide](https://github.com/liyupi/ai-guide) 这个免费 AI 教程仓库,从 0 做到了近 7k Star,靠的是持续输出有价值的内容。  2)装修你的 GitHub 主页:在你的 GitHub 账号下新建一个和用户名同名的仓库,里面的 README 文档会直接展示在你的主页上。 建议在文档里写好自我介绍、置顶最拿得出手的项目。你的主页就是你在开源世界的脸面,别人一看你的主页就知道 follow 你能获得什么,followers 自然越来越多。  3)用心写好项目 README:很多人代码写得不错,但 README 就一句 "A project built with React",谁敢用啊? 好的 README 要说清楚项目解决什么问题,包括效果截图/动图、快速上手步骤、项目亮点等。你把 README 当成产品详情页用心写,别人能感受到你的认真,自然更愿意给你一颗星。 4)提供可体验的地址:光有代码不够,一定要部署一个在线 Demo 让别人直接体验。很多人看到一个项目,如果没有可以直接访问的地址,大概率就划走了,眼见为实嘛。  5)降低使用门槛:提供 Docker 一键部署、写清楚环境要求、准备好示例数据。让别人 clone 下来 2 分钟就能跑起来,而不是折腾半天环境。门槛越低,愿意试用和给 Star 的人越多。  6)重视仓库 SEO:GitHub 本身就是个搜索引擎,你的项目名称、Description、Topics 标签都是关键词。 比如我的 ai-guide 项目,在 GitHub 上搜索 "ai guide"、"AI编程教程" 的排名都很靠前,这些自然搜索流量是免费的。你可以搜一下自己项目的核心关键词,看看排第几,然后针对性优化。  7)追热点出爆款:热门话题自带流量,关键是要快狠准。当年「合成大西瓜」火遍全网的时候,我第一时间做了个魔改工具开源,一夜爆了;「羊了个羊」刷屏的时候,我马上做了「鱼了个鱼」游戏跟上,也迅速起量。  现在有了 AI,你可以用它帮你快速分析热点背后的需求、生成完整项目,从发现热点到上线开源可能就一个晚上的事。 8)社区分享:你的项目做好了,一定要主动去社区曝光,比如在编程导航、掘金、V2EX、Reddit、Twitter 上分享。 注意,分享时不是甩一个链接就完事了!而是要讲清楚:我遇到了什么问题 → 我怎么解决的 → 你也可以直接用。让人觉得「这对我有用」而不是「又来打广告的」。 目前各大平台对开源项目的推广容忍度很高,平台乐意推、用户也天然信任开源的东西。像我的 [编程导航网站](https://www.codefather.cn/),也经常有学编程的同学来分享自己的项目,我作为平台方是很乐意帮忙推荐的,互相成就嘛。  9)拓宽你的受众:先想清楚你的项目是给谁用的。如果你的项目不是纯中文场景,千万不要只盯着国内用户。可以用 AI 把 README 翻译成英文,仓库描述也用英文来写,面向全球开发者。GitHub 上海外用户是大头,多了几十倍的潜在受众。我前段时间还专门开源了一个 [GitHub Global 工具](https://github.com/liyupi/github-global),帮你一键把仓库翻译成多种语言,出海成本不要太低。  10)长期走一个方向:我的 GitHub 从编程学习资源、项目实战教程、面试刷题、到 AI 编程,一直围绕「帮程序员成长」这条线。方向一致,别人才愿意长期关注你,而不是看完一个项目就走了。而且随着 AI 编程的普及,GitHub 的用户群体已经远不止传统程序员了,很多产品经理、设计师、创业者也开始逛 GitHub 找工具,你的潜在受众其实比以前大得多,坚持走下去回报会越来越大。 AI 时代,开源的门槛已经被磨到地板上了,但这也意味着竞争更激烈。与其做 10 个 AI 生成的 Demo,不如认真打磨一个能解决真实问题的项目,持续迭代、持续分享,Star 和 followers 都是水到渠成的事。 --- OK,以上就是本期分享,祝大家新年快乐!还有什么想问的,欢迎留言,我看到都会回复~ ## 更多 💻 编程学习交流:[编程导航](https://www.codefather.cn/) 📃 简历快速制作:[老鱼简历](https://www.laoyujianli.com) ✏️ 面试刷题神器:[面试鸭](https://www.mianshiya.com) 📖 AI 学习指南:[AI 知识库](https://ai.codefather.cn/)
小厂全栈实习面经:双非大三首次面试(笔试手写JWT)
## 个人背景 - **学历**:双非本科大三 - **学习经历**:学习过黑马苍穹外卖和天机学堂 - **实习经历**:无实习经历 ## 背景与笔试 ### 整体流程 投递简历后收到面试邀请,面试流程分为笔试和面试两个环节。笔试环节采用手写代码的形式,考察全栈开发能力。 ### 笔试内容 - **后端部分**:要求手写 `User.java` 的 PO 实体类、登录 DTO 数据传输对象,以及 JWT 校验工具类,并实现登录接口和校验逻辑 > JWT 工具类未完全实现,登录校验逻辑做了基础实现 - **前端部分**:要求手写 `login.vue` 登录页面和路由工具 > 因前端经验有限,仅说明会使用 ElementUI、AntDesign 等组件库 ### 笔试感受 小厂全栈岗位的笔试题覆盖前后端技术栈,考察范围较广,但是手写jwt。。。不清楚何意味 --- ## 面试 笔试之后就是面试,面试就是简单聊了聊,没问太多,最主要就是问稳定性和能不能来,薪资能不能接受,加班能不能接受。 **前端会不会做** >后端做的多一些,前端也能做,简历上也是后端多一些,会用一些ElementUI,AntDesign这类组件库 **vue有没有接触过,会有写后台管理相关的事,前端也写过对吧,我们就尽量不再写接口再对了,太麻烦了** >接触过,后台管理也写过,前端也都写过,就一人全栈全干了对吧 **对的,能干吗** >没问题 **你在你之前的公司是干啥的** > 刚开始做运维系统,主要是打杂,后续参加了公司保险项目的订单模块,参与一些业务逻辑,写了一些增删改查 **能跟着上手干活吗** > 还行,前面有些跟不上,后面慢慢就跟上了,当时那家公司的文档比较多 **反正就是说白了,如果你过来的情况下就直接能上手了是吧** > 对的没问题没问题 **是长期的哈,能接受长期实习** > 对,可以接受 **长期实习的话就是说毕业以后也可以留在公司对吧** > emm...合适的话会留的 **彳亍,没完成工作加班什么的可以吗** > 加班大概到什么程度呢 **就是我们给你制定工作,你在合理范围内合理时间完成,那你得自觉加班** > 没问题,完不成那不就是我的问题吗 **我简单问几个问题,我们这边也算是国家合作的实训基地,也是定期会找实习生,以前找的实习生总是摸鱼,能长期干的实习出去也都是特别厉害的那种,能正常融入工作节奏就行** > 您放心,我肯定不摸鱼,我在之前公司工作的都老认真了,这肯定是没问题的 **springboot问你几个常用的注解吧,springboot你用的比较多还是springcloud用的比较多** > springboot比较多,因为controller还是用boot **小程序有接触过吗** > 接触过,像有些地方要用到appid之类的 **springboot常用注解简单说说,就是有印象的** > 像启动类上的application注解,还有的像是自动注入的,这些是mvc的 **内个内个像maven内个git什么的都会用吗,idea用的熟吗** > 嗯会用,idea直接集成git就能用了 **数据库mysql是吧,国产数据库有没有接触过** > 嗯。。。这个还真没有,数据库用的多的就是mysql,MongoDB这类的 **数据库我再问你,比如说几个聚合函数吧:求和,分页,分类求和,左右连接,有什么区别** > sum、limit、group by,left join、right join,左连接就是能查到左面那个表的详细信息,右连接。。。 **Kafka接触过哈** > 用过,但是用的不太多 **nginx的轮询配置文件里调过吗** > 这个没有调过,因为我们那个项目是前后端分离的哈 **nignx 负载均衡调过吗** > 了解过,默认是轮询 **docker会用哈,Linux简单命令都清楚哈** > 会用,没问题 **Linux删目录、删进程** > rm -rf、kill **K8S接触过吗** > 这个也是了解过,但是平时不怎么用 **ai工具你用过哪些** > 像cursor、trae、Qwen、Gemini这些主流ai都用过 **你拿cursor能写代码吗** > 嗯。。。可以,平时用trae比较多,因为trae便宜 **哦这个我不知道,你在你们那专业课排多少,百分之多少** > 每年那个奖学金没问题,百分之二三十吧 **专业有多少人** > 360 --- 后续就是说了一些公司相关的业务,聊了聊方向,问了多长时间上岗,还问了是不是为了混三方协议来的,感觉聊的还可以,聊了聊投简历面试多不多,行情怎么样之类的 --- ## 总结 面试体验方面,流程相对简单,主要围绕稳定性、入职意愿和基本技能展开,技术深度提问较少,更看重稳定性、工作态度和上手能力,而非技术细节;岗位分析来看,是典型的小厂全栈岗位,需要一人负责前后端开发,包括后台管理系统的前端工作,加班情况则需要根据工作任务自主安排时间完成工作目标; ### 后续进展 面试后一天收到通过通知,拒了。面小厂全栈主要就是积累面试经验,虽然八股项目都没问,也没有积累多少。 (**实在是不太明白这个笔试有什么意义,之前完全没有准备过,有懂行的大佬可以说说**)
提升Java代码效率的AI工具
在java开发时,大家都是使用什么AI编程工具?目前我还是习惯用IDEA,但是插件太难用了,用claude code又没有cursor上用着那么丝滑。 如果可以使用自己的GLM4.7模型就最好了
已经有一段实习经历,大多数使用dify来开发工作流或智能体。简历求拷打与指正。 1.项目经历有必要保留传统java的项目内容吗?还是项目经历与实习经历都改为ai相关的? 2.还有哪些需要继续进步、补充学习的技术呢? 3.有希望暑期实习找到大中厂实习吗?
给大家分享一下我的项目-即效(Jixio)
# 前言 哈喽,大家好,我是程序员-技术但不宅。 今天给大家分享一下我今年利用业余时间开发的个人 + AI 协同开发的项目-即效(Jixio)。  因为我自己主要做后端,所以前端页面就交给了 AI 来做。而且我感觉 AI 非常适合开发前端页面,哪里有问题,可以马上发现,然后让 AI 立刻去改正,所见即所得;但是如果让 AI 来做后端,有些功能涉及到很复杂的业务逻辑,所以对于一些不是很常见的使用场景的逻辑 Bug 很难被发现。  # 项目介绍  这个项目可以理解为是一个工具网站,因为是项目才开始开发嘛,目前功能还不完善,目前只有这几个常用工具。  后期我也不会把它做成那种包含成千上百个小工具的那种传统意义上的工具网站。 一是这种免费的网站已经很多了,实在没有竞争力。 二是论工具种类,我是很难有人家那么大而全。 我想以“场景为核心”,而不是以“功能为核心”,每个工具都有它独特的使用场景,这么说可能有些抽象。具体点就是以用户为核心,先搜集用户需求,然后再开发,所以就有了这个 “共创社区” 的功能。 # 共创社区  大家如果日常工作和学习上想要一个工具来提升效率,可以在【共创社区】中发布。   发布完成后,我这边会在管理后台中可以看到,如果是比较靠谱的工具,我这里会“审核通过”,然后大家在【创意广场】中就可以看到刚刚发布的工具了,如果是一些不靠谱的,比如“自动生成人民币”,我是不会。。。我也太想要这个工具了!!   还可以给每个工具进行投票,如果投票完,感觉后悔了,也可以取消投票,投票数最高的我会优先开发。  # 积分系统 当前积分系统还不完善,目前只有在【个人中心】-【今日签到】每天签到领取积分,连续签到可获取更多积分。   积分系统后续会慢慢完善,大家可以趁早签到积攒积分哟。 后边打算根据【积分排行榜】给前几名的用户发放一些奖励,或者大家可以用积分兑换一些虚拟物品或实体物品,这一块以后再多做考虑。大家有什么好的想法也可以在评论区讨论。  # 工具 目前优先开发了一些常用工具,每个工具我都会尽可能做到美观、简单、用起来很丝滑的效果。正如我们的宗旨:即率,即刻见效。具体效果大家直接去网站试一下就知道了,毕竟说再多也不如一试。  # 移动端适配 网站不仅可以在 PC 端使用,移动端也同样兼容。  # 问题反馈 大家在使用系统过程中,如果发现了 Bug,或者体验不好的地方,欢迎在这里进行反馈,我在后台是可以看到大家的反馈,立即进行修复的。  也欢迎大家加入微信群,在群里进行反馈和交流。  #2025 年度总结
SpringBoot八股汇总-迟来的年前5天成果
## 1. <font style="color:rgb(51, 51, 51);">介绍一下 SpringBoot,与传统的 Spring 有什么区别</font> 1. 传统 Spring 需要手动管理 jar 包、处理依赖冲突、编写 xml 配置文件;而 SpringBoot 相当于 Spring 的脚手架,拥有起步依赖、自动配置、内嵌服务器等特性,我们只需要关心业务代码的编写即可 1. 起步依赖 starters:是预设好的依赖包集合,只需要加一个依赖,就能快速集成所需功能 2. 自动配置:这是 SpringBoot 的核心特性,遵循约定大于配置,SpringBoot 启动时,`@SpringBootApplication`中的`@EnableAutoConfiguration`注解就会生效,这个注解里有个`@Import()`,触发 AutoConfigurationImportSelector 去读 spring.factories 文件中的键值对,得到配置类,然后逐个检查这些类上的条件注解,满足条件就会加载该配置类,注册 Bean 到容器 3. 内嵌服务器:自带 Tomcat,打成 jar 包后,使用命令`java -jar`就能运行,无需单独部署Web服务器 4. 代码和配置分离:可以通过 application-{profile}.yml 来区分开发、测试、生产环境,也可以通过命令参数、环境变量来修改配置,无需重新打包 5. Actuator 监控:提供了一系列 http 端点,可以让我们看到应用的健康状态(/health)、内存指标(/metrics)、环境配置(/env)、日志管理(/loggers)、基本信息(/info)等 2. 关于自动配置方面,SpringBoot2.7和3.0版本有变化:2.7之前用`MEAT-INF/spring.factories`文件通过键值对存储信息;2.7版本之后引入了`MEAT-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`专门存放自动配置类,每行一个;3.0彻底移除`MEAT-INF/spring.factories`这种自动配置的方式,但这个文件依然存在,用来存放 ApplicationContextInitializer、ApplicationListener 等 SPI 扩展 3. SpringBoot 打包出来的 jar 包叫 Fat jar,里面包含所有依赖的 jar 包,有个 BOOT-INF 目录,classes 放代码,lib 放所有依赖。启动时用的不是标准的 JarLauncher,而是 SpringBoot 自己的启动器,可以从嵌套的 jar 中加载类,直接通过`java -jar`运行,普通 jar 包做不到 ## 2. <font style="color:rgb(51, 51, 51);">SpringBoot 打成的 JAR 和普通的 JAR 的区别</font> 1. 普通 jar:包含 .class 文件、资源、清单文件,但依赖库不会打进去,使用时还需要通过 -classpath 指定外部依赖的位置 2. SpringBoot 的 jar:除了打包 .calss 文件、资源(放到 BOOT-INF/classes 目录下)清单文件外,还会把所有依赖原封不动的放到 BOOT-INF/lib 目录下,并内嵌 Tomat 之类的服务器,可以直接通过`java -jar`命令启动 ```plain <普通 jar > myapp.jar ├── com/ │ └── example/ │ └── MainClass.class └── META-INF/ └── MANIFEST.MF <SpringBoot 的 jar > myapp.jar ├── META-INF/ | └── MANIFEST.MF # JAR 的清单文件,指向主类 ├── BOOT-INF/ | └── classes/ # 应用的 .class 文件和资源文件 | └── lib/ # 应用的依赖库 └── org/springframework/boot/loader/ # Spring Boot 的自定义类加载器 └── JarLauncher.class └── WarLauncher.class ``` 3. 需要注意的是,Java 不支持从 jar 里加载嵌套的 jar,传统的方式是把所有依赖全部解压后重新打包,与 .calss 文件放在一起,但这样会出现同名文件覆盖问题;所以 SpringBoot 通过自定义类加载器,使得依赖库可以保持原始的 jar 格式嵌套存放 4. 再说一说 Jar 包的启动流程:运行 jar 包时,JVM 会去 MANIFEST.MF 里找 Main-Class,也就是JarLauncher;创建自定义的类加载器(LaunchedURLClassLoader)加载 BOOT-INF/classes 和 BOOT-INF/lib 下的文件,然后通过 MANIFEST.MF 里的 Start-Class 指定真正的主类,最后通过反射调用主类的 main 方法 ```java // Main-Class 指向 Spring Boot 的启动器(2.x版本) Main-Class: org.springframework.boot.loader.JarLauncher // Main-Class 指向 Spring Boot 的启动器(3.2+版本) Main-Class: org.springframework.boot.loader.launch.JarLauncher // Start-Class 指向真正的应用入口 Start-Class: com.mycompany.project.MyApplication ``` 5. 我想将指定的依赖排除,运行时通过 classpath 加载,应该怎样做?可以使用 PropertiesLauncher 替代JarLauncher 即可,具体方法是在`spring-boot-maven-plugin`里指定`<manifest>`,对于要排除的依赖可以在`<dependency>`层面排除,也可以在`spring-boot-maven-plugin`里排除,最后使用`java -Dloader.path=/xxx -jar app.jar`启动即可 ## 3. <font style="color:rgb(51, 51, 51);">说说 SpringBoot 的启动流程</font> 1. 从带有`@SpringBootApplication`注解的 main() 方法开始,分为两个阶段,new SpringApplication 和run 2. 第一阶段:new SpringApplication 1. 先判断应用的类型,是 Web 应用(Servlet),还是响应式应用(Reactive),还是普通应用 2. 然后设置初始化器、设置启动监听器、确定主应用类 3. 第二阶段:run 1. 把配置文件 application.yml 、环境变量、命令行参数都加载好,组成一个环境对象 2. 根据第一步确定的类型,创建一个空的 Spring IOC 容器 3. Spring 解析`@SpringBootApplication`,扫描所有 Bean,执行自动配置,把所有组件注册到容器里(Tomcat 会在此时启动) 4. 容器刷新完成,会发送启动完成事件,通知所有监听器 5. 最后检查有没有实现 CommandLineRunner 或 ApplicationRunner 的 Bean,有的话就执行他们的 run方法(通常是开机自启的任务) ## 4. <font style="color:rgb(51, 51, 51);">SpringBoot Starter 是什么,里面的默认配置如何覆盖</font> 1. SpringBoot Starter 将某个功能所需要的依赖都捆绑在一起,你只需要引入这一个依赖即可实现特定功能,而不需要自己去找对应组件、自己处理冲突 2. 对于里面的默认值,Starter 内部一般都会通过`@ConfigurationProperties`来绑定属性,你只需要在 application.yml 中写同样的key就能覆盖默认值;另一种方法就是自己实现 Bean,因为大多 Starter 内部都会使用`@ConditionalOnMissingBean` 表示容器中没有这个 Bean 才使用它,如果用户自定义了就不用这个默认 Bean 了 ## 5. <font style="color:rgb(51, 51, 51);">SpringBoot 支持哪些嵌入 Web 容器</font> 1. Tomcat:SpringBoot 默认 Web 容器,BIO/NIO,默认 200 个工作线程,大多数场景直接用它 2. Jetty:内存占用比 Tomcat 小,使用 NIO 非阻塞模型,对长连接支持友好,适用于 WebSocket/Server Sent Event 这类需要维持大量长连接的场景 3. Undertow:由 Red Hat 出品,JBoss/WildFly 的默认容器,使用 XNIO 实现非阻塞 IO,适用于高并发短连接的场景 4. Netty:响应式 WebFlux 应用默认使用它(严格来说,Netty 是响应式服务器,不是 Web 容器) 5. 如何切换容器?SpringBoot 默认使用 Tomcat,如果想要变更,可以在`spring-boot-starter-web`中排除`spring-boot-starter-tomcat`,然后手动添加`spring-boot-starter-jetty`等容器 ## 6. <font style="color:rgb(51, 51, 51);">Tomcat 中 BIO 与 NIO 的区别</font> 1. BIO:阻塞式,每个连接占用一个线程,在高并发下性能较差(InputStream/OutputStream) 2. NIO:非阻塞,采用多路复用,一个线程处理多个连接,高并发下性能好(Channel/Buffer/Selector) 3. Tomcat 8.0 开始默认使用 NIO,9.0 移除了 BIO 连接器 ## 7. <font style="color:rgb(51, 51, 51);">SpringBoot 默认可以同时处理的最大请求数是多少</font> 1. 默认可以同时处理的最大请求数:最大连接数 max-connections + 队列 accept-count = 8192+100=8292 2. 这两个参数可以通过配置文件来调整 3. 具体过程:当请求到达,检查当前连接数有没有超过 max-connections,没超过就接受连接,交给工作线程处理;如果当前连接数满了,会判断 accept-count 队列有没有满,如果有位置,就在操作系统层面排队等着;要是两个都满了,直接拒绝 4. 最大连接数和最大线程数的关系:max-connections 是能同时持有的 TCP 连接数;而 max-threads 是实际处理请求的工作线程数,默认最大线程数为 200 5. 先说一说 max-threads,它在 Tomcat 和 JDK 中的策略不一样,JDK 是核心线程数满了先放在队列,等队列满了再创建非核心线程处理;而在 Tomcat 中,因为是 IO 密集型任务,核心线程数满了会立刻创建非核心线程,直到达到 max-threads 后才放进队列 6. 再回到它们俩之间的关系,Tomcat 8.0 之后由 BIO 改为 NIO,这使得一个线程可以监听多个连接;NIO 模式下,Acceptor 线程负责接受连接,Poller 线程通过多路复用器 selector 监听事件,有数据来了才交给工作线程处理,没有数据的连接就不占用工作线程,所以 200 个工作线程配合 NIO,能抗住 8000 多个连接 ## 8. <font style="color:rgb(51, 51, 51);">想要在 SpringBoot 启动时执行特定代码,有哪些方式?</font> 1. 实现 CommandLineRunner 接口:在 SpringBoot 启动完之后执行,可以有多个实现类,使用`@Order`控制顺序,拿到的是原始数组(此时所有 Bean 等环境已经加载完成,适合做全局初始化,比如预热缓存,建立连接池等场景) ```java @Component @Order(1) public class MyCommandLineRunner implements CommandLineRunner { @Override public void run(String... args) throws Exception { // args = ["file.txt", "--server.port=8081"] System.out.println("Application started!"); } } ``` 2. 实现 ApplicationRunner 接口:跟 CommandLineRunner 类似,但可以接受和处理启动参数(ApplicationArguments),同样遵循`@Order`配置 ```java @Component @Order(2) public class MyApplicationRunner implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { // ["file.txt"] ["server.port"] ["8081"] System.out.println("ApplicationRunner: arguments: " + args); } } ``` 3. 使用`@PostConstruct`注解 [Java标准]:在 Bean 初始化后执行(如果你的代码依赖其他 Bean,而此时其他 Bean 还没初始化好,可能会拿到 null 或触发循环依赖,所以适用于校验自身必要配置等场景) ```java @Component public class MyPostConstructBean { @PostConstruct public void init() { System.out.println("PostConstruct: Bean initialized!"); } } ``` 4. 实现 InitializingBean 接口 [Spring接口]:他提供了 afterPropertiesSet 方法,在初始化 Bean 之后执行(如果你的代码依赖其他 Bean,而此时其他 Bean 还没初始化好,可能会拿到 null 或触发循环依赖,所以适用于校验自身必要配置等场景) ```java @Component public class MyInitializingBean implements InitializingBean { @Override public void afterPropertiesSet() throws Exception { System.out.println("InitializingBean: Properties set!"); } } ``` 5. 使用 Spring 事件监听器:通过监听 Spring 事件,执行特定代码(非常灵活,可以监听启动过程中的不同阶段) ```java @Component public class MyEventListener { @EventListener public void onApplicationEvent(ContextRefreshedEvent event) { System.out.println("EventListener: Context refreshed!"); } } ``` 6. 实现 BeanPostProcessor 接口:在初始化 Bean 之前或之后执行(注意它会作用于容器中的所有 Bean,注意做好类型判断,不要影响其他 Bean。AOP、`@Autowired`注入、`@Value`解析都是通过它实现的) ```java @Component public class MyBeanPostProcessor implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { // bean 初始化前 if (bean instanceof MySpecificBean) { System.out.println("BeanPostProcessor: Before initialization"); } return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { // bean 初始化后 if (bean instanceof MySpecificBean) { System.out.println("BeanPostProcessor: After initialization"); } return bean; } } ``` ## 9. <font style="color:rgb(51, 51, 51);">SpringBoot 的核心注解</font> 1. `@SpringBootApplication` :它由`@Configuration`、`@EnableAutoConfiguration`、`@ComponentScan`这三个注解组成 1. `@Configuration`:表示该类是 Spring 的配置类 2. `@EnableAutoConfiguration`:启用 SpringBoot 的自动配置功能,根据类路径中的依赖自动配置 Spring 中的各种组件 3. `@ComponentScan`:自动扫描当前包及其子包带有 Spring 注解的类(比如`@Controller`、`@Service`等) ## 10. <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">@Async </font><font style="color:rgb(51, 51, 51);">什么时候会失效</font> 1. 它的底层是通过 AOP 代理来实现的,只有调用走代理对象的方法,才能被拦截丢到线程池去执行,所以`@Async`失效的根本原因就是代理没生效! 2. 场景一:同类内部调用,调用 this.method() 指向原始对象,没经过 AOP 代理,注解失效(可以把异步方法拆到另一个类里/自己注入自己/配置`@EnableAspectJAutoProxy(exposeProxy= true)`,用AopContext.currentProxy() 拿到代理对象再调用) ```java @Service public class OrderService { public void createOrder() { // 失效!this 指向原始对象,不是代理 this.sendNotification(); } @Async public void sendNotification() { // 期望异步执行,但实际是同步的 } } //解决方案一:把异步方法拆到另一个类里 @Service public class OrderService { @Autowired private NotificationService notificationService; public void createOrder() { // 走的是 NotificationService 的代理对象 notificationService.sendNotification(); } } @Service public class NotificationService { @Async public void sendNotification() { // 这次能异步执行了 } } //解决方案二:自己注入自己 @Service public class OrderService { @Autowired private OrderService self; // 注入的是代理对象 public void createOrder() { self.sendNotification(); // 走代理 } @Async public void sendNotification() { } } //解决方案三:配置@EnableAspectJAutoProxy(exposeProxy= true),用AopContext.currentProxy()拿到代理对象再调用 @EnableAsync @EnableAspectJAutoProxy(exposeProxy = true) @SpringBootApplication public class Application { } @Service public class OrderService { public void createOrder() { // 拿到代理对象再调用 ((OrderService) AopContext.currentProxy()).sendNotification(); } @Async public void sendNotification() { } } ``` 3. 场景二:非 public 方法,JDK/CGLIB 动态代理无法拦截 private、protected 方法(JDK动态代理基于 java 反射,生成一个实现接口的代理类,因此目标类至少实现一个接口;CGLIB 基于字节码增强,通过继承目标类生成子类;Spring 默认有接口用 JDK,没有接口用 CGLIB,但它们俩都不能代理 final 类和方法) 4. 场景三:未开启异步支持,即配置类/启动类缺少`@EnableAsync`注解,Spring 不会扫描并创建异步代理 5. 场景四:对象非 Spring 代理,手动 new 出来的对象脱离容器,Spring 无法感知 6. 场景五:返回值类型错误,返回值只能是 void/Future/CompletableFuture 类型,否则调用方只能拿到 null 7. 深入说说`@Async`代理机制的原理?`@Async`的实现靠的是 AsyncAnnotationBeanPostProcessor 这个后置处理器,Spring 启动时这个处理器会扫描所有 Bean,发现有`@Async`注解的方法就给这个 Bean 套一层代理。代理对象在方法被调用时,判断方法上有没有`@Async`,有的话就把这个任务包装成 Callable 任务,丢到线程池去执行,然后立即返回 8. 对于异常处理的方式?如果返回 Future/CompletableFuture,调用方可以通过 get()/exceptionally 拿到异常;如果返回值是 void,需要自己实现 AsyncUncaughtExceptionHandler 处理异常 ```java @Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { // 记录日志、发告警 log.error("Async method {} threw exception: {}", method.getName(), ex.getMessage()); }; } } ``` ## 11. <font style="color:rgb(51, 51, 51);">SpringBoot 中读取配置属性的方法有哪些</font> 1. `@Value`:单属性注入,只支持精确匹配!(比如@Value("${my-app}"),配置文件也要 my-app 这样命名;也就是说注入值和配置文件中的属性命名必须相同);注入时如果属性不存在配置文件中,会报错`IllegalArgumentException`,所以最好指定一个默认值 ```java @Value("${my.timeout:5000}") // 带默认值 private int timeout; ``` 2. `@ConfigurationProperties`:批量属性绑定,配置多/层级结构,支持松散绑定,即配置文件中的属性名不需要和 Java 字段名完全一致(比如对于 myApp 字段,配置文件中 myApp/my_app/my-app/MY_APP都可以进行绑定) ```java @Component @ConfigurationProperties(prefix = "my.custom") public class MyCustomProperties { private String property; private int timeout; private List<String> servers; // getter/setter } ``` 3. Environment:编程式获取,工具类或框架中用的多 ```java @Autowired private Environment env; public void someMethod() { String value = env.getProperty("my.custom.property"); int timeout = env.getProperty("my.timeout", Integer.class, 5000); } ``` ## 12. <font style="color:rgb(51, 51, 51);">配置文件加载的优先级</font> 1. SpringBoot 的配置加载遵循“外部优先、profile优先、命令行最高”的原则 2. 命令行参数>环境变量> jar 包外的 application-{profile}.properties > jar 包外的 application.properties > jar 包内的 application-{profile}.properties > jar 包内的 application.properties (同一位置 .properties > .yml ) ## 13. <font style="color:rgb(51, 51, 51);">说一说你对 SpringBoot 事件机制的了解</font> 1. 本质就是发布-订阅模式,基于 Spring 的框架体系实现,让应用内部各个组件之间能够解耦通信 2. 流程:通过发布器(ApplicationEventPublisher)把事件发布出去 -> 广播器(ApplicationEventMulticaster)接收事件并广播给所有注册的监听器 -> 监听器收到事件后执行对应的逻辑处理 3. 发布者不需要关心谁在监听,监听器也不用关心事件是谁发的,两边完全解耦 4. 应用场景:预热缓存、建立连接池、监听 ApplicationReadyEvent、业务解耦、异步处理等 5. SpringBoot 内置了一系列内置事件,我将按顺序写出 1. ApplicationStartingEvent:run 方法被调用,日志和监听器还没初始化 2. ApplicationEnvironmentPreparedEvent:环境准备好了,但 ApplicationContext 还没创建 3. ApplicationContextInitializedEvent:ApplicationContext 创建了,Bean 还没加载 4. ApplicationPreparedEvent:Bean 加载完成,但还未刷新 5. ContextRefreshedEvent:ApplicationContext 刷新完成 6. ApplicationStartedEvent:应用启动完成,CommandLineRunner 还没执行 7. ApplicationReadyEvent:已经就绪,可以接收请求了 8. ApplicationFailedEvent:启动失败触发 6. 自定义事件:Spring 4.2 开始,事件对象不再强制继承 ApplicationEvent,可以是任意 pojo;监听器也不用实现 ApplicationListener 接口,使用`@EventListener`注解即可 ```java // 定义事件 public class OrderCreatedEvent { private Long orderId; private BigDecimal amount; public OrderCreatedEvent(Long orderId, BigDecimal amount) { this.orderId = orderId; this.amount = amount; } // getter... } //发布事件 @Service public class OrderService { @Autowired private ApplicationEventPublisher eventPublisher; @Transactional public void createOrder(Order order) { // 保存订单 orderRepository.save(order); // 发布事件 eventPublisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getAmount())); } } //监听事件 @Component public class OrderEventListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 发送通知、更新统计等 log.info("订单创建成功: {}", event.getOrderId()); } } ``` 7. 事务绑定事件:假设有一个情况,用户下单之后立刻发送事件,如果用的是普通的`@EventLintener`,事件在事务提交前就发出去了,一旦事务回滚,造成的信息就会不一致了,这时可以使用`@TransactionEventLintener`来绑定事件触发时机,比如事务提交可以用`@TransactionEventLintener(phase = TransactionPhase.AFTER_COMMIT)`,它有四个配置,分别为,`before_commit/after_commit/after_rollback/after_completion` 8. 与消息队列的区别:Spring 事件机制是进程内的,事件发完如果 JVM 挂了事件就丢了;而消息队列可以在消费者重启后继续消费。所以单体应用内部解耦用 Spring 事件即可,涉及到可靠性的场景就要用消息队列了 ## 14. <font style="color:rgb(51, 51, 51);">SpringBoot 中如何配置多数据源</font> 1. 在配置文件(application.yml)里定义多个数据源的连接信息,可以用不同前缀区分 2. 为每个数据源写一个配置类,创建 DataSource、SqlSessionFactory、TransactionManager 三个 Bean,主数据源加`@Primary` 3. Mapper 接口按数据源分包,配置类上用`@MapperScan`绑定包路径和 SqlSessionFactory 4. Service 层调用不同包下的 Mapper 时,自动走对应的数据源。使用`@Transactional`时通过 transactionManager 参数指定用哪个事务管理器 ```yaml spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/order_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver secondary: jdbc-url: jdbc:mysql://localhost:3306/user_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver ``` ```java // 【静态数据源】主配置类(其他配置类一样,去掉@Primary,替换前缀和包路径即可) @Configuration @MapperScan(basePackages = "com.example.mapper.primary", sqlSessionFactoryRef = "primarySqlSessionFactory") public class PrimaryDataSourceConfig { @Primary @Bean("primaryDataSource") @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Primary @Bean("primarySqlSessionFactory") public SqlSessionFactory primarySqlSessionFactory(@Qualifier("primaryDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/primary/*.xml")); return factoryBean.getObject(); } @Primary @Bean("primaryTransactionManager") public DataSourceTransactionManager primaryTransactionManager(@Qualifier("primaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } } ``` 5. 对于读写分离、多租户等场景,需要使用动态数据源。实现思路是用一个路由数据源包装多个实际数据源,每次获取连接时根据 ThreadLocal 里存的标识决定走哪个数据源 ```java //数据源上下文 public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSource(String dataSource) { CONTEXT.set(dataSource); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } } //路由数据源 public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } } //路由数据源配置类 @Configuration public class DynamicDataSourceConfig { @Bean("dynamicDataSource") public DataSource dynamicDataSource( @Qualifier("primaryDataSource") DataSource primary, @Qualifier("secondaryDataSource") DataSource secondary) { DynamicDataSource dynamic = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("primary", primary); targetDataSources.put("secondary", secondary); dynamic.setTargetDataSources(targetDataSources); dynamic.setDefaultTargetDataSource(primary); return dynamic; } } //实现AOP注解 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "primary"; } @Aspect @Component public class DataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint point, DS ds) throws Throwable { DataSourceContextHolder.setDataSource(ds.value()); try { return point.proceed(); } finally { DataSourceContextHolder.clear(); } } } //使用 @Service public class UserService { //如果需要事务,要在切换数据源之后再开启,否则切换数据源后事务会失效(或者在DataSourceAspect切面类添加 @Order(-1)或者显式用事务管理器@Transactional("secondaryTransactionManager")) @DS("secondary") @Transactional public User getUser(Long id) { return userMapper.selectById(id); } } ``` 6. `@Qualifier`:当同一类型的 Bean 有多个时,精确告诉 Spring 要用哪一个;`@Primary`:在依赖注入时默认选择被他标记的 Bean ## 15. <font style="color:rgb(51, 51, 51);">多数据源场景下如何实现读写分离</font> 1. 直接在方法指定,如`@DS("secondary")` 2. 可以使用 AOP 拦截 Service 方法,根据方法名前缀判断读写,比如 get/query/find 开头的走从库, insert/update/delete 开头的走主库 3. 编写 AOP 拦截`@Transactional`注解并判断 readOnly 属性,为 true 走从库,否则走主库 ## 16. <font style="color:rgb(51, 51, 51);">SpringBoot 如何处理跨域</font> 1. 局部:在 Controller 方法加上`@CrossOrigin`注解 2. 全局:实现 WebMvcConfigurer 接口,重写 addCorsMappings 方法 3. 过滤器:CorsFilter,它是在 Servlet 层面处理的,优先级比拦截器高。能避免一些问题(比如 JWT 校验场景:Cors 的拦截器在后面,如果在前面的 JWT 拦截器被拦截了,根本走不到 Cors 拦截器,直接就返回了,此时浏览器就会报跨域问题,就像 addCorsMappings 失效了似的,解决办法是在 JWT 的拦截器中放行预检请求) 4. 什么是跨域呢?浏览器有个同源策略,只有当协议/域名/端口这三个一致的时候才算同源,不同源的请求会被浏览器拦截。解决的本质就是让服务端在响应头里告诉浏览器,我允许某个域来访问。关键参数有域、请求方式、请求头等 5. 那预检请求又是什么?它主要是针对复杂请求(自定义请求头 PUT/DELEET 方法),浏览器会先发送一个预检请求 options,问一下服务端行不行,等预检通过后才发送真正的请求。对于简单的请求(GET/HEAD/POST)会直接发送,服务端会返回带 Cors 头的响应,浏览器检查响应头决定是否允许读取。浏览器会缓存预检结果,这也就意味着不用每次请求都先发个预检请求,只有缓存过期或请求头、请求方法发生变更才重新预检 6. 在实际开发中大多数都是复杂请求,因为用了 JSON,Content-Type 是application/json(简单请求的Content-Type 是`application/x-www-form-urlencoded`/`multipart/form-data`/`text/plain`) ## 17. <font style="color:rgb(51, 51, 51);">拦截器的使用方式</font> 1. 实现 HandlerInterceptor 接口,重写里面的三个方法;然后写一个配置类实现 WebMvcConfigurer 接口,在 addInterceptors 方法里把拦截器注册进去。注意如果你注册拦截器时用的是 new MyInterceptor() 的方式,那么 MyInterceptor 这个对象就不归 Spring 管理,MyInterceptor 里面使用`@Autowired`注入的依赖都是 null,所以最好就是直接把拦截器也声明为 Bean,在配置类里注入 ```java @Component public class MyInterceptor implements HandlerInterceptor { //注意:如果注册拦截器时使用的是 new MyInterceptor(),那么 userService 为 null @Autowired private UserService userService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("请求开始前"); return true; // 返回 true 继续处理,false 则拦截请求 } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { System.out.println("请求后"); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception exception) throws Exception { System.out.println("请求完成"); } } ``` ```java @Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private MyInterceptor myInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(myInterceptor).addPathPatterns("/api/**"); } } ``` 2. 拦截器的执行流程:preHandle->Controller->postHandle->afterCompletion,其中 afterCompletion 一定会执行,不管 Controller 方法是否执行成功,类似于 finally 3. 拦截器的执行顺序:对于 preHandle`@Order()`的值越小越先执行,而对于另外两个,越小越后执行,就类似于以 Controller 为轴,执行顺序对称(注意如果是在 addInterceptors 里注册会以注册顺序为准,越先注册越先执行) 4. 过滤器于拦截器的区别:过滤器是 Servlet 层面的,位于整个请求链最前面,比 Spring 中的组件都早执行,能拿到原始的 ServletRequest和ServletResponse,但拿不到 Spring 的上下文信息(用于字符编码、跨域);拦截器则是 Spring MVC 的组件,能拿到 HandlerMethod,知道要执行哪个 Controller 方法,还能注入 Spring Bean(用于权限校验、操作日志) ## 18. <font style="color:rgb(51, 51, 51);">为什么Spring不推荐 </font><font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">@Autowired</font> <font style="color:rgb(51, 51, 51);">字段注入</font> 1. 会触发空指针问题:字段注入发生在构造器执行之后,所以在构造器里访问被注入的字段必为 null。(使用构造器注入就没这个问题) ```java @Service public class OrderService { @Autowired private UserService userService; private String defaultUser; public OrderService() { // 这里 userService 还是 null this.defaultUser = userService.getDefaultUser(); } } ``` 2. 对单元测试不友好:字段注入的依赖是 private,外部无法直接设置,想进行单元测试就要启动整个容器。 ```java // 字段注入,测试很难搞 @Test void testOrder() { OrderService service = new OrderService(); // userService 是 null,没法测 } // 构造器注入,直接 new 就能测 @Test void testOrder() { UserService mockUser = Mockito.mock(UserService.class); OrderService service = new OrderService(mockUser, mockProduct); // 正常测试 } ``` 3. 掩盖循环依赖:Spring 的三级缓存能解决注入字段的循环依赖。(构造器注入遇到循环依赖就报错,透明) 4. 容易违反单一原则:字段注入加个注解就行了,不知不觉能注入十几个依赖。(而使用构造器注入,参数列表会越来越长,提示着你要不要考虑拆分职责) 5. 字段不能声明为 final,对象状态可变。(构造器注入可以声明为 final,确保对象不可变) 6. `@Autowired`与`@Resource`的区别?`@Autowired`是 Spring 注解,按类型匹配;`@Resource`是 Java 标准 JSR-250 定义的,按名称匹配。如果坚持用字段注入,`@Resource`比`@Autowired`要好一点,至少代码没有和 Spring 强绑定,换成其他 IOC 容器也能跑 7. 可以使用 Lombock 的`@RequiredArgsConstrucuor`简化构造器注入,只要把字段声明为 final,Lombock会自动生成包含这些字段的构造器。代码量更低,却拥有构造器注入的所有优点 ## 19. <font style="color:rgb(51, 51, 51);">SpringBoot 的 1、2、3 版本有什么变化</font> 1. SpringBoot 1.x -> 2.x 1. 底层框架:Spring 4 -> Spring 5,传统是 Servlet 阻塞模型,新增响应式编程模型(WebFlux + Reactor) 2. 嵌入容器:Tomcat 8.x -> Tomcat 9.x;Jetty 9.x -> Jetty9.0.x;Undertow 1.x -> Undertow 2.x 3. 连接池:Tomcat JDBC -> HikariCP,在字节码、连接管理、默认配置等方面做了优化 4. 监控:Actuator 端点默认全开 -> 默认关闭大部分端点,默认只暴露 /health、/info 两个端点 5. 部分属性名做了调整:比如`server.contextPath`->`server.servlet.context-path`(可以使用`spring-boot-properties-migrator`依赖,它会在启动时扫描出所有过时的配置并打印警告) 6. 支持 Http2:`server.http2.enabled=true` 7. JDK版本:推荐 11 或更高版本,最低为 JDK 8 2. SpringBoot 2.x -> 3.x 1. javax.* -> jakarta.*,Servlet、JPA、JMS 这些 API 都要变 2. JDK17 起步,主要是因为用新特性来优化框架本身 3. GraalVM 原生编译,把 Java 代码在构建阶段就编译成机器码,运行时不需要 JVM,编译出来的可执行文件启动时间能从秒级压缩到毫秒级,内存占用也大大减小(缺点就是反射、动态代理这些需要提前配置 metadata,不然运行会报错) 4. Observability 可观测性升级,分为三个维度,指标(Metrics)、链路(Tracing)、日志(Logging),Micrometer Tracing 代替了老的 Spring Cloud Sleuth,直接内置在 SpringBoot 里 5. Spring Security 6 认证授权升级,完善 OAuth 2.1 和 OIDC 支持,彻底废弃WebSecurityConfigurerAdapter,改用 SecurityFilterChain Bean 的方式。对老代码有破坏性变化
别再手撸架构图了!我写了个 AI 工具,把 Spring Boot 代码一键变成 Draw.io 流程图
## 前言 在之前的博客中,我曾介绍过一款我自主研发的AI驱动在线协作绘图平台——[IntelliDraw(在线访问地址:https://www.intellidraw.top)。经过持续优化与创新,](https://www.intellidraw.top/)IntelliDraw现已升级为一个功能更为强大的开发辅助工具,能够: * 基于用户上传的数据库SQL文件自动生成实体关系图(ER Diagram) * 支持Java Spring Boot项目代码分析,可一键上传项目ZIP包并生成完整的项目架构图,清晰展示各层级间的调用关系\ 本次更新不仅带来了功能上的扩展,还对系统性能进行了全面优化: * 通过改进后端提示词(Prompt)算法,将模型调用的token消耗降低了60% * 引入了先进的RAG(Retrieval-Augmented Generation)功能\ 这些升级让IntelliDraw在技术开发和团队协作场景中展现出更大的应用价值。如果您对这个工具感兴趣或有试用体验,欢迎随时交流! ## 技术实现 ### Spring Boot 项目解析 使用了 JavaParser 库实现的 Java 类的解析,maven 依赖如下 <!-- Source: https://mvnrepository.com/artifact/com.github.javaparser/javaparser-core --> <dependency> <groupId>com.github.javaparser</groupId> <artifactId>javaparser-core</artifactId> <version>3.25.8</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.github.javaparser</groupId> <artifactId>javaparser-symbol-solver-core</artifactId> <version>3.25.8</version> <scope>compile</scope> </dependency> 通过JavaParser库解析Java代码,生成抽象语法树(AST),以便分析Spring Boot项目的结构和依赖关系。AST是计算机理解代码的逻辑结构,类似于将代码分解为语法节点。例如,int x = 10 + 10;的AST包括变量声明、赋值和加法运算节点。解析整个项目时,需识别包、类、方法、字段、注解及依赖关系,特别是Spring Bean的注解。生成项目架构图时,AST需包含模块划分、层次结构、依赖关系和调用链路,以展示项目的组织和功能流程。 #### 抽象语法树(AST)的定义与作用 抽象语法树(AST)是编程语言源代码的抽象语法结构的树形表示。它帮助计算机理解代码的结构,使代码能够被分析和处理。AST忽略了代码中的语法细节,专注于代码的逻辑结构。\ AST示例解析 1. 加法运算的AST结构\ 代码:int x = 10 + 10;\ AST分解:\ ○ 变量声明节点:int x\ ○ 赋值操作节点:=\ ○ 加法运算节点:+,包含两个子节点10和10 2. If语句的AST结构\ 代码:if (a > 10) { break; }\ AST分解:\ ○ 条件判断节点:if\ ○ 条件表达式节点:a > 10\ ○ 代码块节点:break;\ JavaParser在Spring Boot项目中的应用\ 使用JavaParser库解析整个Spring Boot项目时,需要解析以下内容: 3. 包和类结构\ ○ 包名\ ○ 类名\ ○ 类的修饰符(如public、final) 4. 类成员\ ○ 方法:包括方法名、参数、返回类型、访问修饰符\ ○ 字段:包括字段名、类型、访问修饰符 5. 注解\ ○ 特别是Spring相关的注解,如@Component、@Service、@Autowired等,用于识别Spring Bean及其依赖关系 6. 依赖关系\ ○ 通过注解和依赖注入机制,解析类之间的依赖关系,如@Autowired注入的字段或方法参数 7. 调用关系\ ○ 分析方法之间的调用,构建方法调用链路\ 生成项目架构图所需的AST特征\ 为了生成清晰的项目架构图,AST需要包含以下信息: 8. 模块划分\ ○ 根据包名或注解信息,将项目划分为不同的模块或层(如Controller、Service、Repository) 9. 层次结构\ ○ 展示模块之间的层次关系,如分层架构(表现层、业务逻辑层、数据访问层) 10. 依赖关系\ ○ 通过注解和依赖注入信息,展示类之间的依赖关系,帮助识别高耦合模块 11. 调用链路\ ○ 展示方法之间的调用顺序,帮助理解功能流程 12. 接口和实现\ ○ 展示接口与其实现类之间的关系,帮助理解抽象与具体实现的对应 这里我设计了几个通用的 DTO package com.wfh.drawio.core.model; import lombok.Data; import java.util.List; /** * 1. 架构图中的“节点” (比如一个类、一个表、一个微服务) * @author fenghuanwang */ @Data public class ArchNode { private String id; // 唯一标识 (e.g., "com.example.UserService") private String name; // 显示名称 (e.g., "UserService") private String type; // 类型 (e.g., "CLASS", "INTERFACE", "TABLE", "SERVICE") private String stereotype; // 构造型/注解 (e.g., "@Controller", "@Repository") private List<String> fields; // 关键字段 // 新增字段:用于存放 API 路由列表、SQL 表名或其他备注 private String description; // 建议新增:用于分组(比如按包名分组,画出子图) private String group; private List<String> methods; // 关键方法 } 这个就类似于架构图中的一个节点,或者说是一个服务。 package com.wfh.drawio.core.model; import lombok.Data; /** * 2. 架构图中的“连线” (比如继承、调用、外键) * @author fenghuanwang */ @Data public class ArchRelationship { private String sourceId; // 起点 private String targetId; // 终点 private String type; // 关系类型 (e.g., "DEPENDS_ON", "INHERITS", "CALLS", "FOREIGN_KEY") private String label; // 连线上的文字 (e.g., "findUserById") } 这个就是架构图中的连线,放到代码中就是服务之间的相互调用之类的。 /** * 3. 完整的分析结果 * @author fenghuanwang */ @Data public class ProjectAnalysisResult { private List<ArchNode> nodes; private List<ArchRelationship> relationships; } 而这个就是完整的分析结果了,也就是要返回给前端的东西 那这个 Java 解析器具体代码逻辑是怎么样的呢 下面是完整的代码,这段代码是使用了 Java 的 SPI 机制实现的,具体什么是 SPI 机制,可以去我的上一篇文章中寻找 package com.wfh.drawio.spi.parser; import com.github.javaparser.JavaParser; import com.github.javaparser.ParserConfiguration; import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.ast.ImportDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.comments.Comment; import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.symbolsolver.JavaSymbolSolver; import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; import com.github.javaparser.symbolsolver.resolution.typesolvers.JavaParserTypeSolver; import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver; import com.wfh.drawio.core.model.ArchNode; import com.wfh.drawio.core.model.ArchRelationship; import com.wfh.drawio.core.model.ProjectAnalysisResult; import com.wfh.drawio.spi.LanguageParser; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; /** * @Title: JavaSpringParser * @Author wangfenghuan * @description: 极致优化的 Spring Boot 项目解析器 (支持 Javadoc 提取) */ @Slf4j public class JavaSpringParser implements LanguageParser { private static final Map<String, String> STEREOTYPE_MAPPING = Map.of( "RestController", "API Layer", "Controller", "API Layer", "Service", "Business Layer", "Repository", "Data Layer", "Mapper", "Data Layer", "Component", "Infrastructure", "Configuration", "Infrastructure", "Entity", "Data Layer", "Table", "Data Layer" ); private static final Set<String> IGNORED_SUFFIXES = Set.of( "Test", "Tests", "DTO", "VO", "Request", "Response", "Exception", "Constant", "Config", "Utils", "Properties" ); private static final Set<String> IGNORED_PACKAGES = Set.of( "java.", "javax.", "jakarta.", "org.springframework.", "org.slf4j.", "org.apache.", "com.baomidou.", "lombok.", "com.fasterxml." ); @Override public String getName() { return "Java-Spring-Doc-Enhanced"; } @Override public boolean canParse(String projectDir) { Path rootPath = Paths.get(projectDir); if (!Files.exists(rootPath) || !Files.isDirectory(rootPath)) return false; try (Stream<Path> stream = Files.walk(rootPath, 2)) { return stream.filter(Files::isRegularFile).anyMatch(p -> { String name = p.getFileName().toString(); return name.equals("pom.xml") || name.equals("build.gradle") || name.equals("build.gradle.kts"); }); } catch (IOException e) { return false; } } @Override public ProjectAnalysisResult parse(String projectDir) { log.info("Starting Analysis with Javadoc Extraction: {}", projectDir); JavaParser javaParser = initializeSymbolSolver(projectDir); List<ArchNode> rawNodes = new ArrayList<>(); List<ArchRelationship> rawRelationships = new ArrayList<>(); Set<String> processedClasses = new HashSet<>(); try { List<Path> javaFiles = findJavaFiles(projectDir); for (Path javaFile : javaFiles) { try { CompilationUnit cu = javaParser.parse(javaFile).getResult().orElse(null); if (cu == null) continue; String packageName = cu.getPackageDeclaration().map(pd -> pd.getNameAsString()).orElse(""); cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> { String className = clazz.getNameAsString(); if (isIgnoredClass(className)) return; String fullClassName = getFullyQualifiedName(clazz, packageName); if (processedClasses.contains(fullClassName)) return; ArchNode node = analyzeClass(clazz, fullClassName); if (node != null) rawNodes.add(node); rawRelationships.addAll(analyzeRelationships(clazz, fullClassName, cu)); processedClasses.add(fullClassName); }); } catch (Exception e) { log.warn("Parse error: {}", e.getMessage()); } } } catch (IOException e) { log.error("IO Error", e); } return optimizeResult(rawNodes, rawRelationships); } private ArchNode analyzeClass(ClassOrInterfaceDeclaration clazz, String id) { ArchNode node = new ArchNode(); node.setId(id); node.setName(clazz.getNameAsString()); // 1. 提取 Javadoc 注释 (新增功能) String comment = extractClassComment(clazz); String type = "Class"; String stereotype = "Model"; if (clazz.isInterface()) type = "Interface"; List<String> annotations = clazz.getAnnotations().stream().map(a -> a.getNameAsString()).collect(Collectors.toList()); if (clazz.getNameAsString().endsWith("Mapper") || annotations.contains("Mapper")) { type = "Interface"; stereotype = "Data Layer"; } for (Map.Entry<String, String> entry : STEREOTYPE_MAPPING.entrySet()) { if (annotations.stream().anyMatch(a -> a.contains(entry.getKey()))) { type = entry.getKey().toUpperCase(); stereotype = entry.getValue(); break; } } node.setType(type); node.setStereotype(stereotype); // 2. 构建 Description (合并 注释 + 技术细节) List<String> descParts = new ArrayList<>(); // Part A: 中文注释 if (comment != null && !comment.isEmpty()) { descParts.add(comment); } // Part B: 技术细节 (API 路由 或 表名) if ("API Layer".equals(stereotype)) { List<String> routes = extractApiRoutes(clazz); if (!routes.isEmpty()) { descParts.add("APIs:\n" + String.join("\n", routes)); } } else if (clazz.getNameAsString().endsWith("Entity") || annotations.contains("TableName")) { extractTableName(clazz).ifPresent(t -> descParts.add("Table: " + t)); } if (!descParts.isEmpty()) { node.setDescription(String.join("\n\n", descParts)); } node.setFields(Collections.emptyList()); node.setMethods(Collections.emptyList()); return node; } /** * 新增:提取并清洗 Javadoc */ private String extractClassComment(ClassOrInterfaceDeclaration clazz) { return clazz.getComment() .map(Comment::getContent) .map(this::cleanJavadoc) .orElse(""); } /** * 清洗 Javadoc:去除 * 号、@author 等标签 */ private String cleanJavadoc(String content) { if (content == null) return ""; String[] lines = content.split("\n"); StringBuilder sb = new StringBuilder(); for (String line : lines) { // 去除开头的 * 和空格 String cleanLine = line.trim().replaceAll("^\*+\s?", "").trim(); // 遇到 @ 标签(如 @author, @date)停止读取,或者跳过 // 这里策略是:只读取第一段描述,遇到 @ 就停止,通常第一段是核心描述 if (cleanLine.startsWith("@")) { break; } // 忽略空行 if (!cleanLine.isEmpty()) { sb.append(cleanLine).append(" "); // 将多行描述合并为一行 } } return sb.toString().trim(); } private ProjectAnalysisResult optimizeResult(List<ArchNode> nodes, List<ArchRelationship> relationships) { Set<String> validNodeIds = nodes.stream().map(ArchNode::getId).collect(Collectors.toSet()); List<ArchRelationship> cleanRelationships = relationships.stream() .filter(r -> validNodeIds.contains(r.getSourceId()) && validNodeIds.contains(r.getTargetId())) .filter(r -> !r.getSourceId().equals(r.getTargetId())) .collect(Collectors.toList()); Set<String> connectedNodes = new HashSet<>(); cleanRelationships.forEach(r -> { connectedNodes.add(r.getSourceId()); connectedNodes.add(r.getTargetId()); }); List<ArchNode> cleanNodes = nodes.stream() .filter(n -> { if ("API Layer".equals(n.getStereotype())) return true; return connectedNodes.contains(n.getId()); }) .collect(Collectors.toList()); ProjectAnalysisResult result = new ProjectAnalysisResult(); result.setNodes(cleanNodes); result.setRelationships(cleanRelationships); return result; } private List<ArchRelationship> analyzeRelationships(ClassOrInterfaceDeclaration clazz, String sourceId, CompilationUnit cu) { List<ArchRelationship> rels = new ArrayList<>(); clazz.getExtendedTypes().forEach(t -> addRel(rels, sourceId, resolveType(t, cu), "EXTENDS")); clazz.getImplementedTypes().forEach(t -> addRel(rels, sourceId, resolveType(t, cu), "IMPLEMENTS")); clazz.getFields().forEach(field -> { if (field.isAnnotationPresent("Autowired") || field.isAnnotationPresent("Resource") || field.isAnnotationPresent("Inject")) { field.getVariables().forEach(v -> addRel(rels, sourceId, resolveType(field.getElementType(), cu), "DEPENDS_ON")); } }); clazz.getConstructors().forEach(c -> c.getParameters().forEach(p -> addRel(rels, sourceId, resolveType(p.getType(), cu), "DEPENDS_ON"))); return rels; } private boolean isIgnoredClass(String className) { return IGNORED_SUFFIXES.stream().anyMatch(className::endsWith); } private boolean isIgnoredPackage(String typeName) { if (typeName == null) return true; return IGNORED_PACKAGES.stream().anyMatch(typeName::startsWith); } private void addRel(List<ArchRelationship> rels, String src, String target, String type) { if (target == null || target.equals(src) || isIgnoredPackage(target)) return; ArchRelationship rel = new ArchRelationship(); rel.setSourceId(src); rel.setTargetId(target); rel.setType(type); rels.add(rel); } private JavaParser initializeSymbolSolver(String projectDir) { CombinedTypeSolver combinedSolver = new CombinedTypeSolver(); combinedSolver.add(new ReflectionTypeSolver()); Path srcPath = Paths.get(projectDir, "src", "main", "java"); if (Files.exists(srcPath)) { combinedSolver.add(new JavaParserTypeSolver(srcPath)); } else { combinedSolver.add(new JavaParserTypeSolver(new File(projectDir))); } ParserConfiguration config = new ParserConfiguration(); config.setSymbolResolver(new JavaSymbolSolver(combinedSolver)); return new JavaParser(config); } private String resolveType(ClassOrInterfaceType type, CompilationUnit cu) { try { return type.resolve().asReferenceType().getQualifiedName(); } catch (Exception e) { return inferFullyQualifiedNameFromImports(type.getNameAsString(), cu); } } private String resolveType(com.github.javaparser.ast.type.Type type, CompilationUnit cu) { try { if (type.isClassOrInterfaceType()) return type.asClassOrInterfaceType().resolve().asReferenceType().getQualifiedName(); return null; } catch (Exception e) { return inferFullyQualifiedNameFromImports(type.asString(), cu); } } private String inferFullyQualifiedNameFromImports(String simpleName, CompilationUnit cu) { Optional<ImportDeclaration> match = cu.getImports().stream() .filter(i -> !i.isAsterisk() && i.getNameAsString().endsWith("." + simpleName)) .findFirst(); if (match.isPresent()) return match.get().getNameAsString(); String packageName = cu.getPackageDeclaration().map(pd -> pd.getNameAsString()).orElse(""); return packageName.isEmpty() ? simpleName : packageName + "." + simpleName; } private String getFullyQualifiedName(ClassOrInterfaceDeclaration clazz, String packageName) { return packageName.isEmpty() ? clazz.getNameAsString() : packageName + "." + clazz.getNameAsString(); } private List<String> extractApiRoutes(ClassOrInterfaceDeclaration clazz) { List<String> routes = new ArrayList<>(); String basePath = ""; Optional<AnnotationExpr> classMapping = clazz.getAnnotationByName("RequestMapping"); if (classMapping.isPresent()) basePath = extractPath(classMapping.get()); String finalBasePath = basePath; clazz.getMethods().forEach(m -> { Stream.of("GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "RequestMapping").forEach(methodType -> { m.getAnnotationByName(methodType).ifPresent(ann -> { String methodPath = extractPath(ann); String httpMethod = methodType.replace("Mapping", "").toUpperCase(); if ("REQUEST".equals(httpMethod)) httpMethod = "ALL"; routes.add("[" + httpMethod + "] " + (finalBasePath + methodPath).replaceAll("//", "/")); }); }); }); return routes; } private String extractPath(AnnotationExpr ann) { if (ann.isSingleMemberAnnotationExpr()) { return ann.asSingleMemberAnnotationExpr().getMemberValue().toString().replace(""", ""); } else if (ann.isNormalAnnotationExpr()) { return ann.asNormalAnnotationExpr().getPairs().stream() .filter(p -> p.getNameAsString().equals("value") || p.getNameAsString().equals("path")) .findFirst().map(p -> p.getValue().toString().replace(""", "")).orElse(""); } return ""; } private Optional<String> extractTableName(ClassOrInterfaceDeclaration clazz) { return clazz.getAnnotationByName("TableName").map(this::extractPath) .or(() -> clazz.getAnnotationByName("Table").map(this::extractPath)); } private List<Path> findJavaFiles(String projectPath) throws IOException { try (Stream<Path> paths = Files.walk(Paths.get(projectPath))) { return paths.filter(Files::isRegularFile).filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); } } }  ### SQL 语句解析 在实现SQL解析功能时,我选择了使用阿里巴巴开源的Druid(德鲁伊)项目中的SQLUtil模块。这一选择是基于Druid在SQL解析领域的强大功能和良好性能,同时其丰富的API和活跃的开源社区也为项目开发提供了有力支持。\ 在具体实现上,我采用了插件化(Service Provider Interface, SPI)的设计模式。通过定义统一的接口规范, 解析器代码如下 package com.wfh.drawio.spi.parser; import com.alibaba.druid.sql.SQLUtils; import com.alibaba.druid.sql.ast.SQLStatement; import com.alibaba.druid.sql.ast.statement.*; import com.alibaba.druid.util.JdbcConstants; import com.wfh.drawio.core.model.ArchNode; import com.wfh.drawio.core.model.ArchRelationship; import com.wfh.drawio.core.model.ProjectAnalysisResult; import com.wfh.drawio.spi.LanguageParser; import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; /** * @Title: SqlParser * @Author wangfenghuan * @description: 优化版 SQL 解析器 (已修复 Lambda 变量 Final 问题) */ @Slf4j public class SqlParser implements LanguageParser { private static final Set<String> IGNORED_COLUMNS = Set.of( "create_time", "update_time", "create_at", "update_at", "created_time", "updated_time", "created_at", "updated_at", "is_delete", "is_deleted", "version", "revision" ); @Override public String getName() { return "SQL-DDL-Enhanced"; } @Override public boolean canParse(String projectDir) { File file = new File(projectDir); if (!file.exists()) { return false; } if (file.isFile()) { return file.getName().toLowerCase().endsWith(".sql"); } try (Stream<Path> paths = Files.walk(Paths.get(projectDir), 3)) { return paths.anyMatch(p -> p.toString().toLowerCase().endsWith(".sql")); } catch (IOException e) { return false; } } @Override public ProjectAnalysisResult parse(String projectDir) { log.info("Starting SQL analysis: {}", projectDir); ProjectAnalysisResult result = new ProjectAnalysisResult(); List<ArchNode> nodes = new ArrayList<>(); List<ArchRelationship> relationships = new ArrayList<>(); Set<String> tableNames = new HashSet<>(); try { List<Path> sqlFiles = findSqlFiles(projectDir); for (Path sqlFile : sqlFiles) { String content = Files.readString(sqlFile); parseSqlContent(content, nodes, relationships, tableNames); } inferLogicalRelationships(nodes, relationships, tableNames); } catch (Exception e) { log.error("Error parsing SQL files", e); } result.setNodes(nodes); result.setRelationships(relationships); return result; } private void parseSqlContent(String content, List<ArchNode> nodes, List<ArchRelationship> relationships, Set<String> tableNames) { List<SQLStatement> statements = parseWithFallback(content); for (SQLStatement stmt : statements) { if (stmt instanceof SQLCreateTableStatement) { SQLCreateTableStatement createTable = (SQLCreateTableStatement) stmt; ArchNode node = processCreateTable(createTable, relationships); if (node != null) { nodes.add(node); tableNames.add(node.getId()); } } } } private List<SQLStatement> parseWithFallback(String content) { try { return SQLUtils.parseStatements(content, JdbcConstants.MYSQL); } catch (Exception e1) { try { return SQLUtils.parseStatements(content, JdbcConstants.POSTGRESQL); } catch (Exception e2) { try { return SQLUtils.parseStatements(content, JdbcConstants.ORACLE); } catch (Exception e3) { return Collections.emptyList(); } } } } private ArchNode processCreateTable(SQLCreateTableStatement createTable, List<ArchRelationship> relationships) { String tableName = cleanName(createTable.getTableName()); ArchNode node = new ArchNode(); node.setId(tableName); node.setName(tableName); node.setType("TABLE"); node.setStereotype("Database Table"); if (createTable.getComment() != null) { node.setDescription(cleanComment(createTable.getComment().toString())); } List<String> fields = new ArrayList<>(); for (SQLTableElement element : createTable.getTableElementList()) { if (element instanceof SQLColumnDefinition) { SQLColumnDefinition column = (SQLColumnDefinition) element; String colName = cleanName(column.getName().getSimpleName()); if (IGNORED_COLUMNS.contains(colName.toLowerCase())) { continue; } String colType = column.getDataType().getName(); StringBuilder fieldStr = new StringBuilder(colName).append(": ").append(colType); if (column.isPrimaryKey()) { fieldStr.append(" (PK)"); } else if (createTable.findColumn(colName) != null && isPrimaryKeyInConstraints(createTable, colName)) { fieldStr.append(" (PK)"); } if (column.getComment() != null) { String comment = cleanComment(column.getComment().toString()); if (!comment.isEmpty()) { fieldStr.append(" // ").append(comment); } } fields.add(fieldStr.toString()); } else if (element instanceof SQLForeignKeyConstraint) { SQLForeignKeyConstraint fk = (SQLForeignKeyConstraint) element; String targetTable = cleanName(fk.getReferencedTableName().getSimpleName()); ArchRelationship rel = new ArchRelationship(); rel.setSourceId(tableName); rel.setTargetId(targetTable); rel.setType("FOREIGN_KEY"); String colName = fk.getReferencingColumns().stream() .map(c -> cleanName(c.getSimpleName())) .collect(Collectors.joining(",")); rel.setLabel("FK: " + colName); relationships.add(rel); } } node.setFields(fields); node.setMethods(Collections.emptyList()); return node; } /** * 修复点:inferLogicalRelationships 方法 * 引入 finalTarget 变量,解决 Lambda 表达式报错 */ private void inferLogicalRelationships(List<ArchNode> nodes, List<ArchRelationship> relationships, Set<String> allTables) { for (ArchNode node : nodes) { if (node.getFields() == null) continue; for (String fieldStr : node.getFields()) { String colName = fieldStr.split(":")[0].trim(); if (colName.toLowerCase().endsWith("_id")) { String potentialTableName = colName.substring(0, colName.length() - 3); String tempTarget = matchTable(potentialTableName, allTables); if (tempTarget == null) { tempTarget = matchTable(potentialTableName + "s", allTables); } // ✅ 关键修复:将可能变化的 tempTarget 赋值给一个 final 变量 String finalTarget = tempTarget; if (finalTarget != null && !finalTarget.equals(node.getId())) { // 在 Lambda 中只使用 finalTarget boolean exists = relationships.stream().anyMatch(r -> r.getSourceId().equals(node.getId()) && r.getTargetId().equals(finalTarget) ); if (!exists) { ArchRelationship rel = new ArchRelationship(); rel.setSourceId(node.getId()); rel.setTargetId(finalTarget); rel.setType("LOGICAL_KEY"); rel.setLabel("Link: " + colName); relationships.add(rel); } } } } } } private String matchTable(String guess, Set<String> tables) { for (String table : tables) { if (table.equalsIgnoreCase(guess)) return table; } return null; } private boolean isPrimaryKeyInConstraints(SQLCreateTableStatement table, String colName) { if (table.getTableElementList() == null) return false; for (SQLTableElement element : table.getTableElementList()) { if (element instanceof SQLPrimaryKey) { SQLPrimaryKey pk = (SQLPrimaryKey) element; return pk.getColumns().stream().anyMatch(c -> cleanName(c.getExpr().toString()).equals(colName)); } } return false; } private String cleanName(String name) { if (name == null) return ""; return name.replace("`", "").replace(""", "").replace("'", "").trim(); } private String cleanComment(String comment) { if (comment == null) return ""; return comment.replace("'", "").trim(); } private List<Path> findSqlFiles(String projectPath) throws IOException { Path startPath = Paths.get(projectPath); if (Files.isRegularFile(startPath) && startPath.toString().toLowerCase().endsWith(".sql")) { return List.of(startPath); } try (Stream<Path> paths = Files.walk(startPath)) { return paths .filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".sql")) .collect(Collectors.toList()); } } } 刚才��义的一系列 DTO 是对于任何的图表都适用的,因为节点和连线就是构成一个不管是结构图还有 ER 图的主要元素。  好了,本期的分享就到这里。欢迎各位贡献代码 ## GitHub 仓库 ### 前端 <https://github.com/wangfenghuan/ai-draw-io-fronted> ### 后端 <https://github.com/wangfenghuan/ai-draw-io-backend>
彻底告别手撸架构图!我开源了一款 AI 驱动的 draw.io,支持Java代码/SQL一键逆向成架构图
## 前言 相信各位程序员朋友们一定使用过各种绘图软件吧,比如GitHub上star数量特别高的drawio。我们可以使用drawio来画各种图,比如UML类图,流程图,软件架构图等各种图,甚至可以拿来画简单的产品原型图(对于那些不太熟悉使用AxureRP的人来说)。在这个AI爆火的时代,我就在想能不能用AI来生成drawio可以识别的图表呢,再进一步想,能不能多人同时操作同一个图表也就是多人实时协作呢。于是,我就开发了这款AI驱动+多人实时协作的drawio。 ### 在线体验地址: <https://www.intellidraw.top>  并且,我直接把完整的前后端项目源代码给开源到GitHub上啦!!!,大家可以自行拉取到本地进行学习,修改。 ### 前端开源地址: [https://github.com/wangfenghuan/ai-draw-io-fronted](https://github.com/wangfenghuan/ai-draw-io-fronted "https://github.com/wangfenghuan/ai-draw-io-fronted") ### 后端开源地址: <https://github.com/wangfenghuan/ai-draw-io-backend> 接下来肯定是各位程序员朋友们最关心的技术栈啦! ## 项目技术栈 ### 前端 使用Next.js服务端渲染技术 + Ant Design组件库 + yjs + ws + 内嵌的drawio编辑器 Next.js天然对SEO友好,使用蚂蚁金服开源的Ant Design组件库简化样式的编写,使用yjs+WebSocket实现实时协作编辑图表功能。 ### 后端 当然是使用Java开发啦! 并使用一个Node.js微服务来处理实时协作逻辑 后端采用jdk21 + Spring Boot(SSM) + Spring AI + Spring Security + Node.js实现 Spring Boot后端负责处理整个系统主要的业务逻辑,Spring AI 为系统提供AI能力,并使用工厂模式可以使用多种不同的llm,包括系统内置的和用户自定义的。 Spring Security负责处理基于RBAC的权限校验,包括协作房间的用户权限和团队空间的用户权限。由于Java对yjs的支持并不友好,所以直接引入一个Node.js来处理实时协作逻辑,Spring Boot暴露鉴权接口供Node.js对连接进行鉴权。 ## 项目主要功能 ## 1、AI生成Drawio图表 ### 一句话生成你想要的图表  这样,不管是想要画什么图表,直接一句话,使用自然语言就能拿到自己想要的图表,并且可以直接导出自己想要的格式,比如SVG,或者PNG。  ## ⭐⭐⭐实时协作 可以直接在图表编辑页面点击右上角的协作按钮开启协作。系统会自动创建协作房间。  这里会通过ws连接后端Node.js服务,从而实现实时协作逻辑。比使用Spring Boot的WebSocket透穿yjs的二进制update数据性能更优,支持高并发。 并且也可以管理房间内的成员,比如修改权限等等,前提是私密的房间。如果是公开的房间就不需要进行房间成员的管理了。、   ## 团队空间 本项目有公共空间和团队空间之分,所谓公共空间就比如你创建了一个图表到公共空间里面,那么所有的人都能在图表广场看到你所创建的图表,除非你创建一个私有空间或者是团队空间。 并且团队空间分为普通版专业版和旗舰版三个等级,区别就在于可以创建的图表数量不同,旗舰版最多。 同时团队空间也是基于RBAC的权限控制的。 同时可以编辑团队空间内的图表和空间信息(管理员),也可以在本团队空间之内创建图表。 也可以通过用户id邀请其他用户加入到本团队空间内。在空间管理页面也分为我创建的空间和我加入的空间。 ## 空间管理  ## 协作房间管理  ## 图表管理  ## 开源与贡献 各位大佬可以在GitHub提交PR。 或者是将完整的前后端项目拉取到本地运行 后端的配置文件格式如下: spring: application: name: drawio-backend mail: host: # 您的SMTP服务器地址 port: # 您的SMTP服务器端口 username: # 您的邮箱账户 password: # 您的邮箱密码或授权码 properties: mail: smtp: auth: true starttls: enable: true security: oauth2: client: registration: github: client-id: client-secret: scope: read:user,user:email redirect-uri: client-name: Intellidraw 智能绘图 provider: github provider: github: authorization-uri: https://github.com/login/oauth/authorize token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/user user-name-attribute: login ai: custom: models: moonshotai: api-key: base-url: https://api.qnaigc.com model: moonshotai/kimi-k2.5 deepseek: api-key: base-url: https://api.qnaigc.com model: moonshotai/kimi-k2.5 glm: api-key: model: glm-4.6 qwen: api-key: base-url: https://api.qnaigc.com model: moonshotai/kimi-k2.5 duobao: api-key: base-url: https://api.qnaigc.com model: moonshotai/kimi-k2.5 openai: api-key: base-url: chat: options: model: datasource: type: com.alibaba.druid.pool.DruidDataSource username: url: password: driver-class-name: com.mysql.cj.jdbc.Driver # druid 连接池管理 druid: # 初始化时建立物理连接的个数 initial-size: 5 # 最小连接池数量 min-idle: 5 # 最大连接池数量 max-active: 20 # 获取连接等待超时的时间 max-wait: 60000 # 一个连接在池中最小的生存的时间 test-while-idle: true time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 30000 validation-query: select 'x' test-on-borrow: false test-on-return: false pool-prepared-statements: false filters: stat,wall,slf4j max-pool-prepared-statement-per-connection-size: -1 use-global-data-source-stat: true connection-properties: 'druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000' server: port: 8081 servlet: context-path: /api rustfs: client: endpoint: access-key: acess-secret: bucket-name: management: endpoints: web: exposure: include: health, prometheus metrics: distribution: percentiles: http: server: requests: 0.5, 0.75, 0.9, 0.95, 0.99 1. **Fork 仓库** ➜ 点击 GitHub 右上角 `Fork` 按钮。 2. **创建分支** ➜ 推荐使用有意义的分支名 3. **提交代码** ➜ 确保代码可读性高,符合规范。 4. **提交 Pull Request(PR)** ➜ 详细描述您的更改内容,并关联相关 issue(如有)。 5. **等待审核** ➜ 维护者会进行代码审核并合并。 以上讲解如果对你有帮助,不妨给我的项目点个小小的 star 🌟,成为一下我的精神股东呢
